Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
20 changes: 20 additions & 0 deletions lib/src/algorithms/aes.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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.');
}
}
Comment on lines +33 to +39
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The new validation in aes.dart requires a 12-byte IV for GCM mode, but the example code in example/aes_gcm.dart uses a 16-byte IV, causing a runtime ArgumentError.
Severity: HIGH

Suggested Fix

Update the example code in example/aes_gcm.dart to use a 12-byte IV, for instance, by changing IV.fromSecureRandom(16) to IV.fromSecureRandom(12). This will align the example with the new validation rules. Alternatively, if supporting variable-length IVs for GCM is desired, the validation logic should be relaxed or removed.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: lib/src/algorithms/aes.dart#L33-L39

Potential issue: A new validation was added in `lib/src/algorithms/aes.dart` that
enforces a strict 12-byte IV length for AES in GCM mode. However, the library's own
example file, `example/aes_gcm.dart`, uses `IV.fromSecureRandom(16)` to generate a
16-byte IV. This mismatch will cause the example code to fail with an `ArgumentError` at
runtime, making it unusable and creating a breaking change for users who might have been
using other IV lengths with GCM mode.

Did we get this right? 👍 / 👎 to inform future reviews.

Comment on lines +33 to +39
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The IV validation logic is duplicated between the encrypt and decrypt methods. Consider extracting this validation into a private helper method to reduce code duplication and ensure consistency. For example, a method like _validateIV(IV? iv) could be called at the start of both encrypt and decrypt methods.

Copilot uses AI. Check for mistakes.

if (_streamCipher != null) {
_streamCipher
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions lib/src/secure_random.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ class SecureRandom {

String get base64 => convert.base64.encode(_bytes);

String get base64Url => convert.base64Url.encode(_bytes);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

base64Url includes padding characters

Medium Severity

SecureRandom.base64Url uses convert.base64Url.encode, which emits padded Base64URL (often ending in =). For “URL-safe random token generation”, padding can still break usage in URL/path/query contexts unless callers remember to strip/encode it, so the getter’s behavior doesn’t match the implied guarantee.

Fix in Cursor Fix in Web

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New base64Url getter appears unused

Low Severity

The new SecureRandom.base64Url getter is not referenced anywhere in the repo (the only base64Url usage in tests refers to dart:convert’s top-level base64Url). This looks like dead API surface that increases maintenance cost without coverage proving it works as intended.

Fix in Cursor Fix in Web


String get utf8 => convert.utf8.decode(_bytes);

int get length => _bytes.length;
Expand Down
61 changes: 61 additions & 0 deletions test/encrypt_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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)));
});
});

Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new key and IV validation logic lacks test coverage. While the GCM functional tests are comprehensive, there are no tests verifying that invalid key lengths (e.g., 15, 17, 20 bytes) or invalid IV lengths (e.g., 11, 13, 16 bytes for GCM; 15, 17 bytes for other modes) correctly throw ArgumentError exceptions. The codebase has established patterns for testing validation errors (see test/encrypt_test.dart lines 271-282 for RSA StateError tests). Consider adding a dedicated test group for AES validation errors to ensure the validation logic works correctly and provide clear documentation of expected error behavior.

Suggested change
group('AES validation errors', () {
test('throws ArgumentError for invalid GCM key lengths', () {
for (final keyLen in [15, 17, 20]) {
expect(
() => Encrypter(AES(Key.fromLength(keyLen), mode: AESMode.gcm)),
throwsA(isA<ArgumentError>()),
);
}
});
test('throws ArgumentError for invalid GCM IV lengths', () {
final encrypter = Encrypter(AES(key, mode: AESMode.gcm));
for (final ivLen in [11, 13, 16]) {
final iv = IV.fromLength(ivLen);
expect(
() => encrypter.encrypt(text, iv: iv),
throwsA(isA<ArgumentError>()),
);
}
});
test('throws ArgumentError for invalid IV lengths in non-GCM mode', () {
final encrypter = Encrypter(AES(key, mode: AESMode.cbc));
for (final ivLen in [15, 17]) {
final iv = IV.fromLength(ivLen);
expect(
() => encrypter.encrypt(text, iv: iv),
throwsA(isA<ArgumentError>()),
);
}
});
});

Copilot uses AI. Check for mistakes.
group('Salsa20', () {
const encoded =
'2FCmbbVYQrbLn8pkyPe4mt0ooqNRA8Dm9EmzZVSWgW+M/6FembxLmVdt9/JJt+NSMnx1hNjuOw==';
Expand Down