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 verifyDeviceManifest,39} from "../shared/device-manifest";40import { constantTimeEqualHex } from "../shared/constant-time";41import {42 applyMergedRemoteEntries,43 getDeletedEntries,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";76777879const ENTRY_KEY_INFO_SUFFIX = "sync-entry-v1";80const ENTRY_AAD = "peach-sync-entry-v1";8182export class VaultLockedError extends Error {83 constructor() {84 super("vault_locked");85 this.name = "VaultLockedError";86 }87}8889export function createSyncVaultAdapter(90 context: { deviceName?: string } = {},91): SyncVaultAdapter {92 return {93 async computeVaultId() {94 const master = requireMasterKey();95 return computeVaultId(master);96 },9798 async getOurDeviceId() {99 return getOrCreateDeviceId();100 },101102 async getLocalVersion() {103 return getVaultVersion();104 },105106 async buildEntryManifest() {107 requireUnlocked();108 const manifest: Record<string, number> = {};109 for (const entry of getEntries()) {110 manifest[entry.id] = entry.modifiedAt;111 }112 return manifest;113 },114115 async buildTombstoneManifest() {116 117 118 119 120 121 122 123 124 125 126 requireUnlocked();127 const manifest: Record<string, number> = {};128 for (const entry of getDeletedEntries()) {129 if (typeof entry.deletedAt === "number") {130 manifest[entry.id] = entry.deletedAt;131 }132 }133 return manifest;134 },135136 async getEncryptedEntries(entryIds) {137 requireUnlocked();138 const masterKey = requireMasterKey();139 const out: Record<string, string> = {};140 for (const id of entryIds) {141 const entry = getEntryById(id);142 if (!entry) continue;143 const key = await deriveEntryKey(masterKey, id);144 out[id] = await encryptEntry(key, entry);145 }146 return out;147 },148149 async applyEntries(entries) {150 requireUnlocked();151 const entryList = Object.entries(entries);152 153 if (entryList.length > SYNC_LIMITS.MAX_ENTRY_COUNT) {154 throw new Error(155 `sync entry count ${entryList.length} exceeds limit ${SYNC_LIMITS.MAX_ENTRY_COUNT}`,156 );157 }158 const masterKey = requireMasterKey();159 const decrypted: VaultEntry[] = [];160 for (const [id, envelope] of entryList) {161 162 163 if (envelope.length > (SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3 + 64) {164 continue; 165 }166 const key = await deriveEntryKey(masterKey, id);167 const entry = await decryptEntryOrNull(key, envelope);168 if (!entry) continue;169 170 if (JSON.stringify(entry).length > SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES) {171 continue;172 }173 174 175 if (entry.id !== id) continue;176 decrypted.push(entry);177 }178 if (decrypted.length > 0) {179 await applyMergedRemoteEntries(decrypted, {}, context);180 }181 },182183 async applyTombstones(tombstones) {184 requireUnlocked();185 const tombstoneList = Object.entries(tombstones);186 187 if (tombstoneList.length > SYNC_LIMITS.MAX_TOMBSTONE_COUNT) {188 throw new Error(189 `sync tombstone count ${tombstoneList.length} exceeds limit ${SYNC_LIMITS.MAX_TOMBSTONE_COUNT}`,190 );191 }192 const cleaned: Record<string, number> = {};193 for (const [id, ts] of tombstoneList) {194 if (typeof ts === "number" && Number.isFinite(ts) && ts > 0) {195 196 197 198 cleaned[id] = clampSyncTimestamp(ts);199 }200 }201 if (Object.keys(cleaned).length > 0) {202 await applyMergedRemoteEntries([], cleaned, context);203 }204 },205206 async buildOutgoingDeviceManifest() {207 requireUnlocked();208 const masterKey = requireMasterKey();209 const issuerDeviceId = await getOrCreateDeviceId();210 const signing = await deriveSyncSigningKeypair(masterKey, issuerDeviceId);211 const revoked = await listDeviceRevocations();212 const revokedIds = new Set(revoked.map((r) => r.deviceId));213 const devices = (await listPairedDevices())214 .filter((d) => !revokedIds.has(d.deviceId))215 .map(toManifestEntry)216 .filter((d): d is ManifestDeviceEntry => d !== null);217 return buildSignedDeviceManifest(218 issuerDeviceId,219 devices,220 signing.privateKey,221 { revoked },222 );223 },224225 async applyIncomingDeviceManifest(manifest, authenticatedRemoteStaticPublicKey) {226 requireUnlocked();227 228 console.info("[Peach diag] applyIncomingDeviceManifest: issuerDeviceId=" + manifest?.body?.issuerDeviceId + " devices=" + (manifest?.body?.devices ?? []).map((d) => d.deviceId).join(",") + " revoked=" + (manifest?.body?.revoked ?? []).length);229 try {230 await applyVerifiedPeerManifest(231 manifest,232 authenticatedRemoteStaticPublicKey,233 );234 } catch (diagError) {235 console.info("[Peach diag] applyVerifiedPeerManifest threw:", diagError);236 }237 const all = await listPairedDevices();238 console.info("[Peach diag] post-manifest paired-devices:", all.map((d) => d.deviceId + "/" + d.trustSource).join(","));239 },240 };241}242243function toManifestEntry(device: PairedDevice): ManifestDeviceEntry | null {244 if (245 !device.deviceId ||246 !device.deviceName ||247 !device.deviceType ||248 !device.longTermPublicKeyHex ||249 !/^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex) ||250 !device.signingPublicKeyHex ||251 !/^[0-9a-f]{64}$/.test(device.signingPublicKeyHex)252 ) {253 return null;254 }255 return {256 deviceId: device.deviceId,257 deviceName: device.deviceName,258 deviceType: device.deviceType,259 longTermPublicKeyHex: device.longTermPublicKeyHex,260 signingPublicKeyHex: device.signingPublicKeyHex,261 wsPort: device.wsPort,262 relayUrl: device.relayUrl,263 pairedAt: device.pairedAt,264 };265}266267268269270271272273274275276277278279280281async function applyVerifiedPeerManifest(282 manifest: SignedDeviceManifest,283 authenticatedRemoteStaticPublicKey: Uint8Array,284): Promise<void> {285 286 console.info("[Peach diag] applyVerifiedPeerManifest enter: authPubKeyLen=" + authenticatedRemoteStaticPublicKey.length + " manifestIssuer=" + manifest?.body?.issuerDeviceId);287 const issuerId = manifest?.body?.issuerDeviceId;288 if (typeof issuerId !== "string" || !issuerId) { console.info("[Peach diag] early return: missing issuerId"); return; }289290 if (authenticatedRemoteStaticPublicKey.length !== 32) {291 console.info("[Peach diag] early return: auth key not 32 bytes");292 return;293 }294 const authenticatedPubkeyHex = pubkeyToHex(authenticatedRemoteStaticPublicKey);295 const issuer = await findPairedDeviceByPubkey(authenticatedPubkeyHex);296 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));297 if (298 !issuer ||299 issuer.deviceId !== issuerId ||300 (301 issuer.trustSource !== "direct" &&302 issuer.trustSource !== "vouched"303 ) ||304 !issuer.signingPublicKeyHex ||305 !/^[0-9a-f]{64}$/.test(issuer.signingPublicKeyHex)306 ) {307 console.info("[Peach diag] early return: issuer gate");308 return;309 }310311 let body: Awaited<ReturnType<typeof verifyDeviceManifest>>;312 try {313 body = await verifyDeviceManifest(314 manifest,315 hexToPubkey(issuer!.signingPublicKeyHex!),316 );317 318 console.info("[Peach diag] verifyDeviceManifest OK with stored key");319 } catch (storedError) {320 321 console.info("[Peach diag] verifyDeviceManifest with stored key threw:", String(storedError));322 323 324 325 326 327 const masterForIssuer = requireMasterKey();328 try {329 const derived = await deriveSyncSigningKeypair(330 masterForIssuer,331 issuer.deviceId,332 );333 334 console.info("[Peach diag] retry with derived key: storedSigning=" + issuer!.signingPublicKeyHex!.slice(0,12) + " derivedSigning=" + Buffer.from(derived.publicKeyRaw).toString("hex").slice(0,12));335 body = await verifyDeviceManifest(manifest, derived.publicKeyRaw);336 console.info("[Peach diag] verifyDeviceManifest OK with derived key");337 } catch (derivedError) {338 339 console.info("[Peach diag] verifyDeviceManifest with derived key threw:", String(derivedError));340 return;341 } finally {342 masterForIssuer.fill(0);343 }344 }345 console.info("[Peach diag] body parsed: bodyIssuerId=" + body.issuerDeviceId + " issuerStoredId=" + issuer.deviceId);346 if (body.issuerDeviceId !== issuer.deviceId) { console.info("[Peach diag] early return: body issuer mismatch"); return; }347348 const ourDeviceId = await getOrCreateDeviceId();349 const masterKey = requireMasterKey();350 const localKeys = deriveSyncIdentityKeypair(masterKey, ourDeviceId);351352 try {353 354 355 356 357 const newlyRevoked = await mergeDeviceRevocations(358 body.revoked.filter((r) => r.deviceId !== ourDeviceId),359 );360 for (const deviceId of newlyRevoked) {361 await removePairedDeviceById(deviceId);362 await removePendingVouchedDevice(deviceId);363 void recordSyncAlerts([364 {365 type: "DEVICE_REVOKED",366 entryId: "",367 entryTitle: deviceId,368 deviceName: issuer.deviceName ?? "paired device",369 timestamp: Date.now(),370 },371 ]);372 }373374 for (const entry of body.devices) {375 if (entry.deviceId === ourDeviceId || entry.deviceId === issuer.deviceId) {376 377 console.info("[Peach diag] skip entry (self or issuer):", entry.deviceId);378 continue;379 }380 381 382 383 384 385 if (await isDeviceRevoked(entry.deviceId, entry.longTermPublicKeyHex)) {386 387 console.info("[Peach diag] skip entry (revoked):", entry.deviceId, "longTerm=" + entry.longTermPublicKeyHex?.slice(0, 12) + "...", "hasRelay=" + !!entry.relayUrl);388 continue;389 }390 const existing = await findPairedDeviceById(entry.deviceId);391 const meshRoute = deriveManifestMeshRoute(392 masterKey,393 localKeys.publicKey,394 entry,395 );396 397 if (existing) {398 console.info("[Peach diag] skip entry (existing):", entry.deviceId, "trustSource=" + existing.trustSource);399 } else if (!meshRoute) {400 console.info("[Peach diag] skip entry (no mesh route):", entry.deviceId, "hasRelay=" + !!entry.relayUrl);401 } else {402 console.info("[Peach diag] admit entry:", entry.deviceId, "relay=" + meshRoute.relayUrl);403 }404405 406 407 408 409 410 if (!existing) {411 if (!meshRoute) continue;412 await upsertPairedDevice({413 deviceId: entry.deviceId,414 deviceName: entry.deviceName,415 deviceType: entry.deviceType,416 longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),417 signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),418 wsPort: entry.wsPort,419 rendezvousSecretHex: meshRoute.rendezvousSecretHex,420 relayUrl: meshRoute.relayUrl,421 pairedAt: entry.pairedAt,422 trustSource: "vouched",423 });424 await removePendingVouchedDevice(entry.deviceId);425 continue;426 }427428 429 430 431 432 if (433 existing.longTermPublicKeyHex &&434 !constantTimeEqualHex(435 existing.longTermPublicKeyHex,436 entry.longTermPublicKeyHex,437 )438 ) {439 440 441 442 443 444 const knownName = existing.deviceName ?? entry.deviceName;445 notifyPopup({446 type: "DEVICE_KEY_ROTATED",447 payload: { deviceName: knownName, deviceId: entry.deviceId },448 });449 void recordSyncAlerts([450 {451 type: "DEVICE_KEY_ROTATED",452 entryId: "",453 entryTitle: knownName,454 deviceName: knownName,455 timestamp: Date.now(),456 },457 ]);458 }459460 461 462 463 464 await upsertPairedDevice({465 ...existing,466 deviceId: entry.deviceId,467 deviceName: existing.deviceName ?? entry.deviceName,468 deviceType: entry.deviceType,469 longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),470 signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),471 pairedAt: existing.pairedAt ?? entry.pairedAt,472 trustSource: existing.trustSource ?? "direct",473 wsPort: existing.wsPort ?? entry.wsPort,474 rendezvousSecretHex:475 existing.trustSource === "vouched" && meshRoute476 ? meshRoute.rendezvousSecretHex477 : existing.rendezvousSecretHex,478 relayUrl:479 existing.trustSource === "vouched" && meshRoute480 ? meshRoute.relayUrl481 : existing.relayUrl,482 lastSyncedAt: existing.lastSyncedAt,483 });484 }485 } finally {486 masterKey.fill(0);487 localKeys.secretKey.fill(0);488 }489}490491function deriveManifestMeshRoute(492 masterKey: Uint8Array,493 localPublicKey: Uint8Array,494 entry: ManifestDeviceEntry,495): { rendezvousSecretHex: string; relayUrl: string } | null {496 try {497 if (!entry.relayUrl) return null;498 const relayUrl = normalizeRelayUrl(entry.relayUrl);499 const secret = deriveMeshRendezvousSecret(500 masterKey,501 localPublicKey,502 hexToPubkey(entry.longTermPublicKeyHex),503 );504 try {505 return {506 rendezvousSecretHex: rendezvousBytesToHex(secret),507 relayUrl,508 };509 } finally {510 secret.fill(0);511 }512 } catch {513 return null;514 }515}516517function notifyPopup(msg: { type: string; payload?: unknown }): void {518 try {519 void chrome.runtime.sendMessage(msg).catch(() => undefined);520 } catch {521 522 }523}524525function requireUnlocked(): void {526 if (!isUnlocked()) throw new VaultLockedError();527}528529530531532533534535function requireMasterKey(): Uint8Array {536 if (!isUnlocked()) throw new VaultLockedError();537 const hex = getRootKeyHex();538 if (!hex) throw new VaultLockedError();539 return hexToBytes(hex);540}541542async function deriveEntryKey(masterKey: Uint8Array, entryId: string): Promise<CryptoKey> {543 544 545 546 547 const info = new TextEncoder().encode(`${entryId}\x00${ENTRY_KEY_INFO_SUFFIX}`);548 const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), info).expand(32);549 return crypto.subtle.importKey(550 "raw",551 raw.buffer as ArrayBuffer,552 { name: "AES-GCM" },553 false,554 ["encrypt", "decrypt"],555 );556}557558async function encryptEntry(key: CryptoKey, entry: VaultEntry): Promise<string> {559 const plaintext = new TextEncoder().encode(JSON.stringify(entry));560 const nonce = crypto.getRandomValues(new Uint8Array(12));561 const aad = new TextEncoder().encode(ENTRY_AAD);562 const ct = await crypto.subtle.encrypt(563 {564 name: "AES-GCM",565 iv: nonce.buffer as ArrayBuffer,566 additionalData: aad.buffer as ArrayBuffer,567 tagLength: 128,568 },569 key,570 plaintext.buffer as ArrayBuffer,571 );572 const ctBytes = new Uint8Array(ct);573 const envelope = new Uint8Array(nonce.length + ctBytes.length);574 envelope.set(nonce, 0);575 envelope.set(ctBytes, nonce.length);576 return bytesToBase64(envelope);577}578579async function decryptEntryOrNull(key: CryptoKey, envelopeB64: string): Promise<VaultEntry | null> {580 try {581 const bytes = base64ToBytes(envelopeB64);582 if (bytes.length < 12 + 16) return null;583 const nonce = bytes.subarray(0, 12);584 const ct = bytes.subarray(12);585 const aad = new TextEncoder().encode(ENTRY_AAD);586 const pt = await crypto.subtle.decrypt(587 {588 name: "AES-GCM",589 iv: nonce.buffer.slice(nonce.byteOffset, nonce.byteOffset + nonce.byteLength) as ArrayBuffer,590 additionalData: aad.buffer as ArrayBuffer,591 tagLength: 128,592 },593 key,594 ct.buffer.slice(ct.byteOffset, ct.byteOffset + ct.byteLength) as ArrayBuffer,595 );596 const json = new TextDecoder().decode(pt);597 const parsed = JSON.parse(json);598 if (!parsed || typeof parsed !== "object" || typeof parsed.id !== "string") return null;599 return parsed as VaultEntry;600 } catch {601 return null;602 }603}604605function hexToBytes(hex: string): Uint8Array {606 if (hex.length % 2 !== 0) throw new Error("hex length must be even");607 const out = new Uint8Array(hex.length / 2);608 for (let i = 0; i < out.length; i++) {609 out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);610 }611 return out;612}613614function bytesToBase64(bytes: Uint8Array): string {615 let bin = "";616 for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);617 return btoa(bin);618}619620function base64ToBytes(b64: string): Uint8Array {621 const bin = atob(b64);622 const out = new Uint8Array(bin.length);623 for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);624 return out;625}