12345678910111213141516171819import { generateKeyPairFromSeed } from "@stablelib/x25519";20import { computeVaultId, pubkeyToHex } from "../shared/sync-identity";21import type { LocalIdentity } from "../shared/pairing-handshake";22import type { DeviceType } from "../shared/sync-types";23import { decrypt, encrypt } from "./aes";24import {25 getRootKeyHex,26 isDecoyMode,27 isUnlocked,28 openIdentityKey,29 sealIdentityKeyPreVault,30 sealIdentityKeyUnderVault,31 setIdentityHooks,32 type IdentityKeyEnvelope,33 type SealedBox,34} from "./vault";35import {36 browserGenerationIo,37 enterResetRequired,38 hasLegacyTrustState,39 setGenerationIdentity,40} from "./sync-generation";4142const DEVICE_NAME_KEY = "peach_device_name";43const IDENTITY_RECORD_KEY = "peach_local_identity_v4";44const IDENTITY_MARKER_KEY = "peach_local_identity_initialized";45const LEGACY_IDENTITY_KEY = "peach_local_identity_v2";46const LEGACY_INFO = "peach-local-identity-v2";47const BLOB_INFO = "peach-local-identity-blob-v1";48const SESSION_CACHE_KEY = "peach_local_identity_session";49const HEX_64 = /^[0-9a-f]{64}$/i;505152const IDENTITY_RECORD_VERSION = 3;5354interface IdentityRecord {55 v: typeof IDENTITY_RECORD_VERSION;56 deviceId: string;57 x25519PrivateKeyHex: string;58 x25519PublicKeyHex: string;59 ed25519SeedHex: string;60 ed25519PublicKeyHex: string;61 createdAt: number;62}6364interface IdentityEnvelope {65 v: 4;66 blob: SealedBox;67 ik: IdentityKeyEnvelope;68}6970export interface LocalDeviceIdentity {71 deviceId: string;72 deviceName: string;73 deviceType: DeviceType;74 longTermPublicKeyHex: string;75 signingPublicKeyHex: string;76}7778function hex(value: Uint8Array): string {79 return Array.from(value, (b) => b.toString(16).padStart(2, "0")).join("");80}8182function bytes(value: string): Uint8Array {83 if (!HEX_64.test(value)) throw new Error("identity_migration_required");84 const out = new Uint8Array(32);85 for (let i = 0; i < out.length; i++) out[i] = Number.parseInt(value.slice(i * 2, i * 2 + 2), 16);86 return out;87}8889function base64ToHex(value: string): string {90 const raw = atob(value.replace(/-/g, "+").replace(/_/g, "/"));91 return Array.from(raw, (c) => c.charCodeAt(0).toString(16).padStart(2, "0")).join("");92}9394async function importSigningKey(seed: Uint8Array): Promise<CryptoKey> {95 const prefix = Uint8Array.of(0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20);96 const pkcs8 = new Uint8Array(prefix.length + 32);97 pkcs8.set(prefix);98 pkcs8.set(seed, prefix.length);99 try {100 return await crypto.subtle.importKey("pkcs8", pkcs8 as Uint8Array<ArrayBuffer>, { name: "Ed25519" }, true, ["sign"]);101 } finally {102 pkcs8.fill(0);103 }104}105106107async function derivePublicHex(privateHex: {108 x25519PrivateKeyHex: string;109 ed25519SeedHex: string;110}): Promise<{ x25519PublicKeyHex: string; ed25519PublicKeyHex: string }> {111 const kp = generateKeyPairFromSeed(bytes(privateHex.x25519PrivateKeyHex));112 const x25519PublicKeyHex = pubkeyToHex(kp.publicKey);113 kp.secretKey.fill(0);114 const signingPrivateKey = await importSigningKey(bytes(privateHex.ed25519SeedHex));115 const jwk = (await crypto.subtle.exportKey("jwk", signingPrivateKey)) as JsonWebKey;116 return { x25519PublicKeyHex, ed25519PublicKeyHex: base64ToHex(jwk.x ?? "") };117}118119function parseLegacyRandomRecord(value: unknown): {120 deviceId: string;121 x25519PrivateKeyHex: string;122 ed25519SeedHex: string;123} | null {124 if (typeof value !== "object" || value === null) return null;125 const row = value as Record<string, unknown>;126 if (row.v !== 2) return null;127 if (typeof row.deviceId !== "string" || row.deviceId.length === 0) return null;128 if (typeof row.x25519PrivateKeyHex !== "string" || !HEX_64.test(row.x25519PrivateKeyHex)) return null;129 if (typeof row.ed25519SeedHex !== "string" || !HEX_64.test(row.ed25519SeedHex)) return null;130 return {131 deviceId: row.deviceId,132 x25519PrivateKeyHex: row.x25519PrivateKeyHex,133 ed25519SeedHex: row.ed25519SeedHex,134 };135}136137138139140141142async function parseCurrentRecord(value: unknown): Promise<IdentityRecord | null> {143 if (typeof value !== "object" || value === null) return null;144 const row = value as Record<string, unknown>;145 if (row.v !== IDENTITY_RECORD_VERSION) return null;146 if (typeof row.deviceId !== "string" || row.deviceId.length === 0) return null;147 if (typeof row.x25519PrivateKeyHex !== "string" || !HEX_64.test(row.x25519PrivateKeyHex)) return null;148 if (typeof row.x25519PublicKeyHex !== "string" || !HEX_64.test(row.x25519PublicKeyHex)) return null;149 if (typeof row.ed25519SeedHex !== "string" || !HEX_64.test(row.ed25519SeedHex)) return null;150 if (typeof row.ed25519PublicKeyHex !== "string" || !HEX_64.test(row.ed25519PublicKeyHex)) return null;151 if (typeof row.createdAt !== "number" || !Number.isFinite(row.createdAt) || row.createdAt <= 0) return null;152 const derived = await derivePublicHex({153 x25519PrivateKeyHex: row.x25519PrivateKeyHex,154 ed25519SeedHex: row.ed25519SeedHex,155 });156 if (derived.x25519PublicKeyHex !== row.x25519PublicKeyHex.toLowerCase()) return null;157 if (derived.ed25519PublicKeyHex !== row.ed25519PublicKeyHex.toLowerCase()) return null;158 return {159 v: IDENTITY_RECORD_VERSION,160 deviceId: row.deviceId,161 x25519PrivateKeyHex: row.x25519PrivateKeyHex,162 x25519PublicKeyHex: row.x25519PublicKeyHex,163 ed25519SeedHex: row.ed25519SeedHex,164 ed25519PublicKeyHex: row.ed25519PublicKeyHex,165 createdAt: row.createdAt,166 };167}168169async function makeRecord(): Promise<IdentityRecord> {170 const x = crypto.getRandomValues(new Uint8Array(32));171 const ed = crypto.getRandomValues(new Uint8Array(32));172 const privateHex = { x25519PrivateKeyHex: hex(x), ed25519SeedHex: hex(ed) };173 x.fill(0);174 ed.fill(0);175 return {176 v: IDENTITY_RECORD_VERSION,177 deviceId: generateUuidV4(),178 ...privateHex,179 ...(await derivePublicHex(privateHex)),180 createdAt: Date.now(),181 };182}183184async function persist(185 record: IdentityRecord,186 ik: Uint8Array,187 envelope: IdentityKeyEnvelope,188): Promise<void> {189 const blob = await encrypt(ik, JSON.stringify(record), BLOB_INFO);190 const stored: IdentityEnvelope = { v: 4, blob, ik: envelope };191 192 193 194 await chrome.storage.local.set({ [IDENTITY_RECORD_KEY]: stored, [IDENTITY_MARKER_KEY]: true });195}196197198199200201async function cacheGet(): Promise<IdentityRecord | null> {202 if (!isUnlocked() || isDecoyMode()) return null;203 const session = chrome.storage.session;204 if (!session) return null;205 const stored = await session.get(SESSION_CACHE_KEY);206 return (stored[SESSION_CACHE_KEY] as IdentityRecord | undefined) ?? null;207}208209async function cacheSet(record: IdentityRecord): Promise<void> {210 await chrome.storage.session?.set({ [SESSION_CACHE_KEY]: record });211}212213async function clearCache(): Promise<void> {214 await chrome.storage.session?.remove(SESSION_CACHE_KEY);215}216217setIdentityHooks({218 onSessionEnded: clearCache,219 onKeysRotated: rewrapIdentity,220 onVaultCreated: promotePreVaultIdentity,221});222223224225226227228229230setGenerationIdentity({231 async ensureIdentity() {232 try {233 await buildLocalIdentity();234 return true;235 } catch {236 if (await hasLegacyTrustState(browserGenerationIo)) return false;237 try {238 await rotateDeviceId();239 return true;240 } catch {241 return false;242 }243 }244 },245 deviceId: getOrCreateDeviceId,246});247248249250251252253async function completeLegacyRecord(legacy: {254 deviceId: string;255 x25519PrivateKeyHex: string;256 ed25519SeedHex: string;257}): Promise<IdentityRecord> {258 return {259 v: IDENTITY_RECORD_VERSION,260 deviceId: legacy.deviceId,261 x25519PrivateKeyHex: legacy.x25519PrivateKeyHex,262 ed25519SeedHex: legacy.ed25519SeedHex,263 ...(await derivePublicHex(legacy)),264 createdAt: Date.now(),265 };266}267268269270271272273async function migrateLegacyIdentity(): Promise<IdentityRecord | null> {274 const stored = (await chrome.storage.local.get(LEGACY_IDENTITY_KEY))[LEGACY_IDENTITY_KEY] as275 | Record<string, string>276 | undefined;277 if (!stored) return null;278 const rootHex = getRootKeyHex();279 if (!rootHex) throw new Error("identity_migration_required");280 const root = bytes(rootHex);281 try {282 let parsed: unknown;283 try {284 parsed = JSON.parse(await decrypt(root, stored.nonce, stored.ciphertext, stored.tag, LEGACY_INFO));285 } catch {286 throw new Error("identity_migration_required");287 }288 const legacy = parseLegacyRandomRecord(parsed);289 const record = (await parseCurrentRecord(parsed)) ?? (legacy ? await completeLegacyRecord(legacy) : null);290 if (!record) throw new Error("identity_migration_required");291 const ik = crypto.getRandomValues(new Uint8Array(32));292 try {293 await persist(record, ik, await sealIdentityKeyUnderVault(ik));294 } finally {295 ik.fill(0);296 }297 await chrome.storage.local.remove(LEGACY_IDENTITY_KEY);298 await cacheSet(record);299 return record;300 } finally {301 root.fill(0);302 }303}304305306async function readRecordFromEnvelope(stored: IdentityEnvelope, ik: Uint8Array): Promise<IdentityRecord> {307 let parsed: unknown;308 try {309 parsed = JSON.parse(await decrypt(ik, stored.blob.nonce, stored.blob.ciphertext, stored.blob.tag, BLOB_INFO));310 } catch {311 throw new Error("identity_migration_required");312 }313 const record = await parseCurrentRecord(parsed);314 if (!record) throw new Error("identity_migration_required");315 return record;316}317318async function readEnvelope(): Promise<IdentityEnvelope | null> {319 const stored = (await chrome.storage.local.get(IDENTITY_RECORD_KEY))[IDENTITY_RECORD_KEY] as320 | IdentityEnvelope321 | undefined;322 if (!stored) return null;323 if (stored.v !== 4 || !stored.blob || !stored.ik) throw new Error("identity_migration_required");324 return stored;325}326327async function loadRecord(password?: string): Promise<IdentityRecord | null> {328 const stored = await readEnvelope();329 if (!stored) return migrateLegacyIdentity();330331 const cached = await cacheGet();332 if (cached) return cached;333334 const ik = await openIdentityKey(stored.ik, password);335 if (!ik) throw new Error("identity_migration_required");336 try {337 const record = await readRecordFromEnvelope(stored, ik);338 await cacheSet(record);339 return record;340 } finally {341 ik.fill(0);342 }343}344345346347348349350351352async function rewrapIdentity(): Promise<void> {353 const stored = await readEnvelope();354 if (!stored || !isUnlocked() || getRootKeyHex() === null) return;355 const ik = await openIdentityKey(stored.ik);356 if (!ik) return;357 try {358 const record = await readRecordFromEnvelope(stored, ik);359 await persist(record, ik, await sealIdentityKeyUnderVault(ik));360 await cacheSet(record);361 } finally {362 ik.fill(0);363 }364}365366367368369370371372async function promotePreVaultIdentity(password: string): Promise<void> {373 const stored = await readEnvelope();374 if (!stored?.ik.pending || !stored.ik.pendingSalt) return;375 const ik = await openIdentityKey(376 { pending: stored.ik.pending, pendingSalt: stored.ik.pendingSalt },377 password,378 );379 if (!ik) return;380 try {381 const record = await readRecordFromEnvelope(stored, ik);382 await persist(record, ik, await sealIdentityKeyUnderVault(ik));383 await cacheSet(record);384 } finally {385 ik.fill(0);386 }387}388389async function ensureRecord(): Promise<IdentityRecord> {390 const existing = await loadRecord();391 if (existing) return existing;392 const cached = await cacheGet();393 if (cached) return cached;394 395 396 397 if ((await chrome.storage.local.get(IDENTITY_MARKER_KEY))[IDENTITY_MARKER_KEY]) {398 throw new Error("identity_migration_required");399 }400 if (await hasLegacyTrustState(browserGenerationIo)) {401 await enterResetRequired(browserGenerationIo);402 throw new Error("identity_migration_required");403 }404 const record = await makeRecord();405 const ik = crypto.getRandomValues(new Uint8Array(32));406 try {407 await persist(record, ik, await sealIdentityKeyUnderVault(ik));408 await cacheSet(record);409 return record;410 } finally {411 ik.fill(0);412 }413}414415416417418419420421export async function preparePreVaultIdentity(password: string): Promise<void> {422 const stored = await chrome.storage.local.get(IDENTITY_RECORD_KEY);423 if (stored[IDENTITY_RECORD_KEY]) return;424 const record = await makeRecord();425 const ik = crypto.getRandomValues(new Uint8Array(32));426 try {427 await persist(record, ik, await sealIdentityKeyPreVault(password, ik));428 await cacheSet(record);429 } finally {430 ik.fill(0);431 }432}433434435export async function readPreVaultIdentity(password: string): Promise<LocalDeviceIdentity> {436 const record = await loadRecord(password);437 if (!record) throw new Error("identity_unavailable");438 return {439 deviceId: record.deviceId,440 deviceName: await getDeviceName(),441 deviceType: "extension",442 longTermPublicKeyHex: record.x25519PublicKeyHex,443 signingPublicKeyHex: record.ed25519PublicKeyHex,444 };445}446447export async function rotatePreVaultIdentity(password: string): Promise<LocalDeviceIdentity> {448 const record = await makeRecord();449 const ik = crypto.getRandomValues(new Uint8Array(32));450 try {451 await persist(record, ik, await sealIdentityKeyPreVault(password, ik));452 await cacheSet(record);453 return await readPreVaultIdentity(password);454 } finally {455 ik.fill(0);456 }457}458459export async function getOrCreateDeviceId(): Promise<string> {460 if (!isUnlocked() || getRootKeyHex() === null) throw new Error("vault_locked");461 return (await ensureRecord()).deviceId;462}463464export async function rotateDeviceId(): Promise<string> {465 if (!isUnlocked() || getRootKeyHex() === null) throw new Error("vault_locked");466 const record = await makeRecord();467 const ik = crypto.getRandomValues(new Uint8Array(32));468 try {469 await persist(record, ik, await sealIdentityKeyUnderVault(ik));470 await cacheSet(record);471 return record.deviceId;472 } finally {473 ik.fill(0);474 }475}476477export async function getDeviceName(): Promise<string> {478 const result = await chrome.storage.sync.get(DEVICE_NAME_KEY);479 const stored = (result as Record<string, unknown>)[DEVICE_NAME_KEY];480 return typeof stored === "string" && stored.length > 0 ? stored : defaultDeviceName();481}482483export async function setDeviceName(name: string): Promise<void> {484 const trimmed = name.trim();485 if (!trimmed) throw new Error("device name must not be empty");486 if (trimmed.length > 64) throw new Error("device name must be 64 chars or fewer");487 await chrome.storage.sync.set({ [DEVICE_NAME_KEY]: trimmed });488}489490export async function buildLocalIdentity(): Promise<LocalDeviceIdentity> {491 const record = await ensureRecord();492 return {493 deviceId: record.deviceId,494 deviceName: await getDeviceName(),495 deviceType: "extension",496 longTermPublicKeyHex: record.x25519PublicKeyHex,497 signingPublicKeyHex: record.ed25519PublicKeyHex,498 };499}500501export async function getLocalPairingIdentity(): Promise<{502 identity: LocalIdentity;503 privateKey: Uint8Array;504 publicKey: Uint8Array;505 signingPrivateKey: CryptoKey;506}> {507 if (!isUnlocked()) throw new Error("vault_locked");508 const rootHex = getRootKeyHex();509 if (!rootHex) throw new Error("vault_locked");510 const root = bytes(rootHex);511 try {512 const record = await ensureRecord();513 const privateKey = bytes(record.x25519PrivateKeyHex);514 const publicKey = generateKeyPairFromSeed(privateKey).publicKey;515 const signingPrivateKey = await importSigningKey(bytes(record.ed25519SeedHex));516 const base = await buildLocalIdentity();517 return { identity: { ...base, vaultId: await computeVaultId(root) }, privateKey, publicKey, signingPrivateKey };518 } finally {519 root.fill(0);520 }521}522523function defaultDeviceName(): string {524 525 526 const ua = (typeof navigator !== "undefined" ? navigator.userAgent : "") || "";527 if (/Firefox/i.test(ua)) return "Firefox";528 if (/Edg\//i.test(ua)) return "Edge";529 if (/Chrome/i.test(ua)) return "Chrome";530 if (/Safari/i.test(ua)) return "Safari";531 return "Browser";532}533534function generateUuidV4(): string {535 536 537 if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {538 return crypto.randomUUID();539 }540 const b = crypto.getRandomValues(new Uint8Array(16));541 b[6] = (b[6] & 0x0f) | 0x40;542 b[8] = (b[8] & 0x3f) | 0x80;543 const h = hex(b);544 return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;545}