12345678910111213141516171819202122232425import { HKDF } from "@stablelib/hkdf";26import { SHA256 } from "@stablelib/sha256";27import type { SyncVaultAdapter } from "./sync-coordinator";28import { getOrCreateDeviceId } from "./device-identity";29import {30 computeVaultId,31 deriveSyncIdentityKeypair,32 deriveSyncSigningKeypair,33 hexToPubkey,34 pubkeyToHex,35} from "../shared/sync-identity";36import {37 buildSignedDeviceManifest,38 canonicalise,39 verifyDeviceManifest,40} from "../shared/device-manifest";41import { constantTimeEqualHex } from "../shared/constant-time";42import {43 applyMergedRemoteEntries,44 getDeletedEntries,45 getRootKeyHex,46 getEntries,47 getEntryById,48 getVaultVersion,49 isUnlocked,50} from "./vault";51import {52 findPairedDeviceById,53 findPairedDeviceByPubkey,54 isDeviceRevoked,55 listDeviceRevocations,56 listPairedDevices,57 mergeDeviceRevocations,58 removePairedDeviceById,59 upsertPairedDevice,60} from "./paired-devices";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 type {73 ManifestDeviceEntry,74 PairedDevice,75 SignedDeviceManifest,76} from "../shared/sync-types";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 118 119 120 121 122 123 124 125 126 127 requireUnlocked();128 const manifest: Record<string, number> = {};129 for (const entry of getDeletedEntries()) {130 if (typeof entry.deletedAt === "number") {131 manifest[entry.id] = entry.deletedAt;132 }133 }134 return manifest;135 },136137 async getEncryptedEntries(entryIds) {138 requireUnlocked();139 const masterKey = requireMasterKey();140 const out: Record<string, string> = {};141 for (const id of entryIds) {142 const entry = getEntryById(id);143 if (!entry) continue;144 const key = await deriveEntryKey(masterKey, id);145 out[id] = await encryptEntry(key, entry);146 }147 return out;148 },149150 async applyEntries(entries) {151 requireUnlocked();152 const entryList = Object.entries(entries);153 154 if (entryList.length > SYNC_LIMITS.MAX_ENTRY_COUNT) {155 throw new Error(156 `sync entry count ${entryList.length} exceeds limit ${SYNC_LIMITS.MAX_ENTRY_COUNT}`,157 );158 }159 const masterKey = requireMasterKey();160 const decrypted: VaultEntry[] = [];161 for (const [id, envelope] of entryList) {162 163 164 if (envelope.length > (SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3 + 64) {165 continue; 166 }167 const key = await deriveEntryKey(masterKey, id);168 const entry = await decryptEntryOrNull(key, envelope);169 if (!entry) continue;170 171 if (JSON.stringify(entry).length > SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES) {172 continue;173 }174 175 176 if (entry.id !== id) continue;177 decrypted.push(entry);178 }179 if (decrypted.length > 0) {180 await applyMergedRemoteEntries(decrypted, {}, context);181 }182 },183184 async applyTombstones(tombstones) {185 requireUnlocked();186 const tombstoneList = Object.entries(tombstones);187 188 if (tombstoneList.length > SYNC_LIMITS.MAX_TOMBSTONE_COUNT) {189 throw new Error(190 `sync tombstone count ${tombstoneList.length} exceeds limit ${SYNC_LIMITS.MAX_TOMBSTONE_COUNT}`,191 );192 }193 const cleaned: Record<string, number> = {};194 for (const [id, ts] of tombstoneList) {195 if (typeof ts === "number" && Number.isFinite(ts) && ts > 0) {196 197 198 199 cleaned[id] = clampSyncTimestamp(ts);200 }201 }202 if (Object.keys(cleaned).length > 0) {203 await applyMergedRemoteEntries([], cleaned, context);204 }205 },206207 async buildOutgoingDeviceManifest() {208 requireUnlocked();209 const masterKey = requireMasterKey();210 const issuerDeviceId = await getOrCreateDeviceId();211 const signing = await deriveSyncSigningKeypair(masterKey, issuerDeviceId);212 const revoked = await listDeviceRevocations();213 const revokedIds = new Set(revoked.map((r) => r.deviceId));214 const devices = (await listPairedDevices())215 .filter((d) => !revokedIds.has(d.deviceId))216 .map(toManifestEntry)217 .filter((d): d is ManifestDeviceEntry => d !== null);218 return buildSignedDeviceManifest(219 issuerDeviceId,220 devices,221 signing.privateKey,222 { revoked },223 );224 },225226 async applyIncomingDeviceManifest(manifest, authenticatedRemoteStaticPublicKey) {227 requireUnlocked();228 229 console.info("[Peach diag] applyIncomingDeviceManifest: issuerDeviceId=" + manifest?.body?.issuerDeviceId + " devices=" + (manifest?.body?.devices ?? []).map((d) => d.deviceId).join(",") + " revoked=" + (manifest?.body?.revoked ?? []).length);230 try {231 await applyVerifiedPeerManifest(232 manifest,233 authenticatedRemoteStaticPublicKey,234 );235 } catch (diagError) {236 console.info("[Peach diag] applyVerifiedPeerManifest threw:", diagError);237 }238 const all = await listPairedDevices();239 console.info("[Peach diag] post-manifest paired-devices:", all.map((d) => d.deviceId + "/" + d.trustSource).join(","));240 },241 };242}243244function toManifestEntry(device: PairedDevice): ManifestDeviceEntry | null {245 if (246 !device.deviceId ||247 !device.deviceName ||248 !device.deviceType ||249 !device.longTermPublicKeyHex ||250 !/^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex) ||251 !device.signingPublicKeyHex ||252 !/^[0-9a-f]{64}$/.test(device.signingPublicKeyHex)253 ) {254 return null;255 }256 return {257 deviceId: device.deviceId,258 deviceName: device.deviceName,259 deviceType: device.deviceType,260 longTermPublicKeyHex: device.longTermPublicKeyHex,261 signingPublicKeyHex: device.signingPublicKeyHex,262 wsPort: device.wsPort,263 relayUrl: device.relayUrl,264 pairedAt: device.pairedAt,265 };266}267268269270271272273274275276277278279280281282async function applyVerifiedPeerManifest(283 manifest: SignedDeviceManifest,284 authenticatedRemoteStaticPublicKey: Uint8Array,285): Promise<void> {286 287 console.info("[Peach diag] applyVerifiedPeerManifest enter: authPubKeyLen=" + authenticatedRemoteStaticPublicKey.length + " manifestIssuer=" + manifest?.body?.issuerDeviceId);288 const issuerId = manifest?.body?.issuerDeviceId;289 if (typeof issuerId !== "string" || !issuerId) { console.info("[Peach diag] early return: missing issuerId"); return; }290291 if (authenticatedRemoteStaticPublicKey.length !== 32) {292 console.info("[Peach diag] early return: auth key not 32 bytes");293 return;294 }295 const authenticatedPubkeyHex = pubkeyToHex(authenticatedRemoteStaticPublicKey);296 const issuer = await findPairedDeviceByPubkey(authenticatedPubkeyHex);297 console.info("[Peach diag] lookup issuer: found=" + (issuer !== null) + " storedDeviceId=" + (issuer === null ? "<none>" : issuer.deviceId) + " manifestIssuerId=" + issuerId + " trustSource=" + (issuer === null ? "<none>" : issuer.trustSource) + " hasSigningKey=" + (issuer !== null && issuer.signingPublicKeyHex !== undefined));298 if (299 !issuer ||300 issuer.deviceId !== issuerId ||301 (302 issuer.trustSource !== "direct" &&303 issuer.trustSource !== "vouched"304 ) ||305 !issuer.signingPublicKeyHex ||306 !/^[0-9a-f]{64}$/.test(issuer.signingPublicKeyHex)307 ) {308 console.info("[Peach diag] early return: issuer gate");309 return;310 }311312 let body: Awaited<ReturnType<typeof verifyDeviceManifest>>;313 314 console.info("[Peach diag] verifyDeviceManifest sigHex=" + manifest.signatureHex?.slice(0, 16) + "... bodyIssuerId=" + manifest.body?.issuerDeviceId + " bodyIssuedAt=" + manifest.body?.issuedAt + " bodyDeviceCount=" + (manifest.body?.devices?.length ?? -1) + " deviceIds=" + (manifest.body?.devices ?? []).map((d) => d.deviceId).join(","));315 try {316 body = await verifyDeviceManifest(317 manifest,318 hexToPubkey(issuer!.signingPublicKeyHex!),319 );320 321 console.info("[Peach diag] verifyDeviceManifest OK with stored key");322 } catch (storedError) {323 324 console.info("[Peach diag] verifyDeviceManifest with stored key threw:", String(storedError));325 326 {327 const canonicalStr = canonicalise(manifest.body);328 329 const firstDevice = manifest.body?.devices?.[0];330 const firstDeviceStr = firstDevice ? JSON.stringify(firstDevice) : "<none>";331 console.info("[Peach diag] canonical len=" + canonicalStr.length + " firstDevice=" + firstDeviceStr);332 const revokedArr = manifest.body?.revoked ?? [];333 console.info("[Peach diag] revoked count=" + revokedArr.length + " firstRevoked=" + (revokedArr[0] ? JSON.stringify(revokedArr[0]) : "<none>"));334 }335 336 337 338 339 340 const masterForIssuer = requireMasterKey();341 try {342 const derived = await deriveSyncSigningKeypair(343 masterForIssuer,344 issuer.deviceId,345 );346 347 const derivedHex = Array.from(derived.publicKeyRaw).map((b) => b.toString(16).padStart(2, "0")).join("");348 349 console.info("[Peach diag] retry with derived key: storedSigning=" + issuer!.signingPublicKeyHex!.slice(0,12) + " derivedSigning=" + derivedHex.slice(0,12));350 body = await verifyDeviceManifest(manifest, derived.publicKeyRaw);351 console.info("[Peach diag] verifyDeviceManifest OK with derived key");352 } catch (derivedError) {353 354 console.info("[Peach diag] verifyDeviceManifest with derived key threw:", String(derivedError));355 return;356 } finally {357 masterForIssuer.fill(0);358 }359 }360 console.info("[Peach diag] body parsed: bodyIssuerId=" + body.issuerDeviceId + " issuerStoredId=" + issuer.deviceId);361 if (body.issuerDeviceId !== issuer.deviceId) { console.info("[Peach diag] early return: body issuer mismatch"); return; }362363 const ourDeviceId = await getOrCreateDeviceId();364 const masterKey = requireMasterKey();365 const localKeys = deriveSyncIdentityKeypair(masterKey, ourDeviceId);366367 try {368 369 370 371 372 const newlyRevoked = await mergeDeviceRevocations(373 body.revoked.filter((r) => r.deviceId !== ourDeviceId),374 );375 for (const deviceId of newlyRevoked) {376 await removePairedDeviceById(deviceId);377 await removePendingVouchedDevice(deviceId);378 void recordSyncAlerts([379 {380 type: "DEVICE_REVOKED",381 entryId: "",382 entryTitle: deviceId,383 deviceName: issuer.deviceName ?? "paired device",384 timestamp: Date.now(),385 },386 ]);387 }388389 for (const entry of body.devices) {390 if (entry.deviceId === ourDeviceId || entry.deviceId === issuer.deviceId) {391 392 console.info("[Peach diag] skip entry (self or issuer):", entry.deviceId);393 continue;394 }395 396 397 398 399 400 if (await isDeviceRevoked(entry.deviceId, entry.longTermPublicKeyHex)) {401 402 console.info("[Peach diag] skip entry (revoked):", entry.deviceId, "longTerm=" + entry.longTermPublicKeyHex?.slice(0, 12) + "...", "hasRelay=" + !!entry.relayUrl);403 continue;404 }405 const existing = await findPairedDeviceById(entry.deviceId);406 const meshRoute = deriveManifestMeshRoute(407 masterKey,408 localKeys.publicKey,409 entry,410 );411 412 if (existing) {413 console.info("[Peach diag] skip entry (existing):", entry.deviceId, "trustSource=" + existing.trustSource);414 } else if (!meshRoute) {415 console.info("[Peach diag] skip entry (no mesh route):", entry.deviceId, "hasRelay=" + !!entry.relayUrl);416 } else {417 console.info("[Peach diag] admit entry:", entry.deviceId, "relay=" + meshRoute.relayUrl);418 }419420 421 422 423 424 425 if (!existing) {426 if (!meshRoute) continue;427 await upsertPairedDevice({428 deviceId: entry.deviceId,429 deviceName: entry.deviceName,430 deviceType: entry.deviceType,431 longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),432 signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),433 wsPort: entry.wsPort,434 rendezvousSecretHex: meshRoute.rendezvousSecretHex,435 relayUrl: meshRoute.relayUrl,436 pairedAt: entry.pairedAt,437 trustSource: "vouched",438 });439 await removePendingVouchedDevice(entry.deviceId);440 continue;441 }442443 444 445 446 447 if (448 existing.longTermPublicKeyHex &&449 !constantTimeEqualHex(450 existing.longTermPublicKeyHex,451 entry.longTermPublicKeyHex,452 )453 ) {454 455 456 457 458 459 const knownName = existing.deviceName ?? entry.deviceName;460 notifyPopup({461 type: "DEVICE_KEY_ROTATED",462 payload: { deviceName: knownName, deviceId: entry.deviceId },463 });464 void recordSyncAlerts([465 {466 type: "DEVICE_KEY_ROTATED",467 entryId: "",468 entryTitle: knownName,469 deviceName: knownName,470 timestamp: Date.now(),471 },472 ]);473 }474475 476 477 478 479 await upsertPairedDevice({480 ...existing,481 deviceId: entry.deviceId,482 deviceName: existing.deviceName ?? entry.deviceName,483 deviceType: entry.deviceType,484 longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),485 signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),486 pairedAt: existing.pairedAt ?? entry.pairedAt,487 trustSource: existing.trustSource ?? "direct",488 wsPort: existing.wsPort ?? entry.wsPort,489 rendezvousSecretHex:490 existing.trustSource === "vouched" && meshRoute491 ? meshRoute.rendezvousSecretHex492 : existing.rendezvousSecretHex,493 relayUrl:494 existing.trustSource === "vouched" && meshRoute495 ? meshRoute.relayUrl496 : existing.relayUrl,497 lastSyncedAt: existing.lastSyncedAt,498 });499 }500 } finally {501 masterKey.fill(0);502 localKeys.secretKey.fill(0);503 }504}505506function deriveManifestMeshRoute(507 masterKey: Uint8Array,508 localPublicKey: Uint8Array,509 entry: ManifestDeviceEntry,510): { rendezvousSecretHex: string; relayUrl: string } | null {511 try {512 if (!entry.relayUrl) return null;513 const relayUrl = normalizeRelayUrl(entry.relayUrl);514 const secret = deriveMeshRendezvousSecret(515 masterKey,516 localPublicKey,517 hexToPubkey(entry.longTermPublicKeyHex),518 );519 try {520 return {521 rendezvousSecretHex: rendezvousBytesToHex(secret),522 relayUrl,523 };524 } finally {525 secret.fill(0);526 }527 } catch {528 return null;529 }530}531532function notifyPopup(msg: { type: string; payload?: unknown }): void {533 try {534 void chrome.runtime.sendMessage(msg).catch(() => undefined);535 } catch {536 537 }538}539540function requireUnlocked(): void {541 if (!isUnlocked()) throw new VaultLockedError();542}543544545546547548549550function requireMasterKey(): Uint8Array {551 if (!isUnlocked()) throw new VaultLockedError();552 const hex = getRootKeyHex();553 if (!hex) throw new VaultLockedError();554 return hexToBytes(hex);555}556557async function deriveEntryKey(masterKey: Uint8Array, entryId: string): Promise<CryptoKey> {558 559 560 561 562 const info = new TextEncoder().encode(`${entryId}\x00${ENTRY_KEY_INFO_SUFFIX}`);563 const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), info).expand(32);564 return crypto.subtle.importKey(565 "raw",566 raw.buffer as ArrayBuffer,567 { name: "AES-GCM" },568 false,569 ["encrypt", "decrypt"],570 );571}572573async function encryptEntry(key: CryptoKey, entry: VaultEntry): Promise<string> {574 const plaintext = new TextEncoder().encode(JSON.stringify(entry));575 const nonce = crypto.getRandomValues(new Uint8Array(12));576 const aad = new TextEncoder().encode(ENTRY_AAD);577 const ct = await crypto.subtle.encrypt(578 {579 name: "AES-GCM",580 iv: nonce.buffer as ArrayBuffer,581 additionalData: aad.buffer as ArrayBuffer,582 tagLength: 128,583 },584 key,585 plaintext.buffer as ArrayBuffer,586 );587 const ctBytes = new Uint8Array(ct);588 const envelope = new Uint8Array(nonce.length + ctBytes.length);589 envelope.set(nonce, 0);590 envelope.set(ctBytes, nonce.length);591 return bytesToBase64(envelope);592}593594async function decryptEntryOrNull(key: CryptoKey, envelopeB64: string): Promise<VaultEntry | null> {595 try {596 const bytes = base64ToBytes(envelopeB64);597 if (bytes.length < 12 + 16) return null;598 const nonce = bytes.subarray(0, 12);599 const ct = bytes.subarray(12);600 const aad = new TextEncoder().encode(ENTRY_AAD);601 const pt = await crypto.subtle.decrypt(602 {603 name: "AES-GCM",604 iv: nonce.buffer.slice(nonce.byteOffset, nonce.byteOffset + nonce.byteLength) as ArrayBuffer,605 additionalData: aad.buffer as ArrayBuffer,606 tagLength: 128,607 },608 key,609 ct.buffer.slice(ct.byteOffset, ct.byteOffset + ct.byteLength) as ArrayBuffer,610 );611 const json = new TextDecoder().decode(pt);612 const parsed = JSON.parse(json);613 if (!parsed || typeof parsed !== "object" || typeof parsed.id !== "string") return null;614 return parsed as VaultEntry;615 } catch {616 return null;617 }618}619620function hexToBytes(hex: string): Uint8Array {621 if (hex.length % 2 !== 0) throw new Error("hex length must be even");622 const out = new Uint8Array(hex.length / 2);623 for (let i = 0; i < out.length; i++) {624 out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);625 }626 return out;627}628629function bytesToBase64(bytes: Uint8Array): string {630 let bin = "";631 for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);632 return btoa(bin);633}634635function base64ToBytes(b64: string): Uint8Array {636 const bin = atob(b64);637 const out = new Uint8Array(bin.length);638 for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);639 return out;640}