1import { decrypt, encrypt } from "../background/aes";2import type { PeachLicenseExport, 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: string,19 license?: PeachLicenseExport20): Promise<PeachExportEnvelope> {21 const salt = crypto.getRandomValues(new Uint8Array(16));22 const key = await derivePeachExportKey(password, salt);23 24 25 const inner: { entries: VaultEntry[]; license?: PeachLicenseExport } = { entries };26 if (license) inner.license = license;27 const { nonce, ciphertext, tag } = await encrypt(key, JSON.stringify(inner));2829 return {30 version: 2,31 type: "peach-export",32 kdf: "PBKDF2-SHA256",33 salt: toHex(salt),34 nonce,35 ciphertext,36 tag,37 };38}3940export interface PeachJsonImportResult {41 entries: ImportableEntry[];42 licenseKey?: string;43 licenseActivatedAt?: number;44}4546export async function importEntriesFromPeachJson(47 raw: string,48 password: string49): Promise<PeachJsonImportResult> {50 let envelope: Partial<PeachExportEnvelope>;51 try {52 envelope = JSON.parse(raw) as Partial<PeachExportEnvelope>;53 } catch {54 throw new Error("INVALID_PEACH_EXPORT");55 }5657 if (58 envelope.type !== "peach-export" ||59 envelope.kdf !== "PBKDF2-SHA256" ||60 envelope.version !== 2 ||61 !envelope.salt ||62 !envelope.nonce ||63 !envelope.ciphertext ||64 !envelope.tag65 ) {66 throw new Error("INVALID_PEACH_EXPORT");67 }6869 let plaintext: string;70 try {71 const key = await derivePeachExportKey(password, fromHex(envelope.salt));72 plaintext = await decrypt(73 key,74 envelope.nonce,75 envelope.ciphertext,76 envelope.tag77 );78 } catch {79 throw new Error("INVALID_EXPORT_PASSWORD");80 }8182 let parsed: unknown;83 try {84 parsed = JSON.parse(plaintext);85 } catch {86 throw new Error("INVALID_PEACH_EXPORT");87 }8889 90 let rawEntries: unknown[];91 let licenseKey: string | undefined;92 let licenseActivatedAt: number | undefined;9394 if (Array.isArray(parsed)) {95 rawEntries = parsed;96 } else if (parsed && typeof parsed === "object" && Array.isArray((parsed as { entries?: unknown }).entries)) {97 const obj = parsed as { entries: unknown[]; license?: PeachLicenseExport };98 rawEntries = obj.entries;99 if (obj.license && typeof obj.license.key === "string") {100 licenseKey = obj.license.key;101 licenseActivatedAt = obj.license.activatedAt;102 }103 } else {104 throw new Error("INVALID_PEACH_EXPORT");105 }106107 const entries = rawEntries108 .map(normalizeImportedEntry)109 .filter((entry): entry is ImportableEntry => entry !== null);110111 return { entries, ...(licenseKey ? { licenseKey, licenseActivatedAt } : {}) };112}113114export async function derivePeachExportKey(115 password: string,116 salt: Uint8Array117): Promise<Uint8Array> {118 const material = await crypto.subtle.importKey(119 "raw",120 new TextEncoder().encode(password),121 "PBKDF2",122 false,123 ["deriveBits"]124 );125 const bits = await crypto.subtle.deriveBits(126 {127 name: "PBKDF2",128 salt: salt.buffer as ArrayBuffer,129 iterations: 250000,130 hash: "SHA-256",131 },132 material,133 256134 );135 return new Uint8Array(bits);136}137138function normalizeImportedEntry(value: unknown): ImportableEntry | null {139 if (!value || typeof value !== "object") return null;140141 const candidate = value as Partial<VaultEntry> & {142 name?: string;143 note?: string;144 email?: string;145 };146147 const deletedAt =148 typeof candidate.deletedAt === "number" ? candidate.deletedAt : undefined;149150 if (candidate.type === "note") {151 const title = firstNonEmptyString(152 candidate.title,153 candidate.name,154 "Untitled"155 );156 if (!title) return null;157 return {158 type: "note",159 title,160 notes: firstNonEmptyString(candidate.notes, candidate.note) || undefined,161 tags: Array.isArray(candidate.tags) ? candidate.tags : undefined,162 deletedAt,163 };164 }165166 if (candidate.type === "passkey") {167 if (168 typeof candidate.rpId !== "string" ||169 typeof candidate.rpName !== "string" ||170 typeof candidate.credentialId !== "string" ||171 typeof candidate.publicKeyCose !== "string" ||172 !candidate.privateKeyJwk ||173 typeof candidate.userHandle !== "string" ||174 typeof candidate.userName !== "string" ||175 typeof candidate.userDisplayName !== "string" ||176 (candidate.algorithm !== -7 && candidate.algorithm !== -8)177 ) {178 return null;179 }180181 return {182 type: "passkey",183 title: firstNonEmptyString(candidate.title, candidate.rpName, candidate.rpId),184 rpId: candidate.rpId,185 rpName: candidate.rpName,186 credentialId: candidate.credentialId,187 publicKeyCose: candidate.publicKeyCose,188 privateKeyJwk: candidate.privateKeyJwk,189 userHandle: candidate.userHandle,190 userName: candidate.userName,191 userDisplayName: candidate.userDisplayName,192 algorithm: candidate.algorithm,193 signCount: typeof candidate.signCount === "number" ? candidate.signCount : 0,194 aaguid:195 typeof candidate.aaguid === "string" ? candidate.aaguid : "AAAAAAAAAAAAAAAAAAAAAA",196 backupEligible: candidate.backupEligible !== false,197 backupState: candidate.backupState === true,198 lastUsedAt:199 typeof candidate.lastUsedAt === "number" ? candidate.lastUsedAt : undefined,200 deletedAt,201 };202 }203204 const password =205 typeof candidate.password === "string" ? candidate.password : "";206 if (!password) return null;207208 const title = firstNonEmptyString(209 candidate.title,210 candidate.name,211 candidate.url,212 candidate.username,213 candidate.email,214 "Untitled"215 );216217 return {218 type: "login",219 title,220 username: firstNonEmptyString(candidate.username, candidate.email, ""),221 password,222 url: firstNonEmptyString(candidate.url, ""),223 notes: firstNonEmptyString(candidate.notes, candidate.note) || undefined,224 totp: firstNonEmptyString(candidate.totp) || undefined,225 breach:226 typeof candidate.breach === "number" && candidate.breach > 0227 ? candidate.breach228 : undefined,229 canary: candidate.canary ?? undefined,230 tags: Array.isArray(candidate.tags) ? candidate.tags : undefined,231 deletedAt,232 };233}234235function firstNonEmptyString(236 ...values: Array<string | null | undefined>237): string {238 for (const value of values) {239 if (typeof value === "string" && value.trim()) {240 return value.trim();241 }242 }243 return "";244}245246function toHex(buf: Uint8Array): string {247 return Array.from(buf)248 .map((byte) => byte.toString(16).padStart(2, "0"))249 .join("");250}251252function fromHex(input: string): Uint8Array {253 const out = new Uint8Array(input.length / 2);254 for (let i = 0; i < input.length; i += 2) {255 out[i / 2] = parseInt(input.slice(i, i + 2), 16);256 }257 return out;258}