1import { decrypt, encrypt } from "../background/aes";2import type { VaultEntry } from "./types";34export interface PeachExportEnvelope {5 version: 2;6 type: "peach-export";7 kdf: "PBKDF2-SHA256";8 salt: string;9 nonce: string;10 ciphertext: string;11 tag: string;12}1314export type ImportableEntry = Omit<15 VaultEntry,16 "id" | "createdAt" | "modifiedAt" | "deletedAt"17>;1819export async function exportEntriesToPeachJson(20 entries: VaultEntry[],21 password: string22): Promise<PeachExportEnvelope> {23 const salt = crypto.getRandomValues(new Uint8Array(16));24 const key = await derivePeachExportKey(password, salt);25 const { nonce, ciphertext, tag } = await encrypt(key, JSON.stringify(entries));2627 return {28 version: 2,29 type: "peach-export",30 kdf: "PBKDF2-SHA256",31 salt: toHex(salt),32 nonce,33 ciphertext,34 tag,35 };36}3738export async function importEntriesFromPeachJson(39 raw: string,40 password: string41): Promise<ImportableEntry[]> {42 let envelope: Partial<PeachExportEnvelope>;43 try {44 envelope = JSON.parse(raw) as Partial<PeachExportEnvelope>;45 } catch {46 throw new Error("INVALID_PEACH_EXPORT");47 }4849 if (50 envelope.type !== "peach-export" ||51 envelope.kdf !== "PBKDF2-SHA256" ||52 envelope.version !== 2 ||53 !envelope.salt ||54 !envelope.nonce ||55 !envelope.ciphertext ||56 !envelope.tag57 ) {58 throw new Error("INVALID_PEACH_EXPORT");59 }6061 let plaintext: string;62 try {63 const key = await derivePeachExportKey(password, fromHex(envelope.salt));64 plaintext = await decrypt(65 key,66 envelope.nonce,67 envelope.ciphertext,68 envelope.tag69 );70 } catch {71 throw new Error("INVALID_EXPORT_PASSWORD");72 }7374 let parsed: unknown;75 try {76 parsed = JSON.parse(plaintext);77 } catch {78 throw new Error("INVALID_PEACH_EXPORT");79 }8081 if (!Array.isArray(parsed)) {82 throw new Error("INVALID_PEACH_EXPORT");83 }8485 return parsed86 .map(normalizeImportedEntry)87 .filter((entry): entry is ImportableEntry => entry !== null);88}8990export async function derivePeachExportKey(91 password: string,92 salt: Uint8Array93): Promise<Uint8Array> {94 const material = await crypto.subtle.importKey(95 "raw",96 new TextEncoder().encode(password),97 "PBKDF2",98 false,99 ["deriveBits"]100 );101 const bits = await crypto.subtle.deriveBits(102 {103 name: "PBKDF2",104 salt: salt.buffer as ArrayBuffer,105 iterations: 250000,106 hash: "SHA-256",107 },108 material,109 256110 );111 return new Uint8Array(bits);112}113114function normalizeImportedEntry(value: unknown): ImportableEntry | null {115 if (!value || typeof value !== "object") return null;116117 const candidate = value as Partial<VaultEntry> & {118 name?: string;119 note?: string;120 email?: string;121 };122123 if (candidate.type === "note") {124 const title = firstNonEmptyString(125 candidate.title,126 candidate.name,127 "Untitled"128 );129 if (!title) return null;130 return {131 type: "note",132 title,133 notes: firstNonEmptyString(candidate.notes, candidate.note) || undefined,134 tags: Array.isArray(candidate.tags) ? candidate.tags : undefined,135 };136 }137138 if (candidate.type === "passkey") {139 if (140 typeof candidate.rpId !== "string" ||141 typeof candidate.rpName !== "string" ||142 typeof candidate.credentialId !== "string" ||143 typeof candidate.publicKeyCose !== "string" ||144 !candidate.privateKeyJwk ||145 typeof candidate.userHandle !== "string" ||146 typeof candidate.userName !== "string" ||147 typeof candidate.userDisplayName !== "string" ||148 (candidate.algorithm !== -7 && candidate.algorithm !== -8)149 ) {150 return null;151 }152153 return {154 type: "passkey",155 title: firstNonEmptyString(candidate.title, candidate.rpName, candidate.rpId),156 rpId: candidate.rpId,157 rpName: candidate.rpName,158 credentialId: candidate.credentialId,159 publicKeyCose: candidate.publicKeyCose,160 privateKeyJwk: candidate.privateKeyJwk,161 userHandle: candidate.userHandle,162 userName: candidate.userName,163 userDisplayName: candidate.userDisplayName,164 algorithm: candidate.algorithm,165 signCount: typeof candidate.signCount === "number" ? candidate.signCount : 0,166 aaguid:167 typeof candidate.aaguid === "string" ? candidate.aaguid : "AAAAAAAAAAAAAAAAAAAAAA",168 backupEligible: candidate.backupEligible !== false,169 backupState: candidate.backupState === true,170 lastUsedAt:171 typeof candidate.lastUsedAt === "number" ? candidate.lastUsedAt : undefined,172 };173 }174175 const password =176 typeof candidate.password === "string" ? candidate.password : "";177 if (!password) return null;178179 const title = firstNonEmptyString(180 candidate.title,181 candidate.name,182 candidate.url,183 candidate.username,184 candidate.email,185 "Untitled"186 );187188 return {189 type: "login",190 title,191 username: firstNonEmptyString(candidate.username, candidate.email, ""),192 password,193 url: firstNonEmptyString(candidate.url, ""),194 notes: firstNonEmptyString(candidate.notes, candidate.note) || undefined,195 totp: firstNonEmptyString(candidate.totp) || undefined,196 breach:197 typeof candidate.breach === "number" && candidate.breach > 0198 ? candidate.breach199 : undefined,200 canary: candidate.canary ?? undefined,201 tags: Array.isArray(candidate.tags) ? candidate.tags : undefined,202 };203}204205function firstNonEmptyString(206 ...values: Array<string | null | undefined>207): string {208 for (const value of values) {209 if (typeof value === "string" && value.trim()) {210 return value.trim();211 }212 }213 return "";214}215216function toHex(buf: Uint8Array): string {217 return Array.from(buf)218 .map((byte) => byte.toString(16).padStart(2, "0"))219 .join("");220}221222function fromHex(input: string): Uint8Array {223 const out = new Uint8Array(input.length / 2);224 for (let i = 0; i < input.length; i += 2) {225 out[i / 2] = parseInt(input.slice(i, i + 2), 16);226 }227 return out;228}