device-identity.ts

6.1 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.7//8// `deviceName` defaults to a short, friendly browser+OS description and9// can be overridden by the user from Settings → Sync.10//11// The local sync identity itself is derived deterministically from the12// vault master key (`deriveSyncIdentityKeypair`); the function below is a13// thin coordinator that pulls the master key out of `vault.ts` and exposes14// only the public half to callers that don't need to sign.1516import {17  computeVaultId,18  deriveSyncIdentityKeypair,19  deriveSyncSigningKeypair,20  pubkeyToHex,21} from "../shared/sync-identity";22import type { LocalIdentity } from "../shared/pairing-handshake";23import type { DeviceType } from "../shared/sync-types";24import { getRootKeyHex, isUnlocked } from "./vault";2526const DEVICE_ID_KEY = "peach_device_id";27const DEVICE_NAME_KEY = "peach_device_name";2829export interface LocalDeviceIdentity {30  deviceId: string;31  deviceName: string;32  deviceType: DeviceType;33  longTermPublicKeyHex: string;34  signingPublicKeyHex: string;35}3637export async function getOrCreateDeviceId(): Promise<string> {38  // PCH-01-004: deviceId is the ONLY symmetry-breaker in the sync-identity HKDF39  // (sync-identity.ts:54). It must NOT roam across profiles via chrome.storage.sync,40  // or two profiles on the same browser account sharing a vault derive identical41  // static + signing keys and collide in the device graph. Keep it device-local.42  const local = await chrome.storage.local.get(DEVICE_ID_KEY);43  const localId = (local as Record<string, unknown>)[DEVICE_ID_KEY];44  if (typeof localId === "string" && localId.length > 0) return localId;4546  const fresh = generateUuidV4();47  await chrome.storage.local.set({ [DEVICE_ID_KEY]: fresh });48  return fresh;49}5051/** Replace a copied profile identity after an authenticated peer detects a collision. */52export async function rotateDeviceId(): Promise<string> {53  const fresh = generateUuidV4();54  await chrome.storage.local.set({ [DEVICE_ID_KEY]: fresh });55  return fresh;56}5758export async function getDeviceName(): Promise<string> {59  const result = await chrome.storage.sync.get(DEVICE_NAME_KEY);60  const stored = (result as Record<string, unknown>)[DEVICE_NAME_KEY];61  if (typeof stored === "string" && stored.length > 0) return stored;62  return defaultDeviceName();63}6465export async function setDeviceName(name: string): Promise<void> {66  const trimmed = name.trim();67  if (!trimmed) throw new Error("device name must not be empty");68  if (trimmed.length > 64) throw new Error("device name must be 64 chars or fewer");69  await chrome.storage.sync.set({ [DEVICE_NAME_KEY]: trimmed });70}7172/**73 * Build the public-facing identity record for the local extension. The74 * private key half (used only for ongoing Noise KK sessions) is not75 * exposed by this function — Phase-1 pairing uses ephemeral keys only.76 */77export async function buildLocalIdentity(78  masterKey: Uint8Array,79): Promise<LocalDeviceIdentity> {80  const deviceId = await getOrCreateDeviceId();81  const deviceName = await getDeviceName();82  const kp = deriveSyncIdentityKeypair(masterKey, deviceId);83  const signing = await deriveSyncSigningKeypair(masterKey, deviceId);84  return {85    deviceId,86    deviceName,87    deviceType: "extension",88    longTermPublicKeyHex: pubkeyToHex(kp.publicKey),89    signingPublicKeyHex: pubkeyToHex(signing.publicKeyRaw),90  };91}9293/**94 * Return the local sync identity keypair (public and private). Reserved95 * for the Phase-2 WebSocket sync coordinator that runs in the background.96 * Never surface the private key over a message handler.97 */98export function deriveLocalSyncKeypair(masterKey: Uint8Array, deviceId: string) {99  return deriveSyncIdentityKeypair(masterKey, deviceId);100}101102function defaultDeviceName(): string {103  // Best-effort browser + platform string. Avoids userAgent parsing — the104  // user can rename via Settings.105  const ua = (typeof navigator !== "undefined" ? navigator.userAgent : "") || "";106  if (/Firefox/i.test(ua)) return "Firefox";107  if (/Edg\//i.test(ua)) return "Edge";108  if (/Chrome/i.test(ua)) return "Chrome";109  if (/Safari/i.test(ua)) return "Safari";110  return "Browser";111}112113function generateUuidV4(): string {114  // crypto.randomUUID is available in MV3 service workers and popup pages.115  // Falls back to manual assembly only if a runtime is missing it.116  if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {117    return crypto.randomUUID();118  }119  const bytes = new Uint8Array(16);120  crypto.getRandomValues(bytes as Uint8Array<ArrayBuffer>);121  bytes[6] = (bytes[6] & 0x0f) | 0x40;122  bytes[8] = (bytes[8] & 0x3f) | 0x80;123  const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");124  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;125}126127/**128 * The full local pairing identity plus its sync keypair, derived from the129 * stable ROOT key so pairing survives a master-password change. Callers own130 * the returned key material and must zero it.131 *132 * Lived in `code-pairing-bg.ts` until LAN code pairing was retired133 * (docs/sync-lan-retirement.md); it was never LAN-specific.134 */135export async function getLocalPairingIdentity(): Promise<{136  identity: LocalIdentity;137  privateKey: Uint8Array;138  publicKey: Uint8Array;139}> {140  if (!isUnlocked()) throw new Error("vault_locked");141  const masterKeyHex = getRootKeyHex();142  if (!masterKeyHex) throw new Error("vault_locked");143  const masterKey = new Uint8Array(masterKeyHex.length / 2);144  for (let i = 0; i < masterKey.length; i++) {145    masterKey[i] = parseInt(masterKeyHex.slice(i * 2, i * 2 + 2), 16);146  }147  try {148    const base = await buildLocalIdentity(masterKey);149    const vaultId = await computeVaultId(masterKey);150    const keys = deriveSyncIdentityKeypair(masterKey, base.deviceId);151    return {152      identity: { ...base, vaultId },153      privateKey: keys.secretKey,154      publicKey: keys.publicKey,155    };156  } finally {157    masterKey.fill(0);158  }159}