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 getVaultVersion,40 isUnlocked,41} from "./vault";42import {43 findPairedDeviceById,44 listPairedDevices,45 upsertPairedDevice,46} from "./paired-devices";47import type { VaultEntry } from "../shared/types";48import type {49 ManifestDeviceEntry,50 PairedDevice,51 SignedDeviceManifest,52} from "../shared/sync-types";53545556const ENTRY_KEY_INFO_SUFFIX = "sync-entry-v1";57const ENTRY_AAD = "peach-sync-entry-v1";5859export class VaultLockedError extends Error {60 constructor() {61 super("vault_locked");62 this.name = "VaultLockedError";63 }64}6566export function createChirpSyncVaultAdapter(): ChirpSyncVaultAdapter {67 return {68 async computeVaultId() {69 const master = requireMasterKey();70 return computeVaultId(master);71 },7273 async getOurDeviceId() {74 return getOrCreateDeviceId();75 },7677 async getLocalVersion() {78 return getVaultVersion();79 },8081 async buildEntryManifest() {82 requireUnlocked();83 const manifest: Record<string, number> = {};84 for (const entry of getEntries()) {85 manifest[entry.id] = entry.modifiedAt;86 }87 return manifest;88 },8990 async buildTombstoneManifest() {91 requireUnlocked();92 const manifest: Record<string, number> = {};93 for (const entry of getDeletedEntries()) {94 if (typeof entry.deletedAt === "number") {95 manifest[entry.id] = entry.deletedAt;96 }97 }98 return manifest;99 },100101 async getEncryptedEntries(entryIds) {102 requireUnlocked();103 const masterKey = requireMasterKey();104 const out: Record<string, string> = {};105 for (const id of entryIds) {106 const entry = getEntryById(id);107 if (!entry) continue;108 const key = await deriveEntryKey(masterKey, id);109 out[id] = await encryptEntry(key, entry);110 }111 return out;112 },113114 async applyEntries(entries) {115 requireUnlocked();116 const masterKey = requireMasterKey();117 const decrypted: VaultEntry[] = [];118 for (const [id, envelope] of Object.entries(entries)) {119 const key = await deriveEntryKey(masterKey, id);120 const entry = await decryptEntryOrNull(key, envelope);121 if (!entry) continue;122 123 124 if (entry.id !== id) continue;125 decrypted.push(entry);126 }127 if (decrypted.length > 0) {128 await applyMergedRemoteEntries(decrypted, {});129 }130 },131132 async applyTombstones(tombstones) {133 requireUnlocked();134 const cleaned: Record<string, number> = {};135 for (const [id, ts] of Object.entries(tombstones)) {136 if (typeof ts === "number" && Number.isFinite(ts) && ts > 0) {137 cleaned[id] = ts;138 }139 }140 if (Object.keys(cleaned).length > 0) {141 await applyMergedRemoteEntries([], cleaned);142 }143 },144145 async buildOutgoingDeviceManifest() {146 requireUnlocked();147 const masterKey = requireMasterKey();148 const issuerDeviceId = await getOrCreateDeviceId();149 const signing = await deriveSyncSigningKeypair(masterKey, issuerDeviceId);150 const devices = (await listPairedDevices())151 .map(toManifestEntry)152 .filter((d): d is ManifestDeviceEntry => d !== null);153 return buildSignedDeviceManifest(154 issuerDeviceId,155 devices,156 signing.privateKey,157 );158 },159160 async applyIncomingDeviceManifest(manifest) {161 requireUnlocked();162 await applyVerifiedExtensionManifest(manifest);163 },164 };165}166167function toManifestEntry(device: PairedDevice): ManifestDeviceEntry | null {168 if (169 !device.deviceId ||170 !device.deviceName ||171 !device.deviceType ||172 !device.longTermPublicKeyHex ||173 !/^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex) ||174 !device.signingPublicKeyHex ||175 !/^[0-9a-f]{64}$/.test(device.signingPublicKeyHex)176 ) {177 return null;178 }179 return {180 deviceId: device.deviceId,181 deviceName: device.deviceName,182 deviceType: device.deviceType,183 longTermPublicKeyHex: device.longTermPublicKeyHex,184 signingPublicKeyHex: device.signingPublicKeyHex,185 wsPort: device.wsPort,186 pairedAt: device.pairedAt,187 };188}189190async function applyVerifiedExtensionManifest(191 manifest: SignedDeviceManifest,192): Promise<void> {193 const issuerId = manifest?.body?.issuerDeviceId;194 if (typeof issuerId !== "string" || !issuerId) return;195196 const issuer = await findPairedDeviceById(issuerId);197 if (198 !issuer ||199 issuer.deviceType !== "extension" ||200 !issuer.signingPublicKeyHex ||201 !/^[0-9a-f]{64}$/.test(issuer.signingPublicKeyHex)202 ) {203 return;204 }205206 let body: Awaited<ReturnType<typeof verifyDeviceManifest>>;207 try {208 body = await verifyDeviceManifest(209 manifest,210 hexToPubkey(issuer.signingPublicKeyHex),211 );212 } catch {213 return;214 }215 if (body.issuerDeviceId !== issuer.deviceId) return;216217 const ourDeviceId = await getOrCreateDeviceId();218 for (const entry of body.devices) {219 if (entry.deviceId === ourDeviceId || entry.deviceId === issuer.deviceId) {220 continue;221 }222 const existing = await findPairedDeviceById(entry.deviceId);223 await upsertPairedDevice({224 ...(existing ?? {}),225 deviceId: entry.deviceId,226 deviceName: entry.deviceName,227 deviceType: entry.deviceType,228 longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),229 signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),230 pairedAt: existing?.pairedAt ?? entry.pairedAt,231 trustSource: existing?.trustSource ?? (existing ? "direct" : "vouched"),232 wsPort: existing?.wsPort ?? entry.wsPort,233 lastSyncedAt: existing?.lastSyncedAt,234 });235 }236}237238function requireUnlocked(): void {239 if (!isUnlocked()) throw new VaultLockedError();240}241242function requireMasterKey(): Uint8Array {243 if (!isUnlocked()) throw new VaultLockedError();244 const hex = getDerivedKeyHex();245 if (!hex) throw new VaultLockedError();246 return hexToBytes(hex);247}248249async function deriveEntryKey(masterKey: Uint8Array, entryId: string): Promise<CryptoKey> {250 const info = new TextEncoder().encode(entryId + ENTRY_KEY_INFO_SUFFIX);251 const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), info).expand(32);252 return crypto.subtle.importKey(253 "raw",254 raw.buffer as ArrayBuffer,255 { name: "AES-GCM" },256 false,257 ["encrypt", "decrypt"],258 );259}260261async function encryptEntry(key: CryptoKey, entry: VaultEntry): Promise<string> {262 const plaintext = new TextEncoder().encode(JSON.stringify(entry));263 const nonce = crypto.getRandomValues(new Uint8Array(12));264 const aad = new TextEncoder().encode(ENTRY_AAD);265 const ct = await crypto.subtle.encrypt(266 {267 name: "AES-GCM",268 iv: nonce.buffer as ArrayBuffer,269 additionalData: aad.buffer as ArrayBuffer,270 tagLength: 128,271 },272 key,273 plaintext.buffer as ArrayBuffer,274 );275 const ctBytes = new Uint8Array(ct);276 const envelope = new Uint8Array(nonce.length + ctBytes.length);277 envelope.set(nonce, 0);278 envelope.set(ctBytes, nonce.length);279 return bytesToBase64(envelope);280}281282async function decryptEntryOrNull(key: CryptoKey, envelopeB64: string): Promise<VaultEntry | null> {283 try {284 const bytes = base64ToBytes(envelopeB64);285 if (bytes.length < 12 + 16) return null;286 const nonce = bytes.subarray(0, 12);287 const ct = bytes.subarray(12);288 const aad = new TextEncoder().encode(ENTRY_AAD);289 const pt = await crypto.subtle.decrypt(290 {291 name: "AES-GCM",292 iv: nonce.buffer.slice(nonce.byteOffset, nonce.byteOffset + nonce.byteLength) as ArrayBuffer,293 additionalData: aad.buffer as ArrayBuffer,294 tagLength: 128,295 },296 key,297 ct.buffer.slice(ct.byteOffset, ct.byteOffset + ct.byteLength) as ArrayBuffer,298 );299 const json = new TextDecoder().decode(pt);300 const parsed = JSON.parse(json);301 if (!parsed || typeof parsed !== "object" || typeof parsed.id !== "string") return null;302 return parsed as VaultEntry;303 } catch {304 return null;305 }306}307308function hexToBytes(hex: string): Uint8Array {309 if (hex.length % 2 !== 0) throw new Error("hex length must be even");310 const out = new Uint8Array(hex.length / 2);311 for (let i = 0; i < out.length; i++) {312 out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);313 }314 return out;315}316317function bytesToBase64(bytes: Uint8Array): string {318 let bin = "";319 for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);320 return btoa(bin);321}322323function base64ToBytes(b64: string): Uint8Array {324 const bin = atob(b64);325 const out = new Uint8Array(bin.length);326 for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);327 return out;328}