cjkExport.ts

7.1 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 { 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  passphrase: string;143  argon2?: Argon2Params;144}145146export interface ExportResult {147  phrase: string;148  /** Total entries in the export, including trashed. */149  entryCount: number;150  cjkCharCount: number;151  /** Active (non-trashed) login count. */152  loginCount: number;153  /** Active (non-trashed) note count. */154  noteCount: number;155  /** Active (non-trashed) passkey count. */156  passkeyCount: number;157  /** Entries with deletedAt set — land back in trash on import. */158  trashedCount: number;159  /** The raw binary that `cjkEncode` processed (checksum appended). Useful for tests. */160  binary: Uint8Array;161}162163/**164 * Run the full export pipeline end-to-end. Includes all entries — active165 * and trashed — so a restore is a complete, lossless round-trip. Trashed166 * entries arrive back in trash (deletedAt preserved). `settings` is167 * round-tripped verbatim so a restore carries theme, auto-lock, etc.168 */169export async function exportToCJK(input: ExportInput): Promise<ExportResult> {170  const params = input.argon2 ?? ARGON2_DEFAULT;171172  const allEntries = input.entries;173  const activeEntries = allEntries.filter((e) => !e.deletedAt);174  const { logins, notes, passkeys } = countsByType(activeEntries);175  const trashedCount = allEntries.length - activeEntries.length;176177  const serialised = serialiseVault(allEntries, input.settings ?? {});178179  // Step 3: compress180  const compressed = await compressZstd(serialised, 19);181182  // Step 4: fresh salt and nonce — independent of the at-rest vault183  const salt = crypto.getRandomValues(new Uint8Array(SALT_LEN));184  const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LEN));185186  // Step 5: derive key187  const key = await deriveKdfKey(input.passphrase, salt, params);188  try {189    // Step 6: encrypt (no AAD)190    const { ciphertext, authTag } = await encryptBinary(key, nonce, compressed);191192    // Step 10 (resolved): remaining_bits derives from the final byte count193    const remainingBitsValue = remainingBitsForCiphertextLen(ciphertext.length);194195    // Step 7: assemble header196    const headerFields: HeaderFields = {197      entryCount: allEntries.length,198      argon2Memory: params.memoryKiB,199      argon2Time: params.iterations,200      argon2Parallel: params.parallelism,201      salt,202      remainingBitsValue,203    };204    const header = buildHeader(headerFields);205206    // Step 8+9: finalise (concat + BLAKE3 checksum append)207    const binary = await finalisePayload(header, nonce, ciphertext, authTag);208209    // Step 11: encode210    const cjkChars = cjkEncode(binary);211212    // Step 12: format213    const phrase = formatPhrase(cjkChars, allEntries.length);214215    return {216      phrase,217      entryCount: allEntries.length,218      cjkCharCount: [...cjkChars].length,219      loginCount: logins,220      noteCount: notes,221      passkeyCount: passkeys,222      trashedCount,223      binary,224    };225  } finally {226    // Zero the derived key promptly. The underlying Uint8Array was produced227    // by hash-wasm and is not referenced anywhere else after encryptBinary.228    key.fill(0);229  }230}231232// Re-export flag constants callers may want for assertions233export { PAYLOAD_FLAGS, PAYLOAD_VERSION };