1234567891011121314151617181920212223import { HKDF } from "@stablelib/hkdf";24import { SHA256 } from "@stablelib/sha256";25import type { ChirpSyncVaultAdapter } from "./chirp-sync-coordinator";26import { getOrCreateDeviceId } from "./chirp-identity";27import {28 computeVaultId,29 deriveSyncSigningKeypair,30 hexToPubkey,31} from "../shared/sync-identity";32import {33 buildSignedDeviceManifest,34 verifyDeviceManifest,35} from "../shared/device-manifest";36import {37 applyMergedRemoteEntries,38 getDeletedEntries,39 getRootKeyHex,40 getEntries,41 getEntryById,42 getVaultVersion,43 isUnlocked,44} from "./vault";45import {46 findPairedDeviceById,47 listPairedDevices,48 upsertPairedDevice,49} from "./paired-devices";50import { addPendingVouchedDevice } from "./pending-vouched-devices";51import { SYNC_LIMITS, clampSyncTimestamp } from "../shared/sync-limits";52import type { VaultEntry } from "../shared/types";53import type {54 ManifestDeviceEntry,55 PairedDevice,56 SignedDeviceManifest,57} from "../shared/sync-types";58596061const ENTRY_KEY_INFO_SUFFIX = "sync-entry-v1";62const ENTRY_AAD = "peach-sync-entry-v1";6364export class VaultLockedError extends Error {65 constructor() {66 super("vault_locked");67 this.name = "VaultLockedError";68 }69}7071export function createChirpSyncVaultAdapter(72 context: { deviceName?: string } = {},73): ChirpSyncVaultAdapter {74 return {75 async computeVaultId() {76 const master = requireMasterKey();77 return computeVaultId(master);78 },7980 async getOurDeviceId() {81 return getOrCreateDeviceId();82 },8384 async getLocalVersion() {85 return getVaultVersion();86 },8788 async buildEntryManifest() {89 requireUnlocked();90 const manifest: Record<string, number> = {};91 for (const entry of getEntries()) {92 manifest[entry.id] = entry.modifiedAt;93 }94 return manifest;95 },9697 async buildTombstoneManifest() {98 99 100 101 102 103 104 105 requireUnlocked();106 const manifest: Record<string, number> = {};107 for (const entry of getDeletedEntries()) {108 if (typeof entry.deletedAt === "number") {109 manifest[entry.id] = entry.deletedAt;110 }111 }112 return manifest;113 },114115 async getEncryptedEntries(entryIds) {116 requireUnlocked();117 const masterKey = requireMasterKey();118 const out: Record<string, string> = {};119 for (const id of entryIds) {120 const entry = getEntryById(id);121 if (!entry) continue;122 const key = await deriveEntryKey(masterKey, id);123 out[id] = await encryptEntry(key, entry);124 }125 return out;126 },127128 async applyEntries(entries) {129 requireUnlocked();130 const entryList = Object.entries(entries);131 132 if (entryList.length > SYNC_LIMITS.MAX_ENTRY_COUNT) {133 throw new Error(134 `sync entry count ${entryList.length} exceeds limit ${SYNC_LIMITS.MAX_ENTRY_COUNT}`,135 );136 }137 const masterKey = requireMasterKey();138 const decrypted: VaultEntry[] = [];139 for (const [id, envelope] of entryList) {140 141 142 if (envelope.length > (SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3 + 64) {143 continue; 144 }145 const key = await deriveEntryKey(masterKey, id);146 const entry = await decryptEntryOrNull(key, envelope);147 if (!entry) continue;148 149 if (JSON.stringify(entry).length > SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES) {150 continue;151 }152 153 154 if (entry.id !== id) continue;155 decrypted.push(entry);156 }157 if (decrypted.length > 0) {158 await applyMergedRemoteEntries(decrypted, {}, context);159 }160 },161162 async applyTombstones(tombstones) {163 requireUnlocked();164 const tombstoneList = Object.entries(tombstones);165 166 if (tombstoneList.length > SYNC_LIMITS.MAX_TOMBSTONE_COUNT) {167 throw new Error(168 `sync tombstone count ${tombstoneList.length} exceeds limit ${SYNC_LIMITS.MAX_TOMBSTONE_COUNT}`,169 );170 }171 const cleaned: Record<string, number> = {};172 for (const [id, ts] of tombstoneList) {173 if (typeof ts === "number" && Number.isFinite(ts) && ts > 0) {174 175 176 177 cleaned[id] = clampSyncTimestamp(ts);178 }179 }180 if (Object.keys(cleaned).length > 0) {181 await applyMergedRemoteEntries([], cleaned, context);182 }183 },184185 async buildOutgoingDeviceManifest() {186 requireUnlocked();187 const masterKey = requireMasterKey();188 const issuerDeviceId = await getOrCreateDeviceId();189 const signing = await deriveSyncSigningKeypair(masterKey, issuerDeviceId);190 const devices = (await listPairedDevices())191 .map(toManifestEntry)192 .filter((d): d is ManifestDeviceEntry => d !== null);193 return buildSignedDeviceManifest(194 issuerDeviceId,195 devices,196 signing.privateKey,197 );198 },199200 async applyIncomingDeviceManifest(manifest) {201 requireUnlocked();202 await applyVerifiedExtensionManifest(manifest);203 },204 };205}206207function toManifestEntry(device: PairedDevice): ManifestDeviceEntry | null {208 if (209 !device.deviceId ||210 !device.deviceName ||211 !device.deviceType ||212 !device.longTermPublicKeyHex ||213 !/^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex) ||214 !device.signingPublicKeyHex ||215 !/^[0-9a-f]{64}$/.test(device.signingPublicKeyHex)216 ) {217 return null;218 }219 return {220 deviceId: device.deviceId,221 deviceName: device.deviceName,222 deviceType: device.deviceType,223 longTermPublicKeyHex: device.longTermPublicKeyHex,224 signingPublicKeyHex: device.signingPublicKeyHex,225 wsPort: device.wsPort,226 pairedAt: device.pairedAt,227 };228}229230async function applyVerifiedExtensionManifest(231 manifest: SignedDeviceManifest,232): Promise<void> {233 const issuerId = manifest?.body?.issuerDeviceId;234 if (typeof issuerId !== "string" || !issuerId) return;235236 const issuer = await findPairedDeviceById(issuerId);237 if (238 !issuer ||239 issuer.deviceType !== "extension" ||240 !issuer.signingPublicKeyHex ||241 !/^[0-9a-f]{64}$/.test(issuer.signingPublicKeyHex)242 ) {243 return;244 }245246 let body: Awaited<ReturnType<typeof verifyDeviceManifest>>;247 try {248 body = await verifyDeviceManifest(249 manifest,250 hexToPubkey(issuer.signingPublicKeyHex),251 );252 } catch {253 return;254 }255 if (body.issuerDeviceId !== issuer.deviceId) return;256257 const ourDeviceId = await getOrCreateDeviceId();258 for (const entry of body.devices) {259 if (entry.deviceId === ourDeviceId || entry.deviceId === issuer.deviceId) {260 continue;261 }262 const existing = await findPairedDeviceById(entry.deviceId);263264 265 266 267 268 269 if (!existing) {270 await addPendingVouchedDevice({271 deviceId: entry.deviceId,272 deviceName: entry.deviceName,273 deviceType: entry.deviceType,274 longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),275 signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),276 wsPort: entry.wsPort,277 pairedAt: entry.pairedAt,278 receivedAt: Date.now(),279 vouchedByDeviceId: issuer.deviceId,280 });281 notifyPopup({282 type: "VOUCHED_DEVICE_PENDING",283 payload: { deviceName: entry.deviceName, deviceId: entry.deviceId },284 });285 continue; 286 }287288 289 290 await upsertPairedDevice({291 ...existing,292 deviceId: entry.deviceId,293 deviceName: entry.deviceName,294 deviceType: entry.deviceType,295 longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),296 signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),297 pairedAt: existing.pairedAt ?? entry.pairedAt,298 trustSource: existing.trustSource ?? "direct",299 wsPort: existing.wsPort ?? entry.wsPort,300 lastSyncedAt: existing.lastSyncedAt,301 });302 }303}304305306307function notifyPopup(msg: { type: string; payload?: unknown }): void {308 try {309 void chrome.runtime.sendMessage(msg).catch(() => undefined);310 } catch {311 312 }313}314315function requireUnlocked(): void {316 if (!isUnlocked()) throw new VaultLockedError();317}318319320321322323324325function requireMasterKey(): Uint8Array {326 if (!isUnlocked()) throw new VaultLockedError();327 const hex = getRootKeyHex();328 if (!hex) throw new VaultLockedError();329 return hexToBytes(hex);330}331332async function deriveEntryKey(masterKey: Uint8Array, entryId: string): Promise<CryptoKey> {333 const info = new TextEncoder().encode(entryId + ENTRY_KEY_INFO_SUFFIX);334 const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), info).expand(32);335 return crypto.subtle.importKey(336 "raw",337 raw.buffer as ArrayBuffer,338 { name: "AES-GCM" },339 false,340 ["encrypt", "decrypt"],341 );342}343344async function encryptEntry(key: CryptoKey, entry: VaultEntry): Promise<string> {345 const plaintext = new TextEncoder().encode(JSON.stringify(entry));346 const nonce = crypto.getRandomValues(new Uint8Array(12));347 const aad = new TextEncoder().encode(ENTRY_AAD);348 const ct = await crypto.subtle.encrypt(349 {350 name: "AES-GCM",351 iv: nonce.buffer as ArrayBuffer,352 additionalData: aad.buffer as ArrayBuffer,353 tagLength: 128,354 },355 key,356 plaintext.buffer as ArrayBuffer,357 );358 const ctBytes = new Uint8Array(ct);359 const envelope = new Uint8Array(nonce.length + ctBytes.length);360 envelope.set(nonce, 0);361 envelope.set(ctBytes, nonce.length);362 return bytesToBase64(envelope);363}364365async function decryptEntryOrNull(key: CryptoKey, envelopeB64: string): Promise<VaultEntry | null> {366 try {367 const bytes = base64ToBytes(envelopeB64);368 if (bytes.length < 12 + 16) return null;369 const nonce = bytes.subarray(0, 12);370 const ct = bytes.subarray(12);371 const aad = new TextEncoder().encode(ENTRY_AAD);372 const pt = await crypto.subtle.decrypt(373 {374 name: "AES-GCM",375 iv: nonce.buffer.slice(nonce.byteOffset, nonce.byteOffset + nonce.byteLength) as ArrayBuffer,376 additionalData: aad.buffer as ArrayBuffer,377 tagLength: 128,378 },379 key,380 ct.buffer.slice(ct.byteOffset, ct.byteOffset + ct.byteLength) as ArrayBuffer,381 );382 const json = new TextDecoder().decode(pt);383 const parsed = JSON.parse(json);384 if (!parsed || typeof parsed !== "object" || typeof parsed.id !== "string") return null;385 return parsed as VaultEntry;386 } catch {387 return null;388 }389}390391function hexToBytes(hex: string): Uint8Array {392 if (hex.length % 2 !== 0) throw new Error("hex length must be even");393 const out = new Uint8Array(hex.length / 2);394 for (let i = 0; i < out.length; i++) {395 out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);396 }397 return out;398}399400function bytesToBase64(bytes: Uint8Array): string {401 let bin = "";402 for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);403 return btoa(bin);404}405406function base64ToBytes(b64: string): Uint8Array {407 const bin = atob(b64);408 const out = new Uint8Array(bin.length);409 for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);410 return out;411}