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 type { VaultEntry } from "../shared/types";51import type {52 ManifestDeviceEntry,53 PairedDevice,54 SignedDeviceManifest,55} from "../shared/sync-types";56575859const ENTRY_KEY_INFO_SUFFIX = "sync-entry-v1";60const ENTRY_AAD = "peach-sync-entry-v1";6162export class VaultLockedError extends Error {63 constructor() {64 super("vault_locked");65 this.name = "VaultLockedError";66 }67}6869export function createChirpSyncVaultAdapter(): ChirpSyncVaultAdapter {70 return {71 async computeVaultId() {72 const master = requireMasterKey();73 return computeVaultId(master);74 },7576 async getOurDeviceId() {77 return getOrCreateDeviceId();78 },7980 async getLocalVersion() {81 return getVaultVersion();82 },8384 async buildEntryManifest() {85 requireUnlocked();86 const manifest: Record<string, number> = {};87 for (const entry of getEntries()) {88 manifest[entry.id] = entry.modifiedAt;89 }90 return manifest;91 },9293 async buildTombstoneManifest() {94 95 96 97 98 99 100 101 requireUnlocked();102 const manifest: Record<string, number> = {};103 for (const entry of getDeletedEntries()) {104 if (typeof entry.deletedAt === "number") {105 manifest[entry.id] = entry.deletedAt;106 }107 }108 return manifest;109 },110111 async getEncryptedEntries(entryIds) {112 requireUnlocked();113 const masterKey = requireMasterKey();114 const out: Record<string, string> = {};115 for (const id of entryIds) {116 const entry = getEntryById(id);117 if (!entry) continue;118 const key = await deriveEntryKey(masterKey, id);119 out[id] = await encryptEntry(key, entry);120 }121 return out;122 },123124 async applyEntries(entries) {125 requireUnlocked();126 const masterKey = requireMasterKey();127 const decrypted: VaultEntry[] = [];128 for (const [id, envelope] of Object.entries(entries)) {129 const key = await deriveEntryKey(masterKey, id);130 const entry = await decryptEntryOrNull(key, envelope);131 if (!entry) continue;132 133 134 if (entry.id !== id) continue;135 decrypted.push(entry);136 }137 if (decrypted.length > 0) {138 await applyMergedRemoteEntries(decrypted, {});139 }140 },141142 async applyTombstones(tombstones) {143 requireUnlocked();144 const cleaned: Record<string, number> = {};145 for (const [id, ts] of Object.entries(tombstones)) {146 if (typeof ts === "number" && Number.isFinite(ts) && ts > 0) {147 cleaned[id] = ts;148 }149 }150 if (Object.keys(cleaned).length > 0) {151 await applyMergedRemoteEntries([], cleaned);152 }153 },154155 async buildOutgoingDeviceManifest() {156 requireUnlocked();157 const masterKey = requireMasterKey();158 const issuerDeviceId = await getOrCreateDeviceId();159 const signing = await deriveSyncSigningKeypair(masterKey, issuerDeviceId);160 const devices = (await listPairedDevices())161 .map(toManifestEntry)162 .filter((d): d is ManifestDeviceEntry => d !== null);163 return buildSignedDeviceManifest(164 issuerDeviceId,165 devices,166 signing.privateKey,167 );168 },169170 async applyIncomingDeviceManifest(manifest) {171 requireUnlocked();172 await applyVerifiedExtensionManifest(manifest);173 },174 };175}176177function toManifestEntry(device: PairedDevice): ManifestDeviceEntry | null {178 if (179 !device.deviceId ||180 !device.deviceName ||181 !device.deviceType ||182 !device.longTermPublicKeyHex ||183 !/^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex) ||184 !device.signingPublicKeyHex ||185 !/^[0-9a-f]{64}$/.test(device.signingPublicKeyHex)186 ) {187 return null;188 }189 return {190 deviceId: device.deviceId,191 deviceName: device.deviceName,192 deviceType: device.deviceType,193 longTermPublicKeyHex: device.longTermPublicKeyHex,194 signingPublicKeyHex: device.signingPublicKeyHex,195 wsPort: device.wsPort,196 pairedAt: device.pairedAt,197 };198}199200async function applyVerifiedExtensionManifest(201 manifest: SignedDeviceManifest,202): Promise<void> {203 const issuerId = manifest?.body?.issuerDeviceId;204 if (typeof issuerId !== "string" || !issuerId) return;205206 const issuer = await findPairedDeviceById(issuerId);207 if (208 !issuer ||209 issuer.deviceType !== "extension" ||210 !issuer.signingPublicKeyHex ||211 !/^[0-9a-f]{64}$/.test(issuer.signingPublicKeyHex)212 ) {213 return;214 }215216 let body: Awaited<ReturnType<typeof verifyDeviceManifest>>;217 try {218 body = await verifyDeviceManifest(219 manifest,220 hexToPubkey(issuer.signingPublicKeyHex),221 );222 } catch {223 return;224 }225 if (body.issuerDeviceId !== issuer.deviceId) return;226227 const ourDeviceId = await getOrCreateDeviceId();228 for (const entry of body.devices) {229 if (entry.deviceId === ourDeviceId || entry.deviceId === issuer.deviceId) {230 continue;231 }232 const existing = await findPairedDeviceById(entry.deviceId);233 await upsertPairedDevice({234 ...(existing ?? {}),235 deviceId: entry.deviceId,236 deviceName: entry.deviceName,237 deviceType: entry.deviceType,238 longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),239 signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),240 pairedAt: existing?.pairedAt ?? entry.pairedAt,241 trustSource: existing?.trustSource ?? (existing ? "direct" : "vouched"),242 wsPort: existing?.wsPort ?? entry.wsPort,243 lastSyncedAt: existing?.lastSyncedAt,244 });245 }246}247248function requireUnlocked(): void {249 if (!isUnlocked()) throw new VaultLockedError();250}251252253254255256257258function requireMasterKey(): Uint8Array {259 if (!isUnlocked()) throw new VaultLockedError();260 const hex = getRootKeyHex();261 if (!hex) throw new VaultLockedError();262 return hexToBytes(hex);263}264265async function deriveEntryKey(masterKey: Uint8Array, entryId: string): Promise<CryptoKey> {266 const info = new TextEncoder().encode(entryId + ENTRY_KEY_INFO_SUFFIX);267 const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), info).expand(32);268 return crypto.subtle.importKey(269 "raw",270 raw.buffer as ArrayBuffer,271 { name: "AES-GCM" },272 false,273 ["encrypt", "decrypt"],274 );275}276277async function encryptEntry(key: CryptoKey, entry: VaultEntry): Promise<string> {278 const plaintext = new TextEncoder().encode(JSON.stringify(entry));279 const nonce = crypto.getRandomValues(new Uint8Array(12));280 const aad = new TextEncoder().encode(ENTRY_AAD);281 const ct = await crypto.subtle.encrypt(282 {283 name: "AES-GCM",284 iv: nonce.buffer as ArrayBuffer,285 additionalData: aad.buffer as ArrayBuffer,286 tagLength: 128,287 },288 key,289 plaintext.buffer as ArrayBuffer,290 );291 const ctBytes = new Uint8Array(ct);292 const envelope = new Uint8Array(nonce.length + ctBytes.length);293 envelope.set(nonce, 0);294 envelope.set(ctBytes, nonce.length);295 return bytesToBase64(envelope);296}297298async function decryptEntryOrNull(key: CryptoKey, envelopeB64: string): Promise<VaultEntry | null> {299 try {300 const bytes = base64ToBytes(envelopeB64);301 if (bytes.length < 12 + 16) return null;302 const nonce = bytes.subarray(0, 12);303 const ct = bytes.subarray(12);304 const aad = new TextEncoder().encode(ENTRY_AAD);305 const pt = await crypto.subtle.decrypt(306 {307 name: "AES-GCM",308 iv: nonce.buffer.slice(nonce.byteOffset, nonce.byteOffset + nonce.byteLength) as ArrayBuffer,309 additionalData: aad.buffer as ArrayBuffer,310 tagLength: 128,311 },312 key,313 ct.buffer.slice(ct.byteOffset, ct.byteOffset + ct.byteLength) as ArrayBuffer,314 );315 const json = new TextDecoder().decode(pt);316 const parsed = JSON.parse(json);317 if (!parsed || typeof parsed !== "object" || typeof parsed.id !== "string") return null;318 return parsed as VaultEntry;319 } catch {320 return null;321 }322}323324function hexToBytes(hex: string): Uint8Array {325 if (hex.length % 2 !== 0) throw new Error("hex length must be even");326 const out = new Uint8Array(hex.length / 2);327 for (let i = 0; i < out.length; i++) {328 out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);329 }330 return out;331}332333function bytesToBase64(bytes: Uint8Array): string {334 let bin = "";335 for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);336 return btoa(bin);337}338339function base64ToBytes(b64: string): Uint8Array {340 const bin = atob(b64);341 const out = new Uint8Array(bin.length);342 for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);343 return out;344}