/** * Sovereign Node Identity — Ed25519/ECDSA keypair for mesh authentication. * * Every ShadowBroker node generates a keypair locally. The public key becomes * the Node ID. The private key never leaves the device. All outbound messages * are signed with the private key. * * No registration. No server. No email. You just exist. */ import { buildSignaturePayload, PROTOCOL_VERSION, type JsonValue } from '@/mesh/meshProtocol'; import type { ContactTrustSummary } from '@/mesh/contactTrustTypes'; import { deleteKey, getKey, setKey } from '@/mesh/meshKeyStore'; import { purgeMailboxClaimKey } from '@/mesh/meshMailbox'; import { controlPlaneJson } from '@/lib/controlPlane'; // storage keys const KEY_PUBKEY = 'sb_mesh_pubkey'; const KEY_PRIVKEY = 'sb_mesh_privkey'; const KEY_NODE_ID = 'sb_mesh_node_id'; const KEY_SOVEREIGNTY = 'sb_mesh_sovereignty_accepted'; const KEY_WORMHOLE_PUBKEY = 'sb_wormhole_desc_pubkey'; const KEY_WORMHOLE_NODE_ID = 'sb_wormhole_desc_node_id'; const KEY_WORMHOLE_ALGO = 'sb_wormhole_desc_algo'; const KEY_DH_PUBKEY = 'sb_mesh_dh_pubkey'; const KEY_DH_PRIVKEY = 'sb_mesh_dh_privkey'; const KEY_DH_ALGO = 'sb_mesh_dh_algo'; const KEY_DH_LAST_ROTATION = 'sb_mesh_dh_last_ts'; const KEY_DH_PRIV_IDB = 'sb_mesh_dh_priv'; const KEY_DH_PREV_PRIV_IDB = 'sb_mesh_dh_prev_priv'; const KEY_SIGN_PRIV_IDB = 'sb_mesh_sign_priv'; const KEY_CONTACTS = 'sb_mesh_contacts'; const KEY_DM_NOTIFY = 'sb_mesh_dm_notify'; const KEY_DM_BUNDLE_FINGERPRINT = 'sb_dm_bundle_fingerprint'; const KEY_DM_BUNDLE_SEQUENCE = 'sb_dm_bundle_sequence'; const KEY_RATCHET = 'sb_mesh_dm_ratchet'; const KEY_RATCHET_TELEMETRY = 'sb_mesh_ratchet_telemetry'; const KEY_SEQUENCE = 'sb_mesh_sequence'; const KEY_SESSION_MODE = 'sb_mesh_session_mode'; const KEY_ALGO = 'sb_mesh_algo'; const KEY_WORMHOLE_SECURE_REQUIRED = 'sb_wormhole_secure_required'; const RATCHET_CRYPTO_DB = 'sb_mesh_ratchet_crypto'; const IDENTITY_STATE_EVENT = 'sb:identity-state-changed'; const MESH_STORAGE_PREFIXES = ['sb_mesh_', 'sb_wormhole_', 'sb_dm_'] as const; const CONTACTS_ENCRYPTED_PREFIX = 'enc:'; const CONTACTS_WRAP_CONTEXT = 'SB-CONTACTS-WRAP-V1'; const CONTACTS_WRAP_INFO = 'SB-CONTACTS-STORAGE-V1'; function emitIdentityStateChange(): void { if (typeof window === 'undefined') return; try { window.dispatchEvent(new CustomEvent(IDENTITY_STATE_EVENT)); } catch { /* ignore */ } } function isSessionMode(): boolean { if (typeof window === 'undefined') return false; try { return localStorage.getItem(KEY_SESSION_MODE) !== 'false'; } catch { return true; } } function getStorage(): Storage | null { if (typeof window === 'undefined') return null; try { return isSessionMode() ? sessionStorage : localStorage; } catch { return sessionStorage; } } function getAlternateStorage(store: Storage | null): Storage | null { if (typeof window === 'undefined' || !store) return null; return store === sessionStorage ? localStorage : sessionStorage; } function storageGet(key: string): string | null { const store = getStorage(); if (!store) return null; const primaryValue = store.getItem(key); if (primaryValue !== null) return primaryValue; const alternate = getAlternateStorage(store); if (!alternate) return null; const migratedValue = alternate.getItem(key); if (migratedValue !== null) { store.setItem(key, migratedValue); alternate.removeItem(key); } return migratedValue; } function storageSet(key: string, value: string): void { const store = getStorage(); if (!store) return; store.setItem(key, value); const alternate = getAlternateStorage(store); if (alternate) alternate.removeItem(key); } function storageRemove(key: string): void { for (const store of [localStorage, sessionStorage]) { try { store.removeItem(key); } catch { /* ignore */ } } } function removeLegacyPrivateKeyCopies(): void { if (typeof window === 'undefined') return; for (const store of [localStorage, sessionStorage]) { try { store.removeItem(KEY_PRIVKEY); } catch { /* ignore */ } } } function clearPrefixedStorage(store: Storage): void { const keys: string[] = []; for (let i = 0; i < store.length; i += 1) { const key = store.key(i); if (key && MESH_STORAGE_PREFIXES.some((prefix) => key.startsWith(prefix))) { keys.push(key); } } for (const key of keys) { try { store.removeItem(key); } catch { /* ignore */ } } } async function deleteDatabaseIfPresent(name: string): Promise { if (typeof indexedDB === 'undefined') return; await new Promise((resolve) => { try { const req = indexedDB.deleteDatabase(name); req.onsuccess = () => resolve(); req.onerror = () => resolve(); req.onblocked = () => resolve(); } catch { resolve(); } }); } export interface NodeIdentity { publicKey: string; // Base64-encoded public key privateKey: string; // Base64-encoded private key (never sent to server) nodeId: string; // !sb_ + first 32 hex chars of public key hash } export interface NodeDescriptor { publicKey: string; nodeId: string; publicKeyAlgo: string; } function isNodeIdWithLength(nodeId: string, length: number): boolean { const value = String(nodeId || '').trim(); return new RegExp(`^${NODE_ID_PREFIX}[0-9a-f]{${length}}$`, 'i').test(value); } function isLegacyNodeId(nodeId: string): boolean { return isNodeIdWithLength(nodeId, NODE_ID_LEGACY_HEX_LEN); } function isCompatNodeId(nodeId: string): boolean { return isNodeIdWithLength(nodeId, NODE_ID_COMPAT_HEX_LEN); } function isCurrentNodeId(nodeId: string): boolean { return isNodeIdWithLength(nodeId, NODE_ID_HEX_LEN); } function isMigratableStoredNodeId(nodeId: string): boolean { return isLegacyNodeId(nodeId) || isCompatNodeId(nodeId); } async function migrateStoredNodeIdIfNeeded( publicKeyBase64: string, nodeId: string, persist: (nextNodeId: string) => void, ): Promise { const current = await deriveNodeIdFromPublicKey(publicKeyBase64); if (!isMigratableStoredNodeId(nodeId) || nodeId === current) return current; persist(current); return current; } export function setSecureModeCached(enabled: boolean): void { if (typeof window === 'undefined') return; try { const nextValue = enabled ? 'true' : 'false'; const previousValue = sessionStorage.getItem(KEY_WORMHOLE_SECURE_REQUIRED); sessionStorage.setItem(KEY_WORMHOLE_SECURE_REQUIRED, nextValue); if (previousValue !== nextValue) { emitIdentityStateChange(); } } catch { /* ignore */ } } export function isSecureModeCached(): boolean { if (typeof window === 'undefined') return false; try { return sessionStorage.getItem(KEY_WORMHOLE_SECURE_REQUIRED) === 'true'; } catch { return false; } } export function cachePublicIdentity(descriptor: NodeDescriptor): void { if (typeof window === 'undefined') return; storageSet(KEY_PUBKEY, descriptor.publicKey); storageSet(KEY_NODE_ID, descriptor.nodeId); storageSet(KEY_ALGO, descriptor.publicKeyAlgo || 'Ed25519'); storageSet(KEY_SOVEREIGNTY, 'true'); emitIdentityStateChange(); } export function cacheWormholeIdentityDescriptor(descriptor: NodeDescriptor): void { if (typeof window === 'undefined') return; try { sessionStorage.setItem(KEY_WORMHOLE_PUBKEY, descriptor.publicKey); sessionStorage.setItem(KEY_WORMHOLE_NODE_ID, descriptor.nodeId); sessionStorage.setItem(KEY_WORMHOLE_ALGO, descriptor.publicKeyAlgo || 'Ed25519'); emitIdentityStateChange(); } catch { /* ignore */ } } export function getStoredNodeDescriptor(): NodeDescriptor | null { if (typeof window === 'undefined') return null; const publicKey = storageGet(KEY_PUBKEY); const nodeId = storageGet(KEY_NODE_ID); if (!publicKey || !nodeId) return null; return { publicKey, nodeId, publicKeyAlgo: storageGet(KEY_ALGO) || 'Ed25519', }; } export function getWormholeIdentityDescriptor(): NodeDescriptor | null { if (typeof window === 'undefined') return null; try { const publicKey = sessionStorage.getItem(KEY_WORMHOLE_PUBKEY); const nodeId = sessionStorage.getItem(KEY_WORMHOLE_NODE_ID); if (!publicKey || !nodeId) return null; return { publicKey, nodeId, publicKeyAlgo: sessionStorage.getItem(KEY_WORMHOLE_ALGO) || 'Ed25519', }; } catch { return null; } } export function clearWormholeIdentityDescriptor(): void { if (typeof window === 'undefined') return; try { sessionStorage.removeItem(KEY_WORMHOLE_PUBKEY); sessionStorage.removeItem(KEY_WORMHOLE_NODE_ID); sessionStorage.removeItem(KEY_WORMHOLE_ALGO); emitIdentityStateChange(); } catch { /* ignore */ } } export async function purgeBrowserSigningMaterial(): Promise { if (typeof window === 'undefined') return; for (const store of [localStorage, sessionStorage]) { try { store.removeItem(KEY_PRIVKEY); store.removeItem(KEY_SEQUENCE); } catch { /* ignore */ } } await deleteKey(KEY_SIGN_PRIV_IDB); } export function purgeBrowserContactGraph(): void { if (typeof window === 'undefined') return; contactCache = {}; contactsHydration = null; for (const store of [localStorage, sessionStorage]) { try { store.removeItem(KEY_CONTACTS); } catch { /* ignore */ } } } export async function clearBrowserIdentityState(): Promise { if (typeof window === 'undefined') return; contactCache = {}; contactsHydration = null; for (const store of [localStorage, sessionStorage]) { try { store.removeItem(KEY_PUBKEY); store.removeItem(KEY_PRIVKEY); store.removeItem(KEY_NODE_ID); store.removeItem(KEY_SOVEREIGNTY); store.removeItem(KEY_SEQUENCE); store.removeItem(KEY_ALGO); store.removeItem(KEY_DH_PUBKEY); store.removeItem(KEY_DH_PRIVKEY); store.removeItem(KEY_DH_ALGO); store.removeItem(KEY_DH_LAST_ROTATION); store.removeItem(KEY_CONTACTS); store.removeItem(KEY_DM_NOTIFY); store.removeItem(KEY_WORMHOLE_PUBKEY); store.removeItem(KEY_WORMHOLE_NODE_ID); store.removeItem(KEY_WORMHOLE_ALGO); store.removeItem(KEY_WORMHOLE_SECURE_REQUIRED); store.removeItem(KEY_DM_BUNDLE_FINGERPRINT); store.removeItem(KEY_DM_BUNDLE_SEQUENCE); store.removeItem(KEY_RATCHET); store.removeItem(KEY_RATCHET_TELEMETRY); store.removeItem(KEY_SESSION_MODE); } catch { /* ignore */ } clearPrefixedStorage(store); } await deleteKey(KEY_DH_PRIV_IDB); await deleteKey(KEY_DH_PREV_PRIV_IDB); await deleteKey(KEY_SIGN_PRIV_IDB); await purgeMailboxClaimKey(); await deleteDatabaseIfPresent(RATCHET_CRYPTO_DB); await deleteDatabaseIfPresent('sb_mesh_dm_worker'); emitIdentityStateChange(); } export async function derivePublicMeshAddress(senderId: string): Promise { const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(senderId)); const bytes = Array.from(new Uint8Array(digest).slice(0, 4)); return `!${bytes.map((b) => b.toString(16).padStart(2, '0')).join('')}`; } async function assertBrowserCustodyAllowed(): Promise { if (isSecureModeCached()) { throw new Error('browser_identity_blocked_secure_mode'); } } export function getPublicKeyAlgo(): string { if (typeof window === 'undefined') return 'Ed25519'; return storageGet(KEY_ALGO) || 'Ed25519'; } function normalizeAlgo(value: string): 'Ed25519' | 'ECDSA' { const val = (value || '').toUpperCase(); if (val === 'ED25519' || val === 'EDDSA') return 'Ed25519'; return 'ECDSA'; } export function getSequence(): number { if (typeof window === 'undefined') return 0; const raw = storageGet(KEY_SEQUENCE); const val = raw ? parseInt(raw, 10) : 0; return Number.isFinite(val) ? val : 0; } export function setSequence(value: number): void { if (typeof window === 'undefined') return; storageSet(KEY_SEQUENCE, String(value)); } export function nextSequence(): number { const current = getSequence(); const next = current + 1; storageSet(KEY_SEQUENCE, String(next)); return next; } /** Convert ArrayBuffer to hex string. */ function bufToHex(buf: ArrayBuffer): string { return Array.from(new Uint8Array(buf)) .map((b) => b.toString(16).padStart(2, '0')) .join(''); } /** Convert ArrayBuffer to Base64 string. */ function bufToBase64(buf: ArrayBuffer): string { return btoa(String.fromCharCode(...new Uint8Array(buf))); } /** Convert Base64 string to ArrayBuffer. */ function base64ToBuf(b64: string): ArrayBuffer { const binary = atob(b64); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); return bytes.buffer; } function utf8ToBuf(value: string): Uint8Array { return new TextEncoder().encode(value); } function toCryptoBytes(value: ArrayBuffer | ArrayBufferView): Uint8Array { const source = ArrayBuffer.isView(value) ? new Uint8Array(value.buffer, value.byteOffset, value.byteLength) : new Uint8Array(value); const copy = new Uint8Array(source.byteLength); copy.set(source); return copy; } async function deriveNodeIdForLength(publicKeyRaw: ArrayBuffer, length: number): Promise { const hash = await crypto.subtle.digest('SHA-256', toCryptoBytes(publicKeyRaw)); return NODE_ID_PREFIX + bufToHex(hash).slice(0, length); } /** Generate a Node ID from the public key: !sb_ + first 32 hex chars of SHA-256. */ async function deriveNodeId(publicKeyRaw: ArrayBuffer): Promise { return deriveNodeIdForLength(publicKeyRaw, NODE_ID_HEX_LEN); } async function deriveNodeIdCandidates(publicKeyRaw: ArrayBuffer): Promise { const candidates: string[] = []; for (const length of [NODE_ID_HEX_LEN, NODE_ID_COMPAT_HEX_LEN]) { const candidate = await deriveNodeIdForLength(publicKeyRaw, length); if (!candidates.includes(candidate)) { candidates.push(candidate); } } return candidates; } export async function deriveNodeIdFromPublicKey(publicKeyBase64: string): Promise { const raw = base64ToBuf(publicKeyBase64); return deriveNodeId(raw); } export async function verifyNodeIdBindingFromPublicKey( publicKeyBase64: string, nodeId: string, ): Promise { try { const raw = base64ToBuf(publicKeyBase64); const candidates = await deriveNodeIdCandidates(raw); return candidates.includes(String(nodeId || '').trim()); } catch { return false; } } export async function migrateLegacyNodeIds(): Promise { if (typeof window === 'undefined') return; const publicKey = storageGet(KEY_PUBKEY); const nodeId = storageGet(KEY_NODE_ID); if (publicKey && nodeId && isMigratableStoredNodeId(nodeId) && !isCurrentNodeId(nodeId)) { try { const current = await migrateStoredNodeIdIfNeeded(publicKey, nodeId, (nextNodeId) => { storageSet(KEY_NODE_ID, nextNodeId); }); if (current !== nodeId) { console.warn(`[mesh] migrated legacy browser node id ${nodeId} -> ${current}`); } } catch (err) { console.warn(`[mesh] failed to migrate legacy browser node id ${nodeId}`, err); } } try { const wormholePub = sessionStorage.getItem(KEY_WORMHOLE_PUBKEY); const wormholeNode = sessionStorage.getItem(KEY_WORMHOLE_NODE_ID); if ( wormholePub && wormholeNode && isMigratableStoredNodeId(wormholeNode) && !isCurrentNodeId(wormholeNode) ) { try { const current = await migrateStoredNodeIdIfNeeded( wormholePub, wormholeNode, (nextNodeId) => { sessionStorage.setItem(KEY_WORMHOLE_NODE_ID, nextNodeId); }, ); if (current !== wormholeNode) { console.warn(`[mesh] migrated legacy Wormhole descriptor ${wormholeNode} -> ${current}`); } } catch (err) { console.warn(`[mesh] failed to migrate legacy Wormhole descriptor ${wormholeNode}`, err); } } } catch { /* ignore */ } } async function generateKeyPairRaw(): Promise<{ publicKey: string; privateKey: string; nodeId: string; algo: string; }> { let keyPair: CryptoKeyPair; let algo: string; try { keyPair = await crypto.subtle.generateKey('Ed25519', true, ['sign', 'verify']); algo = 'Ed25519'; } catch { keyPair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, [ 'sign', 'verify', ]); algo = 'ECDSA'; } const pubRaw = await crypto.subtle.exportKey('raw', keyPair.publicKey); const privJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey); const publicKey = bufToBase64(pubRaw); const privateKey = JSON.stringify(privJwk); const nodeId = await deriveNodeId(pubRaw); // Store signing private key as non-extractable CryptoKey in IndexedDB try { const nonExtractable = await crypto.subtle.importKey( 'jwk', privJwk, algo === 'Ed25519' ? 'Ed25519' : { name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign'], ); await setKey(KEY_SIGN_PRIV_IDB, nonExtractable); } catch (err) { console.warn( '[mesh] signing key IndexedDB storage unavailable — key will not persist for this session', err, ); } return { publicKey, privateKey, nodeId, algo }; } /** * Ensure the signing private key lives in IndexedDB as a non-extractable * CryptoKey. Migrates from localStorage JWK on first call if needed, * then removes the localStorage copy. */ async function ensureSigningPrivateKey(): Promise { const existing = await getKey(KEY_SIGN_PRIV_IDB); if (existing) return existing; // Migrate from legacy localStorage JWK const legacy = storageGet(KEY_PRIVKEY); if (!legacy) return null; try { const jwk = JSON.parse(legacy); const algo = normalizeAlgo(storageGet(KEY_ALGO) || 'Ed25519'); const imported = await crypto.subtle.importKey( 'jwk', jwk, algo === 'Ed25519' ? 'Ed25519' : { name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign'], ); await setKey(KEY_SIGN_PRIV_IDB, imported); // Clear the extractable JWK from localStorage after successful migration removeLegacyPrivateKeyCopies(); return imported; } catch (err) { console.warn( '[mesh] legacy signing key migration failed — clearing extractable browser copy', err, ); removeLegacyPrivateKeyCopies(); return null; } } async function deriveIdentityBoundWrapKey(info: string): Promise { const descriptor = getStoredNodeDescriptor(); if (!descriptor?.publicKey || !descriptor.nodeId) { throw new Error('No local identity available for encrypted browser storage'); } let dhPubKey = getDHPubKey(); let dhAlgo = getDHAlgo() || 'X25519'; let dhPrivKey = await ensureDhPrivateKey(); if (!dhPubKey || !dhPrivKey) { dhPubKey = await generateDHKeys(); dhAlgo = getDHAlgo() || dhAlgo; dhPrivKey = await ensureDhPrivateKey(); } if (!dhPubKey || !dhPrivKey) { throw new Error('No DH key available for contact encryption'); } const dhPubRaw = toCryptoBytes(base64ToBuf(dhPubKey)); let selfPubKey: CryptoKey; let sharedSecret: ArrayBuffer; if (dhAlgo === 'X25519') { selfPubKey = await crypto.subtle.importKey('raw', dhPubRaw, 'X25519', false, []); sharedSecret = await crypto.subtle.deriveBits( { name: 'X25519', public: selfPubKey }, dhPrivKey, 256, ); } else { selfPubKey = await crypto.subtle.importKey( 'raw', dhPubRaw, { name: 'ECDH', namedCurve: 'P-256' }, false, [], ); sharedSecret = await crypto.subtle.deriveBits( { name: 'ECDH', public: selfPubKey }, dhPrivKey, 256, ); } const hkdfKey = await crypto.subtle.importKey('raw', toCryptoBytes(sharedSecret), 'HKDF', false, [ 'deriveKey', ]); return crypto.subtle.deriveKey( { name: 'HKDF', hash: 'SHA-256', salt: utf8ToBuf( `${CONTACTS_WRAP_CONTEXT}|${descriptor.nodeId}|${descriptor.publicKey}|${PROTOCOL_VERSION}`, ), info: utf8ToBuf(info), }, hkdfKey, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'], ); } export async function encryptIdentityBoundStoragePayload(value: unknown, info: string): Promise { const key = await deriveIdentityBoundWrapKey(info); const iv = crypto.getRandomValues(new Uint8Array(12)); const encoded = new TextEncoder().encode(JSON.stringify(value)); const ciphertext = await crypto.subtle.encrypt( { name: 'AES-GCM', iv, additionalData: new TextEncoder().encode(info), }, key, encoded, ); const combined = new Uint8Array(iv.length + ciphertext.byteLength); combined.set(iv, 0); combined.set(new Uint8Array(ciphertext), iv.length); return `${CONTACTS_ENCRYPTED_PREFIX}${bufToBase64(combined.buffer)}`; } export async function decryptIdentityBoundStoragePayload( raw: string, info: string, fallback: T, ): Promise { const value = String(raw || '').trim(); if (!value) return fallback; if (!value.startsWith(CONTACTS_ENCRYPTED_PREFIX)) { return JSON.parse(value) as T; } const key = await deriveIdentityBoundWrapKey(info); const payload = value.slice(CONTACTS_ENCRYPTED_PREFIX.length); const combined = new Uint8Array(base64ToBuf(payload)); const iv = combined.slice(0, 12); const ciphertext = combined.slice(12); const decrypted = await crypto.subtle.decrypt( { name: 'AES-GCM', iv, additionalData: new TextEncoder().encode(info), }, key, ciphertext, ); return JSON.parse(new TextDecoder().decode(decrypted)) as T; } async function encryptContactsPayload(contacts: Record): Promise { return encryptIdentityBoundStoragePayload(contacts, CONTACTS_WRAP_INFO); } async function decryptContactsPayload(raw: string): Promise> { return normalizeContactMap( await decryptIdentityBoundStoragePayload>(raw, CONTACTS_WRAP_INFO, {}), ); } /** * Sign a message using the stored non-extractable signing key from IndexedDB. * This is the preferred signing path — avoids exposing raw key material. */ export async function signWithStoredKey(message: string): Promise { const signingKey = await ensureSigningPrivateKey(); if (!signingKey) throw new Error('No signing key available'); const algo = normalizeAlgo(storageGet(KEY_ALGO) || 'Ed25519'); const data = new TextEncoder().encode(message); let signature: ArrayBuffer; if (algo === 'Ed25519') { signature = await crypto.subtle.sign('Ed25519', signingKey, data); } else { signature = await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, signingKey, data); } return bufToHex(signature); } export function persistIdentity(identity: NodeIdentity, algo: string, sequence: number) { if (isSecureModeCached()) { throw new Error('browser_identity_blocked_secure_mode'); } storageSet(KEY_PUBKEY, identity.publicKey); storageSet(KEY_NODE_ID, identity.nodeId); storageSet(KEY_SOVEREIGNTY, 'true'); storageSet(KEY_SEQUENCE, String(sequence)); storageSet(KEY_ALGO, algo); removeLegacyPrivateKeyCopies(); emitIdentityStateChange(); } export async function createIdentityCandidate(): Promise<{ identity: NodeIdentity; algo: string }> { await assertBrowserCustodyAllowed(); const { publicKey, privateKey, nodeId, algo } = await generateKeyPairRaw(); return { identity: { publicKey, privateKey, nodeId }, algo }; } /** * Generate a new Ed25519 keypair (falls back to ECDSA P-256 if unsupported). * Stores in active storage (local or session) and returns the identity. */ export async function generateNodeKeys(): Promise { await assertBrowserCustodyAllowed(); const { publicKey, privateKey, nodeId, algo } = await generateKeyPairRaw(); persistIdentity({ publicKey, privateKey, nodeId }, algo, 0); // Also generate X25519 DH keypair for encrypted DMs await generateDHKeys(); return { publicKey, privateKey, nodeId }; } /** Retrieve existing identity from active storage, or null if not initialized. * * The privateKey field is intentionally kept empty. Signing uses the * non-extractable IndexedDB key, and any legacy JWK copy is scrubbed during * migration. Callers that need to sign * should use `signWithStoredKey()` or `signEvent()` instead of reading * privateKey directly. */ export function getNodeIdentity(): NodeIdentity | null { if (typeof window === 'undefined') return null; if (isSecureModeCached()) return null; const publicKey = storageGet(KEY_PUBKEY); const nodeId = storageGet(KEY_NODE_ID); if (!publicKey || !nodeId) return null; if (storageGet(KEY_PRIVKEY)) { void ensureSigningPrivateKey(); } return { publicKey, privateKey: '', nodeId }; } /** Check if user has accepted the sovereignty declaration. */ export function hasSovereignty(): boolean { if (typeof window === 'undefined') return false; return storageGet(KEY_SOVEREIGNTY) === 'true'; } /** Mark sovereignty as declined (read-only mode). */ export function declineSovereignty(): void { storageSet(KEY_SOVEREIGNTY, 'declined'); } /** Check if sovereignty has been explicitly declined. */ export function isDeclined(): boolean { if (typeof window === 'undefined') return false; return storageGet(KEY_SOVEREIGNTY) === 'declined'; } /** * Sign a message string with the node's private key. * Returns a hex-encoded signature. */ export async function signMessage( message: string, privateKeyJson: string, algoOverride?: string, ): Promise { const normalizedPrivateKey = String(privateKeyJson || '').trim(); if (!normalizedPrivateKey) { throw new Error('Explicit private signing material required'); } const jwk = JSON.parse(normalizedPrivateKey); const algo = algoOverride || storageGet('sb_mesh_algo') || 'Ed25519'; let cryptoKey: CryptoKey; if (normalizeAlgo(algo) === 'Ed25519') { cryptoKey = await crypto.subtle.importKey('jwk', jwk, 'Ed25519', false, ['sign']); } else { cryptoKey = await crypto.subtle.importKey( 'jwk', jwk, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign'], ); } const data = new TextEncoder().encode(message); let signature: ArrayBuffer; if (normalizeAlgo(algo) === 'Ed25519') { signature = await crypto.subtle.sign('Ed25519', cryptoKey, data); } else { signature = await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, cryptoKey, data); } return bufToHex(signature); } export async function signEvent( eventType: string, nodeId: string, sequence: number, payload: Record, ): Promise { const payloadStr = buildSignaturePayload({ eventType, nodeId, sequence, payload: payload as Record, }); return signWithStoredKey(payloadStr); } const VERIFY_KEY_CACHE_MAX = 512; const verifyKeyCache = new Map>(); function verifyKeyCacheKey(publicKeyBase64: string, algo: string): string { return `${normalizeAlgo(algo)}:${publicKeyBase64}`; } async function importVerifyCryptoKey( publicKeyBase64: string, algo: string, ): Promise { const normalizedAlgo = normalizeAlgo(algo); const cacheKey = verifyKeyCacheKey(publicKeyBase64, normalizedAlgo); const existing = verifyKeyCache.get(cacheKey); if (existing) { return existing; } const promise = (async () => { const pubRaw = base64ToBuf(publicKeyBase64); if (normalizedAlgo === 'Ed25519') { return crypto.subtle.importKey('raw', pubRaw, 'Ed25519', false, ['verify']); } return crypto.subtle.importKey( 'raw', pubRaw, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify'], ); })().catch((error) => { verifyKeyCache.delete(cacheKey); throw error; }); verifyKeyCache.set(cacheKey, promise); if (verifyKeyCache.size > VERIFY_KEY_CACHE_MAX) { const oldestKey = verifyKeyCache.keys().next().value; if (oldestKey) { verifyKeyCache.delete(oldestKey); } } return promise; } /** * Verify a signature against a public key and message. */ export async function verifySignature( message: string, signature: string, publicKeyBase64: string, ): Promise { return verifySignatureWithAlgo(message, signature, publicKeyBase64); } async function verifySignatureWithAlgo( message: string, signature: string, publicKeyBase64: string, algoOverride?: string, ): Promise { const algo = normalizeAlgo(algoOverride || storageGet('sb_mesh_algo') || 'Ed25519'); const cryptoKey = await importVerifyCryptoKey(publicKeyBase64, algo); const data = new TextEncoder().encode(message); const sigBuf = new Uint8Array(signature.match(/.{2}/g)!.map((h) => parseInt(h, 16))).buffer; if (algo === 'Ed25519') { return crypto.subtle.verify('Ed25519', cryptoKey, sigBuf, data); } else { return crypto.subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, cryptoKey, sigBuf, data); } } export async function verifyRawSignature(opts: { message: string; signature: string; publicKey: string; publicKeyAlgo: string; }): Promise { try { return await verifySignatureWithAlgo( opts.message, opts.signature, opts.publicKey, opts.publicKeyAlgo, ); } catch { return false; } } export async function verifyEventSignature(opts: { eventType: string; nodeId: string; sequence: number; payload: Record; signature: string; publicKey: string; publicKeyAlgo: string; }): Promise { const bound = await verifyNodeIdBindingFromPublicKey(opts.publicKey, opts.nodeId); if (!bound) return false; const payloadStr = buildSignaturePayload({ eventType: opts.eventType, nodeId: opts.nodeId, sequence: opts.sequence, payload: opts.payload as Record, }); return verifySignatureWithAlgo(payloadStr, opts.signature, opts.publicKey, opts.publicKeyAlgo); } // ─── DH Key Exchange (X25519 / ECDH P-256 fallback) ───────────────────── /** * Generate an X25519 DH keypair for encrypted DMs. * Falls back to ECDH P-256 if X25519 is unsupported. * Stores in active storage. Returns the Base64 public key. */ export async function generateDHKeys(): Promise { await assertBrowserCustodyAllowed(); const previousPrivKey = await getKey(KEY_DH_PRIV_IDB); let keyPair: CryptoKeyPair; let dhAlgo: string; try { keyPair = (await crypto.subtle.generateKey('X25519', true, ['deriveBits', 'deriveKey'])) as CryptoKeyPair; dhAlgo = 'X25519'; } catch { keyPair = (await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, [ 'deriveBits', 'deriveKey', ])) as CryptoKeyPair; dhAlgo = 'ECDH'; } const pubRaw = await crypto.subtle.exportKey('raw', keyPair.publicKey); const privJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey); const dhPubKey = bufToBase64(pubRaw); storageSet(KEY_DH_PUBKEY, dhPubKey); storageSet(KEY_DH_ALGO, dhAlgo); storageSet(KEY_DH_LAST_ROTATION, String(Math.floor(Date.now() / 1000))); // Re-import private key as non-extractable and store in IndexedDB try { const nonExtractable = await crypto.subtle.importKey( 'jwk', privJwk, dhAlgo === 'X25519' ? 'X25519' : { name: 'ECDH', namedCurve: 'P-256' }, false, ['deriveBits', 'deriveKey'], ); if (previousPrivKey) { await setKey(KEY_DH_PREV_PRIV_IDB, previousPrivKey); } else { await deleteKey(KEY_DH_PREV_PRIV_IDB); } await setKey(KEY_DH_PRIV_IDB, nonExtractable); storageRemove(KEY_DH_PRIVKEY); } catch (err) { storageRemove(KEY_DH_PRIVKEY); console.warn('[mesh] DH key IndexedDB storage unavailable — DM encryption disabled', err); throw new Error('IndexedDB required for DH key storage'); } return dhPubKey; } export function getDHLastRotation(): number { if (typeof window === 'undefined') return 0; const raw = storageGet(KEY_DH_LAST_ROTATION); const val = raw ? parseInt(raw, 10) : 0; return Number.isFinite(val) ? val : 0; } export async function ensureDhKeysFresh( maxAgeSeconds: number = 7 * 24 * 3600, ): Promise<{ pub: string | null; rotated: boolean }> { if (typeof window === 'undefined') return { pub: null, rotated: false }; const now = Math.floor(Date.now() / 1000); const last = getDHLastRotation(); let pub = getDHPubKey(); let rotated = false; if (!pub || !last || now - last >= maxAgeSeconds) { pub = await generateDHKeys(); rotated = true; } await ensureDhPrivateKey(); return { pub: pub || null, rotated }; } /** Get our DH public key from active storage. */ export function getDHPubKey(): string | null { if (typeof window === 'undefined') return null; return storageGet(KEY_DH_PUBKEY); } export function getDHAlgo(): string { if (typeof window === 'undefined') return ''; return storageGet(KEY_DH_ALGO) || ''; } async function ensureDhPrivateKey(): Promise { const existing = await getKey(KEY_DH_PRIV_IDB); if (existing) return existing; if (storageGet(KEY_DH_PRIVKEY)) { storageRemove(KEY_DH_PRIVKEY); console.warn('[mesh] cleared legacy DH private key fallback from browser storage'); } return null; } async function retainedDhPrivateKeys(): Promise { const keys: CryptoKey[] = []; const current = await getKey(KEY_DH_PRIV_IDB); if (current) { keys.push(current); } const previous = await getKey(KEY_DH_PREV_PRIV_IDB); if (previous && previous !== current) { keys.push(previous); } return keys; } async function deriveSharedSecretWithPrivateKey( theirDHPubBase64: string, privateKey: CryptoKey, ): Promise { const theirPubRaw = toCryptoBytes(base64ToBuf(theirDHPubBase64)); if (privateKey.algorithm.name === 'X25519') { const theirPubKey = await crypto.subtle.importKey('raw', theirPubRaw, 'X25519', false, []); return crypto.subtle.deriveBits({ name: 'X25519', public: theirPubKey }, privateKey, 256); } const ecAlgorithm = privateKey.algorithm as EcKeyAlgorithm; const theirPubKey = await crypto.subtle.importKey( 'raw', theirPubRaw, { name: 'ECDH', namedCurve: ecAlgorithm.namedCurve || 'P-256' }, false, [], ); return crypto.subtle.deriveBits({ name: 'ECDH', public: theirPubKey }, privateKey, 256); } /** * Derive a shared AES-256-GCM key from our DH private key + their DH public key. */ export async function deriveSharedKey(theirDHPubBase64: string): Promise { const dhAlgo = storageGet(KEY_DH_ALGO) || 'X25519'; const privKey = await ensureDhPrivateKey(); if (!privKey) throw new Error('Missing DH private key'); const theirPubRaw = toCryptoBytes(base64ToBuf(theirDHPubBase64)); let theirPubKey: CryptoKey; if (dhAlgo === 'X25519') { theirPubKey = await crypto.subtle.importKey('raw', theirPubRaw, 'X25519', false, []); return crypto.subtle.deriveKey( { name: 'X25519', public: theirPubKey }, privKey, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'], ); } else { theirPubKey = await crypto.subtle.importKey( 'raw', theirPubRaw, { name: 'ECDH', namedCurve: 'P-256' }, false, [], ); return crypto.subtle.deriveKey( { name: 'ECDH', public: theirPubKey }, privKey, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'], ); } } /** * Derive a raw shared secret (256-bit) from our DH private key + their DH public key. * Used for metadata-hiding tokens (dead-drop) and SAS verification. */ export async function deriveSharedSecret(theirDHPubBase64: string): Promise { const dhAlgo = storageGet(KEY_DH_ALGO) || 'X25519'; const privKey = await ensureDhPrivateKey(); if (!privKey) throw new Error('Missing DH private key'); const theirPubRaw = toCryptoBytes(base64ToBuf(theirDHPubBase64)); let theirPubKey: CryptoKey; if (dhAlgo === 'X25519') { theirPubKey = await crypto.subtle.importKey('raw', theirPubRaw, 'X25519', false, []); return crypto.subtle.deriveBits({ name: 'X25519', public: theirPubKey }, privKey, 256); } theirPubKey = await crypto.subtle.importKey( 'raw', theirPubRaw, { name: 'ECDH', namedCurve: 'P-256' }, false, [], ); return crypto.subtle.deriveBits({ name: 'ECDH', public: theirPubKey }, privKey, 256); } async function sha256Bytes(value: string): Promise { return crypto.subtle.digest('SHA-256', utf8ToBuf(value)); } export async function deriveSenderSealKey( theirDHPubBase64: string, recipientId: string, msgId: string, ): Promise { const secret = await deriveSharedSecret(theirDHPubBase64); const salt = await sha256Bytes(`SB-SEAL-SALT|${recipientId}|${msgId}|${PROTOCOL_VERSION}`); const hkdfKey = await crypto.subtle.importKey('raw', toCryptoBytes(secret), 'HKDF', false, [ 'deriveKey', ]); return crypto.subtle.deriveKey( { name: 'HKDF', hash: 'SHA-256', salt, info: utf8ToBuf('SB-SENDER-SEAL-V2'), }, hkdfKey, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'], ); } export async function deriveSenderSealKeyV3( ephemeralPubBase64: string, recipientId: string, msgId: string, ): Promise { const secret = await deriveSharedSecret(ephemeralPubBase64); const salt = await sha256Bytes( `SB-SEAL-SALT|${recipientId}|${msgId}|${PROTOCOL_VERSION}|${ephemeralPubBase64}`, ); const hkdfKey = await crypto.subtle.importKey('raw', toCryptoBytes(secret), 'HKDF', false, [ 'deriveKey', ]); return crypto.subtle.deriveKey( { name: 'HKDF', hash: 'SHA-256', salt, info: utf8ToBuf('SB-SENDER-SEAL-V3'), }, hkdfKey, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'], ); } async function decryptSenderSealPayloadWithRetainedKeys( payload: string, ephemeralPubBase64: string, recipientId: string, msgId: string, ): Promise { const keys = await retainedDhPrivateKeys(); for (const privateKey of keys) { try { const secret = await deriveSharedSecretWithPrivateKey(ephemeralPubBase64, privateKey); const salt = await sha256Bytes( `SB-SEAL-SALT|${recipientId}|${msgId}|${PROTOCOL_VERSION}|${ephemeralPubBase64}`, ); const hkdfKey = await crypto.subtle.importKey('raw', toCryptoBytes(secret), 'HKDF', false, [ 'deriveKey', ]); const sealKey = await crypto.subtle.deriveKey( { name: 'HKDF', hash: 'SHA-256', salt, info: utf8ToBuf('SB-SENDER-SEAL-V3'), }, hkdfKey, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'], ); return await decryptDM(payload, sealKey); } catch { /* try next retained key */ } } return null; } export async function decryptSenderSealPayloadLocally( senderSeal: string, candidateDhPub: string, recipientId: string, msgId: string, ): Promise { const sealEnvelope = unwrapSenderSealPayload(senderSeal); try { if (sealEnvelope.version === 'v3') { const ephemeralPub = String(sealEnvelope.ephemeralPub || '').trim(); if (!ephemeralPub) return null; return await decryptSenderSealPayloadWithRetainedKeys( sealEnvelope.payload, ephemeralPub, recipientId, msgId, ); } const sealKey = sealEnvelope.version === 'v2' ? await deriveSenderSealKey(candidateDhPub, recipientId, msgId) : await deriveSharedKey(candidateDhPub); return await decryptDM(sealEnvelope.payload, sealKey); } catch { return null; } } export function unwrapSenderSealPayload( senderSeal: string, ): { version: 'v3' | 'v2' | 'legacy'; payload: string; ephemeralPub?: string } { const value = String(senderSeal || '').trim(); if (value.startsWith('v3:')) { const [, ephemeralPub, payload] = value.split(':', 3); return { version: 'v3', payload, ephemeralPub }; } if (value.startsWith('v2:')) { return { version: 'v2', payload: value.slice(3) }; } return { version: 'legacy', payload: value }; } /** * Encrypt a plaintext message with a shared AES-GCM key. * Returns Base64(iv || ciphertext). */ export async function encryptDM(plaintext: string, sharedKey: CryptoKey): Promise { const iv = crypto.getRandomValues(new Uint8Array(12)); const encoded = new TextEncoder().encode(plaintext); const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, sharedKey, encoded); // Concatenate IV + ciphertext const combined = new Uint8Array(iv.length + ciphertext.byteLength); combined.set(iv, 0); combined.set(new Uint8Array(ciphertext), iv.length); return bufToBase64(combined.buffer); } /** * Decrypt a ciphertext blob with a shared AES-GCM key. * Expects Base64(iv || ciphertext) as input. */ export async function decryptDM(ciphertextB64: string, sharedKey: CryptoKey): Promise { const combined = new Uint8Array(base64ToBuf(ciphertextB64)); const iv = combined.slice(0, 12); const ciphertext = combined.slice(12); const plainBuf = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, sharedKey, ciphertext); return new TextDecoder().decode(plainBuf); } // ─── Contact Management (storage) ────────────────────────────────────── export interface Contact { alias?: string; blocked: boolean; dhPubKey?: string; dhAlgo?: string; sharedAlias?: string; previousSharedAliases?: string[]; pendingSharedAlias?: string; sharedAliasGraceUntil?: number; sharedAliasRotatedAt?: number; verify_inband?: boolean; verify_registry?: boolean; verified?: boolean; verify_mismatch?: boolean; verified_at?: number; trust_level?: string; invitePinnedTrustFingerprint?: string; invitePinnedNodeId?: string; invitePinnedPublicKey?: string; invitePinnedPublicKeyAlgo?: string; invitePinnedDhPubKey?: string; invitePinnedDhAlgo?: string; invitePinnedPrekeyLookupHandle?: string; invitePinnedRootFingerprint?: string; invitePinnedRootManifestFingerprint?: string; invitePinnedRootWitnessPolicyFingerprint?: string; invitePinnedRootWitnessThreshold?: number; invitePinnedRootWitnessCount?: number; invitePinnedRootWitnessDomainCount?: number; invitePinnedRootManifestGeneration?: number; invitePinnedRootRotationProven?: boolean; invitePinnedRootNodeId?: string; invitePinnedRootPublicKey?: string; invitePinnedRootPublicKeyAlgo?: string; invitePinnedIssuedAt?: number; invitePinnedExpiresAt?: number; invitePinnedAt?: number; remotePrekeyFingerprint?: string; remotePrekeyObservedFingerprint?: string; remotePrekeyRootFingerprint?: string; remotePrekeyRootManifestFingerprint?: string; remotePrekeyRootWitnessPolicyFingerprint?: string; remotePrekeyRootWitnessThreshold?: number; remotePrekeyRootWitnessCount?: number; remotePrekeyRootWitnessDomainCount?: number; remotePrekeyRootManifestGeneration?: number; remotePrekeyRootRotationProven?: boolean; remotePrekeyObservedRootFingerprint?: string; remotePrekeyObservedRootManifestFingerprint?: string; remotePrekeyObservedRootWitnessPolicyFingerprint?: string; remotePrekeyObservedRootWitnessThreshold?: number; remotePrekeyObservedRootWitnessCount?: number; remotePrekeyObservedRootWitnessDomainCount?: number; remotePrekeyObservedRootManifestGeneration?: number; remotePrekeyObservedRootRotationProven?: boolean; remotePrekeyRootNodeId?: string; remotePrekeyRootPublicKey?: string; remotePrekeyRootPublicKeyAlgo?: string; remotePrekeyRootPinnedAt?: number; remotePrekeyRootLastSeenAt?: number; remotePrekeyRootMismatch?: boolean; remotePrekeyPinnedAt?: number; remotePrekeyLastSeenAt?: number; remotePrekeySequence?: number; remotePrekeySignedAt?: number; remotePrekeyMismatch?: boolean; remotePrekeyTransparencyHead?: string; remotePrekeyTransparencySize?: number; remotePrekeyTransparencySeenAt?: number; remotePrekeyTransparencyConflict?: boolean; remotePrekeyLookupMode?: string; witness_count?: number; witness_checked_at?: number; vouch_count?: number; vouch_checked_at?: number; trustSummary?: ContactTrustSummary; } let contactCache: Record = {}; let contactsHydration: Promise> | null = null; let contactsPersistGeneration = 0; let contactsPersistQueue: Promise = Promise.resolve(); function shouldUseWormholeContacts(): boolean { return isSecureModeCached(); } function sanitizeContact(contact: Partial | undefined): Contact { const trustSummary = contact?.trustSummary; return { alias: String(contact?.alias || ''), blocked: Boolean(contact?.blocked), dhPubKey: String(contact?.dhPubKey || ''), dhAlgo: String(contact?.dhAlgo || ''), sharedAlias: String(contact?.sharedAlias || ''), previousSharedAliases: Array.isArray(contact?.previousSharedAliases) ? contact?.previousSharedAliases.filter(Boolean).map(String).slice(-2) : [], pendingSharedAlias: String(contact?.pendingSharedAlias || ''), sharedAliasGraceUntil: Number(contact?.sharedAliasGraceUntil || 0), sharedAliasRotatedAt: Number(contact?.sharedAliasRotatedAt || 0), verify_inband: Boolean(contact?.verify_inband), verify_registry: Boolean(contact?.verify_registry), verified: Boolean(contact?.verified), verify_mismatch: Boolean(contact?.verify_mismatch), verified_at: Number(contact?.verified_at || 0), trust_level: String(contact?.trust_level || ''), invitePinnedTrustFingerprint: String(contact?.invitePinnedTrustFingerprint || ''), invitePinnedNodeId: String(contact?.invitePinnedNodeId || ''), invitePinnedPublicKey: String(contact?.invitePinnedPublicKey || ''), invitePinnedPublicKeyAlgo: String(contact?.invitePinnedPublicKeyAlgo || ''), invitePinnedDhPubKey: String(contact?.invitePinnedDhPubKey || ''), invitePinnedDhAlgo: String(contact?.invitePinnedDhAlgo || ''), invitePinnedPrekeyLookupHandle: String(contact?.invitePinnedPrekeyLookupHandle || ''), invitePinnedRootFingerprint: String(contact?.invitePinnedRootFingerprint || ''), invitePinnedRootManifestFingerprint: String(contact?.invitePinnedRootManifestFingerprint || ''), invitePinnedRootWitnessPolicyFingerprint: String( contact?.invitePinnedRootWitnessPolicyFingerprint || '', ), invitePinnedRootWitnessThreshold: Number(contact?.invitePinnedRootWitnessThreshold || 0), invitePinnedRootWitnessCount: Number(contact?.invitePinnedRootWitnessCount || 0), invitePinnedRootWitnessDomainCount: Number(contact?.invitePinnedRootWitnessDomainCount || 0), invitePinnedRootManifestGeneration: Number(contact?.invitePinnedRootManifestGeneration || 0), invitePinnedRootRotationProven: Boolean(contact?.invitePinnedRootRotationProven), invitePinnedRootNodeId: String(contact?.invitePinnedRootNodeId || ''), invitePinnedRootPublicKey: String(contact?.invitePinnedRootPublicKey || ''), invitePinnedRootPublicKeyAlgo: String(contact?.invitePinnedRootPublicKeyAlgo || ''), invitePinnedIssuedAt: Number(contact?.invitePinnedIssuedAt || 0), invitePinnedExpiresAt: Number(contact?.invitePinnedExpiresAt || 0), invitePinnedAt: Number(contact?.invitePinnedAt || 0), remotePrekeyFingerprint: String(contact?.remotePrekeyFingerprint || ''), remotePrekeyObservedFingerprint: String(contact?.remotePrekeyObservedFingerprint || ''), remotePrekeyRootFingerprint: String(contact?.remotePrekeyRootFingerprint || ''), remotePrekeyRootManifestFingerprint: String(contact?.remotePrekeyRootManifestFingerprint || ''), remotePrekeyRootWitnessPolicyFingerprint: String( contact?.remotePrekeyRootWitnessPolicyFingerprint || '', ), remotePrekeyRootWitnessThreshold: Number(contact?.remotePrekeyRootWitnessThreshold || 0), remotePrekeyRootWitnessCount: Number(contact?.remotePrekeyRootWitnessCount || 0), remotePrekeyRootWitnessDomainCount: Number(contact?.remotePrekeyRootWitnessDomainCount || 0), remotePrekeyRootManifestGeneration: Number(contact?.remotePrekeyRootManifestGeneration || 0), remotePrekeyRootRotationProven: Boolean(contact?.remotePrekeyRootRotationProven), remotePrekeyObservedRootFingerprint: String(contact?.remotePrekeyObservedRootFingerprint || ''), remotePrekeyObservedRootManifestFingerprint: String( contact?.remotePrekeyObservedRootManifestFingerprint || '', ), remotePrekeyObservedRootWitnessPolicyFingerprint: String( contact?.remotePrekeyObservedRootWitnessPolicyFingerprint || '', ), remotePrekeyObservedRootWitnessThreshold: Number( contact?.remotePrekeyObservedRootWitnessThreshold || 0, ), remotePrekeyObservedRootWitnessCount: Number(contact?.remotePrekeyObservedRootWitnessCount || 0), remotePrekeyObservedRootWitnessDomainCount: Number( contact?.remotePrekeyObservedRootWitnessDomainCount || 0, ), remotePrekeyObservedRootManifestGeneration: Number( contact?.remotePrekeyObservedRootManifestGeneration || 0, ), remotePrekeyObservedRootRotationProven: Boolean(contact?.remotePrekeyObservedRootRotationProven), remotePrekeyRootNodeId: String(contact?.remotePrekeyRootNodeId || ''), remotePrekeyRootPublicKey: String(contact?.remotePrekeyRootPublicKey || ''), remotePrekeyRootPublicKeyAlgo: String(contact?.remotePrekeyRootPublicKeyAlgo || ''), remotePrekeyRootPinnedAt: Number(contact?.remotePrekeyRootPinnedAt || 0), remotePrekeyRootLastSeenAt: Number(contact?.remotePrekeyRootLastSeenAt || 0), remotePrekeyRootMismatch: Boolean(contact?.remotePrekeyRootMismatch), remotePrekeyPinnedAt: Number(contact?.remotePrekeyPinnedAt || 0), remotePrekeyLastSeenAt: Number(contact?.remotePrekeyLastSeenAt || 0), remotePrekeySequence: Number(contact?.remotePrekeySequence || 0), remotePrekeySignedAt: Number(contact?.remotePrekeySignedAt || 0), remotePrekeyMismatch: Boolean(contact?.remotePrekeyMismatch), remotePrekeyTransparencyHead: String(contact?.remotePrekeyTransparencyHead || ''), remotePrekeyTransparencySize: Number(contact?.remotePrekeyTransparencySize || 0), remotePrekeyTransparencySeenAt: Number(contact?.remotePrekeyTransparencySeenAt || 0), remotePrekeyTransparencyConflict: Boolean(contact?.remotePrekeyTransparencyConflict), remotePrekeyLookupMode: String(contact?.remotePrekeyLookupMode || '').trim().toLowerCase(), witness_count: Number(contact?.witness_count || 0), witness_checked_at: Number(contact?.witness_checked_at || 0), vouch_count: Number(contact?.vouch_count || 0), vouch_checked_at: Number(contact?.vouch_checked_at || 0), trustSummary: trustSummary ? { state: String(trustSummary.state || '').trim(), label: String(trustSummary.label || '').trim(), severity: String(trustSummary.severity || 'warn').trim() as ContactTrustSummary['severity'], detail: String(trustSummary.detail || '').trim(), verifiedFirstContact: Boolean(trustSummary.verifiedFirstContact), recommendedAction: String( trustSummary.recommendedAction || 'show_sas', ).trim() as ContactTrustSummary['recommendedAction'], legacyLookup: Boolean(trustSummary.legacyLookup), inviteAttested: Boolean(trustSummary.inviteAttested), rootAttested: Boolean(trustSummary.rootAttested), rootWitnessed: Boolean(trustSummary.rootWitnessed), rootDistributionState: String( trustSummary.rootDistributionState || 'none', ).trim() as ContactTrustSummary['rootDistributionState'], rootWitnessPolicyFingerprint: String(trustSummary.rootWitnessPolicyFingerprint || ''), rootWitnessCount: Number(trustSummary.rootWitnessCount || 0), rootWitnessThreshold: Number(trustSummary.rootWitnessThreshold || 0), rootWitnessQuorumMet: Boolean(trustSummary.rootWitnessQuorumMet), rootWitnessProvenanceState: String( trustSummary.rootWitnessProvenanceState || 'none', ).trim() as ContactTrustSummary['rootWitnessProvenanceState'], rootWitnessDomainCount: Number(trustSummary.rootWitnessDomainCount || 0), rootWitnessIndependentQuorumMet: Boolean( trustSummary.rootWitnessIndependentQuorumMet, ), rootManifestGeneration: Number(trustSummary.rootManifestGeneration || 0), rootRotationProven: Boolean(trustSummary.rootRotationProven), rootMismatch: Boolean(trustSummary.rootMismatch), registryMismatch: Boolean(trustSummary.registryMismatch), transparencyConflict: Boolean(trustSummary.transparencyConflict), } : undefined, }; } function normalizeContactMap(input: Record | Record): Record { return Object.fromEntries( Object.entries(input || {}).map(([peerId, contact]) => [peerId, sanitizeContact(contact as Partial)]), ); } async function persistContactToWormhole(peerId: string, contact: Contact): Promise { await controlPlaneJson('/api/wormhole/dm/contact', { method: 'PUT', requireAdminSession: false, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ peer_id: peerId, contact, }), }); } async function deleteContactFromWormhole(peerId: string): Promise { await controlPlaneJson(`/api/wormhole/dm/contact/${encodeURIComponent(peerId)}`, { method: 'DELETE', requireAdminSession: false, }); } export async function hydrateWormholeContacts(force: boolean = false): Promise> { if (!shouldUseWormholeContacts()) { if (!force && contactsHydration) { return contactsHydration; } contactsHydration = (async () => { if (typeof window === 'undefined') { contactCache = {}; return contactCache; } const raw = storageGet(KEY_CONTACTS) || ''; if (!raw) { contactCache = {}; return contactCache; } try { const hydrated = await decryptContactsPayload(raw); contactCache = hydrated; if (!raw.startsWith(CONTACTS_ENCRYPTED_PREFIX)) { void persistStoredContacts(hydrated); } return contactCache; } catch (err) { console.warn('[mesh] contact storage unreadable — treating as empty contacts', err); contactCache = {}; return contactCache; } })(); return contactsHydration; } if (!force && contactsHydration) { return contactsHydration; } contactsHydration = hydrateWormholeContactsFromNode().catch(() => contactCache); return contactsHydration; } export async function hydrateWormholeContactsFromNode(): Promise> { const data = await controlPlaneJson<{ ok: boolean; contacts: Record }>( '/api/wormhole/dm/contacts', { requireAdminSession: false }, ); contactCache = normalizeContactMap(data.contacts || {}); contactsHydration = Promise.resolve(contactCache); return contactCache; } function getStoredContacts(): Record { if (!shouldUseWormholeContacts() && !contactsHydration && typeof window !== 'undefined') { void hydrateWormholeContacts(); } return contactCache; } export function getContacts(): Record { if (shouldUseWormholeContacts()) { return contactCache; } return getStoredContacts(); } async function persistStoredContacts(contacts: Record): Promise { try { const encrypted = await encryptContactsPayload(normalizeContactMap(contacts)); storageSet(KEY_CONTACTS, encrypted); } catch (err) { console.warn( '[mesh] contact storage encryption unavailable — contacts kept in memory only', err, ); } } function schedulePersistStoredContacts(contacts: Record): void { const generation = ++contactsPersistGeneration; const snapshot = normalizeContactMap(contacts); contactsPersistQueue = contactsPersistQueue .catch(() => { /* preserve queue progression after prior persist errors */ }) .then(async () => { if (generation !== contactsPersistGeneration) { return; } await persistStoredContacts(snapshot); }); } function saveContacts(contacts: Record): void { const normalized = normalizeContactMap(contacts); contactCache = normalized; if (shouldUseWormholeContacts()) { return; } schedulePersistStoredContacts(normalized); } export function addContact(agentId: string, dhPubKey: string, alias?: string, dhAlgo?: string): void { const contacts = getContacts(); const next = sanitizeContact({ alias: alias || contacts[agentId]?.alias, blocked: contacts[agentId]?.blocked || false, dhPubKey, dhAlgo: dhAlgo || contacts[agentId]?.dhAlgo, sharedAlias: contacts[agentId]?.sharedAlias, previousSharedAliases: contacts[agentId]?.previousSharedAliases, pendingSharedAlias: contacts[agentId]?.pendingSharedAlias, sharedAliasGraceUntil: contacts[agentId]?.sharedAliasGraceUntil, sharedAliasRotatedAt: contacts[agentId]?.sharedAliasRotatedAt, verify_inband: contacts[agentId]?.verify_inband, verify_registry: contacts[agentId]?.verify_registry, verified: contacts[agentId]?.verified, verify_mismatch: contacts[agentId]?.verify_mismatch, verified_at: contacts[agentId]?.verified_at, trust_level: contacts[agentId]?.trust_level, invitePinnedTrustFingerprint: contacts[agentId]?.invitePinnedTrustFingerprint, invitePinnedNodeId: contacts[agentId]?.invitePinnedNodeId, invitePinnedPublicKey: contacts[agentId]?.invitePinnedPublicKey, invitePinnedPublicKeyAlgo: contacts[agentId]?.invitePinnedPublicKeyAlgo, invitePinnedDhPubKey: contacts[agentId]?.invitePinnedDhPubKey, invitePinnedDhAlgo: contacts[agentId]?.invitePinnedDhAlgo, invitePinnedPrekeyLookupHandle: contacts[agentId]?.invitePinnedPrekeyLookupHandle, invitePinnedRootFingerprint: contacts[agentId]?.invitePinnedRootFingerprint, invitePinnedRootNodeId: contacts[agentId]?.invitePinnedRootNodeId, invitePinnedRootPublicKey: contacts[agentId]?.invitePinnedRootPublicKey, invitePinnedRootPublicKeyAlgo: contacts[agentId]?.invitePinnedRootPublicKeyAlgo, invitePinnedIssuedAt: contacts[agentId]?.invitePinnedIssuedAt, invitePinnedExpiresAt: contacts[agentId]?.invitePinnedExpiresAt, invitePinnedAt: contacts[agentId]?.invitePinnedAt, remotePrekeyFingerprint: contacts[agentId]?.remotePrekeyFingerprint, remotePrekeyObservedFingerprint: contacts[agentId]?.remotePrekeyObservedFingerprint, remotePrekeyRootFingerprint: contacts[agentId]?.remotePrekeyRootFingerprint, remotePrekeyObservedRootFingerprint: contacts[agentId]?.remotePrekeyObservedRootFingerprint, remotePrekeyRootNodeId: contacts[agentId]?.remotePrekeyRootNodeId, remotePrekeyRootPublicKey: contacts[agentId]?.remotePrekeyRootPublicKey, remotePrekeyRootPublicKeyAlgo: contacts[agentId]?.remotePrekeyRootPublicKeyAlgo, remotePrekeyRootPinnedAt: contacts[agentId]?.remotePrekeyRootPinnedAt, remotePrekeyRootLastSeenAt: contacts[agentId]?.remotePrekeyRootLastSeenAt, remotePrekeyRootMismatch: contacts[agentId]?.remotePrekeyRootMismatch, remotePrekeyPinnedAt: contacts[agentId]?.remotePrekeyPinnedAt, remotePrekeyLastSeenAt: contacts[agentId]?.remotePrekeyLastSeenAt, remotePrekeySequence: contacts[agentId]?.remotePrekeySequence, remotePrekeySignedAt: contacts[agentId]?.remotePrekeySignedAt, remotePrekeyMismatch: contacts[agentId]?.remotePrekeyMismatch, remotePrekeyTransparencyHead: contacts[agentId]?.remotePrekeyTransparencyHead, remotePrekeyTransparencySize: contacts[agentId]?.remotePrekeyTransparencySize, remotePrekeyTransparencySeenAt: contacts[agentId]?.remotePrekeyTransparencySeenAt, remotePrekeyTransparencyConflict: contacts[agentId]?.remotePrekeyTransparencyConflict, remotePrekeyLookupMode: contacts[agentId]?.remotePrekeyLookupMode, witness_count: contacts[agentId]?.witness_count, witness_checked_at: contacts[agentId]?.witness_checked_at, vouch_count: contacts[agentId]?.vouch_count, vouch_checked_at: contacts[agentId]?.vouch_checked_at, }); contacts[agentId] = next; saveContacts(contacts); if (shouldUseWormholeContacts()) { void persistContactToWormhole(agentId, next); } } export function updateContact(agentId: string, updates: Partial): void { const contacts = getContacts(); const current = contacts[agentId] || sanitizeContact({ blocked: false }); contacts[agentId] = sanitizeContact({ ...current, ...updates }); saveContacts(contacts); if (shouldUseWormholeContacts()) { void persistContactToWormhole(agentId, contacts[agentId]); } } export function blockContact(agentId: string): void { const contacts = getContacts(); if (contacts[agentId]) { contacts[agentId].blocked = true; } else { contacts[agentId] = sanitizeContact({ blocked: true }); } saveContacts(contacts); if (shouldUseWormholeContacts()) { void persistContactToWormhole(agentId, sanitizeContact(contacts[agentId])); } } export function unblockContact(agentId: string): void { const contacts = getContacts(); if (contacts[agentId]) { contacts[agentId].blocked = false; saveContacts(contacts); if (shouldUseWormholeContacts()) { void persistContactToWormhole(agentId, sanitizeContact(contacts[agentId])); } } } export function removeContact(agentId: string): void { const contacts = getContacts(); if (!(agentId in contacts)) return; delete contacts[agentId]; saveContacts(contacts); if (shouldUseWormholeContacts()) { void deleteContactFromWormhole(agentId); } } export function isBlocked(agentId: string): boolean { return getContacts()[agentId]?.blocked || false; } export function getDMNotify(): boolean { if (typeof window === 'undefined') return true; return storageGet(KEY_DM_NOTIFY) !== 'false'; } export function setDMNotify(on: boolean): void { storageSet(KEY_DM_NOTIFY, on ? 'true' : 'false'); } const NODE_ID_PREFIX = '!sb_'; const NODE_ID_HEX_LEN = 32; const NODE_ID_COMPAT_HEX_LEN = 16; const NODE_ID_LEGACY_HEX_LEN = 8;