// Local-device identity helpers. Cryptographic credentials are generated once // per installation and kept in an encrypted local record; vault unlock is the // only way to hydrate the operational cache. import { generateKeyPairFromSeed } from "@stablelib/x25519"; import { computeVaultId, pubkeyToHex, } from "../shared/sync-identity"; import type { LocalIdentity } from "../shared/pairing-handshake"; import type { DeviceType } from "../shared/sync-types"; import { decrypt, encrypt } from "./aes"; import { getRootKeyHex, isUnlocked } from "./vault"; const DEVICE_NAME_KEY = "peach_device_name"; const IDENTITY_RECORD_KEY = "peach_local_identity_v2"; const IDENTITY_INFO = "peach-local-identity-v2"; interface IdentityRecord { v: 2; deviceId: string; x25519PrivateKeyHex: string; ed25519SeedHex: string; } let cached: IdentityRecord | null = null; export interface LocalDeviceIdentity { deviceId: string; deviceName: string; deviceType: DeviceType; longTermPublicKeyHex: string; signingPublicKeyHex: string; } function hex(bytes: Uint8Array): string { return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); } function bytes(value: string): Uint8Array { if (!/^[0-9a-f]{64}$/i.test(value)) throw new Error("identity_migration_required"); const out = new Uint8Array(32); for (let i = 0; i < out.length; i++) out[i] = Number.parseInt(value.slice(i * 2, i * 2 + 2), 16); return out; } async function loadRecord(root: Uint8Array): Promise { if (cached) return cached; const row = (await chrome.storage.local.get(IDENTITY_RECORD_KEY))[IDENTITY_RECORD_KEY] as Record | undefined; if (!row) return null; if (row.v !== 2 || typeof row.nonce !== "string" || typeof row.ciphertext !== "string" || typeof row.tag !== "string") { throw new Error("identity_migration_required"); } let parsed: IdentityRecord; try { parsed = JSON.parse(await decrypt(root, row.nonce, row.ciphertext, row.tag, IDENTITY_INFO)) as IdentityRecord; } catch { throw new Error("identity_migration_required"); } if ( parsed.v !== 2 || !/^[0-9a-f]{64}$/i.test(parsed.x25519PrivateKeyHex) || !/^[0-9a-f]{64}$/i.test(parsed.ed25519SeedHex) ) throw new Error("identity_migration_required"); cached = parsed; return parsed; } async function ensureRecord(root: Uint8Array): Promise { const existing = await loadRecord(root); if (existing) return existing; const x = new Uint8Array(32); const ed = new Uint8Array(32); crypto.getRandomValues(x); crypto.getRandomValues(ed); const record: IdentityRecord = { v: 2, deviceId: generateUuidV4(), x25519PrivateKeyHex: hex(x), ed25519SeedHex: hex(ed) }; const sealed = await encrypt(root, JSON.stringify(record), IDENTITY_INFO); await chrome.storage.local.set({ [IDENTITY_RECORD_KEY]: { v: 2, ...sealed } }); x.fill(0); ed.fill(0); cached = record; return record; } export async function getOrCreateDeviceId(): Promise { if (!isUnlocked()) throw new Error("vault_locked"); const rootHex = getRootKeyHex(); if (!rootHex) throw new Error("vault_locked"); return (await ensureRecord(bytes(rootHex))).deviceId; } export async function rotateDeviceId(): Promise { if (!isUnlocked()) throw new Error("vault_locked"); const rootHex = getRootKeyHex(); if (!rootHex) throw new Error("vault_locked"); const root = bytes(rootHex); const x = new Uint8Array(32); const ed = new Uint8Array(32); try { crypto.getRandomValues(x); crypto.getRandomValues(ed); const record: IdentityRecord = { v: 2, deviceId: generateUuidV4(), x25519PrivateKeyHex: hex(x), ed25519SeedHex: hex(ed), }; const sealed = await encrypt(root, JSON.stringify(record), IDENTITY_INFO); await chrome.storage.local.set({ [IDENTITY_RECORD_KEY]: { v: 2, ...sealed } }); cached = record; return record.deviceId; } finally { root.fill(0); x.fill(0); ed.fill(0); } } export async function getDeviceName(): Promise { const result = await chrome.storage.sync.get(DEVICE_NAME_KEY); const stored = (result as Record)[DEVICE_NAME_KEY]; return typeof stored === "string" && stored.length > 0 ? stored : defaultDeviceName(); } export async function setDeviceName(name: string): Promise { const trimmed = name.trim(); if (!trimmed) throw new Error("device name must not be empty"); if (trimmed.length > 64) throw new Error("device name must be 64 chars or fewer"); await chrome.storage.sync.set({ [DEVICE_NAME_KEY]: trimmed }); } export async function buildLocalIdentity(masterKey: Uint8Array): Promise { const record = await ensureRecord(masterKey); const x = bytes(record.x25519PrivateKeyHex); const kp = generateKeyPairFromSeed(x); const signingPrivateKey = await importSigningKey(bytes(record.ed25519SeedHex)); const jwk = await crypto.subtle.exportKey("jwk", signingPrivateKey) as JsonWebKey; return { deviceId: record.deviceId, deviceName: await getDeviceName(), deviceType: "extension", longTermPublicKeyHex: pubkeyToHex(kp.publicKey), signingPublicKeyHex: base64ToHex(jwk.x ?? "") }; } async function importSigningKey(seed: Uint8Array): Promise { const prefix = Uint8Array.of(0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20); const pkcs8 = new Uint8Array(prefix.length + 32); pkcs8.set(prefix); pkcs8.set(seed, prefix.length); return crypto.subtle.importKey("pkcs8", pkcs8 as Uint8Array, { name: "Ed25519" }, true, ["sign"]); } function base64ToHex(value: string): string { const raw = atob(value.replace(/-/g, "+").replace(/_/g, "/")); return Array.from(raw, (c) => c.charCodeAt(0).toString(16).padStart(2, "0")).join(""); } export function deriveLocalSyncKeypair(_masterKey: Uint8Array, _deviceId: string) { throw new Error("identity_migration_required"); } function defaultDeviceName(): string { // Best-effort browser + platform string. Avoids userAgent parsing — the // user can rename via Settings. const ua = (typeof navigator !== "undefined" ? navigator.userAgent : "") || ""; if (/Firefox/i.test(ua)) return "Firefox"; if (/Edg\//i.test(ua)) return "Edge"; if (/Chrome/i.test(ua)) return "Chrome"; if (/Safari/i.test(ua)) return "Safari"; return "Browser"; } function generateUuidV4(): string { // crypto.randomUUID is available in MV3 service workers and popup pages. // Falls back to manual assembly only if a runtime is missing it. if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { return crypto.randomUUID(); } const bytes = new Uint8Array(16); crypto.getRandomValues(bytes as Uint8Array); bytes[6] = (bytes[6] & 0x0f) | 0x40; bytes[8] = (bytes[8] & 0x3f) | 0x80; const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; } export async function getLocalPairingIdentity(): Promise<{ identity: LocalIdentity; privateKey: Uint8Array; publicKey: Uint8Array; signingPrivateKey: CryptoKey; }> { if (!isUnlocked()) throw new Error("vault_locked"); const rootHex = getRootKeyHex(); if (!rootHex) throw new Error("vault_locked"); const root = bytes(rootHex); try { const record = await ensureRecord(root); const privateKey = bytes(record.x25519PrivateKeyHex); const publicKey = generateKeyPairFromSeed(privateKey).publicKey; const signingPrivateKey = await importSigningKey(bytes(record.ed25519SeedHex)); const base = await buildLocalIdentity(root); return { identity: { ...base, vaultId: await computeVaultId(root) }, privateKey, publicKey, signingPrivateKey }; } finally { root.fill(0); } }