device-identity.ts

20.2 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  try {211    await chrome.storage.session?.set({ [SESSION_CACHE_KEY]: record });212  } catch {213    // The durable local envelope is authoritative; session cache is only a214    // restart optimization and must not block identity creation or rotation.215  }216}217218async function clearCache(): Promise<void> {219  await chrome.storage.session?.remove(SESSION_CACHE_KEY);220}221222setIdentityHooks({223  onSessionEnded: clearCache,224  onKeysRotated: rewrapIdentity,225  onVaultCreated: promotePreVaultIdentity,226});227228/**229 * The secure reset's identity step (§11.3). Only reached at RESET_CONFIRMED,230 * after the legacy trust has been archived and cleared, so minting here is the231 * anchored device's own deliberate action rather than a silent replacement. The232 * guard still refuses to mint over live trust, which is what would otherwise233 * turn the create-on-miss refusal into a bypass.234 */235setGenerationIdentity({236  async ensureIdentity() {237    if (await hasLegacyTrustState(browserGenerationIo)) return false;238    try {239      // A secure reset retires the old identity generation as well as its240      // paired-device registry. Reusing the old record would let archived241      // trust material identify this installation after the reset.242      await rotateDeviceId();243      return true;244    } catch {245      return false;246    }247  },248  deviceId: getOrCreateDeviceId,249});250251/**252 * A legacy random record is completed in place -- same device ID and keys --253 * rather than regenerated. `createdAt` is the upgrade time, because the original254 * creation time was never recorded.255 */256async function completeLegacyRecord(legacy: {257  deviceId: string;258  x25519PrivateKeyHex: string;259  ed25519SeedHex: string;260}): Promise<IdentityRecord> {261  return {262    v: IDENTITY_RECORD_VERSION,263    deviceId: legacy.deviceId,264    x25519PrivateKeyHex: legacy.x25519PrivateKeyHex,265    ed25519SeedHex: legacy.ed25519SeedHex,266    ...(await derivePublicHex(legacy)),267    createdAt: Date.now(),268  };269}270271/**272 * Re-seal an identity written before the envelope existed. The old record was273 * sealed under the vault root, so it is a second root-readable copy of the same274 * private keys; the re-sealed envelope is durable by the time it is removed.275 */276async function migrateLegacyIdentity(): Promise<IdentityRecord | null> {277  const stored = (await chrome.storage.local.get(LEGACY_IDENTITY_KEY))[LEGACY_IDENTITY_KEY] as278    | Record<string, string>279    | undefined;280  if (!stored) return null;281  const rootHex = getRootKeyHex();282  if (!rootHex) throw new Error("identity_migration_required");283  const root = bytes(rootHex);284  try {285    let parsed: unknown;286    try {287      parsed = JSON.parse(await decrypt(root, stored.nonce, stored.ciphertext, stored.tag, LEGACY_INFO));288    } catch {289      throw new Error("identity_migration_required");290    }291    const legacy = parseLegacyRandomRecord(parsed);292    const record = (await parseCurrentRecord(parsed)) ?? (legacy ? await completeLegacyRecord(legacy) : null);293    if (!record) throw new Error("identity_migration_required");294    const ik = crypto.getRandomValues(new Uint8Array(32));295    try {296      await persist(record, ik, await sealIdentityKeyUnderVault(ik));297    } finally {298      ik.fill(0);299    }300    await chrome.storage.local.remove(LEGACY_IDENTITY_KEY);301    await cacheSet(record);302    return record;303  } finally {304    root.fill(0);305  }306}307308/** Decrypt and validate the record out of an envelope once its identity key is open. */309async function readRecordFromEnvelope(stored: IdentityEnvelope, ik: Uint8Array): Promise<IdentityRecord> {310  let parsed: unknown;311  try {312    parsed = JSON.parse(await decrypt(ik, stored.blob.nonce, stored.blob.ciphertext, stored.blob.tag, BLOB_INFO));313  } catch {314    throw new Error("identity_migration_required");315  }316  const record = await parseCurrentRecord(parsed);317  if (!record) throw new Error("identity_migration_required");318  return record;319}320321async function readEnvelope(): Promise<IdentityEnvelope | null> {322  const stored = (await chrome.storage.local.get(IDENTITY_RECORD_KEY))[IDENTITY_RECORD_KEY] as323    | IdentityEnvelope324    | undefined;325  if (!stored) return null;326  if (stored.v !== 4 || !stored.blob || !stored.ik) throw new Error("identity_migration_required");327  return stored;328}329330async function loadRecord(password?: string): Promise<IdentityRecord | null> {331  const stored = await readEnvelope();332  if (!stored) return migrateLegacyIdentity();333334  const cached = await cacheGet();335  if (cached) return cached;336337  const ik = await openIdentityKey(stored.ik, password);338  if (!ik) throw new Error("identity_migration_required");339  try {340    const record = await readRecordFromEnvelope(stored, ik);341    await cacheSet(record);342    return record;343  } finally {344    ik.fill(0);345  }346}347348/**349 * Re-wrap the stored envelope under the vault keys now in force. Every vault350 * key swap kills the wrap that was encrypted under the old key, so without this351 * a password change followed by a sync-identity rotation (in either order) would352 * leave no wrap able to open the identity — permanent, unrecoverable loss. Only353 * the live vault keys are used; the pre-vault password is never a way in here.354 */355async function rewrapIdentity(): Promise<void> {356  const stored = await readEnvelope();357  if (!stored || !isUnlocked() || getRootKeyHex() === null) return;358  const ik = await openIdentityKey(stored.ik);359  if (!ik) return;360  try {361    const record = await readRecordFromEnvelope(stored, ik);362    await persist(record, ik, await sealIdentityKeyUnderVault(ik));363    await cacheSet(record);364  } finally {365    ik.fill(0);366  }367}368369/**370 * Replace the pre-vault wrap with the vault wraps now that a vault exists. The371 * pending wrap is the only authority for this: it is sealed under the password372 * the user chose for this install, so a password that never created this373 * identity cannot re-seal it into a vault it does not belong to.374 */375async function promotePreVaultIdentity(password: string): Promise<void> {376  const stored = await readEnvelope();377  if (!stored?.ik.pending || !stored.ik.pendingSalt) return;378  const ik = await openIdentityKey(379    { pending: stored.ik.pending, pendingSalt: stored.ik.pendingSalt },380    password,381  );382  if (!ik) return;383  try {384    const record = await readRecordFromEnvelope(stored, ik);385    await persist(record, ik, await sealIdentityKeyUnderVault(ik));386    await cacheSet(record);387  } finally {388    ik.fill(0);389  }390}391392async function ensureRecord(): Promise<IdentityRecord> {393  const existing = await loadRecord();394  if (existing) return existing;395  const cached = await cacheGet();396  if (cached) return cached;397  // An identity was minted on this install and its record is now gone. Refuse398  // rather than hand every peer an unknown device whose new keys no longer399  // match the revocation ledger.400  if ((await chrome.storage.local.get(IDENTITY_MARKER_KEY))[IDENTITY_MARKER_KEY]) {401    throw new Error("identity_migration_required");402  }403  if (await hasLegacyTrustState(browserGenerationIo)) {404    await enterResetRequired(browserGenerationIo);405    throw new Error("identity_migration_required");406  }407  const record = await makeRecord();408  const ik = crypto.getRandomValues(new Uint8Array(32));409  try {410    await persist(record, ik, await sealIdentityKeyUnderVault(ik));411    await cacheSet(record);412    return record;413  } finally {414    ik.fill(0);415  }416}417418/**419 * Create the identity for an install that has no vault yet. Adoption v2 sends420 * this identity in its FIRST frame, long before a root key exists, so the wrap421 * key comes from the local password the user is choosing. A no-op once any422 * identity record exists.423 */424export async function preparePreVaultIdentity(password: string): Promise<void> {425  const stored = await chrome.storage.local.get(IDENTITY_RECORD_KEY);426  if (stored[IDENTITY_RECORD_KEY]) return;427  const record = await makeRecord();428  const ik = crypto.getRandomValues(new Uint8Array(32));429  try {430    await persist(record, ik, await sealIdentityKeyPreVault(password, ik));431    await cacheSet(record);432  } finally {433    ik.fill(0);434  }435}436437/** Read the pre-vault identity back for the adoption handshake. */438export async function readPreVaultIdentity(password: string): Promise<LocalDeviceIdentity> {439  const record = await loadRecord(password);440  if (!record) throw new Error("identity_unavailable");441  return {442    deviceId: record.deviceId,443    deviceName: await getDeviceName(),444    deviceType: "extension",445    longTermPublicKeyHex: record.x25519PublicKeyHex,446    signingPublicKeyHex: record.ed25519PublicKeyHex,447  };448}449450export async function rotatePreVaultIdentity(password: string): Promise<LocalDeviceIdentity> {451  const record = await makeRecord();452  const ik = crypto.getRandomValues(new Uint8Array(32));453  try {454    await persist(record, ik, await sealIdentityKeyPreVault(password, ik));455    await cacheSet(record);456    return await readPreVaultIdentity(password);457  } finally {458    ik.fill(0);459  }460}461462export async function getOrCreateDeviceId(): Promise<string> {463  if (!isUnlocked() || getRootKeyHex() === null) throw new Error("vault_locked");464  return (await ensureRecord()).deviceId;465}466467export async function rotateDeviceId(): Promise<string> {468  if (!isUnlocked() || getRootKeyHex() === null) throw new Error("vault_locked");469  const record = await makeRecord();470  const ik = crypto.getRandomValues(new Uint8Array(32));471  try {472    await persist(record, ik, await sealIdentityKeyUnderVault(ik));473    await cacheSet(record);474    return record.deviceId;475  } finally {476    ik.fill(0);477  }478}479480export async function getDeviceName(): Promise<string> {481  const result = await chrome.storage.sync.get(DEVICE_NAME_KEY);482  const stored = (result as Record<string, unknown>)[DEVICE_NAME_KEY];483  return typeof stored === "string" && stored.length > 0 ? stored : defaultDeviceName();484}485486export async function setDeviceName(name: string): Promise<void> {487  const trimmed = name.trim();488  if (!trimmed) throw new Error("device name must not be empty");489  if (trimmed.length > 64) throw new Error("device name must be 64 chars or fewer");490  await chrome.storage.sync.set({ [DEVICE_NAME_KEY]: trimmed });491}492493export async function buildLocalIdentity(): Promise<LocalDeviceIdentity> {494  const record = await ensureRecord();495  return {496    deviceId: record.deviceId,497    deviceName: await getDeviceName(),498    deviceType: "extension",499    longTermPublicKeyHex: record.x25519PublicKeyHex,500    signingPublicKeyHex: record.ed25519PublicKeyHex,501  };502}503504export async function getLocalPairingIdentity(): Promise<{505  identity: LocalIdentity;506  privateKey: Uint8Array;507  publicKey: Uint8Array;508  signingPrivateKey: CryptoKey;509}> {510  if (!isUnlocked()) throw new Error("vault_locked");511  const rootHex = getRootKeyHex();512  if (!rootHex) throw new Error("vault_locked");513  const root = bytes(rootHex);514  try {515    const record = await ensureRecord();516    const privateKey = bytes(record.x25519PrivateKeyHex);517    const publicKey = generateKeyPairFromSeed(privateKey).publicKey;518    const signingPrivateKey = await importSigningKey(bytes(record.ed25519SeedHex));519    const base = await buildLocalIdentity();520    return { identity: { ...base, vaultId: await computeVaultId(root) }, privateKey, publicKey, signingPrivateKey };521  } finally {522    root.fill(0);523  }524}525526function defaultDeviceName(): string {527  // Best-effort browser + platform string. Avoids userAgent parsing — the528  // user can rename via Settings.529  const ua = (typeof navigator !== "undefined" ? navigator.userAgent : "") || "";530  if (/Firefox/i.test(ua)) return "Firefox";531  if (/Edg\//i.test(ua)) return "Edge";532  if (/Chrome/i.test(ua)) return "Chrome";533  if (/Safari/i.test(ua)) return "Safari";534  return "Browser";535}536537function generateUuidV4(): string {538  // crypto.randomUUID is available in MV3 service workers and popup pages.539  // Falls back to manual assembly only if a runtime is missing it.540  if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {541    return crypto.randomUUID();542  }543  const b = crypto.getRandomValues(new Uint8Array(16));544  b[6] = (b[6] & 0x0f) | 0x40;545  b[8] = (b[8] & 0x3f) | 0x80;546  const h = hex(b);547  return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;548}