device-identity.ts

19.9 KB · raw

1// Local-device identity helpers.2//3// Credentials are generated once per installation and are never derived from4// the vault root. They are sealed under a random identity key (IK), and IK is5// wrapped under every key that can legitimately recover it:6//7//   root    the vault root key. Survives a password change and a recovery-word8//           re-key; the password-derived key survives neither.9//   local   the vault-unlock key, available from any normal unlock.10//   pending a key derived from the local password on a fresh install that is11//           being adopted. Such an install has no vault yet, so adoption v212//           could not present a real identity unless one exists before the13//           vault does. The pending wrap is dropped as soon as the vault does.14//15// Keeping the root wrap is what stops a password change or a recovery-word16// re-key from orphaning the identity; regenerating an identity underneath an17// existing installation is never acceptable.1819import { generateKeyPairFromSeed } from "@stablelib/x25519";20import { computeVaultId, pubkeyToHex } from "../shared/sync-identity";21import type { LocalIdentity } from "../shared/pairing-handshake";22import type { DeviceType } from "../shared/sync-types";23import { decrypt, encrypt } from "./aes";24import {25  getRootKeyHex,26  isDecoyMode,27  isUnlocked,28  openIdentityKey,29  sealIdentityKeyPreVault,30  sealIdentityKeyUnderVault,31  setIdentityHooks,32  type IdentityKeyEnvelope,33  type SealedBox,34} from "./vault";35import {36  browserGenerationIo,37  enterResetRequired,38  hasLegacyTrustState,39  setGenerationIdentity,40} from "./sync-generation";4142const DEVICE_NAME_KEY = "peach_device_name";43const IDENTITY_RECORD_KEY = "peach_local_identity_v4";44const IDENTITY_MARKER_KEY = "peach_local_identity_initialized";45const LEGACY_IDENTITY_KEY = "peach_local_identity_v2";46const LEGACY_INFO = "peach-local-identity-v2";47const BLOB_INFO = "peach-local-identity-blob-v1";48const SESSION_CACHE_KEY = "peach_local_identity_session";49const HEX_64 = /^[0-9a-f]{64}$/i;5051/** Frozen local-identity schema (campaign task 2.1). */52const IDENTITY_RECORD_VERSION = 3;5354interface IdentityRecord {55  v: typeof IDENTITY_RECORD_VERSION;56  deviceId: string;57  x25519PrivateKeyHex: string;58  x25519PublicKeyHex: string;59  ed25519SeedHex: string;60  ed25519PublicKeyHex: string;61  createdAt: number;62}6364interface IdentityEnvelope {65  v: 4;66  blob: SealedBox;67  ik: IdentityKeyEnvelope;68}6970export interface LocalDeviceIdentity {71  deviceId: string;72  deviceName: string;73  deviceType: DeviceType;74  longTermPublicKeyHex: string;75  signingPublicKeyHex: string;76}7778function hex(value: Uint8Array): string {79  return Array.from(value, (b) => b.toString(16).padStart(2, "0")).join("");80}8182function bytes(value: string): Uint8Array {83  if (!HEX_64.test(value)) throw new Error("identity_migration_required");84  const out = new Uint8Array(32);85  for (let i = 0; i < out.length; i++) out[i] = Number.parseInt(value.slice(i * 2, i * 2 + 2), 16);86  return out;87}8889function base64ToHex(value: string): string {90  const raw = atob(value.replace(/-/g, "+").replace(/_/g, "/"));91  return Array.from(raw, (c) => c.charCodeAt(0).toString(16).padStart(2, "0")).join("");92}9394async function importSigningKey(seed: Uint8Array): Promise<CryptoKey> {95  const prefix = Uint8Array.of(0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20);96  const pkcs8 = new Uint8Array(prefix.length + 32);97  pkcs8.set(prefix);98  pkcs8.set(seed, prefix.length);99  try {100    return await crypto.subtle.importKey("pkcs8", pkcs8 as Uint8Array<ArrayBuffer>, { name: "Ed25519" }, true, ["sign"]);101  } finally {102    pkcs8.fill(0);103  }104}105106/** Public values derived from the private material, for creation and for the integrity check on load. */107async function derivePublicHex(privateHex: {108  x25519PrivateKeyHex: string;109  ed25519SeedHex: string;110}): Promise<{ x25519PublicKeyHex: string; ed25519PublicKeyHex: string }> {111  const kp = generateKeyPairFromSeed(bytes(privateHex.x25519PrivateKeyHex));112  const x25519PublicKeyHex = pubkeyToHex(kp.publicKey);113  kp.secretKey.fill(0);114  const signingPrivateKey = await importSigningKey(bytes(privateHex.ed25519SeedHex));115  const jwk = (await crypto.subtle.exportKey("jwk", signingPrivateKey)) as JsonWebKey;116  return { x25519PublicKeyHex, ed25519PublicKeyHex: base64ToHex(jwk.x ?? "") };117}118119function parseLegacyRandomRecord(value: unknown): {120  deviceId: string;121  x25519PrivateKeyHex: string;122  ed25519SeedHex: string;123} | null {124  if (typeof value !== "object" || value === null) return null;125  const row = value as Record<string, unknown>;126  if (row.v !== 2) return null;127  if (typeof row.deviceId !== "string" || row.deviceId.length === 0) return null;128  if (typeof row.x25519PrivateKeyHex !== "string" || !HEX_64.test(row.x25519PrivateKeyHex)) return null;129  if (typeof row.ed25519SeedHex !== "string" || !HEX_64.test(row.ed25519SeedHex)) return null;130  return {131    deviceId: row.deviceId,132    x25519PrivateKeyHex: row.x25519PrivateKeyHex,133    ed25519SeedHex: row.ed25519SeedHex,134  };135}136137/**138 * Accepts only a complete frozen-schema record: every field present, a real139 * creation time, and stored public values that match the private material.140 * Recomputation alone would accept a record whose public half was tampered with.141 */142async function parseCurrentRecord(value: unknown): Promise<IdentityRecord | null> {143  if (typeof value !== "object" || value === null) return null;144  const row = value as Record<string, unknown>;145  if (row.v !== IDENTITY_RECORD_VERSION) return null;146  if (typeof row.deviceId !== "string" || row.deviceId.length === 0) return null;147  if (typeof row.x25519PrivateKeyHex !== "string" || !HEX_64.test(row.x25519PrivateKeyHex)) return null;148  if (typeof row.x25519PublicKeyHex !== "string" || !HEX_64.test(row.x25519PublicKeyHex)) return null;149  if (typeof row.ed25519SeedHex !== "string" || !HEX_64.test(row.ed25519SeedHex)) return null;150  if (typeof row.ed25519PublicKeyHex !== "string" || !HEX_64.test(row.ed25519PublicKeyHex)) return null;151  if (typeof row.createdAt !== "number" || !Number.isFinite(row.createdAt) || row.createdAt <= 0) return null;152  const derived = await derivePublicHex({153    x25519PrivateKeyHex: row.x25519PrivateKeyHex,154    ed25519SeedHex: row.ed25519SeedHex,155  });156  if (derived.x25519PublicKeyHex !== row.x25519PublicKeyHex.toLowerCase()) return null;157  if (derived.ed25519PublicKeyHex !== row.ed25519PublicKeyHex.toLowerCase()) return null;158  return {159    v: IDENTITY_RECORD_VERSION,160    deviceId: row.deviceId,161    x25519PrivateKeyHex: row.x25519PrivateKeyHex,162    x25519PublicKeyHex: row.x25519PublicKeyHex,163    ed25519SeedHex: row.ed25519SeedHex,164    ed25519PublicKeyHex: row.ed25519PublicKeyHex,165    createdAt: row.createdAt,166  };167}168169async function makeRecord(): Promise<IdentityRecord> {170  const x = crypto.getRandomValues(new Uint8Array(32));171  const ed = crypto.getRandomValues(new Uint8Array(32));172  const privateHex = { x25519PrivateKeyHex: hex(x), ed25519SeedHex: hex(ed) };173  x.fill(0);174  ed.fill(0);175  return {176    v: IDENTITY_RECORD_VERSION,177    deviceId: generateUuidV4(),178    ...privateHex,179    ...(await derivePublicHex(privateHex)),180    createdAt: Date.now(),181  };182}183184async function persist(185  record: IdentityRecord,186  ik: Uint8Array,187  envelope: IdentityKeyEnvelope,188): Promise<void> {189  const blob = await encrypt(ik, JSON.stringify(record), BLOB_INFO);190  const stored: IdentityEnvelope = { v: 4, blob, ik: envelope };191  // The marker is what distinguishes "this install has never minted an192  // identity" from "this install's identity record was lost": a missing record193  // is only mintable in the first case. Written in the same transaction.194  await chrome.storage.local.set({ [IDENTITY_RECORD_KEY]: stored, [IDENTITY_MARKER_KEY]: true });195}196197// The operational copy of the decrypted record. chrome.storage.session survives198// an MV3 service-worker restart but is cleared when the browser closes. It is199// only ever served inside a real unlocked session: serving it while locked or in200// duress mode would hand out the real device identity.201async function cacheGet(): Promise<IdentityRecord | null> {202  if (!isUnlocked() || isDecoyMode()) return null;203  const session = chrome.storage.session;204  if (!session) return null;205  const stored = await session.get(SESSION_CACHE_KEY);206  return (stored[SESSION_CACHE_KEY] as IdentityRecord | undefined) ?? null;207}208209async function cacheSet(record: IdentityRecord): Promise<void> {210  await chrome.storage.session?.set({ [SESSION_CACHE_KEY]: record });211}212213async function clearCache(): Promise<void> {214  await chrome.storage.session?.remove(SESSION_CACHE_KEY);215}216217setIdentityHooks({218  onSessionEnded: clearCache,219  onKeysRotated: rewrapIdentity,220  onVaultCreated: promotePreVaultIdentity,221});222223/**224 * The secure reset's identity step (§11.3). Only reached at RESET_CONFIRMED,225 * after the legacy trust has been archived and cleared, so minting here is the226 * anchored device's own deliberate action rather than a silent replacement. The227 * guard still refuses to mint over live trust, which is what would otherwise228 * turn the create-on-miss refusal into a bypass.229 */230setGenerationIdentity({231  async ensureIdentity() {232    try {233      await buildLocalIdentity();234      return true;235    } catch {236      if (await hasLegacyTrustState(browserGenerationIo)) return false;237      try {238        await rotateDeviceId();239        return true;240      } catch {241        return false;242      }243    }244  },245  deviceId: getOrCreateDeviceId,246});247248/**249 * A legacy random record is completed in place -- same device ID and keys --250 * rather than regenerated. `createdAt` is the upgrade time, because the original251 * creation time was never recorded.252 */253async function completeLegacyRecord(legacy: {254  deviceId: string;255  x25519PrivateKeyHex: string;256  ed25519SeedHex: string;257}): Promise<IdentityRecord> {258  return {259    v: IDENTITY_RECORD_VERSION,260    deviceId: legacy.deviceId,261    x25519PrivateKeyHex: legacy.x25519PrivateKeyHex,262    ed25519SeedHex: legacy.ed25519SeedHex,263    ...(await derivePublicHex(legacy)),264    createdAt: Date.now(),265  };266}267268/**269 * Re-seal an identity written before the envelope existed. The old record was270 * sealed under the vault root, so it is a second root-readable copy of the same271 * private keys; the re-sealed envelope is durable by the time it is removed.272 */273async function migrateLegacyIdentity(): Promise<IdentityRecord | null> {274  const stored = (await chrome.storage.local.get(LEGACY_IDENTITY_KEY))[LEGACY_IDENTITY_KEY] as275    | Record<string, string>276    | undefined;277  if (!stored) return null;278  const rootHex = getRootKeyHex();279  if (!rootHex) throw new Error("identity_migration_required");280  const root = bytes(rootHex);281  try {282    let parsed: unknown;283    try {284      parsed = JSON.parse(await decrypt(root, stored.nonce, stored.ciphertext, stored.tag, LEGACY_INFO));285    } catch {286      throw new Error("identity_migration_required");287    }288    const legacy = parseLegacyRandomRecord(parsed);289    const record = (await parseCurrentRecord(parsed)) ?? (legacy ? await completeLegacyRecord(legacy) : null);290    if (!record) throw new Error("identity_migration_required");291    const ik = crypto.getRandomValues(new Uint8Array(32));292    try {293      await persist(record, ik, await sealIdentityKeyUnderVault(ik));294    } finally {295      ik.fill(0);296    }297    await chrome.storage.local.remove(LEGACY_IDENTITY_KEY);298    await cacheSet(record);299    return record;300  } finally {301    root.fill(0);302  }303}304305/** Decrypt and validate the record out of an envelope once its identity key is open. */306async function readRecordFromEnvelope(stored: IdentityEnvelope, ik: Uint8Array): Promise<IdentityRecord> {307  let parsed: unknown;308  try {309    parsed = JSON.parse(await decrypt(ik, stored.blob.nonce, stored.blob.ciphertext, stored.blob.tag, BLOB_INFO));310  } catch {311    throw new Error("identity_migration_required");312  }313  const record = await parseCurrentRecord(parsed);314  if (!record) throw new Error("identity_migration_required");315  return record;316}317318async function readEnvelope(): Promise<IdentityEnvelope | null> {319  const stored = (await chrome.storage.local.get(IDENTITY_RECORD_KEY))[IDENTITY_RECORD_KEY] as320    | IdentityEnvelope321    | undefined;322  if (!stored) return null;323  if (stored.v !== 4 || !stored.blob || !stored.ik) throw new Error("identity_migration_required");324  return stored;325}326327async function loadRecord(password?: string): Promise<IdentityRecord | null> {328  const stored = await readEnvelope();329  if (!stored) return migrateLegacyIdentity();330331  const cached = await cacheGet();332  if (cached) return cached;333334  const ik = await openIdentityKey(stored.ik, password);335  if (!ik) throw new Error("identity_migration_required");336  try {337    const record = await readRecordFromEnvelope(stored, ik);338    await cacheSet(record);339    return record;340  } finally {341    ik.fill(0);342  }343}344345/**346 * Re-wrap the stored envelope under the vault keys now in force. Every vault347 * key swap kills the wrap that was encrypted under the old key, so without this348 * a password change followed by a sync-identity rotation (in either order) would349 * leave no wrap able to open the identity — permanent, unrecoverable loss. Only350 * the live vault keys are used; the pre-vault password is never a way in here.351 */352async function rewrapIdentity(): Promise<void> {353  const stored = await readEnvelope();354  if (!stored || !isUnlocked() || getRootKeyHex() === null) return;355  const ik = await openIdentityKey(stored.ik);356  if (!ik) return;357  try {358    const record = await readRecordFromEnvelope(stored, ik);359    await persist(record, ik, await sealIdentityKeyUnderVault(ik));360    await cacheSet(record);361  } finally {362    ik.fill(0);363  }364}365366/**367 * Replace the pre-vault wrap with the vault wraps now that a vault exists. The368 * pending wrap is the only authority for this: it is sealed under the password369 * the user chose for this install, so a password that never created this370 * identity cannot re-seal it into a vault it does not belong to.371 */372async function promotePreVaultIdentity(password: string): Promise<void> {373  const stored = await readEnvelope();374  if (!stored?.ik.pending || !stored.ik.pendingSalt) return;375  const ik = await openIdentityKey(376    { pending: stored.ik.pending, pendingSalt: stored.ik.pendingSalt },377    password,378  );379  if (!ik) return;380  try {381    const record = await readRecordFromEnvelope(stored, ik);382    await persist(record, ik, await sealIdentityKeyUnderVault(ik));383    await cacheSet(record);384  } finally {385    ik.fill(0);386  }387}388389async function ensureRecord(): Promise<IdentityRecord> {390  const existing = await loadRecord();391  if (existing) return existing;392  const cached = await cacheGet();393  if (cached) return cached;394  // An identity was minted on this install and its record is now gone. Refuse395  // rather than hand every peer an unknown device whose new keys no longer396  // match the revocation ledger.397  if ((await chrome.storage.local.get(IDENTITY_MARKER_KEY))[IDENTITY_MARKER_KEY]) {398    throw new Error("identity_migration_required");399  }400  if (await hasLegacyTrustState(browserGenerationIo)) {401    await enterResetRequired(browserGenerationIo);402    throw new Error("identity_migration_required");403  }404  const record = await makeRecord();405  const ik = crypto.getRandomValues(new Uint8Array(32));406  try {407    await persist(record, ik, await sealIdentityKeyUnderVault(ik));408    await cacheSet(record);409    return record;410  } finally {411    ik.fill(0);412  }413}414415/**416 * Create the identity for an install that has no vault yet. Adoption v2 sends417 * this identity in its FIRST frame, long before a root key exists, so the wrap418 * key comes from the local password the user is choosing. A no-op once any419 * identity record exists.420 */421export async function preparePreVaultIdentity(password: string): Promise<void> {422  const stored = await chrome.storage.local.get(IDENTITY_RECORD_KEY);423  if (stored[IDENTITY_RECORD_KEY]) return;424  const record = await makeRecord();425  const ik = crypto.getRandomValues(new Uint8Array(32));426  try {427    await persist(record, ik, await sealIdentityKeyPreVault(password, ik));428    await cacheSet(record);429  } finally {430    ik.fill(0);431  }432}433434/** Read the pre-vault identity back for the adoption handshake. */435export async function readPreVaultIdentity(password: string): Promise<LocalDeviceIdentity> {436  const record = await loadRecord(password);437  if (!record) throw new Error("identity_unavailable");438  return {439    deviceId: record.deviceId,440    deviceName: await getDeviceName(),441    deviceType: "extension",442    longTermPublicKeyHex: record.x25519PublicKeyHex,443    signingPublicKeyHex: record.ed25519PublicKeyHex,444  };445}446447export async function rotatePreVaultIdentity(password: string): Promise<LocalDeviceIdentity> {448  const record = await makeRecord();449  const ik = crypto.getRandomValues(new Uint8Array(32));450  try {451    await persist(record, ik, await sealIdentityKeyPreVault(password, ik));452    await cacheSet(record);453    return await readPreVaultIdentity(password);454  } finally {455    ik.fill(0);456  }457}458459export async function getOrCreateDeviceId(): Promise<string> {460  if (!isUnlocked() || getRootKeyHex() === null) throw new Error("vault_locked");461  return (await ensureRecord()).deviceId;462}463464export async function rotateDeviceId(): Promise<string> {465  if (!isUnlocked() || getRootKeyHex() === null) throw new Error("vault_locked");466  const record = await makeRecord();467  const ik = crypto.getRandomValues(new Uint8Array(32));468  try {469    await persist(record, ik, await sealIdentityKeyUnderVault(ik));470    await cacheSet(record);471    return record.deviceId;472  } finally {473    ik.fill(0);474  }475}476477export async function getDeviceName(): Promise<string> {478  const result = await chrome.storage.sync.get(DEVICE_NAME_KEY);479  const stored = (result as Record<string, unknown>)[DEVICE_NAME_KEY];480  return typeof stored === "string" && stored.length > 0 ? stored : defaultDeviceName();481}482483export async function setDeviceName(name: string): Promise<void> {484  const trimmed = name.trim();485  if (!trimmed) throw new Error("device name must not be empty");486  if (trimmed.length > 64) throw new Error("device name must be 64 chars or fewer");487  await chrome.storage.sync.set({ [DEVICE_NAME_KEY]: trimmed });488}489490export async function buildLocalIdentity(): Promise<LocalDeviceIdentity> {491  const record = await ensureRecord();492  return {493    deviceId: record.deviceId,494    deviceName: await getDeviceName(),495    deviceType: "extension",496    longTermPublicKeyHex: record.x25519PublicKeyHex,497    signingPublicKeyHex: record.ed25519PublicKeyHex,498  };499}500501export async function getLocalPairingIdentity(): Promise<{502  identity: LocalIdentity;503  privateKey: Uint8Array;504  publicKey: Uint8Array;505  signingPrivateKey: CryptoKey;506}> {507  if (!isUnlocked()) throw new Error("vault_locked");508  const rootHex = getRootKeyHex();509  if (!rootHex) throw new Error("vault_locked");510  const root = bytes(rootHex);511  try {512    const record = await ensureRecord();513    const privateKey = bytes(record.x25519PrivateKeyHex);514    const publicKey = generateKeyPairFromSeed(privateKey).publicKey;515    const signingPrivateKey = await importSigningKey(bytes(record.ed25519SeedHex));516    const base = await buildLocalIdentity();517    return { identity: { ...base, vaultId: await computeVaultId(root) }, privateKey, publicKey, signingPrivateKey };518  } finally {519    root.fill(0);520  }521}522523function defaultDeviceName(): string {524  // Best-effort browser + platform string. Avoids userAgent parsing — the525  // user can rename via Settings.526  const ua = (typeof navigator !== "undefined" ? navigator.userAgent : "") || "";527  if (/Firefox/i.test(ua)) return "Firefox";528  if (/Edg\//i.test(ua)) return "Edge";529  if (/Chrome/i.test(ua)) return "Chrome";530  if (/Safari/i.test(ua)) return "Safari";531  return "Browser";532}533534function generateUuidV4(): string {535  // crypto.randomUUID is available in MV3 service workers and popup pages.536  // Falls back to manual assembly only if a runtime is missing it.537  if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {538    return crypto.randomUUID();539  }540  const b = crypto.getRandomValues(new Uint8Array(16));541  b[6] = (b[6] & 0x0f) | 0x40;542  b[8] = (b[8] & 0x3f) | 0x80;543  const h = hex(b);544  return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;545}