From 47116a6a589acc25ac3d546b47fd6825a4f51fd6 Mon Sep 17 00:00:00 2001 From: Paul Miller Date: Sun, 3 Nov 2024 01:39:35 +0000 Subject: [PATCH] Move key, nonce & input validation into wrapCipher. Add tests --- src/_micro.ts | 19 ++---- src/aes.ts | 48 +++++---------- src/chacha.ts | 23 +------- src/salsa.ts | 17 +----- src/utils.ts | 61 ++++++++++++++++++-- src/webcrypto.ts | 3 + test/arx.test.js | 3 +- test/basic.test.js | 141 +++++++++++++++++++++++++++++++++++++++++++-- 8 files changed, 220 insertions(+), 95 deletions(-) diff --git a/src/_micro.ts b/src/_micro.ts index eebedf9..8ce8d64 100644 --- a/src/_micro.ts +++ b/src/_micro.ts @@ -209,7 +209,7 @@ const _1 = BigInt(1); // Can be speed-up using BigUint64Array, but would be more complicated export function poly1305(msg: Uint8Array, key: Uint8Array): Uint8Array { abytes(msg); - abytes(key); + abytes(key, 32); let acc = _0; const r = bytesToNumberLE(key.subarray(0, 16)) & CLAMP_R; const s = bytesToNumberLE(key.subarray(16)); @@ -255,11 +255,8 @@ function computeTag( export const xsalsa20poly1305 = /* @__PURE__ */ wrapCipher( { blockSize: 64, nonceLength: 24, tagLength: 16 }, function xsalsa20poly1305(key: Uint8Array, nonce: Uint8Array) { - abytes(key); - abytes(nonce); return { encrypt(plaintext: Uint8Array) { - abytes(plaintext); const m = concatBytes(new Uint8Array(32), plaintext); const c = xsalsa20(key, nonce, m); const authKey = c.subarray(0, 32); @@ -268,12 +265,11 @@ export const xsalsa20poly1305 = /* @__PURE__ */ wrapCipher( return concatBytes(tag, data); }, decrypt(ciphertext: Uint8Array) { - abytes(ciphertext); - if (ciphertext.length < 16) throw new Error('encrypted data must be at least 16 bytes'); const c = concatBytes(new Uint8Array(16), ciphertext); + const passedTag = c.subarray(16, 32); const authKey = xsalsa20(key, nonce, new Uint8Array(32)); const tag = poly1305(c.subarray(32), authKey); - if (!equalBytes(c.subarray(16, 32), tag)) throw new Error('invalid poly1305 tag'); + if (!equalBytes(tag, passedTag)) throw new Error('invalid poly1305 tag'); return xsalsa20(key, nonce, c).subarray(32); }, }; @@ -292,24 +288,17 @@ export const _poly1305_aead = (fn: XorStream) => (key: Uint8Array, nonce: Uint8Array, AAD?: Uint8Array): Cipher => { const tagLength = 16; - const keyLength = 32; - abytes(key, keyLength); - abytes(nonce); return { encrypt(plaintext: Uint8Array) { - abytes(plaintext); const res = fn(key, nonce, plaintext, undefined, 1); const tag = computeTag(fn, key, nonce, res, AAD); return concatBytes(res, tag); }, decrypt(ciphertext: Uint8Array) { - abytes(ciphertext); - if (ciphertext.length < tagLength) - throw new Error(`encrypted data must be at least ${tagLength} bytes`); const passedTag = ciphertext.subarray(-tagLength); const data = ciphertext.subarray(0, -tagLength); const tag = computeTag(fn, key, nonce, data, AAD); - if (!equalBytes(passedTag, tag)) throw new Error('invalid poly1305 tag'); + if (!equalBytes(tag, passedTag)) throw new Error('invalid poly1305 tag'); return fn(key, nonce, data, undefined, 1); }, }; diff --git a/src/aes.ts b/src/aes.ts index f357427..0e9f410 100644 --- a/src/aes.ts +++ b/src/aes.ts @@ -225,13 +225,15 @@ function decrypt(xk: Uint32Array, s0: number, s1: number, s2: number, s3: number return { s0: t0, s1: t1, s2: t2, s3: t3 }; } -function getDst(len: number, dst?: Uint8Array) { - if (dst === undefined) return new Uint8Array(len); - abytes(dst); - if (dst.length < len) - throw new Error(`aes: wrong destination length, expected at least ${len}, got: ${dst.length}`); - if (!isAligned32(dst)) throw new Error('unaligned dst'); - return dst; +function getDst(len: number, output?: Uint8Array): Uint8Array { + if (output === undefined) return new Uint8Array(len); + abytes(output); + if (output.length < len) + throw new Error( + `aes: wrong destination length, expected at least ${len}, got: ${output.length}` + ); + if (!isAligned32(output)) throw new Error('unaligned output'); + return output; } // TODO: investigate merging with ctr32 @@ -324,8 +326,6 @@ function ctr32( export const ctr = wrapCipher( { blockSize: 16, nonceLength: 16 }, function ctr(key: Uint8Array, nonce: Uint8Array): CipherWithOutput { - abytes(key); - abytes(nonce, BLOCK_SIZE); function processCtr(buf: Uint8Array, dst?: Uint8Array) { abytes(buf); if (dst !== undefined) { @@ -351,7 +351,7 @@ function validateBlockDecrypt(data: Uint8Array) { abytes(data); if (data.length % BLOCK_SIZE !== 0) { throw new Error( - `aes/(cbc-ecb).decrypt ciphertext should consist of blocks with size ${BLOCK_SIZE}` + `aes-(cbc/ecb).decrypt ciphertext should consist of blocks with size ${BLOCK_SIZE}` ); } } @@ -404,7 +404,6 @@ export type BlockOpts = { disablePadding?: boolean }; export const ecb = wrapCipher( { blockSize: 16 }, function ecb(key: Uint8Array, opts: BlockOpts = {}): CipherWithOutput { - abytes(key); const pcks5 = !opts.disablePadding; return { encrypt(plaintext: Uint8Array, dst?: Uint8Array) { @@ -449,8 +448,6 @@ export const ecb = wrapCipher( export const cbc = wrapCipher( { blockSize: 16, nonceLength: 16 }, function cbc(key: Uint8Array, iv: Uint8Array, opts: BlockOpts = {}): CipherWithOutput { - abytes(key); - abytes(iv, 16); const pcks5 = !opts.disablePadding; return { encrypt(plaintext: Uint8Array, dst?: Uint8Array) { @@ -511,8 +508,6 @@ export const cbc = wrapCipher( export const cfb = wrapCipher( { blockSize: 16, nonceLength: 16 }, function cfb(key: Uint8Array, iv: Uint8Array): CipherWithOutput { - abytes(key); - abytes(iv, 16); function processCfb(src: Uint8Array, isEncrypt: boolean, dst?: Uint8Array) { abytes(src); const srcLen = src.length; @@ -584,11 +579,8 @@ function computeTag( * As for nonce size, prefer 12-byte, instead of 8-byte. */ export const gcm = wrapCipher( - { blockSize: 16, nonceLength: 12, tagLength: 16 }, + { blockSize: 16, nonceLength: 12, tagLength: 16, varSizeNonce: true }, function gcm(key: Uint8Array, nonce: Uint8Array, AAD?: Uint8Array): Cipher { - abytes(key); - abytes(nonce); - if (AAD !== undefined) abytes(AAD); // NIST 800-38d doesn't enforce minimum nonce length. // We enforce 8 bytes for compat with openssl. // 12 bytes are recommended. More than 12 bytes would be converted into 12. @@ -621,7 +613,6 @@ export const gcm = wrapCipher( } return { encrypt(plaintext: Uint8Array) { - abytes(plaintext); const { xk, authKey, counter, tagMask } = deriveKeys(); const out = new Uint8Array(plaintext.length + tagLength); const toClean: (Uint8Array | Uint32Array)[] = [xk, authKey, counter, tagMask]; @@ -634,9 +625,6 @@ export const gcm = wrapCipher( return out; }, decrypt(ciphertext: Uint8Array) { - abytes(ciphertext); - if (ciphertext.length < tagLength) - throw new Error(`aes/gcm: ciphertext less than tagLen (${tagLength})`); const { xk, authKey, counter, tagMask } = deriveKeys(); const toClean: (Uint8Array | Uint32Array)[] = [xk, authKey, tagMask, counter]; if (!isAligned32(ciphertext)) toClean.push((ciphertext = copyBytes(ciphertext))); @@ -665,7 +653,7 @@ const limit = (name: string, min: number, max: number) => (value: number) => { * RFC 8452, https://datatracker.ietf.org/doc/html/rfc8452 */ export const siv = wrapCipher( - { blockSize: 16, nonceLength: 12, tagLength: 16 }, + { blockSize: 16, nonceLength: 12, tagLength: 16, varSizeNonce: true }, function siv(key: Uint8Array, nonce: Uint8Array, AAD?: Uint8Array): Cipher { const tagLength = 16; // From RFC 8452: Section 6 @@ -674,12 +662,8 @@ export const siv = wrapCipher( const NONCE_LIMIT = limit('nonce', 12, 12); const CIPHER_LIMIT = limit('ciphertext', 16, 2 ** 36 + 16); abytes(key, 16, 24, 32); - abytes(nonce); NONCE_LIMIT(nonce.length); - if (AAD !== undefined) { - abytes(AAD); - AAD_LIMIT(AAD.length); - } + if (AAD !== undefined) AAD_LIMIT(AAD.length); function deriveKeys() { const xk = expandKeyLE(key); const encKey = new Uint8Array(key.length); @@ -732,7 +716,6 @@ export const siv = wrapCipher( } return { encrypt(plaintext: Uint8Array) { - abytes(plaintext); PLAIN_LIMIT(plaintext.length); const { encKey, authKey } = deriveKeys(); const tag = _computeTag(encKey, authKey, plaintext); @@ -746,7 +729,6 @@ export const siv = wrapCipher( return out; }, decrypt(ciphertext: Uint8Array) { - abytes(ciphertext); CIPHER_LIMIT(ciphertext.length); const tag = ciphertext.subarray(-tagLength); const { encKey, authKey } = deriveKeys(); @@ -872,7 +854,6 @@ export const aeskw = wrapCipher( { blockSize: 8 }, (kek: Uint8Array): Cipher => ({ encrypt(plaintext: Uint8Array) { - abytes(plaintext); if (!plaintext.length || plaintext.length % 8 !== 0) throw new Error('invalid plaintext length'); if (plaintext.length === 8) @@ -882,7 +863,6 @@ export const aeskw = wrapCipher( return out; }, decrypt(ciphertext: Uint8Array) { - abytes(ciphertext); // ciphertext must be at least 24 bytes and a multiple of 8 bytes // 24 because should have at least two block (1 iv + 2). // Replace with 16 to enable '8-byte keys' @@ -946,7 +926,6 @@ export const aeskwp = wrapCipher( { blockSize: 8 }, (kek: Uint8Array): Cipher => ({ encrypt(plaintext: Uint8Array) { - abytes(plaintext); if (!plaintext.length) throw new Error('invalid plaintext length'); const padded = Math.ceil(plaintext.length / 8) * 8; const out = new Uint8Array(8 + padded); @@ -958,7 +937,6 @@ export const aeskwp = wrapCipher( return out; }, decrypt(ciphertext: Uint8Array) { - abytes(ciphertext); // 16 because should have at least one block if (ciphertext.length < 16) throw new Error('invalid ciphertext length'); const out = copyBytes(ciphertext); diff --git a/src/chacha.ts b/src/chacha.ts index b9f3b40..8ab770a 100644 --- a/src/chacha.ts +++ b/src/chacha.ts @@ -1,6 +1,5 @@ // prettier-ignore import { createCipher, rotl } from './_arx.js'; -import { bytes as abytes } from './_assert.js'; import { poly1305 } from './_poly1305.js'; import { CipherWithOutput, @@ -8,6 +7,7 @@ import { clean, createView, equalBytes, + getDst, setBigUint64, wrapCipher, } from './utils.js'; @@ -236,18 +236,10 @@ export const _poly1305_aead = (xorStream: XorStream) => (key: Uint8Array, nonce: Uint8Array, AAD?: Uint8Array): CipherWithOutput => { const tagLength = 16; - abytes(key, 32); - abytes(nonce); return { encrypt(plaintext: Uint8Array, output?: Uint8Array) { - abytes(plaintext); const plength = plaintext.length; - const clength = plength + tagLength; - if (output) { - abytes(output, clength); - } else { - output = new Uint8Array(clength); - } + output = getDst(plength + tagLength, output); xorStream(key, nonce, plaintext, output, 1); const tag = computeTag(xorStream, key, nonce, output.subarray(0, -tagLength), AAD); output.set(tag, plength); // append tag @@ -255,16 +247,7 @@ export const _poly1305_aead = return output; }, decrypt(ciphertext: Uint8Array, output?: Uint8Array) { - abytes(ciphertext); - const clength = ciphertext.length; - const plength = clength - tagLength; - if (clength < tagLength) - throw new Error(`encrypted data must be at least ${tagLength} bytes`); - if (output) { - abytes(output, plength); - } else { - output = new Uint8Array(plength); - } + output = getDst(ciphertext.length - tagLength, output); const data = ciphertext.subarray(0, -tagLength); const passedTag = ciphertext.subarray(-tagLength); const tag = computeTag(xorStream, key, nonce, data, AAD); diff --git a/src/salsa.ts b/src/salsa.ts index ae5d44c..fc02c14 100644 --- a/src/salsa.ts +++ b/src/salsa.ts @@ -1,7 +1,7 @@ import { createCipher, rotl } from './_arx.js'; import { bytes as abytes } from './_assert.js'; import { poly1305 } from './_poly1305.js'; -import { Cipher, clean, equalBytes, wrapCipher } from './utils.js'; +import { Cipher, clean, equalBytes, getDst, wrapCipher } from './utils.js'; // Salsa20 stream cipher was released in 2005. // Salsa's goal was to implement AES replacement that does not rely on S-Boxes, @@ -122,11 +122,8 @@ export const xsalsa20poly1305 = /* @__PURE__ */ wrapCipher( { blockSize: 64, nonceLength: 24, tagLength: 16 }, (key: Uint8Array, nonce: Uint8Array): Cipher => { const tagLength = 16; - abytes(key, 32); - abytes(nonce, 24); return { encrypt(plaintext: Uint8Array, output?: Uint8Array) { - abytes(plaintext); // This is small optimization (calculate auth key with same call as encryption itself) makes it hard // to separate tag calculation and encryption itself, since 32 byte is half-block of salsa (64 byte) const clength = plaintext.length + 32; @@ -145,15 +142,7 @@ export const xsalsa20poly1305 = /* @__PURE__ */ wrapCipher( return output.subarray(tagLength); }, decrypt(ciphertext: Uint8Array, output?: Uint8Array) { - abytes(ciphertext); - if (ciphertext.length < tagLength) - throw new Error('encrypted data should be at least 16 bytes'); - const clength = ciphertext.length + 32; // 32 is authKey length - if (output) { - abytes(output, clength); - } else { - output = new Uint8Array(clength); - } + output = getDst(ciphertext.length + 32, output); // 32 is authKey length // Create new ciphertext array: // tmp part auth tag ciphertext // [bytes 0..32] [bytes 32..48] [bytes 48..] @@ -165,7 +154,7 @@ export const xsalsa20poly1305 = /* @__PURE__ */ wrapCipher( const authKeyBuf = output.subarray(0, 32); clean(authKeyBuf); const authKey = xsalsa20(key, nonce, authKeyBuf, authKeyBuf); - const tag = poly1305(output.subarray(32 + tagLength), authKey); // alloc + const tag = poly1305(output.subarray(48), authKey); // alloc if (!equalBytes(output.subarray(32, 48), tag)) throw new Error('invalid tag'); // NOTE: first 32 bytes skipped (used for authKey) xsalsa20(key, nonce, output.subarray(16), output.subarray(16)); diff --git a/src/utils.ts b/src/utils.ts index 297b07f..3453656 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -192,6 +192,7 @@ export type Cipher = { encrypt(plaintext: Uint8Array): Uint8Array; decrypt(ciphertext: Uint8Array): Uint8Array; }; +export type OneTimeCipher = Cipher; export type AsyncCipher = { encrypt(plaintext: Uint8Array): Promise; @@ -205,17 +206,62 @@ export type CipherWithOutput = Cipher & { // Params is outside return type, so it is accessible before calling constructor // If function support multiple nonceLength's, we return best one -export type CipherParams = { blockSize: number; nonceLength?: number; tagLength?: number }; +export type CipherParams = { + blockSize: number; + nonceLength?: number; + tagLength?: number; + varSizeNonce?: boolean; +}; export type CipherCons = (key: Uint8Array, ...args: T) => Cipher; /** * @__NO_SIDE_EFFECTS__ */ export const wrapCipher = , P extends CipherParams>( params: P, - c: C + constructor: C ): C & P => { - Object.assign(c, params); - return c as C & P; + function wrappedCipher(key: Uint8Array, ...args: any[]): CipherWithOutput { + // Validate key + abytes(key); + + // Validate nonce if nonceLength is present + if (params.nonceLength !== undefined) { + const nonce = args[0]; + if (!nonce) throw new Error('nonce / iv required'); + if (params.varSizeNonce) abytes(nonce); + else abytes(nonce, params.nonceLength); + } + + // Validate AAD if tagLength present + const tagl = params.tagLength; + if (tagl && args[1] !== undefined) { + abytes(args[1]); + } + + const cipher = constructor(key, ...args); + + // Create wrapped cipher with validation and single-use encryption + let called = false; + const wrCipher = { + encrypt(data: Uint8Array, output?: Uint8Array) { + if (called) throw new Error('cannot encrypt() twice with same key + nonce'); + called = true; + abytes(data); + return (cipher as CipherWithOutput).encrypt(data, output); + }, + decrypt(data: Uint8Array, output?: Uint8Array) { + abytes(data); + if (tagl && data.length < tagl) + throw new Error(`ciphertext is smaller than tagLength=${tagl}`); + return (cipher as CipherWithOutput).decrypt(data, output); + }, + }; + + return wrCipher; + } + + Object.assign(wrappedCipher, params); + return wrappedCipher as C & P; }; export type XorStream = ( @@ -226,6 +272,13 @@ export type XorStream = ( counter?: number ) => Uint8Array; +export function getDst(expectedLength: number, dst?: Uint8Array) { + if (!dst) return new Uint8Array(expectedLength); + abytes(dst, expectedLength); + if (!isAligned32(dst)) throw new Error('unaligned output'); + return dst; +} + // Polyfill for Safari 14 export function setBigUint64( view: DataView, diff --git a/src/webcrypto.ts b/src/webcrypto.ts index 3368454..d84e52e 100644 --- a/src/webcrypto.ts +++ b/src/webcrypto.ts @@ -103,10 +103,13 @@ function generate(algo: BlockMode) { abytes(nonce); const keyParams = { name: algo, length: key.length * 8 }; const cryptParams = getCryptParams(algo, nonce, AAD); + let consumed = false; return { // keyLength, encrypt(plaintext: Uint8Array) { abytes(plaintext); + if (consumed) throw new Error('Cannot encrypt() twice with same key / nonce'); + consumed = true; return utils.encrypt(key, keyParams, cryptParams, plaintext); }, decrypt(ciphertext: Uint8Array) { diff --git a/test/arx.test.js b/test/arx.test.js index d925f4f..ce1b395 100644 --- a/test/arx.test.js +++ b/test/arx.test.js @@ -261,8 +261,9 @@ describe('handle byte offsets correctly', () => { // Key + nonce with offset const keyOffset = new Uint8Array(v.keyLen + 1).fill(2).subarray(1); const nonceOffset = new Uint8Array(v.nonceLen + 1).fill(3).subarray(1); + const stream_c2 = v.stream(key, nonce); const streamOffset = v.stream(keyOffset, nonceOffset); - const encryptedOffset = stream_c.encrypt(data); + const encryptedOffset = stream_c2.encrypt(data); deepStrictEqual(encryptedOffset, encrypted_c); const decryptedOffset = streamOffset.decrypt(encryptedOffset); deepStrictEqual(decryptedOffset, data); diff --git a/test/basic.test.js b/test/basic.test.js index 254498d..e5675f3 100644 --- a/test/basic.test.js +++ b/test/basic.test.js @@ -1,4 +1,4 @@ -const { deepStrictEqual } = require('assert'); +const { deepStrictEqual, throws } = require('assert'); const { should, describe } = require('micro-should'); const { hex } = require('@scure/base'); const { managedNonce, randomBytes } = require('../webcrypto.js'); @@ -78,11 +78,11 @@ describe('Basic', () => { }); should(`${k}: round-trip`, () => { - const { c, key, nonce, copy } = initCipher(opts); // slice, so cipher has no way to corrupt msg const msg = new Uint8Array(2).fill(12); const msgCopy = msg.slice(); if (checkBlockSize(opts, msgCopy.length)) { + const { c, key, nonce, copy } = initCipher(opts); deepStrictEqual(c.decrypt(c.encrypt(msgCopy)), msg); deepStrictEqual(msg, msgCopy); } @@ -90,9 +90,12 @@ describe('Basic', () => { const msg2 = new Uint8Array(2048).fill(255); const msg2Copy = msg2.slice(); if (checkBlockSize(opts, msg2Copy.length)) { + const { c, key, nonce, copy } = initCipher(opts); deepStrictEqual(c.decrypt(c.encrypt(msg2)), msg2); deepStrictEqual(msg2, msg2Copy); } + + const { c, key, nonce, copy } = initCipher(opts); const msg3 = new Uint8Array(256); const msg3Copy = msg3.slice(); if (!checkBlockSize(opts, msg3Copy.length)) { @@ -104,18 +107,18 @@ describe('Basic', () => { deepStrictEqual(nonce, copy.nonce); }); should(`${k}: different sizes`, () => { - const { c, key, nonce, copy } = initCipher(opts); for (let i = 0; i < 2048; i++) { const msg = new Uint8Array(i).fill(i); const msgCopy = msg.slice(); if (checkBlockSize(opts, msgCopy.length)) { + const { c, key, nonce, copy } = initCipher(opts); deepStrictEqual(c.decrypt(c.encrypt(msg)), msg); deepStrictEqual(msg, msgCopy); + + deepStrictEqual(key, copy.key); + deepStrictEqual(nonce, copy.nonce); } } - // Verify that key/nonce is not modified - deepStrictEqual(key, copy.key); - deepStrictEqual(nonce, copy.nonce); }); for (let i = 0; i < 8; i++) { should(`${k} (unalign ${i})`, () => { @@ -132,6 +135,132 @@ describe('Basic', () => { } }); } + + const msg_10 = new Uint8Array(10); + if (checkBlockSize(opts, msg_10.length) && !k.endsWith('_managedNonce')) { + should(`${k}: prohibit encrypting twice`, () => { + const { c } = initCipher(opts); + c.encrypt(msg_10); + throws(() => { + c.encrypt(msg_10); + }); + }); + } + } +}); + +// In basic.test.js, add after existing tests: + +describe('input validation', () => { + const INVALID_BYTE_ARRAYS = [ + undefined, + null, + 123, + 'string', + {}, + [], + new Uint16Array(4), + new Uint32Array(4), + new Float32Array(4), + ]; + + for (const k in CIPHERS) { + const opts = CIPHERS[k]; + const { fn, keyLen } = opts; + + if (k.includes('managed')) continue; + describe(k, () => { + // Constructor tests + should('reject invalid key', () => { + const nonce = new Uint8Array(fn.nonceLength); + const aad = new Uint8Array(16); + + for (const invalid of INVALID_BYTE_ARRAYS) { + throws(() => fn(invalid, nonce), 'non-u8a'); + } + + // Test wrong key length + const msg = new Uint8Array(1); + throws(() => fn(new Uint8Array(keyLen + 1), nonce).encrypt(msg), 'key length + 1'); + throws(() => fn(new Uint8Array(keyLen - 1), nonce).encrypt(msg), 'key length - 1'); + }); + + if (fn.nonceLength) { + should('reject invalid nonce', () => { + const key = new Uint8Array(keyLen); + const aad = new Uint8Array(16); + + for (const invalid of INVALID_BYTE_ARRAYS) { + throws(() => fn(key, invalid)); + } + + // Test wrong nonce length + if (fn.varSizeNonce) return; + const msg = new Uint8Array(1); + throws(() => fn(key, new Uint8Array(fn.nonceLength + 1)).encrypt(msg)); + throws(() => fn(key, new Uint8Array(fn.nonceLength - 1)).encrypt(msg)); + }); + } + + if (fn.tagLength && k !== 'xsalsa20poly1305') { + should('reject invalid AAD', () => { + const key = new Uint8Array(keyLen); + const nonce = new Uint8Array(fn.nonceLength); + + for (const invalid of INVALID_BYTE_ARRAYS) { + if (invalid == null) return; + throws(() => fn(key, nonce, invalid)); + } + }); + } + + // Method tests + should('reject invalid encrypt input', () => { + const key = new Uint8Array(keyLen); + const nonce = fn.nonceLength ? new Uint8Array(fn.nonceLength) : undefined; + const cipher = nonce ? fn(key, nonce) : fn(key); + + for (const invalid of INVALID_BYTE_ARRAYS) { + throws(() => cipher.encrypt(invalid)); + } + }); + + should('reject invalid decrypt input', () => { + const key = new Uint8Array(keyLen); + const nonce = fn.nonceLength ? new Uint8Array(fn.nonceLength) : undefined; + const cipher = nonce ? fn(key, nonce) : fn(key); + + for (const invalid of INVALID_BYTE_ARRAYS) { + throws(() => cipher.decrypt(invalid)); + } + }); + + if (opts.blockSize) { + should('validate block size on encrypt', () => { + const key = new Uint8Array(keyLen); + const nonce = fn.nonceLength ? new Uint8Array(fn.nonceLength) : undefined; + const cipher = nonce ? fn(key, nonce) : fn(key); + + // Test invalid block size if padding is disabled + if (opts.disablePadding) { + throws(() => cipher.encrypt(new Uint8Array(opts.blockSize - 1))); + throws(() => cipher.encrypt(new Uint8Array(opts.blockSize + 1))); + } + }); + } + + if (fn.tagLength) { + should('validate tag length on decrypt', () => { + const key = new Uint8Array(keyLen); + const nonce = new Uint8Array(fn.nonceLength); + const cipher = fn(key, nonce); + + // Test ciphertext lengths that would result in invalid tag + throws(() => cipher.decrypt(new Uint8Array(fn.tagLength - 1))); + throws(() => cipher.decrypt(new Uint8Array(15))); + }); + } + }); } });