device-identity.ts

20.1 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    if (await hasLegacyTrustState(browserGenerationIo)) return false;233    try {234      // A secure reset retires the old identity generation as well as its235      // paired-device registry. Reusing the old record would let archived236      // trust material identify this installation after the reset.237      await rotateDeviceId();238      return true;239    } catch {240      return false;241    }242  },243  deviceId: getOrCreateDeviceId,244});245246/**247 * A legacy random record is completed in place -- same device ID and keys --248 * rather than regenerated. `createdAt` is the upgrade time, because the original249 * creation time was never recorded.250 */251async function completeLegacyRecord(legacy: {252  deviceId: string;253  x25519PrivateKeyHex: string;254  ed25519SeedHex: string;255}): Promise<IdentityRecord> {256  return {257    v: IDENTITY_RECORD_VERSION,258    deviceId: legacy.deviceId,259    x25519PrivateKeyHex: legacy.x25519PrivateKeyHex,260    ed25519SeedHex: legacy.ed25519SeedHex,261    ...(await derivePublicHex(legacy)),262    createdAt: Date.now(),263  };264}265266/**267 * Re-seal an identity written before the envelope existed. The old record was268 * sealed under the vault root, so it is a second root-readable copy of the same269 * private keys; the re-sealed envelope is durable by the time it is removed.270 */271async function migrateLegacyIdentity(): Promise<IdentityRecord | null> {272  const stored = (await chrome.storage.local.get(LEGACY_IDENTITY_KEY))[LEGACY_IDENTITY_KEY] as273    | Record<string, string>274    | undefined;275  if (!stored) return null;276  const rootHex = getRootKeyHex();277  if (!rootHex) throw new Error("identity_migration_required");278  const root = bytes(rootHex);279  try {280    let parsed: unknown;281    try {282      parsed = JSON.parse(await decrypt(root, stored.nonce, stored.ciphertext, stored.tag, LEGACY_INFO));283    } catch {284      throw new Error("identity_migration_required");285    }286    const legacy = parseLegacyRandomRecord(parsed);287    const record = (await parseCurrentRecord(parsed)) ?? (legacy ? await completeLegacyRecord(legacy) : null);288    if (!record) throw new Error("identity_migration_required");289    const ik = crypto.getRandomValues(new Uint8Array(32));290    try {291      await persist(record, ik, await sealIdentityKeyUnderVault(ik));292    } finally {293      ik.fill(0);294    }295    await chrome.storage.local.remove(LEGACY_IDENTITY_KEY);296    await cacheSet(record);297    return record;298  } finally {299    root.fill(0);300  }301}302303/** Decrypt and validate the record out of an envelope once its identity key is open. */304async function readRecordFromEnvelope(stored: IdentityEnvelope, ik: Uint8Array): Promise<IdentityRecord> {305  let parsed: unknown;306  try {307    parsed = JSON.parse(await decrypt(ik, stored.blob.nonce, stored.blob.ciphertext, stored.blob.tag, BLOB_INFO));308  } catch {309    throw new Error("identity_migration_required");310  }311  const record = await parseCurrentRecord(parsed);312  if (!record) throw new Error("identity_migration_required");313  return record;314}315316async function readEnvelope(): Promise<IdentityEnvelope | null> {317  const stored = (await chrome.storage.local.get(IDENTITY_RECORD_KEY))[IDENTITY_RECORD_KEY] as318    | IdentityEnvelope319    | undefined;320  if (!stored) return null;321  if (stored.v !== 4 || !stored.blob || !stored.ik) throw new Error("identity_migration_required");322  return stored;323}324325async function loadRecord(password?: string): Promise<IdentityRecord | null> {326  const stored = await readEnvelope();327  if (!stored) return migrateLegacyIdentity();328329  const cached = await cacheGet();330  if (cached) return cached;331332  const ik = await openIdentityKey(stored.ik, password);333  if (!ik) throw new Error("identity_migration_required");334  try {335    const record = await readRecordFromEnvelope(stored, ik);336    await cacheSet(record);337    return record;338  } finally {339    ik.fill(0);340  }341}342343/**344 * Re-wrap the stored envelope under the vault keys now in force. Every vault345 * key swap kills the wrap that was encrypted under the old key, so without this346 * a password change followed by a sync-identity rotation (in either order) would347 * leave no wrap able to open the identity — permanent, unrecoverable loss. Only348 * the live vault keys are used; the pre-vault password is never a way in here.349 */350async function rewrapIdentity(): Promise<void> {351  const stored = await readEnvelope();352  if (!stored || !isUnlocked() || getRootKeyHex() === null) return;353  const ik = await openIdentityKey(stored.ik);354  if (!ik) return;355  try {356    const record = await readRecordFromEnvelope(stored, ik);357    await persist(record, ik, await sealIdentityKeyUnderVault(ik));358    await cacheSet(record);359  } finally {360    ik.fill(0);361  }362}363364/**365 * Replace the pre-vault wrap with the vault wraps now that a vault exists. The366 * pending wrap is the only authority for this: it is sealed under the password367 * the user chose for this install, so a password that never created this368 * identity cannot re-seal it into a vault it does not belong to.369 */370async function promotePreVaultIdentity(password: string): Promise<void> {371  const stored = await readEnvelope();372  if (!stored?.ik.pending || !stored.ik.pendingSalt) return;373  const ik = await openIdentityKey(374    { pending: stored.ik.pending, pendingSalt: stored.ik.pendingSalt },375    password,376  );377  if (!ik) return;378  try {379    const record = await readRecordFromEnvelope(stored, ik);380    await persist(record, ik, await sealIdentityKeyUnderVault(ik));381    await cacheSet(record);382  } finally {383    ik.fill(0);384  }385}386387async function ensureRecord(): Promise<IdentityRecord> {388  const existing = await loadRecord();389  if (existing) return existing;390  const cached = await cacheGet();391  if (cached) return cached;392  // An identity was minted on this install and its record is now gone. Refuse393  // rather than hand every peer an unknown device whose new keys no longer394  // match the revocation ledger.395  if ((await chrome.storage.local.get(IDENTITY_MARKER_KEY))[IDENTITY_MARKER_KEY]) {396    throw new Error("identity_migration_required");397  }398  if (await hasLegacyTrustState(browserGenerationIo)) {399    await enterResetRequired(browserGenerationIo);400    throw new Error("identity_migration_required");401  }402  const record = await makeRecord();403  const ik = crypto.getRandomValues(new Uint8Array(32));404  try {405    await persist(record, ik, await sealIdentityKeyUnderVault(ik));406    await cacheSet(record);407    return record;408  } finally {409    ik.fill(0);410  }411}412413/**414 * Create the identity for an install that has no vault yet. Adoption v2 sends415 * this identity in its FIRST frame, long before a root key exists, so the wrap416 * key comes from the local password the user is choosing. A no-op once any417 * identity record exists.418 */419export async function preparePreVaultIdentity(password: string): Promise<void> {420  const stored = await chrome.storage.local.get(IDENTITY_RECORD_KEY);421  if (stored[IDENTITY_RECORD_KEY]) return;422  const record = await makeRecord();423  const ik = crypto.getRandomValues(new Uint8Array(32));424  try {425    await persist(record, ik, await sealIdentityKeyPreVault(password, ik));426    await cacheSet(record);427  } finally {428    ik.fill(0);429  }430}431432/** Read the pre-vault identity back for the adoption handshake. */433export async function readPreVaultIdentity(password: string): Promise<LocalDeviceIdentity> {434  const record = await loadRecord(password);435  if (!record) throw new Error("identity_unavailable");436  return {437    deviceId: record.deviceId,438    deviceName: await getDeviceName(),439    deviceType: "extension",440    longTermPublicKeyHex: record.x25519PublicKeyHex,441    signingPublicKeyHex: record.ed25519PublicKeyHex,442  };443}444445export async function rotatePreVaultIdentity(password: string): Promise<LocalDeviceIdentity> {446  const record = await makeRecord();447  const ik = crypto.getRandomValues(new Uint8Array(32));448  try {449    await persist(record, ik, await sealIdentityKeyPreVault(password, ik));450    await cacheSet(record);451    return await readPreVaultIdentity(password);452  } finally {453    ik.fill(0);454  }455}456457export async function getOrCreateDeviceId(): Promise<string> {458  if (!isUnlocked() || getRootKeyHex() === null) throw new Error("vault_locked");459  return (await ensureRecord()).deviceId;460}461462export async function rotateDeviceId(): Promise<string> {463  if (!isUnlocked() || getRootKeyHex() === null) throw new Error("vault_locked");464  const record = await makeRecord();465  const ik = crypto.getRandomValues(new Uint8Array(32));466  try {467    await persist(record, ik, await sealIdentityKeyUnderVault(ik));468    await cacheSet(record);469    return record.deviceId;470  } finally {471    ik.fill(0);472  }473}474475export async function getDeviceName(): Promise<string> {476  const result = await chrome.storage.sync.get(DEVICE_NAME_KEY);477  const stored = (result as Record<string, unknown>)[DEVICE_NAME_KEY];478  return typeof stored === "string" && stored.length > 0 ? stored : defaultDeviceName();479}480481export async function setDeviceName(name: string): Promise<void> {482  const trimmed = name.trim();483  if (!trimmed) throw new Error("device name must not be empty");484  if (trimmed.length > 64) throw new Error("device name must be 64 chars or fewer");485  await chrome.storage.sync.set({ [DEVICE_NAME_KEY]: trimmed });486}487488export async function buildLocalIdentity(): Promise<LocalDeviceIdentity> {489  const record = await ensureRecord();490  return {491    deviceId: record.deviceId,492    deviceName: await getDeviceName(),493    deviceType: "extension",494    longTermPublicKeyHex: record.x25519PublicKeyHex,495    signingPublicKeyHex: record.ed25519PublicKeyHex,496  };497}498499export async function getLocalPairingIdentity(): Promise<{500  identity: LocalIdentity;501  privateKey: Uint8Array;502  publicKey: Uint8Array;503  signingPrivateKey: CryptoKey;504}> {505  if (!isUnlocked()) throw new Error("vault_locked");506  const rootHex = getRootKeyHex();507  if (!rootHex) throw new Error("vault_locked");508  const root = bytes(rootHex);509  try {510    const record = await ensureRecord();511    const privateKey = bytes(record.x25519PrivateKeyHex);512    const publicKey = generateKeyPairFromSeed(privateKey).publicKey;513    const signingPrivateKey = await importSigningKey(bytes(record.ed25519SeedHex));514    const base = await buildLocalIdentity();515    return { identity: { ...base, vaultId: await computeVaultId(root) }, privateKey, publicKey, signingPrivateKey };516  } finally {517    root.fill(0);518  }519}520521function defaultDeviceName(): string {522  // Best-effort browser + platform string. Avoids userAgent parsing — the523  // user can rename via Settings.524  const ua = (typeof navigator !== "undefined" ? navigator.userAgent : "") || "";525  if (/Firefox/i.test(ua)) return "Firefox";526  if (/Edg\//i.test(ua)) return "Edge";527  if (/Chrome/i.test(ua)) return "Chrome";528  if (/Safari/i.test(ua)) return "Safari";529  return "Browser";530}531532function generateUuidV4(): string {533  // crypto.randomUUID is available in MV3 service workers and popup pages.534  // Falls back to manual assembly only if a runtime is missing it.535  if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {536    return crypto.randomUUID();537  }538  const b = crypto.getRandomValues(new Uint8Array(16));539  b[6] = (b[6] & 0x0f) | 0x40;540  b[8] = (b[8] & 0x3f) | 0x80;541  const h = hex(b);542  return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;543}