1234567891011121314151617181920import { HKDF } from "@stablelib/hkdf";21import { SHA256 } from "@stablelib/sha256";22import type { ChirpSyncVaultAdapter } from "./chirp-sync-coordinator";23import { getOrCreateDeviceId } from "./chirp-identity";24import {25 computeVaultId,26 deriveSyncSigningKeypair,27 hexToPubkey,28} from "../shared/sync-identity";29import {30 buildSignedDeviceManifest,31 verifyDeviceManifest,32} from "../shared/device-manifest";33import {34 applyMergedRemoteEntries,35 getDeletedEntries,36 getDerivedKeyHex,37 getEntries,38 getEntryById,39 isUnlocked,40} from "./vault";41import {42 findPairedDeviceById,43 listPairedDevices,44 upsertPairedDevice,45} from "./paired-devices";46import type { VaultEntry } from "../shared/types";47import type {48 ManifestDeviceEntry,49 PairedDevice,50 SignedDeviceManifest,51} from "../shared/sync-types";5253const ENTRY_KEY_SALT = new TextEncoder().encode("peach-sync-entry-v1");54const ENTRY_AAD = "peach-sync-entry-v1";5556export class VaultLockedError extends Error {57 constructor() {58 super("vault_locked");59 this.name = "VaultLockedError";60 }61}6263export function createChirpSyncVaultAdapter(): ChirpSyncVaultAdapter {64 return {65 async computeVaultId() {66 const master = requireMasterKey();67 return computeVaultId(master);68 },6970 async getOurDeviceId() {71 return getOrCreateDeviceId();72 },7374 async buildEntryManifest() {75 requireUnlocked();76 const manifest: Record<string, number> = {};77 for (const entry of getEntries()) {78 manifest[entry.id] = entry.modifiedAt;79 }80 return manifest;81 },8283 async buildTombstoneManifest() {84 requireUnlocked();85 const manifest: Record<string, number> = {};86 for (const entry of getDeletedEntries()) {87 if (typeof entry.deletedAt === "number") {88 manifest[entry.id] = entry.deletedAt;89 }90 }91 return manifest;92 },9394 async getEncryptedEntries(entryIds) {95 requireUnlocked();96 const masterKey = requireMasterKey();97 const entryKey = await deriveEntryKey(masterKey);98 const out: Record<string, string> = {};99 for (const id of entryIds) {100 const entry = getEntryById(id);101 if (!entry) continue;102 out[id] = await encryptEntry(entryKey, entry);103 }104 return out;105 },106107 async applyEntries(entries) {108 requireUnlocked();109 const masterKey = requireMasterKey();110 const entryKey = await deriveEntryKey(masterKey);111 const decrypted: VaultEntry[] = [];112 for (const [id, envelope] of Object.entries(entries)) {113 const entry = await decryptEntryOrNull(entryKey, envelope);114 if (!entry) continue;115 116 117 if (entry.id !== id) continue;118 decrypted.push(entry);119 }120 if (decrypted.length > 0) {121 await applyMergedRemoteEntries(decrypted, {});122 }123 },124125 async applyTombstones(tombstones) {126 requireUnlocked();127 const cleaned: Record<string, number> = {};128 for (const [id, ts] of Object.entries(tombstones)) {129 if (typeof ts === "number" && Number.isFinite(ts) && ts > 0) {130 cleaned[id] = ts;131 }132 }133 if (Object.keys(cleaned).length > 0) {134 await applyMergedRemoteEntries([], cleaned);135 }136 },137138 async buildOutgoingDeviceManifest() {139 requireUnlocked();140 const masterKey = requireMasterKey();141 const issuerDeviceId = await getOrCreateDeviceId();142 const signing = await deriveSyncSigningKeypair(masterKey, issuerDeviceId);143 const devices = (await listPairedDevices())144 .map(toManifestEntry)145 .filter((d): d is ManifestDeviceEntry => d !== null);146 return buildSignedDeviceManifest(147 issuerDeviceId,148 devices,149 signing.privateKey,150 );151 },152153 async applyIncomingDeviceManifest(manifest) {154 requireUnlocked();155 await applyVerifiedExtensionManifest(manifest);156 },157 };158}159160function toManifestEntry(device: PairedDevice): ManifestDeviceEntry | null {161 if (162 !device.deviceId ||163 !device.deviceName ||164 !device.deviceType ||165 !device.longTermPublicKeyHex ||166 !/^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex) ||167 !device.signingPublicKeyHex ||168 !/^[0-9a-f]{64}$/.test(device.signingPublicKeyHex)169 ) {170 return null;171 }172 return {173 deviceId: device.deviceId,174 deviceName: device.deviceName,175 deviceType: device.deviceType,176 longTermPublicKeyHex: device.longTermPublicKeyHex,177 signingPublicKeyHex: device.signingPublicKeyHex,178 wsPort: device.wsPort,179 pairedAt: device.pairedAt,180 };181}182183async function applyVerifiedExtensionManifest(184 manifest: SignedDeviceManifest,185): Promise<void> {186 const issuerId = manifest?.body?.issuerDeviceId;187 if (typeof issuerId !== "string" || !issuerId) return;188189 const issuer = await findPairedDeviceById(issuerId);190 if (191 !issuer ||192 issuer.deviceType !== "extension" ||193 !issuer.signingPublicKeyHex ||194 !/^[0-9a-f]{64}$/.test(issuer.signingPublicKeyHex)195 ) {196 return;197 }198199 let body: Awaited<ReturnType<typeof verifyDeviceManifest>>;200 try {201 body = await verifyDeviceManifest(202 manifest,203 hexToPubkey(issuer.signingPublicKeyHex),204 );205 } catch {206 return;207 }208 if (body.issuerDeviceId !== issuer.deviceId) return;209210 const ourDeviceId = await getOrCreateDeviceId();211 for (const entry of body.devices) {212 if (entry.deviceId === ourDeviceId || entry.deviceId === issuer.deviceId) {213 continue;214 }215 const existing = await findPairedDeviceById(entry.deviceId);216 await upsertPairedDevice({217 ...(existing ?? {}),218 deviceId: entry.deviceId,219 deviceName: entry.deviceName,220 deviceType: entry.deviceType,221 longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),222 signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),223 pairedAt: existing?.pairedAt ?? entry.pairedAt,224 trustSource: existing?.trustSource ?? (existing ? "direct" : "vouched"),225 wsPort: existing?.wsPort ?? entry.wsPort,226 lastSyncedAt: existing?.lastSyncedAt,227 });228 }229}230231function requireUnlocked(): void {232 if (!isUnlocked()) throw new VaultLockedError();233}234235function requireMasterKey(): Uint8Array {236 if (!isUnlocked()) throw new VaultLockedError();237 const hex = getDerivedKeyHex();238 if (!hex) throw new VaultLockedError();239 return hexToBytes(hex);240}241242async function deriveEntryKey(masterKey: Uint8Array): Promise<CryptoKey> {243 const raw = new HKDF(SHA256, masterKey, ENTRY_KEY_SALT, new Uint8Array(0)).expand(32);244 return crypto.subtle.importKey(245 "raw",246 raw.buffer as ArrayBuffer,247 { name: "AES-GCM" },248 false,249 ["encrypt", "decrypt"],250 );251}252253async function encryptEntry(key: CryptoKey, entry: VaultEntry): Promise<string> {254 const plaintext = new TextEncoder().encode(JSON.stringify(entry));255 const nonce = crypto.getRandomValues(new Uint8Array(12));256 const aad = new TextEncoder().encode(ENTRY_AAD);257 const ct = await crypto.subtle.encrypt(258 {259 name: "AES-GCM",260 iv: nonce.buffer as ArrayBuffer,261 additionalData: aad.buffer as ArrayBuffer,262 tagLength: 128,263 },264 key,265 plaintext.buffer as ArrayBuffer,266 );267 const ctBytes = new Uint8Array(ct);268 const envelope = new Uint8Array(nonce.length + ctBytes.length);269 envelope.set(nonce, 0);270 envelope.set(ctBytes, nonce.length);271 return bytesToBase64(envelope);272}273274async function decryptEntryOrNull(key: CryptoKey, envelopeB64: string): Promise<VaultEntry | null> {275 try {276 const bytes = base64ToBytes(envelopeB64);277 if (bytes.length < 12 + 16) return null;278 const nonce = bytes.subarray(0, 12);279 const ct = bytes.subarray(12);280 const aad = new TextEncoder().encode(ENTRY_AAD);281 const pt = await crypto.subtle.decrypt(282 {283 name: "AES-GCM",284 iv: nonce.buffer.slice(nonce.byteOffset, nonce.byteOffset + nonce.byteLength) as ArrayBuffer,285 additionalData: aad.buffer as ArrayBuffer,286 tagLength: 128,287 },288 key,289 ct.buffer.slice(ct.byteOffset, ct.byteOffset + ct.byteLength) as ArrayBuffer,290 );291 const json = new TextDecoder().decode(pt);292 const parsed = JSON.parse(json);293 if (!parsed || typeof parsed !== "object" || typeof parsed.id !== "string") return null;294 return parsed as VaultEntry;295 } catch {296 return null;297 }298}299300function hexToBytes(hex: string): Uint8Array {301 if (hex.length % 2 !== 0) throw new Error("hex length must be even");302 const out = new Uint8Array(hex.length / 2);303 for (let i = 0; i < out.length; i++) {304 out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);305 }306 return out;307}308309function bytesToBase64(bytes: Uint8Array): string {310 let bin = "";311 for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);312 return btoa(bin);313}314315function base64ToBytes(b64: string): Uint8Array {316 const bin = atob(b64);317 const out = new Uint8Array(bin.length);318 for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);319 return out;320}