1234567891011121314151617181920212223import { HKDF } from "@stablelib/hkdf";24import { SHA256 } from "@stablelib/sha256";25import type { SyncVaultAdapter } from "./sync-coordinator";26import { getOrCreateDeviceId } from "./device-identity";27import {28 computeVaultId,29 deriveSyncSigningKeypair,30 hexToPubkey,31 pubkeyToHex,32} from "../shared/sync-identity";33import {34 buildSignedDeviceManifest,35 verifyDeviceManifest,36} from "../shared/device-manifest";37import {38 applyMergedRemoteEntries,39 getDeletedEntries,40 getRootKeyHex,41 getEntries,42 getEntryById,43 getVaultVersion,44 isUnlocked,45} from "./vault";46import {47 findPairedDeviceById,48 findPairedDeviceByPubkey,49 listPairedDevices,50 upsertPairedDevice,51} from "./paired-devices";52import { addPendingVouchedDevice } from "./pending-vouched-devices";53import { recordSyncAlerts } from "./sync-alerts";54import { SYNC_LIMITS, clampSyncTimestamp } from "../shared/sync-limits";55import type { VaultEntry } from "../shared/types";56import type {57 ManifestDeviceEntry,58 PairedDevice,59 SignedDeviceManifest,60} from "../shared/sync-types";61626364const ENTRY_KEY_INFO_SUFFIX = "sync-entry-v1";65const ENTRY_AAD = "peach-sync-entry-v1";6667export class VaultLockedError extends Error {68 constructor() {69 super("vault_locked");70 this.name = "VaultLockedError";71 }72}7374export function createSyncVaultAdapter(75 context: { deviceName?: string } = {},76): SyncVaultAdapter {77 return {78 async computeVaultId() {79 const master = requireMasterKey();80 return computeVaultId(master);81 },8283 async getOurDeviceId() {84 return getOrCreateDeviceId();85 },8687 async getLocalVersion() {88 return getVaultVersion();89 },9091 async buildEntryManifest() {92 requireUnlocked();93 const manifest: Record<string, number> = {};94 for (const entry of getEntries()) {95 manifest[entry.id] = entry.modifiedAt;96 }97 return manifest;98 },99100 async buildTombstoneManifest() {101 102 103 104 105 106 107 108 requireUnlocked();109 const manifest: Record<string, number> = {};110 for (const entry of getDeletedEntries()) {111 if (typeof entry.deletedAt === "number") {112 manifest[entry.id] = entry.deletedAt;113 }114 }115 return manifest;116 },117118 async getEncryptedEntries(entryIds) {119 requireUnlocked();120 const masterKey = requireMasterKey();121 const out: Record<string, string> = {};122 for (const id of entryIds) {123 const entry = getEntryById(id);124 if (!entry) continue;125 const key = await deriveEntryKey(masterKey, id);126 out[id] = await encryptEntry(key, entry);127 }128 return out;129 },130131 async applyEntries(entries) {132 requireUnlocked();133 const entryList = Object.entries(entries);134 135 if (entryList.length > SYNC_LIMITS.MAX_ENTRY_COUNT) {136 throw new Error(137 `sync entry count ${entryList.length} exceeds limit ${SYNC_LIMITS.MAX_ENTRY_COUNT}`,138 );139 }140 const masterKey = requireMasterKey();141 const decrypted: VaultEntry[] = [];142 for (const [id, envelope] of entryList) {143 144 145 if (envelope.length > (SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3 + 64) {146 continue; 147 }148 const key = await deriveEntryKey(masterKey, id);149 const entry = await decryptEntryOrNull(key, envelope);150 if (!entry) continue;151 152 if (JSON.stringify(entry).length > SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES) {153 continue;154 }155 156 157 if (entry.id !== id) continue;158 decrypted.push(entry);159 }160 if (decrypted.length > 0) {161 await applyMergedRemoteEntries(decrypted, {}, context);162 }163 },164165 async applyTombstones(tombstones) {166 requireUnlocked();167 const tombstoneList = Object.entries(tombstones);168 169 if (tombstoneList.length > SYNC_LIMITS.MAX_TOMBSTONE_COUNT) {170 throw new Error(171 `sync tombstone count ${tombstoneList.length} exceeds limit ${SYNC_LIMITS.MAX_TOMBSTONE_COUNT}`,172 );173 }174 const cleaned: Record<string, number> = {};175 for (const [id, ts] of tombstoneList) {176 if (typeof ts === "number" && Number.isFinite(ts) && ts > 0) {177 178 179 180 cleaned[id] = clampSyncTimestamp(ts);181 }182 }183 if (Object.keys(cleaned).length > 0) {184 await applyMergedRemoteEntries([], cleaned, context);185 }186 },187188 async buildOutgoingDeviceManifest() {189 requireUnlocked();190 const masterKey = requireMasterKey();191 const issuerDeviceId = await getOrCreateDeviceId();192 const signing = await deriveSyncSigningKeypair(masterKey, issuerDeviceId);193 const devices = (await listPairedDevices())194 .map(toManifestEntry)195 .filter((d): d is ManifestDeviceEntry => d !== null);196 return buildSignedDeviceManifest(197 issuerDeviceId,198 devices,199 signing.privateKey,200 );201 },202203 async applyIncomingDeviceManifest(manifest, authenticatedRemoteStaticPublicKey) {204 requireUnlocked();205 await applyVerifiedPeerManifest(206 manifest,207 authenticatedRemoteStaticPublicKey,208 );209 },210 };211}212213function toManifestEntry(device: PairedDevice): ManifestDeviceEntry | null {214 if (215 !device.deviceId ||216 !device.deviceName ||217 !device.deviceType ||218 !device.longTermPublicKeyHex ||219 !/^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex) ||220 !device.signingPublicKeyHex ||221 !/^[0-9a-f]{64}$/.test(device.signingPublicKeyHex)222 ) {223 return null;224 }225 return {226 deviceId: device.deviceId,227 deviceName: device.deviceName,228 deviceType: device.deviceType,229 longTermPublicKeyHex: device.longTermPublicKeyHex,230 signingPublicKeyHex: device.signingPublicKeyHex,231 wsPort: device.wsPort,232 pairedAt: device.pairedAt,233 };234}235236237238239240241242243244245246247248249250251async function applyVerifiedPeerManifest(252 manifest: SignedDeviceManifest,253 authenticatedRemoteStaticPublicKey: Uint8Array,254): Promise<void> {255 const issuerId = manifest?.body?.issuerDeviceId;256 if (typeof issuerId !== "string" || !issuerId) return;257258 if (authenticatedRemoteStaticPublicKey.length !== 32) return;259 const authenticatedPubkeyHex = pubkeyToHex(authenticatedRemoteStaticPublicKey);260 const issuer = await findPairedDeviceByPubkey(authenticatedPubkeyHex);261 if (262 !issuer ||263 issuer.deviceId !== issuerId ||264 issuer.trustSource !== "direct" ||265 !issuer.signingPublicKeyHex ||266 !/^[0-9a-f]{64}$/.test(issuer.signingPublicKeyHex)267 ) {268 return;269 }270271 let body: Awaited<ReturnType<typeof verifyDeviceManifest>>;272 try {273 body = await verifyDeviceManifest(274 manifest,275 hexToPubkey(issuer.signingPublicKeyHex),276 );277 } catch {278 return;279 }280 if (body.issuerDeviceId !== issuer.deviceId) return;281282 const ourDeviceId = await getOrCreateDeviceId();283 for (const entry of body.devices) {284 if (entry.deviceId === ourDeviceId || entry.deviceId === issuer.deviceId) {285 continue;286 }287 const existing = await findPairedDeviceById(entry.deviceId);288289 290 291 292 293 294 if (!existing) {295 await addPendingVouchedDevice({296 deviceId: entry.deviceId,297 deviceName: entry.deviceName,298 deviceType: entry.deviceType,299 longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),300 signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),301 wsPort: entry.wsPort,302 pairedAt: entry.pairedAt,303 receivedAt: Date.now(),304 vouchedByDeviceId: issuer.deviceId,305 });306 notifyPopup({307 type: "VOUCHED_DEVICE_PENDING",308 payload: { deviceName: entry.deviceName, deviceId: entry.deviceId },309 });310 continue; 311 }312313 314 315 316 317 if (318 existing.longTermPublicKeyHex &&319 existing.longTermPublicKeyHex !== entry.longTermPublicKeyHex.toLowerCase()320 ) {321 notifyPopup({322 type: "DEVICE_KEY_ROTATED",323 payload: { deviceName: entry.deviceName, deviceId: entry.deviceId },324 });325 void recordSyncAlerts([326 {327 type: "DEVICE_KEY_ROTATED",328 entryId: "",329 entryTitle: entry.deviceName,330 deviceName: entry.deviceName,331 timestamp: Date.now(),332 },333 ]);334 }335336 337 await upsertPairedDevice({338 ...existing,339 deviceId: entry.deviceId,340 deviceName: entry.deviceName,341 deviceType: entry.deviceType,342 longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),343 signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),344 pairedAt: existing.pairedAt ?? entry.pairedAt,345 trustSource: existing.trustSource ?? "direct",346 wsPort: existing.wsPort ?? entry.wsPort,347 lastSyncedAt: existing.lastSyncedAt,348 });349 }350}351352353354function notifyPopup(msg: { type: string; payload?: unknown }): void {355 try {356 void chrome.runtime.sendMessage(msg).catch(() => undefined);357 } catch {358 359 }360}361362function requireUnlocked(): void {363 if (!isUnlocked()) throw new VaultLockedError();364}365366367368369370371372function requireMasterKey(): Uint8Array {373 if (!isUnlocked()) throw new VaultLockedError();374 const hex = getRootKeyHex();375 if (!hex) throw new VaultLockedError();376 return hexToBytes(hex);377}378379async function deriveEntryKey(masterKey: Uint8Array, entryId: string): Promise<CryptoKey> {380 381 382 383 384 const info = new TextEncoder().encode(`${entryId}\x00${ENTRY_KEY_INFO_SUFFIX}`);385 const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), info).expand(32);386 return crypto.subtle.importKey(387 "raw",388 raw.buffer as ArrayBuffer,389 { name: "AES-GCM" },390 false,391 ["encrypt", "decrypt"],392 );393}394395async function encryptEntry(key: CryptoKey, entry: VaultEntry): Promise<string> {396 const plaintext = new TextEncoder().encode(JSON.stringify(entry));397 const nonce = crypto.getRandomValues(new Uint8Array(12));398 const aad = new TextEncoder().encode(ENTRY_AAD);399 const ct = await crypto.subtle.encrypt(400 {401 name: "AES-GCM",402 iv: nonce.buffer as ArrayBuffer,403 additionalData: aad.buffer as ArrayBuffer,404 tagLength: 128,405 },406 key,407 plaintext.buffer as ArrayBuffer,408 );409 const ctBytes = new Uint8Array(ct);410 const envelope = new Uint8Array(nonce.length + ctBytes.length);411 envelope.set(nonce, 0);412 envelope.set(ctBytes, nonce.length);413 return bytesToBase64(envelope);414}415416async function decryptEntryOrNull(key: CryptoKey, envelopeB64: string): Promise<VaultEntry | null> {417 try {418 const bytes = base64ToBytes(envelopeB64);419 if (bytes.length < 12 + 16) return null;420 const nonce = bytes.subarray(0, 12);421 const ct = bytes.subarray(12);422 const aad = new TextEncoder().encode(ENTRY_AAD);423 const pt = await crypto.subtle.decrypt(424 {425 name: "AES-GCM",426 iv: nonce.buffer.slice(nonce.byteOffset, nonce.byteOffset + nonce.byteLength) as ArrayBuffer,427 additionalData: aad.buffer as ArrayBuffer,428 tagLength: 128,429 },430 key,431 ct.buffer.slice(ct.byteOffset, ct.byteOffset + ct.byteLength) as ArrayBuffer,432 );433 const json = new TextDecoder().decode(pt);434 const parsed = JSON.parse(json);435 if (!parsed || typeof parsed !== "object" || typeof parsed.id !== "string") return null;436 return parsed as VaultEntry;437 } catch {438 return null;439 }440}441442function hexToBytes(hex: string): Uint8Array {443 if (hex.length % 2 !== 0) throw new Error("hex length must be even");444 const out = new Uint8Array(hex.length / 2);445 for (let i = 0; i < out.length; i++) {446 out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);447 }448 return out;449}450451function bytesToBase64(bytes: Uint8Array): string {452 let bin = "";453 for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);454 return btoa(bin);455}456457function base64ToBytes(b64: string): Uint8Array {458 const bin = atob(b64);459 const out = new Uint8Array(bin.length);460 for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);461 return out;462}