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 195 196 197 if (198 record.signatureHex &&199 record.issuerSigningPublicKeyHex &&200 record.issuerSigningKeyVersion201 ) {202 const issuer = await findPairedDeviceById(record.revokedByDeviceId);203 const trustedKeys = [204 ...(issuer?.signingKeyHistory ?? []),205 ...(issuer?.signingPublicKeyHex206 ? [{ version: issuer.signingKeyHistory?.length207 ? Math.max(...issuer.signingKeyHistory.map((key) => key.version)) + 1208 : 1, publicKeyHex: issuer.signingPublicKeyHex }]209 : []),210 ];211 for (const key of trustedKeys) {212 if (213 key.version === record.issuerSigningKeyVersion &&214 key.publicKeyHex.toLowerCase() === record.issuerSigningPublicKeyHex.toLowerCase() &&215 await verifyDeviceRevocation(record, hexToPubkey(key.publicKeyHex))216 ) return record;217 }218 return null;219 }220 221 222 if (record.revokedByDeviceId !== issuerDeviceId) return null;223 return signDeviceRevocation(224 record,225 local.signingPrivateKey,226 local.identity.signingPublicKeyHex!,227 );228 }))229 ).filter((record): record is DeviceRevocation => record !== null);230 return buildSignedDeviceManifest(231 issuerDeviceId,232 devices,233 local.signingPrivateKey,234 { revoked: signedRevoked },235 );236 } finally {237 local.privateKey.fill(0);238 local.publicKey.fill(0);239 }240 },241242 async applyIncomingDeviceManifest(manifest, authenticatedRemoteStaticPublicKey) {243 requireRealUnlocked();244 await applyVerifiedPeerManifest(245 manifest,246 authenticatedRemoteStaticPublicKey,247 );248 },249 };250}251252export async function refreshLockedSyncSpool(): Promise<void> {253 requireUnlocked();254 const masterKey = requireMasterKey();255 const deviceId = await getOrCreateDeviceId();256 const identity = await buildCachedIdentity(masterKey, deviceId);257 const cachedEntries: Record<string, { modifiedAt: number; ciphertext: string }> = {};258 try {259 for (const entry of getEntries()) {260 if (entry.deletedAt) continue;261 const key = await deriveEntryKey(masterKey, entry.id);262 cachedEntries[entry.id] = {263 modifiedAt: entry.modifiedAt,264 ciphertext: await encryptEntry(key, entry),265 };266 }267 const tombstones = getTombstoneManifest();268 269 270 271 272 const { longTermPrivateKeyBytes: _privateKeyBytes, ...storedIdentity } = identity;273 await replaceLockedSyncSpool({274 ...storedIdentity,275 localVersion: await getVaultVersion(),276 entries: cachedEntries,277 tombstones,278 });279 } finally {280 masterKey.fill(0);281 identity.longTermPrivateKeyBytes.fill(0);282 }283}284285export async function drainAndRefreshLockedSyncSpool(): Promise<void> {286 const live = createSyncVaultAdapter();287 await drainLockedSyncInbox(live);288 await refreshLockedSyncSpool();289}290291async function buildCachedIdentity(masterKey: Uint8Array, _deviceId: string) {292 const vaultId = await computeVaultId(masterKey);293 const pairing = await getLocalPairingIdentity();294 const deviceName = await getDeviceName();295 return {296 vaultId,297 deviceId: pairing.identity.deviceId,298 deviceName,299 deviceType: "extension" as const,300 longTermPublicKeyHex: pubkeyToHex(pairing.publicKey),301 longTermPrivateKeyHex: bytesToHex(pairing.privateKey),302 longTermPrivateKeyBytes: pairing.privateKey,303 };304}305306function toManifestEntry(device: PairedDevice): ManifestDeviceEntry | null {307 if (308 !device.deviceId ||309 !device.deviceName ||310 !device.deviceType ||311 !device.longTermPublicKeyHex ||312 !/^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex) ||313 !device.signingPublicKeyHex ||314 !/^[0-9a-f]{64}$/.test(device.signingPublicKeyHex)315 ) {316 return null;317 }318 return {319 deviceId: device.deviceId,320 deviceName: device.deviceName,321 deviceType: device.deviceType,322 longTermPublicKeyHex: device.longTermPublicKeyHex,323 signingPublicKeyHex: device.signingPublicKeyHex,324 wsPort: device.wsPort,325 relayUrl: device.relayUrl,326 pairedAt: device.pairedAt,327 vouchDepth: device.vouchDepth ?? 0,328 };329}330331332333334335336337338339340341342343344345async function applyVerifiedPeerManifest(346 manifest: SignedDeviceManifest,347 authenticatedRemoteStaticPublicKey: Uint8Array,348): Promise<void> {349 const issuerId = manifest?.body?.issuerDeviceId;350 if (typeof issuerId !== "string" || !issuerId) { return; }351352 if (authenticatedRemoteStaticPublicKey.length !== 32) {353 return;354 }355 const authenticatedPubkeyHex = pubkeyToHex(authenticatedRemoteStaticPublicKey);356 const issuer = await findPairedDeviceByPubkey(authenticatedPubkeyHex);357 if (358 !issuer ||359 issuer.deviceId !== issuerId ||360 (361 issuer.trustSource !== "direct" &&362 issuer.trustSource !== "vouched"363 ) ||364 (issuer.vouchDepth ?? 0) >= MAX_VOUCH_DEPTH ||365 !issuer.signingPublicKeyHex ||366 !/^[0-9a-f]{64}$/.test(issuer.signingPublicKeyHex)367 ) {368 return;369 }370371 let body: DeviceManifestBody | undefined;372 let selectedSigningKey: { version: number; publicKeyHex: string } | undefined;373 const issuerSigningPublicKeyHex = issuer.signingPublicKeyHex;374 const historicalKeys = (issuer.signingKeyHistory ?? [])375 .filter((key) => Number.isInteger(key.version) && key.version > 0 && /^[0-9a-f]{64}$/i.test(key.publicKeyHex))376 .map((key) => ({ version: key.version, publicKeyHex: key.publicKeyHex.toLowerCase() }));377 const currentVersion = historicalKeys.find(378 (key) => key.publicKeyHex === issuerSigningPublicKeyHex.toLowerCase(),379 )?.version ?? (historicalKeys.reduce((max, key) => Math.max(max, key.version), 0) + 1);380 const signingKeys = [381 ...historicalKeys,382 { version: currentVersion, publicKeyHex: issuerSigningPublicKeyHex.toLowerCase() },383 ].filter((key, index, keys) =>384 keys.findIndex((candidate) => candidate.version === key.version && candidate.publicKeyHex === key.publicKeyHex) === index,385 );386 try {387 for (const key of signingKeys) {388 try {389 body = await verifyDeviceManifest(manifest, hexToPubkey(key.publicKeyHex));390 selectedSigningKey = key;391 break;392 } catch { }393 }394 if (!body || !selectedSigningKey) return;395 } catch {396 return;397 }398 if (body.issuerDeviceId !== issuer.deviceId) {399 return;400 }401 const verifiedRevocations = (402 await Promise.all(403 body.revoked.map(async (record) => {404 405 406 407 408 const originalIssuer = await findPairedDeviceById(record.revokedByDeviceId);409 if (!originalIssuer?.signingPublicKeyHex) return null;410 const historicalKeys = (originalIssuer.signingKeyHistory ?? [])411 .filter((key) =>412 Number.isInteger(key.version) &&413 key.version > 0 &&414 /^[0-9a-f]{64}$/i.test(key.publicKeyHex)415 )416 .map((key) => ({417 version: key.version,418 publicKeyHex: key.publicKeyHex.toLowerCase(),419 }));420 const currentKey = originalIssuer.signingPublicKeyHex.toLowerCase();421 const currentVersion = historicalKeys.find(422 (key) => key.publicKeyHex === currentKey,423 )?.version ?? (historicalKeys.reduce(424 (max, key) => Math.max(max, key.version),425 0,426 ) + 1);427 const trustedKeys = [428 ...historicalKeys,429 { version: currentVersion, publicKeyHex: currentKey },430 ].filter((key, index, keys) =>431 keys.findIndex((candidate) =>432 candidate.version === key.version &&433 candidate.publicKeyHex === key.publicKeyHex434 ) === index435 );436 if (437 typeof record.issuerSigningPublicKeyHex !== "string" ||438 !Number.isInteger(record.issuerSigningKeyVersion)439 ) return null;440 const key = trustedKeys.find(441 (candidate) =>442 candidate.version === record.issuerSigningKeyVersion &&443 candidate.publicKeyHex === record.issuerSigningPublicKeyHex!.toLowerCase(),444 );445 if (!key) return null;446 return (await verifyDeviceRevocation(record, hexToPubkey(key.publicKeyHex)))447 ? record448 : null;449 }),450 )451 ).filter((record): record is DeviceRevocation => record !== null);452 body = { ...body, revoked: verifiedRevocations };453454 const ourDeviceId = await getOrCreateDeviceId();455 const masterKey = requireMasterKey();456 const localPairing = await getLocalPairingIdentity();457 const localKeys = { publicKey: localPairing.publicKey, secretKey: localPairing.privateKey };458 try {459 const pendingRegistry: PairedDevice[] = [];460 const pendingSecrets: { deviceId: string; secret: string }[] = [];461 const pendingVouchedIds: string[] = [];462 for (const entry of body.devices) {463 if (entry.deviceId === ourDeviceId || entry.deviceId === issuer.deviceId) continue;464 if (await isDeviceRevoked(entry.deviceId, entry.longTermPublicKeyHex)) continue;465 if ((entry.vouchDepth ?? 0) > (issuer.vouchDepth ?? 0) + 1) continue;466 const existing = await findPairedDeviceById(entry.deviceId);467 const meshRoute = deriveManifestMeshRoute(localKeys.secretKey, localKeys.publicKey, entry);468 if (!existing) {469 if (!meshRoute) continue;470 pendingRegistry.push({471 deviceId: entry.deviceId,472 deviceName: entry.deviceName,473 deviceType: entry.deviceType,474 longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),475 signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),476 wsPort: entry.wsPort,477 relayUrl: meshRoute.relayUrl,478 pairedAt: entry.pairedAt,479 trustSource: "vouched",480 vouchDepth: (issuer.vouchDepth ?? 0) + 1,481 });482 pendingSecrets.push({ deviceId: entry.deviceId, secret: meshRoute.rendezvousSecretHex });483 pendingVouchedIds.push(entry.deviceId);484 continue;485 }486 if (existing.longTermPublicKeyHex && !constantTimeEqualHex(existing.longTermPublicKeyHex, entry.longTermPublicKeyHex)) {487 const knownName = existing.deviceName ?? entry.deviceName;488 notifyPopup({ type: "DEVICE_KEY_ROTATED", payload: { deviceName: knownName, deviceId: entry.deviceId } });489 void recordSyncAlerts([{490 type: "DEVICE_KEY_ROTATED",491 entryId: "",492 entryTitle: knownName,493 deviceName: knownName,494 timestamp: Date.now(),495 }]);496 }497 pendingRegistry.push({498 ...existing,499 deviceId: entry.deviceId,500 deviceName: existing.deviceName ?? entry.deviceName,501 deviceType: entry.deviceType,502 longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),503 signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),504 pairedAt: existing.pairedAt ?? entry.pairedAt,505 trustSource: existing.trustSource,506 wsPort: existing.wsPort ?? entry.wsPort,507 relayUrl: existing.trustSource === "vouched" && meshRoute ? meshRoute.relayUrl : existing.relayUrl,508 lastSyncedAt: existing.lastSyncedAt,509 });510 if (existing.trustSource === "vouched" && meshRoute) {511 pendingSecrets.push({ deviceId: entry.deviceId, secret: meshRoute.rendezvousSecretHex });512 }513 }514 const newlyRevoked = await upsertPairedDevicesAndMergeRevocations(515 pendingRegistry,516 body.revoked.filter((r) => r.deviceId !== ourDeviceId),517 );518 for (const deviceId of newlyRevoked) {519 await removePendingVouchedDevice(deviceId);520 void recordSyncAlerts([{521 type: "DEVICE_REVOKED",522 entryId: "",523 entryTitle: deviceId,524 deviceName: issuer.deviceName ?? "paired device",525 timestamp: Date.now(),526 }]);527 }528 for (const pending of pendingSecrets) {529 if (newlyRevoked.includes(pending.deviceId)) continue;530 await setPairSecret(pending.deviceId, pending.secret, masterKey);531 }532 for (const deviceId of pendingVouchedIds) {533 if (newlyRevoked.includes(deviceId)) continue;534 await removePendingVouchedDevice(deviceId);535 }536 } finally {537 masterKey.fill(0);538 localKeys.secretKey.fill(0);539 localKeys.publicKey.fill(0);540 }541}542543function deriveManifestMeshRoute(544 localPrivateKey: Uint8Array,545 localPublicKey: Uint8Array,546 entry: ManifestDeviceEntry,547): { rendezvousSecretHex: string; relayUrl: string } | null {548 try {549 if (!entry.relayUrl) return null;550 const relayUrl = normalizeRelayUrl(entry.relayUrl);551 const secret = deriveMeshRendezvousSecret(552 localPrivateKey,553 localPublicKey,554 hexToPubkey(entry.longTermPublicKeyHex),555 );556 try {557 return {558 rendezvousSecretHex: rendezvousBytesToHex(secret),559 relayUrl,560 };561 } finally {562 secret.fill(0);563 }564 } catch {565 return null;566 }567}568569function notifyPopup(msg: { type: string; payload?: unknown }): void {570 try {571 void chrome.runtime.sendMessage(msg).catch(() => undefined);572 } catch {573 574 }575}576577function requireUnlocked(): void {578 if (!isUnlocked()) throw new VaultLockedError();579}580581function requireRealUnlocked(): void {582 if (!isUnlocked() || isDecoyMode()) throw new VaultLockedError();583}584585586587588589590591function requireMasterKey(): Uint8Array {592 if (!isUnlocked()) throw new VaultLockedError();593 const hex = getRootKeyHex();594 if (!hex) throw new VaultLockedError();595 return hexToBytes(hex);596}597598async function deriveEntryKey(masterKey: Uint8Array, entryId: string): Promise<CryptoKey> {599 600 601 602 603 const info = new TextEncoder().encode(`${entryId}\x00${ENTRY_KEY_INFO_SUFFIX}`);604 const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), info).expand(32);605 return crypto.subtle.importKey(606 "raw",607 raw.buffer as ArrayBuffer,608 { name: "AES-GCM" },609 false,610 ["encrypt", "decrypt"],611 );612}613614async function encryptEntry(key: CryptoKey, entry: VaultEntry): Promise<string> {615 const plaintext = new TextEncoder().encode(JSON.stringify(entry));616 const nonce = crypto.getRandomValues(new Uint8Array(12));617 const aad = new TextEncoder().encode(ENTRY_AAD);618 const ct = await crypto.subtle.encrypt(619 {620 name: "AES-GCM",621 iv: nonce.buffer as ArrayBuffer,622 additionalData: aad.buffer as ArrayBuffer,623 tagLength: 128,624 },625 key,626 plaintext.buffer as ArrayBuffer,627 );628 const ctBytes = new Uint8Array(ct);629 const envelope = new Uint8Array(nonce.length + ctBytes.length);630 envelope.set(nonce, 0);631 envelope.set(ctBytes, nonce.length);632 return bytesToBase64(envelope);633}634635async function decryptEntryOrNull(key: CryptoKey, envelopeB64: string): Promise<VaultEntry | null> {636 try {637 const bytes = base64ToBytes(envelopeB64);638 if (bytes.length < 12 + 16) return null;639 const nonce = bytes.subarray(0, 12);640 const ct = bytes.subarray(12);641 const aad = new TextEncoder().encode(ENTRY_AAD);642 const pt = await crypto.subtle.decrypt(643 {644 name: "AES-GCM",645 iv: nonce.buffer.slice(nonce.byteOffset, nonce.byteOffset + nonce.byteLength) as ArrayBuffer,646 additionalData: aad.buffer as ArrayBuffer,647 tagLength: 128,648 },649 key,650 ct.buffer.slice(ct.byteOffset, ct.byteOffset + ct.byteLength) as ArrayBuffer,651 );652 const json = new TextDecoder().decode(pt);653 const parsed = JSON.parse(json);654 if (!parsed || typeof parsed !== "object" || typeof parsed.id !== "string") return null;655 return parsed as VaultEntry;656 } catch {657 return null;658 }659}660661function hexToBytes(hex: string): Uint8Array {662 if (hex.length % 2 !== 0) throw new Error("hex length must be even");663 const out = new Uint8Array(hex.length / 2);664 for (let i = 0; i < out.length; i++) {665 out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);666 }667 return out;668}669670function bytesToBase64(bytes: Uint8Array): string {671 let bin = "";672 for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);673 return btoa(bin);674}675676function bytesToHex(bytes: Uint8Array): string {677 return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");678}679680function base64ToBytes(b64: string): Uint8Array {681 const bin = atob(b64);682 const out = new Uint8Array(bin.length);683 for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);684 return out;685}