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));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 console.info("[Peach diag] canonical len=" + canonicalStr.length + " head=" + canonicalStr.slice(0, 240));329 }330 331 332 333 334 335 const masterForIssuer = requireMasterKey();336 try {337 const derived = await deriveSyncSigningKeypair(338 masterForIssuer,339 issuer.deviceId,340 );341 342 const derivedHex = Array.from(derived.publicKeyRaw).map((b) => b.toString(16).padStart(2, "0")).join("");343 344 console.info("[Peach diag] retry with derived key: storedSigning=" + issuer!.signingPublicKeyHex!.slice(0,12) + " derivedSigning=" + derivedHex.slice(0,12));345 body = await verifyDeviceManifest(manifest, derived.publicKeyRaw);346 console.info("[Peach diag] verifyDeviceManifest OK with derived key");347 } catch (derivedError) {348 349 console.info("[Peach diag] verifyDeviceManifest with derived key threw:", String(derivedError));350 return;351 } finally {352 masterForIssuer.fill(0);353 }354 }355 console.info("[Peach diag] body parsed: bodyIssuerId=" + body.issuerDeviceId + " issuerStoredId=" + issuer.deviceId);356 if (body.issuerDeviceId !== issuer.deviceId) { console.info("[Peach diag] early return: body issuer mismatch"); return; }357358 const ourDeviceId = await getOrCreateDeviceId();359 const masterKey = requireMasterKey();360 const localKeys = deriveSyncIdentityKeypair(masterKey, ourDeviceId);361362 try {363 364 365 366 367 const newlyRevoked = await mergeDeviceRevocations(368 body.revoked.filter((r) => r.deviceId !== ourDeviceId),369 );370 for (const deviceId of newlyRevoked) {371 await removePairedDeviceById(deviceId);372 await removePendingVouchedDevice(deviceId);373 void recordSyncAlerts([374 {375 type: "DEVICE_REVOKED",376 entryId: "",377 entryTitle: deviceId,378 deviceName: issuer.deviceName ?? "paired device",379 timestamp: Date.now(),380 },381 ]);382 }383384 for (const entry of body.devices) {385 if (entry.deviceId === ourDeviceId || entry.deviceId === issuer.deviceId) {386 387 console.info("[Peach diag] skip entry (self or issuer):", entry.deviceId);388 continue;389 }390 391 392 393 394 395 if (await isDeviceRevoked(entry.deviceId, entry.longTermPublicKeyHex)) {396 397 console.info("[Peach diag] skip entry (revoked):", entry.deviceId, "longTerm=" + entry.longTermPublicKeyHex?.slice(0, 12) + "...", "hasRelay=" + !!entry.relayUrl);398 continue;399 }400 const existing = await findPairedDeviceById(entry.deviceId);401 const meshRoute = deriveManifestMeshRoute(402 masterKey,403 localKeys.publicKey,404 entry,405 );406 407 if (existing) {408 console.info("[Peach diag] skip entry (existing):", entry.deviceId, "trustSource=" + existing.trustSource);409 } else if (!meshRoute) {410 console.info("[Peach diag] skip entry (no mesh route):", entry.deviceId, "hasRelay=" + !!entry.relayUrl);411 } else {412 console.info("[Peach diag] admit entry:", entry.deviceId, "relay=" + meshRoute.relayUrl);413 }414415 416 417 418 419 420 if (!existing) {421 if (!meshRoute) continue;422 await upsertPairedDevice({423 deviceId: entry.deviceId,424 deviceName: entry.deviceName,425 deviceType: entry.deviceType,426 longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),427 signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),428 wsPort: entry.wsPort,429 rendezvousSecretHex: meshRoute.rendezvousSecretHex,430 relayUrl: meshRoute.relayUrl,431 pairedAt: entry.pairedAt,432 trustSource: "vouched",433 });434 await removePendingVouchedDevice(entry.deviceId);435 continue;436 }437438 439 440 441 442 if (443 existing.longTermPublicKeyHex &&444 !constantTimeEqualHex(445 existing.longTermPublicKeyHex,446 entry.longTermPublicKeyHex,447 )448 ) {449 450 451 452 453 454 const knownName = existing.deviceName ?? entry.deviceName;455 notifyPopup({456 type: "DEVICE_KEY_ROTATED",457 payload: { deviceName: knownName, deviceId: entry.deviceId },458 });459 void recordSyncAlerts([460 {461 type: "DEVICE_KEY_ROTATED",462 entryId: "",463 entryTitle: knownName,464 deviceName: knownName,465 timestamp: Date.now(),466 },467 ]);468 }469470 471 472 473 474 await upsertPairedDevice({475 ...existing,476 deviceId: entry.deviceId,477 deviceName: existing.deviceName ?? entry.deviceName,478 deviceType: entry.deviceType,479 longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),480 signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),481 pairedAt: existing.pairedAt ?? entry.pairedAt,482 trustSource: existing.trustSource ?? "direct",483 wsPort: existing.wsPort ?? entry.wsPort,484 rendezvousSecretHex:485 existing.trustSource === "vouched" && meshRoute486 ? meshRoute.rendezvousSecretHex487 : existing.rendezvousSecretHex,488 relayUrl:489 existing.trustSource === "vouched" && meshRoute490 ? meshRoute.relayUrl491 : existing.relayUrl,492 lastSyncedAt: existing.lastSyncedAt,493 });494 }495 } finally {496 masterKey.fill(0);497 localKeys.secretKey.fill(0);498 }499}500501function deriveManifestMeshRoute(502 masterKey: Uint8Array,503 localPublicKey: Uint8Array,504 entry: ManifestDeviceEntry,505): { rendezvousSecretHex: string; relayUrl: string } | null {506 try {507 if (!entry.relayUrl) return null;508 const relayUrl = normalizeRelayUrl(entry.relayUrl);509 const secret = deriveMeshRendezvousSecret(510 masterKey,511 localPublicKey,512 hexToPubkey(entry.longTermPublicKeyHex),513 );514 try {515 return {516 rendezvousSecretHex: rendezvousBytesToHex(secret),517 relayUrl,518 };519 } finally {520 secret.fill(0);521 }522 } catch {523 return null;524 }525}526527function notifyPopup(msg: { type: string; payload?: unknown }): void {528 try {529 void chrome.runtime.sendMessage(msg).catch(() => undefined);530 } catch {531 532 }533}534535function requireUnlocked(): void {536 if (!isUnlocked()) throw new VaultLockedError();537}538539540541542543544545function requireMasterKey(): Uint8Array {546 if (!isUnlocked()) throw new VaultLockedError();547 const hex = getRootKeyHex();548 if (!hex) throw new VaultLockedError();549 return hexToBytes(hex);550}551552async function deriveEntryKey(masterKey: Uint8Array, entryId: string): Promise<CryptoKey> {553 554 555 556 557 const info = new TextEncoder().encode(`${entryId}\x00${ENTRY_KEY_INFO_SUFFIX}`);558 const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), info).expand(32);559 return crypto.subtle.importKey(560 "raw",561 raw.buffer as ArrayBuffer,562 { name: "AES-GCM" },563 false,564 ["encrypt", "decrypt"],565 );566}567568async function encryptEntry(key: CryptoKey, entry: VaultEntry): Promise<string> {569 const plaintext = new TextEncoder().encode(JSON.stringify(entry));570 const nonce = crypto.getRandomValues(new Uint8Array(12));571 const aad = new TextEncoder().encode(ENTRY_AAD);572 const ct = await crypto.subtle.encrypt(573 {574 name: "AES-GCM",575 iv: nonce.buffer as ArrayBuffer,576 additionalData: aad.buffer as ArrayBuffer,577 tagLength: 128,578 },579 key,580 plaintext.buffer as ArrayBuffer,581 );582 const ctBytes = new Uint8Array(ct);583 const envelope = new Uint8Array(nonce.length + ctBytes.length);584 envelope.set(nonce, 0);585 envelope.set(ctBytes, nonce.length);586 return bytesToBase64(envelope);587}588589async function decryptEntryOrNull(key: CryptoKey, envelopeB64: string): Promise<VaultEntry | null> {590 try {591 const bytes = base64ToBytes(envelopeB64);592 if (bytes.length < 12 + 16) return null;593 const nonce = bytes.subarray(0, 12);594 const ct = bytes.subarray(12);595 const aad = new TextEncoder().encode(ENTRY_AAD);596 const pt = await crypto.subtle.decrypt(597 {598 name: "AES-GCM",599 iv: nonce.buffer.slice(nonce.byteOffset, nonce.byteOffset + nonce.byteLength) as ArrayBuffer,600 additionalData: aad.buffer as ArrayBuffer,601 tagLength: 128,602 },603 key,604 ct.buffer.slice(ct.byteOffset, ct.byteOffset + ct.byteLength) as ArrayBuffer,605 );606 const json = new TextDecoder().decode(pt);607 const parsed = JSON.parse(json);608 if (!parsed || typeof parsed !== "object" || typeof parsed.id !== "string") return null;609 return parsed as VaultEntry;610 } catch {611 return null;612 }613}614615function hexToBytes(hex: string): Uint8Array {616 if (hex.length % 2 !== 0) throw new Error("hex length must be even");617 const out = new Uint8Array(hex.length / 2);618 for (let i = 0; i < out.length; i++) {619 out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);620 }621 return out;622}623624function bytesToBase64(bytes: Uint8Array): string {625 let bin = "";626 for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);627 return btoa(bin);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}