123456789import { argon2id } from "hash-wasm";1011import { cjkEncode } from "./cjk";12import {13 buildHeader,14 finalisePayload,15 NONCE_LEN,16 PAYLOAD_FLAGS,17 PAYLOAD_VERSION,18 remainingBitsForCiphertextLen,19 SALT_LEN,20 type HeaderFields,21} from "./cjkPayload";22import { serialiseVault } from "./cjkSerialise";23import { compressZstd } from "./zstd";24import type { PeachLicenseExport, VaultEntry } from "../shared/types";25import type { VaultSettings } from "../background/vault";262728293031export interface Argon2Params {32 memoryKiB: number;33 iterations: number;34 parallelism: number;35}363738export const ARGON2_DEFAULT: Argon2Params = {39 memoryKiB: 65536,40 iterations: 3,41 parallelism: 4,42};434445export const ARGON2_HIGH_SECURITY: Argon2Params = {46 memoryKiB: 262144,47 iterations: 4,48 parallelism: 4,49};505152535455export async function deriveKdfKey(56 passphrase: string,57 salt: Uint8Array,58 params: Argon2Params59): Promise<Uint8Array> {60 return argon2id({61 password: passphrase,62 salt,63 parallelism: params.parallelism,64 iterations: params.iterations,65 memorySize: params.memoryKiB,66 hashLength: 32,67 outputType: "binary",68 });69}7071export async function encryptBinary(72 key: Uint8Array,73 nonce: Uint8Array,74 plaintext: Uint8Array75): Promise<{ ciphertext: Uint8Array; authTag: Uint8Array }> {76 const cryptoKey = await crypto.subtle.importKey(77 "raw",78 key.buffer.slice(key.byteOffset, key.byteOffset + key.byteLength) as ArrayBuffer,79 { name: "AES-GCM" },80 false,81 ["encrypt"]82 );8384 85 86 const combined = await crypto.subtle.encrypt(87 { name: "AES-GCM", iv: nonce.buffer as ArrayBuffer, tagLength: 128 },88 cryptoKey,89 plaintext.buffer.slice(90 plaintext.byteOffset,91 plaintext.byteOffset + plaintext.byteLength92 ) as ArrayBuffer93 );9495 const combinedBytes = new Uint8Array(combined);96 const ciphertext = combinedBytes.slice(0, combinedBytes.length - 16);97 const authTag = combinedBytes.slice(combinedBytes.length - 16);98 return { ciphertext, authTag };99}100101102103104105const LINE_WIDTH = 60;106107function buildHeaderLine(entryCount: number): string {108 return `peach:v${PAYLOAD_VERSION}:entries=${entryCount}:zstd:aes256gcm:argon2id`;109}110111function countsByType(entries: VaultEntry[]): {112 logins: number;113 notes: number;114 passkeys: number;115} {116 let logins = 0;117 let notes = 0;118 let passkeys = 0;119 for (const e of entries) {120 if (e.type === "passkey") passkeys += 1;121 else if (e.type === "note") notes += 1;122 else logins += 1;123 }124 return { logins, notes, passkeys };125}126127export function formatPhrase(cjkChars: string, entryCount: number): string {128 const lines: string[] = [buildHeaderLine(entryCount)];129 for (let i = 0; i < cjkChars.length; i += LINE_WIDTH) {130 lines.push(cjkChars.slice(i, i + LINE_WIDTH));131 }132 return lines.join("\n");133}134135136137138139export interface ExportInput {140 entries: VaultEntry[];141 settings?: VaultSettings;142 license?: PeachLicenseExport;143 passphrase: string;144 argon2?: Argon2Params;145}146147export interface ExportResult {148 phrase: string;149 150 entryCount: number;151 cjkCharCount: number;152 153 loginCount: number;154 155 noteCount: number;156 157 passkeyCount: number;158 159 trashedCount: number;160 161 binary: Uint8Array;162}163164165166167168169170export async function exportToCJK(input: ExportInput): Promise<ExportResult> {171 const params = input.argon2 ?? ARGON2_DEFAULT;172173 const allEntries = input.entries;174 const activeEntries = allEntries.filter((e) => !e.deletedAt);175 const { logins, notes, passkeys } = countsByType(activeEntries);176 const trashedCount = allEntries.length - activeEntries.length;177178 const serialised = serialiseVault(allEntries, input.settings ?? {}, input.license);179180 181 const compressed = await compressZstd(serialised, 19);182183 184 const salt = crypto.getRandomValues(new Uint8Array(SALT_LEN));185 const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LEN));186187 188 const key = await deriveKdfKey(input.passphrase, salt, params);189 try {190 191 const { ciphertext, authTag } = await encryptBinary(key, nonce, compressed);192193 194 const remainingBitsValue = remainingBitsForCiphertextLen(ciphertext.length);195196 197 const headerFields: HeaderFields = {198 entryCount: allEntries.length,199 argon2Memory: params.memoryKiB,200 argon2Time: params.iterations,201 argon2Parallel: params.parallelism,202 salt,203 remainingBitsValue,204 };205 const header = buildHeader(headerFields);206207 208 const binary = await finalisePayload(header, nonce, ciphertext, authTag);209210 211 const cjkChars = cjkEncode(binary);212213 214 const phrase = formatPhrase(cjkChars, allEntries.length);215216 return {217 phrase,218 entryCount: allEntries.length,219 cjkCharCount: [...cjkChars].length,220 loginCount: logins,221 noteCount: notes,222 passkeyCount: passkeys,223 trashedCount,224 binary,225 };226 } finally {227 228 229 key.fill(0);230 }231}232233234export { PAYLOAD_FLAGS, PAYLOAD_VERSION };