12345678910111213141516171819202122import { HKDF } from "@stablelib/hkdf";23import { SHA256 } from "@stablelib/sha256";24import type { ChirpSyncVaultAdapter } from "./chirp-sync-coordinator";25import { getOrCreateDeviceId } from "./chirp-identity";26import { computeVaultId } from "../shared/sync-identity";27import {28 applyMergedRemoteEntries,29 getDeletedEntries,30 getDerivedKeyHex,31 getEntries,32 getEntryById,33 isUnlocked,34} from "./vault";35import type { VaultEntry } from "../shared/types";3637const ENTRY_KEY_SALT = new TextEncoder().encode("peach-sync-entry-v1");38const ENTRY_AAD = "peach-sync-entry-v1";3940export class VaultLockedError extends Error {41 constructor() {42 super("vault_locked");43 this.name = "VaultLockedError";44 }45}4647export function createChirpSyncVaultAdapter(): ChirpSyncVaultAdapter {48 return {49 async computeVaultId() {50 const master = requireMasterKey();51 return computeVaultId(master);52 },5354 async getOurDeviceId() {55 return getOrCreateDeviceId();56 },5758 async buildEntryManifest() {59 requireUnlocked();60 const manifest: Record<string, number> = {};61 for (const entry of getEntries()) {62 manifest[entry.id] = entry.modifiedAt;63 }64 return manifest;65 },6667 async buildTombstoneManifest() {68 requireUnlocked();69 const manifest: Record<string, number> = {};70 for (const entry of getDeletedEntries()) {71 if (typeof entry.deletedAt === "number") {72 manifest[entry.id] = entry.deletedAt;73 }74 }75 return manifest;76 },7778 async getEncryptedEntries(entryIds) {79 requireUnlocked();80 const masterKey = requireMasterKey();81 const entryKey = await deriveEntryKey(masterKey);82 const out: Record<string, string> = {};83 for (const id of entryIds) {84 const entry = getEntryById(id);85 if (!entry) continue;86 out[id] = await encryptEntry(entryKey, entry);87 }88 return out;89 },9091 async applyEntries(entries) {92 requireUnlocked();93 const masterKey = requireMasterKey();94 const entryKey = await deriveEntryKey(masterKey);95 const decrypted: VaultEntry[] = [];96 for (const [id, envelope] of Object.entries(entries)) {97 const entry = await decryptEntryOrNull(entryKey, envelope);98 if (!entry) continue;99 100 101 if (entry.id !== id) continue;102 decrypted.push(entry);103 }104 if (decrypted.length > 0) {105 await applyMergedRemoteEntries(decrypted, {});106 }107 },108109 async applyTombstones(tombstones) {110 requireUnlocked();111 const cleaned: Record<string, number> = {};112 for (const [id, ts] of Object.entries(tombstones)) {113 if (typeof ts === "number" && Number.isFinite(ts) && ts > 0) {114 cleaned[id] = ts;115 }116 }117 if (Object.keys(cleaned).length > 0) {118 await applyMergedRemoteEntries([], cleaned);119 }120 },121 };122}123124function requireUnlocked(): void {125 if (!isUnlocked()) throw new VaultLockedError();126}127128function requireMasterKey(): Uint8Array {129 if (!isUnlocked()) throw new VaultLockedError();130 const hex = getDerivedKeyHex();131 if (!hex) throw new VaultLockedError();132 return hexToBytes(hex);133}134135async function deriveEntryKey(masterKey: Uint8Array): Promise<CryptoKey> {136 const raw = new HKDF(SHA256, masterKey, ENTRY_KEY_SALT, new Uint8Array(0)).expand(32);137 return crypto.subtle.importKey(138 "raw",139 raw.buffer as ArrayBuffer,140 { name: "AES-GCM" },141 false,142 ["encrypt", "decrypt"],143 );144}145146async function encryptEntry(key: CryptoKey, entry: VaultEntry): Promise<string> {147 const plaintext = new TextEncoder().encode(JSON.stringify(entry));148 const nonce = crypto.getRandomValues(new Uint8Array(12));149 const aad = new TextEncoder().encode(ENTRY_AAD);150 const ct = await crypto.subtle.encrypt(151 {152 name: "AES-GCM",153 iv: nonce.buffer as ArrayBuffer,154 additionalData: aad.buffer as ArrayBuffer,155 tagLength: 128,156 },157 key,158 plaintext.buffer as ArrayBuffer,159 );160 const ctBytes = new Uint8Array(ct);161 const envelope = new Uint8Array(nonce.length + ctBytes.length);162 envelope.set(nonce, 0);163 envelope.set(ctBytes, nonce.length);164 return bytesToBase64(envelope);165}166167async function decryptEntryOrNull(key: CryptoKey, envelopeB64: string): Promise<VaultEntry | null> {168 try {169 const bytes = base64ToBytes(envelopeB64);170 if (bytes.length < 12 + 16) return null;171 const nonce = bytes.subarray(0, 12);172 const ct = bytes.subarray(12);173 const aad = new TextEncoder().encode(ENTRY_AAD);174 const pt = await crypto.subtle.decrypt(175 {176 name: "AES-GCM",177 iv: nonce.buffer.slice(nonce.byteOffset, nonce.byteOffset + nonce.byteLength) as ArrayBuffer,178 additionalData: aad.buffer as ArrayBuffer,179 tagLength: 128,180 },181 key,182 ct.buffer.slice(ct.byteOffset, ct.byteOffset + ct.byteLength) as ArrayBuffer,183 );184 const json = new TextDecoder().decode(pt);185 const parsed = JSON.parse(json);186 if (!parsed || typeof parsed !== "object" || typeof parsed.id !== "string") return null;187 return parsed as VaultEntry;188 } catch {189 return null;190 }191}192193function hexToBytes(hex: string): Uint8Array {194 if (hex.length % 2 !== 0) throw new Error("hex length must be even");195 const out = new Uint8Array(hex.length / 2);196 for (let i = 0; i < out.length; i++) {197 out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);198 }199 return out;200}201202function bytesToBase64(bytes: Uint8Array): string {203 let bin = "";204 for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);205 return btoa(bin);206}207208function base64ToBytes(b64: string): Uint8Array {209 const bin = atob(b64);210 const out = new Uint8Array(bin.length);211 for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);212 return out;213}