12345678910111213141516171819202122232425import { HKDF } from "@stablelib/hkdf";26import { SHA256 } from "@stablelib/sha256";27import type { SyncVaultAdapter } from "./sync-coordinator";28import { getDeviceName, 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 getTombstoneManifest,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 { setPairSecret } from "./pair-secrets";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 {73 MAX_VOUCH_DEPTH,74 type ManifestDeviceEntry,75 type PairedDevice,76 type SignedDeviceManifest,77} from "../shared/sync-types";78import { replaceLockedSyncSpool, drainLockedSyncInbox } from "./locked-sync-spool";79808182const ENTRY_KEY_INFO_SUFFIX = "sync-entry-v1";83const ENTRY_AAD = "peach-sync-entry-v1";8485export class VaultLockedError extends Error {86 constructor() {87 super("vault_locked");88 this.name = "VaultLockedError";89 }90}9192export function createSyncVaultAdapter(93 _context: { deviceName?: string } = {},94): SyncVaultAdapter {95 return {96 async computeVaultId() {97 const master = requireMasterKey();98 return computeVaultId(master);99 },100101 async getOurDeviceId() {102 return getOrCreateDeviceId();103 },104105 async getLocalVersion() {106 return getVaultVersion();107 },108109 async buildEntryManifest() {110 requireUnlocked();111 const manifest: Record<string, number> = {};112 for (const entry of getEntries()) {113 manifest[entry.id] = entry.modifiedAt;114 }115 return manifest;116 },117118 async buildTombstoneManifest() {119 requireUnlocked();120 return getTombstoneManifest();121 },122123 async getEncryptedEntries(entryIds) {124 requireUnlocked();125 const masterKey = requireMasterKey();126 const out: Record<string, string> = {};127 for (const id of entryIds) {128 const entry = getEntryById(id);129 if (!entry) continue;130 const key = await deriveEntryKey(masterKey, id);131 out[id] = await encryptEntry(key, entry);132 }133 return out;134 },135136 async applyEntries(entries) {137 requireUnlocked();138 const entryList = Object.entries(entries);139 140 if (entryList.length > SYNC_LIMITS.MAX_ENTRY_COUNT) {141 throw new Error(142 `sync entry count ${entryList.length} exceeds limit ${SYNC_LIMITS.MAX_ENTRY_COUNT}`,143 );144 }145 const masterKey = requireMasterKey();146 const decrypted: VaultEntry[] = [];147 for (const [id, envelope] of entryList) {148 149 150 if (envelope.length > (SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3 + 64) {151 continue; 152 }153 const key = await deriveEntryKey(masterKey, id);154 const entry = await decryptEntryOrNull(key, envelope);155 if (!entry) continue;156 157 if (JSON.stringify(entry).length > SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES) {158 continue;159 }160 161 162 if (entry.id !== id) continue;163 decrypted.push(entry);164 }165 if (decrypted.length > 0) {166 await applyMergedRemoteEntries(decrypted, {});167 }168 },169170 async applyTombstones(tombstones) {171 requireUnlocked();172 const tombstoneList = Object.entries(tombstones);173 174 if (tombstoneList.length > SYNC_LIMITS.MAX_TOMBSTONE_COUNT) {175 throw new Error(176 `sync tombstone count ${tombstoneList.length} exceeds limit ${SYNC_LIMITS.MAX_TOMBSTONE_COUNT}`,177 );178 }179 const cleaned: Record<string, number> = {};180 for (const [id, ts] of tombstoneList) {181 if (typeof ts === "number" && Number.isFinite(ts) && ts > 0) {182 183 184 185 cleaned[id] = clampSyncTimestamp(ts);186 }187 }188 if (Object.keys(cleaned).length > 0) {189 await applyMergedRemoteEntries([], cleaned);190 }191 },192193 async buildOutgoingDeviceManifest() {194 requireUnlocked();195 const masterKey = requireMasterKey();196 const issuerDeviceId = await getOrCreateDeviceId();197 const signing = await deriveSyncSigningKeypair(masterKey, issuerDeviceId);198 const revoked = await listDeviceRevocations();199 const revokedIds = new Set(revoked.map((r) => r.deviceId));200 const devices = (await listPairedDevices())201 .filter((d) => !revokedIds.has(d.deviceId))202 .map(toManifestEntry)203 .filter((d): d is ManifestDeviceEntry => d !== null);204 return buildSignedDeviceManifest(205 issuerDeviceId,206 devices,207 signing.privateKey,208 { revoked },209 );210 },211212 async applyIncomingDeviceManifest(manifest, authenticatedRemoteStaticPublicKey) {213 requireUnlocked();214 await applyVerifiedPeerManifest(215 manifest,216 authenticatedRemoteStaticPublicKey,217 );218 },219 };220}221222export async function refreshLockedSyncSpool(): Promise<void> {223 requireUnlocked();224 const masterKey = requireMasterKey();225 const deviceId = await getOrCreateDeviceId();226 const identity = await buildCachedIdentity(masterKey, deviceId);227 const cachedEntries: Record<string, { modifiedAt: number; ciphertext: string }> = {};228 try {229 for (const entry of getEntries()) {230 if (entry.deletedAt) continue;231 const key = await deriveEntryKey(masterKey, entry.id);232 cachedEntries[entry.id] = {233 modifiedAt: entry.modifiedAt,234 ciphertext: await encryptEntry(key, entry),235 };236 }237 const tombstones = getTombstoneManifest();238 239 240 241 242 const { longTermPrivateKeyBytes: _privateKeyBytes, ...storedIdentity } = identity;243 await replaceLockedSyncSpool({244 ...storedIdentity,245 localVersion: await getVaultVersion(),246 entries: cachedEntries,247 tombstones,248 });249 } finally {250 masterKey.fill(0);251 identity.longTermPrivateKeyBytes.fill(0);252 }253}254255export async function drainAndRefreshLockedSyncSpool(): Promise<void> {256 const live = createSyncVaultAdapter();257 await drainLockedSyncInbox(live);258 await refreshLockedSyncSpool();259}260261async function buildCachedIdentity(masterKey: Uint8Array, deviceId: string) {262 const vaultId = await computeVaultId(masterKey);263 const keys = deriveSyncIdentityKeypair(masterKey, deviceId);264 const deviceName = await getDeviceName();265 return {266 vaultId,267 deviceId,268 deviceName,269 deviceType: "extension" as const,270 longTermPublicKeyHex: pubkeyToHex(keys.publicKey),271 longTermPrivateKeyHex: bytesToHex(keys.secretKey),272 longTermPrivateKeyBytes: keys.secretKey,273 };274}275276function toManifestEntry(device: PairedDevice): ManifestDeviceEntry | null {277 if (278 !device.deviceId ||279 !device.deviceName ||280 !device.deviceType ||281 !device.longTermPublicKeyHex ||282 !/^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex) ||283 !device.signingPublicKeyHex ||284 !/^[0-9a-f]{64}$/.test(device.signingPublicKeyHex)285 ) {286 return null;287 }288 return {289 deviceId: device.deviceId,290 deviceName: device.deviceName,291 deviceType: device.deviceType,292 longTermPublicKeyHex: device.longTermPublicKeyHex,293 signingPublicKeyHex: device.signingPublicKeyHex,294 wsPort: device.wsPort,295 relayUrl: device.relayUrl,296 pairedAt: device.pairedAt,297 vouchDepth: device.vouchDepth ?? 0,298 };299}300301302303304305306307308309310311312313314315async function applyVerifiedPeerManifest(316 manifest: SignedDeviceManifest,317 authenticatedRemoteStaticPublicKey: Uint8Array,318): Promise<void> {319 const issuerId = manifest?.body?.issuerDeviceId;320 if (typeof issuerId !== "string" || !issuerId) { return; }321322 if (authenticatedRemoteStaticPublicKey.length !== 32) {323 return;324 }325 const authenticatedPubkeyHex = pubkeyToHex(authenticatedRemoteStaticPublicKey);326 const issuer = await findPairedDeviceByPubkey(authenticatedPubkeyHex);327 if (328 !issuer ||329 issuer.deviceId !== issuerId ||330 (331 issuer.trustSource !== "direct" &&332 issuer.trustSource !== "vouched"333 ) ||334 (issuer.vouchDepth ?? 0) >= MAX_VOUCH_DEPTH ||335 !issuer.signingPublicKeyHex ||336 !/^[0-9a-f]{64}$/.test(issuer.signingPublicKeyHex)337 ) {338 return;339 }340341 let body: Awaited<ReturnType<typeof verifyDeviceManifest>>;342 try {343 body = await verifyDeviceManifest(344 manifest,345 hexToPubkey(issuer!.signingPublicKeyHex!),346 );347 } catch {348 349 350 351 352 353 const masterForIssuer = requireMasterKey();354 try {355 const derived = await deriveSyncSigningKeypair(356 masterForIssuer,357 issuer.deviceId,358 );359 body = await verifyDeviceManifest(manifest, derived.publicKeyRaw);360 } catch {361 return;362 } finally {363 masterForIssuer.fill(0);364 }365 }366 if (body.issuerDeviceId !== issuer.deviceId) {367 return;368 }369370 const ourDeviceId = await getOrCreateDeviceId();371 const masterKey = requireMasterKey();372 const localKeys = deriveSyncIdentityKeypair(masterKey, ourDeviceId);373374 try {375 376 377 378 379 const newlyRevoked = await mergeDeviceRevocations(380 body.revoked.filter((r) => r.deviceId !== ourDeviceId),381 );382 for (const deviceId of newlyRevoked) {383 await removePairedDeviceById(deviceId);384 await removePendingVouchedDevice(deviceId);385 void recordSyncAlerts([386 {387 type: "DEVICE_REVOKED",388 entryId: "",389 entryTitle: deviceId,390 deviceName: issuer.deviceName ?? "paired device",391 timestamp: Date.now(),392 },393 ]);394 }395396 for (const entry of body.devices) {397 if (entry.deviceId === ourDeviceId || entry.deviceId === issuer.deviceId) {398 continue;399 }400 401 402 403 404 405 if (await isDeviceRevoked(entry.deviceId, entry.longTermPublicKeyHex)) {406 continue;407 }408 if ((entry.vouchDepth ?? 0) > (issuer.vouchDepth ?? 0) + 1) {409 continue;410 }411 const existing = await findPairedDeviceById(entry.deviceId);412 const meshRoute = deriveManifestMeshRoute(413 masterKey,414 localKeys.publicKey,415 entry,416 );417418 419 420 421 422 423 if (!existing) {424 if (!meshRoute) continue;425 await upsertPairedDevice({426 deviceId: entry.deviceId,427 deviceName: entry.deviceName,428 deviceType: entry.deviceType,429 longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),430 signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),431 wsPort: entry.wsPort,432 relayUrl: meshRoute.relayUrl,433 pairedAt: entry.pairedAt,434 trustSource: "vouched",435 vouchDepth: (issuer.vouchDepth ?? 0) + 1,436 });437 438 439 await setPairSecret(entry.deviceId, meshRoute.rendezvousSecretHex, masterKey);440 await removePendingVouchedDevice(entry.deviceId);441 continue;442 }443444 445 446 447 448 if (449 existing.longTermPublicKeyHex &&450 !constantTimeEqualHex(451 existing.longTermPublicKeyHex,452 entry.longTermPublicKeyHex,453 )454 ) {455 456 457 458 459 460 const knownName = existing.deviceName ?? entry.deviceName;461 notifyPopup({462 type: "DEVICE_KEY_ROTATED",463 payload: { deviceName: knownName, deviceId: entry.deviceId },464 });465 void recordSyncAlerts([466 {467 type: "DEVICE_KEY_ROTATED",468 entryId: "",469 entryTitle: knownName,470 deviceName: knownName,471 timestamp: Date.now(),472 },473 ]);474 }475476 477 478 479 480 await upsertPairedDevice({481 ...existing,482 deviceId: entry.deviceId,483 deviceName: existing.deviceName ?? entry.deviceName,484 deviceType: entry.deviceType,485 longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),486 signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),487 pairedAt: existing.pairedAt ?? entry.pairedAt,488 trustSource: existing.trustSource,489 wsPort: existing.wsPort ?? entry.wsPort,490 relayUrl:491 existing.trustSource === "vouched" && meshRoute492 ? meshRoute.relayUrl493 : existing.relayUrl,494 lastSyncedAt: existing.lastSyncedAt,495 });496 if (existing.trustSource === "vouched" && meshRoute) {497 await setPairSecret(entry.deviceId, meshRoute.rendezvousSecretHex, masterKey);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 bytesToHex(bytes: Uint8Array): string {636 return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");637}638639function base64ToBytes(b64: string): Uint8Array {640 const bin = atob(b64);641 const out = new Uint8Array(bin.length);642 for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);643 return out;644}