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, setLockedSyncPrivateKey } 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 setLockedSyncPrivateKey(identity.longTermPrivateKeyBytes);253 const { longTermPrivateKeyHex: _privateKeyHex, longTermPrivateKeyBytes: _privateKeyBytes, ...storedIdentity } = identity;254 await replaceLockedSyncSpool({255 ...storedIdentity,256 localVersion: await getVaultVersion(),257 entries: cachedEntries,258 tombstones,259 });260 } finally {261 masterKey.fill(0);262 identity.longTermPrivateKeyBytes.fill(0);263 }264}265266export async function drainAndRefreshLockedSyncSpool(): Promise<void> {267 const live = createSyncVaultAdapter();268 await drainLockedSyncInbox(live);269 await refreshLockedSyncSpool();270}271272async function buildCachedIdentity(masterKey: Uint8Array, _deviceId: string) {273 const vaultId = await computeVaultId(masterKey);274 const pairing = await getLocalPairingIdentity();275 const deviceName = await getDeviceName();276 return {277 vaultId,278 deviceId: pairing.identity.deviceId,279 deviceName,280 deviceType: "extension" as const,281 longTermPublicKeyHex: pubkeyToHex(pairing.publicKey),282 longTermPrivateKeyHex: bytesToHex(pairing.privateKey),283 longTermPrivateKeyBytes: pairing.privateKey,284 };285}286287function toManifestEntry(device: PairedDevice): ManifestDeviceEntry | null {288 if (289 !device.deviceId ||290 !device.deviceName ||291 !device.deviceType ||292 !device.longTermPublicKeyHex ||293 !/^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex) ||294 !device.signingPublicKeyHex ||295 !/^[0-9a-f]{64}$/.test(device.signingPublicKeyHex)296 ) {297 return null;298 }299 return {300 deviceId: device.deviceId,301 deviceName: device.deviceName,302 deviceType: device.deviceType,303 longTermPublicKeyHex: device.longTermPublicKeyHex,304 signingPublicKeyHex: device.signingPublicKeyHex,305 wsPort: device.wsPort,306 relayUrl: device.relayUrl,307 pairedAt: device.pairedAt,308 vouchDepth: device.vouchDepth ?? 0,309 };310}311312313314315316317318function trustedSigningKeys(319 device:320 | {321 signingPublicKeyHex?: string;322 signingKeyHistory?: Array<{ version: number; publicKeyHex: string }>;323 }324 | null325 | undefined,326): Array<{ version: number; publicKeyHex: string }> {327 const historical = (device?.signingKeyHistory ?? [])328 .filter(329 (key) =>330 Number.isInteger(key.version) &&331 key.version > 0 &&332 /^[0-9a-f]{64}$/i.test(key.publicKeyHex),333 )334 .map((key) => ({ version: key.version, publicKeyHex: key.publicKeyHex.toLowerCase() }));335 const current = device?.signingPublicKeyHex?.toLowerCase();336 if (!current || !/^[0-9a-f]{64}$/.test(current)) return historical;337 const currentVersion =338 historical.find((key) => key.publicKeyHex === current)?.version ??339 historical.reduce((max, key) => Math.max(max, key.version), 0) + 1;340 const all = [...historical, { version: currentVersion, publicKeyHex: current }];341 return all.filter(342 (key, index) =>343 all.findIndex(344 (candidate) =>345 candidate.version === key.version && candidate.publicKeyHex === key.publicKeyHex,346 ) === index,347 );348}349350351352353354355356357358359360361362363364async function applyVerifiedPeerManifest(365 manifest: SignedDeviceManifest,366 authenticatedRemoteStaticPublicKey: Uint8Array,367): Promise<void> {368 const issuerId = manifest?.body?.issuerDeviceId;369 if (typeof issuerId !== "string" || !issuerId) { return; }370371 if (authenticatedRemoteStaticPublicKey.length !== 32) {372 return;373 }374 const authenticatedPubkeyHex = pubkeyToHex(authenticatedRemoteStaticPublicKey);375 const issuer = await findPairedDeviceByPubkey(authenticatedPubkeyHex);376 if (377 !issuer ||378 issuer.deviceId !== issuerId ||379 (380 issuer.trustSource !== "direct" &&381 issuer.trustSource !== "vouched"382 ) ||383 (issuer.vouchDepth ?? 0) >= MAX_VOUCH_DEPTH ||384 !issuer.signingPublicKeyHex ||385 !/^[0-9a-f]{64}$/.test(issuer.signingPublicKeyHex)386 ) {387 return;388 }389390 let body: DeviceManifestBody | undefined;391 let selectedSigningKey: { version: number; publicKeyHex: string } | undefined;392 const issuerSigningPublicKeyHex = issuer.signingPublicKeyHex;393 const historicalKeys = (issuer.signingKeyHistory ?? [])394 .filter((key) => Number.isInteger(key.version) && key.version > 0 && /^[0-9a-f]{64}$/i.test(key.publicKeyHex))395 .map((key) => ({ version: key.version, publicKeyHex: key.publicKeyHex.toLowerCase() }));396 const currentVersion = historicalKeys.find(397 (key) => key.publicKeyHex === issuerSigningPublicKeyHex.toLowerCase(),398 )?.version ?? (historicalKeys.reduce((max, key) => Math.max(max, key.version), 0) + 1);399 const signingKeys = [400 ...historicalKeys,401 { version: currentVersion, publicKeyHex: issuerSigningPublicKeyHex.toLowerCase() },402 ].filter((key, index, keys) =>403 keys.findIndex((candidate) => candidate.version === key.version && candidate.publicKeyHex === key.publicKeyHex) === index,404 );405 try {406 for (const key of signingKeys) {407 try {408 body = await verifyDeviceManifest(manifest, hexToPubkey(key.publicKeyHex));409 selectedSigningKey = key;410 break;411 } catch { }412 }413 if (!body || !selectedSigningKey) return;414 } catch {415 return;416 }417 if (body.issuerDeviceId !== issuer.deviceId) {418 return;419 }420 421 422 423 const allRevocationsAuthorised = (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 true;433 }434 return false;435 }),436 )437 ).every(Boolean);438 if (!allRevocationsAuthorised) return;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 const longTermChanged = existing.longTermPublicKeyHex &&473 !constantTimeEqualHex(existing.longTermPublicKeyHex, entry.longTermPublicKeyHex);474 const existingSigning = existing.signingPublicKeyHex;475 const entrySigning = entry.signingPublicKeyHex;476 const signingChanged =477 Boolean(existingSigning) !== Boolean(entrySigning) ||478 (existingSigning !== undefined &&479 entrySigning !== undefined &&480 !constantTimeEqualHex(existingSigning, entrySigning));481 if (longTermChanged || signingChanged) {482 const knownName = existing.deviceName ?? entry.deviceName;483 notifyPopup({ type: "DEVICE_KEY_ROTATED", payload: { deviceName: knownName, deviceId: entry.deviceId } });484 void recordSyncAlerts([{485 type: "DEVICE_KEY_ROTATED",486 entryId: "",487 entryTitle: knownName,488 deviceName: knownName,489 timestamp: Date.now(),490 }]);491 continue;492 }493 pendingRegistry.push({494 ...existing,495 deviceId: entry.deviceId,496 deviceName: existing.deviceName ?? entry.deviceName,497 deviceType: entry.deviceType,498 longTermPublicKeyHex: existing.longTermPublicKeyHex ?? entry.longTermPublicKeyHex.toLowerCase(),499 signingPublicKeyHex: existing.signingPublicKeyHex ?? entry.signingPublicKeyHex.toLowerCase(),500 pairedAt: existing.pairedAt ?? entry.pairedAt,501 trustSource: existing.trustSource,502 wsPort: existing.wsPort ?? entry.wsPort,503 relayUrl: existing.trustSource === "vouched" && meshRoute ? meshRoute.relayUrl : existing.relayUrl,504 lastSyncedAt: existing.lastSyncedAt,505 });506 if (existing.trustSource === "vouched" && meshRoute) {507 pendingSecrets.push({ deviceId: entry.deviceId, secret: meshRoute.rendezvousSecretHex });508 }509 }510 const newlyRevoked = await upsertPairedDevicesAndMergeRevocations(511 pendingRegistry,512 body.revoked.filter((r) => r.deviceId !== ourDeviceId),513 );514 for (const deviceId of newlyRevoked) {515 await removePendingVouchedDevice(deviceId);516 void recordSyncAlerts([{517 type: "DEVICE_REVOKED",518 entryId: "",519 entryTitle: deviceId,520 deviceName: issuer.deviceName ?? "paired device",521 timestamp: Date.now(),522 }]);523 }524 for (const pending of pendingSecrets) {525 if (newlyRevoked.includes(pending.deviceId)) continue;526 await setPairSecret(pending.deviceId, pending.secret, masterKey);527 }528 for (const deviceId of pendingVouchedIds) {529 if (newlyRevoked.includes(deviceId)) continue;530 await removePendingVouchedDevice(deviceId);531 }532 } finally {533 masterKey.fill(0);534 localKeys.secretKey.fill(0);535 localKeys.publicKey.fill(0);536 }537}538539function deriveManifestMeshRoute(540 localPrivateKey: Uint8Array,541 localPublicKey: Uint8Array,542 entry: ManifestDeviceEntry,543): { rendezvousSecretHex: string; relayUrl: string } | null {544 try {545 if (!entry.relayUrl) return null;546 const relayUrl = normalizeRelayUrl(entry.relayUrl);547 const secret = deriveMeshRendezvousSecret(548 localPrivateKey,549 localPublicKey,550 hexToPubkey(entry.longTermPublicKeyHex),551 );552 try {553 return {554 rendezvousSecretHex: rendezvousBytesToHex(secret),555 relayUrl,556 };557 } finally {558 secret.fill(0);559 }560 } catch {561 return null;562 }563}564565function notifyPopup(msg: { type: string; payload?: unknown }): void {566 try {567 void chrome.runtime.sendMessage(msg).catch(() => undefined);568 } catch {569 570 }571}572573function requireUnlocked(): void {574 if (!isUnlocked()) throw new VaultLockedError();575}576577function requireRealUnlocked(): void {578 if (!isUnlocked() || isDecoyMode()) throw new VaultLockedError();579}580581582583584585586587function requireMasterKey(): Uint8Array {588 if (!isUnlocked()) throw new VaultLockedError();589 const hex = getRootKeyHex();590 if (!hex) throw new VaultLockedError();591 return hexToBytes(hex);592}593594async function deriveEntryKey(masterKey: Uint8Array, entryId: string): Promise<CryptoKey> {595 596 597 598 599 const info = new TextEncoder().encode(`${entryId}\x00${ENTRY_KEY_INFO_SUFFIX}`);600 const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), info).expand(32);601 return crypto.subtle.importKey(602 "raw",603 raw.buffer as ArrayBuffer,604 { name: "AES-GCM" },605 false,606 ["encrypt", "decrypt"],607 );608}609610async function encryptEntry(key: CryptoKey, entry: VaultEntry): Promise<string> {611 const plaintext = new TextEncoder().encode(JSON.stringify(entry));612 const nonce = crypto.getRandomValues(new Uint8Array(12));613 const aad = new TextEncoder().encode(ENTRY_AAD);614 const ct = await crypto.subtle.encrypt(615 {616 name: "AES-GCM",617 iv: nonce.buffer as ArrayBuffer,618 additionalData: aad.buffer as ArrayBuffer,619 tagLength: 128,620 },621 key,622 plaintext.buffer as ArrayBuffer,623 );624 const ctBytes = new Uint8Array(ct);625 const envelope = new Uint8Array(nonce.length + ctBytes.length);626 envelope.set(nonce, 0);627 envelope.set(ctBytes, nonce.length);628 return bytesToBase64(envelope);629}630631async function decryptEntryOrNull(key: CryptoKey, envelopeB64: string): Promise<VaultEntry | null> {632 try {633 const bytes = base64ToBytes(envelopeB64);634 if (bytes.length < 12 + 16) return null;635 const nonce = bytes.subarray(0, 12);636 const ct = bytes.subarray(12);637 const aad = new TextEncoder().encode(ENTRY_AAD);638 const pt = await crypto.subtle.decrypt(639 {640 name: "AES-GCM",641 iv: nonce.buffer.slice(nonce.byteOffset, nonce.byteOffset + nonce.byteLength) as ArrayBuffer,642 additionalData: aad.buffer as ArrayBuffer,643 tagLength: 128,644 },645 key,646 ct.buffer.slice(ct.byteOffset, ct.byteOffset + ct.byteLength) as ArrayBuffer,647 );648 const json = new TextDecoder().decode(pt);649 const parsed = JSON.parse(json);650 if (!parsed || typeof parsed !== "object" || typeof parsed.id !== "string") return null;651 return parsed as VaultEntry;652 } catch {653 return null;654 }655}656657function hexToBytes(hex: string): Uint8Array {658 if (hex.length % 2 !== 0) throw new Error("hex length must be even");659 const out = new Uint8Array(hex.length / 2);660 for (let i = 0; i < out.length; i++) {661 out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);662 }663 return out;664}665666function bytesToBase64(bytes: Uint8Array): string {667 let bin = "";668 for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);669 return btoa(bin);670}671672function bytesToHex(bytes: Uint8Array): string {673 return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");674}675676function base64ToBytes(b64: string): Uint8Array {677 const bin = atob(b64);678 const out = new Uint8Array(bin.length);679 for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);680 return out;681}