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 if (await hasLegacyTrustState(browserGenerationIo)) return false;233 try {234 235 236 237 await rotateDeviceId();238 return true;239 } catch {240 return false;241 }242 },243 deviceId: getOrCreateDeviceId,244});245246247248249250251async function completeLegacyRecord(legacy: {252 deviceId: string;253 x25519PrivateKeyHex: string;254 ed25519SeedHex: string;255}): Promise<IdentityRecord> {256 return {257 v: IDENTITY_RECORD_VERSION,258 deviceId: legacy.deviceId,259 x25519PrivateKeyHex: legacy.x25519PrivateKeyHex,260 ed25519SeedHex: legacy.ed25519SeedHex,261 ...(await derivePublicHex(legacy)),262 createdAt: Date.now(),263 };264}265266267268269270271async function migrateLegacyIdentity(): Promise<IdentityRecord | null> {272 const stored = (await chrome.storage.local.get(LEGACY_IDENTITY_KEY))[LEGACY_IDENTITY_KEY] as273 | Record<string, string>274 | undefined;275 if (!stored) return null;276 const rootHex = getRootKeyHex();277 if (!rootHex) throw new Error("identity_migration_required");278 const root = bytes(rootHex);279 try {280 let parsed: unknown;281 try {282 parsed = JSON.parse(await decrypt(root, stored.nonce, stored.ciphertext, stored.tag, LEGACY_INFO));283 } catch {284 throw new Error("identity_migration_required");285 }286 const legacy = parseLegacyRandomRecord(parsed);287 const record = (await parseCurrentRecord(parsed)) ?? (legacy ? await completeLegacyRecord(legacy) : null);288 if (!record) throw new Error("identity_migration_required");289 const ik = crypto.getRandomValues(new Uint8Array(32));290 try {291 await persist(record, ik, await sealIdentityKeyUnderVault(ik));292 } finally {293 ik.fill(0);294 }295 await chrome.storage.local.remove(LEGACY_IDENTITY_KEY);296 await cacheSet(record);297 return record;298 } finally {299 root.fill(0);300 }301}302303304async function readRecordFromEnvelope(stored: IdentityEnvelope, ik: Uint8Array): Promise<IdentityRecord> {305 let parsed: unknown;306 try {307 parsed = JSON.parse(await decrypt(ik, stored.blob.nonce, stored.blob.ciphertext, stored.blob.tag, BLOB_INFO));308 } catch {309 throw new Error("identity_migration_required");310 }311 const record = await parseCurrentRecord(parsed);312 if (!record) throw new Error("identity_migration_required");313 return record;314}315316async function readEnvelope(): Promise<IdentityEnvelope | null> {317 const stored = (await chrome.storage.local.get(IDENTITY_RECORD_KEY))[IDENTITY_RECORD_KEY] as318 | IdentityEnvelope319 | undefined;320 if (!stored) return null;321 if (stored.v !== 4 || !stored.blob || !stored.ik) throw new Error("identity_migration_required");322 return stored;323}324325async function loadRecord(password?: string): Promise<IdentityRecord | null> {326 const stored = await readEnvelope();327 if (!stored) return migrateLegacyIdentity();328329 const cached = await cacheGet();330 if (cached) return cached;331332 const ik = await openIdentityKey(stored.ik, password);333 if (!ik) throw new Error("identity_migration_required");334 try {335 const record = await readRecordFromEnvelope(stored, ik);336 await cacheSet(record);337 return record;338 } finally {339 ik.fill(0);340 }341}342343344345346347348349350async function rewrapIdentity(): Promise<void> {351 const stored = await readEnvelope();352 if (!stored || !isUnlocked() || getRootKeyHex() === null) return;353 const ik = await openIdentityKey(stored.ik);354 if (!ik) return;355 try {356 const record = await readRecordFromEnvelope(stored, ik);357 await persist(record, ik, await sealIdentityKeyUnderVault(ik));358 await cacheSet(record);359 } finally {360 ik.fill(0);361 }362}363364365366367368369370async function promotePreVaultIdentity(password: string): Promise<void> {371 const stored = await readEnvelope();372 if (!stored?.ik.pending || !stored.ik.pendingSalt) return;373 const ik = await openIdentityKey(374 { pending: stored.ik.pending, pendingSalt: stored.ik.pendingSalt },375 password,376 );377 if (!ik) return;378 try {379 const record = await readRecordFromEnvelope(stored, ik);380 await persist(record, ik, await sealIdentityKeyUnderVault(ik));381 await cacheSet(record);382 } finally {383 ik.fill(0);384 }385}386387async function ensureRecord(): Promise<IdentityRecord> {388 const existing = await loadRecord();389 if (existing) return existing;390 const cached = await cacheGet();391 if (cached) return cached;392 393 394 395 if ((await chrome.storage.local.get(IDENTITY_MARKER_KEY))[IDENTITY_MARKER_KEY]) {396 throw new Error("identity_migration_required");397 }398 if (await hasLegacyTrustState(browserGenerationIo)) {399 await enterResetRequired(browserGenerationIo);400 throw new Error("identity_migration_required");401 }402 const record = await makeRecord();403 const ik = crypto.getRandomValues(new Uint8Array(32));404 try {405 await persist(record, ik, await sealIdentityKeyUnderVault(ik));406 await cacheSet(record);407 return record;408 } finally {409 ik.fill(0);410 }411}412413414415416417418419export async function preparePreVaultIdentity(password: string): Promise<void> {420 const stored = await chrome.storage.local.get(IDENTITY_RECORD_KEY);421 if (stored[IDENTITY_RECORD_KEY]) return;422 const record = await makeRecord();423 const ik = crypto.getRandomValues(new Uint8Array(32));424 try {425 await persist(record, ik, await sealIdentityKeyPreVault(password, ik));426 await cacheSet(record);427 } finally {428 ik.fill(0);429 }430}431432433export async function readPreVaultIdentity(password: string): Promise<LocalDeviceIdentity> {434 const record = await loadRecord(password);435 if (!record) throw new Error("identity_unavailable");436 return {437 deviceId: record.deviceId,438 deviceName: await getDeviceName(),439 deviceType: "extension",440 longTermPublicKeyHex: record.x25519PublicKeyHex,441 signingPublicKeyHex: record.ed25519PublicKeyHex,442 };443}444445export async function rotatePreVaultIdentity(password: string): Promise<LocalDeviceIdentity> {446 const record = await makeRecord();447 const ik = crypto.getRandomValues(new Uint8Array(32));448 try {449 await persist(record, ik, await sealIdentityKeyPreVault(password, ik));450 await cacheSet(record);451 return await readPreVaultIdentity(password);452 } finally {453 ik.fill(0);454 }455}456457export async function getOrCreateDeviceId(): Promise<string> {458 if (!isUnlocked() || getRootKeyHex() === null) throw new Error("vault_locked");459 return (await ensureRecord()).deviceId;460}461462export async function rotateDeviceId(): Promise<string> {463 if (!isUnlocked() || getRootKeyHex() === null) throw new Error("vault_locked");464 const record = await makeRecord();465 const ik = crypto.getRandomValues(new Uint8Array(32));466 try {467 await persist(record, ik, await sealIdentityKeyUnderVault(ik));468 await cacheSet(record);469 return record.deviceId;470 } finally {471 ik.fill(0);472 }473}474475export async function getDeviceName(): Promise<string> {476 const result = await chrome.storage.sync.get(DEVICE_NAME_KEY);477 const stored = (result as Record<string, unknown>)[DEVICE_NAME_KEY];478 return typeof stored === "string" && stored.length > 0 ? stored : defaultDeviceName();479}480481export async function setDeviceName(name: string): Promise<void> {482 const trimmed = name.trim();483 if (!trimmed) throw new Error("device name must not be empty");484 if (trimmed.length > 64) throw new Error("device name must be 64 chars or fewer");485 await chrome.storage.sync.set({ [DEVICE_NAME_KEY]: trimmed });486}487488export async function buildLocalIdentity(): Promise<LocalDeviceIdentity> {489 const record = await ensureRecord();490 return {491 deviceId: record.deviceId,492 deviceName: await getDeviceName(),493 deviceType: "extension",494 longTermPublicKeyHex: record.x25519PublicKeyHex,495 signingPublicKeyHex: record.ed25519PublicKeyHex,496 };497}498499export async function getLocalPairingIdentity(): Promise<{500 identity: LocalIdentity;501 privateKey: Uint8Array;502 publicKey: Uint8Array;503 signingPrivateKey: CryptoKey;504}> {505 if (!isUnlocked()) throw new Error("vault_locked");506 const rootHex = getRootKeyHex();507 if (!rootHex) throw new Error("vault_locked");508 const root = bytes(rootHex);509 try {510 const record = await ensureRecord();511 const privateKey = bytes(record.x25519PrivateKeyHex);512 const publicKey = generateKeyPairFromSeed(privateKey).publicKey;513 const signingPrivateKey = await importSigningKey(bytes(record.ed25519SeedHex));514 const base = await buildLocalIdentity();515 return { identity: { ...base, vaultId: await computeVaultId(root) }, privateKey, publicKey, signingPrivateKey };516 } finally {517 root.fill(0);518 }519}520521function defaultDeviceName(): string {522 523 524 const ua = (typeof navigator !== "undefined" ? navigator.userAgent : "") || "";525 if (/Firefox/i.test(ua)) return "Firefox";526 if (/Edg\//i.test(ua)) return "Edge";527 if (/Chrome/i.test(ua)) return "Chrome";528 if (/Safari/i.test(ua)) return "Safari";529 return "Browser";530}531532function generateUuidV4(): string {533 534 535 if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {536 return crypto.randomUUID();537 }538 const b = crypto.getRandomValues(new Uint8Array(16));539 b[6] = (b[6] & 0x0f) | 0x40;540 b[8] = (b[8] & 0x3f) | 0x80;541 const h = hex(b);542 return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;543}