chirp-identity.ts

4.9 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.sync` so it roams across browser-profile reinstalls.5//6// `deviceName` defaults to a short, friendly browser+OS description and7// can be overridden by the user from Settings → Sync.8//9// The local sync identity itself is derived deterministically from the10// vault master key (`deriveSyncIdentityKeypair`); the function below is a11// thin coordinator that pulls the master key out of `vault.ts` and exposes12// only the public half to callers that don't need to sign.1314import {15  deriveSyncIdentityKeypair,16  deriveSyncSigningKeypair,17  pubkeyToHex,18} from "../shared/sync-identity";19import type { DeviceType } from "../shared/sync-types";2021const DEVICE_ID_KEY = "peach_chirp_device_id";22const DEVICE_NAME_KEY = "peach_chirp_device_name";2324export interface LocalChirpIdentity {25  deviceId: string;26  deviceName: string;27  deviceType: DeviceType;28  longTermPublicKeyHex: string;29  signingPublicKeyHex: string;30}3132export async function getOrCreateDeviceId(): Promise<string> {33  const result = await chrome.storage.sync.get(DEVICE_ID_KEY);34  const current = (result as Record<string, unknown>)[DEVICE_ID_KEY];35  if (typeof current === "string" && current.length > 0) return current;36  const fresh = generateUuidV4();37  await chrome.storage.sync.set({ [DEVICE_ID_KEY]: fresh });38  return fresh;39}4041export async function getDeviceName(): Promise<string> {42  const result = await chrome.storage.sync.get(DEVICE_NAME_KEY);43  const stored = (result as Record<string, unknown>)[DEVICE_NAME_KEY];44  if (typeof stored === "string" && stored.length > 0) return stored;45  return defaultDeviceName();46}4748export async function setDeviceName(name: string): Promise<void> {49  const trimmed = name.trim();50  if (!trimmed) throw new Error("device name must not be empty");51  if (trimmed.length > 64) throw new Error("device name must be 64 chars or fewer");52  await chrome.storage.sync.set({ [DEVICE_NAME_KEY]: trimmed });53}5455/**56 * Build the public-facing identity record for the local extension. The57 * private key half (used only for ongoing Noise XX sessions) is not58 * exposed by this function — Phase-1 pairing uses ephemeral keys only.59 */60export async function buildLocalIdentity(61  masterKey: Uint8Array,62): Promise<LocalChirpIdentity> {63  const deviceId = await getOrCreateDeviceId();64  const deviceName = await getDeviceName();65  const kp = deriveSyncIdentityKeypair(masterKey, deviceId);66  const signing = await deriveSyncSigningKeypair(masterKey, deviceId);67  return {68    deviceId,69    deviceName,70    deviceType: extensionDeviceType(),71    longTermPublicKeyHex: pubkeyToHex(kp.publicKey),72    signingPublicKeyHex: pubkeyToHex(signing.publicKeyRaw),73  };74}7576/**77 * Return the local sync identity keypair (public and private). Reserved78 * for the Phase-2 WebSocket sync coordinator that runs in the background.79 * Never surface the private key over a message handler.80 */81export function deriveLocalSyncKeypair(masterKey: Uint8Array, deviceId: string) {82  return deriveSyncIdentityKeypair(masterKey, deviceId);83}8485function defaultDeviceName(): string {86  // Best-effort browser + platform string. Avoids userAgent parsing — the87  // user can rename via Settings.88  const ua = (typeof navigator !== "undefined" ? navigator.userAgent : "") || "";89  if (/Firefox/i.test(ua)) return "Firefox";90  if (/Edg\//i.test(ua)) return "Edge";91  if (/Chrome/i.test(ua)) return "Chrome";92  if (/Safari/i.test(ua)) return "Safari";93  return "Browser";94}9596function extensionDeviceType(): DeviceType {97  // The same module ships in both the Chrome extension and the Capacitor98  // mobile build. On Capacitor, Peach acts as an iOS / Android sync host99  // rather than an extension; the `isNativePlatform` check toggles the tag.100  try {101    const native = (globalThis as unknown as { Capacitor?: { isNativePlatform?: () => boolean } }).Capacitor;102    if (native?.isNativePlatform?.()) {103      const platform = (globalThis as unknown as { Capacitor?: { getPlatform?: () => string } }).Capacitor?.getPlatform?.();104      if (platform === "ios") return "ios";105      return "android";106    }107  } catch { /* not on Capacitor */ }108  return "extension";109}110111function generateUuidV4(): string {112  // crypto.randomUUID is available in MV3 service workers, popup pages,113  // and modern Capacitor WebView. Falls back to a manual assembly only if114  // a runtime is missing it (effectively unreachable for our targets).115  if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {116    return crypto.randomUUID();117  }118  const bytes = new Uint8Array(16);119  crypto.getRandomValues(bytes as Uint8Array<ArrayBuffer>);120  bytes[6] = (bytes[6] & 0x0f) | 0x40;121  bytes[8] = (bytes[8] & 0x3f) | 0x80;122  const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");123  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;124}