cjkPayload.ts

7.9 KB · raw

1/**2 * Binary payload layout for a CJK phrase (spec §2).3 *4 *   [31 bytes: header]5 *     [1]  version          = 0x026 *     [2]  entry_count      (uint16 LE)7 *     [1]  flags            = 0x01 (zstd + AES-256-GCM)8 *     [4]  argon2_memory    (uint32 LE, KiB)9 *     [4]  argon2_time      (uint32 LE)10 *     [1]  argon2_parallel  (uint8)11 *     [2]  remaining_bits   (uint16 LE)12 *     [16] salt             (CSPRNG, per-export)13 *14 *   [12 bytes: nonce]       (CSPRNG, per-export)15 *   [N bytes: ciphertext]   (AES-256-GCM)16 *   [16 bytes: auth_tag]    (AES-256-GCM tag)17 *   [32 bytes: checksum]    (BLAKE3 of everything above)18 *19 * The checksum covers every byte of the payload except itself. It is20 * verified before the user is prompted for the passphrase, so a corrupt or21 * truncated phrase fails fast with a clear error.22 */2324import { blake3 } from "hash-wasm";25import { remainingBits } from "./cjk";2627export const PAYLOAD_VERSION = 0x03;28export const PAYLOAD_FLAGS = 0x01; // zstd + AES-256-GCM2930export const HEADER_LEN = 31;31export const NONCE_LEN = 12;32export const AUTH_TAG_LEN = 16;33export const CHECKSUM_LEN = 32;34export const SALT_LEN = 16;3536/** Minimum valid payload: header + nonce + 0-byte ciphertext + tag + checksum. */37export const MIN_PAYLOAD_LEN = HEADER_LEN + NONCE_LEN + AUTH_TAG_LEN + CHECKSUM_LEN;3839// ---------------------------------------------------------------------------40// Header-driven field positions, so the rest of the module has a single41// source of truth for where things live.42// ---------------------------------------------------------------------------4344const OFFSET_VERSION = 0;45const OFFSET_ENTRY_COUNT = 1;46const OFFSET_FLAGS = 3;47const OFFSET_ARGON2_MEMORY = 4;48const OFFSET_ARGON2_TIME = 8;49const OFFSET_ARGON2_PARALLEL = 12;50const OFFSET_REMAINING_BITS = 13;51const OFFSET_SALT = 15;5253// ---------------------------------------------------------------------------54// Assembly55// ---------------------------------------------------------------------------5657export interface HeaderFields {58  entryCount: number;59  argon2Memory: number;60  argon2Time: number;61  argon2Parallel: number;62  salt: Uint8Array;63  remainingBitsValue: number;64}6566/**67 * Build the 31-byte header exactly as specified in §2.1.68 */69export function buildHeader(fields: HeaderFields): Uint8Array {70  if (fields.salt.length !== SALT_LEN) {71    throw new Error(`SALT_LEN must be ${SALT_LEN}, got ${fields.salt.length}`);72  }73  if (fields.entryCount > 0xffff) {74    throw new Error("entryCount exceeds uint16 range");75  }7677  const header = new Uint8Array(HEADER_LEN);78  const view = new DataView(header.buffer);7980  header[OFFSET_VERSION] = PAYLOAD_VERSION;81  view.setUint16(OFFSET_ENTRY_COUNT, fields.entryCount, true);82  header[OFFSET_FLAGS] = PAYLOAD_FLAGS;83  view.setUint32(OFFSET_ARGON2_MEMORY, fields.argon2Memory, true);84  view.setUint32(OFFSET_ARGON2_TIME, fields.argon2Time, true);85  header[OFFSET_ARGON2_PARALLEL] = fields.argon2Parallel;86  view.setUint16(OFFSET_REMAINING_BITS, fields.remainingBitsValue, true);87  header.set(fields.salt, OFFSET_SALT);8889  return header;90}9192/**93 * Compute `remaining_bits` for the fully-assembled binary given just the94 * ciphertext length. This resolves the circular dependency called out in95 * spec §4.1 Step 10: remaining_bits depends on the final byte count, but is96 * itself stored in the header. The total byte count is deterministic once97 * the ciphertext length is known.98 */99export function remainingBitsForCiphertextLen(ciphertextLen: number): number {100  return remainingBits(HEADER_LEN + NONCE_LEN + ciphertextLen + AUTH_TAG_LEN + CHECKSUM_LEN);101}102103/**104 * Concatenate header | nonce | ciphertext | auth_tag, and append the BLAKE3105 * checksum. Returns the full binary that the CJK encoder will turn into106 * characters.107 */108export async function finalisePayload(109  header: Uint8Array,110  nonce: Uint8Array,111  ciphertext: Uint8Array,112  authTag: Uint8Array113): Promise<Uint8Array> {114  if (header.length !== HEADER_LEN) throw new Error("header wrong length");115  if (nonce.length !== NONCE_LEN) throw new Error("nonce wrong length");116  if (authTag.length !== AUTH_TAG_LEN) throw new Error("authTag wrong length");117118  const withoutChecksum = concatBytes([header, nonce, ciphertext, authTag]);119  const checksum = await computeBlake3(withoutChecksum);120121  return concatBytes([withoutChecksum, checksum]);122}123124// ---------------------------------------------------------------------------125// Extraction126// ---------------------------------------------------------------------------127128export interface ExtractedFields extends HeaderFields {129  nonce: Uint8Array;130  ciphertext: Uint8Array;131  authTag: Uint8Array;132  storedChecksum: Uint8Array;133  /** Bytes over which BLAKE3 should be recomputed (everything except the checksum). */134  payloadForChecksum: Uint8Array;135}136137/**138 * Parse the binary blob that came out of `cjkDecode`. Throws for any139 * structural problem — unknown version, unsupported flags, truncated140 * payload — using the exact error codes the import UI surfaces.141 */142export function extractFields(binary: Uint8Array): ExtractedFields {143  if (binary.length < MIN_PAYLOAD_LEN) {144    throw new Error("PAYLOAD_TOO_SHORT");145  }146147  const view = new DataView(binary.buffer, binary.byteOffset, binary.byteLength);148149  const version = binary[OFFSET_VERSION];150  if (version !== PAYLOAD_VERSION) {151    throw new Error(`UNSUPPORTED_VERSION: ${version}`);152  }153154  const flags = binary[OFFSET_FLAGS];155  if (flags !== PAYLOAD_FLAGS) {156    throw new Error(`UNSUPPORTED_FLAGS: ${flags}`);157  }158159  const entryCount = view.getUint16(OFFSET_ENTRY_COUNT, true);160  const argon2Memory = view.getUint32(OFFSET_ARGON2_MEMORY, true);161  const argon2Time = view.getUint32(OFFSET_ARGON2_TIME, true);162  const argon2Parallel = binary[OFFSET_ARGON2_PARALLEL];163  const remainingBitsValue = view.getUint16(OFFSET_REMAINING_BITS, true);164  const salt = binary.slice(OFFSET_SALT, OFFSET_SALT + SALT_LEN);165166  const nonceStart = HEADER_LEN;167  const nonceEnd = nonceStart + NONCE_LEN;168  const nonce = binary.slice(nonceStart, nonceEnd);169170  const checksumStart = binary.length - CHECKSUM_LEN;171  const authTagStart = checksumStart - AUTH_TAG_LEN;172173  if (authTagStart < nonceEnd) {174    throw new Error("PAYLOAD_TOO_SHORT");175  }176177  const ciphertext = binary.slice(nonceEnd, authTagStart);178  const authTag = binary.slice(authTagStart, checksumStart);179  const storedChecksum = binary.slice(checksumStart);180  const payloadForChecksum = binary.slice(0, checksumStart);181182  return {183    entryCount,184    argon2Memory,185    argon2Time,186    argon2Parallel,187    salt,188    remainingBitsValue,189    nonce,190    ciphertext,191    authTag,192    storedChecksum,193    payloadForChecksum,194  };195}196197// ---------------------------------------------------------------------------198// BLAKE3199// ---------------------------------------------------------------------------200201export async function computeBlake3(data: Uint8Array): Promise<Uint8Array> {202  const hex = await blake3(data);203  return hexToBytes(hex);204}205206/**207 * Constant-time equality for Uint8Arrays. Required for checksum verification208 * — an early-exit comparison leaks timing information about how many leading209 * bytes of a tampered checksum matched the computed one.210 */211export function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {212  if (a.length !== b.length) return false;213  let diff = 0;214  for (let i = 0; i < a.length; i++) {215    diff |= a[i] ^ b[i];216  }217  return diff === 0;218}219220// ---------------------------------------------------------------------------221// Byte utilities222// ---------------------------------------------------------------------------223224export function concatBytes(chunks: Uint8Array[]): Uint8Array {225  let total = 0;226  for (const c of chunks) total += c.length;227  const out = new Uint8Array(total);228  let offset = 0;229  for (const c of chunks) {230    out.set(c, offset);231    offset += c.length;232  }233  return out;234}235236function hexToBytes(hex: string): Uint8Array {237  const out = new Uint8Array(hex.length / 2);238  for (let i = 0; i < hex.length; i += 2) {239    out[i / 2] = parseInt(hex.slice(i, i + 2), 16);240  }241  return out;242}