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 type {73 ManifestDeviceEntry,74 PairedDevice,75 SignedDeviceManifest,76} from "../shared/sync-types";77import { replaceLockedSyncSpool, drainLockedSyncInbox } from "./locked-sync-spool";78798081const ENTRY_KEY_INFO_SUFFIX = "sync-entry-v1";82const ENTRY_AAD = "peach-sync-entry-v1";8384export class VaultLockedError extends Error {85 constructor() {86 super("vault_locked");87 this.name = "VaultLockedError";88 }89}9091export function createSyncVaultAdapter(92 context: { deviceName?: string } = {},93): SyncVaultAdapter {94 return {95 async computeVaultId() {96 const master = requireMasterKey();97 return computeVaultId(master);98 },99100 async getOurDeviceId() {101 return getOrCreateDeviceId();102 },103104 async getLocalVersion() {105 return getVaultVersion();106 },107108 async buildEntryManifest() {109 requireUnlocked();110 const manifest: Record<string, number> = {};111 for (const entry of getEntries()) {112 manifest[entry.id] = entry.modifiedAt;113 }114 return manifest;115 },116117 async buildTombstoneManifest() {118 requireUnlocked();119 return getTombstoneManifest();120 },121122 async getEncryptedEntries(entryIds) {123 requireUnlocked();124 const masterKey = requireMasterKey();125 const out: Record<string, string> = {};126 for (const id of entryIds) {127 const entry = getEntryById(id);128 if (!entry) continue;129 const key = await deriveEntryKey(masterKey, id);130 out[id] = await encryptEntry(key, entry);131 }132 return out;133 },134135 async applyEntries(entries) {136 requireUnlocked();137 const entryList = Object.entries(entries);138 139 if (entryList.length > SYNC_LIMITS.MAX_ENTRY_COUNT) {140 throw new Error(141 `sync entry count ${entryList.length} exceeds limit ${SYNC_LIMITS.MAX_ENTRY_COUNT}`,142 );143 }144 const masterKey = requireMasterKey();145 const decrypted: VaultEntry[] = [];146 for (const [id, envelope] of entryList) {147 148 149 if (envelope.length > (SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3 + 64) {150 continue; 151 }152 const key = await deriveEntryKey(masterKey, id);153 const entry = await decryptEntryOrNull(key, envelope);154 if (!entry) continue;155 156 if (JSON.stringify(entry).length > SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES) {157 continue;158 }159 160 161 if (entry.id !== id) continue;162 decrypted.push(entry);163 }164 if (decrypted.length > 0) {165 await applyMergedRemoteEntries(decrypted, {}, context);166 }167 },168169 async applyTombstones(tombstones) {170 requireUnlocked();171 const tombstoneList = Object.entries(tombstones);172 173 if (tombstoneList.length > SYNC_LIMITS.MAX_TOMBSTONE_COUNT) {174 throw new Error(175 `sync tombstone count ${tombstoneList.length} exceeds limit ${SYNC_LIMITS.MAX_TOMBSTONE_COUNT}`,176 );177 }178 const cleaned: Record<string, number> = {};179 for (const [id, ts] of tombstoneList) {180 if (typeof ts === "number" && Number.isFinite(ts) && ts > 0) {181 182 183 184 cleaned[id] = clampSyncTimestamp(ts);185 }186 }187 if (Object.keys(cleaned).length > 0) {188 await applyMergedRemoteEntries([], cleaned, context);189 }190 },191192 async buildOutgoingDeviceManifest() {193 requireUnlocked();194 const masterKey = requireMasterKey();195 const issuerDeviceId = await getOrCreateDeviceId();196 const signing = await deriveSyncSigningKeypair(masterKey, issuerDeviceId);197 const revoked = await listDeviceRevocations();198 const revokedIds = new Set(revoked.map((r) => r.deviceId));199 const devices = (await listPairedDevices())200 .filter((d) => !revokedIds.has(d.deviceId))201 .map(toManifestEntry)202 .filter((d): d is ManifestDeviceEntry => d !== null);203 return buildSignedDeviceManifest(204 issuerDeviceId,205 devices,206 signing.privateKey,207 { revoked },208 );209 },210211 async applyIncomingDeviceManifest(manifest, authenticatedRemoteStaticPublicKey) {212 requireUnlocked();213 await applyVerifiedPeerManifest(214 manifest,215 authenticatedRemoteStaticPublicKey,216 );217 },218 };219}220221export async function refreshLockedSyncSpool(): Promise<void> {222 requireUnlocked();223 const masterKey = requireMasterKey();224 const deviceId = await getOrCreateDeviceId();225 const identity = await buildCachedIdentity(masterKey, deviceId);226 const cachedEntries: Record<string, { modifiedAt: number; ciphertext: string }> = {};227 try {228 for (const entry of getEntries()) {229 if (entry.deletedAt) continue;230 const key = await deriveEntryKey(masterKey, entry.id);231 cachedEntries[entry.id] = {232 modifiedAt: entry.modifiedAt,233 ciphertext: await encryptEntry(key, entry),234 };235 }236 const tombstones = getTombstoneManifest();237 238 239 240 241 const { longTermPrivateKeyBytes: _privateKeyBytes, ...storedIdentity } = identity;242 await replaceLockedSyncSpool({243 ...storedIdentity,244 localVersion: await getVaultVersion(),245 entries: cachedEntries,246 tombstones,247 });248 } finally {249 masterKey.fill(0);250 identity.longTermPrivateKeyBytes.fill(0);251 }252}253254export async function drainAndRefreshLockedSyncSpool(): Promise<void> {255 const live = createSyncVaultAdapter();256 await drainLockedSyncInbox(live);257 await refreshLockedSyncSpool();258}259260async function buildCachedIdentity(masterKey: Uint8Array, deviceId: string) {261 const vaultId = await computeVaultId(masterKey);262 const keys = deriveSyncIdentityKeypair(masterKey, deviceId);263 const deviceName = await getDeviceName();264 return {265 vaultId,266 deviceId,267 deviceName,268 deviceType: "extension" as const,269 longTermPublicKeyHex: pubkeyToHex(keys.publicKey),270 longTermPrivateKeyHex: bytesToHex(keys.secretKey),271 longTermPrivateKeyBytes: keys.secretKey,272 };273}274275function toManifestEntry(device: PairedDevice): ManifestDeviceEntry | null {276 if (277 !device.deviceId ||278 !device.deviceName ||279 !device.deviceType ||280 !device.longTermPublicKeyHex ||281 !/^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex) ||282 !device.signingPublicKeyHex ||283 !/^[0-9a-f]{64}$/.test(device.signingPublicKeyHex)284 ) {285 return null;286 }287 return {288 deviceId: device.deviceId,289 deviceName: device.deviceName,290 deviceType: device.deviceType,291 longTermPublicKeyHex: device.longTermPublicKeyHex,292 signingPublicKeyHex: device.signingPublicKeyHex,293 wsPort: device.wsPort,294 relayUrl: device.relayUrl,295 pairedAt: device.pairedAt,296 };297}298299300301302303304305306307308309310311312313async function applyVerifiedPeerManifest(314 manifest: SignedDeviceManifest,315 authenticatedRemoteStaticPublicKey: Uint8Array,316): Promise<void> {317 const issuerId = manifest?.body?.issuerDeviceId;318 if (typeof issuerId !== "string" || !issuerId) { return; }319320 if (authenticatedRemoteStaticPublicKey.length !== 32) {321 return;322 }323 const authenticatedPubkeyHex = pubkeyToHex(authenticatedRemoteStaticPublicKey);324 const issuer = await findPairedDeviceByPubkey(authenticatedPubkeyHex);325 if (326 !issuer ||327 issuer.deviceId !== issuerId ||328 (329 issuer.trustSource !== "direct" &&330 issuer.trustSource !== "vouched"331 ) ||332 !issuer.signingPublicKeyHex ||333 !/^[0-9a-f]{64}$/.test(issuer.signingPublicKeyHex)334 ) {335 return;336 }337338 let body: Awaited<ReturnType<typeof verifyDeviceManifest>>;339 try {340 body = await verifyDeviceManifest(341 manifest,342 hexToPubkey(issuer!.signingPublicKeyHex!),343 );344 } catch {345 346 347 348 349 350 const masterForIssuer = requireMasterKey();351 try {352 const derived = await deriveSyncSigningKeypair(353 masterForIssuer,354 issuer.deviceId,355 );356 body = await verifyDeviceManifest(manifest, derived.publicKeyRaw);357 } catch {358 return;359 } finally {360 masterForIssuer.fill(0);361 }362 }363 if (body.issuerDeviceId !== issuer.deviceId) {364 return;365 }366367 const ourDeviceId = await getOrCreateDeviceId();368 const masterKey = requireMasterKey();369 const localKeys = deriveSyncIdentityKeypair(masterKey, ourDeviceId);370371 try {372 373 374 375 376 const newlyRevoked = await mergeDeviceRevocations(377 body.revoked.filter((r) => r.deviceId !== ourDeviceId),378 );379 for (const deviceId of newlyRevoked) {380 await removePairedDeviceById(deviceId);381 await removePendingVouchedDevice(deviceId);382 void recordSyncAlerts([383 {384 type: "DEVICE_REVOKED",385 entryId: "",386 entryTitle: deviceId,387 deviceName: issuer.deviceName ?? "paired device",388 timestamp: Date.now(),389 },390 ]);391 }392393 for (const entry of body.devices) {394 if (entry.deviceId === ourDeviceId || entry.deviceId === issuer.deviceId) {395 continue;396 }397 398 399 400 401 402 if (await isDeviceRevoked(entry.deviceId, entry.longTermPublicKeyHex)) {403 continue;404 }405 const existing = await findPairedDeviceById(entry.deviceId);406 const meshRoute = deriveManifestMeshRoute(407 masterKey,408 localKeys.publicKey,409 entry,410 );411412 413 414 415 416 417 if (!existing) {418 if (!meshRoute) continue;419 await upsertPairedDevice({420 deviceId: entry.deviceId,421 deviceName: entry.deviceName,422 deviceType: entry.deviceType,423 longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),424 signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),425 wsPort: entry.wsPort,426 relayUrl: meshRoute.relayUrl,427 pairedAt: entry.pairedAt,428 trustSource: "vouched",429 });430 431 432 await setPairSecret(entry.deviceId, meshRoute.rendezvousSecretHex, masterKey);433 await removePendingVouchedDevice(entry.deviceId);434 continue;435 }436437 438 439 440 441 if (442 existing.longTermPublicKeyHex &&443 !constantTimeEqualHex(444 existing.longTermPublicKeyHex,445 entry.longTermPublicKeyHex,446 )447 ) {448 449 450 451 452 453 const knownName = existing.deviceName ?? entry.deviceName;454 notifyPopup({455 type: "DEVICE_KEY_ROTATED",456 payload: { deviceName: knownName, deviceId: entry.deviceId },457 });458 void recordSyncAlerts([459 {460 type: "DEVICE_KEY_ROTATED",461 entryId: "",462 entryTitle: knownName,463 deviceName: knownName,464 timestamp: Date.now(),465 },466 ]);467 }468469 470 471 472 473 await upsertPairedDevice({474 ...existing,475 deviceId: entry.deviceId,476 deviceName: existing.deviceName ?? entry.deviceName,477 deviceType: entry.deviceType,478 longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),479 signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),480 pairedAt: existing.pairedAt ?? entry.pairedAt,481 trustSource: existing.trustSource,482 wsPort: existing.wsPort ?? entry.wsPort,483 relayUrl:484 existing.trustSource === "vouched" && meshRoute485 ? meshRoute.relayUrl486 : existing.relayUrl,487 lastSyncedAt: existing.lastSyncedAt,488 });489 if (existing.trustSource === "vouched" && meshRoute) {490 await setPairSecret(entry.deviceId, meshRoute.rendezvousSecretHex, masterKey);491 }492 }493 } finally {494 masterKey.fill(0);495 localKeys.secretKey.fill(0);496 }497}498499function deriveManifestMeshRoute(500 masterKey: Uint8Array,501 localPublicKey: Uint8Array,502 entry: ManifestDeviceEntry,503): { rendezvousSecretHex: string; relayUrl: string } | null {504 try {505 if (!entry.relayUrl) return null;506 const relayUrl = normalizeRelayUrl(entry.relayUrl);507 const secret = deriveMeshRendezvousSecret(508 masterKey,509 localPublicKey,510 hexToPubkey(entry.longTermPublicKeyHex),511 );512 try {513 return {514 rendezvousSecretHex: rendezvousBytesToHex(secret),515 relayUrl,516 };517 } finally {518 secret.fill(0);519 }520 } catch {521 return null;522 }523}524525function notifyPopup(msg: { type: string; payload?: unknown }): void {526 try {527 void chrome.runtime.sendMessage(msg).catch(() => undefined);528 } catch {529 530 }531}532533function requireUnlocked(): void {534 if (!isUnlocked()) throw new VaultLockedError();535}536537538539540541542543function requireMasterKey(): Uint8Array {544 if (!isUnlocked()) throw new VaultLockedError();545 const hex = getRootKeyHex();546 if (!hex) throw new VaultLockedError();547 return hexToBytes(hex);548}549550async function deriveEntryKey(masterKey: Uint8Array, entryId: string): Promise<CryptoKey> {551 552 553 554 555 const info = new TextEncoder().encode(`${entryId}\x00${ENTRY_KEY_INFO_SUFFIX}`);556 const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), info).expand(32);557 return crypto.subtle.importKey(558 "raw",559 raw.buffer as ArrayBuffer,560 { name: "AES-GCM" },561 false,562 ["encrypt", "decrypt"],563 );564}565566async function encryptEntry(key: CryptoKey, entry: VaultEntry): Promise<string> {567 const plaintext = new TextEncoder().encode(JSON.stringify(entry));568 const nonce = crypto.getRandomValues(new Uint8Array(12));569 const aad = new TextEncoder().encode(ENTRY_AAD);570 const ct = await crypto.subtle.encrypt(571 {572 name: "AES-GCM",573 iv: nonce.buffer as ArrayBuffer,574 additionalData: aad.buffer as ArrayBuffer,575 tagLength: 128,576 },577 key,578 plaintext.buffer as ArrayBuffer,579 );580 const ctBytes = new Uint8Array(ct);581 const envelope = new Uint8Array(nonce.length + ctBytes.length);582 envelope.set(nonce, 0);583 envelope.set(ctBytes, nonce.length);584 return bytesToBase64(envelope);585}586587async function decryptEntryOrNull(key: CryptoKey, envelopeB64: string): Promise<VaultEntry | null> {588 try {589 const bytes = base64ToBytes(envelopeB64);590 if (bytes.length < 12 + 16) return null;591 const nonce = bytes.subarray(0, 12);592 const ct = bytes.subarray(12);593 const aad = new TextEncoder().encode(ENTRY_AAD);594 const pt = await crypto.subtle.decrypt(595 {596 name: "AES-GCM",597 iv: nonce.buffer.slice(nonce.byteOffset, nonce.byteOffset + nonce.byteLength) as ArrayBuffer,598 additionalData: aad.buffer as ArrayBuffer,599 tagLength: 128,600 },601 key,602 ct.buffer.slice(ct.byteOffset, ct.byteOffset + ct.byteLength) as ArrayBuffer,603 );604 const json = new TextDecoder().decode(pt);605 const parsed = JSON.parse(json);606 if (!parsed || typeof parsed !== "object" || typeof parsed.id !== "string") return null;607 return parsed as VaultEntry;608 } catch {609 return null;610 }611}612613function hexToBytes(hex: string): Uint8Array {614 if (hex.length % 2 !== 0) throw new Error("hex length must be even");615 const out = new Uint8Array(hex.length / 2);616 for (let i = 0; i < out.length; i++) {617 out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);618 }619 return out;620}621622function bytesToBase64(bytes: Uint8Array): string {623 let bin = "";624 for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);625 return btoa(bin);626}627628function bytesToHex(bytes: Uint8Array): string {629 return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");630}631632function base64ToBytes(b64: string): Uint8Array {633 const bin = atob(b64);634 const out = new Uint8Array(bin.length);635 for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);636 return out;637}