// Production adapter that backs `runSyncInitiator` with the live vault. // // The coordinator (sync-coordinator.ts) never decrypts vault data; it // only moves opaque base64 entry blobs and metadata. This adapter is the // boundary where vault plaintext meets the sync layer — entries leave the // vault as encrypted envelopes here, and incoming envelopes are decrypted // here before being merged via `applyMergedRemoteEntries`. // // Envelope cipher: AES-GCM with a PER-ENTRY key derived as // HKDF-SHA256(ikm = syncMasterKey, salt = "", info = entryId + "\x00" + "sync-entry-v1") // (random 12B nonce, aad = "peach-sync-entry-v1"). Binding the key to the // entry id means a ciphertext minted for one entry slot cannot be replayed // into another: decryptEntryOrNull rejects any envelope whose decrypted // entry.id does not match the map key it arrived under. Both sides hold the // same syncMasterKey (same vault) so keys match without prior negotiation. The // Noise KK reconnect session encrypts the transport on top — the envelope is // belt-and-braces so a transport-layer leak doesn't expose plaintext entries. // // Device-manifest exchange (v2 brief §4.3) is plumbed here. Any admitted // mesh peer may issue one — see applyVerifiedPeerManifest for why the issuer // is not restricted by device type or first-pair topology, and for what the // Noise-authenticated static key gates. docs/mesh-vouching-authority.md is the // end-to-end trace. import { HKDF } from "@stablelib/hkdf"; import { SHA256 } from "@stablelib/sha256"; import type { DeviceManifestBody, DeviceRevocation } from "../shared/sync-types"; import type { SyncVaultAdapter } from "./sync-coordinator"; import { getDeviceName, getLocalPairingIdentity, getOrCreateDeviceId } from "./device-identity"; import { computeVaultId, hexToPubkey, pubkeyToHex, } from "../shared/sync-identity"; import { buildSignedDeviceManifest, signDeviceRevocation, verifyDeviceManifest, verifyDeviceRevocation, } from "../shared/device-manifest"; import { constantTimeEqualHex } from "../shared/constant-time"; import { applyMergedRemoteEntries, getTombstoneManifest, getRootKeyHex, getEntries, getEntryById, getVaultVersion, isUnlocked, isDecoyMode, } from "./vault"; import { findPairedDeviceById, findPairedDeviceByPubkey, isDeviceRevoked, listDeviceRevocations, listPairedDevices, upsertPairedDevicesAndMergeRevocations, } from "./paired-devices"; import { setPairSecret } from "./pair-secrets"; import { removePendingVouchedDevice, } from "./pending-vouched-devices"; import { bytesToHex as rendezvousBytesToHex, deriveMeshRendezvousSecret, normalizeRelayUrl, } from "../shared/rendezvous"; import { recordSyncAlerts } from "./sync-alerts"; import { SYNC_LIMITS, clampSyncTimestamp } from "../shared/sync-limits"; import type { VaultEntry } from "../shared/types"; import { MAX_VOUCH_DEPTH, type ManifestDeviceEntry, type PairedDevice, type SignedDeviceManifest, } from "../shared/sync-types"; import { replaceLockedSyncSpool, drainLockedSyncInbox } from "./locked-sync-spool"; // Each entry uses a unique AES-GCM key: HKDF(masterKey, info = entryId + "\x00" + "sync-entry-v1"). // The info binding prevents a ciphertext from one entry slot being replayed into another. const ENTRY_KEY_INFO_SUFFIX = "sync-entry-v1"; const ENTRY_AAD = "peach-sync-entry-v1"; export class VaultLockedError extends Error { constructor() { super("vault_locked"); this.name = "VaultLockedError"; } } export function createSyncVaultAdapter( _context: { deviceName?: string } = {}, ): SyncVaultAdapter { return { async computeVaultId() { const master = requireMasterKey(); return computeVaultId(master); }, async getOurDeviceId() { return getOrCreateDeviceId(); }, async getLocalVersion() { return getVaultVersion(); }, async buildEntryManifest() { requireRealUnlocked(); const manifest: Record = {}; for (const entry of getEntries()) { manifest[entry.id] = entry.modifiedAt; } return manifest; }, async buildTombstoneManifest() { requireRealUnlocked(); return getTombstoneManifest(); }, async getEncryptedEntries(entryIds) { requireRealUnlocked(); const masterKey = requireMasterKey(); const out: Record = {}; for (const id of entryIds) { const entry = getEntryById(id); if (!entry) continue; const key = await deriveEntryKey(masterKey, id); out[id] = await encryptEntry(key, entry); } return out; }, async applyEntries(entries) { requireRealUnlocked(); const entryList = Object.entries(entries); if (entryList.length > SYNC_LIMITS.MAX_ENTRY_COUNT) { throw new Error( `sync entry count ${entryList.length} exceeds limit ${SYNC_LIMITS.MAX_ENTRY_COUNT}`, ); } const masterKey = requireMasterKey(); const decrypted: VaultEntry[] = []; for (const [id, envelope] of entryList) { if (envelope.length > (SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3 + 64) continue; const key = await deriveEntryKey(masterKey, id); const entry = await decryptEntryOrNull(key, envelope); if (!entry) continue; if (JSON.stringify(entry).length > SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES) continue; if (entry.id !== id) continue; decrypted.push(entry); } if (decrypted.length > 0) { requireRealUnlocked(); await applyMergedRemoteEntries(decrypted, {}); } }, async applyTombstones(tombstones) { requireRealUnlocked(); const tombstoneList = Object.entries(tombstones); if (tombstoneList.length > SYNC_LIMITS.MAX_TOMBSTONE_COUNT) { throw new Error( `sync tombstone count ${tombstoneList.length} exceeds limit ${SYNC_LIMITS.MAX_TOMBSTONE_COUNT}`, ); } const cleaned: Record = {}; for (const [id, ts] of tombstoneList) { if (typeof ts === "number" && Number.isFinite(ts) && ts > 0) { cleaned[id] = clampSyncTimestamp(ts); } } if (Object.keys(cleaned).length > 0) { requireRealUnlocked(); await applyMergedRemoteEntries([], cleaned); } }, async buildOutgoingDeviceManifest() { requireRealUnlocked(); const local = await getLocalPairingIdentity(); try { const issuerDeviceId = local.identity.deviceId; const revoked = await listDeviceRevocations(); const revokedIds = new Set(revoked.map((r) => r.deviceId)); const devices = (await listPairedDevices()) .filter((d) => !revokedIds.has(d.deviceId)) .map(toManifestEntry) .filter((d): d is ManifestDeviceEntry => d !== null); const signedRevoked = ( await Promise.all(revoked.map(async (record) => { if (record.signatureHex) { // A terminal record may have been issued by another mesh member, // and is re-emitted only when one of its own issuer's trusted keys // verifies it. The record names no key of its own, so the issuer // is resolved here and nowhere else. const issuer = await findPairedDeviceById(record.revokedByDeviceId); for (const key of trustedSigningKeys(issuer)) { if (await verifyDeviceRevocation(record, hexToPubkey(key.publicKeyHex))) return record; } // Unverifiable — typically signed under a superseded canonical — // is dropped from the manifest rather than poisoning every // receiver. The local terminal revocation itself is retained. return null; } // Only this device may mint a new signature; never re-issue a // record attributed to a different issuer as our own. if (record.revokedByDeviceId !== issuerDeviceId) return null; return signDeviceRevocation(record, local.signingPrivateKey); })) ).filter((record): record is DeviceRevocation => record !== null); return buildSignedDeviceManifest( issuerDeviceId, devices, local.signingPrivateKey, { revoked: signedRevoked }, ); } finally { local.privateKey.fill(0); local.publicKey.fill(0); } }, async applyIncomingDeviceManifest(manifest, authenticatedRemoteStaticPublicKey) { requireRealUnlocked(); await applyVerifiedPeerManifest( manifest, authenticatedRemoteStaticPublicKey, ); }, }; } export async function refreshLockedSyncSpool(): Promise { requireUnlocked(); const masterKey = requireMasterKey(); const deviceId = await getOrCreateDeviceId(); const identity = await buildCachedIdentity(masterKey, deviceId); const cachedEntries: Record = {}; try { for (const entry of getEntries()) { if (entry.deletedAt) continue; const key = await deriveEntryKey(masterKey, entry.id); cachedEntries[entry.id] = { modifiedAt: entry.modifiedAt, ciphertext: await encryptEntry(key, entry), }; } const tombstones = getTombstoneManifest(); // PB-26-104: the private key rides along because locked sync needs it, but // the spool is written to chrome.storage.session — never to disk. See // locked-sync-spool.ts for why session storage is the only home that // satisfies both constraints. const { longTermPrivateKeyBytes: _privateKeyBytes, ...storedIdentity } = identity; await replaceLockedSyncSpool({ ...storedIdentity, localVersion: await getVaultVersion(), entries: cachedEntries, tombstones, }); } finally { masterKey.fill(0); identity.longTermPrivateKeyBytes.fill(0); } } export async function drainAndRefreshLockedSyncSpool(): Promise { const live = createSyncVaultAdapter(); await drainLockedSyncInbox(live); await refreshLockedSyncSpool(); } async function buildCachedIdentity(masterKey: Uint8Array, _deviceId: string) { const vaultId = await computeVaultId(masterKey); const pairing = await getLocalPairingIdentity(); const deviceName = await getDeviceName(); return { vaultId, deviceId: pairing.identity.deviceId, deviceName, deviceType: "extension" as const, longTermPublicKeyHex: pubkeyToHex(pairing.publicKey), longTermPrivateKeyHex: bytesToHex(pairing.privateKey), longTermPrivateKeyBytes: pairing.privateKey, }; } function toManifestEntry(device: PairedDevice): ManifestDeviceEntry | null { if ( !device.deviceId || !device.deviceName || !device.deviceType || !device.longTermPublicKeyHex || !/^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex) || !device.signingPublicKeyHex || !/^[0-9a-f]{64}$/.test(device.signingPublicKeyHex) ) { return null; } return { deviceId: device.deviceId, deviceName: device.deviceName, deviceType: device.deviceType, longTermPublicKeyHex: device.longTermPublicKeyHex, signingPublicKeyHex: device.signingPublicKeyHex, wsPort: device.wsPort, relayUrl: device.relayUrl, pairedAt: device.pairedAt, vouchDepth: device.vouchDepth ?? 0, }; } /** * Every signing key we currently trust for a peer: its immutable history plus * its active key. A forwarded revocation is authorised only by one of these, * resolved from `revokedByDeviceId`. The record carries no key of its own, so a * peer that merely relays a manifest cannot authorise a revocation. */ function trustedSigningKeys( device: | { signingPublicKeyHex?: string; signingKeyHistory?: Array<{ version: number; publicKeyHex: string }>; } | null | undefined, ): Array<{ version: number; publicKeyHex: string }> { const historical = (device?.signingKeyHistory ?? []) .filter( (key) => Number.isInteger(key.version) && key.version > 0 && /^[0-9a-f]{64}$/i.test(key.publicKeyHex), ) .map((key) => ({ version: key.version, publicKeyHex: key.publicKeyHex.toLowerCase() })); const current = device?.signingPublicKeyHex?.toLowerCase(); if (!current || !/^[0-9a-f]{64}$/.test(current)) return historical; const currentVersion = historical.find((key) => key.publicKeyHex === current)?.version ?? historical.reduce((max, key) => Math.max(max, key.version), 0) + 1; const all = [...historical, { version: currentVersion, publicKeyHex: current }]; return all.filter( (key, index) => all.findIndex( (candidate) => candidate.version === key.version && candidate.publicKeyHex === key.publicKeyHex, ) === index, ); } /** * Apply a device manifest issued by a peer we pair with directly. * * The issuer used to be required to be an "extension", on the assumption that * the extension was the mesh's trust anchor. It is not: the device a fresh * install joins through is the one holding the peer list to hand over. * * The issuer must already be admitted locally as either direct or vouched. * That is the decentralisation boundary: an unknown key has no session and no * authority, while every authenticated member can propagate the same signed * pool without the original hub online. A v3 entry is admitted only when it * carries a valid relay route; both endpoints independently derive the same * pair-only capability from their vault root and public keys. */ async function applyVerifiedPeerManifest( manifest: SignedDeviceManifest, authenticatedRemoteStaticPublicKey: Uint8Array, ): Promise { const issuerId = manifest?.body?.issuerDeviceId; if (typeof issuerId !== "string" || !issuerId) { return; } if (authenticatedRemoteStaticPublicKey.length !== 32) { return; } const authenticatedPubkeyHex = pubkeyToHex(authenticatedRemoteStaticPublicKey); const issuer = await findPairedDeviceByPubkey(authenticatedPubkeyHex); if ( !issuer || issuer.deviceId !== issuerId || ( issuer.trustSource !== "direct" && issuer.trustSource !== "vouched" ) || (issuer.vouchDepth ?? 0) >= MAX_VOUCH_DEPTH || !issuer.signingPublicKeyHex || !/^[0-9a-f]{64}$/.test(issuer.signingPublicKeyHex) ) { return; } let body: DeviceManifestBody | undefined; let selectedSigningKey: { version: number; publicKeyHex: string } | undefined; const issuerSigningPublicKeyHex = issuer.signingPublicKeyHex; const historicalKeys = (issuer.signingKeyHistory ?? []) .filter((key) => Number.isInteger(key.version) && key.version > 0 && /^[0-9a-f]{64}$/i.test(key.publicKeyHex)) .map((key) => ({ version: key.version, publicKeyHex: key.publicKeyHex.toLowerCase() })); const currentVersion = historicalKeys.find( (key) => key.publicKeyHex === issuerSigningPublicKeyHex.toLowerCase(), )?.version ?? (historicalKeys.reduce((max, key) => Math.max(max, key.version), 0) + 1); const signingKeys = [ ...historicalKeys, { version: currentVersion, publicKeyHex: issuerSigningPublicKeyHex.toLowerCase() }, ].filter((key, index, keys) => keys.findIndex((candidate) => candidate.version === key.version && candidate.publicKeyHex === key.publicKeyHex) === index, ); try { for (const key of signingKeys) { try { body = await verifyDeviceManifest(manifest, hexToPubkey(key.publicKeyHex)); selectedSigningKey = key; break; } catch { /* try the next immutable trusted key */ } } if (!body || !selectedSigningKey) return; } catch { return; } if (body.issuerDeviceId !== issuer.deviceId) { return; } // Fail closed: an unresolvable or unverifiable issuer rejects the whole // manifest rather than dropping just that record, so a pruned history entry // can never silently un-revoke a device. Android enforces the same rule. const allRevocationsAuthorised = ( await Promise.all( body.revoked.map(async (record) => { // The outer manifest is authenticated by the directly connected // forwarder, but a forwarded revocation retains its original issuer. // Resolve that issuer independently from our paired registry and // immutable signing-key history; never bind it to the forwarder's key. const originalIssuer = await findPairedDeviceById(record.revokedByDeviceId); for (const key of trustedSigningKeys(originalIssuer)) { if (await verifyDeviceRevocation(record, hexToPubkey(key.publicKeyHex))) return true; } return false; }), ) ).every(Boolean); if (!allRevocationsAuthorised) return; const ourDeviceId = await getOrCreateDeviceId(); const masterKey = requireMasterKey(); const localPairing = await getLocalPairingIdentity(); const localKeys = { publicKey: localPairing.publicKey, secretKey: localPairing.privateKey }; try { const pendingRegistry: PairedDevice[] = []; const pendingSecrets: { deviceId: string; secret: string }[] = []; const pendingVouchedIds: string[] = []; for (const entry of body.devices) { if (entry.deviceId === ourDeviceId || entry.deviceId === issuer.deviceId) continue; if (await isDeviceRevoked(entry.deviceId, entry.longTermPublicKeyHex)) continue; if ((entry.vouchDepth ?? 0) > (issuer.vouchDepth ?? 0) + 1) continue; const existing = await findPairedDeviceById(entry.deviceId); const meshRoute = deriveManifestMeshRoute(localKeys.secretKey, localKeys.publicKey, entry); if (!existing) { if (!meshRoute) continue; pendingRegistry.push({ deviceId: entry.deviceId, deviceName: entry.deviceName, deviceType: entry.deviceType, longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(), signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(), wsPort: entry.wsPort, relayUrl: meshRoute.relayUrl, pairedAt: entry.pairedAt, trustSource: "vouched", vouchDepth: (issuer.vouchDepth ?? 0) + 1, }); pendingSecrets.push({ deviceId: entry.deviceId, secret: meshRoute.rendezvousSecretHex }); pendingVouchedIds.push(entry.deviceId); continue; } const longTermChanged = existing.longTermPublicKeyHex && !constantTimeEqualHex(existing.longTermPublicKeyHex, entry.longTermPublicKeyHex); const existingSigning = existing.signingPublicKeyHex; const entrySigning = entry.signingPublicKeyHex; const signingChanged = Boolean(existingSigning) !== Boolean(entrySigning) || (existingSigning !== undefined && entrySigning !== undefined && !constantTimeEqualHex(existingSigning, entrySigning)); if (longTermChanged || signingChanged) { const knownName = existing.deviceName ?? entry.deviceName; notifyPopup({ type: "DEVICE_KEY_ROTATED", payload: { deviceName: knownName, deviceId: entry.deviceId } }); void recordSyncAlerts([{ type: "DEVICE_KEY_ROTATED", entryId: "", entryTitle: knownName, deviceName: knownName, timestamp: Date.now(), }]); continue; } pendingRegistry.push({ ...existing, deviceId: entry.deviceId, deviceName: existing.deviceName ?? entry.deviceName, deviceType: entry.deviceType, longTermPublicKeyHex: existing.longTermPublicKeyHex ?? entry.longTermPublicKeyHex.toLowerCase(), signingPublicKeyHex: existing.signingPublicKeyHex ?? entry.signingPublicKeyHex.toLowerCase(), pairedAt: existing.pairedAt ?? entry.pairedAt, trustSource: existing.trustSource, wsPort: existing.wsPort ?? entry.wsPort, relayUrl: existing.trustSource === "vouched" && meshRoute ? meshRoute.relayUrl : existing.relayUrl, lastSyncedAt: existing.lastSyncedAt, }); if (existing.trustSource === "vouched" && meshRoute) { pendingSecrets.push({ deviceId: entry.deviceId, secret: meshRoute.rendezvousSecretHex }); } } const newlyRevoked = await upsertPairedDevicesAndMergeRevocations( pendingRegistry, body.revoked.filter((r) => r.deviceId !== ourDeviceId), ); for (const deviceId of newlyRevoked) { await removePendingVouchedDevice(deviceId); void recordSyncAlerts([{ type: "DEVICE_REVOKED", entryId: "", entryTitle: deviceId, deviceName: issuer.deviceName ?? "paired device", timestamp: Date.now(), }]); } for (const pending of pendingSecrets) { if (newlyRevoked.includes(pending.deviceId)) continue; await setPairSecret(pending.deviceId, pending.secret, masterKey); } for (const deviceId of pendingVouchedIds) { if (newlyRevoked.includes(deviceId)) continue; await removePendingVouchedDevice(deviceId); } } finally { masterKey.fill(0); localKeys.secretKey.fill(0); localKeys.publicKey.fill(0); } } function deriveManifestMeshRoute( localPrivateKey: Uint8Array, localPublicKey: Uint8Array, entry: ManifestDeviceEntry, ): { rendezvousSecretHex: string; relayUrl: string } | null { try { if (!entry.relayUrl) return null; const relayUrl = normalizeRelayUrl(entry.relayUrl); const secret = deriveMeshRendezvousSecret( localPrivateKey, localPublicKey, hexToPubkey(entry.longTermPublicKeyHex), ); try { return { rendezvousSecretHex: rendezvousBytesToHex(secret), relayUrl, }; } finally { secret.fill(0); } } catch { return null; } } function notifyPopup(msg: { type: string; payload?: unknown }): void { try { void chrome.runtime.sendMessage(msg).catch(() => undefined); } catch { /* popup closed — the durable sync-alert queue remains authoritative */ } } function requireUnlocked(): void { if (!isUnlocked()) throw new VaultLockedError(); } function requireRealUnlocked(): void { if (!isUnlocked() || isDecoyMode()) throw new VaultLockedError(); } // Sync derives everything — vault_id, the Noise static key, the Ed25519 // manifest signing key, and the per-entry envelope keys — from the vault's // stable root key, NOT the password-derived key. This keeps paired devices' // identities and envelope keys constant across a master-password change on // either side (SEV-2-B); two paired devices with independently-rotated // passwords still share this key and can decrypt each other's entries. function requireMasterKey(): Uint8Array { if (!isUnlocked()) throw new VaultLockedError(); const hex = getRootKeyHex(); if (!hex) throw new VaultLockedError(); return hexToBytes(hex); } async function deriveEntryKey(masterKey: Uint8Array, entryId: string): Promise { // Null-byte separator (HKDF hardening): entryId cannot contain \x00, so this // makes the (entryId, suffix) boundary unambiguous regardless of future id // formats. Coordinated wire change — peach-android VaultSyncAdapter derives // the identical info; the two MUST stay byte-for-byte in lockstep. const info = new TextEncoder().encode(`${entryId}\x00${ENTRY_KEY_INFO_SUFFIX}`); const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), info).expand(32); return crypto.subtle.importKey( "raw", raw.buffer as ArrayBuffer, { name: "AES-GCM" }, false, ["encrypt", "decrypt"], ); } async function encryptEntry(key: CryptoKey, entry: VaultEntry): Promise { const plaintext = new TextEncoder().encode(JSON.stringify(entry)); const nonce = crypto.getRandomValues(new Uint8Array(12)); const aad = new TextEncoder().encode(ENTRY_AAD); const ct = await crypto.subtle.encrypt( { name: "AES-GCM", iv: nonce.buffer as ArrayBuffer, additionalData: aad.buffer as ArrayBuffer, tagLength: 128, }, key, plaintext.buffer as ArrayBuffer, ); const ctBytes = new Uint8Array(ct); const envelope = new Uint8Array(nonce.length + ctBytes.length); envelope.set(nonce, 0); envelope.set(ctBytes, nonce.length); return bytesToBase64(envelope); } async function decryptEntryOrNull(key: CryptoKey, envelopeB64: string): Promise { try { const bytes = base64ToBytes(envelopeB64); if (bytes.length < 12 + 16) return null; const nonce = bytes.subarray(0, 12); const ct = bytes.subarray(12); const aad = new TextEncoder().encode(ENTRY_AAD); const pt = await crypto.subtle.decrypt( { name: "AES-GCM", iv: nonce.buffer.slice(nonce.byteOffset, nonce.byteOffset + nonce.byteLength) as ArrayBuffer, additionalData: aad.buffer as ArrayBuffer, tagLength: 128, }, key, ct.buffer.slice(ct.byteOffset, ct.byteOffset + ct.byteLength) as ArrayBuffer, ); const json = new TextDecoder().decode(pt); const parsed = JSON.parse(json); if (!parsed || typeof parsed !== "object" || typeof parsed.id !== "string") return null; return parsed as VaultEntry; } catch { return null; } } function hexToBytes(hex: string): Uint8Array { if (hex.length % 2 !== 0) throw new Error("hex length must be even"); const out = new Uint8Array(hex.length / 2); for (let i = 0; i < out.length; i++) { out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); } return out; } function bytesToBase64(bytes: Uint8Array): string { let bin = ""; for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); return btoa(bin); } function bytesToHex(bytes: Uint8Array): string { return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); } function base64ToBytes(b64: string): Uint8Array { const bin = atob(b64); const out = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); return out; }