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 if (existing.longTermPublicKeyHex && !constantTimeEqualHex(existing.longTermPublicKeyHex, entry.longTermPublicKeyHex)) {476 const knownName = existing.deviceName ?? entry.deviceName;477 notifyPopup({ type: "DEVICE_KEY_ROTATED", payload: { deviceName: knownName, deviceId: entry.deviceId } });478 void recordSyncAlerts([{479 type: "DEVICE_KEY_ROTATED",480 entryId: "",481 entryTitle: knownName,482 deviceName: knownName,483 timestamp: Date.now(),484 }]);485 }486 pendingRegistry.push({487 ...existing,488 deviceId: entry.deviceId,489 deviceName: existing.deviceName ?? entry.deviceName,490 deviceType: entry.deviceType,491 longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),492 signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),493 pairedAt: existing.pairedAt ?? entry.pairedAt,494 trustSource: existing.trustSource,495 wsPort: existing.wsPort ?? entry.wsPort,496 relayUrl: existing.trustSource === "vouched" && meshRoute ? meshRoute.relayUrl : existing.relayUrl,497 lastSyncedAt: existing.lastSyncedAt,498 });499 if (existing.trustSource === "vouched" && meshRoute) {500 pendingSecrets.push({ deviceId: entry.deviceId, secret: meshRoute.rendezvousSecretHex });501 }502 }503 const newlyRevoked = await upsertPairedDevicesAndMergeRevocations(504 pendingRegistry,505 body.revoked.filter((r) => r.deviceId !== ourDeviceId),506 );507 for (const deviceId of newlyRevoked) {508 await removePendingVouchedDevice(deviceId);509 void recordSyncAlerts([{510 type: "DEVICE_REVOKED",511 entryId: "",512 entryTitle: deviceId,513 deviceName: issuer.deviceName ?? "paired device",514 timestamp: Date.now(),515 }]);516 }517 for (const pending of pendingSecrets) {518 if (newlyRevoked.includes(pending.deviceId)) continue;519 await setPairSecret(pending.deviceId, pending.secret, masterKey);520 }521 for (const deviceId of pendingVouchedIds) {522 if (newlyRevoked.includes(deviceId)) continue;523 await removePendingVouchedDevice(deviceId);524 }525 } finally {526 masterKey.fill(0);527 localKeys.secretKey.fill(0);528 localKeys.publicKey.fill(0);529 }530}531532function deriveManifestMeshRoute(533 localPrivateKey: Uint8Array,534 localPublicKey: Uint8Array,535 entry: ManifestDeviceEntry,536): { rendezvousSecretHex: string; relayUrl: string } | null {537 try {538 if (!entry.relayUrl) return null;539 const relayUrl = normalizeRelayUrl(entry.relayUrl);540 const secret = deriveMeshRendezvousSecret(541 localPrivateKey,542 localPublicKey,543 hexToPubkey(entry.longTermPublicKeyHex),544 );545 try {546 return {547 rendezvousSecretHex: rendezvousBytesToHex(secret),548 relayUrl,549 };550 } finally {551 secret.fill(0);552 }553 } catch {554 return null;555 }556}557558function notifyPopup(msg: { type: string; payload?: unknown }): void {559 try {560 void chrome.runtime.sendMessage(msg).catch(() => undefined);561 } catch {562 563 }564}565566function requireUnlocked(): void {567 if (!isUnlocked()) throw new VaultLockedError();568}569570function requireRealUnlocked(): void {571 if (!isUnlocked() || isDecoyMode()) throw new VaultLockedError();572}573574575576577578579580function requireMasterKey(): Uint8Array {581 if (!isUnlocked()) throw new VaultLockedError();582 const hex = getRootKeyHex();583 if (!hex) throw new VaultLockedError();584 return hexToBytes(hex);585}586587async function deriveEntryKey(masterKey: Uint8Array, entryId: string): Promise<CryptoKey> {588 589 590 591 592 const info = new TextEncoder().encode(`${entryId}\x00${ENTRY_KEY_INFO_SUFFIX}`);593 const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), info).expand(32);594 return crypto.subtle.importKey(595 "raw",596 raw.buffer as ArrayBuffer,597 { name: "AES-GCM" },598 false,599 ["encrypt", "decrypt"],600 );601}602603async function encryptEntry(key: CryptoKey, entry: VaultEntry): Promise<string> {604 const plaintext = new TextEncoder().encode(JSON.stringify(entry));605 const nonce = crypto.getRandomValues(new Uint8Array(12));606 const aad = new TextEncoder().encode(ENTRY_AAD);607 const ct = await crypto.subtle.encrypt(608 {609 name: "AES-GCM",610 iv: nonce.buffer as ArrayBuffer,611 additionalData: aad.buffer as ArrayBuffer,612 tagLength: 128,613 },614 key,615 plaintext.buffer as ArrayBuffer,616 );617 const ctBytes = new Uint8Array(ct);618 const envelope = new Uint8Array(nonce.length + ctBytes.length);619 envelope.set(nonce, 0);620 envelope.set(ctBytes, nonce.length);621 return bytesToBase64(envelope);622}623624async function decryptEntryOrNull(key: CryptoKey, envelopeB64: string): Promise<VaultEntry | null> {625 try {626 const bytes = base64ToBytes(envelopeB64);627 if (bytes.length < 12 + 16) return null;628 const nonce = bytes.subarray(0, 12);629 const ct = bytes.subarray(12);630 const aad = new TextEncoder().encode(ENTRY_AAD);631 const pt = await crypto.subtle.decrypt(632 {633 name: "AES-GCM",634 iv: nonce.buffer.slice(nonce.byteOffset, nonce.byteOffset + nonce.byteLength) as ArrayBuffer,635 additionalData: aad.buffer as ArrayBuffer,636 tagLength: 128,637 },638 key,639 ct.buffer.slice(ct.byteOffset, ct.byteOffset + ct.byteLength) as ArrayBuffer,640 );641 const json = new TextDecoder().decode(pt);642 const parsed = JSON.parse(json);643 if (!parsed || typeof parsed !== "object" || typeof parsed.id !== "string") return null;644 return parsed as VaultEntry;645 } catch {646 return null;647 }648}649650function hexToBytes(hex: string): Uint8Array {651 if (hex.length % 2 !== 0) throw new Error("hex length must be even");652 const out = new Uint8Array(hex.length / 2);653 for (let i = 0; i < out.length; i++) {654 out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);655 }656 return out;657}658659function bytesToBase64(bytes: Uint8Array): string {660 let bin = "";661 for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);662 return btoa(bin);663}664665function bytesToHex(bytes: Uint8Array): string {666 return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");667}668669function base64ToBytes(b64: string): Uint8Array {670 const bin = atob(b64);671 const out = new Uint8Array(bin.length);672 for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);673 return out;674}