device-identity.ts

7.7 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";1819interface IdentityRecord {20  v: 2;21  deviceId: string;22  x25519PrivateKeyHex: string;23  ed25519SeedHex: string;24}2526let cached: IdentityRecord | null = null;2728export interface LocalDeviceIdentity {29  deviceId: string;30  deviceName: string;31  deviceType: DeviceType;32  longTermPublicKeyHex: string;33  signingPublicKeyHex: string;34}3536function hex(bytes: Uint8Array): string {37  return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");38}39function bytes(value: string): Uint8Array {40  if (!/^[0-9a-f]{64}$/i.test(value)) throw new Error("identity_migration_required");41  const out = new Uint8Array(32);42  for (let i = 0; i < out.length; i++) out[i] = Number.parseInt(value.slice(i * 2, i * 2 + 2), 16);43  return out;44}45async function loadRecord(root: Uint8Array): Promise<IdentityRecord | null> {46  if (cached) return cached;47  const row = (await chrome.storage.local.get(IDENTITY_RECORD_KEY))[IDENTITY_RECORD_KEY] as Record<string, unknown> | undefined;48  if (!row) return null;49  if (row.v !== 2 || typeof row.nonce !== "string" || typeof row.ciphertext !== "string" || typeof row.tag !== "string") {50    throw new Error("identity_migration_required");51  }52  let parsed: IdentityRecord;53  try {54    parsed = JSON.parse(await decrypt(root, row.nonce, row.ciphertext, row.tag, IDENTITY_INFO)) as IdentityRecord;55  } catch { throw new Error("identity_migration_required"); }56  if (57    parsed.v !== 2 ||58    !/^[0-9a-f]{64}$/i.test(parsed.x25519PrivateKeyHex) ||59    !/^[0-9a-f]{64}$/i.test(parsed.ed25519SeedHex)60  ) throw new Error("identity_migration_required");61  cached = parsed;62  return parsed;63}64async function ensureRecord(root: Uint8Array): Promise<IdentityRecord> {65  const existing = await loadRecord(root);66  if (existing) return existing;67  const x = new Uint8Array(32); const ed = new Uint8Array(32);68  crypto.getRandomValues(x); crypto.getRandomValues(ed);69  const record: IdentityRecord = { v: 2, deviceId: generateUuidV4(), x25519PrivateKeyHex: hex(x), ed25519SeedHex: hex(ed) };70  const sealed = await encrypt(root, JSON.stringify(record), IDENTITY_INFO);71  await chrome.storage.local.set({ [IDENTITY_RECORD_KEY]: { v: 2, ...sealed } });72  x.fill(0); ed.fill(0); cached = record;73  return record;74}7576export async function getOrCreateDeviceId(): Promise<string> {77  if (!isUnlocked()) throw new Error("vault_locked");78  const rootHex = getRootKeyHex(); if (!rootHex) throw new Error("vault_locked");79  return (await ensureRecord(bytes(rootHex))).deviceId;80}81export async function rotateDeviceId(): Promise<string> {82  if (!isUnlocked()) throw new Error("vault_locked");83  const rootHex = getRootKeyHex();84  if (!rootHex) throw new Error("vault_locked");85  const root = bytes(rootHex);86  const x = new Uint8Array(32);87  const ed = new Uint8Array(32);88  try {89    crypto.getRandomValues(x);90    crypto.getRandomValues(ed);91    const record: IdentityRecord = {92      v: 2,93      deviceId: generateUuidV4(),94      x25519PrivateKeyHex: hex(x),95      ed25519SeedHex: hex(ed),96    };97    const sealed = await encrypt(root, JSON.stringify(record), IDENTITY_INFO);98    await chrome.storage.local.set({ [IDENTITY_RECORD_KEY]: { v: 2, ...sealed } });99    cached = record;100    return record.deviceId;101  } finally {102    root.fill(0);103    x.fill(0);104    ed.fill(0);105  }106}107export async function getDeviceName(): Promise<string> {108  const result = await chrome.storage.sync.get(DEVICE_NAME_KEY);109  const stored = (result as Record<string, unknown>)[DEVICE_NAME_KEY];110  return typeof stored === "string" && stored.length > 0 ? stored : defaultDeviceName();111}112export async function setDeviceName(name: string): Promise<void> {113  const trimmed = name.trim();114  if (!trimmed) throw new Error("device name must not be empty");115  if (trimmed.length > 64) throw new Error("device name must be 64 chars or fewer");116  await chrome.storage.sync.set({ [DEVICE_NAME_KEY]: trimmed });117}118export async function buildLocalIdentity(masterKey: Uint8Array): Promise<LocalDeviceIdentity> {119  const record = await ensureRecord(masterKey);120  const x = bytes(record.x25519PrivateKeyHex);121  const kp = generateKeyPairFromSeed(x);122  const signingPrivateKey = await importSigningKey(bytes(record.ed25519SeedHex));123  const jwk = await crypto.subtle.exportKey("jwk", signingPrivateKey) as JsonWebKey;124  return { deviceId: record.deviceId, deviceName: await getDeviceName(), deviceType: "extension",125    longTermPublicKeyHex: pubkeyToHex(kp.publicKey), signingPublicKeyHex: base64ToHex(jwk.x ?? "") };126}127async function importSigningKey(seed: Uint8Array): Promise<CryptoKey> {128  const prefix = Uint8Array.of(0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20);129  const pkcs8 = new Uint8Array(prefix.length + 32);130  pkcs8.set(prefix);131  pkcs8.set(seed, prefix.length);132  return crypto.subtle.importKey("pkcs8", pkcs8 as Uint8Array<ArrayBuffer>, { name: "Ed25519" }, true, ["sign"]);133}134function base64ToHex(value: string): string {135  const raw = atob(value.replace(/-/g, "+").replace(/_/g, "/"));136  return Array.from(raw, (c) => c.charCodeAt(0).toString(16).padStart(2, "0")).join("");137}138export function deriveLocalSyncKeypair(_masterKey: Uint8Array, _deviceId: string) {139  throw new Error("identity_migration_required");140}141142function defaultDeviceName(): string {143  // Best-effort browser + platform string. Avoids userAgent parsing — the144  // user can rename via Settings.145  const ua = (typeof navigator !== "undefined" ? navigator.userAgent : "") || "";146  if (/Firefox/i.test(ua)) return "Firefox";147  if (/Edg\//i.test(ua)) return "Edge";148  if (/Chrome/i.test(ua)) return "Chrome";149  if (/Safari/i.test(ua)) return "Safari";150  return "Browser";151}152153function generateUuidV4(): string {154  // crypto.randomUUID is available in MV3 service workers and popup pages.155  // Falls back to manual assembly only if a runtime is missing it.156  if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {157    return crypto.randomUUID();158  }159  const bytes = new Uint8Array(16);160  crypto.getRandomValues(bytes as Uint8Array<ArrayBuffer>);161  bytes[6] = (bytes[6] & 0x0f) | 0x40;162  bytes[8] = (bytes[8] & 0x3f) | 0x80;163  const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");164  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;165}166167export async function getLocalPairingIdentity(): Promise<{168  identity: LocalIdentity;169  privateKey: Uint8Array;170  publicKey: Uint8Array;171  signingPrivateKey: CryptoKey;172}> {173  if (!isUnlocked()) throw new Error("vault_locked");174  const rootHex = getRootKeyHex(); if (!rootHex) throw new Error("vault_locked");175  const root = bytes(rootHex);176  try {177    const record = await ensureRecord(root);178    const privateKey = bytes(record.x25519PrivateKeyHex);179    const publicKey = generateKeyPairFromSeed(privateKey).publicKey;180    const signingPrivateKey = await importSigningKey(bytes(record.ed25519SeedHex));181    const base = await buildLocalIdentity(root);182    return { identity: { ...base, vaultId: await computeVaultId(root) }, privateKey, publicKey, signingPrivateKey };183  } finally { root.fill(0); }184}