chirp-identity.ts

6.0 KB · raw

1// Local-device identity helpers for the chirp-sync architecture.2//3// `deviceId` is a stable UUID v4 generated on first call and persisted in4// `chrome.storage.local`. It is deliberately device-local and must NOT roam:5// it is the only symmetry-breaker in the sync-identity HKDF (see PCH-01-004),6// so a shared id across profiles would collide derived sync keys. A first-read7// migration moves any legacy value out of `chrome.storage.sync`.8//9// `deviceName` defaults to a short, friendly browser+OS description and10// can be overridden by the user from Settings → Sync.11//12// The local sync identity itself is derived deterministically from the13// vault master key (`deriveSyncIdentityKeypair`); the function below is a14// thin coordinator that pulls the master key out of `vault.ts` and exposes15// only the public half to callers that don't need to sign.1617import {18  deriveSyncIdentityKeypair,19  deriveSyncSigningKeypair,20  pubkeyToHex,21} from "../shared/sync-identity";22import type { DeviceType } from "../shared/sync-types";2324const DEVICE_ID_KEY = "peach_chirp_device_id";25const DEVICE_NAME_KEY = "peach_chirp_device_name";2627export interface LocalChirpIdentity {28  deviceId: string;29  deviceName: string;30  deviceType: DeviceType;31  longTermPublicKeyHex: string;32  signingPublicKeyHex: string;33}3435export async function getOrCreateDeviceId(): Promise<string> {36  // PCH-01-004: deviceId is the ONLY symmetry-breaker in the sync-identity HKDF37  // (sync-identity.ts:54). It must NOT roam across profiles via chrome.storage.sync,38  // or two profiles on the same browser account sharing a vault derive identical39  // static + signing keys and collide in the device graph. Keep it device-local.40  const local = await chrome.storage.local.get(DEVICE_ID_KEY);41  const localId = (local as Record<string, unknown>)[DEVICE_ID_KEY];42  if (typeof localId === "string" && localId.length > 0) return localId;4344  // First-read migration: adopt a value previously stored in sync, then evict it45  // from sync so a sibling profile cannot re-roam it back. Idempotent — once the46  // id lives in local, this branch never runs again.47  const synced = await chrome.storage.sync.get(DEVICE_ID_KEY);48  const syncedId = (synced as Record<string, unknown>)[DEVICE_ID_KEY];49  if (typeof syncedId === "string" && syncedId.length > 0) {50    await chrome.storage.local.set({ [DEVICE_ID_KEY]: syncedId });51    await chrome.storage.sync.remove(DEVICE_ID_KEY);52    return syncedId;53  }5455  const fresh = generateUuidV4();56  await chrome.storage.local.set({ [DEVICE_ID_KEY]: fresh });57  return fresh;58}5960export async function getDeviceName(): Promise<string> {61  const result = await chrome.storage.sync.get(DEVICE_NAME_KEY);62  const stored = (result as Record<string, unknown>)[DEVICE_NAME_KEY];63  if (typeof stored === "string" && stored.length > 0) return stored;64  return defaultDeviceName();65}6667export async function setDeviceName(name: string): Promise<void> {68  const trimmed = name.trim();69  if (!trimmed) throw new Error("device name must not be empty");70  if (trimmed.length > 64) throw new Error("device name must be 64 chars or fewer");71  await chrome.storage.sync.set({ [DEVICE_NAME_KEY]: trimmed });72}7374/**75 * Build the public-facing identity record for the local extension. The76 * private key half (used only for ongoing Noise XX sessions) is not77 * exposed by this function — Phase-1 pairing uses ephemeral keys only.78 */79export async function buildLocalIdentity(80  masterKey: Uint8Array,81): Promise<LocalChirpIdentity> {82  const deviceId = await getOrCreateDeviceId();83  const deviceName = await getDeviceName();84  const kp = deriveSyncIdentityKeypair(masterKey, deviceId);85  const signing = await deriveSyncSigningKeypair(masterKey, deviceId);86  return {87    deviceId,88    deviceName,89    deviceType: extensionDeviceType(),90    longTermPublicKeyHex: pubkeyToHex(kp.publicKey),91    signingPublicKeyHex: pubkeyToHex(signing.publicKeyRaw),92  };93}9495/**96 * Return the local sync identity keypair (public and private). Reserved97 * for the Phase-2 WebSocket sync coordinator that runs in the background.98 * Never surface the private key over a message handler.99 */100export function deriveLocalSyncKeypair(masterKey: Uint8Array, deviceId: string) {101  return deriveSyncIdentityKeypair(masterKey, deviceId);102}103104function defaultDeviceName(): string {105  // Best-effort browser + platform string. Avoids userAgent parsing — the106  // user can rename via Settings.107  const ua = (typeof navigator !== "undefined" ? navigator.userAgent : "") || "";108  if (/Firefox/i.test(ua)) return "Firefox";109  if (/Edg\//i.test(ua)) return "Edge";110  if (/Chrome/i.test(ua)) return "Chrome";111  if (/Safari/i.test(ua)) return "Safari";112  return "Browser";113}114115function extensionDeviceType(): DeviceType {116  // The same module ships in both the Chrome extension and the Capacitor117  // mobile build. On Capacitor, Peach acts as an iOS / Android sync host118  // rather than an extension; the `isNativePlatform` check toggles the tag.119  try {120    const native = (globalThis as unknown as { Capacitor?: { isNativePlatform?: () => boolean } }).Capacitor;121    if (native?.isNativePlatform?.()) {122      const platform = (globalThis as unknown as { Capacitor?: { getPlatform?: () => string } }).Capacitor?.getPlatform?.();123      if (platform === "ios") return "ios";124      return "android";125    }126  } catch { /* not on Capacitor */ }127  return "extension";128}129130function generateUuidV4(): string {131  // crypto.randomUUID is available in MV3 service workers, popup pages,132  // and modern Capacitor WebView. Falls back to a manual assembly only if133  // a runtime is missing it (effectively unreachable for our targets).134  if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {135    return crypto.randomUUID();136  }137  const bytes = new Uint8Array(16);138  crypto.getRandomValues(bytes as Uint8Array<ArrayBuffer>);139  bytes[6] = (bytes[6] & 0x0f) | 0x40;140  bytes[8] = (bytes[8] & 0x3f) | 0x80;141  const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");142  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;143}