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 throw new Error("INVALID_EXPORT_PASSWORD");95 }9697 let parsed: unknown;98 try {99 parsed = JSON.parse(plaintext);100 } catch {101 throw new Error("INVALID_PEACH_EXPORT");102 }103104 105 let rawEntries: unknown[];106 let licenseKey: string | undefined;107 let licenseActivatedAt: number | undefined;108109 if (Array.isArray(parsed)) {110 rawEntries = parsed;111 } else if (parsed && typeof parsed === "object" && Array.isArray((parsed as { entries?: unknown }).entries)) {112 const obj = parsed as { entries: unknown[]; license?: PeachLicenseExport };113 rawEntries = obj.entries;114 if (obj.license && typeof obj.license.key === "string") {115 licenseKey = obj.license.key;116 licenseActivatedAt = obj.license.activatedAt;117 }118 } else {119 throw new Error("INVALID_PEACH_EXPORT");120 }121122 const entries = rawEntries123 .map(normalizeImportedEntry)124 .filter((entry): entry is ImportableEntry => entry !== null);125126 return { entries, ...(licenseKey ? { licenseKey, licenseActivatedAt } : {}) };127}128129export async function derivePeachExportKey(130 password: string,131 salt: Uint8Array132): Promise<Uint8Array> {133 const material = await crypto.subtle.importKey(134 "raw",135 new TextEncoder().encode(password),136 "PBKDF2",137 false,138 ["deriveBits"]139 );140 const bits = await crypto.subtle.deriveBits(141 {142 name: "PBKDF2",143 salt: salt.buffer as ArrayBuffer,144 iterations: 250000,145 hash: "SHA-256",146 },147 material,148 256149 );150 return new Uint8Array(bits);151}152153function normalizeImportedEntry(value: unknown): ImportableEntry | null {154 if (!value || typeof value !== "object") return null;155156 const candidate = value as Partial<VaultEntry> & {157 name?: string;158 note?: string;159 email?: string;160 };161162 const deletedAt =163 typeof candidate.deletedAt === "number" ? candidate.deletedAt : undefined;164165 if (candidate.type === "note") {166 const title = firstNonEmptyString(167 candidate.title,168 candidate.name,169 "Untitled"170 );171 if (!title) return null;172 return {173 type: "note",174 title,175 notes: firstNonEmptyString(candidate.notes, candidate.note) || undefined,176 tags: Array.isArray(candidate.tags) ? candidate.tags : undefined,177 deletedAt,178 };179 }180181 if (candidate.type === "passkey") {182 if (183 typeof candidate.rpId !== "string" ||184 typeof candidate.rpName !== "string" ||185 typeof candidate.credentialId !== "string" ||186 typeof candidate.publicKeyCose !== "string" ||187 !candidate.privateKeyJwk ||188 typeof candidate.userHandle !== "string" ||189 typeof candidate.userName !== "string" ||190 typeof candidate.userDisplayName !== "string" ||191 (candidate.algorithm !== -7 && candidate.algorithm !== -8)192 ) {193 return null;194 }195196 return {197 type: "passkey",198 title: firstNonEmptyString(candidate.title, candidate.rpName, candidate.rpId),199 rpId: candidate.rpId,200 rpName: candidate.rpName,201 credentialId: candidate.credentialId,202 publicKeyCose: candidate.publicKeyCose,203 privateKeyJwk: candidate.privateKeyJwk,204 userHandle: candidate.userHandle,205 userName: candidate.userName,206 userDisplayName: candidate.userDisplayName,207 algorithm: candidate.algorithm,208 signCount: typeof candidate.signCount === "number" ? candidate.signCount : 0,209 aaguid:210 typeof candidate.aaguid === "string" ? candidate.aaguid : "AAAAAAAAAAAAAAAAAAAAAA",211 backupEligible: candidate.backupEligible !== false,212 backupState: candidate.backupState === true,213 lastUsedAt:214 typeof candidate.lastUsedAt === "number" ? candidate.lastUsedAt : undefined,215 deletedAt,216 };217 }218219 const password =220 typeof candidate.password === "string" ? candidate.password : "";221 if (!password) return null;222223 const title = firstNonEmptyString(224 candidate.title,225 candidate.name,226 candidate.url,227 candidate.username,228 candidate.email,229 "Untitled"230 );231232 return {233 type: "login",234 title,235 username: firstNonEmptyString(candidate.username, candidate.email, ""),236 password,237 url: firstNonEmptyString(candidate.url, ""),238 notes: firstNonEmptyString(candidate.notes, candidate.note) || undefined,239 totp: firstNonEmptyString(candidate.totp) || undefined,240 breach:241 typeof candidate.breach === "number" && candidate.breach > 0242 ? candidate.breach243 : undefined,244 canary: candidate.canary ?? undefined,245 tags: Array.isArray(candidate.tags) ? candidate.tags : undefined,246 deletedAt,247 };248}249250function firstNonEmptyString(251 ...values: Array<string | null | undefined>252): string {253 for (const value of values) {254 if (typeof value === "string" && value.trim()) {255 return value.trim();256 }257 }258 return "";259}260261function toHex(buf: Uint8Array): string {262 return Array.from(buf)263 .map((byte) => byte.toString(16).padStart(2, "0"))264 .join("");265}266267function fromHex(input: string): Uint8Array {268 const out = new Uint8Array(input.length / 2);269 for (let i = 0; i < input.length; i += 2) {270 out[i / 2] = parseInt(input.slice(i, i + 2), 16);271 }272 return out;273}