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 deriveSyncSigningKeypair,32 hexToPubkey,33 pubkeyToHex,34} from "../shared/sync-identity";35import {36 buildSignedDeviceManifest,37 verifyDeviceManifest,38} from "../shared/device-manifest";39import { constantTimeEqualHex } from "../shared/constant-time";40import {41 applyMergedRemoteEntries,42 getDeletedEntries,43 getRootKeyHex,44 getEntries,45 getEntryById,46 getVaultVersion,47 isUnlocked,48} from "./vault";49import {50 findPairedDeviceById,51 findPairedDeviceByPubkey,52 isDeviceRevoked,53 listDeviceRevocations,54 listPairedDevices,55 mergeDeviceRevocations,56 removePairedDeviceById,57 upsertPairedDevice,58} from "./paired-devices";59import {60 addPendingVouchedDevice,61 removePendingVouchedDevice,62} from "./pending-vouched-devices";63import { recordSyncAlerts } from "./sync-alerts";64import { SYNC_LIMITS, clampSyncTimestamp } from "../shared/sync-limits";65import type { VaultEntry } from "../shared/types";66import type {67 ManifestDeviceEntry,68 PairedDevice,69 SignedDeviceManifest,70} from "../shared/sync-types";71727374const ENTRY_KEY_INFO_SUFFIX = "sync-entry-v1";75const ENTRY_AAD = "peach-sync-entry-v1";7677export class VaultLockedError extends Error {78 constructor() {79 super("vault_locked");80 this.name = "VaultLockedError";81 }82}8384export function createSyncVaultAdapter(85 context: { deviceName?: string } = {},86): SyncVaultAdapter {87 return {88 async computeVaultId() {89 const master = requireMasterKey();90 return computeVaultId(master);91 },9293 async getOurDeviceId() {94 return getOrCreateDeviceId();95 },9697 async getLocalVersion() {98 return getVaultVersion();99 },100101 async buildEntryManifest() {102 requireUnlocked();103 const manifest: Record<string, number> = {};104 for (const entry of getEntries()) {105 manifest[entry.id] = entry.modifiedAt;106 }107 return manifest;108 },109110 async buildTombstoneManifest() {111 112 113 114 115 116 117 118 requireUnlocked();119 const manifest: Record<string, number> = {};120 for (const entry of getDeletedEntries()) {121 if (typeof entry.deletedAt === "number") {122 manifest[entry.id] = entry.deletedAt;123 }124 }125 return manifest;126 },127128 async getEncryptedEntries(entryIds) {129 requireUnlocked();130 const masterKey = requireMasterKey();131 const out: Record<string, string> = {};132 for (const id of entryIds) {133 const entry = getEntryById(id);134 if (!entry) continue;135 const key = await deriveEntryKey(masterKey, id);136 out[id] = await encryptEntry(key, entry);137 }138 return out;139 },140141 async applyEntries(entries) {142 requireUnlocked();143 const entryList = Object.entries(entries);144 145 if (entryList.length > SYNC_LIMITS.MAX_ENTRY_COUNT) {146 throw new Error(147 `sync entry count ${entryList.length} exceeds limit ${SYNC_LIMITS.MAX_ENTRY_COUNT}`,148 );149 }150 const masterKey = requireMasterKey();151 const decrypted: VaultEntry[] = [];152 for (const [id, envelope] of entryList) {153 154 155 if (envelope.length > (SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3 + 64) {156 continue; 157 }158 const key = await deriveEntryKey(masterKey, id);159 const entry = await decryptEntryOrNull(key, envelope);160 if (!entry) continue;161 162 if (JSON.stringify(entry).length > SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES) {163 continue;164 }165 166 167 if (entry.id !== id) continue;168 decrypted.push(entry);169 }170 if (decrypted.length > 0) {171 await applyMergedRemoteEntries(decrypted, {}, context);172 }173 },174175 async applyTombstones(tombstones) {176 requireUnlocked();177 const tombstoneList = Object.entries(tombstones);178 179 if (tombstoneList.length > SYNC_LIMITS.MAX_TOMBSTONE_COUNT) {180 throw new Error(181 `sync tombstone count ${tombstoneList.length} exceeds limit ${SYNC_LIMITS.MAX_TOMBSTONE_COUNT}`,182 );183 }184 const cleaned: Record<string, number> = {};185 for (const [id, ts] of tombstoneList) {186 if (typeof ts === "number" && Number.isFinite(ts) && ts > 0) {187 188 189 190 cleaned[id] = clampSyncTimestamp(ts);191 }192 }193 if (Object.keys(cleaned).length > 0) {194 await applyMergedRemoteEntries([], cleaned, context);195 }196 },197198 async buildOutgoingDeviceManifest() {199 requireUnlocked();200 const masterKey = requireMasterKey();201 const issuerDeviceId = await getOrCreateDeviceId();202 const signing = await deriveSyncSigningKeypair(masterKey, issuerDeviceId);203 const revoked = await listDeviceRevocations();204 const revokedIds = new Set(revoked.map((r) => r.deviceId));205 const devices = (await listPairedDevices())206 .filter((d) => !revokedIds.has(d.deviceId))207 .map(toManifestEntry)208 .filter((d): d is ManifestDeviceEntry => d !== null);209 return buildSignedDeviceManifest(210 issuerDeviceId,211 devices,212 signing.privateKey,213 { revoked },214 );215 },216217 async applyIncomingDeviceManifest(manifest, authenticatedRemoteStaticPublicKey) {218 requireUnlocked();219 await applyVerifiedPeerManifest(220 manifest,221 authenticatedRemoteStaticPublicKey,222 );223 },224 };225}226227function toManifestEntry(device: PairedDevice): ManifestDeviceEntry | null {228 if (229 !device.deviceId ||230 !device.deviceName ||231 !device.deviceType ||232 !device.longTermPublicKeyHex ||233 !/^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex) ||234 !device.signingPublicKeyHex ||235 !/^[0-9a-f]{64}$/.test(device.signingPublicKeyHex)236 ) {237 return null;238 }239 return {240 deviceId: device.deviceId,241 deviceName: device.deviceName,242 deviceType: device.deviceType,243 longTermPublicKeyHex: device.longTermPublicKeyHex,244 signingPublicKeyHex: device.signingPublicKeyHex,245 wsPort: device.wsPort,246 pairedAt: device.pairedAt,247 };248}249250251252253254255256257258259260261262263264265async function applyVerifiedPeerManifest(266 manifest: SignedDeviceManifest,267 authenticatedRemoteStaticPublicKey: Uint8Array,268): Promise<void> {269 const issuerId = manifest?.body?.issuerDeviceId;270 if (typeof issuerId !== "string" || !issuerId) return;271272 if (authenticatedRemoteStaticPublicKey.length !== 32) return;273 const authenticatedPubkeyHex = pubkeyToHex(authenticatedRemoteStaticPublicKey);274 const issuer = await findPairedDeviceByPubkey(authenticatedPubkeyHex);275 if (276 !issuer ||277 issuer.deviceId !== issuerId ||278 issuer.trustSource !== "direct" ||279 !issuer.signingPublicKeyHex ||280 !/^[0-9a-f]{64}$/.test(issuer.signingPublicKeyHex)281 ) {282 return;283 }284285 let body: Awaited<ReturnType<typeof verifyDeviceManifest>>;286 try {287 body = await verifyDeviceManifest(288 manifest,289 hexToPubkey(issuer.signingPublicKeyHex),290 );291 } catch {292 return;293 }294 if (body.issuerDeviceId !== issuer.deviceId) return;295296 const ourDeviceId = await getOrCreateDeviceId();297298 299 300 301 302 const newlyRevoked = await mergeDeviceRevocations(303 body.revoked.filter((r) => r.deviceId !== ourDeviceId),304 );305 for (const deviceId of newlyRevoked) {306 await removePairedDeviceById(deviceId);307 await removePendingVouchedDevice(deviceId);308 void recordSyncAlerts([309 {310 type: "DEVICE_REVOKED",311 entryId: "",312 entryTitle: deviceId,313 deviceName: issuer.deviceName ?? "paired device",314 timestamp: Date.now(),315 },316 ]);317 }318319 for (const entry of body.devices) {320 if (entry.deviceId === ourDeviceId || entry.deviceId === issuer.deviceId) {321 continue;322 }323 324 325 326 327 328 if (await isDeviceRevoked(entry.deviceId, entry.longTermPublicKeyHex)) {329 continue;330 }331 const existing = await findPairedDeviceById(entry.deviceId);332333 334 335 336 337 338 if (!existing) {339 await addPendingVouchedDevice({340 deviceId: entry.deviceId,341 deviceName: entry.deviceName,342 deviceType: entry.deviceType,343 longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),344 signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),345 wsPort: entry.wsPort,346 pairedAt: entry.pairedAt,347 receivedAt: Date.now(),348 vouchedByDeviceId: issuer.deviceId,349 });350 351 352 353 notifyPopup({354 type: "VOUCHED_DEVICE_PENDING",355 payload: { deviceName: entry.deviceName, deviceId: entry.deviceId },356 });357 continue; 358 }359360 361 362 363 364 if (365 existing.longTermPublicKeyHex &&366 !constantTimeEqualHex(367 existing.longTermPublicKeyHex,368 entry.longTermPublicKeyHex,369 )370 ) {371 372 373 374 375 376 const knownName = existing.deviceName ?? entry.deviceName;377 notifyPopup({378 type: "DEVICE_KEY_ROTATED",379 payload: { deviceName: knownName, deviceId: entry.deviceId },380 });381 void recordSyncAlerts([382 {383 type: "DEVICE_KEY_ROTATED",384 entryId: "",385 entryTitle: knownName,386 deviceName: knownName,387 timestamp: Date.now(),388 },389 ]);390 }391392 393 394 395 396 await upsertPairedDevice({397 ...existing,398 deviceId: entry.deviceId,399 deviceName: existing.deviceName ?? entry.deviceName,400 deviceType: entry.deviceType,401 longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),402 signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),403 pairedAt: existing.pairedAt ?? entry.pairedAt,404 trustSource: existing.trustSource ?? "direct",405 wsPort: existing.wsPort ?? entry.wsPort,406 lastSyncedAt: existing.lastSyncedAt,407 });408 }409}410411412413function notifyPopup(msg: { type: string; payload?: unknown }): void {414 try {415 void chrome.runtime.sendMessage(msg).catch(() => undefined);416 } catch {417 418 }419}420421function requireUnlocked(): void {422 if (!isUnlocked()) throw new VaultLockedError();423}424425426427428429430431function requireMasterKey(): Uint8Array {432 if (!isUnlocked()) throw new VaultLockedError();433 const hex = getRootKeyHex();434 if (!hex) throw new VaultLockedError();435 return hexToBytes(hex);436}437438async function deriveEntryKey(masterKey: Uint8Array, entryId: string): Promise<CryptoKey> {439 440 441 442 443 const info = new TextEncoder().encode(`${entryId}\x00${ENTRY_KEY_INFO_SUFFIX}`);444 const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), info).expand(32);445 return crypto.subtle.importKey(446 "raw",447 raw.buffer as ArrayBuffer,448 { name: "AES-GCM" },449 false,450 ["encrypt", "decrypt"],451 );452}453454async function encryptEntry(key: CryptoKey, entry: VaultEntry): Promise<string> {455 const plaintext = new TextEncoder().encode(JSON.stringify(entry));456 const nonce = crypto.getRandomValues(new Uint8Array(12));457 const aad = new TextEncoder().encode(ENTRY_AAD);458 const ct = await crypto.subtle.encrypt(459 {460 name: "AES-GCM",461 iv: nonce.buffer as ArrayBuffer,462 additionalData: aad.buffer as ArrayBuffer,463 tagLength: 128,464 },465 key,466 plaintext.buffer as ArrayBuffer,467 );468 const ctBytes = new Uint8Array(ct);469 const envelope = new Uint8Array(nonce.length + ctBytes.length);470 envelope.set(nonce, 0);471 envelope.set(ctBytes, nonce.length);472 return bytesToBase64(envelope);473}474475async function decryptEntryOrNull(key: CryptoKey, envelopeB64: string): Promise<VaultEntry | null> {476 try {477 const bytes = base64ToBytes(envelopeB64);478 if (bytes.length < 12 + 16) return null;479 const nonce = bytes.subarray(0, 12);480 const ct = bytes.subarray(12);481 const aad = new TextEncoder().encode(ENTRY_AAD);482 const pt = await crypto.subtle.decrypt(483 {484 name: "AES-GCM",485 iv: nonce.buffer.slice(nonce.byteOffset, nonce.byteOffset + nonce.byteLength) as ArrayBuffer,486 additionalData: aad.buffer as ArrayBuffer,487 tagLength: 128,488 },489 key,490 ct.buffer.slice(ct.byteOffset, ct.byteOffset + ct.byteLength) as ArrayBuffer,491 );492 const json = new TextDecoder().decode(pt);493 const parsed = JSON.parse(json);494 if (!parsed || typeof parsed !== "object" || typeof parsed.id !== "string") return null;495 return parsed as VaultEntry;496 } catch {497 return null;498 }499}500501function hexToBytes(hex: string): Uint8Array {502 if (hex.length % 2 !== 0) throw new Error("hex length must be even");503 const out = new Uint8Array(hex.length / 2);504 for (let i = 0; i < out.length; i++) {505 out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);506 }507 return out;508}509510function bytesToBase64(bytes: Uint8Array): string {511 let bin = "";512 for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);513 return btoa(bin);514}515516function base64ToBytes(b64: string): Uint8Array {517 const bin = atob(b64);518 const out = new Uint8Array(bin.length);519 for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);520 return out;521}