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} from "./vault";52import {53 findPairedDeviceById,54 findPairedDeviceByPubkey,55 isDeviceRevoked,56 listDeviceRevocations,57 listPairedDevices,58 upsertPairedDevicesAndMergeRevocations,59} from "./paired-devices";60import { setPairSecret } from "./pair-secrets";61import {62 removePendingVouchedDevice,63} from "./pending-vouched-devices";64import {65 bytesToHex as rendezvousBytesToHex,66 deriveMeshRendezvousSecret,67 normalizeRelayUrl,68} from "../shared/rendezvous";69import { recordSyncAlerts } from "./sync-alerts";70import { SYNC_LIMITS, clampSyncTimestamp } from "../shared/sync-limits";71import type { VaultEntry } from "../shared/types";72import {73 MAX_VOUCH_DEPTH,74 type ManifestDeviceEntry,75 type PairedDevice,76 type SignedDeviceManifest,77} from "../shared/sync-types";78import { replaceLockedSyncSpool, drainLockedSyncInbox } from "./locked-sync-spool";79808182const ENTRY_KEY_INFO_SUFFIX = "sync-entry-v1";83const ENTRY_AAD = "peach-sync-entry-v1";8485export class VaultLockedError extends Error {86 constructor() {87 super("vault_locked");88 this.name = "VaultLockedError";89 }90}9192export function createSyncVaultAdapter(93 _context: { deviceName?: string } = {},94): SyncVaultAdapter {95 return {96 async computeVaultId() {97 const master = requireMasterKey();98 return computeVaultId(master);99 },100101 async getOurDeviceId() {102 return getOrCreateDeviceId();103 },104105 async getLocalVersion() {106 return getVaultVersion();107 },108109 async buildEntryManifest() {110 requireRealUnlocked();111 const manifest: Record<string, number> = {};112 for (const entry of getEntries()) {113 manifest[entry.id] = entry.modifiedAt;114 }115 return manifest;116 },117118 async buildTombstoneManifest() {119 requireRealUnlocked();120 return getTombstoneManifest();121 },122123 async getEncryptedEntries(entryIds) {124 requireRealUnlocked();125 const masterKey = requireMasterKey();126 const out: Record<string, string> = {};127 for (const id of entryIds) {128 const entry = getEntryById(id);129 if (!entry) continue;130 const key = await deriveEntryKey(masterKey, id);131 out[id] = await encryptEntry(key, entry);132 }133 return out;134 },135136 async applyEntries(entries) {137 requireRealUnlocked();138 const entryList = Object.entries(entries);139 if (entryList.length > SYNC_LIMITS.MAX_ENTRY_COUNT) {140 throw new Error(141 `sync entry count ${entryList.length} exceeds limit ${SYNC_LIMITS.MAX_ENTRY_COUNT}`,142 );143 }144 const masterKey = requireMasterKey();145 const decrypted: VaultEntry[] = [];146 for (const [id, envelope] of entryList) {147 if (envelope.length > (SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3 + 64) continue;148 const key = await deriveEntryKey(masterKey, id);149 const entry = await decryptEntryOrNull(key, envelope);150 if (!entry) continue;151 if (JSON.stringify(entry).length > SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES) continue;152 if (entry.id !== id) continue;153 decrypted.push(entry);154 }155 if (decrypted.length > 0) {156 requireRealUnlocked();157 await applyMergedRemoteEntries(decrypted, {});158 }159 },160161 async applyTombstones(tombstones) {162 requireRealUnlocked();163 const tombstoneList = Object.entries(tombstones);164 if (tombstoneList.length > SYNC_LIMITS.MAX_TOMBSTONE_COUNT) {165 throw new Error(166 `sync tombstone count ${tombstoneList.length} exceeds limit ${SYNC_LIMITS.MAX_TOMBSTONE_COUNT}`,167 );168 }169 const cleaned: Record<string, number> = {};170 for (const [id, ts] of tombstoneList) {171 if (typeof ts === "number" && Number.isFinite(ts) && ts > 0) {172 cleaned[id] = clampSyncTimestamp(ts);173 }174 }175 if (Object.keys(cleaned).length > 0) {176 requireRealUnlocked();177 await applyMergedRemoteEntries([], cleaned);178 }179 },180181 async buildOutgoingDeviceManifest() {182 requireRealUnlocked();183 const local = await getLocalPairingIdentity();184 try {185 const issuerDeviceId = local.identity.deviceId;186 const revoked = await listDeviceRevocations();187 const revokedIds = new Set(revoked.map((r) => r.deviceId));188 const devices = (await listPairedDevices())189 .filter((d) => !revokedIds.has(d.deviceId))190 .map(toManifestEntry)191 .filter((d): d is ManifestDeviceEntry => d !== null);192 const signedRevoked = (193 await Promise.all(revoked.map(async (record) => {194 if (record.signatureHex) {195 196 197 198 199 const issuer = await findPairedDeviceById(record.revokedByDeviceId);200 for (const key of trustedSigningKeys(issuer)) {201 if (await verifyDeviceRevocation(record, hexToPubkey(key.publicKeyHex))) return record;202 }203 204 205 206 return null;207 }208 209 210 if (record.revokedByDeviceId !== issuerDeviceId) return null;211 return signDeviceRevocation(record, local.signingPrivateKey);212 }))213 ).filter((record): record is DeviceRevocation => record !== null);214 return buildSignedDeviceManifest(215 issuerDeviceId,216 devices,217 local.signingPrivateKey,218 { revoked: signedRevoked },219 );220 } finally {221 local.privateKey.fill(0);222 local.publicKey.fill(0);223 }224 },225226 async applyIncomingDeviceManifest(manifest, authenticatedRemoteStaticPublicKey) {227 requireRealUnlocked();228 await applyVerifiedPeerManifest(229 manifest,230 authenticatedRemoteStaticPublicKey,231 );232 },233 };234}235236export async function refreshLockedSyncSpool(): Promise<void> {237 requireUnlocked();238 const masterKey = requireMasterKey();239 const deviceId = await getOrCreateDeviceId();240 const identity = await buildCachedIdentity(masterKey, deviceId);241 const cachedEntries: Record<string, { modifiedAt: number; ciphertext: string }> = {};242 try {243 for (const entry of getEntries()) {244 if (entry.deletedAt) continue;245 const key = await deriveEntryKey(masterKey, entry.id);246 cachedEntries[entry.id] = {247 modifiedAt: entry.modifiedAt,248 ciphertext: await encryptEntry(key, entry),249 };250 }251 const tombstones = getTombstoneManifest();252 253 254 255 256 const { 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 signingChanged = existing.signingPublicKeyHex &&478 !constantTimeEqualHex(existing.signingPublicKeyHex, entry.signingPublicKeyHex);479 if (longTermChanged || signingChanged) {480 const knownName = existing.deviceName ?? entry.deviceName;481 notifyPopup({ type: "DEVICE_KEY_ROTATED", payload: { deviceName: knownName, deviceId: entry.deviceId } });482 void recordSyncAlerts([{483 type: "DEVICE_KEY_ROTATED",484 entryId: "",485 entryTitle: knownName,486 deviceName: knownName,487 timestamp: Date.now(),488 }]);489 continue;490 }491 pendingRegistry.push({492 ...existing,493 deviceId: entry.deviceId,494 deviceName: existing.deviceName ?? entry.deviceName,495 deviceType: entry.deviceType,496 longTermPublicKeyHex: existing.longTermPublicKeyHex ?? entry.longTermPublicKeyHex.toLowerCase(),497 signingPublicKeyHex: existing.signingPublicKeyHex ?? entry.signingPublicKeyHex.toLowerCase(),498 pairedAt: existing.pairedAt ?? entry.pairedAt,499 trustSource: existing.trustSource,500 wsPort: existing.wsPort ?? entry.wsPort,501 relayUrl: existing.trustSource === "vouched" && meshRoute ? meshRoute.relayUrl : existing.relayUrl,502 lastSyncedAt: existing.lastSyncedAt,503 });504 if (existing.trustSource === "vouched" && meshRoute) {505 pendingSecrets.push({ deviceId: entry.deviceId, secret: meshRoute.rendezvousSecretHex });506 }507 }508 const newlyRevoked = await upsertPairedDevicesAndMergeRevocations(509 pendingRegistry,510 body.revoked.filter((r) => r.deviceId !== ourDeviceId),511 );512 for (const deviceId of newlyRevoked) {513 await removePendingVouchedDevice(deviceId);514 void recordSyncAlerts([{515 type: "DEVICE_REVOKED",516 entryId: "",517 entryTitle: deviceId,518 deviceName: issuer.deviceName ?? "paired device",519 timestamp: Date.now(),520 }]);521 }522 for (const pending of pendingSecrets) {523 if (newlyRevoked.includes(pending.deviceId)) continue;524 await setPairSecret(pending.deviceId, pending.secret, masterKey);525 }526 for (const deviceId of pendingVouchedIds) {527 if (newlyRevoked.includes(deviceId)) continue;528 await removePendingVouchedDevice(deviceId);529 }530 } finally {531 masterKey.fill(0);532 localKeys.secretKey.fill(0);533 localKeys.publicKey.fill(0);534 }535}536537function deriveManifestMeshRoute(538 localPrivateKey: Uint8Array,539 localPublicKey: Uint8Array,540 entry: ManifestDeviceEntry,541): { rendezvousSecretHex: string; relayUrl: string } | null {542 try {543 if (!entry.relayUrl) return null;544 const relayUrl = normalizeRelayUrl(entry.relayUrl);545 const secret = deriveMeshRendezvousSecret(546 localPrivateKey,547 localPublicKey,548 hexToPubkey(entry.longTermPublicKeyHex),549 );550 try {551 return {552 rendezvousSecretHex: rendezvousBytesToHex(secret),553 relayUrl,554 };555 } finally {556 secret.fill(0);557 }558 } catch {559 return null;560 }561}562563function notifyPopup(msg: { type: string; payload?: unknown }): void {564 try {565 void chrome.runtime.sendMessage(msg).catch(() => undefined);566 } catch {567 568 }569}570571function requireUnlocked(): void {572 if (!isUnlocked()) throw new VaultLockedError();573}574575function requireRealUnlocked(): void {576 if (!isUnlocked() || isDecoyMode()) throw new VaultLockedError();577}578579580581582583584585function requireMasterKey(): Uint8Array {586 if (!isUnlocked()) throw new VaultLockedError();587 const hex = getRootKeyHex();588 if (!hex) throw new VaultLockedError();589 return hexToBytes(hex);590}591592async function deriveEntryKey(masterKey: Uint8Array, entryId: string): Promise<CryptoKey> {593 594 595 596 597 const info = new TextEncoder().encode(`${entryId}\x00${ENTRY_KEY_INFO_SUFFIX}`);598 const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), info).expand(32);599 return crypto.subtle.importKey(600 "raw",601 raw.buffer as ArrayBuffer,602 { name: "AES-GCM" },603 false,604 ["encrypt", "decrypt"],605 );606}607608async function encryptEntry(key: CryptoKey, entry: VaultEntry): Promise<string> {609 const plaintext = new TextEncoder().encode(JSON.stringify(entry));610 const nonce = crypto.getRandomValues(new Uint8Array(12));611 const aad = new TextEncoder().encode(ENTRY_AAD);612 const ct = await crypto.subtle.encrypt(613 {614 name: "AES-GCM",615 iv: nonce.buffer as ArrayBuffer,616 additionalData: aad.buffer as ArrayBuffer,617 tagLength: 128,618 },619 key,620 plaintext.buffer as ArrayBuffer,621 );622 const ctBytes = new Uint8Array(ct);623 const envelope = new Uint8Array(nonce.length + ctBytes.length);624 envelope.set(nonce, 0);625 envelope.set(ctBytes, nonce.length);626 return bytesToBase64(envelope);627}628629async function decryptEntryOrNull(key: CryptoKey, envelopeB64: string): Promise<VaultEntry | null> {630 try {631 const bytes = base64ToBytes(envelopeB64);632 if (bytes.length < 12 + 16) return null;633 const nonce = bytes.subarray(0, 12);634 const ct = bytes.subarray(12);635 const aad = new TextEncoder().encode(ENTRY_AAD);636 const pt = await crypto.subtle.decrypt(637 {638 name: "AES-GCM",639 iv: nonce.buffer.slice(nonce.byteOffset, nonce.byteOffset + nonce.byteLength) as ArrayBuffer,640 additionalData: aad.buffer as ArrayBuffer,641 tagLength: 128,642 },643 key,644 ct.buffer.slice(ct.byteOffset, ct.byteOffset + ct.byteLength) as ArrayBuffer,645 );646 const json = new TextDecoder().decode(pt);647 const parsed = JSON.parse(json);648 if (!parsed || typeof parsed !== "object" || typeof parsed.id !== "string") return null;649 return parsed as VaultEntry;650 } catch {651 return null;652 }653}654655function hexToBytes(hex: string): Uint8Array {656 if (hex.length % 2 !== 0) throw new Error("hex length must be even");657 const out = new Uint8Array(hex.length / 2);658 for (let i = 0; i < out.length; i++) {659 out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);660 }661 return out;662}663664function bytesToBase64(bytes: Uint8Array): string {665 let bin = "";666 for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);667 return btoa(bin);668}669670function bytesToHex(bytes: Uint8Array): string {671 return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");672}673674function base64ToBytes(b64: string): Uint8Array {675 const bin = atob(b64);676 const out = new Uint8Array(bin.length);677 for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);678 return out;679}