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}145146export async function derivePeachExportKey(147 password: string,148 salt: Uint8Array,149 opts: { raw?: boolean } = {}150): Promise<Uint8Array> {151 152 153 154 const pw = opts.raw ? password : password.normalize("NFC");155 const material = await crypto.subtle.importKey(156 "raw",157 new TextEncoder().encode(pw),158 "PBKDF2",159 false,160 ["deriveBits"]161 );162 const bits = await crypto.subtle.deriveBits(163 {164 name: "PBKDF2",165 salt: salt.buffer as ArrayBuffer,166 iterations: 250000,167 hash: "SHA-256",168 },169 material,170 256171 );172 return new Uint8Array(bits);173}174175function normalizeImportedEntry(value: unknown): ImportableEntry | null {176 if (!value || typeof value !== "object") return null;177178 const candidate = value as Partial<VaultEntry> & {179 name?: string;180 note?: string;181 email?: string;182 };183184 const deletedAt =185 typeof candidate.deletedAt === "number" ? candidate.deletedAt : undefined;186187 if (candidate.type === "note") {188 const title = firstNonEmptyString(189 candidate.title,190 candidate.name,191 "Untitled"192 );193 if (!title) return null;194 return {195 type: "note",196 title,197 notes: firstNonEmptyString(candidate.notes, candidate.note) || undefined,198 tags: Array.isArray(candidate.tags) ? candidate.tags : undefined,199 deletedAt,200 };201 }202203 if (candidate.type === "passkey") {204 if (205 typeof candidate.rpId !== "string" ||206 typeof candidate.rpName !== "string" ||207 typeof candidate.credentialId !== "string" ||208 typeof candidate.publicKeyCose !== "string" ||209 !candidate.privateKeyJwk ||210 typeof candidate.userHandle !== "string" ||211 typeof candidate.userName !== "string" ||212 typeof candidate.userDisplayName !== "string" ||213 (candidate.algorithm !== -7 && candidate.algorithm !== -8)214 ) {215 return null;216 }217218 return {219 type: "passkey",220 title: firstNonEmptyString(candidate.title, candidate.rpName, candidate.rpId),221 rpId: candidate.rpId,222 rpName: candidate.rpName,223 credentialId: candidate.credentialId,224 publicKeyCose: candidate.publicKeyCose,225 privateKeyJwk: candidate.privateKeyJwk,226 userHandle: candidate.userHandle,227 userName: candidate.userName,228 userDisplayName: candidate.userDisplayName,229 algorithm: candidate.algorithm,230 signCount: typeof candidate.signCount === "number" ? candidate.signCount : 0,231 aaguid:232 typeof candidate.aaguid === "string" ? candidate.aaguid : "AAAAAAAAAAAAAAAAAAAAAA",233 backupEligible: candidate.backupEligible !== false,234 backupState: candidate.backupState === true,235 lastUsedAt:236 typeof candidate.lastUsedAt === "number" ? candidate.lastUsedAt : undefined,237 deletedAt,238 };239 }240241 const password =242 typeof candidate.password === "string" ? candidate.password : "";243 if (!password) return null;244245 const title = firstNonEmptyString(246 candidate.title,247 candidate.name,248 candidate.url,249 candidate.username,250 candidate.email,251 "Untitled"252 );253254 return {255 type: "login",256 title,257 username: firstNonEmptyString(candidate.username, candidate.email, ""),258 password,259 url: firstNonEmptyString(candidate.url, ""),260 notes: firstNonEmptyString(candidate.notes, candidate.note) || undefined,261 totp: firstNonEmptyString(candidate.totp) || undefined,262 breach:263 typeof candidate.breach === "number" && candidate.breach > 0264 ? candidate.breach265 : undefined,266 canary: candidate.canary ?? undefined,267 tags: Array.isArray(candidate.tags) ? candidate.tags : undefined,268 deletedAt,269 };270}271272function firstNonEmptyString(273 ...values: Array<string | null | undefined>274): string {275 for (const value of values) {276 if (typeof value === "string" && value.trim()) {277 return value.trim();278 }279 }280 return "";281}282283function toHex(buf: Uint8Array): string {284 return Array.from(buf)285 .map((byte) => byte.toString(16).padStart(2, "0"))286 .join("");287}288289function fromHex(input: string): Uint8Array {290 const out = new Uint8Array(input.length / 2);291 for (let i = 0; i < input.length; i += 2) {292 out[i / 2] = parseInt(input.slice(i, i + 2), 16);293 }294 return out;295}