// 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"; /** Frozen local-identity schema (campaign task 2.1). */ const IDENTITY_RECORD_VERSION = 3; /** First random-identity format — private seeds only. Completed in place. */ const LEGACY_RANDOM_RECORD_VERSION = 2; const HEX_64 = /^[0-9a-f]{64}$/i; interface IdentityRecord { v: typeof IDENTITY_RECORD_VERSION; deviceId: string; x25519PrivateKeyHex: string; x25519PublicKeyHex: string; ed25519SeedHex: string; ed25519PublicKeyHex: string; createdAt: number; } 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; } /** Public values derived from the private material, for creation and for the integrity check on load. */ async function derivePublicHex(privateHex: { x25519PrivateKeyHex: string; ed25519SeedHex: string; }): Promise<{ x25519PublicKeyHex: string; ed25519PublicKeyHex: string }> { const kp = generateKeyPairFromSeed(bytes(privateHex.x25519PrivateKeyHex)); const signingPrivateKey = await importSigningKey(bytes(privateHex.ed25519SeedHex)); const jwk = (await crypto.subtle.exportKey("jwk", signingPrivateKey)) as JsonWebKey; return { x25519PublicKeyHex: pubkeyToHex(kp.publicKey), ed25519PublicKeyHex: base64ToHex(jwk.x ?? ""), }; } /** * Accepts only a complete frozen-schema record: every field present, a real * creation time, and stored public values that match the private material. * Recomputation alone would accept a record whose public half was tampered with. */ async function parseCurrentRecord(value: unknown): Promise { if (typeof value !== "object" || value === null) return null; const row = value as Record; if (row.v !== IDENTITY_RECORD_VERSION) return null; if (typeof row.deviceId !== "string" || row.deviceId.length === 0) return null; if (typeof row.x25519PrivateKeyHex !== "string" || !HEX_64.test(row.x25519PrivateKeyHex)) return null; if (typeof row.x25519PublicKeyHex !== "string" || !HEX_64.test(row.x25519PublicKeyHex)) return null; if (typeof row.ed25519SeedHex !== "string" || !HEX_64.test(row.ed25519SeedHex)) return null; if (typeof row.ed25519PublicKeyHex !== "string" || !HEX_64.test(row.ed25519PublicKeyHex)) return null; if (typeof row.createdAt !== "number" || !Number.isFinite(row.createdAt) || row.createdAt <= 0) return null; const derived = await derivePublicHex({ x25519PrivateKeyHex: row.x25519PrivateKeyHex, ed25519SeedHex: row.ed25519SeedHex, }); if (derived.x25519PublicKeyHex !== row.x25519PublicKeyHex.toLowerCase()) return null; if (derived.ed25519PublicKeyHex !== row.ed25519PublicKeyHex.toLowerCase()) return null; return { v: IDENTITY_RECORD_VERSION, deviceId: row.deviceId, x25519PrivateKeyHex: row.x25519PrivateKeyHex, x25519PublicKeyHex: row.x25519PublicKeyHex, ed25519SeedHex: row.ed25519SeedHex, ed25519PublicKeyHex: row.ed25519PublicKeyHex, createdAt: row.createdAt, }; } function parseLegacyRandomRecord(value: unknown): { deviceId: string; x25519PrivateKeyHex: string; ed25519SeedHex: string; } | null { if (typeof value !== "object" || value === null) return null; const row = value as Record; if (row.v !== LEGACY_RANDOM_RECORD_VERSION) return null; if (typeof row.deviceId !== "string" || row.deviceId.length === 0) return null; if (typeof row.x25519PrivateKeyHex !== "string" || !HEX_64.test(row.x25519PrivateKeyHex)) return null; if (typeof row.ed25519SeedHex !== "string" || !HEX_64.test(row.ed25519SeedHex)) return null; return { deviceId: row.deviceId, x25519PrivateKeyHex: row.x25519PrivateKeyHex, ed25519SeedHex: row.ed25519SeedHex, }; } async function persistRecord(root: Uint8Array, record: IdentityRecord): Promise { const sealed = await encrypt(root, JSON.stringify(record), IDENTITY_INFO); await chrome.storage.local.set({ [IDENTITY_RECORD_KEY]: { v: IDENTITY_RECORD_VERSION, ...sealed }, }); } /** * A legacy random record is completed in place — same device ID and keys — * rather than regenerated. `createdAt` is the upgrade time, because the original * creation time was never recorded. */ async function completeLegacyRecord( root: Uint8Array, legacy: { deviceId: string; x25519PrivateKeyHex: string; ed25519SeedHex: string }, ): Promise { const derived = await derivePublicHex(legacy); const record: IdentityRecord = { v: IDENTITY_RECORD_VERSION, deviceId: legacy.deviceId, x25519PrivateKeyHex: legacy.x25519PrivateKeyHex, ed25519SeedHex: legacy.ed25519SeedHex, ...derived, createdAt: Date.now(), }; await persistRecord(root, record); return record; } 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 (typeof row.v !== "number" || typeof row.nonce !== "string" || typeof row.ciphertext !== "string" || typeof row.tag !== "string") { throw new Error("identity_migration_required"); } let parsed: unknown; try { parsed = JSON.parse(await decrypt(root, row.nonce, row.ciphertext, row.tag, IDENTITY_INFO)); } catch { throw new Error("identity_migration_required"); } const current = await parseCurrentRecord(parsed); if (current) { cached = current; return current; } const legacy = parseLegacyRandomRecord(parsed); if (legacy) { const upgraded = await completeLegacyRecord(root, legacy); cached = upgraded; return upgraded; } throw new Error("identity_migration_required"); } 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 privateHex = { x25519PrivateKeyHex: hex(x), ed25519SeedHex: hex(ed) }; x.fill(0); ed.fill(0); const record: IdentityRecord = { v: IDENTITY_RECORD_VERSION, deviceId: generateUuidV4(), ...privateHex, ...(await derivePublicHex(privateHex)), createdAt: Date.now(), }; await persistRecord(root, record); 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 privateHex = { x25519PrivateKeyHex: hex(x), ed25519SeedHex: hex(ed) }; const record: IdentityRecord = { v: IDENTITY_RECORD_VERSION, deviceId: generateUuidV4(), ...privateHex, ...(await derivePublicHex(privateHex)), createdAt: Date.now(), }; await persistRecord(root, record); 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); return { deviceId: record.deviceId, deviceName: await getDeviceName(), deviceType: "extension", longTermPublicKeyHex: record.x25519PublicKeyHex, signingPublicKeyHex: record.ed25519PublicKeyHex, }; } 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(""); } 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); } }