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 await applyVerifiedPeerManifest(228 manifest,229 authenticatedRemoteStaticPublicKey,230 );231 },232 };233}234235function toManifestEntry(device: PairedDevice): ManifestDeviceEntry | null {236 if (237 !device.deviceId ||238 !device.deviceName ||239 !device.deviceType ||240 !device.longTermPublicKeyHex ||241 !/^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex) ||242 !device.signingPublicKeyHex ||243 !/^[0-9a-f]{64}$/.test(device.signingPublicKeyHex)244 ) {245 return null;246 }247 return {248 deviceId: device.deviceId,249 deviceName: device.deviceName,250 deviceType: device.deviceType,251 longTermPublicKeyHex: device.longTermPublicKeyHex,252 signingPublicKeyHex: device.signingPublicKeyHex,253 wsPort: device.wsPort,254 relayUrl: device.relayUrl,255 pairedAt: device.pairedAt,256 };257}258259260261262263264265266267268269270271272273async function applyVerifiedPeerManifest(274 manifest: SignedDeviceManifest,275 authenticatedRemoteStaticPublicKey: Uint8Array,276): Promise<void> {277 const issuerId = manifest?.body?.issuerDeviceId;278 if (typeof issuerId !== "string" || !issuerId) { return; }279280 if (authenticatedRemoteStaticPublicKey.length !== 32) {281 return;282 }283 const authenticatedPubkeyHex = pubkeyToHex(authenticatedRemoteStaticPublicKey);284 const issuer = await findPairedDeviceByPubkey(authenticatedPubkeyHex);285 if (286 !issuer ||287 issuer.deviceId !== issuerId ||288 (289 issuer.trustSource !== "direct" &&290 issuer.trustSource !== "vouched"291 ) ||292 !issuer.signingPublicKeyHex ||293 !/^[0-9a-f]{64}$/.test(issuer.signingPublicKeyHex)294 ) {295 296 console.info("[Peach diag] issuer gate rejected", {297 authenticatedPubkeyHex,298 claimedIssuerId: issuerId,299 found: !!issuer,300 foundId: issuer?.deviceId,301 trustSource: issuer?.trustSource,302 hasSigningKey: !!issuer?.signingPublicKeyHex,303 });304 return;305 }306307 let body: Awaited<ReturnType<typeof verifyDeviceManifest>>;308 try {309 body = await verifyDeviceManifest(310 manifest,311 hexToPubkey(issuer!.signingPublicKeyHex!),312 );313 } catch (storedError) {314 315 316 317 318 319 const masterForIssuer = requireMasterKey();320 try {321 const derived = await deriveSyncSigningKeypair(322 masterForIssuer,323 issuer.deviceId,324 );325 body = await verifyDeviceManifest(manifest, derived.publicKeyRaw);326 } catch (derivedError) {327 328 console.info("[Peach diag] manifest signature rejected (stored + derived)", {329 issuerId: issuer.deviceId,330 storedError: String(storedError),331 derivedError: String(derivedError),332 });333 return;334 } finally {335 masterForIssuer.fill(0);336 }337 }338 if (body.issuerDeviceId !== issuer.deviceId) {339 340 console.info("[Peach diag] issuerDeviceId mismatch", {341 body: body.issuerDeviceId,342 expected: issuer.deviceId,343 });344 return;345 }346 347 console.info("[Peach diag] manifest verified", {348 issuer: issuer.deviceId,349 devices: body.devices.map((d) => ({ id: d.deviceId, relayUrl: d.relayUrl ?? null })),350 revoked: body.revoked.map((r) => r.deviceId),351 });352353 const ourDeviceId = await getOrCreateDeviceId();354 const masterKey = requireMasterKey();355 const localKeys = deriveSyncIdentityKeypair(masterKey, ourDeviceId);356357 try {358 359 360 361 362 const newlyRevoked = await mergeDeviceRevocations(363 body.revoked.filter((r) => r.deviceId !== ourDeviceId),364 );365 for (const deviceId of newlyRevoked) {366 await removePairedDeviceById(deviceId);367 await removePendingVouchedDevice(deviceId);368 void recordSyncAlerts([369 {370 type: "DEVICE_REVOKED",371 entryId: "",372 entryTitle: deviceId,373 deviceName: issuer.deviceName ?? "paired device",374 timestamp: Date.now(),375 },376 ]);377 }378379 for (const entry of body.devices) {380 if (entry.deviceId === ourDeviceId || entry.deviceId === issuer.deviceId) {381 continue;382 }383 384 385 386 387 388 if (await isDeviceRevoked(entry.deviceId, entry.longTermPublicKeyHex)) {389 continue;390 }391 const existing = await findPairedDeviceById(entry.deviceId);392 const meshRoute = deriveManifestMeshRoute(393 masterKey,394 localKeys.publicKey,395 entry,396 );397 398 console.info("[Peach diag] entry", {399 deviceId: entry.deviceId,400 hasRelayUrl: !!entry.relayUrl,401 revoked: await isDeviceRevoked(entry.deviceId, entry.longTermPublicKeyHex),402 existing: !!existing,403 hasMeshRoute: !!meshRoute,404 });405406 407 408 409 410 411 if (!existing) {412 if (!meshRoute) continue;413 await upsertPairedDevice({414 deviceId: entry.deviceId,415 deviceName: entry.deviceName,416 deviceType: entry.deviceType,417 longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),418 signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),419 wsPort: entry.wsPort,420 rendezvousSecretHex: meshRoute.rendezvousSecretHex,421 relayUrl: meshRoute.relayUrl,422 pairedAt: entry.pairedAt,423 trustSource: "vouched",424 });425 await removePendingVouchedDevice(entry.deviceId);426 continue;427 }428429 430 431 432 433 if (434 existing.longTermPublicKeyHex &&435 !constantTimeEqualHex(436 existing.longTermPublicKeyHex,437 entry.longTermPublicKeyHex,438 )439 ) {440 441 442 443 444 445 const knownName = existing.deviceName ?? entry.deviceName;446 notifyPopup({447 type: "DEVICE_KEY_ROTATED",448 payload: { deviceName: knownName, deviceId: entry.deviceId },449 });450 void recordSyncAlerts([451 {452 type: "DEVICE_KEY_ROTATED",453 entryId: "",454 entryTitle: knownName,455 deviceName: knownName,456 timestamp: Date.now(),457 },458 ]);459 }460461 462 463 464 465 await upsertPairedDevice({466 ...existing,467 deviceId: entry.deviceId,468 deviceName: existing.deviceName ?? entry.deviceName,469 deviceType: entry.deviceType,470 longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),471 signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),472 pairedAt: existing.pairedAt ?? entry.pairedAt,473 trustSource: existing.trustSource ?? "direct",474 wsPort: existing.wsPort ?? entry.wsPort,475 rendezvousSecretHex:476 existing.trustSource === "vouched" && meshRoute477 ? meshRoute.rendezvousSecretHex478 : existing.rendezvousSecretHex,479 relayUrl:480 existing.trustSource === "vouched" && meshRoute481 ? meshRoute.relayUrl482 : existing.relayUrl,483 lastSyncedAt: existing.lastSyncedAt,484 });485 }486 } finally {487 masterKey.fill(0);488 localKeys.secretKey.fill(0);489 }490}491492function deriveManifestMeshRoute(493 masterKey: Uint8Array,494 localPublicKey: Uint8Array,495 entry: ManifestDeviceEntry,496): { rendezvousSecretHex: string; relayUrl: string } | null {497 try {498 if (!entry.relayUrl) return null;499 const relayUrl = normalizeRelayUrl(entry.relayUrl);500 const secret = deriveMeshRendezvousSecret(501 masterKey,502 localPublicKey,503 hexToPubkey(entry.longTermPublicKeyHex),504 );505 try {506 return {507 rendezvousSecretHex: rendezvousBytesToHex(secret),508 relayUrl,509 };510 } finally {511 secret.fill(0);512 }513 } catch {514 return null;515 }516}517518function notifyPopup(msg: { type: string; payload?: unknown }): void {519 try {520 void chrome.runtime.sendMessage(msg).catch(() => undefined);521 } catch {522 523 }524}525526function requireUnlocked(): void {527 if (!isUnlocked()) throw new VaultLockedError();528}529530531532533534535536function requireMasterKey(): Uint8Array {537 if (!isUnlocked()) throw new VaultLockedError();538 const hex = getRootKeyHex();539 if (!hex) throw new VaultLockedError();540 return hexToBytes(hex);541}542543async function deriveEntryKey(masterKey: Uint8Array, entryId: string): Promise<CryptoKey> {544 545 546 547 548 const info = new TextEncoder().encode(`${entryId}\x00${ENTRY_KEY_INFO_SUFFIX}`);549 const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), info).expand(32);550 return crypto.subtle.importKey(551 "raw",552 raw.buffer as ArrayBuffer,553 { name: "AES-GCM" },554 false,555 ["encrypt", "decrypt"],556 );557}558559async function encryptEntry(key: CryptoKey, entry: VaultEntry): Promise<string> {560 const plaintext = new TextEncoder().encode(JSON.stringify(entry));561 const nonce = crypto.getRandomValues(new Uint8Array(12));562 const aad = new TextEncoder().encode(ENTRY_AAD);563 const ct = await crypto.subtle.encrypt(564 {565 name: "AES-GCM",566 iv: nonce.buffer as ArrayBuffer,567 additionalData: aad.buffer as ArrayBuffer,568 tagLength: 128,569 },570 key,571 plaintext.buffer as ArrayBuffer,572 );573 const ctBytes = new Uint8Array(ct);574 const envelope = new Uint8Array(nonce.length + ctBytes.length);575 envelope.set(nonce, 0);576 envelope.set(ctBytes, nonce.length);577 return bytesToBase64(envelope);578}579580async function decryptEntryOrNull(key: CryptoKey, envelopeB64: string): Promise<VaultEntry | null> {581 try {582 const bytes = base64ToBytes(envelopeB64);583 if (bytes.length < 12 + 16) return null;584 const nonce = bytes.subarray(0, 12);585 const ct = bytes.subarray(12);586 const aad = new TextEncoder().encode(ENTRY_AAD);587 const pt = await crypto.subtle.decrypt(588 {589 name: "AES-GCM",590 iv: nonce.buffer.slice(nonce.byteOffset, nonce.byteOffset + nonce.byteLength) as ArrayBuffer,591 additionalData: aad.buffer as ArrayBuffer,592 tagLength: 128,593 },594 key,595 ct.buffer.slice(ct.byteOffset, ct.byteOffset + ct.byteLength) as ArrayBuffer,596 );597 const json = new TextDecoder().decode(pt);598 const parsed = JSON.parse(json);599 if (!parsed || typeof parsed !== "object" || typeof parsed.id !== "string") return null;600 return parsed as VaultEntry;601 } catch {602 return null;603 }604}605606function hexToBytes(hex: string): Uint8Array {607 if (hex.length % 2 !== 0) throw new Error("hex length must be even");608 const out = new Uint8Array(hex.length / 2);609 for (let i = 0; i < out.length; i++) {610 out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);611 }612 return out;613}614615function bytesToBase64(bytes: Uint8Array): string {616 let bin = "";617 for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);618 return btoa(bin);619}620621function base64ToBytes(b64: string): Uint8Array {622 const bin = atob(b64);623 const out = new Uint8Array(bin.length);624 for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);625 return out;626}