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 { addPendingVouchedDevice } from "./pending-vouched-devices";51import { recordSyncAlerts } from "./sync-alerts";52import { SYNC_LIMITS, clampSyncTimestamp } from "../shared/sync-limits";53import type { VaultEntry } from "../shared/types";54import type {55 ManifestDeviceEntry,56 PairedDevice,57 SignedDeviceManifest,58} from "../shared/sync-types";59606162const ENTRY_KEY_INFO_SUFFIX = "sync-entry-v1";63const ENTRY_AAD = "peach-sync-entry-v1";6465export class VaultLockedError extends Error {66 constructor() {67 super("vault_locked");68 this.name = "VaultLockedError";69 }70}7172export function createChirpSyncVaultAdapter(73 context: { deviceName?: string } = {},74): ChirpSyncVaultAdapter {75 return {76 async computeVaultId() {77 const master = requireMasterKey();78 return computeVaultId(master);79 },8081 async getOurDeviceId() {82 return getOrCreateDeviceId();83 },8485 async getLocalVersion() {86 return getVaultVersion();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 100 101 102 103 104 105 106 requireUnlocked();107 const manifest: Record<string, number> = {};108 for (const entry of getDeletedEntries()) {109 if (typeof entry.deletedAt === "number") {110 manifest[entry.id] = entry.deletedAt;111 }112 }113 return manifest;114 },115116 async getEncryptedEntries(entryIds) {117 requireUnlocked();118 const masterKey = requireMasterKey();119 const out: Record<string, string> = {};120 for (const id of entryIds) {121 const entry = getEntryById(id);122 if (!entry) continue;123 const key = await deriveEntryKey(masterKey, id);124 out[id] = await encryptEntry(key, entry);125 }126 return out;127 },128129 async applyEntries(entries) {130 requireUnlocked();131 const entryList = Object.entries(entries);132 133 if (entryList.length > SYNC_LIMITS.MAX_ENTRY_COUNT) {134 throw new Error(135 `sync entry count ${entryList.length} exceeds limit ${SYNC_LIMITS.MAX_ENTRY_COUNT}`,136 );137 }138 const masterKey = requireMasterKey();139 const decrypted: VaultEntry[] = [];140 for (const [id, envelope] of entryList) {141 142 143 if (envelope.length > (SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3 + 64) {144 continue; 145 }146 const key = await deriveEntryKey(masterKey, id);147 const entry = await decryptEntryOrNull(key, envelope);148 if (!entry) continue;149 150 if (JSON.stringify(entry).length > SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES) {151 continue;152 }153 154 155 if (entry.id !== id) continue;156 decrypted.push(entry);157 }158 if (decrypted.length > 0) {159 await applyMergedRemoteEntries(decrypted, {}, context);160 }161 },162163 async applyTombstones(tombstones) {164 requireUnlocked();165 const tombstoneList = Object.entries(tombstones);166 167 if (tombstoneList.length > SYNC_LIMITS.MAX_TOMBSTONE_COUNT) {168 throw new Error(169 `sync tombstone count ${tombstoneList.length} exceeds limit ${SYNC_LIMITS.MAX_TOMBSTONE_COUNT}`,170 );171 }172 const cleaned: Record<string, number> = {};173 for (const [id, ts] of tombstoneList) {174 if (typeof ts === "number" && Number.isFinite(ts) && ts > 0) {175 176 177 178 cleaned[id] = clampSyncTimestamp(ts);179 }180 }181 if (Object.keys(cleaned).length > 0) {182 await applyMergedRemoteEntries([], cleaned, context);183 }184 },185186 async buildOutgoingDeviceManifest() {187 requireUnlocked();188 const masterKey = requireMasterKey();189 const issuerDeviceId = await getOrCreateDeviceId();190 const signing = await deriveSyncSigningKeypair(masterKey, issuerDeviceId);191 const devices = (await listPairedDevices())192 .map(toManifestEntry)193 .filter((d): d is ManifestDeviceEntry => d !== null);194 return buildSignedDeviceManifest(195 issuerDeviceId,196 devices,197 signing.privateKey,198 );199 },200201 async applyIncomingDeviceManifest(manifest) {202 requireUnlocked();203 await applyVerifiedPeerManifest(manifest);204 },205 };206}207208function toManifestEntry(device: PairedDevice): ManifestDeviceEntry | null {209 if (210 !device.deviceId ||211 !device.deviceName ||212 !device.deviceType ||213 !device.longTermPublicKeyHex ||214 !/^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex) ||215 !device.signingPublicKeyHex ||216 !/^[0-9a-f]{64}$/.test(device.signingPublicKeyHex)217 ) {218 return null;219 }220 return {221 deviceId: device.deviceId,222 deviceName: device.deviceName,223 deviceType: device.deviceType,224 longTermPublicKeyHex: device.longTermPublicKeyHex,225 signingPublicKeyHex: device.signingPublicKeyHex,226 wsPort: device.wsPort,227 pairedAt: device.pairedAt,228 };229}230231232233234235236237238239240241242243244245246async function applyVerifiedPeerManifest(247 manifest: SignedDeviceManifest,248): Promise<void> {249 const issuerId = manifest?.body?.issuerDeviceId;250 if (typeof issuerId !== "string" || !issuerId) return;251252 const issuer = await findPairedDeviceById(issuerId);253 if (254 !issuer ||255 issuer.trustSource !== "direct" ||256 !issuer.signingPublicKeyHex ||257 !/^[0-9a-f]{64}$/.test(issuer.signingPublicKeyHex)258 ) {259 return;260 }261262 let body: Awaited<ReturnType<typeof verifyDeviceManifest>>;263 try {264 body = await verifyDeviceManifest(265 manifest,266 hexToPubkey(issuer.signingPublicKeyHex),267 );268 } catch {269 return;270 }271 if (body.issuerDeviceId !== issuer.deviceId) return;272273 const ourDeviceId = await getOrCreateDeviceId();274 for (const entry of body.devices) {275 if (entry.deviceId === ourDeviceId || entry.deviceId === issuer.deviceId) {276 continue;277 }278 const existing = await findPairedDeviceById(entry.deviceId);279280 281 282 283 284 285 if (!existing) {286 await addPendingVouchedDevice({287 deviceId: entry.deviceId,288 deviceName: entry.deviceName,289 deviceType: entry.deviceType,290 longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),291 signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),292 wsPort: entry.wsPort,293 pairedAt: entry.pairedAt,294 receivedAt: Date.now(),295 vouchedByDeviceId: issuer.deviceId,296 });297 notifyPopup({298 type: "VOUCHED_DEVICE_PENDING",299 payload: { deviceName: entry.deviceName, deviceId: entry.deviceId },300 });301 continue; 302 }303304 305 306 307 308 if (309 existing.longTermPublicKeyHex &&310 existing.longTermPublicKeyHex !== entry.longTermPublicKeyHex.toLowerCase()311 ) {312 notifyPopup({313 type: "DEVICE_KEY_ROTATED",314 payload: { deviceName: entry.deviceName, deviceId: entry.deviceId },315 });316 void recordSyncAlerts([317 {318 type: "DEVICE_KEY_ROTATED",319 entryId: "",320 entryTitle: entry.deviceName,321 deviceName: entry.deviceName,322 timestamp: Date.now(),323 },324 ]);325 }326327 328 await upsertPairedDevice({329 ...existing,330 deviceId: entry.deviceId,331 deviceName: entry.deviceName,332 deviceType: entry.deviceType,333 longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),334 signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),335 pairedAt: existing.pairedAt ?? entry.pairedAt,336 trustSource: existing.trustSource ?? "direct",337 wsPort: existing.wsPort ?? entry.wsPort,338 lastSyncedAt: existing.lastSyncedAt,339 });340 }341}342343344345function notifyPopup(msg: { type: string; payload?: unknown }): void {346 try {347 void chrome.runtime.sendMessage(msg).catch(() => undefined);348 } catch {349 350 }351}352353function requireUnlocked(): void {354 if (!isUnlocked()) throw new VaultLockedError();355}356357358359360361362363function requireMasterKey(): Uint8Array {364 if (!isUnlocked()) throw new VaultLockedError();365 const hex = getRootKeyHex();366 if (!hex) throw new VaultLockedError();367 return hexToBytes(hex);368}369370async function deriveEntryKey(masterKey: Uint8Array, entryId: string): Promise<CryptoKey> {371 372 373 374 375 const info = new TextEncoder().encode(`${entryId}\x00${ENTRY_KEY_INFO_SUFFIX}`);376 const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), info).expand(32);377 return crypto.subtle.importKey(378 "raw",379 raw.buffer as ArrayBuffer,380 { name: "AES-GCM" },381 false,382 ["encrypt", "decrypt"],383 );384}385386async function encryptEntry(key: CryptoKey, entry: VaultEntry): Promise<string> {387 const plaintext = new TextEncoder().encode(JSON.stringify(entry));388 const nonce = crypto.getRandomValues(new Uint8Array(12));389 const aad = new TextEncoder().encode(ENTRY_AAD);390 const ct = await crypto.subtle.encrypt(391 {392 name: "AES-GCM",393 iv: nonce.buffer as ArrayBuffer,394 additionalData: aad.buffer as ArrayBuffer,395 tagLength: 128,396 },397 key,398 plaintext.buffer as ArrayBuffer,399 );400 const ctBytes = new Uint8Array(ct);401 const envelope = new Uint8Array(nonce.length + ctBytes.length);402 envelope.set(nonce, 0);403 envelope.set(ctBytes, nonce.length);404 return bytesToBase64(envelope);405}406407async function decryptEntryOrNull(key: CryptoKey, envelopeB64: string): Promise<VaultEntry | null> {408 try {409 const bytes = base64ToBytes(envelopeB64);410 if (bytes.length < 12 + 16) return null;411 const nonce = bytes.subarray(0, 12);412 const ct = bytes.subarray(12);413 const aad = new TextEncoder().encode(ENTRY_AAD);414 const pt = await crypto.subtle.decrypt(415 {416 name: "AES-GCM",417 iv: nonce.buffer.slice(nonce.byteOffset, nonce.byteOffset + nonce.byteLength) as ArrayBuffer,418 additionalData: aad.buffer as ArrayBuffer,419 tagLength: 128,420 },421 key,422 ct.buffer.slice(ct.byteOffset, ct.byteOffset + ct.byteLength) as ArrayBuffer,423 );424 const json = new TextDecoder().decode(pt);425 const parsed = JSON.parse(json);426 if (!parsed || typeof parsed !== "object" || typeof parsed.id !== "string") return null;427 return parsed as VaultEntry;428 } catch {429 return null;430 }431}432433function hexToBytes(hex: string): Uint8Array {434 if (hex.length % 2 !== 0) throw new Error("hex length must be even");435 const out = new Uint8Array(hex.length / 2);436 for (let i = 0; i < out.length; i++) {437 out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);438 }439 return out;440}441442function bytesToBase64(bytes: Uint8Array): string {443 let bin = "";444 for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);445 return btoa(bin);446}447448function base64ToBytes(b64: string): Uint8Array {449 const bin = atob(b64);450 const out = new Uint8Array(bin.length);451 for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);452 return out;453}