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 { 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 passphrase: string;143 argon2?: Argon2Params;144}145146export interface ExportResult {147 phrase: string;148 entryCount: number;149 cjkCharCount: number;150 loginCount: number;151 noteCount: number;152 passkeyCount: number;153 154 binary: Uint8Array;155}156157158159160161162163164165export async function exportToCJK(input: ExportInput): Promise<ExportResult> {166 const params = input.argon2 ?? ARGON2_DEFAULT;167168 const liveEntries = input.entries.filter((e) => !e.deletedAt);169 const { logins, notes, passkeys } = countsByType(liveEntries);170171 const serialised = serialiseVault(liveEntries, input.settings ?? {});172173 174 const compressed = await compressZstd(serialised, 19);175176 177 const salt = crypto.getRandomValues(new Uint8Array(SALT_LEN));178 const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LEN));179180 181 const key = await deriveKdfKey(input.passphrase, salt, params);182 try {183 184 const { ciphertext, authTag } = await encryptBinary(key, nonce, compressed);185186 187 const remainingBitsValue = remainingBitsForCiphertextLen(ciphertext.length);188189 190 const headerFields: HeaderFields = {191 entryCount: liveEntries.length,192 argon2Memory: params.memoryKiB,193 argon2Time: params.iterations,194 argon2Parallel: params.parallelism,195 salt,196 remainingBitsValue,197 };198 const header = buildHeader(headerFields);199200 201 const binary = await finalisePayload(header, nonce, ciphertext, authTag);202203 204 const cjkChars = cjkEncode(binary);205206 207 const phrase = formatPhrase(cjkChars, liveEntries.length);208209 return {210 phrase,211 entryCount: liveEntries.length,212 cjkCharCount: [...cjkChars].length,213 loginCount: logins,214 noteCount: notes,215 passkeyCount: passkeys,216 binary,217 };218 } finally {219 220 221 key.fill(0);222 }223}224225226export { PAYLOAD_FLAGS, PAYLOAD_VERSION };