device-identity.ts

11.8 KB · raw

1// Local-device identity helpers. Cryptographic credentials are generated once2// per installation and kept in an encrypted local record; vault unlock is the3// only way to hydrate the operational cache.45import { generateKeyPairFromSeed } from "@stablelib/x25519";6import {7  computeVaultId,8  pubkeyToHex,9} from "../shared/sync-identity";10import type { LocalIdentity } from "../shared/pairing-handshake";11import type { DeviceType } from "../shared/sync-types";12import { decrypt, encrypt } from "./aes";13import { getRootKeyHex, isUnlocked } from "./vault";1415const DEVICE_NAME_KEY = "peach_device_name";16const IDENTITY_RECORD_KEY = "peach_local_identity_v2";17const IDENTITY_INFO = "peach-local-identity-v2";1819/** Frozen local-identity schema (campaign task 2.1). */20const IDENTITY_RECORD_VERSION = 3;21/** First random-identity format — private seeds only. Completed in place. */22const LEGACY_RANDOM_RECORD_VERSION = 2;23const HEX_64 = /^[0-9a-f]{64}$/i;2425interface IdentityRecord {26  v: typeof IDENTITY_RECORD_VERSION;27  deviceId: string;28  x25519PrivateKeyHex: string;29  x25519PublicKeyHex: string;30  ed25519SeedHex: string;31  ed25519PublicKeyHex: string;32  createdAt: number;33}3435let cached: IdentityRecord | null = null;3637export interface LocalDeviceIdentity {38  deviceId: string;39  deviceName: string;40  deviceType: DeviceType;41  longTermPublicKeyHex: string;42  signingPublicKeyHex: string;43}4445function hex(bytes: Uint8Array): string {46  return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");47}48function bytes(value: string): Uint8Array {49  if (!/^[0-9a-f]{64}$/i.test(value)) throw new Error("identity_migration_required");50  const out = new Uint8Array(32);51  for (let i = 0; i < out.length; i++) out[i] = Number.parseInt(value.slice(i * 2, i * 2 + 2), 16);52  return out;53}54/** Public values derived from the private material, for creation and for the integrity check on load. */55async function derivePublicHex(privateHex: {56  x25519PrivateKeyHex: string;57  ed25519SeedHex: string;58}): Promise<{ x25519PublicKeyHex: string; ed25519PublicKeyHex: string }> {59  const kp = generateKeyPairFromSeed(bytes(privateHex.x25519PrivateKeyHex));60  const signingPrivateKey = await importSigningKey(bytes(privateHex.ed25519SeedHex));61  const jwk = (await crypto.subtle.exportKey("jwk", signingPrivateKey)) as JsonWebKey;62  return {63    x25519PublicKeyHex: pubkeyToHex(kp.publicKey),64    ed25519PublicKeyHex: base64ToHex(jwk.x ?? ""),65  };66}6768/**69 * Accepts only a complete frozen-schema record: every field present, a real70 * creation time, and stored public values that match the private material.71 * Recomputation alone would accept a record whose public half was tampered with.72 */73async function parseCurrentRecord(value: unknown): Promise<IdentityRecord | null> {74  if (typeof value !== "object" || value === null) return null;75  const row = value as Record<string, unknown>;76  if (row.v !== IDENTITY_RECORD_VERSION) return null;77  if (typeof row.deviceId !== "string" || row.deviceId.length === 0) return null;78  if (typeof row.x25519PrivateKeyHex !== "string" || !HEX_64.test(row.x25519PrivateKeyHex)) return null;79  if (typeof row.x25519PublicKeyHex !== "string" || !HEX_64.test(row.x25519PublicKeyHex)) return null;80  if (typeof row.ed25519SeedHex !== "string" || !HEX_64.test(row.ed25519SeedHex)) return null;81  if (typeof row.ed25519PublicKeyHex !== "string" || !HEX_64.test(row.ed25519PublicKeyHex)) return null;82  if (typeof row.createdAt !== "number" || !Number.isFinite(row.createdAt) || row.createdAt <= 0) return null;83  const derived = await derivePublicHex({84    x25519PrivateKeyHex: row.x25519PrivateKeyHex,85    ed25519SeedHex: row.ed25519SeedHex,86  });87  if (derived.x25519PublicKeyHex !== row.x25519PublicKeyHex.toLowerCase()) return null;88  if (derived.ed25519PublicKeyHex !== row.ed25519PublicKeyHex.toLowerCase()) return null;89  return {90    v: IDENTITY_RECORD_VERSION,91    deviceId: row.deviceId,92    x25519PrivateKeyHex: row.x25519PrivateKeyHex,93    x25519PublicKeyHex: row.x25519PublicKeyHex,94    ed25519SeedHex: row.ed25519SeedHex,95    ed25519PublicKeyHex: row.ed25519PublicKeyHex,96    createdAt: row.createdAt,97  };98}99100function parseLegacyRandomRecord(value: unknown): {101  deviceId: string;102  x25519PrivateKeyHex: string;103  ed25519SeedHex: string;104} | null {105  if (typeof value !== "object" || value === null) return null;106  const row = value as Record<string, unknown>;107  if (row.v !== LEGACY_RANDOM_RECORD_VERSION) return null;108  if (typeof row.deviceId !== "string" || row.deviceId.length === 0) return null;109  if (typeof row.x25519PrivateKeyHex !== "string" || !HEX_64.test(row.x25519PrivateKeyHex)) return null;110  if (typeof row.ed25519SeedHex !== "string" || !HEX_64.test(row.ed25519SeedHex)) return null;111  return {112    deviceId: row.deviceId,113    x25519PrivateKeyHex: row.x25519PrivateKeyHex,114    ed25519SeedHex: row.ed25519SeedHex,115  };116}117118async function persistRecord(root: Uint8Array, record: IdentityRecord): Promise<void> {119  const sealed = await encrypt(root, JSON.stringify(record), IDENTITY_INFO);120  await chrome.storage.local.set({121    [IDENTITY_RECORD_KEY]: { v: IDENTITY_RECORD_VERSION, ...sealed },122  });123}124125/**126 * A legacy random record is completed in place — same device ID and keys —127 * rather than regenerated. `createdAt` is the upgrade time, because the original128 * creation time was never recorded.129 */130async function completeLegacyRecord(131  root: Uint8Array,132  legacy: { deviceId: string; x25519PrivateKeyHex: string; ed25519SeedHex: string },133): Promise<IdentityRecord> {134  const derived = await derivePublicHex(legacy);135  const record: IdentityRecord = {136    v: IDENTITY_RECORD_VERSION,137    deviceId: legacy.deviceId,138    x25519PrivateKeyHex: legacy.x25519PrivateKeyHex,139    ed25519SeedHex: legacy.ed25519SeedHex,140    ...derived,141    createdAt: Date.now(),142  };143  await persistRecord(root, record);144  return record;145}146147async function loadRecord(root: Uint8Array): Promise<IdentityRecord | null> {148  if (cached) return cached;149  const row = (await chrome.storage.local.get(IDENTITY_RECORD_KEY))[IDENTITY_RECORD_KEY] as Record<string, unknown> | undefined;150  if (!row) return null;151  if (typeof row.v !== "number" || typeof row.nonce !== "string" || typeof row.ciphertext !== "string" || typeof row.tag !== "string") {152    throw new Error("identity_migration_required");153  }154  let parsed: unknown;155  try {156    parsed = JSON.parse(await decrypt(root, row.nonce, row.ciphertext, row.tag, IDENTITY_INFO));157  } catch { throw new Error("identity_migration_required"); }158  const current = await parseCurrentRecord(parsed);159  if (current) {160    cached = current;161    return current;162  }163  const legacy = parseLegacyRandomRecord(parsed);164  if (legacy) {165    const upgraded = await completeLegacyRecord(root, legacy);166    cached = upgraded;167    return upgraded;168  }169  throw new Error("identity_migration_required");170}171172async function ensureRecord(root: Uint8Array): Promise<IdentityRecord> {173  const existing = await loadRecord(root);174  if (existing) return existing;175  const x = new Uint8Array(32);176  const ed = new Uint8Array(32);177  crypto.getRandomValues(x);178  crypto.getRandomValues(ed);179  const privateHex = { x25519PrivateKeyHex: hex(x), ed25519SeedHex: hex(ed) };180  x.fill(0);181  ed.fill(0);182  const record: IdentityRecord = {183    v: IDENTITY_RECORD_VERSION,184    deviceId: generateUuidV4(),185    ...privateHex,186    ...(await derivePublicHex(privateHex)),187    createdAt: Date.now(),188  };189  await persistRecord(root, record);190  cached = record;191  return record;192}193194export async function getOrCreateDeviceId(): Promise<string> {195  if (!isUnlocked()) throw new Error("vault_locked");196  const rootHex = getRootKeyHex(); if (!rootHex) throw new Error("vault_locked");197  return (await ensureRecord(bytes(rootHex))).deviceId;198}199export async function rotateDeviceId(): Promise<string> {200  if (!isUnlocked()) throw new Error("vault_locked");201  const rootHex = getRootKeyHex();202  if (!rootHex) throw new Error("vault_locked");203  const root = bytes(rootHex);204  const x = new Uint8Array(32);205  const ed = new Uint8Array(32);206  try {207    crypto.getRandomValues(x);208    crypto.getRandomValues(ed);209    const privateHex = { x25519PrivateKeyHex: hex(x), ed25519SeedHex: hex(ed) };210    const record: IdentityRecord = {211      v: IDENTITY_RECORD_VERSION,212      deviceId: generateUuidV4(),213      ...privateHex,214      ...(await derivePublicHex(privateHex)),215      createdAt: Date.now(),216    };217    await persistRecord(root, record);218    cached = record;219    return record.deviceId;220  } finally {221    root.fill(0);222    x.fill(0);223    ed.fill(0);224  }225}226export async function getDeviceName(): Promise<string> {227  const result = await chrome.storage.sync.get(DEVICE_NAME_KEY);228  const stored = (result as Record<string, unknown>)[DEVICE_NAME_KEY];229  return typeof stored === "string" && stored.length > 0 ? stored : defaultDeviceName();230}231export async function setDeviceName(name: string): Promise<void> {232  const trimmed = name.trim();233  if (!trimmed) throw new Error("device name must not be empty");234  if (trimmed.length > 64) throw new Error("device name must be 64 chars or fewer");235  await chrome.storage.sync.set({ [DEVICE_NAME_KEY]: trimmed });236}237export async function buildLocalIdentity(masterKey: Uint8Array): Promise<LocalDeviceIdentity> {238  const record = await ensureRecord(masterKey);239  return {240    deviceId: record.deviceId,241    deviceName: await getDeviceName(),242    deviceType: "extension",243    longTermPublicKeyHex: record.x25519PublicKeyHex,244    signingPublicKeyHex: record.ed25519PublicKeyHex,245  };246}247async function importSigningKey(seed: Uint8Array): Promise<CryptoKey> {248  const prefix = Uint8Array.of(0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20);249  const pkcs8 = new Uint8Array(prefix.length + 32);250  pkcs8.set(prefix);251  pkcs8.set(seed, prefix.length);252  return crypto.subtle.importKey("pkcs8", pkcs8 as Uint8Array<ArrayBuffer>, { name: "Ed25519" }, true, ["sign"]);253}254function base64ToHex(value: string): string {255  const raw = atob(value.replace(/-/g, "+").replace(/_/g, "/"));256  return Array.from(raw, (c) => c.charCodeAt(0).toString(16).padStart(2, "0")).join("");257}258function defaultDeviceName(): string {259  // Best-effort browser + platform string. Avoids userAgent parsing — the260  // user can rename via Settings.261  const ua = (typeof navigator !== "undefined" ? navigator.userAgent : "") || "";262  if (/Firefox/i.test(ua)) return "Firefox";263  if (/Edg\//i.test(ua)) return "Edge";264  if (/Chrome/i.test(ua)) return "Chrome";265  if (/Safari/i.test(ua)) return "Safari";266  return "Browser";267}268269function generateUuidV4(): string {270  // crypto.randomUUID is available in MV3 service workers and popup pages.271  // Falls back to manual assembly only if a runtime is missing it.272  if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {273    return crypto.randomUUID();274  }275  const bytes = new Uint8Array(16);276  crypto.getRandomValues(bytes as Uint8Array<ArrayBuffer>);277  bytes[6] = (bytes[6] & 0x0f) | 0x40;278  bytes[8] = (bytes[8] & 0x3f) | 0x80;279  const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");280  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;281}282283export async function getLocalPairingIdentity(): Promise<{284  identity: LocalIdentity;285  privateKey: Uint8Array;286  publicKey: Uint8Array;287  signingPrivateKey: CryptoKey;288}> {289  if (!isUnlocked()) throw new Error("vault_locked");290  const rootHex = getRootKeyHex(); if (!rootHex) throw new Error("vault_locked");291  const root = bytes(rootHex);292  try {293    const record = await ensureRecord(root);294    const privateKey = bytes(record.x25519PrivateKeyHex);295    const publicKey = generateKeyPairFromSeed(privateKey).publicKey;296    const signingPrivateKey = await importSigningKey(bytes(record.ed25519SeedHex));297    const base = await buildLocalIdentity(root);298    return { identity: { ...base, vaultId: await computeVaultId(root) }, privateKey, publicKey, signingPrivateKey };299  } finally { root.fill(0); }300}