// Local-device identity helpers. // // Credentials are generated once per installation and are never derived from // the vault root. They are sealed under a random identity key (IK), and IK is // wrapped under every key that can legitimately recover it: // // root the vault root key. Survives a password change and a recovery-word // re-key; the password-derived key survives neither. // local the vault-unlock key, available from any normal unlock. // pending a key derived from the local password on a fresh install that is // being adopted. Such an install has no vault yet, so adoption v2 // could not present a real identity unless one exists before the // vault does. The pending wrap is dropped as soon as the vault does. // // Keeping the root wrap is what stops a password change or a recovery-word // re-key from orphaning the identity; regenerating an identity underneath an // existing installation is never acceptable. 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, isDecoyMode, isUnlocked, openIdentityKey, sealIdentityKeyPreVault, sealIdentityKeyUnderVault, setIdentityHooks, type IdentityKeyEnvelope, type SealedBox, } from "./vault"; import { browserGenerationIo, enterResetRequired, hasLegacyTrustState, setGenerationIdentity, } from "./sync-generation"; const DEVICE_NAME_KEY = "peach_device_name"; const IDENTITY_RECORD_KEY = "peach_local_identity_v4"; const IDENTITY_MARKER_KEY = "peach_local_identity_initialized"; const LEGACY_IDENTITY_KEY = "peach_local_identity_v2"; const LEGACY_INFO = "peach-local-identity-v2"; const BLOB_INFO = "peach-local-identity-blob-v1"; const SESSION_CACHE_KEY = "peach_local_identity_session"; const HEX_64 = /^[0-9a-f]{64}$/i; /** Frozen local-identity schema (campaign task 2.1). */ const IDENTITY_RECORD_VERSION = 3; interface IdentityRecord { v: typeof IDENTITY_RECORD_VERSION; deviceId: string; x25519PrivateKeyHex: string; x25519PublicKeyHex: string; ed25519SeedHex: string; ed25519PublicKeyHex: string; createdAt: number; } interface IdentityEnvelope { v: 4; blob: SealedBox; ik: IdentityKeyEnvelope; } export interface LocalDeviceIdentity { deviceId: string; deviceName: string; deviceType: DeviceType; longTermPublicKeyHex: string; signingPublicKeyHex: string; } function hex(value: Uint8Array): string { return Array.from(value, (b) => b.toString(16).padStart(2, "0")).join(""); } function bytes(value: string): Uint8Array { if (!HEX_64.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; } 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(""); } 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); try { return await crypto.subtle.importKey("pkcs8", pkcs8 as Uint8Array, { name: "Ed25519" }, true, ["sign"]); } finally { pkcs8.fill(0); } } /** 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 x25519PublicKeyHex = pubkeyToHex(kp.publicKey); kp.secretKey.fill(0); const signingPrivateKey = await importSigningKey(bytes(privateHex.ed25519SeedHex)); const jwk = (await crypto.subtle.exportKey("jwk", signingPrivateKey)) as JsonWebKey; return { x25519PublicKeyHex, ed25519PublicKeyHex: base64ToHex(jwk.x ?? "") }; } 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 !== 2) 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, }; } /** * 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, }; } async function makeRecord(): Promise { const x = crypto.getRandomValues(new Uint8Array(32)); const ed = crypto.getRandomValues(new Uint8Array(32)); const privateHex = { x25519PrivateKeyHex: hex(x), ed25519SeedHex: hex(ed) }; x.fill(0); ed.fill(0); return { v: IDENTITY_RECORD_VERSION, deviceId: generateUuidV4(), ...privateHex, ...(await derivePublicHex(privateHex)), createdAt: Date.now(), }; } async function persist( record: IdentityRecord, ik: Uint8Array, envelope: IdentityKeyEnvelope, ): Promise { const blob = await encrypt(ik, JSON.stringify(record), BLOB_INFO); const stored: IdentityEnvelope = { v: 4, blob, ik: envelope }; // The marker is what distinguishes "this install has never minted an // identity" from "this install's identity record was lost": a missing record // is only mintable in the first case. Written in the same transaction. await chrome.storage.local.set({ [IDENTITY_RECORD_KEY]: stored, [IDENTITY_MARKER_KEY]: true }); } // The operational copy of the decrypted record. chrome.storage.session survives // an MV3 service-worker restart but is cleared when the browser closes. It is // only ever served inside a real unlocked session: serving it while locked or in // duress mode would hand out the real device identity. async function cacheGet(): Promise { if (!isUnlocked() || isDecoyMode()) return null; const session = chrome.storage.session; if (!session) return null; const stored = await session.get(SESSION_CACHE_KEY); return (stored[SESSION_CACHE_KEY] as IdentityRecord | undefined) ?? null; } async function cacheSet(record: IdentityRecord): Promise { try { await chrome.storage.session?.set({ [SESSION_CACHE_KEY]: record }); } catch { // The durable local envelope is authoritative; session cache is only a // restart optimization and must not block identity creation or rotation. } } async function clearCache(): Promise { await chrome.storage.session?.remove(SESSION_CACHE_KEY); } setIdentityHooks({ onSessionEnded: clearCache, onKeysRotated: rewrapIdentity, onVaultCreated: promotePreVaultIdentity, }); /** * The secure reset's identity step (§11.3). Only reached at RESET_CONFIRMED, * after the legacy trust has been archived and cleared, so minting here is the * anchored device's own deliberate action rather than a silent replacement. The * guard still refuses to mint over live trust, which is what would otherwise * turn the create-on-miss refusal into a bypass. */ setGenerationIdentity({ async ensureIdentity() { if (await hasLegacyTrustState(browserGenerationIo)) return false; try { // A secure reset retires the old identity generation as well as its // paired-device registry. Reusing the old record would let archived // trust material identify this installation after the reset. await rotateDeviceId(); return true; } catch { return false; } }, deviceId: getOrCreateDeviceId, }); /** * 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(legacy: { deviceId: string; x25519PrivateKeyHex: string; ed25519SeedHex: string; }): Promise { return { v: IDENTITY_RECORD_VERSION, deviceId: legacy.deviceId, x25519PrivateKeyHex: legacy.x25519PrivateKeyHex, ed25519SeedHex: legacy.ed25519SeedHex, ...(await derivePublicHex(legacy)), createdAt: Date.now(), }; } /** * Re-seal an identity written before the envelope existed. The old record was * sealed under the vault root, so it is a second root-readable copy of the same * private keys; the re-sealed envelope is durable by the time it is removed. */ async function migrateLegacyIdentity(): Promise { const stored = (await chrome.storage.local.get(LEGACY_IDENTITY_KEY))[LEGACY_IDENTITY_KEY] as | Record | undefined; if (!stored) return null; const rootHex = getRootKeyHex(); if (!rootHex) throw new Error("identity_migration_required"); const root = bytes(rootHex); try { let parsed: unknown; try { parsed = JSON.parse(await decrypt(root, stored.nonce, stored.ciphertext, stored.tag, LEGACY_INFO)); } catch { throw new Error("identity_migration_required"); } const legacy = parseLegacyRandomRecord(parsed); const record = (await parseCurrentRecord(parsed)) ?? (legacy ? await completeLegacyRecord(legacy) : null); if (!record) throw new Error("identity_migration_required"); const ik = crypto.getRandomValues(new Uint8Array(32)); try { await persist(record, ik, await sealIdentityKeyUnderVault(ik)); } finally { ik.fill(0); } await chrome.storage.local.remove(LEGACY_IDENTITY_KEY); await cacheSet(record); return record; } finally { root.fill(0); } } /** Decrypt and validate the record out of an envelope once its identity key is open. */ async function readRecordFromEnvelope(stored: IdentityEnvelope, ik: Uint8Array): Promise { let parsed: unknown; try { parsed = JSON.parse(await decrypt(ik, stored.blob.nonce, stored.blob.ciphertext, stored.blob.tag, BLOB_INFO)); } catch { throw new Error("identity_migration_required"); } const record = await parseCurrentRecord(parsed); if (!record) throw new Error("identity_migration_required"); return record; } async function readEnvelope(): Promise { const stored = (await chrome.storage.local.get(IDENTITY_RECORD_KEY))[IDENTITY_RECORD_KEY] as | IdentityEnvelope | undefined; if (!stored) return null; if (stored.v !== 4 || !stored.blob || !stored.ik) throw new Error("identity_migration_required"); return stored; } async function loadRecord(password?: string): Promise { const stored = await readEnvelope(); if (!stored) return migrateLegacyIdentity(); const cached = await cacheGet(); if (cached) return cached; const ik = await openIdentityKey(stored.ik, password); if (!ik) throw new Error("identity_migration_required"); try { const record = await readRecordFromEnvelope(stored, ik); await cacheSet(record); return record; } finally { ik.fill(0); } } /** * Re-wrap the stored envelope under the vault keys now in force. Every vault * key swap kills the wrap that was encrypted under the old key, so without this * a password change followed by a sync-identity rotation (in either order) would * leave no wrap able to open the identity — permanent, unrecoverable loss. Only * the live vault keys are used; the pre-vault password is never a way in here. */ async function rewrapIdentity(): Promise { const stored = await readEnvelope(); if (!stored || !isUnlocked() || getRootKeyHex() === null) return; const ik = await openIdentityKey(stored.ik); if (!ik) return; try { const record = await readRecordFromEnvelope(stored, ik); await persist(record, ik, await sealIdentityKeyUnderVault(ik)); await cacheSet(record); } finally { ik.fill(0); } } /** * Replace the pre-vault wrap with the vault wraps now that a vault exists. The * pending wrap is the only authority for this: it is sealed under the password * the user chose for this install, so a password that never created this * identity cannot re-seal it into a vault it does not belong to. */ async function promotePreVaultIdentity(password: string): Promise { const stored = await readEnvelope(); if (!stored?.ik.pending || !stored.ik.pendingSalt) return; const ik = await openIdentityKey( { pending: stored.ik.pending, pendingSalt: stored.ik.pendingSalt }, password, ); if (!ik) return; try { const record = await readRecordFromEnvelope(stored, ik); await persist(record, ik, await sealIdentityKeyUnderVault(ik)); await cacheSet(record); } finally { ik.fill(0); } } async function ensureRecord(): Promise { const existing = await loadRecord(); if (existing) return existing; const cached = await cacheGet(); if (cached) return cached; // An identity was minted on this install and its record is now gone. Refuse // rather than hand every peer an unknown device whose new keys no longer // match the revocation ledger. if ((await chrome.storage.local.get(IDENTITY_MARKER_KEY))[IDENTITY_MARKER_KEY]) { throw new Error("identity_migration_required"); } if (await hasLegacyTrustState(browserGenerationIo)) { await enterResetRequired(browserGenerationIo); throw new Error("identity_migration_required"); } const record = await makeRecord(); const ik = crypto.getRandomValues(new Uint8Array(32)); try { await persist(record, ik, await sealIdentityKeyUnderVault(ik)); await cacheSet(record); return record; } finally { ik.fill(0); } } /** * Create the identity for an install that has no vault yet. Adoption v2 sends * this identity in its FIRST frame, long before a root key exists, so the wrap * key comes from the local password the user is choosing. A no-op once any * identity record exists. */ export async function preparePreVaultIdentity(password: string): Promise { const stored = await chrome.storage.local.get(IDENTITY_RECORD_KEY); if (stored[IDENTITY_RECORD_KEY]) return; const record = await makeRecord(); const ik = crypto.getRandomValues(new Uint8Array(32)); try { await persist(record, ik, await sealIdentityKeyPreVault(password, ik)); await cacheSet(record); } finally { ik.fill(0); } } /** Read the pre-vault identity back for the adoption handshake. */ export async function readPreVaultIdentity(password: string): Promise { const record = await loadRecord(password); if (!record) throw new Error("identity_unavailable"); return { deviceId: record.deviceId, deviceName: await getDeviceName(), deviceType: "extension", longTermPublicKeyHex: record.x25519PublicKeyHex, signingPublicKeyHex: record.ed25519PublicKeyHex, }; } export async function rotatePreVaultIdentity(password: string): Promise { const record = await makeRecord(); const ik = crypto.getRandomValues(new Uint8Array(32)); try { await persist(record, ik, await sealIdentityKeyPreVault(password, ik)); await cacheSet(record); return await readPreVaultIdentity(password); } finally { ik.fill(0); } } export async function getOrCreateDeviceId(): Promise { if (!isUnlocked() || getRootKeyHex() === null) throw new Error("vault_locked"); return (await ensureRecord()).deviceId; } export async function rotateDeviceId(): Promise { if (!isUnlocked() || getRootKeyHex() === null) throw new Error("vault_locked"); const record = await makeRecord(); const ik = crypto.getRandomValues(new Uint8Array(32)); try { await persist(record, ik, await sealIdentityKeyUnderVault(ik)); await cacheSet(record); return record.deviceId; } finally { ik.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(): Promise { const record = await ensureRecord(); return { deviceId: record.deviceId, deviceName: await getDeviceName(), deviceType: "extension", longTermPublicKeyHex: record.x25519PublicKeyHex, signingPublicKeyHex: record.ed25519PublicKeyHex, }; } 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(); const privateKey = bytes(record.x25519PrivateKeyHex); const publicKey = generateKeyPairFromSeed(privateKey).publicKey; const signingPrivateKey = await importSigningKey(bytes(record.ed25519SeedHex)); const base = await buildLocalIdentity(); return { identity: { ...base, vaultId: await computeVaultId(root) }, privateKey, publicKey, signingPrivateKey }; } finally { root.fill(0); } } 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 b = crypto.getRandomValues(new Uint8Array(16)); b[6] = (b[6] & 0x0f) | 0x40; b[8] = (b[8] & 0x3f) | 0x80; const h = hex(b); return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`; }