// 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 { SyncVaultAdapter } from "./sync-coordinator"; import { getDeviceName, getOrCreateDeviceId } from "./device-identity"; import { computeVaultId, deriveSyncIdentityKeypair, deriveSyncSigningKeypair, hexToPubkey, pubkeyToHex, } from "../shared/sync-identity"; import { buildSignedDeviceManifest, verifyDeviceManifest, } from "../shared/device-manifest"; import { constantTimeEqualHex } from "../shared/constant-time"; import { applyMergedRemoteEntries, getTombstoneManifest, getRootKeyHex, getEntries, getEntryById, getVaultVersion, isUnlocked, } from "./vault"; import { findPairedDeviceById, findPairedDeviceByPubkey, isDeviceRevoked, listDeviceRevocations, listPairedDevices, mergeDeviceRevocations, removePairedDeviceById, upsertPairedDevice, } 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 type { ManifestDeviceEntry, PairedDevice, 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() { requireUnlocked(); const manifest: Record = {}; for (const entry of getEntries()) { manifest[entry.id] = entry.modifiedAt; } return manifest; }, async buildTombstoneManifest() { requireUnlocked(); return getTombstoneManifest(); }, async getEncryptedEntries(entryIds) { requireUnlocked(); 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) { requireUnlocked(); const entryList = Object.entries(entries); // MV-3: bound the batch before any per-entry crypto work. 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) { // MV-3: per-entry ceiling, checked on the (base64) envelope before // decrypting. Base64 inflates by 4/3, so allow that factor. if (envelope.length > (SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3 + 64) { continue; // oversized envelope — skip, never decrypt } const key = await deriveEntryKey(masterKey, id); const entry = await decryptEntryOrNull(key, envelope); if (!entry) continue; // MV-3: defense in depth — re-measure the decrypted plaintext. if (JSON.stringify(entry).length > SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES) { continue; } // The map key is the authority on id — refuse mismatched envelopes // so a swapped envelope can't poison a different entry slot. if (entry.id !== id) continue; decrypted.push(entry); } if (decrypted.length > 0) { await applyMergedRemoteEntries(decrypted, {}); } }, async applyTombstones(tombstones) { requireUnlocked(); const tombstoneList = Object.entries(tombstones); // MV-3: bound the tombstone batch before processing. 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) { // HV-1c: clamp before the value participates in any comparison. // vault.ts clamps again defensively; clamping here keeps the // invariant local to the boundary where peer data enters. cleaned[id] = clampSyncTimestamp(ts); } } if (Object.keys(cleaned).length > 0) { await applyMergedRemoteEntries([], cleaned); } }, async buildOutgoingDeviceManifest() { requireUnlocked(); const masterKey = requireMasterKey(); const issuerDeviceId = await getOrCreateDeviceId(); const signing = await deriveSyncSigningKeypair(masterKey, issuerDeviceId); 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); return buildSignedDeviceManifest( issuerDeviceId, devices, signing.privateKey, { revoked }, ); }, async applyIncomingDeviceManifest(manifest, authenticatedRemoteStaticPublicKey) { requireUnlocked(); 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 keys = deriveSyncIdentityKeypair(masterKey, deviceId); const deviceName = await getDeviceName(); return { vaultId, deviceId, deviceName, deviceType: "extension" as const, longTermPublicKeyHex: pubkeyToHex(keys.publicKey), longTermPrivateKeyHex: bytesToHex(keys.secretKey), longTermPrivateKeyBytes: keys.secretKey, }; } 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, }; } /** * 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.signingPublicKeyHex || !/^[0-9a-f]{64}$/.test(issuer.signingPublicKeyHex) ) { return; } let body: Awaited>; try { body = await verifyDeviceManifest( manifest, hexToPubkey(issuer!.signingPublicKeyHex!), ); } catch { // Stored key may be stale (older builds persisted the key derived at // first-pair and never refreshed it). The signing keypair is deterministic // from (masterKey, deviceId) on both sides, so retry once with the locally // re-derived pubkey. A genuinely forged signature throws here and is // silently rejected — only a stale-but-correct key survives the retry. const masterForIssuer = requireMasterKey(); try { const derived = await deriveSyncSigningKeypair( masterForIssuer, issuer.deviceId, ); body = await verifyDeviceManifest(manifest, derived.publicKeyRaw); } catch { return; } finally { masterForIssuer.fill(0); } } if (body.issuerDeviceId !== issuer.deviceId) { return; } const ourDeviceId = await getOrCreateDeviceId(); const masterKey = requireMasterKey(); const localKeys = deriveSyncIdentityKeypair(masterKey, ourDeviceId); try { // Revocations are absorbed BEFORE any vouching, so a manifest that both // revokes and re-vouches the same device in one payload cannot race itself. // A peer may not revoke US — that would let one compromised device sever the // whole mesh — so our own id is filtered out first. const newlyRevoked = await mergeDeviceRevocations( body.revoked.filter((r) => r.deviceId !== ourDeviceId), ); for (const deviceId of newlyRevoked) { await removePairedDeviceById(deviceId); await removePendingVouchedDevice(deviceId); void recordSyncAlerts([ { type: "DEVICE_REVOKED", entryId: "", entryTitle: deviceId, deviceName: issuer.deviceName ?? "paired device", timestamp: Date.now(), }, ]); } for (const entry of body.devices) { if (entry.deviceId === ourDeviceId || entry.deviceId === issuer.deviceId) { continue; } // Terminal: no manifest re-admits a revoked device. Matched on the key as // well as the id, because the id is the issuer's to choose — checking only // the id let a revoked key walk back in under a fresh one. The check sits // ahead of the existing-device lookup so it also refuses rotating a device // we still trust onto a revoked key. if (await isDeviceRevoked(entry.deviceId, entry.longTermPublicKeyHex)) { continue; } const existing = await findPairedDeviceById(entry.deviceId); const meshRoute = deriveManifestMeshRoute( masterKey, localKeys.publicKey, entry, ); // A v3 manifest from an admitted, KK-authenticated issuer proves // same-vault membership and carries enough route material for the two // introduced endpoints to derive their own capability. Admit it // immediately so a six-device vault converges without keeping the // original pairing hub online. if (!existing) { if (!meshRoute) continue; await upsertPairedDevice({ 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", }); // PB-26-213: the secret bypasses the public registry into the // encrypted pair-secret record. await setPairSecret(entry.deviceId, meshRoute.rendezvousSecretHex, masterKey); await removePendingVouchedDevice(entry.deviceId); continue; } // MV-8d: a key changing under a stable deviceId is legitimate after a // reinstall, but it is exactly what a graph-poisoning attack looks like — // signal the user either way, durably (the alert queue survives a closed // popup) and live (the broadcast updates an open one). if ( existing.longTermPublicKeyHex && !constantTimeEqualHex( existing.longTermPublicKeyHex, entry.longTermPublicKeyHex, ) ) { // S5: name the device from the LOCAL record, never from the manifest. // The issuer is a third party here (its own entry is skipped above), so // `entry.deviceName` is an unverified claim about someone else — an // attacker who could put it on screen could make its own key rotation // read as a rotation on a device the user trusts more. 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(), }, ]); } // Existing device — update in place as before. The name is deliberately // NOT taken from the manifest: a device's name is set by that device at // pairing, and letting a third-party issuer rewrite it is the rename-then- // impersonate half of S5. Self-renames never arrive here anyway. await upsertPairedDevice({ ...existing, deviceId: entry.deviceId, deviceName: existing.deviceName ?? entry.deviceName, deviceType: entry.deviceType, longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(), 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) { await setPairSecret(entry.deviceId, meshRoute.rendezvousSecretHex, masterKey); } } } finally { masterKey.fill(0); localKeys.secretKey.fill(0); } } function deriveManifestMeshRoute( masterKey: Uint8Array, localPublicKey: Uint8Array, entry: ManifestDeviceEntry, ): { rendezvousSecretHex: string; relayUrl: string } | null { try { if (!entry.relayUrl) return null; const relayUrl = normalizeRelayUrl(entry.relayUrl); const secret = deriveMeshRendezvousSecret( masterKey, 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(); } // 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; }