import { decrypt, encrypt } from "../background/aes"; import { IMPORT_LIMITS, importTooLarge } from "./import-limits"; import type { PeachLicenseExport, VaultEntry } from "./types"; /** * Encrypted Peach JSON envelope (legacy v2). v2.5.0 removed the * encrypted-JSON export option from the UI in favour of PeachScript and the * QR matrix code (see src/crypto/peachPayload.ts). The import path remains * so users can still restore older `.json` backups, and the export helper * below is kept private to tests that exercise the import path with * round-trip fixtures. */ export interface PeachExportEnvelope { version: 2; type: "peach-export"; kdf: "PBKDF2-SHA256"; salt: string; nonce: string; ciphertext: string; tag: string; } export type ImportableEntry = Omit; /** @internal — test fixture generator only. No UI code path calls this. */ export async function exportEntriesToPeachJson( entries: VaultEntry[], password: string, license?: PeachLicenseExport ): Promise { const salt = crypto.getRandomValues(new Uint8Array(16)); const key = await derivePeachExportKey(password, salt); const inner: { entries: VaultEntry[]; license?: PeachLicenseExport } = { entries }; if (license) inner.license = license; const { nonce, ciphertext, tag } = await encrypt(key, JSON.stringify(inner)); return { version: 2, type: "peach-export", kdf: "PBKDF2-SHA256", salt: toHex(salt), nonce, ciphertext, tag, }; } export interface PeachJsonImportResult { entries: ImportableEntry[]; licenseKey?: string; licenseActivatedAt?: number; } export async function importEntriesFromPeachJson( raw: string, password: string ): Promise { // MV-5: defense in depth — parseImportSource caps first when this is // reached through it, but this function is also exported directly. if (raw.length > IMPORT_LIMITS.MAX_JSON_BYTES) { throw importTooLarge( `peach export ${raw.length} chars exceeds ${IMPORT_LIMITS.MAX_JSON_BYTES}`, ); } let envelope: Partial; try { envelope = JSON.parse(raw) as Partial; } catch { throw new Error("INVALID_PEACH_EXPORT"); } if ( envelope.type !== "peach-export" || envelope.kdf !== "PBKDF2-SHA256" || envelope.version !== 2 || !envelope.salt || !envelope.nonce || !envelope.ciphertext || !envelope.tag ) { throw new Error("INVALID_PEACH_EXPORT"); } let plaintext: string; try { const key = await derivePeachExportKey(password, fromHex(envelope.salt)); plaintext = await decrypt( key, envelope.nonce, envelope.ciphertext, envelope.tag ); } catch { // LV-1: an export sealed before NFC normalization with a non-NFC // passphrase derives a different key. Retry once with the raw bytes. if (password === password.normalize("NFC")) { throw new Error("INVALID_EXPORT_PASSWORD"); } try { const key = await derivePeachExportKey(password, fromHex(envelope.salt), { raw: true, }); plaintext = await decrypt( key, envelope.nonce, envelope.ciphertext, envelope.tag ); } catch { throw new Error("INVALID_EXPORT_PASSWORD"); } } let parsed: unknown; try { parsed = JSON.parse(plaintext); } catch { throw new Error("INVALID_PEACH_EXPORT"); } // Handle both old format (plain entries array) and new format ({ entries, license? }) let rawEntries: unknown[]; let licenseKey: string | undefined; let licenseActivatedAt: number | undefined; if (Array.isArray(parsed)) { rawEntries = parsed; } else if (parsed && typeof parsed === "object" && Array.isArray((parsed as { entries?: unknown }).entries)) { const obj = parsed as { entries: unknown[]; license?: PeachLicenseExport }; rawEntries = obj.entries; if (obj.license && typeof obj.license.key === "string") { licenseKey = obj.license.key; licenseActivatedAt = obj.license.activatedAt; } } else { throw new Error("INVALID_PEACH_EXPORT"); } const entries = rawEntries .map(normalizeImportedEntry) .filter((entry): entry is ImportableEntry => entry !== null); return { entries, ...(licenseKey ? { licenseKey, licenseActivatedAt } : {}) }; } export async function derivePeachExportKey( password: string, salt: Uint8Array, opts: { raw?: boolean } = {} ): Promise { // LV-1: NFC-normalize the export passphrase to match the master-password // KDF chokepoint. `raw:true` is used only by the import fallback to open // pre-LV-1 exports sealed under un-normalized bytes. const pw = opts.raw ? password : password.normalize("NFC"); const material = await crypto.subtle.importKey( "raw", new TextEncoder().encode(pw), "PBKDF2", false, ["deriveBits"] ); const bits = await crypto.subtle.deriveBits( { name: "PBKDF2", salt: salt.buffer as ArrayBuffer, iterations: 250000, hash: "SHA-256", }, material, 256 ); return new Uint8Array(bits); } function normalizeImportedEntry(value: unknown): ImportableEntry | null { if (!value || typeof value !== "object") return null; const candidate = value as Partial & { name?: string; note?: string; email?: string; }; const deletedAt = typeof candidate.deletedAt === "number" ? candidate.deletedAt : undefined; if (candidate.type === "note") { const title = firstNonEmptyString( candidate.title, candidate.name, "Untitled" ); if (!title) return null; return { type: "note", title, notes: firstNonEmptyString(candidate.notes, candidate.note) || undefined, tags: Array.isArray(candidate.tags) ? candidate.tags : undefined, deletedAt, }; } if (candidate.type === "passkey") { if ( typeof candidate.rpId !== "string" || typeof candidate.rpName !== "string" || typeof candidate.credentialId !== "string" || typeof candidate.publicKeyCose !== "string" || !candidate.privateKeyJwk || typeof candidate.userHandle !== "string" || typeof candidate.userName !== "string" || typeof candidate.userDisplayName !== "string" || (candidate.algorithm !== -7 && candidate.algorithm !== -8) ) { return null; } return { type: "passkey", title: firstNonEmptyString(candidate.title, candidate.rpName, candidate.rpId), rpId: candidate.rpId, rpName: candidate.rpName, credentialId: candidate.credentialId, publicKeyCose: candidate.publicKeyCose, privateKeyJwk: candidate.privateKeyJwk, userHandle: candidate.userHandle, userName: candidate.userName, userDisplayName: candidate.userDisplayName, algorithm: candidate.algorithm, signCount: typeof candidate.signCount === "number" ? candidate.signCount : 0, aaguid: typeof candidate.aaguid === "string" ? candidate.aaguid : "AAAAAAAAAAAAAAAAAAAAAA", backupEligible: candidate.backupEligible !== false, backupState: candidate.backupState === true, lastUsedAt: typeof candidate.lastUsedAt === "number" ? candidate.lastUsedAt : undefined, deletedAt, }; } const password = typeof candidate.password === "string" ? candidate.password : ""; if (!password) return null; const title = firstNonEmptyString( candidate.title, candidate.name, candidate.url, candidate.username, candidate.email, "Untitled" ); return { type: "login", title, username: firstNonEmptyString(candidate.username, candidate.email, ""), password, url: firstNonEmptyString(candidate.url, ""), notes: firstNonEmptyString(candidate.notes, candidate.note) || undefined, totp: firstNonEmptyString(candidate.totp) || undefined, breach: typeof candidate.breach === "number" && candidate.breach > 0 ? candidate.breach : undefined, canary: candidate.canary ?? undefined, tags: Array.isArray(candidate.tags) ? candidate.tags : undefined, deletedAt, }; } function firstNonEmptyString( ...values: Array ): string { for (const value of values) { if (typeof value === "string" && value.trim()) { return value.trim(); } } return ""; } function toHex(buf: Uint8Array): string { return Array.from(buf) .map((byte) => byte.toString(16).padStart(2, "0")) .join(""); } function fromHex(input: string): Uint8Array { const out = new Uint8Array(input.length / 2); for (let i = 0; i < input.length; i += 2) { out[i / 2] = parseInt(input.slice(i, i + 2), 16); } return out; }