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 isUnlocked,40} from "./vault";41import {42 findPairedDeviceById,43 listPairedDevices,44 upsertPairedDevice,45} from "./paired-devices";46import type { VaultEntry } from "../shared/types";47import type {48 ManifestDeviceEntry,49 PairedDevice,50 SignedDeviceManifest,51} from "../shared/sync-types";5253const ENTRY_KEY_SALT = new TextEncoder().encode("peach-sync-entry-v1");54const ENTRY_AAD = "peach-sync-entry-v1";5556export class VaultLockedError extends Error {57 constructor() {58 super("vault_locked");59 this.name = "VaultLockedError";60 }61}6263export function createChirpSyncVaultAdapter(): ChirpSyncVaultAdapter {64 return {65 async computeVaultId() {66 const master = requireMasterKey();67 return computeVaultId(master);68 },6970 async getOurDeviceId() {71 return getOrCreateDeviceId();72 },7374 async getLocalVersion() {75 try {76 const raw = await chrome.storage.local.get("peach_last_modified_at");77 const v = (raw as Record<string, unknown>).peach_last_modified_at;78 if (typeof v === "number") return v;79 if (typeof v === "string" && v !== "") {80 const n = Number.parseInt(v, 10);81 return Number.isFinite(n) ? n : 0;82 }83 return 0;84 } catch {85 return 0;86 }87 },8889 async buildEntryManifest() {90 requireUnlocked();91 const manifest: Record<string, number> = {};92 for (const entry of getEntries()) {93 manifest[entry.id] = entry.modifiedAt;94 }95 return manifest;96 },9798 async buildTombstoneManifest() {99 requireUnlocked();100 const manifest: Record<string, number> = {};101 for (const entry of getDeletedEntries()) {102 if (typeof entry.deletedAt === "number") {103 manifest[entry.id] = entry.deletedAt;104 }105 }106 return manifest;107 },108109 async getEncryptedEntries(entryIds) {110 requireUnlocked();111 const masterKey = requireMasterKey();112 const entryKey = await deriveEntryKey(masterKey);113 const out: Record<string, string> = {};114 for (const id of entryIds) {115 const entry = getEntryById(id);116 if (!entry) continue;117 out[id] = await encryptEntry(entryKey, entry);118 }119 return out;120 },121122 async applyEntries(entries) {123 requireUnlocked();124 const masterKey = requireMasterKey();125 const entryKey = await deriveEntryKey(masterKey);126 const decrypted: VaultEntry[] = [];127 for (const [id, envelope] of Object.entries(entries)) {128 const entry = await decryptEntryOrNull(entryKey, envelope);129 if (!entry) continue;130 131 132 if (entry.id !== id) continue;133 decrypted.push(entry);134 }135 if (decrypted.length > 0) {136 await applyMergedRemoteEntries(decrypted, {});137 }138 },139140 async applyTombstones(tombstones) {141 requireUnlocked();142 const cleaned: Record<string, number> = {};143 for (const [id, ts] of Object.entries(tombstones)) {144 if (typeof ts === "number" && Number.isFinite(ts) && ts > 0) {145 cleaned[id] = ts;146 }147 }148 if (Object.keys(cleaned).length > 0) {149 await applyMergedRemoteEntries([], cleaned);150 }151 },152153 async buildOutgoingDeviceManifest() {154 requireUnlocked();155 const masterKey = requireMasterKey();156 const issuerDeviceId = await getOrCreateDeviceId();157 const signing = await deriveSyncSigningKeypair(masterKey, issuerDeviceId);158 const devices = (await listPairedDevices())159 .map(toManifestEntry)160 .filter((d): d is ManifestDeviceEntry => d !== null);161 return buildSignedDeviceManifest(162 issuerDeviceId,163 devices,164 signing.privateKey,165 );166 },167168 async applyIncomingDeviceManifest(manifest) {169 requireUnlocked();170 await applyVerifiedExtensionManifest(manifest);171 },172 };173}174175function toManifestEntry(device: PairedDevice): ManifestDeviceEntry | null {176 if (177 !device.deviceId ||178 !device.deviceName ||179 !device.deviceType ||180 !device.longTermPublicKeyHex ||181 !/^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex) ||182 !device.signingPublicKeyHex ||183 !/^[0-9a-f]{64}$/.test(device.signingPublicKeyHex)184 ) {185 return null;186 }187 return {188 deviceId: device.deviceId,189 deviceName: device.deviceName,190 deviceType: device.deviceType,191 longTermPublicKeyHex: device.longTermPublicKeyHex,192 signingPublicKeyHex: device.signingPublicKeyHex,193 wsPort: device.wsPort,194 pairedAt: device.pairedAt,195 };196}197198async function applyVerifiedExtensionManifest(199 manifest: SignedDeviceManifest,200): Promise<void> {201 const issuerId = manifest?.body?.issuerDeviceId;202 if (typeof issuerId !== "string" || !issuerId) return;203204 const issuer = await findPairedDeviceById(issuerId);205 if (206 !issuer ||207 issuer.deviceType !== "extension" ||208 !issuer.signingPublicKeyHex ||209 !/^[0-9a-f]{64}$/.test(issuer.signingPublicKeyHex)210 ) {211 return;212 }213214 let body: Awaited<ReturnType<typeof verifyDeviceManifest>>;215 try {216 body = await verifyDeviceManifest(217 manifest,218 hexToPubkey(issuer.signingPublicKeyHex),219 );220 } catch {221 return;222 }223 if (body.issuerDeviceId !== issuer.deviceId) return;224225 const ourDeviceId = await getOrCreateDeviceId();226 for (const entry of body.devices) {227 if (entry.deviceId === ourDeviceId || entry.deviceId === issuer.deviceId) {228 continue;229 }230 const existing = await findPairedDeviceById(entry.deviceId);231 await upsertPairedDevice({232 ...(existing ?? {}),233 deviceId: entry.deviceId,234 deviceName: entry.deviceName,235 deviceType: entry.deviceType,236 longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),237 signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),238 pairedAt: existing?.pairedAt ?? entry.pairedAt,239 trustSource: existing?.trustSource ?? (existing ? "direct" : "vouched"),240 wsPort: existing?.wsPort ?? entry.wsPort,241 lastSyncedAt: existing?.lastSyncedAt,242 });243 }244}245246function requireUnlocked(): void {247 if (!isUnlocked()) throw new VaultLockedError();248}249250function requireMasterKey(): Uint8Array {251 if (!isUnlocked()) throw new VaultLockedError();252 const hex = getDerivedKeyHex();253 if (!hex) throw new VaultLockedError();254 return hexToBytes(hex);255}256257async function deriveEntryKey(masterKey: Uint8Array): Promise<CryptoKey> {258 const raw = new HKDF(SHA256, masterKey, ENTRY_KEY_SALT, new Uint8Array(0)).expand(32);259 return crypto.subtle.importKey(260 "raw",261 raw.buffer as ArrayBuffer,262 { name: "AES-GCM" },263 false,264 ["encrypt", "decrypt"],265 );266}267268async function encryptEntry(key: CryptoKey, entry: VaultEntry): Promise<string> {269 const plaintext = new TextEncoder().encode(JSON.stringify(entry));270 const nonce = crypto.getRandomValues(new Uint8Array(12));271 const aad = new TextEncoder().encode(ENTRY_AAD);272 const ct = await crypto.subtle.encrypt(273 {274 name: "AES-GCM",275 iv: nonce.buffer as ArrayBuffer,276 additionalData: aad.buffer as ArrayBuffer,277 tagLength: 128,278 },279 key,280 plaintext.buffer as ArrayBuffer,281 );282 const ctBytes = new Uint8Array(ct);283 const envelope = new Uint8Array(nonce.length + ctBytes.length);284 envelope.set(nonce, 0);285 envelope.set(ctBytes, nonce.length);286 return bytesToBase64(envelope);287}288289async function decryptEntryOrNull(key: CryptoKey, envelopeB64: string): Promise<VaultEntry | null> {290 try {291 const bytes = base64ToBytes(envelopeB64);292 if (bytes.length < 12 + 16) return null;293 const nonce = bytes.subarray(0, 12);294 const ct = bytes.subarray(12);295 const aad = new TextEncoder().encode(ENTRY_AAD);296 const pt = await crypto.subtle.decrypt(297 {298 name: "AES-GCM",299 iv: nonce.buffer.slice(nonce.byteOffset, nonce.byteOffset + nonce.byteLength) as ArrayBuffer,300 additionalData: aad.buffer as ArrayBuffer,301 tagLength: 128,302 },303 key,304 ct.buffer.slice(ct.byteOffset, ct.byteOffset + ct.byteLength) as ArrayBuffer,305 );306 const json = new TextDecoder().decode(pt);307 const parsed = JSON.parse(json);308 if (!parsed || typeof parsed !== "object" || typeof parsed.id !== "string") return null;309 return parsed as VaultEntry;310 } catch {311 return null;312 }313}314315function hexToBytes(hex: string): Uint8Array {316 if (hex.length % 2 !== 0) throw new Error("hex length must be even");317 const out = new Uint8Array(hex.length / 2);318 for (let i = 0; i < out.length; i++) {319 out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);320 }321 return out;322}323324function bytesToBase64(bytes: Uint8Array): string {325 let bin = "";326 for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);327 return btoa(bin);328}329330function base64ToBytes(b64: string): Uint8Array {331 const bin = atob(b64);332 const out = new Uint8Array(bin.length);333 for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);334 return out;335}