1import { decrypt, encrypt } from "../background/aes";2import type { PeachLicenseExport, VaultEntry } from "./types";3456789101112export interface PeachExportEnvelope {13 version: 2;14 type: "peach-export";15 kdf: "PBKDF2-SHA256";16 salt: string;17 nonce: string;18 ciphertext: string;19 tag: string;20}2122export type ImportableEntry = Omit<VaultEntry, "id" | "createdAt" | "modifiedAt">;2324@internal25export async function exportEntriesToPeachJson(26 entries: VaultEntry[],27 password: string,28 license?: PeachLicenseExport29): Promise<PeachExportEnvelope> {30 const salt = crypto.getRandomValues(new Uint8Array(16));31 const key = await derivePeachExportKey(password, salt);32 const inner: { entries: VaultEntry[]; license?: PeachLicenseExport } = { entries };33 if (license) inner.license = license;34 const { nonce, ciphertext, tag } = await encrypt(key, JSON.stringify(inner));3536 return {37 version: 2,38 type: "peach-export",39 kdf: "PBKDF2-SHA256",40 salt: toHex(salt),41 nonce,42 ciphertext,43 tag,44 };45}4647export interface PeachJsonImportResult {48 entries: ImportableEntry[];49 licenseKey?: string;50 licenseActivatedAt?: number;51}5253export async function importEntriesFromPeachJson(54 raw: string,55 password: string56): Promise<PeachJsonImportResult> {57 let envelope: Partial<PeachExportEnvelope>;58 try {59 envelope = JSON.parse(raw) as Partial<PeachExportEnvelope>;60 } catch {61 throw new Error("INVALID_PEACH_EXPORT");62 }6364 if (65 envelope.type !== "peach-export" ||66 envelope.kdf !== "PBKDF2-SHA256" ||67 envelope.version !== 2 ||68 !envelope.salt ||69 !envelope.nonce ||70 !envelope.ciphertext ||71 !envelope.tag72 ) {73 throw new Error("INVALID_PEACH_EXPORT");74 }7576 let plaintext: string;77 try {78 const key = await derivePeachExportKey(password, fromHex(envelope.salt));79 plaintext = await decrypt(80 key,81 envelope.nonce,82 envelope.ciphertext,83 envelope.tag84 );85 } catch {86 throw new Error("INVALID_EXPORT_PASSWORD");87 }8889 let parsed: unknown;90 try {91 parsed = JSON.parse(plaintext);92 } catch {93 throw new Error("INVALID_PEACH_EXPORT");94 }9596 97 let rawEntries: unknown[];98 let licenseKey: string | undefined;99 let licenseActivatedAt: number | undefined;100101 if (Array.isArray(parsed)) {102 rawEntries = parsed;103 } else if (parsed && typeof parsed === "object" && Array.isArray((parsed as { entries?: unknown }).entries)) {104 const obj = parsed as { entries: unknown[]; license?: PeachLicenseExport };105 rawEntries = obj.entries;106 if (obj.license && typeof obj.license.key === "string") {107 licenseKey = obj.license.key;108 licenseActivatedAt = obj.license.activatedAt;109 }110 } else {111 throw new Error("INVALID_PEACH_EXPORT");112 }113114 const entries = rawEntries115 .map(normalizeImportedEntry)116 .filter((entry): entry is ImportableEntry => entry !== null);117118 return { entries, ...(licenseKey ? { licenseKey, licenseActivatedAt } : {}) };119}120121export async function derivePeachExportKey(122 password: string,123 salt: Uint8Array124): Promise<Uint8Array> {125 const material = await crypto.subtle.importKey(126 "raw",127 new TextEncoder().encode(password),128 "PBKDF2",129 false,130 ["deriveBits"]131 );132 const bits = await crypto.subtle.deriveBits(133 {134 name: "PBKDF2",135 salt: salt.buffer as ArrayBuffer,136 iterations: 250000,137 hash: "SHA-256",138 },139 material,140 256141 );142 return new Uint8Array(bits);143}144145function normalizeImportedEntry(value: unknown): ImportableEntry | null {146 if (!value || typeof value !== "object") return null;147148 const candidate = value as Partial<VaultEntry> & {149 name?: string;150 note?: string;151 email?: string;152 };153154 const deletedAt =155 typeof candidate.deletedAt === "number" ? candidate.deletedAt : undefined;156157 if (candidate.type === "note") {158 const title = firstNonEmptyString(159 candidate.title,160 candidate.name,161 "Untitled"162 );163 if (!title) return null;164 return {165 type: "note",166 title,167 notes: firstNonEmptyString(candidate.notes, candidate.note) || undefined,168 tags: Array.isArray(candidate.tags) ? candidate.tags : undefined,169 deletedAt,170 };171 }172173 if (candidate.type === "passkey") {174 if (175 typeof candidate.rpId !== "string" ||176 typeof candidate.rpName !== "string" ||177 typeof candidate.credentialId !== "string" ||178 typeof candidate.publicKeyCose !== "string" ||179 !candidate.privateKeyJwk ||180 typeof candidate.userHandle !== "string" ||181 typeof candidate.userName !== "string" ||182 typeof candidate.userDisplayName !== "string" ||183 (candidate.algorithm !== -7 && candidate.algorithm !== -8)184 ) {185 return null;186 }187188 return {189 type: "passkey",190 title: firstNonEmptyString(candidate.title, candidate.rpName, candidate.rpId),191 rpId: candidate.rpId,192 rpName: candidate.rpName,193 credentialId: candidate.credentialId,194 publicKeyCose: candidate.publicKeyCose,195 privateKeyJwk: candidate.privateKeyJwk,196 userHandle: candidate.userHandle,197 userName: candidate.userName,198 userDisplayName: candidate.userDisplayName,199 algorithm: candidate.algorithm,200 signCount: typeof candidate.signCount === "number" ? candidate.signCount : 0,201 aaguid:202 typeof candidate.aaguid === "string" ? candidate.aaguid : "AAAAAAAAAAAAAAAAAAAAAA",203 backupEligible: candidate.backupEligible !== false,204 backupState: candidate.backupState === true,205 lastUsedAt:206 typeof candidate.lastUsedAt === "number" ? candidate.lastUsedAt : undefined,207 deletedAt,208 };209 }210211 const password =212 typeof candidate.password === "string" ? candidate.password : "";213 if (!password) return null;214215 const title = firstNonEmptyString(216 candidate.title,217 candidate.name,218 candidate.url,219 candidate.username,220 candidate.email,221 "Untitled"222 );223224 return {225 type: "login",226 title,227 username: firstNonEmptyString(candidate.username, candidate.email, ""),228 password,229 url: firstNonEmptyString(candidate.url, ""),230 notes: firstNonEmptyString(candidate.notes, candidate.note) || undefined,231 totp: firstNonEmptyString(candidate.totp) || undefined,232 breach:233 typeof candidate.breach === "number" && candidate.breach > 0234 ? candidate.breach235 : undefined,236 canary: candidate.canary ?? undefined,237 tags: Array.isArray(candidate.tags) ? candidate.tags : undefined,238 deletedAt,239 };240}241242function firstNonEmptyString(243 ...values: Array<string | null | undefined>244): string {245 for (const value of values) {246 if (typeof value === "string" && value.trim()) {247 return value.trim();248 }249 }250 return "";251}252253function toHex(buf: Uint8Array): string {254 return Array.from(buf)255 .map((byte) => byte.toString(16).padStart(2, "0"))256 .join("");257}258259function fromHex(input: string): Uint8Array {260 const out = new Uint8Array(input.length / 2);261 for (let i = 0; i < input.length; i += 2) {262 out[i / 2] = parseInt(input.slice(i, i + 2), 16);263 }264 return out;265}