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 { SYNC_LIMITS, clampSyncTimestamp } from "../shared/sync-limits";51import type { VaultEntry } from "../shared/types";52import type {53 ManifestDeviceEntry,54 PairedDevice,55 SignedDeviceManifest,56} from "../shared/sync-types";57585960const ENTRY_KEY_INFO_SUFFIX = "sync-entry-v1";61const ENTRY_AAD = "peach-sync-entry-v1";6263export class VaultLockedError extends Error {64 constructor() {65 super("vault_locked");66 this.name = "VaultLockedError";67 }68}6970export function createChirpSyncVaultAdapter(71 context: { deviceName?: string } = {},72): ChirpSyncVaultAdapter {73 return {74 async computeVaultId() {75 const master = requireMasterKey();76 return computeVaultId(master);77 },7879 async getOurDeviceId() {80 return getOrCreateDeviceId();81 },8283 async getLocalVersion() {84 return getVaultVersion();85 },8687 async buildEntryManifest() {88 requireUnlocked();89 const manifest: Record<string, number> = {};90 for (const entry of getEntries()) {91 manifest[entry.id] = entry.modifiedAt;92 }93 return manifest;94 },9596 async buildTombstoneManifest() {97 98 99 100 101 102 103 104 requireUnlocked();105 const manifest: Record<string, number> = {};106 for (const entry of getDeletedEntries()) {107 if (typeof entry.deletedAt === "number") {108 manifest[entry.id] = entry.deletedAt;109 }110 }111 return manifest;112 },113114 async getEncryptedEntries(entryIds) {115 requireUnlocked();116 const masterKey = requireMasterKey();117 const out: Record<string, string> = {};118 for (const id of entryIds) {119 const entry = getEntryById(id);120 if (!entry) continue;121 const key = await deriveEntryKey(masterKey, id);122 out[id] = await encryptEntry(key, entry);123 }124 return out;125 },126127 async applyEntries(entries) {128 requireUnlocked();129 const entryList = Object.entries(entries);130 131 if (entryList.length > SYNC_LIMITS.MAX_ENTRY_COUNT) {132 throw new Error(133 `sync entry count ${entryList.length} exceeds limit ${SYNC_LIMITS.MAX_ENTRY_COUNT}`,134 );135 }136 const masterKey = requireMasterKey();137 const decrypted: VaultEntry[] = [];138 for (const [id, envelope] of entryList) {139 140 141 if (envelope.length > (SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3 + 64) {142 continue; 143 }144 const key = await deriveEntryKey(masterKey, id);145 const entry = await decryptEntryOrNull(key, envelope);146 if (!entry) continue;147 148 if (JSON.stringify(entry).length > SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES) {149 continue;150 }151 152 153 if (entry.id !== id) continue;154 decrypted.push(entry);155 }156 if (decrypted.length > 0) {157 await applyMergedRemoteEntries(decrypted, {}, context);158 }159 },160161 async applyTombstones(tombstones) {162 requireUnlocked();163 const tombstoneList = Object.entries(tombstones);164 165 if (tombstoneList.length > SYNC_LIMITS.MAX_TOMBSTONE_COUNT) {166 throw new Error(167 `sync tombstone count ${tombstoneList.length} exceeds limit ${SYNC_LIMITS.MAX_TOMBSTONE_COUNT}`,168 );169 }170 const cleaned: Record<string, number> = {};171 for (const [id, ts] of tombstoneList) {172 if (typeof ts === "number" && Number.isFinite(ts) && ts > 0) {173 174 175 176 cleaned[id] = clampSyncTimestamp(ts);177 }178 }179 if (Object.keys(cleaned).length > 0) {180 await applyMergedRemoteEntries([], cleaned, context);181 }182 },183184 async buildOutgoingDeviceManifest() {185 requireUnlocked();186 const masterKey = requireMasterKey();187 const issuerDeviceId = await getOrCreateDeviceId();188 const signing = await deriveSyncSigningKeypair(masterKey, issuerDeviceId);189 const devices = (await listPairedDevices())190 .map(toManifestEntry)191 .filter((d): d is ManifestDeviceEntry => d !== null);192 return buildSignedDeviceManifest(193 issuerDeviceId,194 devices,195 signing.privateKey,196 );197 },198199 async applyIncomingDeviceManifest(manifest) {200 requireUnlocked();201 await applyVerifiedExtensionManifest(manifest);202 },203 };204}205206function toManifestEntry(device: PairedDevice): ManifestDeviceEntry | null {207 if (208 !device.deviceId ||209 !device.deviceName ||210 !device.deviceType ||211 !device.longTermPublicKeyHex ||212 !/^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex) ||213 !device.signingPublicKeyHex ||214 !/^[0-9a-f]{64}$/.test(device.signingPublicKeyHex)215 ) {216 return null;217 }218 return {219 deviceId: device.deviceId,220 deviceName: device.deviceName,221 deviceType: device.deviceType,222 longTermPublicKeyHex: device.longTermPublicKeyHex,223 signingPublicKeyHex: device.signingPublicKeyHex,224 wsPort: device.wsPort,225 pairedAt: device.pairedAt,226 };227}228229async function applyVerifiedExtensionManifest(230 manifest: SignedDeviceManifest,231): Promise<void> {232 const issuerId = manifest?.body?.issuerDeviceId;233 if (typeof issuerId !== "string" || !issuerId) return;234235 const issuer = await findPairedDeviceById(issuerId);236 if (237 !issuer ||238 issuer.deviceType !== "extension" ||239 !issuer.signingPublicKeyHex ||240 !/^[0-9a-f]{64}$/.test(issuer.signingPublicKeyHex)241 ) {242 return;243 }244245 let body: Awaited<ReturnType<typeof verifyDeviceManifest>>;246 try {247 body = await verifyDeviceManifest(248 manifest,249 hexToPubkey(issuer.signingPublicKeyHex),250 );251 } catch {252 return;253 }254 if (body.issuerDeviceId !== issuer.deviceId) return;255256 const ourDeviceId = await getOrCreateDeviceId();257 for (const entry of body.devices) {258 if (entry.deviceId === ourDeviceId || entry.deviceId === issuer.deviceId) {259 continue;260 }261 const existing = await findPairedDeviceById(entry.deviceId);262 await upsertPairedDevice({263 ...(existing ?? {}),264 deviceId: entry.deviceId,265 deviceName: entry.deviceName,266 deviceType: entry.deviceType,267 longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),268 signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),269 pairedAt: existing?.pairedAt ?? entry.pairedAt,270 trustSource: existing?.trustSource ?? (existing ? "direct" : "vouched"),271 wsPort: existing?.wsPort ?? entry.wsPort,272 lastSyncedAt: existing?.lastSyncedAt,273 });274 }275}276277function requireUnlocked(): void {278 if (!isUnlocked()) throw new VaultLockedError();279}280281282283284285286287function requireMasterKey(): Uint8Array {288 if (!isUnlocked()) throw new VaultLockedError();289 const hex = getRootKeyHex();290 if (!hex) throw new VaultLockedError();291 return hexToBytes(hex);292}293294async function deriveEntryKey(masterKey: Uint8Array, entryId: string): Promise<CryptoKey> {295 const info = new TextEncoder().encode(entryId + ENTRY_KEY_INFO_SUFFIX);296 const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), info).expand(32);297 return crypto.subtle.importKey(298 "raw",299 raw.buffer as ArrayBuffer,300 { name: "AES-GCM" },301 false,302 ["encrypt", "decrypt"],303 );304}305306async function encryptEntry(key: CryptoKey, entry: VaultEntry): Promise<string> {307 const plaintext = new TextEncoder().encode(JSON.stringify(entry));308 const nonce = crypto.getRandomValues(new Uint8Array(12));309 const aad = new TextEncoder().encode(ENTRY_AAD);310 const ct = await crypto.subtle.encrypt(311 {312 name: "AES-GCM",313 iv: nonce.buffer as ArrayBuffer,314 additionalData: aad.buffer as ArrayBuffer,315 tagLength: 128,316 },317 key,318 plaintext.buffer as ArrayBuffer,319 );320 const ctBytes = new Uint8Array(ct);321 const envelope = new Uint8Array(nonce.length + ctBytes.length);322 envelope.set(nonce, 0);323 envelope.set(ctBytes, nonce.length);324 return bytesToBase64(envelope);325}326327async function decryptEntryOrNull(key: CryptoKey, envelopeB64: string): Promise<VaultEntry | null> {328 try {329 const bytes = base64ToBytes(envelopeB64);330 if (bytes.length < 12 + 16) return null;331 const nonce = bytes.subarray(0, 12);332 const ct = bytes.subarray(12);333 const aad = new TextEncoder().encode(ENTRY_AAD);334 const pt = await crypto.subtle.decrypt(335 {336 name: "AES-GCM",337 iv: nonce.buffer.slice(nonce.byteOffset, nonce.byteOffset + nonce.byteLength) as ArrayBuffer,338 additionalData: aad.buffer as ArrayBuffer,339 tagLength: 128,340 },341 key,342 ct.buffer.slice(ct.byteOffset, ct.byteOffset + ct.byteLength) as ArrayBuffer,343 );344 const json = new TextDecoder().decode(pt);345 const parsed = JSON.parse(json);346 if (!parsed || typeof parsed !== "object" || typeof parsed.id !== "string") return null;347 return parsed as VaultEntry;348 } catch {349 return null;350 }351}352353function hexToBytes(hex: string): Uint8Array {354 if (hex.length % 2 !== 0) throw new Error("hex length must be even");355 const out = new Uint8Array(hex.length / 2);356 for (let i = 0; i < out.length; i++) {357 out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);358 }359 return out;360}361362function bytesToBase64(bytes: Uint8Array): string {363 let bin = "";364 for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);365 return btoa(bin);366}367368function base64ToBytes(b64: string): Uint8Array {369 const bin = atob(b64);370 const out = new Uint8Array(bin.length);371 for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);372 return out;373}