Skip to content

bug: native AES-CTR corrupts stream chunks larger than 4096 bytes #307

Description

@harrshita123

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:

  1. Ciphertext length equals plaintext length.
  2. Encryption/decryption round-trips successfully.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions