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 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";76import { replaceLockedSyncSpool, drainLockedSyncInbox } from "./locked-sync-spool";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 await applyVerifiedPeerManifest(229 manifest,230 authenticatedRemoteStaticPublicKey,231 );232 },233 };234}235236export async function refreshLockedSyncSpool(): Promise<void> {237 requireUnlocked();238 const masterKey = requireMasterKey();239 const deviceId = await getOrCreateDeviceId();240 const identity = await buildCachedIdentity(masterKey, deviceId);241 const cachedEntries: Record<string, { modifiedAt: number; ciphertext: string }> = {};242 try {243 for (const entry of getEntries()) {244 if (entry.deletedAt) continue;245 const key = await deriveEntryKey(masterKey, entry.id);246 cachedEntries[entry.id] = {247 modifiedAt: entry.modifiedAt,248 ciphertext: await encryptEntry(key, entry),249 };250 }251 const tombstones: Record<string, number> = {};252 for (const entry of getDeletedEntries()) {253 if (typeof entry.deletedAt === "number") tombstones[entry.id] = entry.deletedAt;254 }255 const { longTermPrivateKeyBytes: _privateKeyBytes, ...storedIdentity } = identity;256 await replaceLockedSyncSpool({257 ...storedIdentity,258 localVersion: await getVaultVersion(),259 entries: cachedEntries,260 tombstones,261 });262 } finally {263 masterKey.fill(0);264 identity.longTermPrivateKeyBytes.fill(0);265 }266}267268export async function drainAndRefreshLockedSyncSpool(): Promise<void> {269 const live = createSyncVaultAdapter();270 await drainLockedSyncInbox(live);271 await refreshLockedSyncSpool();272}273274async function buildCachedIdentity(masterKey: Uint8Array, deviceId: string) {275 const vaultId = await computeVaultId(masterKey);276 const keys = deriveSyncIdentityKeypair(masterKey, deviceId);277 const deviceName = await getDeviceName();278 return {279 vaultId,280 deviceId,281 deviceName,282 deviceType: "extension" as const,283 longTermPublicKeyHex: pubkeyToHex(keys.publicKey),284 longTermPrivateKeyHex: bytesToHex(keys.secretKey),285 longTermPrivateKeyBytes: keys.secretKey,286 };287}288289function toManifestEntry(device: PairedDevice): ManifestDeviceEntry | null {290 if (291 !device.deviceId ||292 !device.deviceName ||293 !device.deviceType ||294 !device.longTermPublicKeyHex ||295 !/^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex) ||296 !device.signingPublicKeyHex ||297 !/^[0-9a-f]{64}$/.test(device.signingPublicKeyHex)298 ) {299 return null;300 }301 return {302 deviceId: device.deviceId,303 deviceName: device.deviceName,304 deviceType: device.deviceType,305 longTermPublicKeyHex: device.longTermPublicKeyHex,306 signingPublicKeyHex: device.signingPublicKeyHex,307 wsPort: device.wsPort,308 relayUrl: device.relayUrl,309 pairedAt: device.pairedAt,310 };311}312313314315316317318319320321322323324325326327async function applyVerifiedPeerManifest(328 manifest: SignedDeviceManifest,329 authenticatedRemoteStaticPublicKey: Uint8Array,330): Promise<void> {331 const issuerId = manifest?.body?.issuerDeviceId;332 if (typeof issuerId !== "string" || !issuerId) { return; }333334 if (authenticatedRemoteStaticPublicKey.length !== 32) {335 return;336 }337 const authenticatedPubkeyHex = pubkeyToHex(authenticatedRemoteStaticPublicKey);338 const issuer = await findPairedDeviceByPubkey(authenticatedPubkeyHex);339 if (340 !issuer ||341 issuer.deviceId !== issuerId ||342 (343 issuer.trustSource !== "direct" &&344 issuer.trustSource !== "vouched"345 ) ||346 !issuer.signingPublicKeyHex ||347 !/^[0-9a-f]{64}$/.test(issuer.signingPublicKeyHex)348 ) {349 return;350 }351352 let body: Awaited<ReturnType<typeof verifyDeviceManifest>>;353 try {354 body = await verifyDeviceManifest(355 manifest,356 hexToPubkey(issuer!.signingPublicKeyHex!),357 );358 } catch {359 360 361 362 363 364 const masterForIssuer = requireMasterKey();365 try {366 const derived = await deriveSyncSigningKeypair(367 masterForIssuer,368 issuer.deviceId,369 );370 body = await verifyDeviceManifest(manifest, derived.publicKeyRaw);371 } catch {372 return;373 } finally {374 masterForIssuer.fill(0);375 }376 }377 if (body.issuerDeviceId !== issuer.deviceId) {378 return;379 }380381 const ourDeviceId = await getOrCreateDeviceId();382 const masterKey = requireMasterKey();383 const localKeys = deriveSyncIdentityKeypair(masterKey, ourDeviceId);384385 try {386 387 388 389 390 const newlyRevoked = await mergeDeviceRevocations(391 body.revoked.filter((r) => r.deviceId !== ourDeviceId),392 );393 for (const deviceId of newlyRevoked) {394 await removePairedDeviceById(deviceId);395 await removePendingVouchedDevice(deviceId);396 void recordSyncAlerts([397 {398 type: "DEVICE_REVOKED",399 entryId: "",400 entryTitle: deviceId,401 deviceName: issuer.deviceName ?? "paired device",402 timestamp: Date.now(),403 },404 ]);405 }406407 for (const entry of body.devices) {408 if (entry.deviceId === ourDeviceId || entry.deviceId === issuer.deviceId) {409 continue;410 }411 412 413 414 415 416 if (await isDeviceRevoked(entry.deviceId, entry.longTermPublicKeyHex)) {417 continue;418 }419 const existing = await findPairedDeviceById(entry.deviceId);420 const meshRoute = deriveManifestMeshRoute(421 masterKey,422 localKeys.publicKey,423 entry,424 );425426 427 428 429 430 431 if (!existing) {432 if (!meshRoute) continue;433 await upsertPairedDevice({434 deviceId: entry.deviceId,435 deviceName: entry.deviceName,436 deviceType: entry.deviceType,437 longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),438 signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),439 wsPort: entry.wsPort,440 rendezvousSecretHex: meshRoute.rendezvousSecretHex,441 relayUrl: meshRoute.relayUrl,442 pairedAt: entry.pairedAt,443 trustSource: "vouched",444 });445 await removePendingVouchedDevice(entry.deviceId);446 continue;447 }448449 450 451 452 453 if (454 existing.longTermPublicKeyHex &&455 !constantTimeEqualHex(456 existing.longTermPublicKeyHex,457 entry.longTermPublicKeyHex,458 )459 ) {460 461 462 463 464 465 const knownName = existing.deviceName ?? entry.deviceName;466 notifyPopup({467 type: "DEVICE_KEY_ROTATED",468 payload: { deviceName: knownName, deviceId: entry.deviceId },469 });470 void recordSyncAlerts([471 {472 type: "DEVICE_KEY_ROTATED",473 entryId: "",474 entryTitle: knownName,475 deviceName: knownName,476 timestamp: Date.now(),477 },478 ]);479 }480481 482 483 484 485 await upsertPairedDevice({486 ...existing,487 deviceId: entry.deviceId,488 deviceName: existing.deviceName ?? entry.deviceName,489 deviceType: entry.deviceType,490 longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),491 signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),492 pairedAt: existing.pairedAt ?? entry.pairedAt,493 trustSource: existing.trustSource ?? "direct",494 wsPort: existing.wsPort ?? entry.wsPort,495 rendezvousSecretHex:496 existing.trustSource === "vouched" && meshRoute497 ? meshRoute.rendezvousSecretHex498 : existing.rendezvousSecretHex,499 relayUrl:500 existing.trustSource === "vouched" && meshRoute501 ? meshRoute.relayUrl502 : existing.relayUrl,503 lastSyncedAt: existing.lastSyncedAt,504 });505 }506 } finally {507 masterKey.fill(0);508 localKeys.secretKey.fill(0);509 }510}511512function deriveManifestMeshRoute(513 masterKey: Uint8Array,514 localPublicKey: Uint8Array,515 entry: ManifestDeviceEntry,516): { rendezvousSecretHex: string; relayUrl: string } | null {517 try {518 if (!entry.relayUrl) return null;519 const relayUrl = normalizeRelayUrl(entry.relayUrl);520 const secret = deriveMeshRendezvousSecret(521 masterKey,522 localPublicKey,523 hexToPubkey(entry.longTermPublicKeyHex),524 );525 try {526 return {527 rendezvousSecretHex: rendezvousBytesToHex(secret),528 relayUrl,529 };530 } finally {531 secret.fill(0);532 }533 } catch {534 return null;535 }536}537538function notifyPopup(msg: { type: string; payload?: unknown }): void {539 try {540 void chrome.runtime.sendMessage(msg).catch(() => undefined);541 } catch {542 543 }544}545546function requireUnlocked(): void {547 if (!isUnlocked()) throw new VaultLockedError();548}549550551552553554555556function requireMasterKey(): Uint8Array {557 if (!isUnlocked()) throw new VaultLockedError();558 const hex = getRootKeyHex();559 if (!hex) throw new VaultLockedError();560 return hexToBytes(hex);561}562563async function deriveEntryKey(masterKey: Uint8Array, entryId: string): Promise<CryptoKey> {564 565 566 567 568 const info = new TextEncoder().encode(`${entryId}\x00${ENTRY_KEY_INFO_SUFFIX}`);569 const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), info).expand(32);570 return crypto.subtle.importKey(571 "raw",572 raw.buffer as ArrayBuffer,573 { name: "AES-GCM" },574 false,575 ["encrypt", "decrypt"],576 );577}578579async function encryptEntry(key: CryptoKey, entry: VaultEntry): Promise<string> {580 const plaintext = new TextEncoder().encode(JSON.stringify(entry));581 const nonce = crypto.getRandomValues(new Uint8Array(12));582 const aad = new TextEncoder().encode(ENTRY_AAD);583 const ct = await crypto.subtle.encrypt(584 {585 name: "AES-GCM",586 iv: nonce.buffer as ArrayBuffer,587 additionalData: aad.buffer as ArrayBuffer,588 tagLength: 128,589 },590 key,591 plaintext.buffer as ArrayBuffer,592 );593 const ctBytes = new Uint8Array(ct);594 const envelope = new Uint8Array(nonce.length + ctBytes.length);595 envelope.set(nonce, 0);596 envelope.set(ctBytes, nonce.length);597 return bytesToBase64(envelope);598}599600async function decryptEntryOrNull(key: CryptoKey, envelopeB64: string): Promise<VaultEntry | null> {601 try {602 const bytes = base64ToBytes(envelopeB64);603 if (bytes.length < 12 + 16) return null;604 const nonce = bytes.subarray(0, 12);605 const ct = bytes.subarray(12);606 const aad = new TextEncoder().encode(ENTRY_AAD);607 const pt = await crypto.subtle.decrypt(608 {609 name: "AES-GCM",610 iv: nonce.buffer.slice(nonce.byteOffset, nonce.byteOffset + nonce.byteLength) as ArrayBuffer,611 additionalData: aad.buffer as ArrayBuffer,612 tagLength: 128,613 },614 key,615 ct.buffer.slice(ct.byteOffset, ct.byteOffset + ct.byteLength) as ArrayBuffer,616 );617 const json = new TextDecoder().decode(pt);618 const parsed = JSON.parse(json);619 if (!parsed || typeof parsed !== "object" || typeof parsed.id !== "string") return null;620 return parsed as VaultEntry;621 } catch {622 return null;623 }624}625626function hexToBytes(hex: string): Uint8Array {627 if (hex.length % 2 !== 0) throw new Error("hex length must be even");628 const out = new Uint8Array(hex.length / 2);629 for (let i = 0; i < out.length; i++) {630 out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);631 }632 return out;633}634635function bytesToBase64(bytes: Uint8Array): string {636 let bin = "";637 for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);638 return btoa(bin);639}640641function bytesToHex(bytes: Uint8Array): string {642 return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");643}644645function base64ToBytes(b64: string): Uint8Array {646 const bin = atob(b64);647 const out = new Uint8Array(bin.length);648 for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);649 return out;650}