cjkPayload.test.ts

5.4 KB · raw

1/**2 * Binary payload assembly and extraction (spec §2, §10.2).3 */45import { describe, expect, it } from "vitest";67import {8  AUTH_TAG_LEN,9  buildHeader,10  CHECKSUM_LEN,11  computeBlake3,12  concatBytes,13  constantTimeEqual,14  extractFields,15  finalisePayload,16  HEADER_LEN,17  MIN_PAYLOAD_LEN,18  NONCE_LEN,19  PAYLOAD_FLAGS,20  PAYLOAD_VERSION,21  remainingBitsForCiphertextLen,22  SALT_LEN,23  type HeaderFields,24} from "./cjkPayload";2526function makeFields(overrides: Partial<HeaderFields> = {}): HeaderFields {27  return {28    entryCount: 1,29    argon2Memory: 65536,30    argon2Time: 3,31    argon2Parallel: 4,32    salt: new Uint8Array(SALT_LEN).fill(0x42),33    remainingBitsValue: 0,34    ...overrides,35  };36}3738describe("buildHeader", () => {39  it("writes the exact byte layout from §2.1", () => {40    const ciphertextLen = 128;41    const rb = remainingBitsForCiphertextLen(ciphertextLen);4243    const header = buildHeader(44      makeFields({ remainingBitsValue: rb, entryCount: 1 })45    );4647    expect(header.length).toBe(HEADER_LEN);48    const view = new DataView(49      header.buffer,50      header.byteOffset,51      header.byteLength52    );5354    expect(header[0]).toBe(PAYLOAD_VERSION);55    expect(view.getUint16(1, true)).toBe(1); // entry_count56    expect(header[3]).toBe(PAYLOAD_FLAGS);57    expect(view.getUint32(4, true)).toBe(65536); // argon2_memory58    expect(view.getUint32(8, true)).toBe(3); // argon2_time59    expect(header[12]).toBe(4); // argon2_parallel60    expect(view.getUint16(13, true)).toBe(rb);61    for (let i = 0; i < SALT_LEN; i++) {62      expect(header[15 + i]).toBe(0x42);63    }64  });6566  it("rejects wrongly-sized salts", () => {67    expect(() =>68      buildHeader(makeFields({ salt: new Uint8Array(8) }))69    ).toThrow(/SALT_LEN/);70  });7172  it("rejects entryCounts that overflow uint16", () => {73    expect(() =>74      buildHeader(makeFields({ entryCount: 70000 }))75    ).toThrow(/uint16/);76  });77});7879describe("finalisePayload + extractFields round-trip", () => {80  it("assembles header|nonce|ct|tag|checksum and extracts each field", async () => {81    const ciphertext = new Uint8Array(64);82    crypto.getRandomValues(ciphertext);83    const authTag = new Uint8Array(AUTH_TAG_LEN).fill(0xAA);84    const nonce = new Uint8Array(NONCE_LEN).fill(0x13);85    const rb = remainingBitsForCiphertextLen(ciphertext.length);8687    const header = buildHeader(88      makeFields({ entryCount: 2, remainingBitsValue: rb })89    );9091    const full = await finalisePayload(header, nonce, ciphertext, authTag);9293    // Length sanity94    expect(full.length).toBe(95      HEADER_LEN + NONCE_LEN + ciphertext.length + AUTH_TAG_LEN + CHECKSUM_LEN96    );9798    const extracted = extractFields(full);99100    expect(extracted.entryCount).toBe(2);101    expect(extracted.argon2Memory).toBe(65536);102    expect(extracted.argon2Time).toBe(3);103    expect(extracted.argon2Parallel).toBe(4);104    expect(extracted.remainingBitsValue).toBe(rb);105    expect(extracted.salt).toEqual(new Uint8Array(SALT_LEN).fill(0x42));106    expect(extracted.nonce).toEqual(nonce);107    expect(extracted.ciphertext).toEqual(ciphertext);108    expect(extracted.authTag).toEqual(authTag);109110    // Stored checksum must match a fresh BLAKE3 over the payload-minus-checksum111    const recomputed = await computeBlake3(extracted.payloadForChecksum);112    expect(constantTimeEqual(recomputed, extracted.storedChecksum)).toBe(true);113  });114115  it("rejects a truncated payload", () => {116    const tiny = new Uint8Array(MIN_PAYLOAD_LEN - 1);117    expect(() => extractFields(tiny)).toThrow(/PAYLOAD_TOO_SHORT/);118  });119120  it("rejects an unsupported version byte", async () => {121    const ciphertext = new Uint8Array(16);122    const authTag = new Uint8Array(AUTH_TAG_LEN);123    const nonce = new Uint8Array(NONCE_LEN);124    const rb = remainingBitsForCiphertextLen(ciphertext.length);125    const header = buildHeader(makeFields({ remainingBitsValue: rb }));126127    const full = await finalisePayload(header, nonce, ciphertext, authTag);128    full[0] = 0x99; // corrupt version byte129    expect(() => extractFields(full)).toThrow(/UNSUPPORTED_VERSION/);130  });131132  it("rejects unsupported flags", async () => {133    const ciphertext = new Uint8Array(16);134    const authTag = new Uint8Array(AUTH_TAG_LEN);135    const nonce = new Uint8Array(NONCE_LEN);136    const rb = remainingBitsForCiphertextLen(ciphertext.length);137    const header = buildHeader(makeFields({ remainingBitsValue: rb }));138139    const full = await finalisePayload(header, nonce, ciphertext, authTag);140    full[3] = 0x05; // corrupt flags byte141    expect(() => extractFields(full)).toThrow(/UNSUPPORTED_FLAGS/);142  });143});144145describe("constantTimeEqual", () => {146  it("returns true for identical buffers", () => {147    const a = new Uint8Array([1, 2, 3, 4]);148    const b = new Uint8Array([1, 2, 3, 4]);149    expect(constantTimeEqual(a, b)).toBe(true);150  });151152  it("returns false when any byte differs", () => {153    const a = new Uint8Array([1, 2, 3, 4]);154    const b = new Uint8Array([1, 2, 3, 5]);155    expect(constantTimeEqual(a, b)).toBe(false);156  });157158  it("returns false for different lengths", () => {159    expect(160      constantTimeEqual(new Uint8Array([1, 2]), new Uint8Array([1, 2, 3]))161    ).toBe(false);162  });163});164165describe("concatBytes", () => {166  it("concatenates in order", () => {167    const out = concatBytes([168      new Uint8Array([1, 2]),169      new Uint8Array([3]),170      new Uint8Array([4, 5, 6]),171    ]);172    expect(out).toEqual(new Uint8Array([1, 2, 3, 4, 5, 6]));173  });174});