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 requireUnlocked();95 const manifest: Record<string, number> = {};96 for (const entry of getDeletedEntries()) {97 if (typeof entry.deletedAt === "number") {98 manifest[entry.id] = entry.deletedAt;99 }100 }101 return manifest;102 },103104 async getEncryptedEntries(entryIds) {105 requireUnlocked();106 const masterKey = requireMasterKey();107 const out: Record<string, string> = {};108 for (const id of entryIds) {109 const entry = getEntryById(id);110 if (!entry) continue;111 const key = await deriveEntryKey(masterKey, id);112 out[id] = await encryptEntry(key, entry);113 }114 return out;115 },116117 async applyEntries(entries) {118 requireUnlocked();119 const masterKey = requireMasterKey();120 const decrypted: VaultEntry[] = [];121 for (const [id, envelope] of Object.entries(entries)) {122 const key = await deriveEntryKey(masterKey, id);123 const entry = await decryptEntryOrNull(key, envelope);124 if (!entry) continue;125 126 127 if (entry.id !== id) continue;128 decrypted.push(entry);129 }130 if (decrypted.length > 0) {131 await applyMergedRemoteEntries(decrypted, {});132 }133 },134135 async applyTombstones(tombstones) {136 requireUnlocked();137 const cleaned: Record<string, number> = {};138 for (const [id, ts] of Object.entries(tombstones)) {139 if (typeof ts === "number" && Number.isFinite(ts) && ts > 0) {140 cleaned[id] = ts;141 }142 }143 if (Object.keys(cleaned).length > 0) {144 await applyMergedRemoteEntries([], cleaned);145 }146 },147148 async buildOutgoingDeviceManifest() {149 requireUnlocked();150 const masterKey = requireMasterKey();151 const issuerDeviceId = await getOrCreateDeviceId();152 const signing = await deriveSyncSigningKeypair(masterKey, issuerDeviceId);153 const devices = (await listPairedDevices())154 .map(toManifestEntry)155 .filter((d): d is ManifestDeviceEntry => d !== null);156 return buildSignedDeviceManifest(157 issuerDeviceId,158 devices,159 signing.privateKey,160 );161 },162163 async applyIncomingDeviceManifest(manifest) {164 requireUnlocked();165 await applyVerifiedExtensionManifest(manifest);166 },167 };168}169170function toManifestEntry(device: PairedDevice): ManifestDeviceEntry | null {171 if (172 !device.deviceId ||173 !device.deviceName ||174 !device.deviceType ||175 !device.longTermPublicKeyHex ||176 !/^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex) ||177 !device.signingPublicKeyHex ||178 !/^[0-9a-f]{64}$/.test(device.signingPublicKeyHex)179 ) {180 return null;181 }182 return {183 deviceId: device.deviceId,184 deviceName: device.deviceName,185 deviceType: device.deviceType,186 longTermPublicKeyHex: device.longTermPublicKeyHex,187 signingPublicKeyHex: device.signingPublicKeyHex,188 wsPort: device.wsPort,189 pairedAt: device.pairedAt,190 };191}192193async function applyVerifiedExtensionManifest(194 manifest: SignedDeviceManifest,195): Promise<void> {196 const issuerId = manifest?.body?.issuerDeviceId;197 if (typeof issuerId !== "string" || !issuerId) return;198199 const issuer = await findPairedDeviceById(issuerId);200 if (201 !issuer ||202 issuer.deviceType !== "extension" ||203 !issuer.signingPublicKeyHex ||204 !/^[0-9a-f]{64}$/.test(issuer.signingPublicKeyHex)205 ) {206 return;207 }208209 let body: Awaited<ReturnType<typeof verifyDeviceManifest>>;210 try {211 body = await verifyDeviceManifest(212 manifest,213 hexToPubkey(issuer.signingPublicKeyHex),214 );215 } catch {216 return;217 }218 if (body.issuerDeviceId !== issuer.deviceId) return;219220 const ourDeviceId = await getOrCreateDeviceId();221 for (const entry of body.devices) {222 if (entry.deviceId === ourDeviceId || entry.deviceId === issuer.deviceId) {223 continue;224 }225 const existing = await findPairedDeviceById(entry.deviceId);226 await upsertPairedDevice({227 ...(existing ?? {}),228 deviceId: entry.deviceId,229 deviceName: entry.deviceName,230 deviceType: entry.deviceType,231 longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),232 signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),233 pairedAt: existing?.pairedAt ?? entry.pairedAt,234 trustSource: existing?.trustSource ?? (existing ? "direct" : "vouched"),235 wsPort: existing?.wsPort ?? entry.wsPort,236 lastSyncedAt: existing?.lastSyncedAt,237 });238 }239}240241function requireUnlocked(): void {242 if (!isUnlocked()) throw new VaultLockedError();243}244245function requireMasterKey(): Uint8Array {246 if (!isUnlocked()) throw new VaultLockedError();247 const hex = getDerivedKeyHex();248 if (!hex) throw new VaultLockedError();249 return hexToBytes(hex);250}251252async function deriveEntryKey(masterKey: Uint8Array, entryId: string): Promise<CryptoKey> {253 const info = new TextEncoder().encode(entryId + ENTRY_KEY_INFO_SUFFIX);254 const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), info).expand(32);255 return crypto.subtle.importKey(256 "raw",257 raw.buffer as ArrayBuffer,258 { name: "AES-GCM" },259 false,260 ["encrypt", "decrypt"],261 );262}263264async function encryptEntry(key: CryptoKey, entry: VaultEntry): Promise<string> {265 const plaintext = new TextEncoder().encode(JSON.stringify(entry));266 const nonce = crypto.getRandomValues(new Uint8Array(12));267 const aad = new TextEncoder().encode(ENTRY_AAD);268 const ct = await crypto.subtle.encrypt(269 {270 name: "AES-GCM",271 iv: nonce.buffer as ArrayBuffer,272 additionalData: aad.buffer as ArrayBuffer,273 tagLength: 128,274 },275 key,276 plaintext.buffer as ArrayBuffer,277 );278 const ctBytes = new Uint8Array(ct);279 const envelope = new Uint8Array(nonce.length + ctBytes.length);280 envelope.set(nonce, 0);281 envelope.set(ctBytes, nonce.length);282 return bytesToBase64(envelope);283}284285async function decryptEntryOrNull(key: CryptoKey, envelopeB64: string): Promise<VaultEntry | null> {286 try {287 const bytes = base64ToBytes(envelopeB64);288 if (bytes.length < 12 + 16) return null;289 const nonce = bytes.subarray(0, 12);290 const ct = bytes.subarray(12);291 const aad = new TextEncoder().encode(ENTRY_AAD);292 const pt = await crypto.subtle.decrypt(293 {294 name: "AES-GCM",295 iv: nonce.buffer.slice(nonce.byteOffset, nonce.byteOffset + nonce.byteLength) as ArrayBuffer,296 additionalData: aad.buffer as ArrayBuffer,297 tagLength: 128,298 },299 key,300 ct.buffer.slice(ct.byteOffset, ct.byteOffset + ct.byteLength) as ArrayBuffer,301 );302 const json = new TextDecoder().decode(pt);303 const parsed = JSON.parse(json);304 if (!parsed || typeof parsed !== "object" || typeof parsed.id !== "string") return null;305 return parsed as VaultEntry;306 } catch {307 return null;308 }309}310311function hexToBytes(hex: string): Uint8Array {312 if (hex.length % 2 !== 0) throw new Error("hex length must be even");313 const out = new Uint8Array(hex.length / 2);314 for (let i = 0; i < out.length; i++) {315 out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);316 }317 return out;318}319320function bytesToBase64(bytes: Uint8Array): string {321 let bin = "";322 for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);323 return btoa(bin);324}325326function base64ToBytes(b64: string): Uint8Array {327 const bin = atob(b64);328 const out = new Uint8Array(bin.length);329 for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);330 return out;331}