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 getDerivedKeyHex,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}251252function requireMasterKey(): Uint8Array {253 if (!isUnlocked()) throw new VaultLockedError();254 const hex = getDerivedKeyHex();255 if (!hex) throw new VaultLockedError();256 return hexToBytes(hex);257}258259async function deriveEntryKey(masterKey: Uint8Array, entryId: string): Promise<CryptoKey> {260 const info = new TextEncoder().encode(entryId + ENTRY_KEY_INFO_SUFFIX);261 const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), info).expand(32);262 return crypto.subtle.importKey(263 "raw",264 raw.buffer as ArrayBuffer,265 { name: "AES-GCM" },266 false,267 ["encrypt", "decrypt"],268 );269}270271async function encryptEntry(key: CryptoKey, entry: VaultEntry): Promise<string> {272 const plaintext = new TextEncoder().encode(JSON.stringify(entry));273 const nonce = crypto.getRandomValues(new Uint8Array(12));274 const aad = new TextEncoder().encode(ENTRY_AAD);275 const ct = await crypto.subtle.encrypt(276 {277 name: "AES-GCM",278 iv: nonce.buffer as ArrayBuffer,279 additionalData: aad.buffer as ArrayBuffer,280 tagLength: 128,281 },282 key,283 plaintext.buffer as ArrayBuffer,284 );285 const ctBytes = new Uint8Array(ct);286 const envelope = new Uint8Array(nonce.length + ctBytes.length);287 envelope.set(nonce, 0);288 envelope.set(ctBytes, nonce.length);289 return bytesToBase64(envelope);290}291292async function decryptEntryOrNull(key: CryptoKey, envelopeB64: string): Promise<VaultEntry | null> {293 try {294 const bytes = base64ToBytes(envelopeB64);295 if (bytes.length < 12 + 16) return null;296 const nonce = bytes.subarray(0, 12);297 const ct = bytes.subarray(12);298 const aad = new TextEncoder().encode(ENTRY_AAD);299 const pt = await crypto.subtle.decrypt(300 {301 name: "AES-GCM",302 iv: nonce.buffer.slice(nonce.byteOffset, nonce.byteOffset + nonce.byteLength) as ArrayBuffer,303 additionalData: aad.buffer as ArrayBuffer,304 tagLength: 128,305 },306 key,307 ct.buffer.slice(ct.byteOffset, ct.byteOffset + ct.byteLength) as ArrayBuffer,308 );309 const json = new TextDecoder().decode(pt);310 const parsed = JSON.parse(json);311 if (!parsed || typeof parsed !== "object" || typeof parsed.id !== "string") return null;312 return parsed as VaultEntry;313 } catch {314 return null;315 }316}317318function hexToBytes(hex: string): Uint8Array {319 if (hex.length % 2 !== 0) throw new Error("hex length must be even");320 const out = new Uint8Array(hex.length / 2);321 for (let i = 0; i < out.length; i++) {322 out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);323 }324 return out;325}326327function bytesToBase64(bytes: Uint8Array): string {328 let bin = "";329 for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);330 return btoa(bin);331}332333function base64ToBytes(b64: string): Uint8Array {334 const bin = atob(b64);335 const out = new Uint8Array(bin.length);336 for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);337 return out;338}