1import { decrypt, encrypt } from "../background/aes";2import { IMPORT_LIMITS, importTooLarge } from "./import-limits";3import type { PeachLicenseExport, VaultEntry } from "./types";45678910111213export interface PeachExportEnvelope {14 version: 2;15 type: "peach-export";16 kdf: "PBKDF2-SHA256";17 salt: string;18 nonce: string;19 ciphertext: string;20 tag: string;21}2223export type ImportableEntry = Omit<VaultEntry, "id" | "createdAt" | "modifiedAt">;2425@internal26export async function exportEntriesToPeachJson(27 entries: VaultEntry[],28 password: string,29 license?: PeachLicenseExport30): Promise<PeachExportEnvelope> {31 const salt = crypto.getRandomValues(new Uint8Array(16));32 const key = await derivePeachExportKey(password, salt);33 const inner: { entries: VaultEntry[]; license?: PeachLicenseExport } = { entries };34 if (license) inner.license = license;35 const { nonce, ciphertext, tag } = await encrypt(key, JSON.stringify(inner));3637 return {38 version: 2,39 type: "peach-export",40 kdf: "PBKDF2-SHA256",41 salt: toHex(salt),42 nonce,43 ciphertext,44 tag,45 };46}4748export interface PeachJsonImportResult {49 entries: ImportableEntry[];50 licenseKey?: string;51 licenseActivatedAt?: number;52}5354export async function importEntriesFromPeachJson(55 raw: string,56 password: string57): Promise<PeachJsonImportResult> {58 59 60 if (raw.length > IMPORT_LIMITS.MAX_JSON_BYTES) {61 throw importTooLarge(62 `peach export ${raw.length} chars exceeds ${IMPORT_LIMITS.MAX_JSON_BYTES}`,63 );64 }65 let envelope: Partial<PeachExportEnvelope>;66 try {67 envelope = JSON.parse(raw) as Partial<PeachExportEnvelope>;68 } catch {69 throw new Error("INVALID_PEACH_EXPORT");70 }7172 if (73 envelope.type !== "peach-export" ||74 envelope.kdf !== "PBKDF2-SHA256" ||75 envelope.version !== 2 ||76 !envelope.salt ||77 !envelope.nonce ||78 !envelope.ciphertext ||79 !envelope.tag80 ) {81 throw new Error("INVALID_PEACH_EXPORT");82 }8384 let plaintext: string;85 try {86 const key = await derivePeachExportKey(password, fromHex(envelope.salt));87 plaintext = await decrypt(88 key,89 envelope.nonce,90 envelope.ciphertext,91 envelope.tag92 );93 } catch {94 95 96 if (password === password.normalize("NFC")) {97 throw new Error("INVALID_EXPORT_PASSWORD");98 }99 try {100 const key = await derivePeachExportKey(password, fromHex(envelope.salt), {101 raw: true,102 });103 plaintext = await decrypt(104 key,105 envelope.nonce,106 envelope.ciphertext,107 envelope.tag108 );109 } catch {110 throw new Error("INVALID_EXPORT_PASSWORD");111 }112 }113114 let parsed: unknown;115 try {116 parsed = JSON.parse(plaintext);117 } catch {118 throw new Error("INVALID_PEACH_EXPORT");119 }120121 122 let rawEntries: unknown[];123 let licenseKey: string | undefined;124 let licenseActivatedAt: number | undefined;125126 if (Array.isArray(parsed)) {127 rawEntries = parsed;128 } else if (parsed && typeof parsed === "object" && Array.isArray((parsed as { entries?: unknown }).entries)) {129 const obj = parsed as { entries: unknown[]; license?: PeachLicenseExport };130 rawEntries = obj.entries;131 if (obj.license && typeof obj.license.key === "string") {132 licenseKey = obj.license.key;133 licenseActivatedAt = obj.license.activatedAt;134 }135 } else {136 throw new Error("INVALID_PEACH_EXPORT");137 }138139 const entries = rawEntries140 .map(normalizeImportedEntry)141 .filter((entry): entry is ImportableEntry => entry !== null);142143 return { entries, ...(licenseKey ? { licenseKey, licenseActivatedAt } : {}) };144}145146147148149150151152153154155156157158export async function derivePeachExportKey(159 password: string,160 salt: Uint8Array,161 opts: { raw?: boolean } = {}162): Promise<Uint8Array> {163 164 165 166 const pw = opts.raw ? password : password.normalize("NFC");167 const material = await crypto.subtle.importKey(168 "raw",169 new TextEncoder().encode(pw),170 "PBKDF2",171 false,172 ["deriveBits"]173 );174 const bits = await crypto.subtle.deriveBits(175 {176 name: "PBKDF2",177 salt: salt.buffer as ArrayBuffer,178 iterations: 250000,179 hash: "SHA-256",180 },181 material,182 256183 );184 return new Uint8Array(bits);185}186187function normalizeImportedEntry(value: unknown): ImportableEntry | null {188 if (!value || typeof value !== "object") return null;189190 const candidate = value as Partial<VaultEntry> & {191 name?: string;192 note?: string;193 email?: string;194 };195196 const deletedAt =197 typeof candidate.deletedAt === "number" ? candidate.deletedAt : undefined;198199 if (candidate.type === "note") {200 const title = firstNonEmptyString(201 candidate.title,202 candidate.name,203 "Untitled"204 );205 if (!title) return null;206 return {207 type: "note",208 title,209 notes: firstNonEmptyString(candidate.notes, candidate.note) || undefined,210 tags: Array.isArray(candidate.tags) ? candidate.tags : undefined,211 deletedAt,212 };213 }214215 if (candidate.type === "passkey") {216 if (217 typeof candidate.rpId !== "string" ||218 typeof candidate.rpName !== "string" ||219 typeof candidate.credentialId !== "string" ||220 typeof candidate.publicKeyCose !== "string" ||221 !candidate.privateKeyJwk ||222 typeof candidate.userHandle !== "string" ||223 typeof candidate.userName !== "string" ||224 typeof candidate.userDisplayName !== "string" ||225 (candidate.algorithm !== -7 && candidate.algorithm !== -8)226 ) {227 return null;228 }229230 return {231 type: "passkey",232 title: firstNonEmptyString(candidate.title, candidate.rpName, candidate.rpId),233 rpId: candidate.rpId,234 rpName: candidate.rpName,235 credentialId: candidate.credentialId,236 publicKeyCose: candidate.publicKeyCose,237 privateKeyJwk: candidate.privateKeyJwk,238 userHandle: candidate.userHandle,239 userName: candidate.userName,240 userDisplayName: candidate.userDisplayName,241 algorithm: candidate.algorithm,242 signCount: typeof candidate.signCount === "number" ? candidate.signCount : 0,243 aaguid:244 typeof candidate.aaguid === "string" ? candidate.aaguid : "AAAAAAAAAAAAAAAAAAAAAA",245 backupEligible: candidate.backupEligible !== false,246 backupState: candidate.backupState === true,247 lastUsedAt:248 typeof candidate.lastUsedAt === "number" ? candidate.lastUsedAt : undefined,249 deletedAt,250 };251 }252253 const password =254 typeof candidate.password === "string" ? candidate.password : "";255 if (!password) return null;256257 const title = firstNonEmptyString(258 candidate.title,259 candidate.name,260 candidate.url,261 candidate.username,262 candidate.email,263 "Untitled"264 );265266 return {267 type: "login",268 title,269 username: firstNonEmptyString(candidate.username, candidate.email, ""),270 password,271 url: firstNonEmptyString(candidate.url, ""),272 notes: firstNonEmptyString(candidate.notes, candidate.note) || undefined,273 totp: firstNonEmptyString(candidate.totp) || undefined,274 breach:275 typeof candidate.breach === "number" && candidate.breach > 0276 ? candidate.breach277 : undefined,278 canary: candidate.canary ?? undefined,279 tags: Array.isArray(candidate.tags) ? candidate.tags : undefined,280 deletedAt,281 };282}283284function firstNonEmptyString(285 ...values: Array<string | null | undefined>286): string {287 for (const value of values) {288 if (typeof value === "string" && value.trim()) {289 return value.trim();290 }291 }292 return "";293}294295function toHex(buf: Uint8Array): string {296 return Array.from(buf)297 .map((byte) => byte.toString(16).padStart(2, "0"))298 .join("");299}300301function fromHex(input: string): Uint8Array {302 const out = new Uint8Array(input.length / 2);303 for (let i = 0; i < input.length; i += 2) {304 out[i / 2] = parseInt(input.slice(i, i + 2), 16);305 }306 return out;307}