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 { constantTimeEqualHex } from "../shared/constant-time";38import {39 applyMergedRemoteEntries,40 getDeletedEntries,41 getRootKeyHex,42 getEntries,43 getEntryById,44 getVaultVersion,45 isUnlocked,46} from "./vault";47import {48 findPairedDeviceById,49 findPairedDeviceByPubkey,50 listPairedDevices,51 upsertPairedDevice,52} from "./paired-devices";53import { addPendingVouchedDevice } from "./pending-vouched-devices";54import { recordSyncAlerts } from "./sync-alerts";55import { SYNC_LIMITS, clampSyncTimestamp } from "../shared/sync-limits";56import type { VaultEntry } from "../shared/types";57import type {58 ManifestDeviceEntry,59 PairedDevice,60 SignedDeviceManifest,61} from "../shared/sync-types";62636465const ENTRY_KEY_INFO_SUFFIX = "sync-entry-v1";66const ENTRY_AAD = "peach-sync-entry-v1";6768export class VaultLockedError extends Error {69 constructor() {70 super("vault_locked");71 this.name = "VaultLockedError";72 }73}7475export function createSyncVaultAdapter(76 context: { deviceName?: string } = {},77): SyncVaultAdapter {78 return {79 async computeVaultId() {80 const master = requireMasterKey();81 return computeVaultId(master);82 },8384 async getOurDeviceId() {85 return getOrCreateDeviceId();86 },8788 async getLocalVersion() {89 return getVaultVersion();90 },9192 async buildEntryManifest() {93 requireUnlocked();94 const manifest: Record<string, number> = {};95 for (const entry of getEntries()) {96 manifest[entry.id] = entry.modifiedAt;97 }98 return manifest;99 },100101 async buildTombstoneManifest() {102 103 104 105 106 107 108 109 requireUnlocked();110 const manifest: Record<string, number> = {};111 for (const entry of getDeletedEntries()) {112 if (typeof entry.deletedAt === "number") {113 manifest[entry.id] = entry.deletedAt;114 }115 }116 return manifest;117 },118119 async getEncryptedEntries(entryIds) {120 requireUnlocked();121 const masterKey = requireMasterKey();122 const out: Record<string, string> = {};123 for (const id of entryIds) {124 const entry = getEntryById(id);125 if (!entry) continue;126 const key = await deriveEntryKey(masterKey, id);127 out[id] = await encryptEntry(key, entry);128 }129 return out;130 },131132 async applyEntries(entries) {133 requireUnlocked();134 const entryList = Object.entries(entries);135 136 if (entryList.length > SYNC_LIMITS.MAX_ENTRY_COUNT) {137 throw new Error(138 `sync entry count ${entryList.length} exceeds limit ${SYNC_LIMITS.MAX_ENTRY_COUNT}`,139 );140 }141 const masterKey = requireMasterKey();142 const decrypted: VaultEntry[] = [];143 for (const [id, envelope] of entryList) {144 145 146 if (envelope.length > (SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3 + 64) {147 continue; 148 }149 const key = await deriveEntryKey(masterKey, id);150 const entry = await decryptEntryOrNull(key, envelope);151 if (!entry) continue;152 153 if (JSON.stringify(entry).length > SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES) {154 continue;155 }156 157 158 if (entry.id !== id) continue;159 decrypted.push(entry);160 }161 if (decrypted.length > 0) {162 await applyMergedRemoteEntries(decrypted, {}, context);163 }164 },165166 async applyTombstones(tombstones) {167 requireUnlocked();168 const tombstoneList = Object.entries(tombstones);169 170 if (tombstoneList.length > SYNC_LIMITS.MAX_TOMBSTONE_COUNT) {171 throw new Error(172 `sync tombstone count ${tombstoneList.length} exceeds limit ${SYNC_LIMITS.MAX_TOMBSTONE_COUNT}`,173 );174 }175 const cleaned: Record<string, number> = {};176 for (const [id, ts] of tombstoneList) {177 if (typeof ts === "number" && Number.isFinite(ts) && ts > 0) {178 179 180 181 cleaned[id] = clampSyncTimestamp(ts);182 }183 }184 if (Object.keys(cleaned).length > 0) {185 await applyMergedRemoteEntries([], cleaned, context);186 }187 },188189 async buildOutgoingDeviceManifest() {190 requireUnlocked();191 const masterKey = requireMasterKey();192 const issuerDeviceId = await getOrCreateDeviceId();193 const signing = await deriveSyncSigningKeypair(masterKey, issuerDeviceId);194 const devices = (await listPairedDevices())195 .map(toManifestEntry)196 .filter((d): d is ManifestDeviceEntry => d !== null);197 return buildSignedDeviceManifest(198 issuerDeviceId,199 devices,200 signing.privateKey,201 );202 },203204 async applyIncomingDeviceManifest(manifest, authenticatedRemoteStaticPublicKey) {205 requireUnlocked();206 await applyVerifiedPeerManifest(207 manifest,208 authenticatedRemoteStaticPublicKey,209 );210 },211 };212}213214function toManifestEntry(device: PairedDevice): ManifestDeviceEntry | null {215 if (216 !device.deviceId ||217 !device.deviceName ||218 !device.deviceType ||219 !device.longTermPublicKeyHex ||220 !/^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex) ||221 !device.signingPublicKeyHex ||222 !/^[0-9a-f]{64}$/.test(device.signingPublicKeyHex)223 ) {224 return null;225 }226 return {227 deviceId: device.deviceId,228 deviceName: device.deviceName,229 deviceType: device.deviceType,230 longTermPublicKeyHex: device.longTermPublicKeyHex,231 signingPublicKeyHex: device.signingPublicKeyHex,232 wsPort: device.wsPort,233 pairedAt: device.pairedAt,234 };235}236237238239240241242243244245246247248249250251252async function applyVerifiedPeerManifest(253 manifest: SignedDeviceManifest,254 authenticatedRemoteStaticPublicKey: Uint8Array,255): Promise<void> {256 const issuerId = manifest?.body?.issuerDeviceId;257 if (typeof issuerId !== "string" || !issuerId) return;258259 if (authenticatedRemoteStaticPublicKey.length !== 32) return;260 const authenticatedPubkeyHex = pubkeyToHex(authenticatedRemoteStaticPublicKey);261 const issuer = await findPairedDeviceByPubkey(authenticatedPubkeyHex);262 if (263 !issuer ||264 issuer.deviceId !== issuerId ||265 issuer.trustSource !== "direct" ||266 !issuer.signingPublicKeyHex ||267 !/^[0-9a-f]{64}$/.test(issuer.signingPublicKeyHex)268 ) {269 return;270 }271272 let body: Awaited<ReturnType<typeof verifyDeviceManifest>>;273 try {274 body = await verifyDeviceManifest(275 manifest,276 hexToPubkey(issuer.signingPublicKeyHex),277 );278 } catch {279 return;280 }281 if (body.issuerDeviceId !== issuer.deviceId) return;282283 const ourDeviceId = await getOrCreateDeviceId();284 for (const entry of body.devices) {285 if (entry.deviceId === ourDeviceId || entry.deviceId === issuer.deviceId) {286 continue;287 }288 const existing = await findPairedDeviceById(entry.deviceId);289290 291 292 293 294 295 if (!existing) {296 await addPendingVouchedDevice({297 deviceId: entry.deviceId,298 deviceName: entry.deviceName,299 deviceType: entry.deviceType,300 longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),301 signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),302 wsPort: entry.wsPort,303 pairedAt: entry.pairedAt,304 receivedAt: Date.now(),305 vouchedByDeviceId: issuer.deviceId,306 });307 308 309 310 notifyPopup({311 type: "VOUCHED_DEVICE_PENDING",312 payload: { deviceName: entry.deviceName, deviceId: entry.deviceId },313 });314 continue; 315 }316317 318 319 320 321 if (322 existing.longTermPublicKeyHex &&323 !constantTimeEqualHex(324 existing.longTermPublicKeyHex,325 entry.longTermPublicKeyHex,326 )327 ) {328 329 330 331 332 333 const knownName = existing.deviceName ?? entry.deviceName;334 notifyPopup({335 type: "DEVICE_KEY_ROTATED",336 payload: { deviceName: knownName, deviceId: entry.deviceId },337 });338 void recordSyncAlerts([339 {340 type: "DEVICE_KEY_ROTATED",341 entryId: "",342 entryTitle: knownName,343 deviceName: knownName,344 timestamp: Date.now(),345 },346 ]);347 }348349 350 351 352 353 await upsertPairedDevice({354 ...existing,355 deviceId: entry.deviceId,356 deviceName: existing.deviceName ?? entry.deviceName,357 deviceType: entry.deviceType,358 longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),359 signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),360 pairedAt: existing.pairedAt ?? entry.pairedAt,361 trustSource: existing.trustSource ?? "direct",362 wsPort: existing.wsPort ?? entry.wsPort,363 lastSyncedAt: existing.lastSyncedAt,364 });365 }366}367368369370function notifyPopup(msg: { type: string; payload?: unknown }): void {371 try {372 void chrome.runtime.sendMessage(msg).catch(() => undefined);373 } catch {374 375 }376}377378function requireUnlocked(): void {379 if (!isUnlocked()) throw new VaultLockedError();380}381382383384385386387388function requireMasterKey(): Uint8Array {389 if (!isUnlocked()) throw new VaultLockedError();390 const hex = getRootKeyHex();391 if (!hex) throw new VaultLockedError();392 return hexToBytes(hex);393}394395async function deriveEntryKey(masterKey: Uint8Array, entryId: string): Promise<CryptoKey> {396 397 398 399 400 const info = new TextEncoder().encode(`${entryId}\x00${ENTRY_KEY_INFO_SUFFIX}`);401 const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), info).expand(32);402 return crypto.subtle.importKey(403 "raw",404 raw.buffer as ArrayBuffer,405 { name: "AES-GCM" },406 false,407 ["encrypt", "decrypt"],408 );409}410411async function encryptEntry(key: CryptoKey, entry: VaultEntry): Promise<string> {412 const plaintext = new TextEncoder().encode(JSON.stringify(entry));413 const nonce = crypto.getRandomValues(new Uint8Array(12));414 const aad = new TextEncoder().encode(ENTRY_AAD);415 const ct = await crypto.subtle.encrypt(416 {417 name: "AES-GCM",418 iv: nonce.buffer as ArrayBuffer,419 additionalData: aad.buffer as ArrayBuffer,420 tagLength: 128,421 },422 key,423 plaintext.buffer as ArrayBuffer,424 );425 const ctBytes = new Uint8Array(ct);426 const envelope = new Uint8Array(nonce.length + ctBytes.length);427 envelope.set(nonce, 0);428 envelope.set(ctBytes, nonce.length);429 return bytesToBase64(envelope);430}431432async function decryptEntryOrNull(key: CryptoKey, envelopeB64: string): Promise<VaultEntry | null> {433 try {434 const bytes = base64ToBytes(envelopeB64);435 if (bytes.length < 12 + 16) return null;436 const nonce = bytes.subarray(0, 12);437 const ct = bytes.subarray(12);438 const aad = new TextEncoder().encode(ENTRY_AAD);439 const pt = await crypto.subtle.decrypt(440 {441 name: "AES-GCM",442 iv: nonce.buffer.slice(nonce.byteOffset, nonce.byteOffset + nonce.byteLength) as ArrayBuffer,443 additionalData: aad.buffer as ArrayBuffer,444 tagLength: 128,445 },446 key,447 ct.buffer.slice(ct.byteOffset, ct.byteOffset + ct.byteLength) as ArrayBuffer,448 );449 const json = new TextDecoder().decode(pt);450 const parsed = JSON.parse(json);451 if (!parsed || typeof parsed !== "object" || typeof parsed.id !== "string") return null;452 return parsed as VaultEntry;453 } catch {454 return null;455 }456}457458function hexToBytes(hex: string): Uint8Array {459 if (hex.length % 2 !== 0) throw new Error("hex length must be even");460 const out = new Uint8Array(hex.length / 2);461 for (let i = 0; i < out.length; i++) {462 out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);463 }464 return out;465}466467function bytesToBase64(bytes: Uint8Array): string {468 let bin = "";469 for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);470 return btoa(bin);471}472473function base64ToBytes(b64: string): Uint8Array {474 const bin = atob(b64);475 const out = new Uint8Array(bin.length);476 for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);477 return out;478}