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<VaultEntry, "id" | "createdAt" | "modifiedAt">;1516export async function exportEntriesToPeachJson(17 entries: VaultEntry[],18 password: string19): Promise<PeachExportEnvelope> {20 const salt = crypto.getRandomValues(new Uint8Array(16));21 const key = await derivePeachExportKey(password, salt);22 const { nonce, ciphertext, tag } = await encrypt(key, JSON.stringify(entries));2324 return {25 version: 2,26 type: "peach-export",27 kdf: "PBKDF2-SHA256",28 salt: toHex(salt),29 nonce,30 ciphertext,31 tag,32 };33}3435export async function importEntriesFromPeachJson(36 raw: string,37 password: string38): Promise<ImportableEntry[]> {39 let envelope: Partial<PeachExportEnvelope>;40 try {41 envelope = JSON.parse(raw) as Partial<PeachExportEnvelope>;42 } catch {43 throw new Error("INVALID_PEACH_EXPORT");44 }4546 if (47 envelope.type !== "peach-export" ||48 envelope.kdf !== "PBKDF2-SHA256" ||49 envelope.version !== 2 ||50 !envelope.salt ||51 !envelope.nonce ||52 !envelope.ciphertext ||53 !envelope.tag54 ) {55 throw new Error("INVALID_PEACH_EXPORT");56 }5758 let plaintext: string;59 try {60 const key = await derivePeachExportKey(password, fromHex(envelope.salt));61 plaintext = await decrypt(62 key,63 envelope.nonce,64 envelope.ciphertext,65 envelope.tag66 );67 } catch {68 throw new Error("INVALID_EXPORT_PASSWORD");69 }7071 let parsed: unknown;72 try {73 parsed = JSON.parse(plaintext);74 } catch {75 throw new Error("INVALID_PEACH_EXPORT");76 }7778 if (!Array.isArray(parsed)) {79 throw new Error("INVALID_PEACH_EXPORT");80 }8182 return parsed83 .map(normalizeImportedEntry)84 .filter((entry): entry is ImportableEntry => entry !== null);85}8687export async function derivePeachExportKey(88 password: string,89 salt: Uint8Array90): Promise<Uint8Array> {91 const material = await crypto.subtle.importKey(92 "raw",93 new TextEncoder().encode(password),94 "PBKDF2",95 false,96 ["deriveBits"]97 );98 const bits = await crypto.subtle.deriveBits(99 {100 name: "PBKDF2",101 salt: salt.buffer as ArrayBuffer,102 iterations: 250000,103 hash: "SHA-256",104 },105 material,106 256107 );108 return new Uint8Array(bits);109}110111function normalizeImportedEntry(value: unknown): ImportableEntry | null {112 if (!value || typeof value !== "object") return null;113114 const candidate = value as Partial<VaultEntry> & {115 name?: string;116 note?: string;117 email?: string;118 };119120 const deletedAt =121 typeof candidate.deletedAt === "number" ? candidate.deletedAt : undefined;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 deletedAt,136 };137 }138139 if (candidate.type === "passkey") {140 if (141 typeof candidate.rpId !== "string" ||142 typeof candidate.rpName !== "string" ||143 typeof candidate.credentialId !== "string" ||144 typeof candidate.publicKeyCose !== "string" ||145 !candidate.privateKeyJwk ||146 typeof candidate.userHandle !== "string" ||147 typeof candidate.userName !== "string" ||148 typeof candidate.userDisplayName !== "string" ||149 (candidate.algorithm !== -7 && candidate.algorithm !== -8)150 ) {151 return null;152 }153154 return {155 type: "passkey",156 title: firstNonEmptyString(candidate.title, candidate.rpName, candidate.rpId),157 rpId: candidate.rpId,158 rpName: candidate.rpName,159 credentialId: candidate.credentialId,160 publicKeyCose: candidate.publicKeyCose,161 privateKeyJwk: candidate.privateKeyJwk,162 userHandle: candidate.userHandle,163 userName: candidate.userName,164 userDisplayName: candidate.userDisplayName,165 algorithm: candidate.algorithm,166 signCount: typeof candidate.signCount === "number" ? candidate.signCount : 0,167 aaguid:168 typeof candidate.aaguid === "string" ? candidate.aaguid : "AAAAAAAAAAAAAAAAAAAAAA",169 backupEligible: candidate.backupEligible !== false,170 backupState: candidate.backupState === true,171 lastUsedAt:172 typeof candidate.lastUsedAt === "number" ? candidate.lastUsedAt : undefined,173 deletedAt,174 };175 }176177 const password =178 typeof candidate.password === "string" ? candidate.password : "";179 if (!password) return null;180181 const title = firstNonEmptyString(182 candidate.title,183 candidate.name,184 candidate.url,185 candidate.username,186 candidate.email,187 "Untitled"188 );189190 return {191 type: "login",192 title,193 username: firstNonEmptyString(candidate.username, candidate.email, ""),194 password,195 url: firstNonEmptyString(candidate.url, ""),196 notes: firstNonEmptyString(candidate.notes, candidate.note) || undefined,197 totp: firstNonEmptyString(candidate.totp) || undefined,198 breach:199 typeof candidate.breach === "number" && candidate.breach > 0200 ? candidate.breach201 : undefined,202 canary: candidate.canary ?? undefined,203 tags: Array.isArray(candidate.tags) ? candidate.tags : undefined,204 deletedAt,205 };206}207208function firstNonEmptyString(209 ...values: Array<string | null | undefined>210): string {211 for (const value of values) {212 if (typeof value === "string" && value.trim()) {213 return value.trim();214 }215 }216 return "";217}218219function toHex(buf: Uint8Array): string {220 return Array.from(buf)221 .map((byte) => byte.toString(16).padStart(2, "0"))222 .join("");223}224225function fromHex(input: string): Uint8Array {226 const out = new Uint8Array(input.length / 2);227 for (let i = 0; i < input.length; i += 2) {228 out[i / 2] = parseInt(input.slice(i, i + 2), 16);229 }230 return out;231}