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 requireUnlocked();124 const manifest: Record<string, number> = {};125 for (const entry of getDeletedEntries()) {126 if (typeof entry.deletedAt === "number") {127 manifest[entry.id] = entry.deletedAt;128 }129 }130 return manifest;131 },132133 async getEncryptedEntries(entryIds) {134 requireUnlocked();135 const masterKey = requireMasterKey();136 const out: Record<string, string> = {};137 for (const id of entryIds) {138 const entry = getEntryById(id);139 if (!entry) continue;140 const key = await deriveEntryKey(masterKey, id);141 out[id] = await encryptEntry(key, entry);142 }143 return out;144 },145146 async applyEntries(entries) {147 requireUnlocked();148 const entryList = Object.entries(entries);149 150 if (entryList.length > SYNC_LIMITS.MAX_ENTRY_COUNT) {151 throw new Error(152 `sync entry count ${entryList.length} exceeds limit ${SYNC_LIMITS.MAX_ENTRY_COUNT}`,153 );154 }155 const masterKey = requireMasterKey();156 const decrypted: VaultEntry[] = [];157 for (const [id, envelope] of entryList) {158 159 160 if (envelope.length > (SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3 + 64) {161 continue; 162 }163 const key = await deriveEntryKey(masterKey, id);164 const entry = await decryptEntryOrNull(key, envelope);165 if (!entry) continue;166 167 if (JSON.stringify(entry).length > SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES) {168 continue;169 }170 171 172 if (entry.id !== id) continue;173 decrypted.push(entry);174 }175 if (decrypted.length > 0) {176 await applyMergedRemoteEntries(decrypted, {}, context);177 }178 },179180 async applyTombstones(tombstones) {181 requireUnlocked();182 const tombstoneList = Object.entries(tombstones);183 184 if (tombstoneList.length > SYNC_LIMITS.MAX_TOMBSTONE_COUNT) {185 throw new Error(186 `sync tombstone count ${tombstoneList.length} exceeds limit ${SYNC_LIMITS.MAX_TOMBSTONE_COUNT}`,187 );188 }189 const cleaned: Record<string, number> = {};190 for (const [id, ts] of tombstoneList) {191 if (typeof ts === "number" && Number.isFinite(ts) && ts > 0) {192 193 194 195 cleaned[id] = clampSyncTimestamp(ts);196 }197 }198 if (Object.keys(cleaned).length > 0) {199 await applyMergedRemoteEntries([], cleaned, context);200 }201 },202203 async buildOutgoingDeviceManifest() {204 requireUnlocked();205 const masterKey = requireMasterKey();206 const issuerDeviceId = await getOrCreateDeviceId();207 const signing = await deriveSyncSigningKeypair(masterKey, issuerDeviceId);208 const revoked = await listDeviceRevocations();209 const revokedIds = new Set(revoked.map((r) => r.deviceId));210 const devices = (await listPairedDevices())211 .filter((d) => !revokedIds.has(d.deviceId))212 .map(toManifestEntry)213 .filter((d): d is ManifestDeviceEntry => d !== null);214 return buildSignedDeviceManifest(215 issuerDeviceId,216 devices,217 signing.privateKey,218 { revoked },219 );220 },221222 async applyIncomingDeviceManifest(manifest, authenticatedRemoteStaticPublicKey) {223 requireUnlocked();224 await applyVerifiedPeerManifest(225 manifest,226 authenticatedRemoteStaticPublicKey,227 );228 },229 };230}231232function toManifestEntry(device: PairedDevice): ManifestDeviceEntry | null {233 if (234 !device.deviceId ||235 !device.deviceName ||236 !device.deviceType ||237 !device.longTermPublicKeyHex ||238 !/^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex) ||239 !device.signingPublicKeyHex ||240 !/^[0-9a-f]{64}$/.test(device.signingPublicKeyHex)241 ) {242 return null;243 }244 return {245 deviceId: device.deviceId,246 deviceName: device.deviceName,247 deviceType: device.deviceType,248 longTermPublicKeyHex: device.longTermPublicKeyHex,249 signingPublicKeyHex: device.signingPublicKeyHex,250 wsPort: device.wsPort,251 relayUrl: device.relayUrl,252 pairedAt: device.pairedAt,253 };254}255256257258259260261262263264265266267268269270async function applyVerifiedPeerManifest(271 manifest: SignedDeviceManifest,272 authenticatedRemoteStaticPublicKey: Uint8Array,273): Promise<void> {274 const issuerId = manifest?.body?.issuerDeviceId;275 if (typeof issuerId !== "string" || !issuerId) return;276277 if (authenticatedRemoteStaticPublicKey.length !== 32) return;278 const authenticatedPubkeyHex = pubkeyToHex(authenticatedRemoteStaticPublicKey);279 const issuer = await findPairedDeviceByPubkey(authenticatedPubkeyHex);280 if (281 !issuer ||282 issuer.deviceId !== issuerId ||283 (284 issuer.trustSource !== "direct" &&285 issuer.trustSource !== "vouched"286 ) ||287 !issuer.signingPublicKeyHex ||288 !/^[0-9a-f]{64}$/.test(issuer.signingPublicKeyHex)289 ) {290 return;291 }292293 let body: Awaited<ReturnType<typeof verifyDeviceManifest>>;294 try {295 body = await verifyDeviceManifest(296 manifest,297 hexToPubkey(issuer.signingPublicKeyHex),298 );299 } catch {300 return;301 }302 if (body.issuerDeviceId !== issuer.deviceId) return;303304 const ourDeviceId = await getOrCreateDeviceId();305 const masterKey = requireMasterKey();306 const localKeys = deriveSyncIdentityKeypair(masterKey, ourDeviceId);307308 try {309 310 311 312 313 const newlyRevoked = await mergeDeviceRevocations(314 body.revoked.filter((r) => r.deviceId !== ourDeviceId),315 );316 for (const deviceId of newlyRevoked) {317 await removePairedDeviceById(deviceId);318 await removePendingVouchedDevice(deviceId);319 void recordSyncAlerts([320 {321 type: "DEVICE_REVOKED",322 entryId: "",323 entryTitle: deviceId,324 deviceName: issuer.deviceName ?? "paired device",325 timestamp: Date.now(),326 },327 ]);328 }329330 for (const entry of body.devices) {331 if (entry.deviceId === ourDeviceId || entry.deviceId === issuer.deviceId) {332 continue;333 }334 335 336 337 338 339 if (await isDeviceRevoked(entry.deviceId, entry.longTermPublicKeyHex)) {340 continue;341 }342 const existing = await findPairedDeviceById(entry.deviceId);343 const meshRoute = deriveManifestMeshRoute(344 masterKey,345 localKeys.publicKey,346 entry,347 );348349 350 351 352 353 354 if (!existing) {355 if (!meshRoute) continue;356 await upsertPairedDevice({357 deviceId: entry.deviceId,358 deviceName: entry.deviceName,359 deviceType: entry.deviceType,360 longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),361 signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),362 wsPort: entry.wsPort,363 rendezvousSecretHex: meshRoute.rendezvousSecretHex,364 relayUrl: meshRoute.relayUrl,365 pairedAt: entry.pairedAt,366 trustSource: "vouched",367 });368 await removePendingVouchedDevice(entry.deviceId);369 continue;370 }371372 373 374 375 376 if (377 existing.longTermPublicKeyHex &&378 !constantTimeEqualHex(379 existing.longTermPublicKeyHex,380 entry.longTermPublicKeyHex,381 )382 ) {383 384 385 386 387 388 const knownName = existing.deviceName ?? entry.deviceName;389 notifyPopup({390 type: "DEVICE_KEY_ROTATED",391 payload: { deviceName: knownName, deviceId: entry.deviceId },392 });393 void recordSyncAlerts([394 {395 type: "DEVICE_KEY_ROTATED",396 entryId: "",397 entryTitle: knownName,398 deviceName: knownName,399 timestamp: Date.now(),400 },401 ]);402 }403404 405 406 407 408 await upsertPairedDevice({409 ...existing,410 deviceId: entry.deviceId,411 deviceName: existing.deviceName ?? entry.deviceName,412 deviceType: entry.deviceType,413 longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),414 signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),415 pairedAt: existing.pairedAt ?? entry.pairedAt,416 trustSource: existing.trustSource ?? "direct",417 wsPort: existing.wsPort ?? entry.wsPort,418 rendezvousSecretHex:419 existing.trustSource === "vouched" && meshRoute420 ? meshRoute.rendezvousSecretHex421 : existing.rendezvousSecretHex,422 relayUrl:423 existing.trustSource === "vouched" && meshRoute424 ? meshRoute.relayUrl425 : existing.relayUrl,426 lastSyncedAt: existing.lastSyncedAt,427 });428 }429 } finally {430 masterKey.fill(0);431 localKeys.secretKey.fill(0);432 }433}434435function deriveManifestMeshRoute(436 masterKey: Uint8Array,437 localPublicKey: Uint8Array,438 entry: ManifestDeviceEntry,439): { rendezvousSecretHex: string; relayUrl: string } | null {440 try {441 if (!entry.relayUrl) return null;442 const relayUrl = normalizeRelayUrl(entry.relayUrl);443 const secret = deriveMeshRendezvousSecret(444 masterKey,445 localPublicKey,446 hexToPubkey(entry.longTermPublicKeyHex),447 );448 try {449 return {450 rendezvousSecretHex: rendezvousBytesToHex(secret),451 relayUrl,452 };453 } finally {454 secret.fill(0);455 }456 } catch {457 return null;458 }459}460461function notifyPopup(msg: { type: string; payload?: unknown }): void {462 try {463 void chrome.runtime.sendMessage(msg).catch(() => undefined);464 } catch {465 466 }467}468469function requireUnlocked(): void {470 if (!isUnlocked()) throw new VaultLockedError();471}472473474475476477478479function requireMasterKey(): Uint8Array {480 if (!isUnlocked()) throw new VaultLockedError();481 const hex = getRootKeyHex();482 if (!hex) throw new VaultLockedError();483 return hexToBytes(hex);484}485486async function deriveEntryKey(masterKey: Uint8Array, entryId: string): Promise<CryptoKey> {487 488 489 490 491 const info = new TextEncoder().encode(`${entryId}\x00${ENTRY_KEY_INFO_SUFFIX}`);492 const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), info).expand(32);493 return crypto.subtle.importKey(494 "raw",495 raw.buffer as ArrayBuffer,496 { name: "AES-GCM" },497 false,498 ["encrypt", "decrypt"],499 );500}501502async function encryptEntry(key: CryptoKey, entry: VaultEntry): Promise<string> {503 const plaintext = new TextEncoder().encode(JSON.stringify(entry));504 const nonce = crypto.getRandomValues(new Uint8Array(12));505 const aad = new TextEncoder().encode(ENTRY_AAD);506 const ct = await crypto.subtle.encrypt(507 {508 name: "AES-GCM",509 iv: nonce.buffer as ArrayBuffer,510 additionalData: aad.buffer as ArrayBuffer,511 tagLength: 128,512 },513 key,514 plaintext.buffer as ArrayBuffer,515 );516 const ctBytes = new Uint8Array(ct);517 const envelope = new Uint8Array(nonce.length + ctBytes.length);518 envelope.set(nonce, 0);519 envelope.set(ctBytes, nonce.length);520 return bytesToBase64(envelope);521}522523async function decryptEntryOrNull(key: CryptoKey, envelopeB64: string): Promise<VaultEntry | null> {524 try {525 const bytes = base64ToBytes(envelopeB64);526 if (bytes.length < 12 + 16) return null;527 const nonce = bytes.subarray(0, 12);528 const ct = bytes.subarray(12);529 const aad = new TextEncoder().encode(ENTRY_AAD);530 const pt = await crypto.subtle.decrypt(531 {532 name: "AES-GCM",533 iv: nonce.buffer.slice(nonce.byteOffset, nonce.byteOffset + nonce.byteLength) as ArrayBuffer,534 additionalData: aad.buffer as ArrayBuffer,535 tagLength: 128,536 },537 key,538 ct.buffer.slice(ct.byteOffset, ct.byteOffset + ct.byteLength) as ArrayBuffer,539 );540 const json = new TextDecoder().decode(pt);541 const parsed = JSON.parse(json);542 if (!parsed || typeof parsed !== "object" || typeof parsed.id !== "string") return null;543 return parsed as VaultEntry;544 } catch {545 return null;546 }547}548549function hexToBytes(hex: string): Uint8Array {550 if (hex.length % 2 !== 0) throw new Error("hex length must be even");551 const out = new Uint8Array(hex.length / 2);552 for (let i = 0; i < out.length; i++) {553 out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);554 }555 return out;556}557558function bytesToBase64(bytes: Uint8Array): string {559 let bin = "";560 for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);561 return btoa(bin);562}563564function base64ToBytes(b64: string): Uint8Array {565 const bin = atob(b64);566 const out = new Uint8Array(bin.length);567 for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);568 return out;569}