device-identity.ts

18.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";3536const DEVICE_NAME_KEY = "peach_device_name";37const IDENTITY_RECORD_KEY = "peach_local_identity_v4";38const IDENTITY_MARKER_KEY = "peach_local_identity_initialized";39const LEGACY_IDENTITY_KEY = "peach_local_identity_v2";40const LEGACY_INFO = "peach-local-identity-v2";41const BLOB_INFO = "peach-local-identity-blob-v1";42const SESSION_CACHE_KEY = "peach_local_identity_session";43const HEX_64 = /^[0-9a-f]{64}$/i;4445/** Frozen local-identity schema (campaign task 2.1). */46const IDENTITY_RECORD_VERSION = 3;4748interface IdentityRecord {49  v: typeof IDENTITY_RECORD_VERSION;50  deviceId: string;51  x25519PrivateKeyHex: string;52  x25519PublicKeyHex: string;53  ed25519SeedHex: string;54  ed25519PublicKeyHex: string;55  createdAt: number;56}5758interface IdentityEnvelope {59  v: 4;60  blob: SealedBox;61  ik: IdentityKeyEnvelope;62}6364export interface LocalDeviceIdentity {65  deviceId: string;66  deviceName: string;67  deviceType: DeviceType;68  longTermPublicKeyHex: string;69  signingPublicKeyHex: string;70}7172function hex(value: Uint8Array): string {73  return Array.from(value, (b) => b.toString(16).padStart(2, "0")).join("");74}7576function bytes(value: string): Uint8Array {77  if (!HEX_64.test(value)) throw new Error("identity_migration_required");78  const out = new Uint8Array(32);79  for (let i = 0; i < out.length; i++) out[i] = Number.parseInt(value.slice(i * 2, i * 2 + 2), 16);80  return out;81}8283function base64ToHex(value: string): string {84  const raw = atob(value.replace(/-/g, "+").replace(/_/g, "/"));85  return Array.from(raw, (c) => c.charCodeAt(0).toString(16).padStart(2, "0")).join("");86}8788async function importSigningKey(seed: Uint8Array): Promise<CryptoKey> {89  const prefix = Uint8Array.of(0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20);90  const pkcs8 = new Uint8Array(prefix.length + 32);91  pkcs8.set(prefix);92  pkcs8.set(seed, prefix.length);93  try {94    return await crypto.subtle.importKey("pkcs8", pkcs8 as Uint8Array<ArrayBuffer>, { name: "Ed25519" }, true, ["sign"]);95  } finally {96    pkcs8.fill(0);97  }98}99100/** Public values derived from the private material, for creation and for the integrity check on load. */101async function derivePublicHex(privateHex: {102  x25519PrivateKeyHex: string;103  ed25519SeedHex: string;104}): Promise<{ x25519PublicKeyHex: string; ed25519PublicKeyHex: string }> {105  const kp = generateKeyPairFromSeed(bytes(privateHex.x25519PrivateKeyHex));106  const x25519PublicKeyHex = pubkeyToHex(kp.publicKey);107  kp.secretKey.fill(0);108  const signingPrivateKey = await importSigningKey(bytes(privateHex.ed25519SeedHex));109  const jwk = (await crypto.subtle.exportKey("jwk", signingPrivateKey)) as JsonWebKey;110  return { x25519PublicKeyHex, ed25519PublicKeyHex: base64ToHex(jwk.x ?? "") };111}112113function parseLegacyRandomRecord(value: unknown): {114  deviceId: string;115  x25519PrivateKeyHex: string;116  ed25519SeedHex: string;117} | null {118  if (typeof value !== "object" || value === null) return null;119  const row = value as Record<string, unknown>;120  if (row.v !== 2) return null;121  if (typeof row.deviceId !== "string" || row.deviceId.length === 0) return null;122  if (typeof row.x25519PrivateKeyHex !== "string" || !HEX_64.test(row.x25519PrivateKeyHex)) return null;123  if (typeof row.ed25519SeedHex !== "string" || !HEX_64.test(row.ed25519SeedHex)) return null;124  return {125    deviceId: row.deviceId,126    x25519PrivateKeyHex: row.x25519PrivateKeyHex,127    ed25519SeedHex: row.ed25519SeedHex,128  };129}130131/**132 * Accepts only a complete frozen-schema record: every field present, a real133 * creation time, and stored public values that match the private material.134 * Recomputation alone would accept a record whose public half was tampered with.135 */136async function parseCurrentRecord(value: unknown): Promise<IdentityRecord | null> {137  if (typeof value !== "object" || value === null) return null;138  const row = value as Record<string, unknown>;139  if (row.v !== IDENTITY_RECORD_VERSION) return null;140  if (typeof row.deviceId !== "string" || row.deviceId.length === 0) return null;141  if (typeof row.x25519PrivateKeyHex !== "string" || !HEX_64.test(row.x25519PrivateKeyHex)) return null;142  if (typeof row.x25519PublicKeyHex !== "string" || !HEX_64.test(row.x25519PublicKeyHex)) return null;143  if (typeof row.ed25519SeedHex !== "string" || !HEX_64.test(row.ed25519SeedHex)) return null;144  if (typeof row.ed25519PublicKeyHex !== "string" || !HEX_64.test(row.ed25519PublicKeyHex)) return null;145  if (typeof row.createdAt !== "number" || !Number.isFinite(row.createdAt) || row.createdAt <= 0) return null;146  const derived = await derivePublicHex({147    x25519PrivateKeyHex: row.x25519PrivateKeyHex,148    ed25519SeedHex: row.ed25519SeedHex,149  });150  if (derived.x25519PublicKeyHex !== row.x25519PublicKeyHex.toLowerCase()) return null;151  if (derived.ed25519PublicKeyHex !== row.ed25519PublicKeyHex.toLowerCase()) return null;152  return {153    v: IDENTITY_RECORD_VERSION,154    deviceId: row.deviceId,155    x25519PrivateKeyHex: row.x25519PrivateKeyHex,156    x25519PublicKeyHex: row.x25519PublicKeyHex,157    ed25519SeedHex: row.ed25519SeedHex,158    ed25519PublicKeyHex: row.ed25519PublicKeyHex,159    createdAt: row.createdAt,160  };161}162163async function makeRecord(): Promise<IdentityRecord> {164  const x = crypto.getRandomValues(new Uint8Array(32));165  const ed = crypto.getRandomValues(new Uint8Array(32));166  const privateHex = { x25519PrivateKeyHex: hex(x), ed25519SeedHex: hex(ed) };167  x.fill(0);168  ed.fill(0);169  return {170    v: IDENTITY_RECORD_VERSION,171    deviceId: generateUuidV4(),172    ...privateHex,173    ...(await derivePublicHex(privateHex)),174    createdAt: Date.now(),175  };176}177178async function persist(179  record: IdentityRecord,180  ik: Uint8Array,181  envelope: IdentityKeyEnvelope,182): Promise<void> {183  const blob = await encrypt(ik, JSON.stringify(record), BLOB_INFO);184  const stored: IdentityEnvelope = { v: 4, blob, ik: envelope };185  // The marker is what distinguishes "this install has never minted an186  // identity" from "this install's identity record was lost": a missing record187  // is only mintable in the first case. Written in the same transaction.188  await chrome.storage.local.set({ [IDENTITY_RECORD_KEY]: stored, [IDENTITY_MARKER_KEY]: true });189}190191// The operational copy of the decrypted record. chrome.storage.session survives192// an MV3 service-worker restart but is cleared when the browser closes. It is193// only ever served inside a real unlocked session: serving it while locked or in194// duress mode would hand out the real device identity.195async function cacheGet(): Promise<IdentityRecord | null> {196  if (!isUnlocked() || isDecoyMode()) return null;197  const session = chrome.storage.session;198  if (!session) return null;199  const stored = await session.get(SESSION_CACHE_KEY);200  return (stored[SESSION_CACHE_KEY] as IdentityRecord | undefined) ?? null;201}202203async function cacheSet(record: IdentityRecord): Promise<void> {204  await chrome.storage.session?.set({ [SESSION_CACHE_KEY]: record });205}206207async function clearCache(): Promise<void> {208  await chrome.storage.session?.remove(SESSION_CACHE_KEY);209}210211setIdentityHooks({212  onSessionEnded: clearCache,213  onKeysRotated: rewrapIdentity,214  onVaultCreated: promotePreVaultIdentity,215});216217/**218 * A legacy random record is completed in place -- same device ID and keys --219 * rather than regenerated. `createdAt` is the upgrade time, because the original220 * creation time was never recorded.221 */222async function completeLegacyRecord(legacy: {223  deviceId: string;224  x25519PrivateKeyHex: string;225  ed25519SeedHex: string;226}): Promise<IdentityRecord> {227  return {228    v: IDENTITY_RECORD_VERSION,229    deviceId: legacy.deviceId,230    x25519PrivateKeyHex: legacy.x25519PrivateKeyHex,231    ed25519SeedHex: legacy.ed25519SeedHex,232    ...(await derivePublicHex(legacy)),233    createdAt: Date.now(),234  };235}236237/**238 * Re-seal an identity written before the envelope existed. The old record was239 * sealed under the vault root, so it is a second root-readable copy of the same240 * private keys; the re-sealed envelope is durable by the time it is removed.241 */242async function migrateLegacyIdentity(): Promise<IdentityRecord | null> {243  const stored = (await chrome.storage.local.get(LEGACY_IDENTITY_KEY))[LEGACY_IDENTITY_KEY] as244    | Record<string, string>245    | undefined;246  if (!stored) return null;247  const rootHex = getRootKeyHex();248  if (!rootHex) throw new Error("identity_migration_required");249  const root = bytes(rootHex);250  try {251    let parsed: unknown;252    try {253      parsed = JSON.parse(await decrypt(root, stored.nonce, stored.ciphertext, stored.tag, LEGACY_INFO));254    } catch {255      throw new Error("identity_migration_required");256    }257    const legacy = parseLegacyRandomRecord(parsed);258    const record = (await parseCurrentRecord(parsed)) ?? (legacy ? await completeLegacyRecord(legacy) : null);259    if (!record) throw new Error("identity_migration_required");260    const ik = crypto.getRandomValues(new Uint8Array(32));261    try {262      await persist(record, ik, await sealIdentityKeyUnderVault(ik));263    } finally {264      ik.fill(0);265    }266    await chrome.storage.local.remove(LEGACY_IDENTITY_KEY);267    await cacheSet(record);268    return record;269  } finally {270    root.fill(0);271  }272}273274/** Decrypt and validate the record out of an envelope once its identity key is open. */275async function readRecordFromEnvelope(stored: IdentityEnvelope, ik: Uint8Array): Promise<IdentityRecord> {276  let parsed: unknown;277  try {278    parsed = JSON.parse(await decrypt(ik, stored.blob.nonce, stored.blob.ciphertext, stored.blob.tag, BLOB_INFO));279  } catch {280    throw new Error("identity_migration_required");281  }282  const record = await parseCurrentRecord(parsed);283  if (!record) throw new Error("identity_migration_required");284  return record;285}286287async function readEnvelope(): Promise<IdentityEnvelope | null> {288  const stored = (await chrome.storage.local.get(IDENTITY_RECORD_KEY))[IDENTITY_RECORD_KEY] as289    | IdentityEnvelope290    | undefined;291  if (!stored) return null;292  if (stored.v !== 4 || !stored.blob || !stored.ik) throw new Error("identity_migration_required");293  return stored;294}295296async function loadRecord(password?: string): Promise<IdentityRecord | null> {297  const stored = await readEnvelope();298  if (!stored) return migrateLegacyIdentity();299300  const cached = await cacheGet();301  if (cached) return cached;302303  const ik = await openIdentityKey(stored.ik, password);304  if (!ik) throw new Error("identity_migration_required");305  try {306    const record = await readRecordFromEnvelope(stored, ik);307    await cacheSet(record);308    return record;309  } finally {310    ik.fill(0);311  }312}313314/**315 * Re-wrap the stored envelope under the vault keys now in force. Every vault316 * key swap kills the wrap that was encrypted under the old key, so without this317 * a password change followed by a sync-identity rotation (in either order) would318 * leave no wrap able to open the identity — permanent, unrecoverable loss. Only319 * the live vault keys are used; the pre-vault password is never a way in here.320 */321async function rewrapIdentity(): Promise<void> {322  const stored = await readEnvelope();323  if (!stored || !isUnlocked() || getRootKeyHex() === null) return;324  const ik = await openIdentityKey(stored.ik);325  if (!ik) return;326  try {327    const record = await readRecordFromEnvelope(stored, ik);328    await persist(record, ik, await sealIdentityKeyUnderVault(ik));329    await cacheSet(record);330  } finally {331    ik.fill(0);332  }333}334335/**336 * Replace the pre-vault wrap with the vault wraps now that a vault exists. The337 * pending wrap is the only authority for this: it is sealed under the password338 * the user chose for this install, so a password that never created this339 * identity cannot re-seal it into a vault it does not belong to.340 */341async function promotePreVaultIdentity(password: string): Promise<void> {342  const stored = await readEnvelope();343  if (!stored?.ik.pending || !stored.ik.pendingSalt) return;344  const ik = await openIdentityKey(345    { pending: stored.ik.pending, pendingSalt: stored.ik.pendingSalt },346    password,347  );348  if (!ik) return;349  try {350    const record = await readRecordFromEnvelope(stored, ik);351    await persist(record, ik, await sealIdentityKeyUnderVault(ik));352    await cacheSet(record);353  } finally {354    ik.fill(0);355  }356}357358async function ensureRecord(): Promise<IdentityRecord> {359  const existing = await loadRecord();360  if (existing) return existing;361  const cached = await cacheGet();362  if (cached) return cached;363  // An identity was minted on this install and its record is now gone. Refuse364  // rather than hand every peer an unknown device whose new keys no longer365  // match the revocation ledger.366  if ((await chrome.storage.local.get(IDENTITY_MARKER_KEY))[IDENTITY_MARKER_KEY]) {367    throw new Error("identity_migration_required");368  }369  const record = await makeRecord();370  const ik = crypto.getRandomValues(new Uint8Array(32));371  try {372    await persist(record, ik, await sealIdentityKeyUnderVault(ik));373    await cacheSet(record);374    return record;375  } finally {376    ik.fill(0);377  }378}379380/**381 * Create the identity for an install that has no vault yet. Adoption v2 sends382 * this identity in its FIRST frame, long before a root key exists, so the wrap383 * key comes from the local password the user is choosing. A no-op once any384 * identity record exists.385 */386export async function preparePreVaultIdentity(password: string): Promise<void> {387  const stored = await chrome.storage.local.get(IDENTITY_RECORD_KEY);388  if (stored[IDENTITY_RECORD_KEY]) return;389  const record = await makeRecord();390  const ik = crypto.getRandomValues(new Uint8Array(32));391  try {392    await persist(record, ik, await sealIdentityKeyPreVault(password, ik));393    await cacheSet(record);394  } finally {395    ik.fill(0);396  }397}398399/** Read the pre-vault identity back for the adoption handshake. */400export async function readPreVaultIdentity(password: string): Promise<LocalDeviceIdentity> {401  const record = await loadRecord(password);402  if (!record) throw new Error("identity_unavailable");403  return {404    deviceId: record.deviceId,405    deviceName: await getDeviceName(),406    deviceType: "extension",407    longTermPublicKeyHex: record.x25519PublicKeyHex,408    signingPublicKeyHex: record.ed25519PublicKeyHex,409  };410}411412export async function rotatePreVaultIdentity(password: string): Promise<LocalDeviceIdentity> {413  const record = await makeRecord();414  const ik = crypto.getRandomValues(new Uint8Array(32));415  try {416    await persist(record, ik, await sealIdentityKeyPreVault(password, ik));417    await cacheSet(record);418    return await readPreVaultIdentity(password);419  } finally {420    ik.fill(0);421  }422}423424export async function getOrCreateDeviceId(): Promise<string> {425  if (!isUnlocked() || getRootKeyHex() === null) throw new Error("vault_locked");426  return (await ensureRecord()).deviceId;427}428429export async function rotateDeviceId(): Promise<string> {430  if (!isUnlocked() || getRootKeyHex() === null) throw new Error("vault_locked");431  const record = await makeRecord();432  const ik = crypto.getRandomValues(new Uint8Array(32));433  try {434    await persist(record, ik, await sealIdentityKeyUnderVault(ik));435    await cacheSet(record);436    return record.deviceId;437  } finally {438    ik.fill(0);439  }440}441442export async function getDeviceName(): Promise<string> {443  const result = await chrome.storage.sync.get(DEVICE_NAME_KEY);444  const stored = (result as Record<string, unknown>)[DEVICE_NAME_KEY];445  return typeof stored === "string" && stored.length > 0 ? stored : defaultDeviceName();446}447448export async function setDeviceName(name: string): Promise<void> {449  const trimmed = name.trim();450  if (!trimmed) throw new Error("device name must not be empty");451  if (trimmed.length > 64) throw new Error("device name must be 64 chars or fewer");452  await chrome.storage.sync.set({ [DEVICE_NAME_KEY]: trimmed });453}454455export async function buildLocalIdentity(): Promise<LocalDeviceIdentity> {456  const record = await ensureRecord();457  return {458    deviceId: record.deviceId,459    deviceName: await getDeviceName(),460    deviceType: "extension",461    longTermPublicKeyHex: record.x25519PublicKeyHex,462    signingPublicKeyHex: record.ed25519PublicKeyHex,463  };464}465466export async function getLocalPairingIdentity(): Promise<{467  identity: LocalIdentity;468  privateKey: Uint8Array;469  publicKey: Uint8Array;470  signingPrivateKey: CryptoKey;471}> {472  if (!isUnlocked()) throw new Error("vault_locked");473  const rootHex = getRootKeyHex();474  if (!rootHex) throw new Error("vault_locked");475  const root = bytes(rootHex);476  try {477    const record = await ensureRecord();478    const privateKey = bytes(record.x25519PrivateKeyHex);479    const publicKey = generateKeyPairFromSeed(privateKey).publicKey;480    const signingPrivateKey = await importSigningKey(bytes(record.ed25519SeedHex));481    const base = await buildLocalIdentity();482    return { identity: { ...base, vaultId: await computeVaultId(root) }, privateKey, publicKey, signingPrivateKey };483  } finally {484    root.fill(0);485  }486}487488function defaultDeviceName(): string {489  // Best-effort browser + platform string. Avoids userAgent parsing — the490  // user can rename via Settings.491  const ua = (typeof navigator !== "undefined" ? navigator.userAgent : "") || "";492  if (/Firefox/i.test(ua)) return "Firefox";493  if (/Edg\//i.test(ua)) return "Edge";494  if (/Chrome/i.test(ua)) return "Chrome";495  if (/Safari/i.test(ua)) return "Safari";496  return "Browser";497}498499function generateUuidV4(): string {500  // crypto.randomUUID is available in MV3 service workers and popup pages.501  // Falls back to manual assembly only if a runtime is missing it.502  if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {503    return crypto.randomUUID();504  }505  const b = crypto.getRandomValues(new Uint8Array(16));506  b[6] = (b[6] & 0x0f) | 0x40;507  b[8] = (b[8] & 0x3f) | 0x80;508  const h = hex(b);509  return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;510}