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";3536const DEVICE_NAME_KEY = "peach_device_name";37const IDENTITY_RECORD_KEY = "peach_local_identity_v4";38const IDENTITY_MARKER_KEY = "peach_local_identity_initialized";39const LEGACY_IDENTITY_KEY = "peach_local_identity_v2";40const LEGACY_INFO = "peach-local-identity-v2";41const BLOB_INFO = "peach-local-identity-blob-v1";42const SESSION_CACHE_KEY = "peach_local_identity_session";43const HEX_64 = /^[0-9a-f]{64}$/i;444546const IDENTITY_RECORD_VERSION = 3;4748interface IdentityRecord {49 v: typeof IDENTITY_RECORD_VERSION;50 deviceId: string;51 x25519PrivateKeyHex: string;52 x25519PublicKeyHex: string;53 ed25519SeedHex: string;54 ed25519PublicKeyHex: string;55 createdAt: number;56}5758interface IdentityEnvelope {59 v: 4;60 blob: SealedBox;61 ik: IdentityKeyEnvelope;62}6364export interface LocalDeviceIdentity {65 deviceId: string;66 deviceName: string;67 deviceType: DeviceType;68 longTermPublicKeyHex: string;69 signingPublicKeyHex: string;70}7172function hex(value: Uint8Array): string {73 return Array.from(value, (b) => b.toString(16).padStart(2, "0")).join("");74}7576function bytes(value: string): Uint8Array {77 if (!HEX_64.test(value)) throw new Error("identity_migration_required");78 const out = new Uint8Array(32);79 for (let i = 0; i < out.length; i++) out[i] = Number.parseInt(value.slice(i * 2, i * 2 + 2), 16);80 return out;81}8283function base64ToHex(value: string): string {84 const raw = atob(value.replace(/-/g, "+").replace(/_/g, "/"));85 return Array.from(raw, (c) => c.charCodeAt(0).toString(16).padStart(2, "0")).join("");86}8788async function importSigningKey(seed: Uint8Array): Promise<CryptoKey> {89 const prefix = Uint8Array.of(0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20);90 const pkcs8 = new Uint8Array(prefix.length + 32);91 pkcs8.set(prefix);92 pkcs8.set(seed, prefix.length);93 try {94 return await crypto.subtle.importKey("pkcs8", pkcs8 as Uint8Array<ArrayBuffer>, { name: "Ed25519" }, true, ["sign"]);95 } finally {96 pkcs8.fill(0);97 }98}99100101async function derivePublicHex(privateHex: {102 x25519PrivateKeyHex: string;103 ed25519SeedHex: string;104}): Promise<{ x25519PublicKeyHex: string; ed25519PublicKeyHex: string }> {105 const kp = generateKeyPairFromSeed(bytes(privateHex.x25519PrivateKeyHex));106 const x25519PublicKeyHex = pubkeyToHex(kp.publicKey);107 kp.secretKey.fill(0);108 const signingPrivateKey = await importSigningKey(bytes(privateHex.ed25519SeedHex));109 const jwk = (await crypto.subtle.exportKey("jwk", signingPrivateKey)) as JsonWebKey;110 return { x25519PublicKeyHex, ed25519PublicKeyHex: base64ToHex(jwk.x ?? "") };111}112113function parseLegacyRandomRecord(value: unknown): {114 deviceId: string;115 x25519PrivateKeyHex: string;116 ed25519SeedHex: string;117} | null {118 if (typeof value !== "object" || value === null) return null;119 const row = value as Record<string, unknown>;120 if (row.v !== 2) return null;121 if (typeof row.deviceId !== "string" || row.deviceId.length === 0) return null;122 if (typeof row.x25519PrivateKeyHex !== "string" || !HEX_64.test(row.x25519PrivateKeyHex)) return null;123 if (typeof row.ed25519SeedHex !== "string" || !HEX_64.test(row.ed25519SeedHex)) return null;124 return {125 deviceId: row.deviceId,126 x25519PrivateKeyHex: row.x25519PrivateKeyHex,127 ed25519SeedHex: row.ed25519SeedHex,128 };129}130131132133134135136async function parseCurrentRecord(value: unknown): Promise<IdentityRecord | null> {137 if (typeof value !== "object" || value === null) return null;138 const row = value as Record<string, unknown>;139 if (row.v !== IDENTITY_RECORD_VERSION) return null;140 if (typeof row.deviceId !== "string" || row.deviceId.length === 0) return null;141 if (typeof row.x25519PrivateKeyHex !== "string" || !HEX_64.test(row.x25519PrivateKeyHex)) return null;142 if (typeof row.x25519PublicKeyHex !== "string" || !HEX_64.test(row.x25519PublicKeyHex)) return null;143 if (typeof row.ed25519SeedHex !== "string" || !HEX_64.test(row.ed25519SeedHex)) return null;144 if (typeof row.ed25519PublicKeyHex !== "string" || !HEX_64.test(row.ed25519PublicKeyHex)) return null;145 if (typeof row.createdAt !== "number" || !Number.isFinite(row.createdAt) || row.createdAt <= 0) return null;146 const derived = await derivePublicHex({147 x25519PrivateKeyHex: row.x25519PrivateKeyHex,148 ed25519SeedHex: row.ed25519SeedHex,149 });150 if (derived.x25519PublicKeyHex !== row.x25519PublicKeyHex.toLowerCase()) return null;151 if (derived.ed25519PublicKeyHex !== row.ed25519PublicKeyHex.toLowerCase()) return null;152 return {153 v: IDENTITY_RECORD_VERSION,154 deviceId: row.deviceId,155 x25519PrivateKeyHex: row.x25519PrivateKeyHex,156 x25519PublicKeyHex: row.x25519PublicKeyHex,157 ed25519SeedHex: row.ed25519SeedHex,158 ed25519PublicKeyHex: row.ed25519PublicKeyHex,159 createdAt: row.createdAt,160 };161}162163async function makeRecord(): Promise<IdentityRecord> {164 const x = crypto.getRandomValues(new Uint8Array(32));165 const ed = crypto.getRandomValues(new Uint8Array(32));166 const privateHex = { x25519PrivateKeyHex: hex(x), ed25519SeedHex: hex(ed) };167 x.fill(0);168 ed.fill(0);169 return {170 v: IDENTITY_RECORD_VERSION,171 deviceId: generateUuidV4(),172 ...privateHex,173 ...(await derivePublicHex(privateHex)),174 createdAt: Date.now(),175 };176}177178async function persist(179 record: IdentityRecord,180 ik: Uint8Array,181 envelope: IdentityKeyEnvelope,182): Promise<void> {183 const blob = await encrypt(ik, JSON.stringify(record), BLOB_INFO);184 const stored: IdentityEnvelope = { v: 4, blob, ik: envelope };185 186 187 188 await chrome.storage.local.set({ [IDENTITY_RECORD_KEY]: stored, [IDENTITY_MARKER_KEY]: true });189}190191192193194195async function cacheGet(): Promise<IdentityRecord | null> {196 if (!isUnlocked() || isDecoyMode()) return null;197 const session = chrome.storage.session;198 if (!session) return null;199 const stored = await session.get(SESSION_CACHE_KEY);200 return (stored[SESSION_CACHE_KEY] as IdentityRecord | undefined) ?? null;201}202203async function cacheSet(record: IdentityRecord): Promise<void> {204 await chrome.storage.session?.set({ [SESSION_CACHE_KEY]: record });205}206207async function clearCache(): Promise<void> {208 await chrome.storage.session?.remove(SESSION_CACHE_KEY);209}210211setIdentityHooks({212 onSessionEnded: clearCache,213 onKeysRotated: rewrapIdentity,214 onVaultCreated: promotePreVaultIdentity,215});216217218219220221222async function completeLegacyRecord(legacy: {223 deviceId: string;224 x25519PrivateKeyHex: string;225 ed25519SeedHex: string;226}): Promise<IdentityRecord> {227 return {228 v: IDENTITY_RECORD_VERSION,229 deviceId: legacy.deviceId,230 x25519PrivateKeyHex: legacy.x25519PrivateKeyHex,231 ed25519SeedHex: legacy.ed25519SeedHex,232 ...(await derivePublicHex(legacy)),233 createdAt: Date.now(),234 };235}236237238239240241242async function migrateLegacyIdentity(): Promise<IdentityRecord | null> {243 const stored = (await chrome.storage.local.get(LEGACY_IDENTITY_KEY))[LEGACY_IDENTITY_KEY] as244 | Record<string, string>245 | undefined;246 if (!stored) return null;247 const rootHex = getRootKeyHex();248 if (!rootHex) throw new Error("identity_migration_required");249 const root = bytes(rootHex);250 try {251 let parsed: unknown;252 try {253 parsed = JSON.parse(await decrypt(root, stored.nonce, stored.ciphertext, stored.tag, LEGACY_INFO));254 } catch {255 throw new Error("identity_migration_required");256 }257 const legacy = parseLegacyRandomRecord(parsed);258 const record = (await parseCurrentRecord(parsed)) ?? (legacy ? await completeLegacyRecord(legacy) : null);259 if (!record) throw new Error("identity_migration_required");260 const ik = crypto.getRandomValues(new Uint8Array(32));261 try {262 await persist(record, ik, await sealIdentityKeyUnderVault(ik));263 } finally {264 ik.fill(0);265 }266 await chrome.storage.local.remove(LEGACY_IDENTITY_KEY);267 await cacheSet(record);268 return record;269 } finally {270 root.fill(0);271 }272}273274275async function readRecordFromEnvelope(stored: IdentityEnvelope, ik: Uint8Array): Promise<IdentityRecord> {276 let parsed: unknown;277 try {278 parsed = JSON.parse(await decrypt(ik, stored.blob.nonce, stored.blob.ciphertext, stored.blob.tag, BLOB_INFO));279 } catch {280 throw new Error("identity_migration_required");281 }282 const record = await parseCurrentRecord(parsed);283 if (!record) throw new Error("identity_migration_required");284 return record;285}286287async function readEnvelope(): Promise<IdentityEnvelope | null> {288 const stored = (await chrome.storage.local.get(IDENTITY_RECORD_KEY))[IDENTITY_RECORD_KEY] as289 | IdentityEnvelope290 | undefined;291 if (!stored) return null;292 if (stored.v !== 4 || !stored.blob || !stored.ik) throw new Error("identity_migration_required");293 return stored;294}295296async function loadRecord(password?: string): Promise<IdentityRecord | null> {297 const stored = await readEnvelope();298 if (!stored) return migrateLegacyIdentity();299300 const cached = await cacheGet();301 if (cached) return cached;302303 const ik = await openIdentityKey(stored.ik, password);304 if (!ik) throw new Error("identity_migration_required");305 try {306 const record = await readRecordFromEnvelope(stored, ik);307 await cacheSet(record);308 return record;309 } finally {310 ik.fill(0);311 }312}313314315316317318319320321async function rewrapIdentity(): Promise<void> {322 const stored = await readEnvelope();323 if (!stored || !isUnlocked() || getRootKeyHex() === null) return;324 const ik = await openIdentityKey(stored.ik);325 if (!ik) return;326 try {327 const record = await readRecordFromEnvelope(stored, ik);328 await persist(record, ik, await sealIdentityKeyUnderVault(ik));329 await cacheSet(record);330 } finally {331 ik.fill(0);332 }333}334335336337338339340341async function promotePreVaultIdentity(password: string): Promise<void> {342 const stored = await readEnvelope();343 if (!stored?.ik.pending || !stored.ik.pendingSalt) return;344 const ik = await openIdentityKey(345 { pending: stored.ik.pending, pendingSalt: stored.ik.pendingSalt },346 password,347 );348 if (!ik) return;349 try {350 const record = await readRecordFromEnvelope(stored, ik);351 await persist(record, ik, await sealIdentityKeyUnderVault(ik));352 await cacheSet(record);353 } finally {354 ik.fill(0);355 }356}357358async function ensureRecord(): Promise<IdentityRecord> {359 const existing = await loadRecord();360 if (existing) return existing;361 const cached = await cacheGet();362 if (cached) return cached;363 364 365 366 if ((await chrome.storage.local.get(IDENTITY_MARKER_KEY))[IDENTITY_MARKER_KEY]) {367 throw new Error("identity_migration_required");368 }369 const record = await makeRecord();370 const ik = crypto.getRandomValues(new Uint8Array(32));371 try {372 await persist(record, ik, await sealIdentityKeyUnderVault(ik));373 await cacheSet(record);374 return record;375 } finally {376 ik.fill(0);377 }378}379380381382383384385386export async function preparePreVaultIdentity(password: string): Promise<void> {387 const stored = await chrome.storage.local.get(IDENTITY_RECORD_KEY);388 if (stored[IDENTITY_RECORD_KEY]) return;389 const record = await makeRecord();390 const ik = crypto.getRandomValues(new Uint8Array(32));391 try {392 await persist(record, ik, await sealIdentityKeyPreVault(password, ik));393 await cacheSet(record);394 } finally {395 ik.fill(0);396 }397}398399400export async function readPreVaultIdentity(password: string): Promise<LocalDeviceIdentity> {401 const record = await loadRecord(password);402 if (!record) throw new Error("identity_unavailable");403 return {404 deviceId: record.deviceId,405 deviceName: await getDeviceName(),406 deviceType: "extension",407 longTermPublicKeyHex: record.x25519PublicKeyHex,408 signingPublicKeyHex: record.ed25519PublicKeyHex,409 };410}411412export async function rotatePreVaultIdentity(password: string): Promise<LocalDeviceIdentity> {413 const record = await makeRecord();414 const ik = crypto.getRandomValues(new Uint8Array(32));415 try {416 await persist(record, ik, await sealIdentityKeyPreVault(password, ik));417 await cacheSet(record);418 return await readPreVaultIdentity(password);419 } finally {420 ik.fill(0);421 }422}423424export async function getOrCreateDeviceId(): Promise<string> {425 if (!isUnlocked() || getRootKeyHex() === null) throw new Error("vault_locked");426 return (await ensureRecord()).deviceId;427}428429export async function rotateDeviceId(): Promise<string> {430 if (!isUnlocked() || getRootKeyHex() === null) throw new Error("vault_locked");431 const record = await makeRecord();432 const ik = crypto.getRandomValues(new Uint8Array(32));433 try {434 await persist(record, ik, await sealIdentityKeyUnderVault(ik));435 await cacheSet(record);436 return record.deviceId;437 } finally {438 ik.fill(0);439 }440}441442export async function getDeviceName(): Promise<string> {443 const result = await chrome.storage.sync.get(DEVICE_NAME_KEY);444 const stored = (result as Record<string, unknown>)[DEVICE_NAME_KEY];445 return typeof stored === "string" && stored.length > 0 ? stored : defaultDeviceName();446}447448export async function setDeviceName(name: string): Promise<void> {449 const trimmed = name.trim();450 if (!trimmed) throw new Error("device name must not be empty");451 if (trimmed.length > 64) throw new Error("device name must be 64 chars or fewer");452 await chrome.storage.sync.set({ [DEVICE_NAME_KEY]: trimmed });453}454455export async function buildLocalIdentity(): Promise<LocalDeviceIdentity> {456 const record = await ensureRecord();457 return {458 deviceId: record.deviceId,459 deviceName: await getDeviceName(),460 deviceType: "extension",461 longTermPublicKeyHex: record.x25519PublicKeyHex,462 signingPublicKeyHex: record.ed25519PublicKeyHex,463 };464}465466export async function getLocalPairingIdentity(): Promise<{467 identity: LocalIdentity;468 privateKey: Uint8Array;469 publicKey: Uint8Array;470 signingPrivateKey: CryptoKey;471}> {472 if (!isUnlocked()) throw new Error("vault_locked");473 const rootHex = getRootKeyHex();474 if (!rootHex) throw new Error("vault_locked");475 const root = bytes(rootHex);476 try {477 const record = await ensureRecord();478 const privateKey = bytes(record.x25519PrivateKeyHex);479 const publicKey = generateKeyPairFromSeed(privateKey).publicKey;480 const signingPrivateKey = await importSigningKey(bytes(record.ed25519SeedHex));481 const base = await buildLocalIdentity();482 return { identity: { ...base, vaultId: await computeVaultId(root) }, privateKey, publicKey, signingPrivateKey };483 } finally {484 root.fill(0);485 }486}487488function defaultDeviceName(): string {489 490 491 const ua = (typeof navigator !== "undefined" ? navigator.userAgent : "") || "";492 if (/Firefox/i.test(ua)) return "Firefox";493 if (/Edg\//i.test(ua)) return "Edge";494 if (/Chrome/i.test(ua)) return "Chrome";495 if (/Safari/i.test(ua)) return "Safari";496 return "Browser";497}498499function generateUuidV4(): string {500 501 502 if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {503 return crypto.randomUUID();504 }505 const b = crypto.getRandomValues(new Uint8Array(16));506 b[6] = (b[6] & 0x0f) | 0x40;507 b[8] = (b[8] & 0x3f) | 0x80;508 const h = hex(b);509 return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;510}