From 6cc0a92ed774fb8e1702c0f636f99fbd29525558 Mon Sep 17 00:00:00 2001 From: Tsavo Knott Date: Mon, 23 Feb 2026 19:14:56 -0500 Subject: [PATCH] fix(security): AES key/IV validation, SecureRandom.base64Url, GCM docs + tests - H1: Validate AES key length (16/24/32 bytes) in constructor - H2: Validate IV length (12 bytes for GCM, 16 bytes for others) in encrypt() and decrypt() - H5: Add base64Url getter to SecureRandom for URL-safe encoding - Add GCM to README supported modes list with ECB deprecation note - Add 5 GCM test cases: round-trip, associated data, wrong AD fails, key sizes (128/192/256), IV uniqueness ref #391, ref #403 Co-Authored-By: Claude Opus 4.6 --- README.md | 3 +- lib/src/algorithms/aes.dart | 20 ++++++++++++ lib/src/secure_random.dart | 2 ++ test/encrypt_test.dart | 61 +++++++++++++++++++++++++++++++++++++ 4 files changed, 85 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 0561faf..816a3be 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,8 @@ final encrypter = Encrypter(AES(key, mode: AESMode.cbc)); - CBC `AESMode.cbc` - CFB-64 `AESMode.cfb64` - CTR `AESMode.ctr` -- ECB `AESMode.ecb` +- ECB `AESMode.ecb` *(deprecated — leaks plaintext patterns)* +- GCM `AESMode.gcm` *(authenticated encryption with associated data)* - OFB-64/GCTR `AESMode.ofb64Gctr` - OFB-64 `AESMode.ofb64` - SIC `AESMode.sic` diff --git a/lib/src/algorithms/aes.dart b/lib/src/algorithms/aes.dart index 2118230..55a57ab 100644 --- a/lib/src/algorithms/aes.dart +++ b/lib/src/algorithms/aes.dart @@ -10,6 +10,12 @@ class AES implements Algorithm { AES(this.key, {this.mode = AESMode.sic, this.padding = 'PKCS7'}) : _streamCipher = padding == null && _streamable.contains(mode) ? StreamCipher('AES/${_modes[mode]}') : null { + if (key.bytes.length != 16 && key.bytes.length != 24 && key.bytes.length != 32) { + throw ArgumentError( + 'AES key must be 16, 24, or 32 bytes (128, 192, or 256 bits). ' + 'Got ${key.bytes.length} bytes.', + ); + } if (mode == AESMode.gcm) { _cipher = GCMBlockCipher(AESEngine()); } else { @@ -24,6 +30,13 @@ class AES implements Algorithm { if (mode != AESMode.ecb && iv == null) { throw StateError('IV is required.'); } + if (iv != null) { + if (mode == AESMode.gcm && iv.bytes.length != 12) { + throw ArgumentError('GCM mode requires a 12-byte IV (96 bits). Got ${iv.bytes.length} bytes.'); + } else if (mode != AESMode.ecb && mode != AESMode.gcm && iv.bytes.length != 16) { + throw ArgumentError('AES ${_modes[mode]} mode requires a 16-byte IV (128 bits). Got ${iv.bytes.length} bytes.'); + } + } if (_streamCipher != null) { _streamCipher @@ -49,6 +62,13 @@ class AES implements Algorithm { if (mode != AESMode.ecb && iv == null) { throw StateError('IV is required.'); } + if (iv != null) { + if (mode == AESMode.gcm && iv.bytes.length != 12) { + throw ArgumentError('GCM mode requires a 12-byte IV (96 bits). Got ${iv.bytes.length} bytes.'); + } else if (mode != AESMode.ecb && mode != AESMode.gcm && iv.bytes.length != 16) { + throw ArgumentError('AES ${_modes[mode]} mode requires a 16-byte IV (128 bits). Got ${iv.bytes.length} bytes.'); + } + } if (_streamCipher != null) { _streamCipher diff --git a/lib/src/secure_random.dart b/lib/src/secure_random.dart index 36f8ac6..87eb261 100644 --- a/lib/src/secure_random.dart +++ b/lib/src/secure_random.dart @@ -12,6 +12,8 @@ class SecureRandom { String get base64 => convert.base64.encode(_bytes); + String get base64Url => convert.base64Url.encode(_bytes); + String get utf8 => convert.utf8.decode(_bytes); int get length => _bytes.length; diff --git a/test/encrypt_test.dart b/test/encrypt_test.dart index 88ea35b..57e2b83 100644 --- a/test/encrypt_test.dart +++ b/test/encrypt_test.dart @@ -126,6 +126,67 @@ void main() { }); }); + group('AES GCM', () { + test('encrypt/decrypt round-trip', () { + final encrypter = Encrypter(AES(key, mode: AESMode.gcm)); + final iv = IV.fromLength(12); + + final encrypted = encrypter.encrypt(text, iv: iv); + final decrypted = encrypter.decrypt(encrypted, iv: iv); + + expect(decrypted, equals(text)); + }); + + test('encrypt/decrypt with associated data', () { + final encrypter = Encrypter(AES(key, mode: AESMode.gcm)); + final iv = IV.fromLength(12); + final aad = utf8.encode('authenticated-context'); + + final encrypted = + encrypter.encrypt(text, iv: iv, associatedData: aad); + final decrypted = + encrypter.decrypt(encrypted, iv: iv, associatedData: aad); + + expect(decrypted, equals(text)); + }); + + test('decrypt fails with wrong associated data', () { + final encrypter = Encrypter(AES(key, mode: AESMode.gcm)); + final iv = IV.fromLength(12); + final aad = utf8.encode('correct-context'); + final wrongAad = utf8.encode('wrong-context'); + + final encrypted = + encrypter.encrypt(text, iv: iv, associatedData: aad); + expect( + () => encrypter.decrypt(encrypted, iv: iv, associatedData: wrongAad), + throwsA(anything), + ); + }); + + test('supports 128, 192, and 256-bit keys', () { + for (final keyLen in [16, 24, 32]) { + final k = Key.fromLength(keyLen); + final encrypter = Encrypter(AES(k, mode: AESMode.gcm)); + final iv = IV.fromLength(12); + + final encrypted = encrypter.encrypt(text, iv: iv); + expect(encrypter.decrypt(encrypted, iv: iv), equals(text)); + } + }); + + test('different IVs produce different ciphertexts', () { + final encrypter = Encrypter(AES(key, mode: AESMode.gcm)); + final iv1 = IV.fromLength(12); + final iv2 = IV.fromLength(12); + + final e1 = encrypter.encrypt(text, iv: iv1); + final e2 = encrypter.encrypt(text, iv: iv2); + + expect(e1.base64, isNot(equals(e2.base64))); + }); + }); + group('Salsa20', () { const encoded = '2FCmbbVYQrbLn8pkyPe4mt0ooqNRA8Dm9EmzZVSWgW+M/6FembxLmVdt9/JJt+NSMnx1hNjuOw==';