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";1819interface IdentityRecord {20 v: 2;21 deviceId: string;22 x25519PrivateKeyHex: string;23 ed25519SeedHex: string;24}2526let cached: IdentityRecord | null = null;2728export interface LocalDeviceIdentity {29 deviceId: string;30 deviceName: string;31 deviceType: DeviceType;32 longTermPublicKeyHex: string;33 signingPublicKeyHex: string;34}3536function hex(bytes: Uint8Array): string {37 return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");38}39function bytes(value: string): Uint8Array {40 if (!/^[0-9a-f]{64}$/i.test(value)) throw new Error("identity_migration_required");41 const out = new Uint8Array(32);42 for (let i = 0; i < out.length; i++) out[i] = Number.parseInt(value.slice(i * 2, i * 2 + 2), 16);43 return out;44}45async function loadRecord(root: Uint8Array): Promise<IdentityRecord | null> {46 if (cached) return cached;47 const row = (await chrome.storage.local.get(IDENTITY_RECORD_KEY))[IDENTITY_RECORD_KEY] as Record<string, unknown> | undefined;48 if (!row) return null;49 if (row.v !== 2 || typeof row.nonce !== "string" || typeof row.ciphertext !== "string" || typeof row.tag !== "string") {50 throw new Error("identity_migration_required");51 }52 let parsed: IdentityRecord;53 try {54 parsed = JSON.parse(await decrypt(root, row.nonce, row.ciphertext, row.tag, IDENTITY_INFO)) as IdentityRecord;55 } catch { throw new Error("identity_migration_required"); }56 if (57 parsed.v !== 2 ||58 !/^[0-9a-f]{64}$/i.test(parsed.x25519PrivateKeyHex) ||59 !/^[0-9a-f]{64}$/i.test(parsed.ed25519SeedHex)60 ) throw new Error("identity_migration_required");61 cached = parsed;62 return parsed;63}64async function ensureRecord(root: Uint8Array): Promise<IdentityRecord> {65 const existing = await loadRecord(root);66 if (existing) return existing;67 const x = new Uint8Array(32); const ed = new Uint8Array(32);68 crypto.getRandomValues(x); crypto.getRandomValues(ed);69 const record: IdentityRecord = { v: 2, deviceId: generateUuidV4(), x25519PrivateKeyHex: hex(x), ed25519SeedHex: hex(ed) };70 const sealed = await encrypt(root, JSON.stringify(record), IDENTITY_INFO);71 await chrome.storage.local.set({ [IDENTITY_RECORD_KEY]: { v: 2, ...sealed } });72 x.fill(0); ed.fill(0); cached = record;73 return record;74}7576export async function getOrCreateDeviceId(): Promise<string> {77 if (!isUnlocked()) throw new Error("vault_locked");78 const rootHex = getRootKeyHex(); if (!rootHex) throw new Error("vault_locked");79 return (await ensureRecord(bytes(rootHex))).deviceId;80}81export async function rotateDeviceId(): Promise<string> {82 if (!isUnlocked()) throw new Error("vault_locked");83 const rootHex = getRootKeyHex();84 if (!rootHex) throw new Error("vault_locked");85 const root = bytes(rootHex);86 const x = new Uint8Array(32);87 const ed = new Uint8Array(32);88 try {89 crypto.getRandomValues(x);90 crypto.getRandomValues(ed);91 const record: IdentityRecord = {92 v: 2,93 deviceId: generateUuidV4(),94 x25519PrivateKeyHex: hex(x),95 ed25519SeedHex: hex(ed),96 };97 const sealed = await encrypt(root, JSON.stringify(record), IDENTITY_INFO);98 await chrome.storage.local.set({ [IDENTITY_RECORD_KEY]: { v: 2, ...sealed } });99 cached = record;100 return record.deviceId;101 } finally {102 root.fill(0);103 x.fill(0);104 ed.fill(0);105 }106}107export async function getDeviceName(): Promise<string> {108 const result = await chrome.storage.sync.get(DEVICE_NAME_KEY);109 const stored = (result as Record<string, unknown>)[DEVICE_NAME_KEY];110 return typeof stored === "string" && stored.length > 0 ? stored : defaultDeviceName();111}112export async function setDeviceName(name: string): Promise<void> {113 const trimmed = name.trim();114 if (!trimmed) throw new Error("device name must not be empty");115 if (trimmed.length > 64) throw new Error("device name must be 64 chars or fewer");116 await chrome.storage.sync.set({ [DEVICE_NAME_KEY]: trimmed });117}118export async function buildLocalIdentity(masterKey: Uint8Array): Promise<LocalDeviceIdentity> {119 const record = await ensureRecord(masterKey);120 const x = bytes(record.x25519PrivateKeyHex);121 const kp = generateKeyPairFromSeed(x);122 const signingPrivateKey = await importSigningKey(bytes(record.ed25519SeedHex));123 const jwk = await crypto.subtle.exportKey("jwk", signingPrivateKey) as JsonWebKey;124 return { deviceId: record.deviceId, deviceName: await getDeviceName(), deviceType: "extension",125 longTermPublicKeyHex: pubkeyToHex(kp.publicKey), signingPublicKeyHex: base64ToHex(jwk.x ?? "") };126}127async function importSigningKey(seed: Uint8Array): Promise<CryptoKey> {128 const prefix = Uint8Array.of(0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20);129 const pkcs8 = new Uint8Array(prefix.length + 32);130 pkcs8.set(prefix);131 pkcs8.set(seed, prefix.length);132 return crypto.subtle.importKey("pkcs8", pkcs8 as Uint8Array<ArrayBuffer>, { name: "Ed25519" }, true, ["sign"]);133}134function base64ToHex(value: string): string {135 const raw = atob(value.replace(/-/g, "+").replace(/_/g, "/"));136 return Array.from(raw, (c) => c.charCodeAt(0).toString(16).padStart(2, "0")).join("");137}138export function deriveLocalSyncKeypair(_masterKey: Uint8Array, _deviceId: string) {139 throw new Error("identity_migration_required");140}141142function defaultDeviceName(): string {143 144 145 const ua = (typeof navigator !== "undefined" ? navigator.userAgent : "") || "";146 if (/Firefox/i.test(ua)) return "Firefox";147 if (/Edg\//i.test(ua)) return "Edge";148 if (/Chrome/i.test(ua)) return "Chrome";149 if (/Safari/i.test(ua)) return "Safari";150 return "Browser";151}152153function generateUuidV4(): string {154 155 156 if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {157 return crypto.randomUUID();158 }159 const bytes = new Uint8Array(16);160 crypto.getRandomValues(bytes as Uint8Array<ArrayBuffer>);161 bytes[6] = (bytes[6] & 0x0f) | 0x40;162 bytes[8] = (bytes[8] & 0x3f) | 0x80;163 const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");164 return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;165}166167export async function getLocalPairingIdentity(): Promise<{168 identity: LocalIdentity;169 privateKey: Uint8Array;170 publicKey: Uint8Array;171 signingPrivateKey: CryptoKey;172}> {173 if (!isUnlocked()) throw new Error("vault_locked");174 const rootHex = getRootKeyHex(); if (!rootHex) throw new Error("vault_locked");175 const root = bytes(rootHex);176 try {177 const record = await ensureRecord(root);178 const privateKey = bytes(record.x25519PrivateKeyHex);179 const publicKey = generateKeyPairFromSeed(privateKey).publicKey;180 const signingPrivateKey = await importSigningKey(bytes(record.ed25519SeedHex));181 const base = await buildLocalIdentity(root);182 return { identity: { ...base, vaultId: await computeVaultId(root) }, privateKey, publicKey, signingPrivateKey };183 } finally { root.fill(0); }184}