mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-06-04 13:28:13 +02:00
709 lines
23 KiB
TypeScript
709 lines
23 KiB
TypeScript
import { deadDropToken, deadDropTokensForContacts } from '@/mesh/meshDeadDrop';
|
|
import { mailboxClaimToken, mailboxDecoySharedToken } from '@/mesh/meshMailbox';
|
|
import {
|
|
deriveSenderSealKey,
|
|
ensureDhKeysFresh,
|
|
deriveSharedKey,
|
|
encryptDM,
|
|
getDHAlgo,
|
|
getNodeIdentity,
|
|
getPublicKeyAlgo,
|
|
nextSequence,
|
|
verifyNodeIdBindingFromPublicKey,
|
|
type Contact,
|
|
type NodeIdentity,
|
|
} from '@/mesh/meshIdentity';
|
|
import {
|
|
buildWormholeSenderSeal,
|
|
getActiveSigningContext,
|
|
isWormholeSecureRequired,
|
|
issueWormholeDmSenderToken,
|
|
issueWormholeDmSenderTokens,
|
|
registerWormholeDmKey,
|
|
signRawMeshMessage,
|
|
signMeshEvent,
|
|
} from '@/mesh/wormholeIdentityClient';
|
|
import { PROTOCOL_VERSION, type JsonValue } from '@/mesh/meshProtocol';
|
|
import {
|
|
ensureCanonicalRequestV2SenderSeal,
|
|
requiresCanonicalRequestV2SenderSeal,
|
|
} from '@/mesh/requestSenderSealPolicy';
|
|
import { validateEventPayload } from '@/mesh/meshSchema';
|
|
|
|
export type MailboxClaim = { type: 'self' | 'requests' | 'shared'; token?: string };
|
|
|
|
export type DmPublicKeyBundle = {
|
|
ok: boolean;
|
|
agent_id: string;
|
|
lookup_mode?: string;
|
|
dh_pub_key: string;
|
|
dh_algo?: string;
|
|
timestamp?: number;
|
|
signature?: string;
|
|
public_key?: string;
|
|
public_key_algo?: string;
|
|
protocol_version?: string;
|
|
sequence?: number;
|
|
bundle_fingerprint?: string;
|
|
prekey_transparency_head?: string;
|
|
prekey_transparency_size?: number;
|
|
prekey_transparency_fingerprint?: string;
|
|
witness_count?: number;
|
|
witness_latest_at?: number;
|
|
};
|
|
|
|
export type DmMessageEnvelope = {
|
|
sender_id: string;
|
|
ciphertext: string;
|
|
timestamp: number;
|
|
msg_id: string;
|
|
delivery_class?: 'request' | 'shared';
|
|
sender_seal?: string;
|
|
transport?: 'reticulum' | 'relay';
|
|
request_contract_version?: 'request-v2-reduced-v3';
|
|
sender_recovery_required?: boolean;
|
|
sender_recovery_state?: 'pending' | 'verified' | 'failed';
|
|
};
|
|
|
|
export type DmPollResponse = {
|
|
ok: boolean;
|
|
messages: DmMessageEnvelope[];
|
|
count: number;
|
|
has_more?: boolean;
|
|
detail?: string;
|
|
};
|
|
|
|
export type DmCountResponse = {
|
|
ok: boolean;
|
|
count: number;
|
|
detail?: string;
|
|
};
|
|
|
|
export type DmSendResponse = {
|
|
ok: boolean;
|
|
msg_id?: string;
|
|
detail?: string;
|
|
transport?: 'reticulum' | 'relay';
|
|
queued?: boolean;
|
|
outbox_id?: string;
|
|
private_transport_pending?: boolean;
|
|
};
|
|
|
|
export type DmSendRequest = {
|
|
apiBase: string;
|
|
identity: NodeIdentity;
|
|
recipientId: string;
|
|
recipientDhPub?: string;
|
|
ciphertext: string;
|
|
msgId: string;
|
|
timestamp: number;
|
|
deliveryClass: 'request' | 'shared';
|
|
recipientToken?: string;
|
|
useSealedSender?: boolean;
|
|
format?: 'mls1' | 'dm1';
|
|
sessionWelcome?: string;
|
|
};
|
|
|
|
const KEY_DM_BUNDLE_FINGERPRINT = 'sb_dm_bundle_fingerprint';
|
|
const KEY_DM_BUNDLE_SEQUENCE = 'sb_dm_bundle_sequence';
|
|
const MIN_SHARED_MAILBOX_CLAIMS = 3;
|
|
const MAX_TOTAL_MAILBOX_CLAIMS = 32;
|
|
const FIXED_MAILBOX_CLAIMS = 2;
|
|
const MAX_SHARED_MAILBOX_CLAIMS = MAX_TOTAL_MAILBOX_CLAIMS - FIXED_MAILBOX_CLAIMS;
|
|
const MAILBOX_SHARED_CLAIM_BUCKETS = [3, 6, 12, 24, 30] as const;
|
|
const MAILBOX_SHARED_CLAIM_EXPERIMENT_ENABLED =
|
|
process.env.NEXT_PUBLIC_ENABLE_RFC2A_CLAIM_SHAPE === '1';
|
|
export const MAILBOX_SHARED_CLAIM_SHAPE_VERSION = MAILBOX_SHARED_CLAIM_EXPERIMENT_ENABLED
|
|
? 'rfc2a-bucketed-v1'
|
|
: 'legacy-floor-v1';
|
|
const PRIVATE_DM_TRANSPORT_LOCK = 'private_strong';
|
|
const senderTokenCache = new Map<string, Array<{ sender_token: string; expires_at: number }>>();
|
|
let bundleFingerprintCache = '';
|
|
|
|
if (typeof window !== 'undefined') {
|
|
try {
|
|
localStorage.removeItem(KEY_DM_BUNDLE_FINGERPRINT);
|
|
localStorage.removeItem(KEY_DM_BUNDLE_SEQUENCE);
|
|
sessionStorage.removeItem(KEY_DM_BUNDLE_FINGERPRINT);
|
|
sessionStorage.removeItem(KEY_DM_BUNDLE_SEQUENCE);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
function randomHex(bytes: number = 12): string {
|
|
const arr = crypto.getRandomValues(new Uint8Array(bytes));
|
|
return Array.from(arr)
|
|
.map((byte) => byte.toString(16).padStart(2, '0'))
|
|
.join('');
|
|
}
|
|
|
|
function bufToBase64(buf: ArrayBuffer): string {
|
|
return btoa(String.fromCharCode(...new Uint8Array(buf)));
|
|
}
|
|
|
|
async function createSenderSealV3(
|
|
recipientId: string,
|
|
recipientDhPub: string,
|
|
msgId: string,
|
|
timestamp: number,
|
|
): Promise<string> {
|
|
const ephemeral = (await crypto.subtle.generateKey('X25519', true, ['deriveBits'])) as CryptoKeyPair;
|
|
const recipientPub = await crypto.subtle.importKey(
|
|
'raw',
|
|
Uint8Array.from(atob(recipientDhPub), (ch) => ch.charCodeAt(0)),
|
|
'X25519',
|
|
false,
|
|
[],
|
|
);
|
|
const secret = await crypto.subtle.deriveBits(
|
|
{ name: 'X25519', public: recipientPub },
|
|
ephemeral.privateKey,
|
|
256,
|
|
);
|
|
const ephemeralPubRaw = await crypto.subtle.exportKey('raw', ephemeral.publicKey);
|
|
const ephemeralPub = bufToBase64(ephemeralPubRaw);
|
|
const salt = await crypto.subtle.digest(
|
|
'SHA-256',
|
|
new TextEncoder().encode(
|
|
`SB-SEAL-SALT|${recipientId}|${msgId}|${PROTOCOL_VERSION}|${ephemeralPub}`,
|
|
),
|
|
);
|
|
const hkdfKey = await crypto.subtle.importKey('raw', secret, 'HKDF', false, ['deriveKey']);
|
|
const sealKey = await crypto.subtle.deriveKey(
|
|
{
|
|
name: 'HKDF',
|
|
hash: 'SHA-256',
|
|
salt,
|
|
info: new TextEncoder().encode('SB-SENDER-SEAL-V3'),
|
|
},
|
|
hkdfKey,
|
|
{ name: 'AES-GCM', length: 256 },
|
|
false,
|
|
['encrypt', 'decrypt'],
|
|
);
|
|
const sealMessage = `seal|v3|${msgId}|${timestamp}|${recipientId}|${ephemeralPub}`;
|
|
const signed = await signRawMeshMessage(sealMessage);
|
|
const isBound = await verifyNodeIdBindingFromPublicKey(
|
|
signed.context.publicKey,
|
|
signed.context.nodeId,
|
|
);
|
|
if (!isBound) {
|
|
throw new Error('Sender seal node binding failed');
|
|
}
|
|
const encrypted = await encryptDM(
|
|
JSON.stringify({
|
|
seal_version: 'v3',
|
|
ephemeral_pub_key: ephemeralPub,
|
|
sender_id: signed.context.nodeId,
|
|
public_key: signed.context.publicKey,
|
|
public_key_algo: signed.context.publicKeyAlgo,
|
|
msg_id: msgId,
|
|
timestamp,
|
|
signature: signed.signature,
|
|
protocol_version: signed.protocolVersion,
|
|
}),
|
|
sealKey,
|
|
);
|
|
return `v3:${ephemeralPub}:${encrypted}`;
|
|
}
|
|
|
|
function setStoredBundleFingerprint(fingerprint: string, sequence: number): void {
|
|
bundleFingerprintCache = String(fingerprint || '');
|
|
if (typeof window === 'undefined') return;
|
|
try {
|
|
localStorage.removeItem(KEY_DM_BUNDLE_FINGERPRINT);
|
|
localStorage.removeItem(KEY_DM_BUNDLE_SEQUENCE);
|
|
sessionStorage.removeItem(KEY_DM_BUNDLE_FINGERPRINT);
|
|
sessionStorage.removeItem(KEY_DM_BUNDLE_SEQUENCE);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
function getStoredBundleFingerprint(): string {
|
|
return bundleFingerprintCache;
|
|
}
|
|
|
|
function senderTokenCacheKey(
|
|
recipientId: string,
|
|
deliveryClass: 'request' | 'shared',
|
|
recipientToken?: string,
|
|
): string {
|
|
return `${deliveryClass}|${recipientId}|${recipientToken || ''}`;
|
|
}
|
|
|
|
function takeCachedSenderToken(
|
|
recipientId: string,
|
|
deliveryClass: 'request' | 'shared',
|
|
recipientToken?: string,
|
|
): string {
|
|
const key = senderTokenCacheKey(recipientId, deliveryClass, recipientToken);
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const existing = (senderTokenCache.get(key) || []).filter((item) => item.expires_at > now + 10);
|
|
senderTokenCache.set(key, existing);
|
|
const next = existing.shift();
|
|
senderTokenCache.set(key, existing);
|
|
return String(next?.sender_token || '');
|
|
}
|
|
|
|
async function replenishSenderTokenCache(
|
|
recipientId: string,
|
|
deliveryClass: 'request' | 'shared',
|
|
recipientToken?: string,
|
|
): Promise<string> {
|
|
const key = senderTokenCacheKey(recipientId, deliveryClass, recipientToken);
|
|
try {
|
|
const batch = await issueWormholeDmSenderTokens(
|
|
recipientId,
|
|
deliveryClass,
|
|
recipientToken,
|
|
3,
|
|
);
|
|
const tokens = Array.isArray(batch.tokens) ? batch.tokens : [];
|
|
senderTokenCache.set(
|
|
key,
|
|
tokens
|
|
.map((item) => ({
|
|
sender_token: String(item.sender_token || ''),
|
|
expires_at: Number(item.expires_at || 0),
|
|
}))
|
|
.filter((item) => item.sender_token && item.expires_at > 0),
|
|
);
|
|
} catch {
|
|
senderTokenCache.delete(key);
|
|
}
|
|
return takeCachedSenderToken(recipientId, deliveryClass, recipientToken);
|
|
}
|
|
|
|
async function localBundleFingerprint(identity: NodeIdentity, dhPubKey: string, dhAlgo: string): Promise<string> {
|
|
const raw = [
|
|
dhPubKey,
|
|
dhAlgo,
|
|
identity.publicKey,
|
|
getPublicKeyAlgo(),
|
|
PROTOCOL_VERSION,
|
|
].join('|');
|
|
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(raw));
|
|
return Array.from(new Uint8Array(digest))
|
|
.map((byte) => byte.toString(16).padStart(2, '0'))
|
|
.join('');
|
|
}
|
|
|
|
export async function ensureRegisteredDmKey(
|
|
apiBase: string,
|
|
identity: NodeIdentity,
|
|
opts?: { force?: boolean },
|
|
): Promise<{ ok: boolean; dhPubKey?: string; dhAlgo?: string; acceptedSequence?: number; bundleFingerprint?: string; detail?: string }> {
|
|
const signingContext = await getActiveSigningContext();
|
|
if (signingContext?.source === 'wormhole') {
|
|
try {
|
|
const data = await registerWormholeDmKey();
|
|
if (data.ok) {
|
|
if (data.bundle_fingerprint && data.bundle_sequence) {
|
|
setStoredBundleFingerprint(data.bundle_fingerprint, data.bundle_sequence);
|
|
}
|
|
return {
|
|
ok: true,
|
|
dhPubKey: data.dh_pub_key,
|
|
dhAlgo: data.dh_algo,
|
|
acceptedSequence: Number(data.bundle_sequence || data.sequence || 0),
|
|
bundleFingerprint: data.bundle_fingerprint,
|
|
};
|
|
}
|
|
return { ok: false, detail: data.detail || 'Failed to register Wormhole DM key' };
|
|
} catch {
|
|
if (await isWormholeSecureRequired()) {
|
|
return { ok: false, detail: 'Wormhole DM key registration required in secure mode' };
|
|
}
|
|
const localIdentity = getNodeIdentity();
|
|
if (!localIdentity) {
|
|
return { ok: false, detail: 'Wormhole DM key registration failed' };
|
|
}
|
|
identity = localIdentity;
|
|
}
|
|
}
|
|
|
|
const { pub: dhPubKey, rotated } = await ensureDhKeysFresh();
|
|
if (!dhPubKey) return { ok: false, detail: 'Missing DH public key' };
|
|
const dhAlgo = getDHAlgo();
|
|
const fingerprint = await localBundleFingerprint(identity, dhPubKey, dhAlgo);
|
|
if (!opts?.force && !rotated && fingerprint === getStoredBundleFingerprint()) {
|
|
return { ok: true, dhPubKey, dhAlgo };
|
|
}
|
|
|
|
const timestamp = Math.floor(Date.now() / 1000);
|
|
const payload = {
|
|
dh_pub_key: dhPubKey,
|
|
dh_algo: dhAlgo,
|
|
timestamp,
|
|
transport_lock: PRIVATE_DM_TRANSPORT_LOCK,
|
|
};
|
|
const valid = validateEventPayload('dm_key', payload as Record<string, JsonValue>);
|
|
if (!valid.ok) return { ok: false, detail: valid.reason };
|
|
const sequence = nextSequence();
|
|
const signed = await signMeshEvent('dm_key', payload, sequence);
|
|
const res = await fetch(`${apiBase}/api/mesh/dm/register`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
agent_id: signed.context.nodeId,
|
|
dh_pub_key: dhPubKey,
|
|
dh_algo: dhAlgo,
|
|
timestamp,
|
|
transport_lock: PRIVATE_DM_TRANSPORT_LOCK,
|
|
public_key: signed.context.publicKey,
|
|
public_key_algo: signed.context.publicKeyAlgo,
|
|
signature: signed.signature,
|
|
sequence: signed.sequence,
|
|
protocol_version: signed.protocolVersion,
|
|
}),
|
|
});
|
|
const data = await res.json();
|
|
if (data.ok) {
|
|
setStoredBundleFingerprint(data.bundle_fingerprint || fingerprint, data.accepted_sequence || sequence);
|
|
}
|
|
return {
|
|
ok: Boolean(data.ok),
|
|
dhPubKey,
|
|
dhAlgo,
|
|
acceptedSequence: Number(data.accepted_sequence || sequence),
|
|
bundleFingerprint: String(data.bundle_fingerprint || fingerprint),
|
|
detail: data.detail,
|
|
};
|
|
}
|
|
|
|
export async function fetchDmPublicKey(
|
|
apiBase: string,
|
|
agentId: string,
|
|
lookupToken?: string,
|
|
options?: { allowLegacyAgentId?: boolean },
|
|
): Promise<DmPublicKeyBundle | null> {
|
|
const normalizedLookupToken = String(lookupToken || '').trim();
|
|
const normalizedAgentId = String(agentId || '').trim();
|
|
if (!normalizedLookupToken && !options?.allowLegacyAgentId) {
|
|
return null;
|
|
}
|
|
const params = new URLSearchParams();
|
|
if (normalizedLookupToken) {
|
|
params.set('lookup_token', normalizedLookupToken);
|
|
}
|
|
if (normalizedAgentId && !normalizedLookupToken && options?.allowLegacyAgentId) {
|
|
params.set('agent_id', normalizedAgentId);
|
|
}
|
|
const res = await fetch(`${apiBase}/api/mesh/dm/pubkey?${params.toString()}`);
|
|
const data = await res.json();
|
|
return data.ok ? data : null;
|
|
}
|
|
|
|
function spreadClaimPositions(totalClaims: number, spreadClaims: number): Set<number> {
|
|
if (spreadClaims <= 0) return new Set();
|
|
const positions = new Set<number>();
|
|
for (let index = 0; index < spreadClaims; index += 1) {
|
|
const position = Math.floor(((index + 0.5) * totalClaims) / spreadClaims);
|
|
positions.add(Math.min(totalClaims - 1, position));
|
|
}
|
|
return positions;
|
|
}
|
|
|
|
function interleaveSharedClaims(realTokens: string[], decoyTokens: string[]): MailboxClaim[] {
|
|
const totalClaims = realTokens.length + decoyTokens.length;
|
|
if (!totalClaims) return [];
|
|
const spreadRealTokens = realTokens.length > 0 && realTokens.length <= decoyTokens.length;
|
|
const spreadTokens = spreadRealTokens ? realTokens : decoyTokens;
|
|
const fillTokens = spreadRealTokens ? decoyTokens : realTokens;
|
|
const spreadTokenPositions = spreadClaimPositions(totalClaims, spreadTokens.length);
|
|
const claims: MailboxClaim[] = [];
|
|
let spreadIndex = 0;
|
|
let fillIndex = 0;
|
|
for (let slot = 0; slot < totalClaims; slot += 1) {
|
|
if (spreadTokenPositions.has(slot) && spreadIndex < spreadTokens.length) {
|
|
claims.push({ type: 'shared', token: spreadTokens[spreadIndex] });
|
|
spreadIndex += 1;
|
|
continue;
|
|
}
|
|
if (fillIndex < fillTokens.length) {
|
|
claims.push({ type: 'shared', token: fillTokens[fillIndex] });
|
|
fillIndex += 1;
|
|
continue;
|
|
}
|
|
claims.push({ type: 'shared', token: spreadTokens[spreadIndex] });
|
|
spreadIndex += 1;
|
|
}
|
|
return claims;
|
|
}
|
|
|
|
async function buildLegacySharedMailboxClaims(sharedTokens: string[]): Promise<MailboxClaim[]> {
|
|
const claims = sharedTokens.map((token) => ({ type: 'shared' as const, token }));
|
|
for (let index = sharedTokens.length; index < MIN_SHARED_MAILBOX_CLAIMS; index += 1) {
|
|
claims.push({ type: 'shared', token: await mailboxDecoySharedToken(index) });
|
|
}
|
|
return claims;
|
|
}
|
|
|
|
function sharedClaimBucketSize(realSharedClaims: number): number | null {
|
|
if (!MAILBOX_SHARED_CLAIM_EXPERIMENT_ENABLED) {
|
|
return null;
|
|
}
|
|
if (realSharedClaims > MAX_SHARED_MAILBOX_CLAIMS) {
|
|
return null;
|
|
}
|
|
return (
|
|
MAILBOX_SHARED_CLAIM_BUCKETS.find((bucketSize) => realSharedClaims <= bucketSize) ??
|
|
MAX_SHARED_MAILBOX_CLAIMS
|
|
);
|
|
}
|
|
|
|
async function buildBucketedSharedMailboxClaims(sharedTokens: string[]): Promise<MailboxClaim[]> {
|
|
const bucketSize = sharedClaimBucketSize(sharedTokens.length);
|
|
if (bucketSize == null) {
|
|
return buildLegacySharedMailboxClaims(sharedTokens);
|
|
}
|
|
const decoyTokens: string[] = [];
|
|
for (let index = 0; index < bucketSize - sharedTokens.length; index += 1) {
|
|
decoyTokens.push(await mailboxDecoySharedToken(index));
|
|
}
|
|
return interleaveSharedClaims(sharedTokens, decoyTokens);
|
|
}
|
|
|
|
export async function buildMailboxClaims(
|
|
contacts: Record<string, Contact>,
|
|
identityOverride?: Pick<NodeIdentity, 'nodeId'> | null,
|
|
): Promise<MailboxClaim[]> {
|
|
const identity = identityOverride?.nodeId ? identityOverride : getNodeIdentity();
|
|
if (!identity?.nodeId) {
|
|
throw new Error('No local identity available for mailbox claims');
|
|
}
|
|
const claims: MailboxClaim[] = [
|
|
{
|
|
type: 'self',
|
|
token: await mailboxClaimToken('self', identity.nodeId),
|
|
},
|
|
{
|
|
type: 'requests',
|
|
token: await mailboxClaimToken('requests', identity.nodeId),
|
|
},
|
|
];
|
|
const sharedTokens = Array.from(new Set((await deadDropTokensForContacts(contacts)).filter(Boolean)));
|
|
claims.push(...(await buildBucketedSharedMailboxClaims(sharedTokens)));
|
|
return claims;
|
|
}
|
|
|
|
async function signedMailboxRequest(
|
|
apiBase: string,
|
|
identity: NodeIdentity,
|
|
eventType: 'dm_poll' | 'dm_count',
|
|
mailboxClaims: MailboxClaim[],
|
|
) {
|
|
const payload = {
|
|
mailbox_claims: mailboxClaims.map((claim) => ({
|
|
type: claim.type,
|
|
token: claim.token || '',
|
|
})),
|
|
timestamp: Math.floor(Date.now() / 1000),
|
|
nonce: randomHex(16),
|
|
transport_lock: PRIVATE_DM_TRANSPORT_LOCK,
|
|
};
|
|
const valid = validateEventPayload(eventType, payload as Record<string, JsonValue>);
|
|
if (!valid.ok) {
|
|
throw new Error(valid.reason);
|
|
}
|
|
const sequence = nextSequence();
|
|
const signed = await signMeshEvent(eventType, payload, sequence);
|
|
const res = await fetch(`${apiBase}/api/mesh/dm/${eventType === 'dm_poll' ? 'poll' : 'count'}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
agent_id: signed.context.nodeId,
|
|
mailbox_claims: payload.mailbox_claims,
|
|
timestamp: payload.timestamp,
|
|
nonce: payload.nonce,
|
|
transport_lock: PRIVATE_DM_TRANSPORT_LOCK,
|
|
public_key: signed.context.publicKey,
|
|
public_key_algo: signed.context.publicKeyAlgo,
|
|
signature: signed.signature,
|
|
sequence: signed.sequence,
|
|
protocol_version: signed.protocolVersion,
|
|
}),
|
|
});
|
|
return res.json();
|
|
}
|
|
|
|
export async function pollDmMailboxes(
|
|
apiBase: string,
|
|
identity: NodeIdentity,
|
|
mailboxClaims: MailboxClaim[],
|
|
): Promise<DmPollResponse> {
|
|
return signedMailboxRequest(apiBase, identity, 'dm_poll', mailboxClaims);
|
|
}
|
|
|
|
export async function countDmMailboxes(
|
|
apiBase: string,
|
|
identity: NodeIdentity,
|
|
mailboxClaims: MailboxClaim[],
|
|
): Promise<DmCountResponse> {
|
|
return signedMailboxRequest(apiBase, identity, 'dm_count', mailboxClaims);
|
|
}
|
|
|
|
export async function buildSenderSeal(
|
|
recipientId: string,
|
|
recipientDhPub: string,
|
|
msgId: string,
|
|
timestamp: number,
|
|
): Promise<string> {
|
|
const signingContext = await getActiveSigningContext();
|
|
if (signingContext?.source === 'wormhole') {
|
|
const built = await buildWormholeSenderSeal(recipientId, recipientDhPub, msgId, timestamp);
|
|
if (!built?.ok || !built.sender_seal) {
|
|
throw new Error('wormhole_sender_seal_failed');
|
|
}
|
|
return String(built.sender_seal || '');
|
|
}
|
|
try {
|
|
return await createSenderSealV3(recipientId, recipientDhPub, msgId, timestamp);
|
|
} catch {
|
|
const sealMessage = `seal|${msgId}|${timestamp}|${recipientId}`;
|
|
const signed = await signRawMeshMessage(sealMessage);
|
|
const isBound = await verifyNodeIdBindingFromPublicKey(
|
|
signed.context.publicKey,
|
|
signed.context.nodeId,
|
|
);
|
|
if (!isBound) {
|
|
throw new Error('Sender seal node binding failed');
|
|
}
|
|
const sealKey = await deriveSenderSealKey(recipientDhPub, recipientId, msgId);
|
|
const encrypted = await encryptDM(
|
|
JSON.stringify({
|
|
seal_version: 'v2',
|
|
sender_id: signed.context.nodeId,
|
|
public_key: signed.context.publicKey,
|
|
public_key_algo: signed.context.publicKeyAlgo,
|
|
msg_id: msgId,
|
|
timestamp,
|
|
signature: signed.signature,
|
|
protocol_version: signed.protocolVersion,
|
|
}),
|
|
sealKey,
|
|
);
|
|
return `v2:${encrypted}`;
|
|
}
|
|
}
|
|
|
|
export async function sendDmMessage(request: DmSendRequest): Promise<DmSendResponse> {
|
|
const payloadFormat = request.format || 'mls1';
|
|
let senderSeal = '';
|
|
let senderToken = '';
|
|
let relaySalt = '';
|
|
const requireCanonicalV3Seal = requiresCanonicalRequestV2SenderSeal({
|
|
deliveryClass: request.deliveryClass,
|
|
useSealedSender: request.useSealedSender,
|
|
});
|
|
if (request.useSealedSender && request.recipientDhPub) {
|
|
senderSeal = await buildSenderSeal(
|
|
request.recipientId,
|
|
request.recipientDhPub,
|
|
request.msgId,
|
|
request.timestamp,
|
|
);
|
|
if (requireCanonicalV3Seal) {
|
|
senderSeal = ensureCanonicalRequestV2SenderSeal(senderSeal);
|
|
}
|
|
relaySalt = randomHex(16);
|
|
}
|
|
|
|
const dmPayload: Record<string, unknown> = {
|
|
recipient_id: request.recipientId,
|
|
delivery_class: request.deliveryClass,
|
|
recipient_token: request.recipientToken || '',
|
|
ciphertext: request.ciphertext,
|
|
msg_id: request.msgId,
|
|
timestamp: request.timestamp,
|
|
format: payloadFormat,
|
|
transport_lock: PRIVATE_DM_TRANSPORT_LOCK,
|
|
};
|
|
if (request.sessionWelcome) {
|
|
dmPayload.session_welcome = request.sessionWelcome;
|
|
}
|
|
if (senderSeal) {
|
|
dmPayload.sender_seal = senderSeal;
|
|
}
|
|
if (relaySalt) {
|
|
dmPayload.relay_salt = relaySalt;
|
|
}
|
|
const valid = validateEventPayload('dm_message', dmPayload as Record<string, JsonValue>);
|
|
if (!valid.ok) {
|
|
throw new Error(valid.reason);
|
|
}
|
|
const sequence = nextSequence();
|
|
const signed = await signMeshEvent('dm_message', dmPayload, sequence);
|
|
if (request.deliveryClass === 'request' || request.deliveryClass === 'shared' || senderSeal) {
|
|
try {
|
|
senderToken = takeCachedSenderToken(
|
|
request.recipientId,
|
|
request.deliveryClass,
|
|
request.recipientToken || '',
|
|
);
|
|
if (!senderToken) {
|
|
senderToken = await replenishSenderTokenCache(
|
|
request.recipientId,
|
|
request.deliveryClass,
|
|
request.recipientToken || '',
|
|
);
|
|
}
|
|
if (!senderToken) {
|
|
const issued = await issueWormholeDmSenderToken(
|
|
request.recipientId,
|
|
request.deliveryClass,
|
|
request.recipientToken || '',
|
|
);
|
|
senderToken = String(issued.sender_token || '');
|
|
}
|
|
} catch {
|
|
senderToken = '';
|
|
}
|
|
}
|
|
const res = await fetch(`${request.apiBase}/api/mesh/dm/send`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
sender_id: senderSeal && senderToken ? '' : signed.context.nodeId,
|
|
sender_token: senderToken,
|
|
recipient_id: senderSeal && senderToken ? '' : request.recipientId,
|
|
delivery_class: request.deliveryClass,
|
|
recipient_token: request.recipientToken || '',
|
|
ciphertext: request.ciphertext,
|
|
format: payloadFormat,
|
|
transport_lock: PRIVATE_DM_TRANSPORT_LOCK,
|
|
session_welcome: request.sessionWelcome || '',
|
|
sender_seal: senderSeal,
|
|
relay_salt: relaySalt,
|
|
msg_id: request.msgId,
|
|
timestamp: request.timestamp,
|
|
public_key: senderSeal && senderToken ? '' : signed.context.publicKey,
|
|
public_key_algo: senderSeal && senderToken ? '' : signed.context.publicKeyAlgo,
|
|
signature: signed.signature,
|
|
sequence: signed.sequence,
|
|
protocol_version: senderSeal && senderToken ? '' : signed.protocolVersion,
|
|
}),
|
|
});
|
|
return res.json();
|
|
}
|
|
|
|
export async function sendOffLedgerConsentMessage(
|
|
request: Omit<DmSendRequest, 'deliveryClass' | 'useSealedSender'>,
|
|
): Promise<DmSendResponse> {
|
|
return sendDmMessage({
|
|
...request,
|
|
deliveryClass: 'request',
|
|
useSealedSender: true,
|
|
});
|
|
}
|
|
|
|
export async function sharedMailboxToken(peerId: string, peerDhPub: string): Promise<string> {
|
|
return deadDropToken(peerId, peerDhPub);
|
|
}
|
|
|
|
export function currentIdentity(): NodeIdentity | null {
|
|
return getNodeIdentity();
|
|
}
|