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;34let mutationTail: Promise<void> = Promise.resolve();3536function serializeMutation<T>(operation: () => Promise<T>): Promise<T> {37 const result = mutationTail.then(operation, operation);38 mutationTail = result.then(39 () => undefined,40 () => undefined,41 );42 return result;43}4445export function setSessionAllowed(allowed: boolean): void {46 sessionAllowed = allowed;47}4849export function isValidPairSecret(value: unknown): value is string {50 return typeof value === "string" && /^[0-9a-f]{64}$/.test(value);51}5253function wrappingKey(rootKey: Uint8Array): Uint8Array {54 if (rootKey.length !== 32) throw new Error("pair_secrets_root_key_invalid");55 return new HKDF(SHA256, rootKey, EMPTY_SALT, WRAP_INFO).expand(32);56}5758function hexToBytes(value: string): Uint8Array {59 const out = new Uint8Array(value.length / 2);60 for (let i = 0; i < value.length; i += 2) {61 out[i / 2] = parseInt(value.slice(i, i + 2), 16);62 }63 return out;64}6566type SecretEnvelope = { v: number; nonce: string; ciphertext: string; tag: string };6768function isEnvelope(value: unknown): value is SecretEnvelope {69 if (typeof value !== "object" || value === null) return false;70 const r = value as Record<string, unknown>;71 return (72 r.v === RECORD_VERSION &&73 typeof r.nonce === "string" &&74 typeof r.ciphertext === "string" &&75 typeof r.tag === "string"76 );77}7879function sanitizeMap(value: unknown): PairSecretMap {80 if (typeof value !== "object" || value === null) return {};81 const out: PairSecretMap = {};82 for (const [deviceId, secret] of Object.entries(value as Record<string, unknown>)) {83 if (typeof deviceId === "string" && deviceId && isValidPairSecret(secret)) {84 out[deviceId] = secret;85 }86 }87 return out;88}8990async function readEncryptedMap(rootKey: Uint8Array): Promise<PairSecretMap> {91 let stored: unknown;92 try {93 const result = await chrome.storage.local.get(RECORD_KEY);94 stored = (result as Record<string, unknown>)[RECORD_KEY];95 } catch {96 return {};97 }98 if (stored === undefined) return {};99 if (!isEnvelope(stored)) return {};100 const wrap = wrappingKey(rootKey);101 try {102 return sanitizeMap(JSON.parse(await decrypt(wrap, stored.nonce, stored.ciphertext, stored.tag, AAD)));103 } catch {104 105 return {};106 } finally {107 zeroKey(wrap);108 }109}110111async function writeEncryptedMap(rootKey: Uint8Array, map: PairSecretMap): Promise<void> {112 const wrap = wrappingKey(rootKey);113 try {114 const sealed = await encrypt(wrap, JSON.stringify(map), AAD);115 await chrome.storage.local.set({ [RECORD_KEY]: { v: RECORD_VERSION, ...sealed } });116 } finally {117 zeroKey(wrap);118 }119}120121async function writeSessionCache(map: PairSecretMap): Promise<void> {122 if (!sessionAllowed || !chrome.storage.session) return;123 try {124 await chrome.storage.session.set({ [SESSION_KEY]: map });125 } catch {126 127 128 }129}130131async function readSessionCache(): Promise<PairSecretMap> {132 if (!sessionAllowed || !chrome.storage.session) return {};133 try {134 const result = await chrome.storage.session.get(SESSION_KEY);135 return sanitizeMap((result as Record<string, unknown>)[SESSION_KEY]);136 } catch {137 return {};138 }139}140141async function clearSessionCache(): Promise<void> {142 if (!chrome.storage.session) return;143 try {144 await chrome.storage.session.remove(SESSION_KEY);145 } catch {146 147 }148}149150151152153154155export async function getPairSecret(156 deviceId: string,157 rootKeyHex: string | null,158): Promise<string | null> {159 const cached = await readSessionCache();160 const hit = cached[deviceId];161 if (isValidPairSecret(hit)) return hit;162 if (!rootKeyHex || !/^[0-9a-f]{64}$/.test(rootKeyHex)) return null;163 const rootKey = hexToBytes(rootKeyHex);164 try {165 const map = await readEncryptedMap(rootKey);166 const secret = map[deviceId];167 return isValidPairSecret(secret) ? secret : null;168 } finally {169 zeroKey(rootKey);170 }171}172173174175176177export async function setPairSecret(178 deviceId: string,179 secretHex: string,180 rootKey: Uint8Array,181): Promise<void> {182 if (!deviceId || !isValidPairSecret(secretHex)) throw new Error("pair_secret_invalid");183 await serializeMutation(async () => {184 const map = await readEncryptedMap(rootKey);185 map[deviceId] = secretHex;186 await writeEncryptedMap(rootKey, map);187 const cached = await readSessionCache();188 cached[deviceId] = secretHex;189 await writeSessionCache(cached);190 });191}192193194195196197198199200201export async function reconcilePairSecrets(202 rootKey: Uint8Array,203): Promise<{ migrated: number }> {204 return serializeMutation(async () => {205 const devices = await listPairedDevices();206 const registryIds = new Set(devices.map((d) => d.deviceId));207 const legacy = new Map<string, string>();208 for (const device of devices) {209 if (isValidPairSecret(device.rendezvousSecretHex)) {210 legacy.set(device.deviceId, device.rendezvousSecretHex);211 }212 }213214 const map = await readEncryptedMap(rootKey);215 let migrated = 0;216 for (const [deviceId, secret] of legacy) {217 if (map[deviceId] !== secret) {218 map[deviceId] = secret;219 migrated += 1;220 }221 }222 let pruned = false;223 for (const deviceId of Object.keys(map)) {224 if (!registryIds.has(deviceId)) {225 delete map[deviceId];226 pruned = true;227 }228 }229230 231 232 if (migrated > 0 || pruned || legacy.size > 0 || Object.keys(map).length > 0) {233 await writeEncryptedMap(rootKey, map);234 235 const verified = await readEncryptedMap(rootKey);236 for (const [deviceId, secret] of legacy) {237 if (verified[deviceId] !== secret) throw new Error("pair_secrets_verify_failed");238 }239 if (legacy.size > 0) {240 await writePairedDevices(241 devices.map((device) => {242 if (!isValidPairSecret(device.rendezvousSecretHex)) return device;243 const { rendezvousSecretHex: _dropped, ...pub } = device;244 return pub;245 }),246 );247 }248 }249250 await writeSessionCache(map);251 return { migrated };252 });253}254255256export async function clearPairSecrets(): Promise<void> {257 await serializeMutation(async () => {258 try {259 await chrome.storage.local.remove(RECORD_KEY);260 } catch {261 262 }263 await clearSessionCache();264 });265}266267268269270271272export async function dropSessionCache(): Promise<void> {273 await serializeMutation(clearSessionCache);274}275276277278279280281282export async function dropPairSecret(283 deviceId: string,284 rootKey: Uint8Array | null,285): Promise<void> {286 await serializeMutation(async () => {287 const cached = await readSessionCache();288 if (cached[deviceId] !== undefined) {289 delete cached[deviceId];290 await writeSessionCache(cached);291 }292 if (!rootKey) return;293 const map = await readEncryptedMap(rootKey);294 if (map[deviceId] === undefined) return;295 delete map[deviceId];296 await writeEncryptedMap(rootKey, map);297 });298}