12345import { generateKeyPairFromSeed } from "@stablelib/x25519";6import {7 computeVaultId,8 pubkeyToHex,9} from "../shared/sync-identity";10import type { LocalIdentity } from "../shared/pairing-handshake";11import type { DeviceType } from "../shared/sync-types";12import { decrypt, encrypt } from "./aes";13import { getRootKeyHex, isUnlocked } from "./vault";1415const DEVICE_NAME_KEY = "peach_device_name";16const IDENTITY_RECORD_KEY = "peach_local_identity_v2";17const IDENTITY_INFO = "peach-local-identity-v2";181920const IDENTITY_RECORD_VERSION = 3;2122const LEGACY_RANDOM_RECORD_VERSION = 2;23const HEX_64 = /^[0-9a-f]{64}$/i;2425interface IdentityRecord {26 v: typeof IDENTITY_RECORD_VERSION;27 deviceId: string;28 x25519PrivateKeyHex: string;29 x25519PublicKeyHex: string;30 ed25519SeedHex: string;31 ed25519PublicKeyHex: string;32 createdAt: number;33}3435let cached: IdentityRecord | null = null;3637export interface LocalDeviceIdentity {38 deviceId: string;39 deviceName: string;40 deviceType: DeviceType;41 longTermPublicKeyHex: string;42 signingPublicKeyHex: string;43}4445function hex(bytes: Uint8Array): string {46 return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");47}48function bytes(value: string): Uint8Array {49 if (!/^[0-9a-f]{64}$/i.test(value)) throw new Error("identity_migration_required");50 const out = new Uint8Array(32);51 for (let i = 0; i < out.length; i++) out[i] = Number.parseInt(value.slice(i * 2, i * 2 + 2), 16);52 return out;53}5455async function derivePublicHex(privateHex: {56 x25519PrivateKeyHex: string;57 ed25519SeedHex: string;58}): Promise<{ x25519PublicKeyHex: string; ed25519PublicKeyHex: string }> {59 const kp = generateKeyPairFromSeed(bytes(privateHex.x25519PrivateKeyHex));60 const signingPrivateKey = await importSigningKey(bytes(privateHex.ed25519SeedHex));61 const jwk = (await crypto.subtle.exportKey("jwk", signingPrivateKey)) as JsonWebKey;62 return {63 x25519PublicKeyHex: pubkeyToHex(kp.publicKey),64 ed25519PublicKeyHex: base64ToHex(jwk.x ?? ""),65 };66}67686970717273async function parseCurrentRecord(value: unknown): Promise<IdentityRecord | null> {74 if (typeof value !== "object" || value === null) return null;75 const row = value as Record<string, unknown>;76 if (row.v !== IDENTITY_RECORD_VERSION) return null;77 if (typeof row.deviceId !== "string" || row.deviceId.length === 0) return null;78 if (typeof row.x25519PrivateKeyHex !== "string" || !HEX_64.test(row.x25519PrivateKeyHex)) return null;79 if (typeof row.x25519PublicKeyHex !== "string" || !HEX_64.test(row.x25519PublicKeyHex)) return null;80 if (typeof row.ed25519SeedHex !== "string" || !HEX_64.test(row.ed25519SeedHex)) return null;81 if (typeof row.ed25519PublicKeyHex !== "string" || !HEX_64.test(row.ed25519PublicKeyHex)) return null;82 if (typeof row.createdAt !== "number" || !Number.isFinite(row.createdAt) || row.createdAt <= 0) return null;83 const derived = await derivePublicHex({84 x25519PrivateKeyHex: row.x25519PrivateKeyHex,85 ed25519SeedHex: row.ed25519SeedHex,86 });87 if (derived.x25519PublicKeyHex !== row.x25519PublicKeyHex.toLowerCase()) return null;88 if (derived.ed25519PublicKeyHex !== row.ed25519PublicKeyHex.toLowerCase()) return null;89 return {90 v: IDENTITY_RECORD_VERSION,91 deviceId: row.deviceId,92 x25519PrivateKeyHex: row.x25519PrivateKeyHex,93 x25519PublicKeyHex: row.x25519PublicKeyHex,94 ed25519SeedHex: row.ed25519SeedHex,95 ed25519PublicKeyHex: row.ed25519PublicKeyHex,96 createdAt: row.createdAt,97 };98}99100function parseLegacyRandomRecord(value: unknown): {101 deviceId: string;102 x25519PrivateKeyHex: string;103 ed25519SeedHex: string;104} | null {105 if (typeof value !== "object" || value === null) return null;106 const row = value as Record<string, unknown>;107 if (row.v !== LEGACY_RANDOM_RECORD_VERSION) return null;108 if (typeof row.deviceId !== "string" || row.deviceId.length === 0) return null;109 if (typeof row.x25519PrivateKeyHex !== "string" || !HEX_64.test(row.x25519PrivateKeyHex)) return null;110 if (typeof row.ed25519SeedHex !== "string" || !HEX_64.test(row.ed25519SeedHex)) return null;111 return {112 deviceId: row.deviceId,113 x25519PrivateKeyHex: row.x25519PrivateKeyHex,114 ed25519SeedHex: row.ed25519SeedHex,115 };116}117118async function persistRecord(root: Uint8Array, record: IdentityRecord): Promise<void> {119 const sealed = await encrypt(root, JSON.stringify(record), IDENTITY_INFO);120 await chrome.storage.local.set({121 [IDENTITY_RECORD_KEY]: { v: IDENTITY_RECORD_VERSION, ...sealed },122 });123}124125126127128129130async function completeLegacyRecord(131 root: Uint8Array,132 legacy: { deviceId: string; x25519PrivateKeyHex: string; ed25519SeedHex: string },133): Promise<IdentityRecord> {134 const derived = await derivePublicHex(legacy);135 const record: IdentityRecord = {136 v: IDENTITY_RECORD_VERSION,137 deviceId: legacy.deviceId,138 x25519PrivateKeyHex: legacy.x25519PrivateKeyHex,139 ed25519SeedHex: legacy.ed25519SeedHex,140 ...derived,141 createdAt: Date.now(),142 };143 await persistRecord(root, record);144 return record;145}146147async function loadRecord(root: Uint8Array): Promise<IdentityRecord | null> {148 if (cached) return cached;149 const row = (await chrome.storage.local.get(IDENTITY_RECORD_KEY))[IDENTITY_RECORD_KEY] as Record<string, unknown> | undefined;150 if (!row) return null;151 if (typeof row.v !== "number" || typeof row.nonce !== "string" || typeof row.ciphertext !== "string" || typeof row.tag !== "string") {152 throw new Error("identity_migration_required");153 }154 let parsed: unknown;155 try {156 parsed = JSON.parse(await decrypt(root, row.nonce, row.ciphertext, row.tag, IDENTITY_INFO));157 } catch { throw new Error("identity_migration_required"); }158 const current = await parseCurrentRecord(parsed);159 if (current) {160 cached = current;161 return current;162 }163 const legacy = parseLegacyRandomRecord(parsed);164 if (legacy) {165 const upgraded = await completeLegacyRecord(root, legacy);166 cached = upgraded;167 return upgraded;168 }169 throw new Error("identity_migration_required");170}171172async function ensureRecord(root: Uint8Array): Promise<IdentityRecord> {173 const existing = await loadRecord(root);174 if (existing) return existing;175 const x = new Uint8Array(32);176 const ed = new Uint8Array(32);177 crypto.getRandomValues(x);178 crypto.getRandomValues(ed);179 const privateHex = { x25519PrivateKeyHex: hex(x), ed25519SeedHex: hex(ed) };180 x.fill(0);181 ed.fill(0);182 const record: IdentityRecord = {183 v: IDENTITY_RECORD_VERSION,184 deviceId: generateUuidV4(),185 ...privateHex,186 ...(await derivePublicHex(privateHex)),187 createdAt: Date.now(),188 };189 await persistRecord(root, record);190 cached = record;191 return record;192}193194export async function getOrCreateDeviceId(): Promise<string> {195 if (!isUnlocked()) throw new Error("vault_locked");196 const rootHex = getRootKeyHex(); if (!rootHex) throw new Error("vault_locked");197 return (await ensureRecord(bytes(rootHex))).deviceId;198}199export async function rotateDeviceId(): Promise<string> {200 if (!isUnlocked()) throw new Error("vault_locked");201 const rootHex = getRootKeyHex();202 if (!rootHex) throw new Error("vault_locked");203 const root = bytes(rootHex);204 const x = new Uint8Array(32);205 const ed = new Uint8Array(32);206 try {207 crypto.getRandomValues(x);208 crypto.getRandomValues(ed);209 const privateHex = { x25519PrivateKeyHex: hex(x), ed25519SeedHex: hex(ed) };210 const record: IdentityRecord = {211 v: IDENTITY_RECORD_VERSION,212 deviceId: generateUuidV4(),213 ...privateHex,214 ...(await derivePublicHex(privateHex)),215 createdAt: Date.now(),216 };217 await persistRecord(root, record);218 cached = record;219 return record.deviceId;220 } finally {221 root.fill(0);222 x.fill(0);223 ed.fill(0);224 }225}226export async function getDeviceName(): Promise<string> {227 const result = await chrome.storage.sync.get(DEVICE_NAME_KEY);228 const stored = (result as Record<string, unknown>)[DEVICE_NAME_KEY];229 return typeof stored === "string" && stored.length > 0 ? stored : defaultDeviceName();230}231export async function setDeviceName(name: string): Promise<void> {232 const trimmed = name.trim();233 if (!trimmed) throw new Error("device name must not be empty");234 if (trimmed.length > 64) throw new Error("device name must be 64 chars or fewer");235 await chrome.storage.sync.set({ [DEVICE_NAME_KEY]: trimmed });236}237export async function buildLocalIdentity(masterKey: Uint8Array): Promise<LocalDeviceIdentity> {238 const record = await ensureRecord(masterKey);239 return {240 deviceId: record.deviceId,241 deviceName: await getDeviceName(),242 deviceType: "extension",243 longTermPublicKeyHex: record.x25519PublicKeyHex,244 signingPublicKeyHex: record.ed25519PublicKeyHex,245 };246}247async function importSigningKey(seed: Uint8Array): Promise<CryptoKey> {248 const prefix = Uint8Array.of(0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20);249 const pkcs8 = new Uint8Array(prefix.length + 32);250 pkcs8.set(prefix);251 pkcs8.set(seed, prefix.length);252 return crypto.subtle.importKey("pkcs8", pkcs8 as Uint8Array<ArrayBuffer>, { name: "Ed25519" }, true, ["sign"]);253}254function base64ToHex(value: string): string {255 const raw = atob(value.replace(/-/g, "+").replace(/_/g, "/"));256 return Array.from(raw, (c) => c.charCodeAt(0).toString(16).padStart(2, "0")).join("");257}258function defaultDeviceName(): string {259 260 261 const ua = (typeof navigator !== "undefined" ? navigator.userAgent : "") || "";262 if (/Firefox/i.test(ua)) return "Firefox";263 if (/Edg\//i.test(ua)) return "Edge";264 if (/Chrome/i.test(ua)) return "Chrome";265 if (/Safari/i.test(ua)) return "Safari";266 return "Browser";267}268269function generateUuidV4(): string {270 271 272 if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {273 return crypto.randomUUID();274 }275 const bytes = new Uint8Array(16);276 crypto.getRandomValues(bytes as Uint8Array<ArrayBuffer>);277 bytes[6] = (bytes[6] & 0x0f) | 0x40;278 bytes[8] = (bytes[8] & 0x3f) | 0x80;279 const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");280 return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;281}282283export async function getLocalPairingIdentity(): Promise<{284 identity: LocalIdentity;285 privateKey: Uint8Array;286 publicKey: Uint8Array;287 signingPrivateKey: CryptoKey;288}> {289 if (!isUnlocked()) throw new Error("vault_locked");290 const rootHex = getRootKeyHex(); if (!rootHex) throw new Error("vault_locked");291 const root = bytes(rootHex);292 try {293 const record = await ensureRecord(root);294 const privateKey = bytes(record.x25519PrivateKeyHex);295 const publicKey = generateKeyPairFromSeed(privateKey).publicKey;296 const signingPrivateKey = await importSigningKey(bytes(record.ed25519SeedHex));297 const base = await buildLocalIdentity(root);298 return { identity: { ...base, vaultId: await computeVaultId(root) }, privateKey, publicKey, signingPrivateKey };299 } finally { root.fill(0); }300}