1234567891011121314151617181920212223import { HKDF } from "@stablelib/hkdf";24import { SHA256 } from "@stablelib/sha256";25import type { SyncVaultAdapter } from "./sync-coordinator";26import { getOrCreateDeviceId } from "./device-identity";27import {28 computeVaultId,29 deriveSyncSigningKeypair,30 hexToPubkey,31 pubkeyToHex,32} from "../shared/sync-identity";33import {34 buildSignedDeviceManifest,35 verifyDeviceManifest,36} from "../shared/device-manifest";37import { constantTimeEqualHex } from "../shared/constant-time";38import {39 applyMergedRemoteEntries,40 getDeletedEntries,41 getRootKeyHex,42 getEntries,43 getEntryById,44 getVaultVersion,45 isUnlocked,46} from "./vault";47import {48 findPairedDeviceById,49 findPairedDeviceByPubkey,50 listDeviceRevocations,51 listPairedDevices,52 mergeDeviceRevocations,53 removePairedDeviceById,54 upsertPairedDevice,55} from "./paired-devices";56import {57 addPendingVouchedDevice,58 removePendingVouchedDevice,59} from "./pending-vouched-devices";60import { recordSyncAlerts } from "./sync-alerts";61import { SYNC_LIMITS, clampSyncTimestamp } from "../shared/sync-limits";62import type { VaultEntry } from "../shared/types";63import type {64 ManifestDeviceEntry,65 PairedDevice,66 SignedDeviceManifest,67} from "../shared/sync-types";68697071const ENTRY_KEY_INFO_SUFFIX = "sync-entry-v1";72const ENTRY_AAD = "peach-sync-entry-v1";7374export class VaultLockedError extends Error {75 constructor() {76 super("vault_locked");77 this.name = "VaultLockedError";78 }79}8081export function createSyncVaultAdapter(82 context: { deviceName?: string } = {},83): SyncVaultAdapter {84 return {85 async computeVaultId() {86 const master = requireMasterKey();87 return computeVaultId(master);88 },8990 async getOurDeviceId() {91 return getOrCreateDeviceId();92 },9394 async getLocalVersion() {95 return getVaultVersion();96 },9798 async buildEntryManifest() {99 requireUnlocked();100 const manifest: Record<string, number> = {};101 for (const entry of getEntries()) {102 manifest[entry.id] = entry.modifiedAt;103 }104 return manifest;105 },106107 async buildTombstoneManifest() {108 109 110 111 112 113 114 115 requireUnlocked();116 const manifest: Record<string, number> = {};117 for (const entry of getDeletedEntries()) {118 if (typeof entry.deletedAt === "number") {119 manifest[entry.id] = entry.deletedAt;120 }121 }122 return manifest;123 },124125 async getEncryptedEntries(entryIds) {126 requireUnlocked();127 const masterKey = requireMasterKey();128 const out: Record<string, string> = {};129 for (const id of entryIds) {130 const entry = getEntryById(id);131 if (!entry) continue;132 const key = await deriveEntryKey(masterKey, id);133 out[id] = await encryptEntry(key, entry);134 }135 return out;136 },137138 async applyEntries(entries) {139 requireUnlocked();140 const entryList = Object.entries(entries);141 142 if (entryList.length > SYNC_LIMITS.MAX_ENTRY_COUNT) {143 throw new Error(144 `sync entry count ${entryList.length} exceeds limit ${SYNC_LIMITS.MAX_ENTRY_COUNT}`,145 );146 }147 const masterKey = requireMasterKey();148 const decrypted: VaultEntry[] = [];149 for (const [id, envelope] of entryList) {150 151 152 if (envelope.length > (SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3 + 64) {153 continue; 154 }155 const key = await deriveEntryKey(masterKey, id);156 const entry = await decryptEntryOrNull(key, envelope);157 if (!entry) continue;158 159 if (JSON.stringify(entry).length > SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES) {160 continue;161 }162 163 164 if (entry.id !== id) continue;165 decrypted.push(entry);166 }167 if (decrypted.length > 0) {168 await applyMergedRemoteEntries(decrypted, {}, context);169 }170 },171172 async applyTombstones(tombstones) {173 requireUnlocked();174 const tombstoneList = Object.entries(tombstones);175 176 if (tombstoneList.length > SYNC_LIMITS.MAX_TOMBSTONE_COUNT) {177 throw new Error(178 `sync tombstone count ${tombstoneList.length} exceeds limit ${SYNC_LIMITS.MAX_TOMBSTONE_COUNT}`,179 );180 }181 const cleaned: Record<string, number> = {};182 for (const [id, ts] of tombstoneList) {183 if (typeof ts === "number" && Number.isFinite(ts) && ts > 0) {184 185 186 187 cleaned[id] = clampSyncTimestamp(ts);188 }189 }190 if (Object.keys(cleaned).length > 0) {191 await applyMergedRemoteEntries([], cleaned, context);192 }193 },194195 async buildOutgoingDeviceManifest() {196 requireUnlocked();197 const masterKey = requireMasterKey();198 const issuerDeviceId = await getOrCreateDeviceId();199 const signing = await deriveSyncSigningKeypair(masterKey, issuerDeviceId);200 const revoked = await listDeviceRevocations();201 const revokedIds = new Set(revoked.map((r) => r.deviceId));202 const devices = (await listPairedDevices())203 .filter((d) => !revokedIds.has(d.deviceId))204 .map(toManifestEntry)205 .filter((d): d is ManifestDeviceEntry => d !== null);206 return buildSignedDeviceManifest(207 issuerDeviceId,208 devices,209 signing.privateKey,210 { revoked },211 );212 },213214 async applyIncomingDeviceManifest(manifest, authenticatedRemoteStaticPublicKey) {215 requireUnlocked();216 await applyVerifiedPeerManifest(217 manifest,218 authenticatedRemoteStaticPublicKey,219 );220 },221 };222}223224function toManifestEntry(device: PairedDevice): ManifestDeviceEntry | null {225 if (226 !device.deviceId ||227 !device.deviceName ||228 !device.deviceType ||229 !device.longTermPublicKeyHex ||230 !/^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex) ||231 !device.signingPublicKeyHex ||232 !/^[0-9a-f]{64}$/.test(device.signingPublicKeyHex)233 ) {234 return null;235 }236 return {237 deviceId: device.deviceId,238 deviceName: device.deviceName,239 deviceType: device.deviceType,240 longTermPublicKeyHex: device.longTermPublicKeyHex,241 signingPublicKeyHex: device.signingPublicKeyHex,242 wsPort: device.wsPort,243 pairedAt: device.pairedAt,244 };245}246247248249250251252253254255256257258259260261262async function applyVerifiedPeerManifest(263 manifest: SignedDeviceManifest,264 authenticatedRemoteStaticPublicKey: Uint8Array,265): Promise<void> {266 const issuerId = manifest?.body?.issuerDeviceId;267 if (typeof issuerId !== "string" || !issuerId) return;268269 if (authenticatedRemoteStaticPublicKey.length !== 32) return;270 const authenticatedPubkeyHex = pubkeyToHex(authenticatedRemoteStaticPublicKey);271 const issuer = await findPairedDeviceByPubkey(authenticatedPubkeyHex);272 if (273 !issuer ||274 issuer.deviceId !== issuerId ||275 issuer.trustSource !== "direct" ||276 !issuer.signingPublicKeyHex ||277 !/^[0-9a-f]{64}$/.test(issuer.signingPublicKeyHex)278 ) {279 return;280 }281282 let body: Awaited<ReturnType<typeof verifyDeviceManifest>>;283 try {284 body = await verifyDeviceManifest(285 manifest,286 hexToPubkey(issuer.signingPublicKeyHex),287 );288 } catch {289 return;290 }291 if (body.issuerDeviceId !== issuer.deviceId) return;292293 const ourDeviceId = await getOrCreateDeviceId();294295 296 297 298 299 const newlyRevoked = await mergeDeviceRevocations(300 body.revoked.filter((r) => r.deviceId !== ourDeviceId),301 );302 for (const deviceId of newlyRevoked) {303 await removePairedDeviceById(deviceId);304 await removePendingVouchedDevice(deviceId);305 void recordSyncAlerts([306 {307 type: "DEVICE_REVOKED",308 entryId: "",309 entryTitle: deviceId,310 deviceName: issuer.deviceName ?? "paired device",311 timestamp: Date.now(),312 },313 ]);314 }315316 const revokedIds = new Set(317 (await listDeviceRevocations()).map((r) => r.deviceId),318 );319320 for (const entry of body.devices) {321 if (entry.deviceId === ourDeviceId || entry.deviceId === issuer.deviceId) {322 continue;323 }324 325 if (revokedIds.has(entry.deviceId)) continue;326 const existing = await findPairedDeviceById(entry.deviceId);327328 329 330 331 332 333 if (!existing) {334 await addPendingVouchedDevice({335 deviceId: entry.deviceId,336 deviceName: entry.deviceName,337 deviceType: entry.deviceType,338 longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),339 signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),340 wsPort: entry.wsPort,341 pairedAt: entry.pairedAt,342 receivedAt: Date.now(),343 vouchedByDeviceId: issuer.deviceId,344 });345 346 347 348 notifyPopup({349 type: "VOUCHED_DEVICE_PENDING",350 payload: { deviceName: entry.deviceName, deviceId: entry.deviceId },351 });352 continue; 353 }354355 356 357 358 359 if (360 existing.longTermPublicKeyHex &&361 !constantTimeEqualHex(362 existing.longTermPublicKeyHex,363 entry.longTermPublicKeyHex,364 )365 ) {366 367 368 369 370 371 const knownName = existing.deviceName ?? entry.deviceName;372 notifyPopup({373 type: "DEVICE_KEY_ROTATED",374 payload: { deviceName: knownName, deviceId: entry.deviceId },375 });376 void recordSyncAlerts([377 {378 type: "DEVICE_KEY_ROTATED",379 entryId: "",380 entryTitle: knownName,381 deviceName: knownName,382 timestamp: Date.now(),383 },384 ]);385 }386387 388 389 390 391 await upsertPairedDevice({392 ...existing,393 deviceId: entry.deviceId,394 deviceName: existing.deviceName ?? entry.deviceName,395 deviceType: entry.deviceType,396 longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),397 signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),398 pairedAt: existing.pairedAt ?? entry.pairedAt,399 trustSource: existing.trustSource ?? "direct",400 wsPort: existing.wsPort ?? entry.wsPort,401 lastSyncedAt: existing.lastSyncedAt,402 });403 }404}405406407408function notifyPopup(msg: { type: string; payload?: unknown }): void {409 try {410 void chrome.runtime.sendMessage(msg).catch(() => undefined);411 } catch {412 413 }414}415416function requireUnlocked(): void {417 if (!isUnlocked()) throw new VaultLockedError();418}419420421422423424425426function requireMasterKey(): Uint8Array {427 if (!isUnlocked()) throw new VaultLockedError();428 const hex = getRootKeyHex();429 if (!hex) throw new VaultLockedError();430 return hexToBytes(hex);431}432433async function deriveEntryKey(masterKey: Uint8Array, entryId: string): Promise<CryptoKey> {434 435 436 437 438 const info = new TextEncoder().encode(`${entryId}\x00${ENTRY_KEY_INFO_SUFFIX}`);439 const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), info).expand(32);440 return crypto.subtle.importKey(441 "raw",442 raw.buffer as ArrayBuffer,443 { name: "AES-GCM" },444 false,445 ["encrypt", "decrypt"],446 );447}448449async function encryptEntry(key: CryptoKey, entry: VaultEntry): Promise<string> {450 const plaintext = new TextEncoder().encode(JSON.stringify(entry));451 const nonce = crypto.getRandomValues(new Uint8Array(12));452 const aad = new TextEncoder().encode(ENTRY_AAD);453 const ct = await crypto.subtle.encrypt(454 {455 name: "AES-GCM",456 iv: nonce.buffer as ArrayBuffer,457 additionalData: aad.buffer as ArrayBuffer,458 tagLength: 128,459 },460 key,461 plaintext.buffer as ArrayBuffer,462 );463 const ctBytes = new Uint8Array(ct);464 const envelope = new Uint8Array(nonce.length + ctBytes.length);465 envelope.set(nonce, 0);466 envelope.set(ctBytes, nonce.length);467 return bytesToBase64(envelope);468}469470async function decryptEntryOrNull(key: CryptoKey, envelopeB64: string): Promise<VaultEntry | null> {471 try {472 const bytes = base64ToBytes(envelopeB64);473 if (bytes.length < 12 + 16) return null;474 const nonce = bytes.subarray(0, 12);475 const ct = bytes.subarray(12);476 const aad = new TextEncoder().encode(ENTRY_AAD);477 const pt = await crypto.subtle.decrypt(478 {479 name: "AES-GCM",480 iv: nonce.buffer.slice(nonce.byteOffset, nonce.byteOffset + nonce.byteLength) as ArrayBuffer,481 additionalData: aad.buffer as ArrayBuffer,482 tagLength: 128,483 },484 key,485 ct.buffer.slice(ct.byteOffset, ct.byteOffset + ct.byteLength) as ArrayBuffer,486 );487 const json = new TextDecoder().decode(pt);488 const parsed = JSON.parse(json);489 if (!parsed || typeof parsed !== "object" || typeof parsed.id !== "string") return null;490 return parsed as VaultEntry;491 } catch {492 return null;493 }494}495496function hexToBytes(hex: string): Uint8Array {497 if (hex.length % 2 !== 0) throw new Error("hex length must be even");498 const out = new Uint8Array(hex.length / 2);499 for (let i = 0; i < out.length; i++) {500 out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);501 }502 return out;503}504505function bytesToBase64(bytes: Uint8Array): string {506 let bin = "";507 for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);508 return btoa(bin);509}510511function base64ToBytes(b64: string): Uint8Array {512 const bin = atob(b64);513 const out = new Uint8Array(bin.length);514 for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);515 return out;516}