device-identity.ts

7.3 KB · raw

1// Local-device identity helpers for the 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  computeVaultId,19  deriveSyncIdentityKeypair,20  deriveSyncSigningKeypair,21  pubkeyToHex,22} from "../shared/sync-identity";23import type { LocalIdentity } from "../shared/pairing-handshake";24import type { DeviceType } from "../shared/sync-types";25import { getRootKeyHex, isUnlocked } from "./vault";2627const DEVICE_ID_KEY = "peach_device_id";28const DEVICE_NAME_KEY = "peach_device_name";2930export interface LocalDeviceIdentity {31  deviceId: string;32  deviceName: string;33  deviceType: DeviceType;34  longTermPublicKeyHex: string;35  signingPublicKeyHex: string;36}3738export async function getOrCreateDeviceId(): Promise<string> {39  // PCH-01-004: deviceId is the ONLY symmetry-breaker in the sync-identity HKDF40  // (sync-identity.ts:54). It must NOT roam across profiles via chrome.storage.sync,41  // or two profiles on the same browser account sharing a vault derive identical42  // static + signing keys and collide in the device graph. Keep it device-local.43  const local = await chrome.storage.local.get(DEVICE_ID_KEY);44  const localId = (local as Record<string, unknown>)[DEVICE_ID_KEY];45  if (typeof localId === "string" && localId.length > 0) return localId;4647  // First-read migration: adopt a value previously stored in sync, then evict it48  // from sync so a sibling profile cannot re-roam it back. Idempotent — once the49  // id lives in local, this branch never runs again.50  const synced = await chrome.storage.sync.get(DEVICE_ID_KEY);51  const syncedId = (synced as Record<string, unknown>)[DEVICE_ID_KEY];52  if (typeof syncedId === "string" && syncedId.length > 0) {53    await chrome.storage.local.set({ [DEVICE_ID_KEY]: syncedId });54    await chrome.storage.sync.remove(DEVICE_ID_KEY);55    return syncedId;56  }5758  const fresh = generateUuidV4();59  await chrome.storage.local.set({ [DEVICE_ID_KEY]: fresh });60  return fresh;61}6263export async function getDeviceName(): Promise<string> {64  const result = await chrome.storage.sync.get(DEVICE_NAME_KEY);65  const stored = (result as Record<string, unknown>)[DEVICE_NAME_KEY];66  if (typeof stored === "string" && stored.length > 0) return stored;67  return defaultDeviceName();68}6970export async function setDeviceName(name: string): Promise<void> {71  const trimmed = name.trim();72  if (!trimmed) throw new Error("device name must not be empty");73  if (trimmed.length > 64) throw new Error("device name must be 64 chars or fewer");74  await chrome.storage.sync.set({ [DEVICE_NAME_KEY]: trimmed });75}7677/**78 * Build the public-facing identity record for the local extension. The79 * private key half (used only for ongoing Noise KK sessions) is not80 * exposed by this function — Phase-1 pairing uses ephemeral keys only.81 */82export async function buildLocalIdentity(83  masterKey: Uint8Array,84): Promise<LocalDeviceIdentity> {85  const deviceId = await getOrCreateDeviceId();86  const deviceName = await getDeviceName();87  const kp = deriveSyncIdentityKeypair(masterKey, deviceId);88  const signing = await deriveSyncSigningKeypair(masterKey, deviceId);89  return {90    deviceId,91    deviceName,92    deviceType: extensionDeviceType(),93    longTermPublicKeyHex: pubkeyToHex(kp.publicKey),94    signingPublicKeyHex: pubkeyToHex(signing.publicKeyRaw),95  };96}9798/**99 * Return the local sync identity keypair (public and private). Reserved100 * for the Phase-2 WebSocket sync coordinator that runs in the background.101 * Never surface the private key over a message handler.102 */103export function deriveLocalSyncKeypair(masterKey: Uint8Array, deviceId: string) {104  return deriveSyncIdentityKeypair(masterKey, deviceId);105}106107function defaultDeviceName(): string {108  // Best-effort browser + platform string. Avoids userAgent parsing — the109  // user can rename via Settings.110  const ua = (typeof navigator !== "undefined" ? navigator.userAgent : "") || "";111  if (/Firefox/i.test(ua)) return "Firefox";112  if (/Edg\//i.test(ua)) return "Edge";113  if (/Chrome/i.test(ua)) return "Chrome";114  if (/Safari/i.test(ua)) return "Safari";115  return "Browser";116}117118function extensionDeviceType(): DeviceType {119  // The same module ships in both the Chrome extension and the Capacitor120  // mobile build. On Capacitor, Peach acts as an iOS / Android sync host121  // rather than an extension; the `isNativePlatform` check toggles the tag.122  try {123    const native = (globalThis as unknown as { Capacitor?: { isNativePlatform?: () => boolean } }).Capacitor;124    if (native?.isNativePlatform?.()) {125      const platform = (globalThis as unknown as { Capacitor?: { getPlatform?: () => string } }).Capacitor?.getPlatform?.();126      if (platform === "ios") return "ios";127      return "android";128    }129  } catch { /* not on Capacitor */ }130  return "extension";131}132133function generateUuidV4(): string {134  // crypto.randomUUID is available in MV3 service workers, popup pages,135  // and modern Capacitor WebView. Falls back to a manual assembly only if136  // a runtime is missing it (effectively unreachable for our targets).137  if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {138    return crypto.randomUUID();139  }140  const bytes = new Uint8Array(16);141  crypto.getRandomValues(bytes as Uint8Array<ArrayBuffer>);142  bytes[6] = (bytes[6] & 0x0f) | 0x40;143  bytes[8] = (bytes[8] & 0x3f) | 0x80;144  const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");145  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;146}147148/**149 * The full local pairing identity plus its sync keypair, derived from the150 * stable ROOT key so pairing survives a master-password change. Callers own151 * the returned key material and must zero it.152 *153 * Lived in `code-pairing-bg.ts` until LAN code pairing was retired154 * (docs/sync-lan-retirement.md); it was never LAN-specific.155 */156export async function getLocalPairingIdentity(): Promise<{157  identity: LocalIdentity;158  privateKey: Uint8Array;159  publicKey: Uint8Array;160}> {161  if (!isUnlocked()) throw new Error("vault_locked");162  const masterKeyHex = getRootKeyHex();163  if (!masterKeyHex) throw new Error("vault_locked");164  const masterKey = new Uint8Array(masterKeyHex.length / 2);165  for (let i = 0; i < masterKey.length; i++) {166    masterKey[i] = parseInt(masterKeyHex.slice(i * 2, i * 2 + 2), 16);167  }168  try {169    const base = await buildLocalIdentity(masterKey);170    const vaultId = await computeVaultId(masterKey);171    const keys = deriveSyncIdentityKeypair(masterKey, base.deviceId);172    return {173      identity: { ...base, vaultId },174      privateKey: keys.secretKey,175      publicKey: keys.publicKey,176    };177  } finally {178    masterKey.fill(0);179  }180}