From 9d642ad1d26d4c6da5af24a280a151a1fb92b169 Mon Sep 17 00:00:00 2001 From: YATIN JAMWAL Date: Tue, 10 Feb 2026 19:27:51 +0530 Subject: [PATCH] Fix(ffi): Ensure BoringSSL error queue is cleared Fixes #63. This change updates the FFI implementation to ensure that the BoringSSL thread-local error queue is always cleared after operations, regardless of success or failure. This prevents stale errors from contaminating subsequent cryptographic operations or leaking across asynchronous boundaries. Also adds test/thread_local_error_test.dart to verify this behavior. --- lib/src/impl_ffi/impl_ffi.utils.dart | 13 +++-- test/thread_local_error_test.dart | 85 ++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 4 deletions(-) create mode 100644 test/thread_local_error_test.dart diff --git a/lib/src/impl_ffi/impl_ffi.utils.dart b/lib/src/impl_ffi/impl_ffi.utils.dart index 5ea46b4e..79bbb17d 100644 --- a/lib/src/impl_ffi/impl_ffi.utils.dart +++ b/lib/src/impl_ffi/impl_ffi.utils.dart @@ -93,6 +93,7 @@ void _checkOp(bool condition, {String? message, String? fallback}) { message ??= err ?? fallback ?? 'unknown error'; throw operationError(message); } + ssl.ERR_clear_error(); } /// Throw [OperationError] if [retval] is not `1`. @@ -113,6 +114,7 @@ void _checkData(bool condition, {String? message, String? fallback}) { message ??= err ?? fallback ?? 'unknown error'; throw FormatException(message); } + ssl.ERR_clear_error(); } /// Throw [FormatException] if [retval] is `1`. @@ -270,6 +272,7 @@ class _Scope implements Allocator { return await fn(scope); } finally { scope._release(); + ssl.ERR_clear_error(); } } @@ -281,6 +284,7 @@ class _Scope implements Allocator { yield* fn(scope); } finally { scope._release(); + ssl.ERR_clear_error(); } } @@ -294,6 +298,7 @@ class _Scope implements Allocator { return fn(scope); } finally { scope._release(); + ssl.ERR_clear_error(); } } } @@ -428,10 +433,10 @@ Future _verifyStream( signature.length, ); if (result != 1) { - // TODO: We should always clear errors, when returning from any - // function that uses BoringSSL. - // Note: In this case we could probably assert that error is just - // signature related. + // Always clear errors, so we don't leak anything from the error queue. + ssl.ERR_clear_error(); + } else { + // Also clear errors on success, just in case. ssl.ERR_clear_error(); } return result == 1; diff --git a/test/thread_local_error_test.dart b/test/thread_local_error_test.dart new file mode 100644 index 00000000..74606da8 --- /dev/null +++ b/test/thread_local_error_test.dart @@ -0,0 +1,85 @@ +@TestOn('vm') +library thread_local_error_test; + +import 'dart:typed_data'; + +import 'package:test/test.dart'; +import 'package:webcrypto/webcrypto.dart'; + +// This relies on internal implementation details to check for error leaks. +// If implementation changes significantly, this test might need updates. +import 'package:webcrypto/src/boringssl/lookup/lookup.dart'; // Access internal ssl + +void main() { + test('BoringSSL error stack is empty after operations', () async { + // Helper to check error stack + void checkErrorStack() { + // We peep at the error stack to see if anything was left behind. + // Operations must clean up after themselves. + final err = ssl.ERR_peek_error(); + if (err != 0) { + // Just failing with the error code is sufficient to signal a leak. + fail('BoringSSL error stack not empty. Error code: $err'); + } + } + + // Initial check to ensure clean slate + checkErrorStack(); + + // 1. Digest (SHA-256) + await Hash.sha256.digestBytes(Uint8List(10)); + checkErrorStack(); + + // 2. HMAC Generation & Sign & Verify + final hmacKey = await HmacSecretKey.generateKey(Hash.sha256); + checkErrorStack(); + final signature = await hmacKey.signBytes(Uint8List(10)); + checkErrorStack(); + final isValid = await hmacKey.verifyBytes(signature, Uint8List(10)); + expect(isValid, isTrue); + checkErrorStack(); + + // 3. HMAC Verify Failure + // Flip a bit in signature to cause verification failure + final invalidSig = Uint8List.fromList(signature); + if (invalidSig.isNotEmpty) { + invalidSig[0] ^= 0xff; + } + final isInvalid = await hmacKey.verifyBytes(invalidSig, Uint8List(10)); + expect(isInvalid, isFalse); + checkErrorStack(); + + // 4. AES-GCM + final aesKey = await AesGcmSecretKey.generateKey(256); + checkErrorStack(); + final iv = Uint8List(12); + final encrypted = await aesKey.encryptBytes(Uint8List(10), iv); + checkErrorStack(); + await aesKey.decryptBytes(encrypted, iv); + checkErrorStack(); + + // 5. ECDSA + final ecKey = await EcdsaPrivateKey.generateKey(EllipticCurve.p256); + checkErrorStack(); + final ecSig = await ecKey.privateKey.signBytes(Uint8List(10), Hash.sha256); + checkErrorStack(); + final ecValid = await ecKey.publicKey.verifyBytes(ecSig, Uint8List(10), Hash.sha256); + expect(ecValid, isTrue); + checkErrorStack(); + + // 6. Randomness + final randomBytes = Uint8List(32); + fillRandomBytes(randomBytes); + checkErrorStack(); + + // 7. Expected Failure (Import invalid JWK) + try { + // Missing 'k' property or invalid format + await AesGcmSecretKey.importJsonWebKey({'kty': 'oct', 'alg': 'A256GCM'}); + fail('Should have thrown ArgumentError or FormatException'); + } catch (_) { + // Expected exception + checkErrorStack(); + } + }); +}