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 try {211 await chrome.storage.session?.set({ [SESSION_CACHE_KEY]: record });212 } catch {213 214 215 }216}217218async function clearCache(): Promise<void> {219 await chrome.storage.session?.remove(SESSION_CACHE_KEY);220}221222setIdentityHooks({223 onSessionEnded: clearCache,224 onKeysRotated: rewrapIdentity,225 onVaultCreated: promotePreVaultIdentity,226});227228229230231232233234235setGenerationIdentity({236 async ensureIdentity() {237 if (await hasLegacyTrustState(browserGenerationIo)) return false;238 try {239 240 241 242 await rotateDeviceId();243 return true;244 } catch {245 return false;246 }247 },248 deviceId: getOrCreateDeviceId,249});250251252253254255256async function completeLegacyRecord(legacy: {257 deviceId: string;258 x25519PrivateKeyHex: string;259 ed25519SeedHex: string;260}): Promise<IdentityRecord> {261 return {262 v: IDENTITY_RECORD_VERSION,263 deviceId: legacy.deviceId,264 x25519PrivateKeyHex: legacy.x25519PrivateKeyHex,265 ed25519SeedHex: legacy.ed25519SeedHex,266 ...(await derivePublicHex(legacy)),267 createdAt: Date.now(),268 };269}270271272273274275276async function migrateLegacyIdentity(): Promise<IdentityRecord | null> {277 const stored = (await chrome.storage.local.get(LEGACY_IDENTITY_KEY))[LEGACY_IDENTITY_KEY] as278 | Record<string, string>279 | undefined;280 if (!stored) return null;281 const rootHex = getRootKeyHex();282 if (!rootHex) throw new Error("identity_migration_required");283 const root = bytes(rootHex);284 try {285 let parsed: unknown;286 try {287 parsed = JSON.parse(await decrypt(root, stored.nonce, stored.ciphertext, stored.tag, LEGACY_INFO));288 } catch {289 throw new Error("identity_migration_required");290 }291 const legacy = parseLegacyRandomRecord(parsed);292 const record = (await parseCurrentRecord(parsed)) ?? (legacy ? await completeLegacyRecord(legacy) : null);293 if (!record) throw new Error("identity_migration_required");294 const ik = crypto.getRandomValues(new Uint8Array(32));295 try {296 await persist(record, ik, await sealIdentityKeyUnderVault(ik));297 } finally {298 ik.fill(0);299 }300 await chrome.storage.local.remove(LEGACY_IDENTITY_KEY);301 await cacheSet(record);302 return record;303 } finally {304 root.fill(0);305 }306}307308309async function readRecordFromEnvelope(stored: IdentityEnvelope, ik: Uint8Array): Promise<IdentityRecord> {310 let parsed: unknown;311 try {312 parsed = JSON.parse(await decrypt(ik, stored.blob.nonce, stored.blob.ciphertext, stored.blob.tag, BLOB_INFO));313 } catch {314 throw new Error("identity_migration_required");315 }316 const record = await parseCurrentRecord(parsed);317 if (!record) throw new Error("identity_migration_required");318 return record;319}320321async function readEnvelope(): Promise<IdentityEnvelope | null> {322 const stored = (await chrome.storage.local.get(IDENTITY_RECORD_KEY))[IDENTITY_RECORD_KEY] as323 | IdentityEnvelope324 | undefined;325 if (!stored) return null;326 if (stored.v !== 4 || !stored.blob || !stored.ik) throw new Error("identity_migration_required");327 return stored;328}329330async function loadRecord(password?: string): Promise<IdentityRecord | null> {331 const stored = await readEnvelope();332 if (!stored) return migrateLegacyIdentity();333334 const cached = await cacheGet();335 if (cached) return cached;336337 const ik = await openIdentityKey(stored.ik, password);338 if (!ik) throw new Error("identity_migration_required");339 try {340 const record = await readRecordFromEnvelope(stored, ik);341 await cacheSet(record);342 return record;343 } finally {344 ik.fill(0);345 }346}347348349350351352353354355async function rewrapIdentity(): Promise<void> {356 const stored = await readEnvelope();357 if (!stored || !isUnlocked() || getRootKeyHex() === null) return;358 const ik = await openIdentityKey(stored.ik);359 if (!ik) return;360 try {361 const record = await readRecordFromEnvelope(stored, ik);362 await persist(record, ik, await sealIdentityKeyUnderVault(ik));363 await cacheSet(record);364 } finally {365 ik.fill(0);366 }367}368369370371372373374375async function promotePreVaultIdentity(password: string): Promise<void> {376 const stored = await readEnvelope();377 if (!stored?.ik.pending || !stored.ik.pendingSalt) return;378 const ik = await openIdentityKey(379 { pending: stored.ik.pending, pendingSalt: stored.ik.pendingSalt },380 password,381 );382 if (!ik) return;383 try {384 const record = await readRecordFromEnvelope(stored, ik);385 await persist(record, ik, await sealIdentityKeyUnderVault(ik));386 await cacheSet(record);387 } finally {388 ik.fill(0);389 }390}391392async function ensureRecord(): Promise<IdentityRecord> {393 const existing = await loadRecord();394 if (existing) return existing;395 const cached = await cacheGet();396 if (cached) return cached;397 398 399 400 if ((await chrome.storage.local.get(IDENTITY_MARKER_KEY))[IDENTITY_MARKER_KEY]) {401 throw new Error("identity_migration_required");402 }403 if (await hasLegacyTrustState(browserGenerationIo)) {404 await enterResetRequired(browserGenerationIo);405 throw new Error("identity_migration_required");406 }407 const record = await makeRecord();408 const ik = crypto.getRandomValues(new Uint8Array(32));409 try {410 await persist(record, ik, await sealIdentityKeyUnderVault(ik));411 await cacheSet(record);412 return record;413 } finally {414 ik.fill(0);415 }416}417418419420421422423424export async function preparePreVaultIdentity(password: string): Promise<void> {425 const stored = await chrome.storage.local.get(IDENTITY_RECORD_KEY);426 if (stored[IDENTITY_RECORD_KEY]) return;427 const record = await makeRecord();428 const ik = crypto.getRandomValues(new Uint8Array(32));429 try {430 await persist(record, ik, await sealIdentityKeyPreVault(password, ik));431 await cacheSet(record);432 } finally {433 ik.fill(0);434 }435}436437438export async function readPreVaultIdentity(password: string): Promise<LocalDeviceIdentity> {439 const record = await loadRecord(password);440 if (!record) throw new Error("identity_unavailable");441 return {442 deviceId: record.deviceId,443 deviceName: await getDeviceName(),444 deviceType: "extension",445 longTermPublicKeyHex: record.x25519PublicKeyHex,446 signingPublicKeyHex: record.ed25519PublicKeyHex,447 };448}449450export async function rotatePreVaultIdentity(password: string): Promise<LocalDeviceIdentity> {451 const record = await makeRecord();452 const ik = crypto.getRandomValues(new Uint8Array(32));453 try {454 await persist(record, ik, await sealIdentityKeyPreVault(password, ik));455 await cacheSet(record);456 return await readPreVaultIdentity(password);457 } finally {458 ik.fill(0);459 }460}461462export async function getOrCreateDeviceId(): Promise<string> {463 if (!isUnlocked() || getRootKeyHex() === null) throw new Error("vault_locked");464 return (await ensureRecord()).deviceId;465}466467export async function rotateDeviceId(): Promise<string> {468 if (!isUnlocked() || getRootKeyHex() === null) throw new Error("vault_locked");469 const record = await makeRecord();470 const ik = crypto.getRandomValues(new Uint8Array(32));471 try {472 await persist(record, ik, await sealIdentityKeyUnderVault(ik));473 await cacheSet(record);474 return record.deviceId;475 } finally {476 ik.fill(0);477 }478}479480export async function getDeviceName(): Promise<string> {481 const result = await chrome.storage.sync.get(DEVICE_NAME_KEY);482 const stored = (result as Record<string, unknown>)[DEVICE_NAME_KEY];483 return typeof stored === "string" && stored.length > 0 ? stored : defaultDeviceName();484}485486export async function setDeviceName(name: string): Promise<void> {487 const trimmed = name.trim();488 if (!trimmed) throw new Error("device name must not be empty");489 if (trimmed.length > 64) throw new Error("device name must be 64 chars or fewer");490 await chrome.storage.sync.set({ [DEVICE_NAME_KEY]: trimmed });491}492493export async function buildLocalIdentity(): Promise<LocalDeviceIdentity> {494 const record = await ensureRecord();495 return {496 deviceId: record.deviceId,497 deviceName: await getDeviceName(),498 deviceType: "extension",499 longTermPublicKeyHex: record.x25519PublicKeyHex,500 signingPublicKeyHex: record.ed25519PublicKeyHex,501 };502}503504export async function getLocalPairingIdentity(): Promise<{505 identity: LocalIdentity;506 privateKey: Uint8Array;507 publicKey: Uint8Array;508 signingPrivateKey: CryptoKey;509}> {510 if (!isUnlocked()) throw new Error("vault_locked");511 const rootHex = getRootKeyHex();512 if (!rootHex) throw new Error("vault_locked");513 const root = bytes(rootHex);514 try {515 const record = await ensureRecord();516 const privateKey = bytes(record.x25519PrivateKeyHex);517 const publicKey = generateKeyPairFromSeed(privateKey).publicKey;518 const signingPrivateKey = await importSigningKey(bytes(record.ed25519SeedHex));519 const base = await buildLocalIdentity();520 return { identity: { ...base, vaultId: await computeVaultId(root) }, privateKey, publicKey, signingPrivateKey };521 } finally {522 root.fill(0);523 }524}525526function defaultDeviceName(): string {527 528 529 const ua = (typeof navigator !== "undefined" ? navigator.userAgent : "") || "";530 if (/Firefox/i.test(ua)) return "Firefox";531 if (/Edg\//i.test(ua)) return "Edge";532 if (/Chrome/i.test(ua)) return "Chrome";533 if (/Safari/i.test(ua)) return "Safari";534 return "Browser";535}536537function generateUuidV4(): string {538 539 540 if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {541 return crypto.randomUUID();542 }543 const b = crypto.getRandomValues(new Uint8Array(16));544 b[6] = (b[6] & 0x0f) | 0x40;545 b[8] = (b[8] & 0x3f) | 0x80;546 const h = hex(b);547 return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;548}