1234567891011121314import { HKDF } from "@stablelib/hkdf";15import { SHA256 } from "@stablelib/sha256";1617import { decrypt, encrypt, zeroKey } from "./aes";18import { listPairedDevices, writePairedDevices } from "./paired-devices";1920const RECORD_KEY = "peach_pair_secrets_v1";21const SESSION_KEY = "peach_pair_secrets_session";22const RECORD_VERSION = 1;23const AAD = "peach-pair-secrets-v1";24const WRAP_INFO = new TextEncoder().encode("peach-pair-secrets-v1");25const EMPTY_SALT = new Uint8Array(0);2627export type PairSecretMap = Record<string, string>;282930313233let sessionAllowed = true;3435export function setSessionAllowed(allowed: boolean): void {36 sessionAllowed = allowed;37}3839export function isValidPairSecret(value: unknown): value is string {40 return typeof value === "string" && /^[0-9a-f]{64}$/.test(value);41}4243function wrappingKey(rootKey: Uint8Array): Uint8Array {44 if (rootKey.length !== 32) throw new Error("pair_secrets_root_key_invalid");45 return new HKDF(SHA256, rootKey, EMPTY_SALT, WRAP_INFO).expand(32);46}4748function hexToBytes(value: string): Uint8Array {49 const out = new Uint8Array(value.length / 2);50 for (let i = 0; i < value.length; i += 2) {51 out[i / 2] = parseInt(value.slice(i, i + 2), 16);52 }53 return out;54}5556type SecretEnvelope = { v: number; nonce: string; ciphertext: string; tag: string };5758function isEnvelope(value: unknown): value is SecretEnvelope {59 if (typeof value !== "object" || value === null) return false;60 const r = value as Record<string, unknown>;61 return (62 r.v === RECORD_VERSION &&63 typeof r.nonce === "string" &&64 typeof r.ciphertext === "string" &&65 typeof r.tag === "string"66 );67}6869function sanitizeMap(value: unknown): PairSecretMap {70 if (typeof value !== "object" || value === null) return {};71 const out: PairSecretMap = {};72 for (const [deviceId, secret] of Object.entries(value as Record<string, unknown>)) {73 if (typeof deviceId === "string" && deviceId && isValidPairSecret(secret)) {74 out[deviceId] = secret;75 }76 }77 return out;78}7980async function readEncryptedMap(rootKey: Uint8Array): Promise<PairSecretMap> {81 let stored: unknown;82 try {83 const result = await chrome.storage.local.get(RECORD_KEY);84 stored = (result as Record<string, unknown>)[RECORD_KEY];85 } catch {86 return {};87 }88 if (stored === undefined) return {};89 if (!isEnvelope(stored)) return {};90 const wrap = wrappingKey(rootKey);91 try {92 return sanitizeMap(JSON.parse(await decrypt(wrap, stored.nonce, stored.ciphertext, stored.tag, AAD)));93 } catch {94 95 return {};96 } finally {97 zeroKey(wrap);98 }99}100101async function writeEncryptedMap(rootKey: Uint8Array, map: PairSecretMap): Promise<void> {102 const wrap = wrappingKey(rootKey);103 try {104 const sealed = await encrypt(wrap, JSON.stringify(map), AAD);105 await chrome.storage.local.set({ [RECORD_KEY]: { v: RECORD_VERSION, ...sealed } });106 } finally {107 zeroKey(wrap);108 }109}110111async function writeSessionCache(map: PairSecretMap): Promise<void> {112 if (!sessionAllowed || !chrome.storage.session) return;113 try {114 await chrome.storage.session.set({ [SESSION_KEY]: map });115 } catch {116 117 118 }119}120121async function readSessionCache(): Promise<PairSecretMap> {122 if (!sessionAllowed || !chrome.storage.session) return {};123 try {124 const result = await chrome.storage.session.get(SESSION_KEY);125 return sanitizeMap((result as Record<string, unknown>)[SESSION_KEY]);126 } catch {127 return {};128 }129}130131async function clearSessionCache(): Promise<void> {132 if (!chrome.storage.session) return;133 try {134 await chrome.storage.session.remove(SESSION_KEY);135 } catch {136 137 }138}139140141142143144145export async function getPairSecret(146 deviceId: string,147 rootKeyHex: string | null,148): Promise<string | null> {149 const cached = await readSessionCache();150 const hit = cached[deviceId];151 if (isValidPairSecret(hit)) return hit;152 if (!rootKeyHex || !/^[0-9a-f]{64}$/.test(rootKeyHex)) return null;153 const rootKey = hexToBytes(rootKeyHex);154 try {155 const map = await readEncryptedMap(rootKey);156 const secret = map[deviceId];157 return isValidPairSecret(secret) ? secret : null;158 } finally {159 zeroKey(rootKey);160 }161}162163164165166167export async function setPairSecret(168 deviceId: string,169 secretHex: string,170 rootKey: Uint8Array,171): Promise<void> {172 if (!deviceId || !isValidPairSecret(secretHex)) throw new Error("pair_secret_invalid");173 const map = await readEncryptedMap(rootKey);174 map[deviceId] = secretHex;175 await writeEncryptedMap(rootKey, map);176 const cached = await readSessionCache();177 cached[deviceId] = secretHex;178 await writeSessionCache(cached);179}180181182183184185186187188189export async function reconcilePairSecrets(190 rootKey: Uint8Array,191): Promise<{ migrated: number }> {192 const devices = await listPairedDevices();193 const registryIds = new Set(devices.map((d) => d.deviceId));194 const legacy = new Map<string, string>();195 for (const device of devices) {196 if (isValidPairSecret(device.rendezvousSecretHex)) {197 legacy.set(device.deviceId, device.rendezvousSecretHex);198 }199 }200201 const map = await readEncryptedMap(rootKey);202 let migrated = 0;203 for (const [deviceId, secret] of legacy) {204 if (map[deviceId] !== secret) {205 map[deviceId] = secret;206 migrated += 1;207 }208 }209 let pruned = false;210 for (const deviceId of Object.keys(map)) {211 if (!registryIds.has(deviceId)) {212 delete map[deviceId];213 pruned = true;214 }215 }216217 218 219 if (migrated > 0 || pruned || legacy.size > 0 || Object.keys(map).length > 0) {220 await writeEncryptedMap(rootKey, map);221 222 const verified = await readEncryptedMap(rootKey);223 for (const [deviceId, secret] of legacy) {224 if (verified[deviceId] !== secret) throw new Error("pair_secrets_verify_failed");225 }226 if (legacy.size > 0) {227 await writePairedDevices(228 devices.map((device) => {229 if (!isValidPairSecret(device.rendezvousSecretHex)) return device;230 const { rendezvousSecretHex: _dropped, ...pub } = device;231 return pub;232 }),233 );234 }235 }236237 await writeSessionCache(map);238 return { migrated };239}240241242export async function clearPairSecrets(): Promise<void> {243 try {244 await chrome.storage.local.remove(RECORD_KEY);245 } catch {246 247 }248 await clearSessionCache();249}250251252253254255256export async function dropSessionCache(): Promise<void> {257 await clearSessionCache();258}259260261262263264265266export async function dropPairSecret(267 deviceId: string,268 rootKey: Uint8Array | null,269): Promise<void> {270 const cached = await readSessionCache();271 if (cached[deviceId] !== undefined) {272 delete cached[deviceId];273 await writeSessionCache(cached);274 }275 if (!rootKey) return;276 const map = await readEncryptedMap(rootKey);277 if (map[deviceId] === undefined) return;278 delete map[deviceId];279 await writeEncryptedMap(rootKey, map);280}