12345678910111213141516171819202122232425import { HKDF } from "@stablelib/hkdf";26import { SHA256 } from "@stablelib/sha256";27import type { SyncVaultAdapter } from "./sync-coordinator";28import { getDeviceName, getOrCreateDeviceId } from "./device-identity";29import {30 computeVaultId,31 deriveSyncIdentityKeypair,32 deriveSyncSigningKeypair,33 hexToPubkey,34 pubkeyToHex,35} from "../shared/sync-identity";36import {37 buildSignedDeviceManifest,38 verifyDeviceManifest,39} from "../shared/device-manifest";40import { constantTimeEqualHex } from "../shared/constant-time";41import {42 applyMergedRemoteEntries,43 getTombstoneManifest,44 getRootKeyHex,45 getEntries,46 getEntryById,47 getVaultVersion,48 isUnlocked,49 isDecoyMode,50} from "./vault";51import {52 findPairedDeviceById,53 findPairedDeviceByPubkey,54 isDeviceRevoked,55 listDeviceRevocations,56 listPairedDevices,57 upsertPairedDevicesAndMergeRevocations,58} from "./paired-devices";59import { setPairSecret } from "./pair-secrets";60import {61 removePendingVouchedDevice,62} from "./pending-vouched-devices";63import {64 bytesToHex as rendezvousBytesToHex,65 deriveMeshRendezvousSecret,66 normalizeRelayUrl,67} from "../shared/rendezvous";68import { recordSyncAlerts } from "./sync-alerts";69import { SYNC_LIMITS, clampSyncTimestamp } from "../shared/sync-limits";70import type { VaultEntry } from "../shared/types";71import {72 MAX_VOUCH_DEPTH,73 type ManifestDeviceEntry,74 type PairedDevice,75 type SignedDeviceManifest,76} from "../shared/sync-types";77import { replaceLockedSyncSpool, drainLockedSyncInbox } from "./locked-sync-spool";78798081const ENTRY_KEY_INFO_SUFFIX = "sync-entry-v1";82const ENTRY_AAD = "peach-sync-entry-v1";8384export class VaultLockedError extends Error {85 constructor() {86 super("vault_locked");87 this.name = "VaultLockedError";88 }89}9091export function createSyncVaultAdapter(92 _context: { deviceName?: string } = {},93): SyncVaultAdapter {94 return {95 async computeVaultId() {96 const master = requireMasterKey();97 return computeVaultId(master);98 },99100 async getOurDeviceId() {101 return getOrCreateDeviceId();102 },103104 async getLocalVersion() {105 return getVaultVersion();106 },107108 async buildEntryManifest() {109 requireRealUnlocked();110 const manifest: Record<string, number> = {};111 for (const entry of getEntries()) {112 manifest[entry.id] = entry.modifiedAt;113 }114 return manifest;115 },116117 async buildTombstoneManifest() {118 requireRealUnlocked();119 return getTombstoneManifest();120 },121122 async getEncryptedEntries(entryIds) {123 requireRealUnlocked();124 const masterKey = requireMasterKey();125 const out: Record<string, string> = {};126 for (const id of entryIds) {127 const entry = getEntryById(id);128 if (!entry) continue;129 const key = await deriveEntryKey(masterKey, id);130 out[id] = await encryptEntry(key, entry);131 }132 return out;133 },134135 async applyEntries(entries) {136 requireRealUnlocked();137 const entryList = Object.entries(entries);138 if (entryList.length > SYNC_LIMITS.MAX_ENTRY_COUNT) {139 throw new Error(140 `sync entry count ${entryList.length} exceeds limit ${SYNC_LIMITS.MAX_ENTRY_COUNT}`,141 );142 }143 const masterKey = requireMasterKey();144 const decrypted: VaultEntry[] = [];145 for (const [id, envelope] of entryList) {146 if (envelope.length > (SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3 + 64) continue;147 const key = await deriveEntryKey(masterKey, id);148 const entry = await decryptEntryOrNull(key, envelope);149 if (!entry) continue;150 if (JSON.stringify(entry).length > SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES) continue;151 if (entry.id !== id) continue;152 decrypted.push(entry);153 }154 if (decrypted.length > 0) {155 requireRealUnlocked();156 await applyMergedRemoteEntries(decrypted, {});157 }158 },159160 async applyTombstones(tombstones) {161 requireRealUnlocked();162 const tombstoneList = Object.entries(tombstones);163 if (tombstoneList.length > SYNC_LIMITS.MAX_TOMBSTONE_COUNT) {164 throw new Error(165 `sync tombstone count ${tombstoneList.length} exceeds limit ${SYNC_LIMITS.MAX_TOMBSTONE_COUNT}`,166 );167 }168 const cleaned: Record<string, number> = {};169 for (const [id, ts] of tombstoneList) {170 if (typeof ts === "number" && Number.isFinite(ts) && ts > 0) {171 cleaned[id] = clampSyncTimestamp(ts);172 }173 }174 if (Object.keys(cleaned).length > 0) {175 requireRealUnlocked();176 await applyMergedRemoteEntries([], cleaned);177 }178 },179180 async buildOutgoingDeviceManifest() {181 requireRealUnlocked();182 const masterKey = requireMasterKey();183 const issuerDeviceId = await getOrCreateDeviceId();184 const signing = await deriveSyncSigningKeypair(masterKey, issuerDeviceId);185 const revoked = await listDeviceRevocations();186 const revokedIds = new Set(revoked.map((r) => r.deviceId));187 const devices = (await listPairedDevices())188 .filter((d) => !revokedIds.has(d.deviceId))189 .map(toManifestEntry)190 .filter((d): d is ManifestDeviceEntry => d !== null);191 return buildSignedDeviceManifest(192 issuerDeviceId,193 devices,194 signing.privateKey,195 { revoked },196 );197 },198199 async applyIncomingDeviceManifest(manifest, authenticatedRemoteStaticPublicKey) {200 requireRealUnlocked();201 await applyVerifiedPeerManifest(202 manifest,203 authenticatedRemoteStaticPublicKey,204 );205 },206 };207}208209export async function refreshLockedSyncSpool(): Promise<void> {210 requireUnlocked();211 const masterKey = requireMasterKey();212 const deviceId = await getOrCreateDeviceId();213 const identity = await buildCachedIdentity(masterKey, deviceId);214 const cachedEntries: Record<string, { modifiedAt: number; ciphertext: string }> = {};215 try {216 for (const entry of getEntries()) {217 if (entry.deletedAt) continue;218 const key = await deriveEntryKey(masterKey, entry.id);219 cachedEntries[entry.id] = {220 modifiedAt: entry.modifiedAt,221 ciphertext: await encryptEntry(key, entry),222 };223 }224 const tombstones = getTombstoneManifest();225 226 227 228 229 const { longTermPrivateKeyBytes: _privateKeyBytes, ...storedIdentity } = identity;230 await replaceLockedSyncSpool({231 ...storedIdentity,232 localVersion: await getVaultVersion(),233 entries: cachedEntries,234 tombstones,235 });236 } finally {237 masterKey.fill(0);238 identity.longTermPrivateKeyBytes.fill(0);239 }240}241242export async function drainAndRefreshLockedSyncSpool(): Promise<void> {243 const live = createSyncVaultAdapter();244 await drainLockedSyncInbox(live);245 await refreshLockedSyncSpool();246}247248async function buildCachedIdentity(masterKey: Uint8Array, deviceId: string) {249 const vaultId = await computeVaultId(masterKey);250 const keys = deriveSyncIdentityKeypair(masterKey, deviceId);251 const deviceName = await getDeviceName();252 return {253 vaultId,254 deviceId,255 deviceName,256 deviceType: "extension" as const,257 longTermPublicKeyHex: pubkeyToHex(keys.publicKey),258 longTermPrivateKeyHex: bytesToHex(keys.secretKey),259 longTermPrivateKeyBytes: keys.secretKey,260 };261}262263function toManifestEntry(device: PairedDevice): ManifestDeviceEntry | null {264 if (265 !device.deviceId ||266 !device.deviceName ||267 !device.deviceType ||268 !device.longTermPublicKeyHex ||269 !/^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex) ||270 !device.signingPublicKeyHex ||271 !/^[0-9a-f]{64}$/.test(device.signingPublicKeyHex)272 ) {273 return null;274 }275 return {276 deviceId: device.deviceId,277 deviceName: device.deviceName,278 deviceType: device.deviceType,279 longTermPublicKeyHex: device.longTermPublicKeyHex,280 signingPublicKeyHex: device.signingPublicKeyHex,281 wsPort: device.wsPort,282 relayUrl: device.relayUrl,283 pairedAt: device.pairedAt,284 vouchDepth: device.vouchDepth ?? 0,285 };286}287288289290291292293294295296297298299300301302async function applyVerifiedPeerManifest(303 manifest: SignedDeviceManifest,304 authenticatedRemoteStaticPublicKey: Uint8Array,305): Promise<void> {306 const issuerId = manifest?.body?.issuerDeviceId;307 if (typeof issuerId !== "string" || !issuerId) { return; }308309 if (authenticatedRemoteStaticPublicKey.length !== 32) {310 return;311 }312 const authenticatedPubkeyHex = pubkeyToHex(authenticatedRemoteStaticPublicKey);313 const issuer = await findPairedDeviceByPubkey(authenticatedPubkeyHex);314 if (315 !issuer ||316 issuer.deviceId !== issuerId ||317 (318 issuer.trustSource !== "direct" &&319 issuer.trustSource !== "vouched"320 ) ||321 (issuer.vouchDepth ?? 0) >= MAX_VOUCH_DEPTH ||322 !issuer.signingPublicKeyHex ||323 !/^[0-9a-f]{64}$/.test(issuer.signingPublicKeyHex)324 ) {325 return;326 }327328 let body: Awaited<ReturnType<typeof verifyDeviceManifest>>;329 try {330 body = await verifyDeviceManifest(331 manifest,332 hexToPubkey(issuer!.signingPublicKeyHex!),333 );334 } catch {335 336 337 338 339 340 const masterForIssuer = requireMasterKey();341 try {342 const derived = await deriveSyncSigningKeypair(343 masterForIssuer,344 issuer.deviceId,345 );346 body = await verifyDeviceManifest(manifest, derived.publicKeyRaw);347 } catch {348 return;349 } finally {350 masterForIssuer.fill(0);351 }352 }353 if (body.issuerDeviceId !== issuer.deviceId) {354 return;355 }356357 const ourDeviceId = await getOrCreateDeviceId();358 const masterKey = requireMasterKey();359 const localKeys = deriveSyncIdentityKeypair(masterKey, ourDeviceId);360 try {361 const pendingRegistry: PairedDevice[] = [];362 const pendingSecrets: { deviceId: string; secret: string }[] = [];363 const pendingVouchedIds: string[] = [];364 for (const entry of body.devices) {365 if (entry.deviceId === ourDeviceId || entry.deviceId === issuer.deviceId) continue;366 if (await isDeviceRevoked(entry.deviceId, entry.longTermPublicKeyHex)) continue;367 if ((entry.vouchDepth ?? 0) > (issuer.vouchDepth ?? 0) + 1) continue;368 const existing = await findPairedDeviceById(entry.deviceId);369 const meshRoute = deriveManifestMeshRoute(masterKey, localKeys.publicKey, entry);370 if (!existing) {371 if (!meshRoute) continue;372 pendingRegistry.push({373 deviceId: entry.deviceId,374 deviceName: entry.deviceName,375 deviceType: entry.deviceType,376 longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),377 signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),378 wsPort: entry.wsPort,379 relayUrl: meshRoute.relayUrl,380 pairedAt: entry.pairedAt,381 trustSource: "vouched",382 vouchDepth: (issuer.vouchDepth ?? 0) + 1,383 });384 pendingSecrets.push({ deviceId: entry.deviceId, secret: meshRoute.rendezvousSecretHex });385 pendingVouchedIds.push(entry.deviceId);386 continue;387 }388 if (existing.longTermPublicKeyHex && !constantTimeEqualHex(existing.longTermPublicKeyHex, entry.longTermPublicKeyHex)) {389 const knownName = existing.deviceName ?? entry.deviceName;390 notifyPopup({ type: "DEVICE_KEY_ROTATED", payload: { deviceName: knownName, deviceId: entry.deviceId } });391 void recordSyncAlerts([{392 type: "DEVICE_KEY_ROTATED",393 entryId: "",394 entryTitle: knownName,395 deviceName: knownName,396 timestamp: Date.now(),397 }]);398 }399 pendingRegistry.push({400 ...existing,401 deviceId: entry.deviceId,402 deviceName: existing.deviceName ?? entry.deviceName,403 deviceType: entry.deviceType,404 longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),405 signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),406 pairedAt: existing.pairedAt ?? entry.pairedAt,407 trustSource: existing.trustSource,408 wsPort: existing.wsPort ?? entry.wsPort,409 relayUrl: existing.trustSource === "vouched" && meshRoute ? meshRoute.relayUrl : existing.relayUrl,410 lastSyncedAt: existing.lastSyncedAt,411 });412 if (existing.trustSource === "vouched" && meshRoute) {413 pendingSecrets.push({ deviceId: entry.deviceId, secret: meshRoute.rendezvousSecretHex });414 }415 }416 const newlyRevoked = await upsertPairedDevicesAndMergeRevocations(417 pendingRegistry,418 body.revoked.filter((r) => r.deviceId !== ourDeviceId),419 );420 for (const deviceId of newlyRevoked) {421 await removePendingVouchedDevice(deviceId);422 void recordSyncAlerts([{423 type: "DEVICE_REVOKED",424 entryId: "",425 entryTitle: deviceId,426 deviceName: issuer.deviceName ?? "paired device",427 timestamp: Date.now(),428 }]);429 }430 for (const pending of pendingSecrets) {431 if (newlyRevoked.includes(pending.deviceId)) continue;432 await setPairSecret(pending.deviceId, pending.secret, masterKey);433 }434 for (const deviceId of pendingVouchedIds) {435 if (newlyRevoked.includes(deviceId)) continue;436 await removePendingVouchedDevice(deviceId);437 }438 } finally {439 masterKey.fill(0);440 localKeys.secretKey.fill(0);441 }442}443444function deriveManifestMeshRoute(445 masterKey: Uint8Array,446 localPublicKey: Uint8Array,447 entry: ManifestDeviceEntry,448): { rendezvousSecretHex: string; relayUrl: string } | null {449 try {450 if (!entry.relayUrl) return null;451 const relayUrl = normalizeRelayUrl(entry.relayUrl);452 const secret = deriveMeshRendezvousSecret(453 masterKey,454 localPublicKey,455 hexToPubkey(entry.longTermPublicKeyHex),456 );457 try {458 return {459 rendezvousSecretHex: rendezvousBytesToHex(secret),460 relayUrl,461 };462 } finally {463 secret.fill(0);464 }465 } catch {466 return null;467 }468}469470function notifyPopup(msg: { type: string; payload?: unknown }): void {471 try {472 void chrome.runtime.sendMessage(msg).catch(() => undefined);473 } catch {474 475 }476}477478function requireUnlocked(): void {479 if (!isUnlocked()) throw new VaultLockedError();480}481482function requireRealUnlocked(): void {483 if (!isUnlocked() || isDecoyMode()) throw new VaultLockedError();484}485486487488489490491492function requireMasterKey(): Uint8Array {493 if (!isUnlocked()) throw new VaultLockedError();494 const hex = getRootKeyHex();495 if (!hex) throw new VaultLockedError();496 return hexToBytes(hex);497}498499async function deriveEntryKey(masterKey: Uint8Array, entryId: string): Promise<CryptoKey> {500 501 502 503 504 const info = new TextEncoder().encode(`${entryId}\x00${ENTRY_KEY_INFO_SUFFIX}`);505 const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), info).expand(32);506 return crypto.subtle.importKey(507 "raw",508 raw.buffer as ArrayBuffer,509 { name: "AES-GCM" },510 false,511 ["encrypt", "decrypt"],512 );513}514515async function encryptEntry(key: CryptoKey, entry: VaultEntry): Promise<string> {516 const plaintext = new TextEncoder().encode(JSON.stringify(entry));517 const nonce = crypto.getRandomValues(new Uint8Array(12));518 const aad = new TextEncoder().encode(ENTRY_AAD);519 const ct = await crypto.subtle.encrypt(520 {521 name: "AES-GCM",522 iv: nonce.buffer as ArrayBuffer,523 additionalData: aad.buffer as ArrayBuffer,524 tagLength: 128,525 },526 key,527 plaintext.buffer as ArrayBuffer,528 );529 const ctBytes = new Uint8Array(ct);530 const envelope = new Uint8Array(nonce.length + ctBytes.length);531 envelope.set(nonce, 0);532 envelope.set(ctBytes, nonce.length);533 return bytesToBase64(envelope);534}535536async function decryptEntryOrNull(key: CryptoKey, envelopeB64: string): Promise<VaultEntry | null> {537 try {538 const bytes = base64ToBytes(envelopeB64);539 if (bytes.length < 12 + 16) return null;540 const nonce = bytes.subarray(0, 12);541 const ct = bytes.subarray(12);542 const aad = new TextEncoder().encode(ENTRY_AAD);543 const pt = await crypto.subtle.decrypt(544 {545 name: "AES-GCM",546 iv: nonce.buffer.slice(nonce.byteOffset, nonce.byteOffset + nonce.byteLength) as ArrayBuffer,547 additionalData: aad.buffer as ArrayBuffer,548 tagLength: 128,549 },550 key,551 ct.buffer.slice(ct.byteOffset, ct.byteOffset + ct.byteLength) as ArrayBuffer,552 );553 const json = new TextDecoder().decode(pt);554 const parsed = JSON.parse(json);555 if (!parsed || typeof parsed !== "object" || typeof parsed.id !== "string") return null;556 return parsed as VaultEntry;557 } catch {558 return null;559 }560}561562function hexToBytes(hex: string): Uint8Array {563 if (hex.length % 2 !== 0) throw new Error("hex length must be even");564 const out = new Uint8Array(hex.length / 2);565 for (let i = 0; i < out.length; i++) {566 out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);567 }568 return out;569}570571function bytesToBase64(bytes: Uint8Array): string {572 let bin = "";573 for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);574 return btoa(bin);575}576577function bytesToHex(bytes: Uint8Array): string {578 return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");579}580581function base64ToBytes(b64: string): Uint8Array {582 const bin = atob(b64);583 const out = new Uint8Array(bin.length);584 for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);585 return out;586}