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 const verifiedRevocations = (424 await Promise.all(425 body.revoked.map(async (record) => {426 427 428 429 430 const originalIssuer = await findPairedDeviceById(record.revokedByDeviceId);431 for (const key of trustedSigningKeys(originalIssuer)) {432 if (await verifyDeviceRevocation(record, hexToPubkey(key.publicKeyHex))) return record;433 }434 return null;435 }),436 )437 ).filter((record): record is DeviceRevocation => record !== null);438 body = { ...body, revoked: verifiedRevocations };439440 const ourDeviceId = await getOrCreateDeviceId();441 const masterKey = requireMasterKey();442 const localPairing = await getLocalPairingIdentity();443 const localKeys = { publicKey: localPairing.publicKey, secretKey: localPairing.privateKey };444 try {445 const pendingRegistry: PairedDevice[] = [];446 const pendingSecrets: { deviceId: string; secret: string }[] = [];447 const pendingVouchedIds: string[] = [];448 for (const entry of body.devices) {449 if (entry.deviceId === ourDeviceId || entry.deviceId === issuer.deviceId) continue;450 if (await isDeviceRevoked(entry.deviceId, entry.longTermPublicKeyHex)) continue;451 if ((entry.vouchDepth ?? 0) > (issuer.vouchDepth ?? 0) + 1) continue;452 const existing = await findPairedDeviceById(entry.deviceId);453 const meshRoute = deriveManifestMeshRoute(localKeys.secretKey, localKeys.publicKey, entry);454 if (!existing) {455 if (!meshRoute) continue;456 pendingRegistry.push({457 deviceId: entry.deviceId,458 deviceName: entry.deviceName,459 deviceType: entry.deviceType,460 longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),461 signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),462 wsPort: entry.wsPort,463 relayUrl: meshRoute.relayUrl,464 pairedAt: entry.pairedAt,465 trustSource: "vouched",466 vouchDepth: (issuer.vouchDepth ?? 0) + 1,467 });468 pendingSecrets.push({ deviceId: entry.deviceId, secret: meshRoute.rendezvousSecretHex });469 pendingVouchedIds.push(entry.deviceId);470 continue;471 }472 if (existing.longTermPublicKeyHex && !constantTimeEqualHex(existing.longTermPublicKeyHex, entry.longTermPublicKeyHex)) {473 const knownName = existing.deviceName ?? entry.deviceName;474 notifyPopup({ type: "DEVICE_KEY_ROTATED", payload: { deviceName: knownName, deviceId: entry.deviceId } });475 void recordSyncAlerts([{476 type: "DEVICE_KEY_ROTATED",477 entryId: "",478 entryTitle: knownName,479 deviceName: knownName,480 timestamp: Date.now(),481 }]);482 }483 pendingRegistry.push({484 ...existing,485 deviceId: entry.deviceId,486 deviceName: existing.deviceName ?? entry.deviceName,487 deviceType: entry.deviceType,488 longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),489 signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),490 pairedAt: existing.pairedAt ?? entry.pairedAt,491 trustSource: existing.trustSource,492 wsPort: existing.wsPort ?? entry.wsPort,493 relayUrl: existing.trustSource === "vouched" && meshRoute ? meshRoute.relayUrl : existing.relayUrl,494 lastSyncedAt: existing.lastSyncedAt,495 });496 if (existing.trustSource === "vouched" && meshRoute) {497 pendingSecrets.push({ deviceId: entry.deviceId, secret: meshRoute.rendezvousSecretHex });498 }499 }500 const newlyRevoked = await upsertPairedDevicesAndMergeRevocations(501 pendingRegistry,502 body.revoked.filter((r) => r.deviceId !== ourDeviceId),503 );504 for (const deviceId of newlyRevoked) {505 await removePendingVouchedDevice(deviceId);506 void recordSyncAlerts([{507 type: "DEVICE_REVOKED",508 entryId: "",509 entryTitle: deviceId,510 deviceName: issuer.deviceName ?? "paired device",511 timestamp: Date.now(),512 }]);513 }514 for (const pending of pendingSecrets) {515 if (newlyRevoked.includes(pending.deviceId)) continue;516 await setPairSecret(pending.deviceId, pending.secret, masterKey);517 }518 for (const deviceId of pendingVouchedIds) {519 if (newlyRevoked.includes(deviceId)) continue;520 await removePendingVouchedDevice(deviceId);521 }522 } finally {523 masterKey.fill(0);524 localKeys.secretKey.fill(0);525 localKeys.publicKey.fill(0);526 }527}528529function deriveManifestMeshRoute(530 localPrivateKey: Uint8Array,531 localPublicKey: Uint8Array,532 entry: ManifestDeviceEntry,533): { rendezvousSecretHex: string; relayUrl: string } | null {534 try {535 if (!entry.relayUrl) return null;536 const relayUrl = normalizeRelayUrl(entry.relayUrl);537 const secret = deriveMeshRendezvousSecret(538 localPrivateKey,539 localPublicKey,540 hexToPubkey(entry.longTermPublicKeyHex),541 );542 try {543 return {544 rendezvousSecretHex: rendezvousBytesToHex(secret),545 relayUrl,546 };547 } finally {548 secret.fill(0);549 }550 } catch {551 return null;552 }553}554555function notifyPopup(msg: { type: string; payload?: unknown }): void {556 try {557 void chrome.runtime.sendMessage(msg).catch(() => undefined);558 } catch {559 560 }561}562563function requireUnlocked(): void {564 if (!isUnlocked()) throw new VaultLockedError();565}566567function requireRealUnlocked(): void {568 if (!isUnlocked() || isDecoyMode()) throw new VaultLockedError();569}570571572573574575576577function requireMasterKey(): Uint8Array {578 if (!isUnlocked()) throw new VaultLockedError();579 const hex = getRootKeyHex();580 if (!hex) throw new VaultLockedError();581 return hexToBytes(hex);582}583584async function deriveEntryKey(masterKey: Uint8Array, entryId: string): Promise<CryptoKey> {585 586 587 588 589 const info = new TextEncoder().encode(`${entryId}\x00${ENTRY_KEY_INFO_SUFFIX}`);590 const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), info).expand(32);591 return crypto.subtle.importKey(592 "raw",593 raw.buffer as ArrayBuffer,594 { name: "AES-GCM" },595 false,596 ["encrypt", "decrypt"],597 );598}599600async function encryptEntry(key: CryptoKey, entry: VaultEntry): Promise<string> {601 const plaintext = new TextEncoder().encode(JSON.stringify(entry));602 const nonce = crypto.getRandomValues(new Uint8Array(12));603 const aad = new TextEncoder().encode(ENTRY_AAD);604 const ct = await crypto.subtle.encrypt(605 {606 name: "AES-GCM",607 iv: nonce.buffer as ArrayBuffer,608 additionalData: aad.buffer as ArrayBuffer,609 tagLength: 128,610 },611 key,612 plaintext.buffer as ArrayBuffer,613 );614 const ctBytes = new Uint8Array(ct);615 const envelope = new Uint8Array(nonce.length + ctBytes.length);616 envelope.set(nonce, 0);617 envelope.set(ctBytes, nonce.length);618 return bytesToBase64(envelope);619}620621async function decryptEntryOrNull(key: CryptoKey, envelopeB64: string): Promise<VaultEntry | null> {622 try {623 const bytes = base64ToBytes(envelopeB64);624 if (bytes.length < 12 + 16) return null;625 const nonce = bytes.subarray(0, 12);626 const ct = bytes.subarray(12);627 const aad = new TextEncoder().encode(ENTRY_AAD);628 const pt = await crypto.subtle.decrypt(629 {630 name: "AES-GCM",631 iv: nonce.buffer.slice(nonce.byteOffset, nonce.byteOffset + nonce.byteLength) as ArrayBuffer,632 additionalData: aad.buffer as ArrayBuffer,633 tagLength: 128,634 },635 key,636 ct.buffer.slice(ct.byteOffset, ct.byteOffset + ct.byteLength) as ArrayBuffer,637 );638 const json = new TextDecoder().decode(pt);639 const parsed = JSON.parse(json);640 if (!parsed || typeof parsed !== "object" || typeof parsed.id !== "string") return null;641 return parsed as VaultEntry;642 } catch {643 return null;644 }645}646647function hexToBytes(hex: string): Uint8Array {648 if (hex.length % 2 !== 0) throw new Error("hex length must be even");649 const out = new Uint8Array(hex.length / 2);650 for (let i = 0; i < out.length; i++) {651 out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);652 }653 return out;654}655656function bytesToBase64(bytes: Uint8Array): string {657 let bin = "";658 for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);659 return btoa(bin);660}661662function bytesToHex(bytes: Uint8Array): string {663 return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");664}665666function base64ToBytes(b64: string): Uint8Array {667 const bin = atob(b64);668 const out = new Uint8Array(bin.length);669 for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);670 return out;671}