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} from "./vault";50import {51 findPairedDeviceById,52 findPairedDeviceByPubkey,53 isDeviceRevoked,54 listDeviceRevocations,55 listPairedDevices,56 mergeDeviceRevocations,57 removePairedDeviceById,58 upsertPairedDevice,59} from "./paired-devices";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 type {72 ManifestDeviceEntry,73 PairedDevice,74 SignedDeviceManifest,75} from "../shared/sync-types";76import { replaceLockedSyncSpool, drainLockedSyncInbox } from "./locked-sync-spool";77787980const ENTRY_KEY_INFO_SUFFIX = "sync-entry-v1";81const ENTRY_AAD = "peach-sync-entry-v1";8283export class VaultLockedError extends Error {84 constructor() {85 super("vault_locked");86 this.name = "VaultLockedError";87 }88}8990export function createSyncVaultAdapter(91 context: { deviceName?: string } = {},92): SyncVaultAdapter {93 return {94 async computeVaultId() {95 const master = requireMasterKey();96 return computeVaultId(master);97 },9899 async getOurDeviceId() {100 return getOrCreateDeviceId();101 },102103 async getLocalVersion() {104 return getVaultVersion();105 },106107 async buildEntryManifest() {108 requireUnlocked();109 const manifest: Record<string, number> = {};110 for (const entry of getEntries()) {111 manifest[entry.id] = entry.modifiedAt;112 }113 return manifest;114 },115116 async buildTombstoneManifest() {117 requireUnlocked();118 return getTombstoneManifest();119 },120121 async getEncryptedEntries(entryIds) {122 requireUnlocked();123 const masterKey = requireMasterKey();124 const out: Record<string, string> = {};125 for (const id of entryIds) {126 const entry = getEntryById(id);127 if (!entry) continue;128 const key = await deriveEntryKey(masterKey, id);129 out[id] = await encryptEntry(key, entry);130 }131 return out;132 },133134 async applyEntries(entries) {135 requireUnlocked();136 const entryList = Object.entries(entries);137 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 147 148 if (envelope.length > (SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3 + 64) {149 continue; 150 }151 const key = await deriveEntryKey(masterKey, id);152 const entry = await decryptEntryOrNull(key, envelope);153 if (!entry) continue;154 155 if (JSON.stringify(entry).length > SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES) {156 continue;157 }158 159 160 if (entry.id !== id) continue;161 decrypted.push(entry);162 }163 if (decrypted.length > 0) {164 await applyMergedRemoteEntries(decrypted, {}, context);165 }166 },167168 async applyTombstones(tombstones) {169 requireUnlocked();170 const tombstoneList = Object.entries(tombstones);171 172 if (tombstoneList.length > SYNC_LIMITS.MAX_TOMBSTONE_COUNT) {173 throw new Error(174 `sync tombstone count ${tombstoneList.length} exceeds limit ${SYNC_LIMITS.MAX_TOMBSTONE_COUNT}`,175 );176 }177 const cleaned: Record<string, number> = {};178 for (const [id, ts] of tombstoneList) {179 if (typeof ts === "number" && Number.isFinite(ts) && ts > 0) {180 181 182 183 cleaned[id] = clampSyncTimestamp(ts);184 }185 }186 if (Object.keys(cleaned).length > 0) {187 await applyMergedRemoteEntries([], cleaned, context);188 }189 },190191 async buildOutgoingDeviceManifest() {192 requireUnlocked();193 const masterKey = requireMasterKey();194 const issuerDeviceId = await getOrCreateDeviceId();195 const signing = await deriveSyncSigningKeypair(masterKey, issuerDeviceId);196 const revoked = await listDeviceRevocations();197 const revokedIds = new Set(revoked.map((r) => r.deviceId));198 const devices = (await listPairedDevices())199 .filter((d) => !revokedIds.has(d.deviceId))200 .map(toManifestEntry)201 .filter((d): d is ManifestDeviceEntry => d !== null);202 return buildSignedDeviceManifest(203 issuerDeviceId,204 devices,205 signing.privateKey,206 { revoked },207 );208 },209210 async applyIncomingDeviceManifest(manifest, authenticatedRemoteStaticPublicKey) {211 requireUnlocked();212 await applyVerifiedPeerManifest(213 manifest,214 authenticatedRemoteStaticPublicKey,215 );216 },217 };218}219220export async function refreshLockedSyncSpool(): Promise<void> {221 requireUnlocked();222 const masterKey = requireMasterKey();223 const deviceId = await getOrCreateDeviceId();224 const identity = await buildCachedIdentity(masterKey, deviceId);225 const cachedEntries: Record<string, { modifiedAt: number; ciphertext: string }> = {};226 try {227 for (const entry of getEntries()) {228 if (entry.deletedAt) continue;229 const key = await deriveEntryKey(masterKey, entry.id);230 cachedEntries[entry.id] = {231 modifiedAt: entry.modifiedAt,232 ciphertext: await encryptEntry(key, entry),233 };234 }235 const tombstones = getTombstoneManifest();236 237 238 239 240 const { longTermPrivateKeyBytes: _privateKeyBytes, ...storedIdentity } = identity;241 await replaceLockedSyncSpool({242 ...storedIdentity,243 localVersion: await getVaultVersion(),244 entries: cachedEntries,245 tombstones,246 });247 } finally {248 masterKey.fill(0);249 identity.longTermPrivateKeyBytes.fill(0);250 }251}252253export async function drainAndRefreshLockedSyncSpool(): Promise<void> {254 const live = createSyncVaultAdapter();255 await drainLockedSyncInbox(live);256 await refreshLockedSyncSpool();257}258259async function buildCachedIdentity(masterKey: Uint8Array, deviceId: string) {260 const vaultId = await computeVaultId(masterKey);261 const keys = deriveSyncIdentityKeypair(masterKey, deviceId);262 const deviceName = await getDeviceName();263 return {264 vaultId,265 deviceId,266 deviceName,267 deviceType: "extension" as const,268 longTermPublicKeyHex: pubkeyToHex(keys.publicKey),269 longTermPrivateKeyHex: bytesToHex(keys.secretKey),270 longTermPrivateKeyBytes: keys.secretKey,271 };272}273274function toManifestEntry(device: PairedDevice): ManifestDeviceEntry | null {275 if (276 !device.deviceId ||277 !device.deviceName ||278 !device.deviceType ||279 !device.longTermPublicKeyHex ||280 !/^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex) ||281 !device.signingPublicKeyHex ||282 !/^[0-9a-f]{64}$/.test(device.signingPublicKeyHex)283 ) {284 return null;285 }286 return {287 deviceId: device.deviceId,288 deviceName: device.deviceName,289 deviceType: device.deviceType,290 longTermPublicKeyHex: device.longTermPublicKeyHex,291 signingPublicKeyHex: device.signingPublicKeyHex,292 wsPort: device.wsPort,293 relayUrl: device.relayUrl,294 pairedAt: device.pairedAt,295 };296}297298299300301302303304305306307308309310311312async function applyVerifiedPeerManifest(313 manifest: SignedDeviceManifest,314 authenticatedRemoteStaticPublicKey: Uint8Array,315): Promise<void> {316 const issuerId = manifest?.body?.issuerDeviceId;317 if (typeof issuerId !== "string" || !issuerId) { return; }318319 if (authenticatedRemoteStaticPublicKey.length !== 32) {320 return;321 }322 const authenticatedPubkeyHex = pubkeyToHex(authenticatedRemoteStaticPublicKey);323 const issuer = await findPairedDeviceByPubkey(authenticatedPubkeyHex);324 if (325 !issuer ||326 issuer.deviceId !== issuerId ||327 (328 issuer.trustSource !== "direct" &&329 issuer.trustSource !== "vouched"330 ) ||331 !issuer.signingPublicKeyHex ||332 !/^[0-9a-f]{64}$/.test(issuer.signingPublicKeyHex)333 ) {334 return;335 }336337 let body: Awaited<ReturnType<typeof verifyDeviceManifest>>;338 try {339 body = await verifyDeviceManifest(340 manifest,341 hexToPubkey(issuer!.signingPublicKeyHex!),342 );343 } catch {344 345 346 347 348 349 const masterForIssuer = requireMasterKey();350 try {351 const derived = await deriveSyncSigningKeypair(352 masterForIssuer,353 issuer.deviceId,354 );355 body = await verifyDeviceManifest(manifest, derived.publicKeyRaw);356 } catch {357 return;358 } finally {359 masterForIssuer.fill(0);360 }361 }362 if (body.issuerDeviceId !== issuer.deviceId) {363 return;364 }365366 const ourDeviceId = await getOrCreateDeviceId();367 const masterKey = requireMasterKey();368 const localKeys = deriveSyncIdentityKeypair(masterKey, ourDeviceId);369370 try {371 372 373 374 375 const newlyRevoked = await mergeDeviceRevocations(376 body.revoked.filter((r) => r.deviceId !== ourDeviceId),377 );378 for (const deviceId of newlyRevoked) {379 await removePairedDeviceById(deviceId);380 await removePendingVouchedDevice(deviceId);381 void recordSyncAlerts([382 {383 type: "DEVICE_REVOKED",384 entryId: "",385 entryTitle: deviceId,386 deviceName: issuer.deviceName ?? "paired device",387 timestamp: Date.now(),388 },389 ]);390 }391392 for (const entry of body.devices) {393 if (entry.deviceId === ourDeviceId || entry.deviceId === issuer.deviceId) {394 continue;395 }396 397 398 399 400 401 if (await isDeviceRevoked(entry.deviceId, entry.longTermPublicKeyHex)) {402 continue;403 }404 const existing = await findPairedDeviceById(entry.deviceId);405 const meshRoute = deriveManifestMeshRoute(406 masterKey,407 localKeys.publicKey,408 entry,409 );410411 412 413 414 415 416 if (!existing) {417 if (!meshRoute) continue;418 await upsertPairedDevice({419 deviceId: entry.deviceId,420 deviceName: entry.deviceName,421 deviceType: entry.deviceType,422 longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),423 signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),424 wsPort: entry.wsPort,425 rendezvousSecretHex: meshRoute.rendezvousSecretHex,426 relayUrl: meshRoute.relayUrl,427 pairedAt: entry.pairedAt,428 trustSource: "vouched",429 });430 await removePendingVouchedDevice(entry.deviceId);431 continue;432 }433434 435 436 437 438 if (439 existing.longTermPublicKeyHex &&440 !constantTimeEqualHex(441 existing.longTermPublicKeyHex,442 entry.longTermPublicKeyHex,443 )444 ) {445 446 447 448 449 450 const knownName = existing.deviceName ?? entry.deviceName;451 notifyPopup({452 type: "DEVICE_KEY_ROTATED",453 payload: { deviceName: knownName, deviceId: entry.deviceId },454 });455 void recordSyncAlerts([456 {457 type: "DEVICE_KEY_ROTATED",458 entryId: "",459 entryTitle: knownName,460 deviceName: knownName,461 timestamp: Date.now(),462 },463 ]);464 }465466 467 468 469 470 await upsertPairedDevice({471 ...existing,472 deviceId: entry.deviceId,473 deviceName: existing.deviceName ?? entry.deviceName,474 deviceType: entry.deviceType,475 longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),476 signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),477 pairedAt: existing.pairedAt ?? entry.pairedAt,478 trustSource: existing.trustSource,479 wsPort: existing.wsPort ?? entry.wsPort,480 rendezvousSecretHex:481 existing.trustSource === "vouched" && meshRoute482 ? meshRoute.rendezvousSecretHex483 : existing.rendezvousSecretHex,484 relayUrl:485 existing.trustSource === "vouched" && meshRoute486 ? meshRoute.relayUrl487 : existing.relayUrl,488 lastSyncedAt: existing.lastSyncedAt,489 });490 }491 } finally {492 masterKey.fill(0);493 localKeys.secretKey.fill(0);494 }495}496497function deriveManifestMeshRoute(498 masterKey: Uint8Array,499 localPublicKey: Uint8Array,500 entry: ManifestDeviceEntry,501): { rendezvousSecretHex: string; relayUrl: string } | null {502 try {503 if (!entry.relayUrl) return null;504 const relayUrl = normalizeRelayUrl(entry.relayUrl);505 const secret = deriveMeshRendezvousSecret(506 masterKey,507 localPublicKey,508 hexToPubkey(entry.longTermPublicKeyHex),509 );510 try {511 return {512 rendezvousSecretHex: rendezvousBytesToHex(secret),513 relayUrl,514 };515 } finally {516 secret.fill(0);517 }518 } catch {519 return null;520 }521}522523function notifyPopup(msg: { type: string; payload?: unknown }): void {524 try {525 void chrome.runtime.sendMessage(msg).catch(() => undefined);526 } catch {527 528 }529}530531function requireUnlocked(): void {532 if (!isUnlocked()) throw new VaultLockedError();533}534535536537538539540541function requireMasterKey(): Uint8Array {542 if (!isUnlocked()) throw new VaultLockedError();543 const hex = getRootKeyHex();544 if (!hex) throw new VaultLockedError();545 return hexToBytes(hex);546}547548async function deriveEntryKey(masterKey: Uint8Array, entryId: string): Promise<CryptoKey> {549 550 551 552 553 const info = new TextEncoder().encode(`${entryId}\x00${ENTRY_KEY_INFO_SUFFIX}`);554 const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), info).expand(32);555 return crypto.subtle.importKey(556 "raw",557 raw.buffer as ArrayBuffer,558 { name: "AES-GCM" },559 false,560 ["encrypt", "decrypt"],561 );562}563564async function encryptEntry(key: CryptoKey, entry: VaultEntry): Promise<string> {565 const plaintext = new TextEncoder().encode(JSON.stringify(entry));566 const nonce = crypto.getRandomValues(new Uint8Array(12));567 const aad = new TextEncoder().encode(ENTRY_AAD);568 const ct = await crypto.subtle.encrypt(569 {570 name: "AES-GCM",571 iv: nonce.buffer as ArrayBuffer,572 additionalData: aad.buffer as ArrayBuffer,573 tagLength: 128,574 },575 key,576 plaintext.buffer as ArrayBuffer,577 );578 const ctBytes = new Uint8Array(ct);579 const envelope = new Uint8Array(nonce.length + ctBytes.length);580 envelope.set(nonce, 0);581 envelope.set(ctBytes, nonce.length);582 return bytesToBase64(envelope);583}584585async function decryptEntryOrNull(key: CryptoKey, envelopeB64: string): Promise<VaultEntry | null> {586 try {587 const bytes = base64ToBytes(envelopeB64);588 if (bytes.length < 12 + 16) return null;589 const nonce = bytes.subarray(0, 12);590 const ct = bytes.subarray(12);591 const aad = new TextEncoder().encode(ENTRY_AAD);592 const pt = await crypto.subtle.decrypt(593 {594 name: "AES-GCM",595 iv: nonce.buffer.slice(nonce.byteOffset, nonce.byteOffset + nonce.byteLength) as ArrayBuffer,596 additionalData: aad.buffer as ArrayBuffer,597 tagLength: 128,598 },599 key,600 ct.buffer.slice(ct.byteOffset, ct.byteOffset + ct.byteLength) as ArrayBuffer,601 );602 const json = new TextDecoder().decode(pt);603 const parsed = JSON.parse(json);604 if (!parsed || typeof parsed !== "object" || typeof parsed.id !== "string") return null;605 return parsed as VaultEntry;606 } catch {607 return null;608 }609}610611function hexToBytes(hex: string): Uint8Array {612 if (hex.length % 2 !== 0) throw new Error("hex length must be even");613 const out = new Uint8Array(hex.length / 2);614 for (let i = 0; i < out.length; i++) {615 out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);616 }617 return out;618}619620function bytesToBase64(bytes: Uint8Array): string {621 let bin = "";622 for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);623 return btoa(bin);624}625626function bytesToHex(bytes: Uint8Array): string {627 return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");628}629630function base64ToBytes(b64: string): Uint8Array {631 const bin = atob(b64);632 const out = new Uint8Array(bin.length);633 for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);634 return out;635}