Description
The native FFI AES-CTR implementation silently corrupts data when a source chunk is larger than 4096 bytes and is not an exact multiple of 4096.
For a 4097-byte plaintext, encryptBytes() returns 8192 bytes. Decrypting it does not recover the original plaintext. With assertions enabled, the operation instead fails at assert(i == M).
Reproduction
import 'package:webcrypto/webcrypto.dart';
Future<void> main() async {
final key = await AesCtrSecretKey.importRawKey(List<int>.filled(16, 0));
final plaintext = List<int>.generate(4097, (i) => i & 0xff);
final counter = List<int>.filled(16, 0);
final ciphertext = await key.encryptBytes(plaintext, counter, 128);
final decrypted = await key.decryptBytes(ciphertext, counter, 128);
print('plaintext length: ${plaintext.length}');
print('ciphertext length: ${ciphertext.length}');
print('decrypted length: ${decrypted.length}');
}
Output:
plaintext length: 4097
ciphertext length: 8192
decrypted length: 8192
With dart --enable-asserts run:
Failed assertion: 'i == M': is not true.
Expected behavior
AES-CTR ciphertext must have the same length as the plaintext, and decrypting it with the same key and counter must recover the original bytes.
Root cause
In lib/src/impl_ffi/impl_ffi.aesctr.dart, the inner buffering loop uses:
final N = math.min(M, bufSize);
After the first 4096-byte block, this still passes 4096 to EVP_CipherUpdate, even when fewer bytes remain. The unused buffer tail
contains stale data.
The chunk size should account for bytes already consumed:
final N = math.min(M - i, bufSize);
Impact
- Affects the native FFI backend.
- Affects
encryptBytes() and decryptBytes().
- Also affects streaming operations whenever an individual source chunk or
counter-wrap segment exceeds 4096 bytes with a partial final block.
- Causes silent cryptographic data corruption when assertions are disabled.
Suggested regression coverage
Test a 4097-byte input and verify:
- Ciphertext length equals plaintext length.
- Encryption/decryption round-trips successfully.
- Both byte and streaming APIs behave identically.
Environment
webcrypto 0.6.1
- Current
master contains the affected loop
- Dart 3.12.1
- macOS arm64
The existing test suite passes because its generated AES-CTR plaintext is limited to small inputs.
Description
The native FFI AES-CTR implementation silently corrupts data when a source chunk is larger than 4096 bytes and is not an exact multiple of 4096.
For a 4097-byte plaintext,
encryptBytes()returns 8192 bytes. Decrypting it does not recover the original plaintext. With assertions enabled, the operation instead fails atassert(i == M).Reproduction
Output:
With
dart --enable-asserts run:Expected behavior
AES-CTR ciphertext must have the same length as the plaintext, and decrypting it with the same key and counter must recover the original bytes.
Root cause
In
lib/src/impl_ffi/impl_ffi.aesctr.dart, the inner buffering loop uses:After the first 4096-byte block, this still passes 4096 to
EVP_CipherUpdate, even when fewer bytes remain. The unused buffer tailcontains stale data.
The chunk size should account for bytes already consumed:
Impact
encryptBytes()anddecryptBytes().counter-wrap segment exceeds 4096 bytes with a partial final block.
Suggested regression coverage
Test a 4097-byte input and verify:
Environment
webcrypto0.6.1mastercontains the affected loopThe existing test suite passes because its generated AES-CTR plaintext is limited to small inputs.