// Local-device identity helpers for the sync architecture. // // `deviceId` is a stable UUID v4 generated on first call and persisted in // `chrome.storage.local`. It is deliberately device-local and must NOT roam: // it is the only symmetry-breaker in the sync-identity HKDF (see PCH-01-004), // so a shared id across profiles would collide derived sync keys. // // `deviceName` defaults to a short, friendly browser+OS description and // can be overridden by the user from Settings → Sync. // // The local sync identity itself is derived deterministically from the // vault master key (`deriveSyncIdentityKeypair`); the function below is a // thin coordinator that pulls the master key out of `vault.ts` and exposes // only the public half to callers that don't need to sign. import { computeVaultId, deriveSyncIdentityKeypair, deriveSyncSigningKeypair, pubkeyToHex, } from "../shared/sync-identity"; import type { LocalIdentity } from "../shared/pairing-handshake"; import type { DeviceType } from "../shared/sync-types"; import { getRootKeyHex, isUnlocked } from "./vault"; const DEVICE_ID_KEY = "peach_device_id"; const DEVICE_NAME_KEY = "peach_device_name"; export interface LocalDeviceIdentity { deviceId: string; deviceName: string; deviceType: DeviceType; longTermPublicKeyHex: string; signingPublicKeyHex: string; } export async function getOrCreateDeviceId(): Promise { // PCH-01-004: deviceId is the ONLY symmetry-breaker in the sync-identity HKDF // (sync-identity.ts:54). It must NOT roam across profiles via chrome.storage.sync, // or two profiles on the same browser account sharing a vault derive identical // static + signing keys and collide in the device graph. Keep it device-local. const local = await chrome.storage.local.get(DEVICE_ID_KEY); const localId = (local as Record)[DEVICE_ID_KEY]; if (typeof localId === "string" && localId.length > 0) return localId; const fresh = generateUuidV4(); await chrome.storage.local.set({ [DEVICE_ID_KEY]: fresh }); return fresh; } /** Replace a copied profile identity after an authenticated peer detects a collision. */ export async function rotateDeviceId(): Promise { const fresh = generateUuidV4(); await chrome.storage.local.set({ [DEVICE_ID_KEY]: fresh }); return fresh; } export async function getDeviceName(): Promise { const result = await chrome.storage.sync.get(DEVICE_NAME_KEY); const stored = (result as Record)[DEVICE_NAME_KEY]; if (typeof stored === "string" && stored.length > 0) return stored; return defaultDeviceName(); } export async function setDeviceName(name: string): Promise { const trimmed = name.trim(); if (!trimmed) throw new Error("device name must not be empty"); if (trimmed.length > 64) throw new Error("device name must be 64 chars or fewer"); await chrome.storage.sync.set({ [DEVICE_NAME_KEY]: trimmed }); } /** * Build the public-facing identity record for the local extension. The * private key half (used only for ongoing Noise KK sessions) is not * exposed by this function — Phase-1 pairing uses ephemeral keys only. */ export async function buildLocalIdentity( masterKey: Uint8Array, ): Promise { const deviceId = await getOrCreateDeviceId(); const deviceName = await getDeviceName(); const kp = deriveSyncIdentityKeypair(masterKey, deviceId); const signing = await deriveSyncSigningKeypair(masterKey, deviceId); return { deviceId, deviceName, deviceType: "extension", longTermPublicKeyHex: pubkeyToHex(kp.publicKey), signingPublicKeyHex: pubkeyToHex(signing.publicKeyRaw), }; } /** * Return the local sync identity keypair (public and private). Reserved * for the Phase-2 WebSocket sync coordinator that runs in the background. * Never surface the private key over a message handler. */ export function deriveLocalSyncKeypair(masterKey: Uint8Array, deviceId: string) { return deriveSyncIdentityKeypair(masterKey, deviceId); } function defaultDeviceName(): string { // Best-effort browser + platform string. Avoids userAgent parsing — the // user can rename via Settings. const ua = (typeof navigator !== "undefined" ? navigator.userAgent : "") || ""; if (/Firefox/i.test(ua)) return "Firefox"; if (/Edg\//i.test(ua)) return "Edge"; if (/Chrome/i.test(ua)) return "Chrome"; if (/Safari/i.test(ua)) return "Safari"; return "Browser"; } function generateUuidV4(): string { // crypto.randomUUID is available in MV3 service workers and popup pages. // Falls back to manual assembly only if a runtime is missing it. if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { return crypto.randomUUID(); } const bytes = new Uint8Array(16); crypto.getRandomValues(bytes as Uint8Array); bytes[6] = (bytes[6] & 0x0f) | 0x40; bytes[8] = (bytes[8] & 0x3f) | 0x80; const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; } /** * The full local pairing identity plus its sync keypair, derived from the * stable ROOT key so pairing survives a master-password change. Callers own * the returned key material and must zero it. * * Lived in `code-pairing-bg.ts` until LAN code pairing was retired * (docs/sync-lan-retirement.md); it was never LAN-specific. */ export async function getLocalPairingIdentity(): Promise<{ identity: LocalIdentity; privateKey: Uint8Array; publicKey: Uint8Array; }> { if (!isUnlocked()) throw new Error("vault_locked"); const masterKeyHex = getRootKeyHex(); if (!masterKeyHex) throw new Error("vault_locked"); const masterKey = new Uint8Array(masterKeyHex.length / 2); for (let i = 0; i < masterKey.length; i++) { masterKey[i] = parseInt(masterKeyHex.slice(i * 2, i * 2 + 2), 16); } try { const base = await buildLocalIdentity(masterKey); const vaultId = await computeVaultId(masterKey); const keys = deriveSyncIdentityKeypair(masterKey, base.deviceId); return { identity: { ...base, vaultId }, privateKey: keys.secretKey, publicKey: keys.publicKey, }; } finally { masterKey.fill(0); } }