@webbuf/aescbc
Version:
Rust/wasm optimized AES+CBC encryption/decryption for web, node.js, deno and bun.
544 lines (449 loc) • 19.6 kB
text/typescript
/**
* Audit tests for @webbuf/aescbc
*
* These tests verify the AES-CBC implementation against:
* 1. NIST CAVP test vectors
* 2. Web Crypto API interoperability
* 3. Property-based tests for correctness
*/
import { describe, it, expect } from "vitest";
import { aescbcEncrypt, aescbcDecrypt } from "../src/index.js";
import { WebBuf } from "@webbuf/webbuf";
import { FixedBuf } from "@webbuf/fixedbuf";
// Helper to encrypt with Web Crypto API
async function webCryptoEncrypt(
plaintext: Uint8Array,
key: Uint8Array,
iv: Uint8Array,
): Promise<Uint8Array> {
const cryptoKey = await crypto.subtle.importKey(
"raw",
key as Uint8Array<ArrayBuffer>,
{ name: "AES-CBC" },
false,
["encrypt"],
);
const ciphertext = await crypto.subtle.encrypt(
{ name: "AES-CBC", iv: iv as Uint8Array<ArrayBuffer> },
cryptoKey,
plaintext as Uint8Array<ArrayBuffer>,
);
return new Uint8Array(ciphertext);
}
// Helper to decrypt with Web Crypto API
async function webCryptoDecrypt(
ciphertext: Uint8Array,
key: Uint8Array,
iv: Uint8Array,
): Promise<Uint8Array> {
const cryptoKey = await crypto.subtle.importKey(
"raw",
key as Uint8Array<ArrayBuffer>,
{ name: "AES-CBC" },
false,
["decrypt"],
);
const plaintext = await crypto.subtle.decrypt(
{ name: "AES-CBC", iv: iv as Uint8Array<ArrayBuffer> },
cryptoKey,
ciphertext as Uint8Array<ArrayBuffer>,
);
return new Uint8Array(plaintext);
}
describe("Audit: NIST CAVP AES-CBC test vectors", () => {
// Test vectors from NIST SP 800-38A
// https://csrc.nist.gov/projects/cryptographic-algorithm-validation-program/block-ciphers
//
// NOTE: NIST test vectors are for raw AES-CBC without PKCS7 padding.
// The webbuf implementation always applies PKCS7 padding (as is standard for AES-CBC).
// Therefore:
// 1. Encryption output will have an extra 16-byte padding block appended
// 2. We verify that the first N bytes match the NIST expected ciphertext
// 3. We verify decryption via round-trip rather than against unpadded NIST vectors
describe("AES-128-CBC", () => {
const key = FixedBuf.fromHex(16, "2b7e151628aed2a6abf7158809cf4f3c");
const iv = FixedBuf.fromHex(16, "000102030405060708090a0b0c0d0e0f");
const plaintext = WebBuf.fromHex(
"6bc1bee22e409f96e93d7e117393172a" +
"ae2d8a571e03ac9c9eb76fac45af8e51" +
"30c81c46a35ce411e5fbc1191a0a52ef" +
"f69f2445df4f9b17ad2b417be66c3710",
);
const expectedCiphertext = WebBuf.fromHex(
"7649abac8119b246cee98e9b12e9197d" +
"5086cb9b507219ee95db113a917678b2" +
"73bed6b8e3c1743b7116e69e22229516" +
"3ff1caa1681fac09120eca307586e1a7",
);
it("should encrypt correctly (first 64 bytes match NIST vector)", () => {
const result = aescbcEncrypt(plaintext, key, iv);
// Result includes IV prefix, then ciphertext + PKCS7 padding block
const ciphertext = result.slice(16);
// First 64 bytes should match NIST vector exactly
expect(ciphertext.slice(0, 64).toHex()).toBe(expectedCiphertext.toHex());
// Total should be 80 bytes (64 + 16 padding block)
expect(ciphertext.length).toBe(80);
});
it("should round-trip correctly", () => {
const encrypted = aescbcEncrypt(plaintext, key, iv);
const decrypted = aescbcDecrypt(encrypted, key);
expect(decrypted.toHex()).toBe(plaintext.toHex());
});
it("should match Web Crypto for NIST plaintext", async () => {
const encrypted = aescbcEncrypt(plaintext, key, iv);
const ciphertext = encrypted.slice(16);
const webCryptoCiphertext = await webCryptoEncrypt(plaintext, key.buf, iv.buf);
expect(ciphertext.toHex()).toBe(WebBuf.fromUint8Array(webCryptoCiphertext).toHex());
});
});
describe("AES-192-CBC", () => {
const key = FixedBuf.fromHex(24, "8e73b0f7da0e6452c810f32b809079e562f8ead2522c6b7b");
const iv = FixedBuf.fromHex(16, "000102030405060708090a0b0c0d0e0f");
const plaintext = WebBuf.fromHex(
"6bc1bee22e409f96e93d7e117393172a" +
"ae2d8a571e03ac9c9eb76fac45af8e51" +
"30c81c46a35ce411e5fbc1191a0a52ef" +
"f69f2445df4f9b17ad2b417be66c3710",
);
const expectedCiphertext = WebBuf.fromHex(
"4f021db243bc633d7178183a9fa071e8" +
"b4d9ada9ad7dedf4e5e738763f69145a" +
"571b242012fb7ae07fa9baac3df102e0" +
"08b0e27988598881d920a9e64f5615cd",
);
it("should encrypt correctly (first 64 bytes match NIST vector)", () => {
const result = aescbcEncrypt(plaintext, key, iv);
const ciphertext = result.slice(16);
expect(ciphertext.slice(0, 64).toHex()).toBe(expectedCiphertext.toHex());
expect(ciphertext.length).toBe(80);
});
it("should round-trip correctly", () => {
const encrypted = aescbcEncrypt(plaintext, key, iv);
const decrypted = aescbcDecrypt(encrypted, key);
expect(decrypted.toHex()).toBe(plaintext.toHex());
});
it("should match Web Crypto for NIST plaintext", async () => {
const encrypted = aescbcEncrypt(plaintext, key, iv);
const ciphertext = encrypted.slice(16);
const webCryptoCiphertext = await webCryptoEncrypt(plaintext, key.buf, iv.buf);
expect(ciphertext.toHex()).toBe(WebBuf.fromUint8Array(webCryptoCiphertext).toHex());
});
});
describe("AES-256-CBC", () => {
const key = FixedBuf.fromHex(
32,
"603deb1015ca71be2b73aef0857d77811f352c073b6108d72d9810a30914dff4",
);
const iv = FixedBuf.fromHex(16, "000102030405060708090a0b0c0d0e0f");
const plaintext = WebBuf.fromHex(
"6bc1bee22e409f96e93d7e117393172a" +
"ae2d8a571e03ac9c9eb76fac45af8e51" +
"30c81c46a35ce411e5fbc1191a0a52ef" +
"f69f2445df4f9b17ad2b417be66c3710",
);
const expectedCiphertext = WebBuf.fromHex(
"f58c4c04d6e5f1ba779eabfb5f7bfbd6" +
"9cfc4e967edb808d679f777bc6702c7d" +
"39f23369a9d9bacfa530e26304231461" +
"b2eb05e2c39be9fcda6c19078c6a9d1b",
);
it("should encrypt correctly (first 64 bytes match NIST vector)", () => {
const result = aescbcEncrypt(plaintext, key, iv);
const ciphertext = result.slice(16);
expect(ciphertext.slice(0, 64).toHex()).toBe(expectedCiphertext.toHex());
expect(ciphertext.length).toBe(80);
});
it("should round-trip correctly", () => {
const encrypted = aescbcEncrypt(plaintext, key, iv);
const decrypted = aescbcDecrypt(encrypted, key);
expect(decrypted.toHex()).toBe(plaintext.toHex());
});
it("should match Web Crypto for NIST plaintext", async () => {
const encrypted = aescbcEncrypt(plaintext, key, iv);
const ciphertext = encrypted.slice(16);
const webCryptoCiphertext = await webCryptoEncrypt(plaintext, key.buf, iv.buf);
expect(ciphertext.toHex()).toBe(WebBuf.fromUint8Array(webCryptoCiphertext).toHex());
});
});
});
describe("Audit: Web Crypto interoperability", () => {
describe("encrypt with webbuf, decrypt with Web Crypto", () => {
it("should work with AES-128", async () => {
const key = FixedBuf.fromRandom(16);
const iv = FixedBuf.fromRandom(16);
const plaintext = WebBuf.fromUtf8("Hello, Web Crypto!");
const encrypted = aescbcEncrypt(plaintext, key, iv);
// Extract IV and ciphertext
const extractedIv = encrypted.slice(0, 16);
const ciphertext = encrypted.slice(16);
const decrypted = await webCryptoDecrypt(ciphertext, key.buf, extractedIv);
expect(WebBuf.fromUint8Array(decrypted).toUtf8()).toBe("Hello, Web Crypto!");
});
it("should work with AES-256", async () => {
const key = FixedBuf.fromRandom(32);
const iv = FixedBuf.fromRandom(16);
const plaintext = WebBuf.fromUtf8("Hello, Web Crypto with AES-256!");
const encrypted = aescbcEncrypt(plaintext, key, iv);
const extractedIv = encrypted.slice(0, 16);
const ciphertext = encrypted.slice(16);
const decrypted = await webCryptoDecrypt(ciphertext, key.buf, extractedIv);
expect(WebBuf.fromUint8Array(decrypted).toUtf8()).toBe(
"Hello, Web Crypto with AES-256!",
);
});
});
describe("encrypt with Web Crypto, decrypt with webbuf", () => {
it("should work with AES-128", async () => {
const key = FixedBuf.fromRandom(16);
const iv = FixedBuf.fromRandom(16);
const plaintext = WebBuf.fromUtf8("Hello from Web Crypto!");
const ciphertext = await webCryptoEncrypt(plaintext, key.buf, iv.buf);
const ciphertextWithIv = WebBuf.concat([
iv.buf,
WebBuf.fromUint8Array(ciphertext),
]);
const decrypted = aescbcDecrypt(ciphertextWithIv, key);
expect(decrypted.toUtf8()).toBe("Hello from Web Crypto!");
});
it("should work with AES-256", async () => {
const key = FixedBuf.fromRandom(32);
const iv = FixedBuf.fromRandom(16);
const plaintext = WebBuf.fromUtf8("Hello from Web Crypto AES-256!");
const ciphertext = await webCryptoEncrypt(plaintext, key.buf, iv.buf);
const ciphertextWithIv = WebBuf.concat([
iv.buf,
WebBuf.fromUint8Array(ciphertext),
]);
const decrypted = aescbcDecrypt(ciphertextWithIv, key);
expect(decrypted.toUtf8()).toBe("Hello from Web Crypto AES-256!");
});
});
describe("ciphertext comparison", () => {
it("should produce identical ciphertext as Web Crypto for AES-128", async () => {
const key = FixedBuf.fromRandom(16);
const iv = FixedBuf.fromRandom(16);
const plaintext = WebBuf.fromUtf8("Test message for comparison");
const webbufEncrypted = aescbcEncrypt(plaintext, key, iv);
const webbufCiphertext = webbufEncrypted.slice(16);
const webCryptoCiphertext = await webCryptoEncrypt(plaintext, key.buf, iv.buf);
expect(webbufCiphertext.toHex()).toBe(
WebBuf.fromUint8Array(webCryptoCiphertext).toHex(),
);
});
it("should produce identical ciphertext as Web Crypto for AES-256", async () => {
const key = FixedBuf.fromRandom(32);
const iv = FixedBuf.fromRandom(16);
const plaintext = WebBuf.fromUtf8("Test message for AES-256 comparison");
const webbufEncrypted = aescbcEncrypt(plaintext, key, iv);
const webbufCiphertext = webbufEncrypted.slice(16);
const webCryptoCiphertext = await webCryptoEncrypt(plaintext, key.buf, iv.buf);
expect(webbufCiphertext.toHex()).toBe(
WebBuf.fromUint8Array(webCryptoCiphertext).toHex(),
);
});
});
});
describe("Audit: PKCS7 padding", () => {
it("should correctly pad and unpad empty plaintext", () => {
const key = FixedBuf.fromRandom(32);
const iv = FixedBuf.fromRandom(16);
const plaintext = WebBuf.alloc(0);
const encrypted = aescbcEncrypt(plaintext, key, iv);
// Empty plaintext with PKCS7 padding becomes one full block (16 bytes)
expect(encrypted.length).toBe(16 + 16); // IV + one padded block
const decrypted = aescbcDecrypt(encrypted, key);
expect(decrypted.length).toBe(0);
});
it("should correctly pad plaintext of 1 byte", () => {
const key = FixedBuf.fromRandom(32);
const iv = FixedBuf.fromRandom(16);
const plaintext = WebBuf.from([0x42]);
const encrypted = aescbcEncrypt(plaintext, key, iv);
expect(encrypted.length).toBe(16 + 16); // IV + one padded block
const decrypted = aescbcDecrypt(encrypted, key);
expect(decrypted.toHex()).toBe("42");
});
it("should correctly pad plaintext of 15 bytes", () => {
const key = FixedBuf.fromRandom(32);
const plaintext = WebBuf.alloc(15, 0xaa);
const encrypted = aescbcEncrypt(plaintext, key);
expect(encrypted.length).toBe(16 + 16); // IV + one padded block
const decrypted = aescbcDecrypt(encrypted, key);
expect(decrypted.length).toBe(15);
});
it("should correctly pad plaintext of 16 bytes (full block)", () => {
const key = FixedBuf.fromRandom(32);
const plaintext = WebBuf.alloc(16, 0xbb);
const encrypted = aescbcEncrypt(plaintext, key);
// 16 bytes plaintext needs full block of padding
expect(encrypted.length).toBe(16 + 32); // IV + original block + padding block
const decrypted = aescbcDecrypt(encrypted, key);
expect(decrypted.length).toBe(16);
});
it("should correctly pad plaintext of 17 bytes", () => {
const key = FixedBuf.fromRandom(32);
const plaintext = WebBuf.alloc(17, 0xcc);
const encrypted = aescbcEncrypt(plaintext, key);
expect(encrypted.length).toBe(16 + 32); // IV + two blocks
const decrypted = aescbcDecrypt(encrypted, key);
expect(decrypted.length).toBe(17);
});
});
describe("Audit: IV handling", () => {
it("should prepend IV to ciphertext", () => {
const key = FixedBuf.fromRandom(32);
const iv = FixedBuf.fromHex(16, "00112233445566778899aabbccddeeff");
const plaintext = WebBuf.fromUtf8("test");
const encrypted = aescbcEncrypt(plaintext, key, iv);
// First 16 bytes should be the IV
expect(encrypted.slice(0, 16).toHex()).toBe("00112233445566778899aabbccddeeff");
});
it("should extract IV from ciphertext during decryption", () => {
const key = FixedBuf.fromRandom(32);
const iv = FixedBuf.fromRandom(16);
const plaintext = WebBuf.fromUtf8("test message");
const encrypted = aescbcEncrypt(plaintext, key, iv);
const decrypted = aescbcDecrypt(encrypted, key);
expect(decrypted.toUtf8()).toBe("test message");
});
it("should generate random IV when not provided", () => {
const key = FixedBuf.fromRandom(32);
const plaintext = WebBuf.fromUtf8("test");
const encrypted1 = aescbcEncrypt(plaintext, key);
const encrypted2 = aescbcEncrypt(plaintext, key);
// IVs should be different (random)
const iv1 = encrypted1.slice(0, 16).toHex();
const iv2 = encrypted2.slice(0, 16).toHex();
expect(iv1).not.toBe(iv2);
// But both should decrypt correctly
expect(aescbcDecrypt(encrypted1, key).toUtf8()).toBe("test");
expect(aescbcDecrypt(encrypted2, key).toUtf8()).toBe("test");
});
it("should produce different ciphertext with different IVs", () => {
const key = FixedBuf.fromRandom(32);
const iv1 = FixedBuf.fromHex(16, "00000000000000000000000000000000");
const iv2 = FixedBuf.fromHex(16, "ffffffffffffffffffffffffffffffff");
const plaintext = WebBuf.fromUtf8("same plaintext");
const encrypted1 = aescbcEncrypt(plaintext, key, iv1);
const encrypted2 = aescbcEncrypt(plaintext, key, iv2);
// Ciphertexts (excluding IV) should be different
expect(encrypted1.slice(16).toHex()).not.toBe(encrypted2.slice(16).toHex());
});
});
describe("Audit: Error handling", () => {
it("should throw for ciphertext shorter than 16 bytes", () => {
const key = FixedBuf.fromRandom(32);
const shortData = WebBuf.alloc(15);
expect(() => aescbcDecrypt(shortData, key)).toThrow(
"Data must be at least 16 bytes long",
);
});
it("should throw for ciphertext not a multiple of 16 bytes (after IV)", () => {
const key = FixedBuf.fromRandom(32);
// 16 bytes IV + 17 bytes (not multiple of 16)
const badData = WebBuf.alloc(16 + 17);
expect(() => aescbcDecrypt(badData, key)).toThrow(
"Data length must be a multiple of 16",
);
});
it("should accept ciphertext that is exactly 16 bytes (IV only, empty plaintext)", () => {
const key = FixedBuf.fromRandom(32);
const iv = FixedBuf.fromRandom(16);
const plaintext = WebBuf.alloc(0);
const encrypted = aescbcEncrypt(plaintext, key, iv);
const decrypted = aescbcDecrypt(encrypted, key);
expect(decrypted.length).toBe(0);
});
});
describe("Audit: Key sizes", () => {
it("should work with 128-bit (16 byte) key", () => {
const key = FixedBuf.fromRandom(16);
const plaintext = WebBuf.fromUtf8("AES-128 test");
const encrypted = aescbcEncrypt(plaintext, key);
const decrypted = aescbcDecrypt(encrypted, key);
expect(decrypted.toUtf8()).toBe("AES-128 test");
});
it("should work with 192-bit (24 byte) key", () => {
const key = FixedBuf.fromRandom(24);
const plaintext = WebBuf.fromUtf8("AES-192 test");
const encrypted = aescbcEncrypt(plaintext, key);
const decrypted = aescbcDecrypt(encrypted, key);
expect(decrypted.toUtf8()).toBe("AES-192 test");
});
it("should work with 256-bit (32 byte) key", () => {
const key = FixedBuf.fromRandom(32);
const plaintext = WebBuf.fromUtf8("AES-256 test");
const encrypted = aescbcEncrypt(plaintext, key);
const decrypted = aescbcDecrypt(encrypted, key);
expect(decrypted.toUtf8()).toBe("AES-256 test");
});
});
describe("Audit: Round-trip tests", () => {
it("should round-trip various plaintext sizes", () => {
const key = FixedBuf.fromRandom(32);
const sizes = [0, 1, 15, 16, 17, 31, 32, 33, 64, 100, 1000, 10000];
for (const size of sizes) {
const plaintext = WebBuf.alloc(size);
crypto.getRandomValues(plaintext);
const encrypted = aescbcEncrypt(plaintext, key);
const decrypted = aescbcDecrypt(encrypted, key);
expect(decrypted.toHex()).toBe(plaintext.toHex());
}
});
it("should round-trip with all key sizes", () => {
const keySizes: (16 | 24 | 32)[] = [16, 24, 32];
const plaintext = WebBuf.fromUtf8("Test all key sizes");
for (const keySize of keySizes) {
const key = FixedBuf.fromRandom(keySize) as FixedBuf<16> | FixedBuf<24> | FixedBuf<32>;
const encrypted = aescbcEncrypt(plaintext, key);
const decrypted = aescbcDecrypt(encrypted, key);
expect(decrypted.toUtf8()).toBe("Test all key sizes");
}
});
});
describe("Audit: Determinism", () => {
it("should produce same ciphertext for same key, IV, and plaintext", () => {
const key = FixedBuf.fromHex(
32,
"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f",
);
const iv = FixedBuf.fromHex(16, "000102030405060708090a0b0c0d0e0f");
const plaintext = WebBuf.fromUtf8("deterministic test");
const encrypted1 = aescbcEncrypt(plaintext, key, iv);
const encrypted2 = aescbcEncrypt(plaintext, key, iv);
expect(encrypted1.toHex()).toBe(encrypted2.toHex());
});
});
describe("Audit: Security properties", () => {
it("should produce different ciphertext with different keys", () => {
const key1 = FixedBuf.fromHex(
32,
"0000000000000000000000000000000000000000000000000000000000000000",
);
const key2 = FixedBuf.fromHex(
32,
"0000000000000000000000000000000000000000000000000000000000000001",
);
const iv = FixedBuf.fromRandom(16);
const plaintext = WebBuf.fromUtf8("test");
const encrypted1 = aescbcEncrypt(plaintext, key1, iv);
const encrypted2 = aescbcEncrypt(plaintext, key2, iv);
// Ciphertexts should differ (same IV used to isolate key effect)
expect(encrypted1.slice(16).toHex()).not.toBe(encrypted2.slice(16).toHex());
});
it("should not decrypt correctly with wrong key", () => {
const key1 = FixedBuf.fromRandom(32);
const key2 = FixedBuf.fromRandom(32);
const plaintext = WebBuf.fromUtf8("secret message");
const encrypted = aescbcEncrypt(plaintext, key1);
// Decrypting with wrong key should either throw or produce garbage
// (depending on padding validation)
try {
const decrypted = aescbcDecrypt(encrypted, key2);
// If it doesn't throw, the result should be different
expect(decrypted.toHex()).not.toBe(plaintext.toHex());
} catch {
// Expected - padding validation failed
}
});
});