12345678910111213141516171819202122232425import { HKDF } from "@stablelib/hkdf";26import { SHA256 } from "@stablelib/sha256";27import type { DeviceManifestBody, DeviceRevocation } from "../shared/sync-types";28import type { SyncVaultAdapter } from "./sync-coordinator";29import { getDeviceName, getLocalPairingIdentity, getOrCreateDeviceId } from "./device-identity";30import {31 computeVaultId,32 hexToPubkey,33 pubkeyToHex,34} from "../shared/sync-identity";35import {36 buildSignedDeviceManifest,37 signDeviceRevocation,38 verifyDeviceManifest,39 verifyDeviceRevocation,40} from "../shared/device-manifest";41import { constantTimeEqualHex } from "../shared/constant-time";42import {43 applyMergedRemoteEntries,44 getTombstoneManifest,45 getRootKeyHex,46 getEntries,47 getEntryById,48 getVaultVersion,49 isUnlocked,50 isDecoyMode,51 ensureSettingsLoaded,52 lockedSyncRequiresUnlock,53} from "./vault";54import {55 findPairedDeviceById,56 findPairedDeviceByPubkey,57 isDeviceRevoked,58 listDeviceRevocations,59 listPairedDevices,60 upsertPairedDevicesAndMergeRevocations,61} from "./paired-devices";62import { setPairSecret } from "./pair-secrets";63import {64 removePendingVouchedDevice,65} from "./pending-vouched-devices";66import {67 bytesToHex as rendezvousBytesToHex,68 deriveMeshRendezvousSecret,69 normalizeRelayUrl,70} from "../shared/rendezvous";71import { recordSyncAlerts } from "./sync-alerts";72import { SYNC_LIMITS, clampSyncTimestamp } from "../shared/sync-limits";73import type { VaultEntry } from "../shared/types";74import {75 MAX_VOUCH_DEPTH,76 type ManifestDeviceEntry,77 type PairedDevice,78 type SignedDeviceManifest,79} from "../shared/sync-types";80import { replaceLockedSyncSpool, drainLockedSyncInbox, setLockedSyncPrivateKey } from "./locked-sync-spool";81828384const ENTRY_KEY_INFO_SUFFIX = "sync-entry-v1";85const ENTRY_AAD = "peach-sync-entry-v1";8687export class VaultLockedError extends Error {88 constructor() {89 super("vault_locked");90 this.name = "VaultLockedError";91 }92}9394export function createSyncVaultAdapter(95 _context: { deviceName?: string } = {},96): SyncVaultAdapter {97 return {98 async computeVaultId() {99 const master = requireMasterKey();100 return computeVaultId(master);101 },102103 async getOurDeviceId() {104 return getOrCreateDeviceId();105 },106107 async getLocalVersion() {108 return getVaultVersion();109 },110111 async buildEntryManifest() {112 requireRealUnlocked();113 const manifest: Record<string, number> = {};114 for (const entry of getEntries()) {115 manifest[entry.id] = entry.modifiedAt;116 }117 return manifest;118 },119120 async buildTombstoneManifest() {121 requireRealUnlocked();122 return getTombstoneManifest();123 },124125 async getEncryptedEntries(entryIds) {126 requireRealUnlocked();127 const masterKey = requireMasterKey();128 const out: Record<string, string> = {};129 for (const id of entryIds) {130 const entry = getEntryById(id);131 if (!entry) continue;132 const key = await deriveEntryKey(masterKey, id);133 out[id] = await encryptEntry(key, entry);134 }135 return out;136 },137138 async applyEntries(entries) {139 requireRealUnlocked();140 const entryList = Object.entries(entries);141 if (entryList.length > SYNC_LIMITS.MAX_ENTRY_COUNT) {142 throw new Error(143 `sync entry count ${entryList.length} exceeds limit ${SYNC_LIMITS.MAX_ENTRY_COUNT}`,144 );145 }146 const masterKey = requireMasterKey();147 const decrypted: VaultEntry[] = [];148 for (const [id, envelope] of entryList) {149 if (envelope.length > (SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3 + 64) continue;150 const key = await deriveEntryKey(masterKey, id);151 const entry = await decryptEntryOrNull(key, envelope);152 if (!entry) continue;153 if (JSON.stringify(entry).length > SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES) continue;154 if (entry.id !== id) continue;155 decrypted.push(entry);156 }157 if (decrypted.length > 0) {158 requireRealUnlocked();159 await applyMergedRemoteEntries(decrypted, {});160 }161 },162163 async applyTombstones(tombstones) {164 requireRealUnlocked();165 const tombstoneList = Object.entries(tombstones);166 if (tombstoneList.length > SYNC_LIMITS.MAX_TOMBSTONE_COUNT) {167 throw new Error(168 `sync tombstone count ${tombstoneList.length} exceeds limit ${SYNC_LIMITS.MAX_TOMBSTONE_COUNT}`,169 );170 }171 const cleaned: Record<string, number> = {};172 for (const [id, ts] of tombstoneList) {173 if (typeof ts === "number" && Number.isFinite(ts) && ts > 0) {174 cleaned[id] = clampSyncTimestamp(ts);175 }176 }177 if (Object.keys(cleaned).length > 0) {178 requireRealUnlocked();179 await applyMergedRemoteEntries([], cleaned);180 }181 },182183 async buildOutgoingDeviceManifest() {184 requireRealUnlocked();185 const local = await getLocalPairingIdentity();186 try {187 const issuerDeviceId = local.identity.deviceId;188 const revoked = await listDeviceRevocations();189 const revokedIds = new Set(revoked.map((r) => r.deviceId));190 const devices = (await listPairedDevices())191 .filter((d) => !revokedIds.has(d.deviceId))192 .map(toManifestEntry)193 .filter((d): d is ManifestDeviceEntry => d !== null);194 const signedRevoked = (195 await Promise.all(revoked.map(async (record) => {196 if (record.signatureHex) {197 198 199 200 201 const issuer = await findPairedDeviceById(record.revokedByDeviceId);202 for (const key of trustedSigningKeys(issuer)) {203 if (await verifyDeviceRevocation(record, hexToPubkey(key.publicKeyHex))) return record;204 }205 206 207 208 return null;209 }210 211 212 if (record.revokedByDeviceId !== issuerDeviceId) return null;213 return signDeviceRevocation(record, local.signingPrivateKey);214 }))215 ).filter((record): record is DeviceRevocation => record !== null);216 return buildSignedDeviceManifest(217 issuerDeviceId,218 devices,219 local.signingPrivateKey,220 { revoked: signedRevoked },221 );222 } finally {223 local.privateKey.fill(0);224 local.publicKey.fill(0);225 }226 },227228 async applyIncomingDeviceManifest(manifest, authenticatedRemoteStaticPublicKey) {229 requireRealUnlocked();230 await applyVerifiedPeerManifest(231 manifest,232 authenticatedRemoteStaticPublicKey,233 );234 },235 };236}237238export async function refreshLockedSyncSpool(): Promise<void> {239 requireUnlocked();240 const masterKey = requireMasterKey();241 const deviceId = await getOrCreateDeviceId();242 const identity = await buildCachedIdentity(masterKey, deviceId);243 const cachedEntries: Record<string, { modifiedAt: number; ciphertext: string }> = {};244 try {245 for (const entry of getEntries()) {246 if (entry.deletedAt) continue;247 const key = await deriveEntryKey(masterKey, entry.id);248 cachedEntries[entry.id] = {249 modifiedAt: entry.modifiedAt,250 ciphertext: await encryptEntry(key, entry),251 };252 }253 const tombstones = getTombstoneManifest();254 await ensureSettingsLoaded();255 await setLockedSyncPrivateKey(identity.longTermPrivateKeyBytes, !lockedSyncRequiresUnlock());256 const { longTermPrivateKeyHex: _privateKeyHex, longTermPrivateKeyBytes: _privateKeyBytes, ...storedIdentity } = identity;257 await replaceLockedSyncSpool({258 ...storedIdentity,259 localVersion: await getVaultVersion(),260 entries: cachedEntries,261 tombstones,262 });263 } finally {264 masterKey.fill(0);265 identity.longTermPrivateKeyBytes.fill(0);266 }267}268269export async function drainAndRefreshLockedSyncSpool(): Promise<void> {270 const live = createSyncVaultAdapter();271 await drainLockedSyncInbox(live);272 await refreshLockedSyncSpool();273}274275async function buildCachedIdentity(masterKey: Uint8Array, _deviceId: string) {276 const vaultId = await computeVaultId(masterKey);277 const pairing = await getLocalPairingIdentity();278 const deviceName = await getDeviceName();279 return {280 vaultId,281 deviceId: pairing.identity.deviceId,282 deviceName,283 deviceType: "extension" as const,284 longTermPublicKeyHex: pubkeyToHex(pairing.publicKey),285 longTermPrivateKeyHex: bytesToHex(pairing.privateKey),286 longTermPrivateKeyBytes: pairing.privateKey,287 };288}289290function toManifestEntry(device: PairedDevice): ManifestDeviceEntry | null {291 if (292 !device.deviceId ||293 !device.deviceName ||294 !device.deviceType ||295 !device.longTermPublicKeyHex ||296 !/^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex) ||297 !device.signingPublicKeyHex ||298 !/^[0-9a-f]{64}$/.test(device.signingPublicKeyHex)299 ) {300 return null;301 }302 return {303 deviceId: device.deviceId,304 deviceName: device.deviceName,305 deviceType: device.deviceType,306 longTermPublicKeyHex: device.longTermPublicKeyHex,307 signingPublicKeyHex: device.signingPublicKeyHex,308 wsPort: device.wsPort,309 relayUrl: device.relayUrl,310 pairedAt: device.pairedAt,311 vouchDepth: device.vouchDepth ?? 0,312 };313}314315316317318319320321function trustedSigningKeys(322 device:323 | {324 signingPublicKeyHex?: string;325 signingKeyHistory?: Array<{ version: number; publicKeyHex: string }>;326 }327 | null328 | undefined,329): Array<{ version: number; publicKeyHex: string }> {330 const historical = (device?.signingKeyHistory ?? [])331 .filter(332 (key) =>333 Number.isInteger(key.version) &&334 key.version > 0 &&335 /^[0-9a-f]{64}$/i.test(key.publicKeyHex),336 )337 .map((key) => ({ version: key.version, publicKeyHex: key.publicKeyHex.toLowerCase() }));338 const current = device?.signingPublicKeyHex?.toLowerCase();339 if (!current || !/^[0-9a-f]{64}$/.test(current)) return historical;340 const currentVersion =341 historical.find((key) => key.publicKeyHex === current)?.version ??342 historical.reduce((max, key) => Math.max(max, key.version), 0) + 1;343 const all = [...historical, { version: currentVersion, publicKeyHex: current }];344 return all.filter(345 (key, index) =>346 all.findIndex(347 (candidate) =>348 candidate.version === key.version && candidate.publicKeyHex === key.publicKeyHex,349 ) === index,350 );351}352353354355356357358359360361362363364365366367async function applyVerifiedPeerManifest(368 manifest: SignedDeviceManifest,369 authenticatedRemoteStaticPublicKey: Uint8Array,370): Promise<void> {371 const issuerId = manifest?.body?.issuerDeviceId;372 if (typeof issuerId !== "string" || !issuerId) { return; }373374 if (authenticatedRemoteStaticPublicKey.length !== 32) {375 return;376 }377 const authenticatedPubkeyHex = pubkeyToHex(authenticatedRemoteStaticPublicKey);378 const issuer = await findPairedDeviceByPubkey(authenticatedPubkeyHex);379 if (380 !issuer ||381 issuer.deviceId !== issuerId ||382 (383 issuer.trustSource !== "direct" &&384 issuer.trustSource !== "vouched"385 ) ||386 (issuer.vouchDepth ?? 0) >= MAX_VOUCH_DEPTH ||387 !issuer.signingPublicKeyHex ||388 !/^[0-9a-f]{64}$/.test(issuer.signingPublicKeyHex)389 ) {390 return;391 }392393 let body: DeviceManifestBody | undefined;394 let selectedSigningKey: { version: number; publicKeyHex: string } | undefined;395 const issuerSigningPublicKeyHex = issuer.signingPublicKeyHex;396 const historicalKeys = (issuer.signingKeyHistory ?? [])397 .filter((key) => Number.isInteger(key.version) && key.version > 0 && /^[0-9a-f]{64}$/i.test(key.publicKeyHex))398 .map((key) => ({ version: key.version, publicKeyHex: key.publicKeyHex.toLowerCase() }));399 const currentVersion = historicalKeys.find(400 (key) => key.publicKeyHex === issuerSigningPublicKeyHex.toLowerCase(),401 )?.version ?? (historicalKeys.reduce((max, key) => Math.max(max, key.version), 0) + 1);402 const signingKeys = [403 ...historicalKeys,404 { version: currentVersion, publicKeyHex: issuerSigningPublicKeyHex.toLowerCase() },405 ].filter((key, index, keys) =>406 keys.findIndex((candidate) => candidate.version === key.version && candidate.publicKeyHex === key.publicKeyHex) === index,407 );408 try {409 for (const key of signingKeys) {410 try {411 body = await verifyDeviceManifest(manifest, hexToPubkey(key.publicKeyHex));412 selectedSigningKey = key;413 break;414 } catch { }415 }416 if (!body || !selectedSigningKey) return;417 } catch {418 return;419 }420 if (body.issuerDeviceId !== issuer.deviceId) {421 return;422 }423 424 425 426 const allRevocationsAuthorised = (427 await Promise.all(428 body.revoked.map(async (record) => {429 430 431 432 433 const originalIssuer = await findPairedDeviceById(record.revokedByDeviceId);434 for (const key of trustedSigningKeys(originalIssuer)) {435 if (await verifyDeviceRevocation(record, hexToPubkey(key.publicKeyHex))) return true;436 }437 return false;438 }),439 )440 ).every(Boolean);441 if (!allRevocationsAuthorised) return;442443 const ourDeviceId = await getOrCreateDeviceId();444 const masterKey = requireMasterKey();445 const localPairing = await getLocalPairingIdentity();446 const localKeys = { publicKey: localPairing.publicKey, secretKey: localPairing.privateKey };447 try {448 const pendingRegistry: PairedDevice[] = [];449 const pendingSecrets: { deviceId: string; secret: string }[] = [];450 const pendingVouchedIds: string[] = [];451 for (const entry of body.devices) {452 if (entry.deviceId === ourDeviceId || entry.deviceId === issuer.deviceId) continue;453 if (await isDeviceRevoked(entry.deviceId, entry.longTermPublicKeyHex)) continue;454 if ((entry.vouchDepth ?? 0) > (issuer.vouchDepth ?? 0) + 1) continue;455 const existing = await findPairedDeviceById(entry.deviceId);456 const meshRoute = deriveManifestMeshRoute(localKeys.secretKey, localKeys.publicKey, entry);457 if (!existing) {458 if (!meshRoute) continue;459 pendingRegistry.push({460 deviceId: entry.deviceId,461 deviceName: entry.deviceName,462 deviceType: entry.deviceType,463 longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),464 signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),465 wsPort: entry.wsPort,466 relayUrl: meshRoute.relayUrl,467 pairedAt: entry.pairedAt,468 trustSource: "vouched",469 vouchDepth: (issuer.vouchDepth ?? 0) + 1,470 });471 pendingSecrets.push({ deviceId: entry.deviceId, secret: meshRoute.rendezvousSecretHex });472 pendingVouchedIds.push(entry.deviceId);473 continue;474 }475 const longTermChanged = existing.longTermPublicKeyHex &&476 !constantTimeEqualHex(existing.longTermPublicKeyHex, entry.longTermPublicKeyHex);477 const existingSigning = existing.signingPublicKeyHex;478 const entrySigning = entry.signingPublicKeyHex;479 const signingChanged =480 Boolean(existingSigning) !== Boolean(entrySigning) ||481 (existingSigning !== undefined &&482 entrySigning !== undefined &&483 !constantTimeEqualHex(existingSigning, entrySigning));484 if (longTermChanged || signingChanged) {485 const knownName = existing.deviceName ?? entry.deviceName;486 notifyPopup({ type: "DEVICE_KEY_ROTATED", payload: { deviceName: knownName, deviceId: entry.deviceId } });487 void recordSyncAlerts([{488 type: "DEVICE_KEY_ROTATED",489 entryId: "",490 entryTitle: knownName,491 deviceName: knownName,492 timestamp: Date.now(),493 }]);494 continue;495 }496 pendingRegistry.push({497 ...existing,498 deviceId: entry.deviceId,499 deviceName: existing.deviceName ?? entry.deviceName,500 deviceType: entry.deviceType,501 longTermPublicKeyHex: existing.longTermPublicKeyHex ?? entry.longTermPublicKeyHex.toLowerCase(),502 signingPublicKeyHex: existing.signingPublicKeyHex ?? entry.signingPublicKeyHex.toLowerCase(),503 pairedAt: existing.pairedAt ?? entry.pairedAt,504 trustSource: existing.trustSource,505 wsPort: existing.wsPort ?? entry.wsPort,506 relayUrl: existing.trustSource === "vouched" && meshRoute ? meshRoute.relayUrl : existing.relayUrl,507 lastSyncedAt: existing.lastSyncedAt,508 });509 if (existing.trustSource === "vouched" && meshRoute) {510 pendingSecrets.push({ deviceId: entry.deviceId, secret: meshRoute.rendezvousSecretHex });511 }512 }513 const newlyRevoked = await upsertPairedDevicesAndMergeRevocations(514 pendingRegistry,515 body.revoked.filter((r) => r.deviceId !== ourDeviceId),516 );517 for (const deviceId of newlyRevoked) {518 await removePendingVouchedDevice(deviceId);519 void recordSyncAlerts([{520 type: "DEVICE_REVOKED",521 entryId: "",522 entryTitle: deviceId,523 deviceName: issuer.deviceName ?? "paired device",524 timestamp: Date.now(),525 }]);526 }527 for (const pending of pendingSecrets) {528 if (newlyRevoked.includes(pending.deviceId)) continue;529 await setPairSecret(pending.deviceId, pending.secret, masterKey);530 }531 for (const deviceId of pendingVouchedIds) {532 if (newlyRevoked.includes(deviceId)) continue;533 await removePendingVouchedDevice(deviceId);534 }535 } finally {536 masterKey.fill(0);537 localKeys.secretKey.fill(0);538 localKeys.publicKey.fill(0);539 }540}541542function deriveManifestMeshRoute(543 localPrivateKey: Uint8Array,544 localPublicKey: Uint8Array,545 entry: ManifestDeviceEntry,546): { rendezvousSecretHex: string; relayUrl: string } | null {547 try {548 if (!entry.relayUrl) return null;549 const relayUrl = normalizeRelayUrl(entry.relayUrl);550 const secret = deriveMeshRendezvousSecret(551 localPrivateKey,552 localPublicKey,553 hexToPubkey(entry.longTermPublicKeyHex),554 );555 try {556 return {557 rendezvousSecretHex: rendezvousBytesToHex(secret),558 relayUrl,559 };560 } finally {561 secret.fill(0);562 }563 } catch {564 return null;565 }566}567568function notifyPopup(msg: { type: string; payload?: unknown }): void {569 try {570 void chrome.runtime.sendMessage(msg).catch(() => undefined);571 } catch {572 573 }574}575576function requireUnlocked(): void {577 if (!isUnlocked()) throw new VaultLockedError();578}579580function requireRealUnlocked(): void {581 if (!isUnlocked() || isDecoyMode()) throw new VaultLockedError();582}583584585586587588589590function requireMasterKey(): Uint8Array {591 if (!isUnlocked()) throw new VaultLockedError();592 const hex = getRootKeyHex();593 if (!hex) throw new VaultLockedError();594 return hexToBytes(hex);595}596597async function deriveEntryKey(masterKey: Uint8Array, entryId: string): Promise<CryptoKey> {598 599 600 601 602 const info = new TextEncoder().encode(`${entryId}\x00${ENTRY_KEY_INFO_SUFFIX}`);603 const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), info).expand(32);604 return crypto.subtle.importKey(605 "raw",606 raw.buffer as ArrayBuffer,607 { name: "AES-GCM" },608 false,609 ["encrypt", "decrypt"],610 );611}612613async function encryptEntry(key: CryptoKey, entry: VaultEntry): Promise<string> {614 const plaintext = new TextEncoder().encode(JSON.stringify(entry));615 const nonce = crypto.getRandomValues(new Uint8Array(12));616 const aad = new TextEncoder().encode(ENTRY_AAD);617 const ct = await crypto.subtle.encrypt(618 {619 name: "AES-GCM",620 iv: nonce.buffer as ArrayBuffer,621 additionalData: aad.buffer as ArrayBuffer,622 tagLength: 128,623 },624 key,625 plaintext.buffer as ArrayBuffer,626 );627 const ctBytes = new Uint8Array(ct);628 const envelope = new Uint8Array(nonce.length + ctBytes.length);629 envelope.set(nonce, 0);630 envelope.set(ctBytes, nonce.length);631 return bytesToBase64(envelope);632}633634async function decryptEntryOrNull(key: CryptoKey, envelopeB64: string): Promise<VaultEntry | null> {635 try {636 const bytes = base64ToBytes(envelopeB64);637 if (bytes.length < 12 + 16) return null;638 const nonce = bytes.subarray(0, 12);639 const ct = bytes.subarray(12);640 const aad = new TextEncoder().encode(ENTRY_AAD);641 const pt = await crypto.subtle.decrypt(642 {643 name: "AES-GCM",644 iv: nonce.buffer.slice(nonce.byteOffset, nonce.byteOffset + nonce.byteLength) as ArrayBuffer,645 additionalData: aad.buffer as ArrayBuffer,646 tagLength: 128,647 },648 key,649 ct.buffer.slice(ct.byteOffset, ct.byteOffset + ct.byteLength) as ArrayBuffer,650 );651 const json = new TextDecoder().decode(pt);652 const parsed = JSON.parse(json);653 if (!parsed || typeof parsed !== "object" || typeof parsed.id !== "string") return null;654 return parsed as VaultEntry;655 } catch {656 return null;657 }658}659660function hexToBytes(hex: string): Uint8Array {661 if (hex.length % 2 !== 0) throw new Error("hex length must be even");662 const out = new Uint8Array(hex.length / 2);663 for (let i = 0; i < out.length; i++) {664 out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);665 }666 return out;667}668669function bytesToBase64(bytes: Uint8Array): string {670 let bin = "";671 for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);672 return btoa(bin);673}674675function bytesToHex(bytes: Uint8Array): string {676 return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");677}678679function base64ToBytes(b64: string): Uint8Array {680 const bin = atob(b64);681 const out = new Uint8Array(bin.length);682 for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);683 return out;684}