cjkExport.ts

7.2 KB · raw

1/**2 * Export pipeline for a CJK phrase backup (spec §4.1, §5).3 *4 * The 12 steps defined in the spec must execute in order. Each one has a5 * reason the next one depends on. `exportToCJK` is the public entry point;6 * the sub-helpers are exported for direct use by the test vectors.7 */89import { 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";2627// ---------------------------------------------------------------------------28// Argon2id parameter sets29// ---------------------------------------------------------------------------3031export interface Argon2Params {32  memoryKiB: number;33  iterations: number;34  parallelism: number;35}3637/** Default set — ~1 second on modern hardware. */38export const ARGON2_DEFAULT: Argon2Params = {39  memoryKiB: 65536,40  iterations: 3,41  parallelism: 4,42};4344/** High-security set — ~3 seconds, 256 MB memory. */45export const ARGON2_HIGH_SECURITY: Argon2Params = {46  memoryKiB: 262144,47  iterations: 4,48  parallelism: 4,49};5051// ---------------------------------------------------------------------------52// Low-level helpers used by both the export pipeline and the test vectors53// ---------------------------------------------------------------------------5455export 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  // AES-GCM with no AAD (spec §4.1 Step 6 — the header version byte is85  // protected by the BLAKE3 checksum, not by AEAD-AAD binding).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}100101// ---------------------------------------------------------------------------102// Phrase formatting103// ---------------------------------------------------------------------------104105const 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}134135// ---------------------------------------------------------------------------136// Top-level export137// ---------------------------------------------------------------------------138139export 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  /** Total entries in the export, including trashed. */150  entryCount: number;151  cjkCharCount: number;152  /** Active (non-trashed) login count. */153  loginCount: number;154  /** Active (non-trashed) note count. */155  noteCount: number;156  /** Active (non-trashed) passkey count. */157  passkeyCount: number;158  /** Entries with deletedAt set — land back in trash on import. */159  trashedCount: number;160  /** The raw binary that `cjkEncode` processed (checksum appended). Useful for tests. */161  binary: Uint8Array;162}163164/**165 * Run the full export pipeline end-to-end. Includes all entries — active166 * and trashed — so a restore is a complete, lossless round-trip. Trashed167 * entries arrive back in trash (deletedAt preserved). `settings` is168 * round-tripped verbatim so a restore carries theme, auto-lock, etc.169 */170export 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  // Step 3: compress181  const compressed = await compressZstd(serialised, 19);182183  // Step 4: fresh salt and nonce — independent of the at-rest vault184  const salt = crypto.getRandomValues(new Uint8Array(SALT_LEN));185  const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LEN));186187  // Step 5: derive key188  const key = await deriveKdfKey(input.passphrase, salt, params);189  try {190    // Step 6: encrypt (no AAD)191    const { ciphertext, authTag } = await encryptBinary(key, nonce, compressed);192193    // Step 10 (resolved): remaining_bits derives from the final byte count194    const remainingBitsValue = remainingBitsForCiphertextLen(ciphertext.length);195196    // Step 7: assemble header197    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    // Step 8+9: finalise (concat + BLAKE3 checksum append)208    const binary = await finalisePayload(header, nonce, ciphertext, authTag);209210    // Step 11: encode211    const cjkChars = cjkEncode(binary);212213    // Step 12: format214    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    // Zero the derived key promptly. The underlying Uint8Array was produced228    // by hash-wasm and is not referenced anywhere else after encryptBinary.229    key.fill(0);230  }231}232233// Re-export flag constants callers may want for assertions234export { PAYLOAD_FLAGS, PAYLOAD_VERSION };