From b1ca698bae5f0d956b480bcd2236e25bb9d30d7d Mon Sep 17 00:00:00 2001 From: HamdaanAliQuatil Date: Thu, 25 Jun 2026 00:06:43 +0530 Subject: [PATCH 1/3] ci: add FFI memory-safety test lane --- .github/workflows/test.yml | 22 +++ test/ffi_memory_safety_test.dart | 261 +++++++++++++++++++++++++++++++ 2 files changed, 283 insertions(+) create mode 100644 test/ffi_memory_safety_test.dart diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1855aa3a..fece7c96 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -58,6 +58,28 @@ jobs: - uses: dart-lang/setup-dart@v1 - run: dart pub get --no-example - run: xvfb-run dart test --exclude-tags browser-interop -p vm,chrome,firefox -c dart2js,dart2wasm + linux-ffi-memory-safety: + name: Linux FFI memory safety + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - uses: dart-lang/setup-dart@v1 + - name: Install Valgrind + run: | + sudo apt-get update -y + sudo apt-get install -y valgrind + - run: dart pub get --no-example + - name: Run FFI memory-safety tests under Valgrind + run: >- + valgrind + --tool=memcheck + --leak-check=full + --show-leak-kinds=definite,possible + --errors-for-leak-kinds=definite + --track-origins=yes + --error-exitcode=1 + dart test -p vm test/ffi_memory_safety_test.dart macos: name: MacOS desktop / Chrome runs-on: macos-15 # Test with xcode 16 diff --git a/test/ffi_memory_safety_test.dart b/test/ffi_memory_safety_test.dart new file mode 100644 index 00000000..d6317933 --- /dev/null +++ b/test/ffi_memory_safety_test.dart @@ -0,0 +1,261 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +@TestOn('vm') +@Timeout(Duration(minutes: 3)) +library; + +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:test/test.dart'; +import 'package:webcrypto/webcrypto.dart'; + +void main() { + test('symmetric operations survive memory pressure', () async { + for (var i = 0; i < 16; i++) { + final message = _bytes(64 + i, i); + final digest = await Hash.sha256.digestBytes(message); + expect(digest, hasLength(32)); + + final aesKeyData = _bytes(16, 100 + i); + final aesKey = await AesGcmSecretKey.importRawKey(aesKeyData); + final iv = _bytes(12, 200 + i); + final additionalData = _bytes(8, 300 + i); + final ciphertext = await aesKey.encryptBytes( + message, + iv, + additionalData: additionalData, + ); + final plaintext = await aesKey.decryptBytes( + ciphertext, + iv, + additionalData: additionalData, + ); + expect(plaintext, equals(message)); + expect(await aesKey.exportRawKey(), equals(aesKeyData)); + + final aesJwk = await aesKey.exportJsonWebKey(); + final aesJwkKey = await AesGcmSecretKey.importJsonWebKey(aesJwk); + expect(await aesJwkKey.exportRawKey(), equals(aesKeyData)); + + final hmacKeyData = _bytes(32, 400 + i); + final hmacKey = await HmacSecretKey.importRawKey( + hmacKeyData, + Hash.sha256, + ); + final signature = await hmacKey.signBytes(message); + expect(await hmacKey.verifyBytes(signature, message), isTrue); + expect(await hmacKey.exportRawKey(), equals(hmacKeyData)); + + final hmacJwk = await hmacKey.exportJsonWebKey(); + final hmacJwkKey = await HmacSecretKey.importJsonWebKey( + hmacJwk, + Hash.sha256, + ); + expect(await hmacJwkKey.verifyBytes(signature, message), isTrue); + + if (i.isEven) { + await _applyMemoryPressure(); + } + } + }); + + test('asymmetric key ownership survives memory pressure', () async { + for (var i = 0; i < 4; i++) { + final alice = await EcdhPrivateKey.generateKey(EllipticCurve.p256); + final bob = await EcdhPrivateKey.generateKey(EllipticCurve.p256); + + final aliceSecret = await alice.privateKey.deriveBits(256, bob.publicKey); + final bobSecret = await bob.privateKey.deriveBits(256, alice.publicKey); + expect(aliceSecret, equals(bobSecret)); + + final privatePkcs8 = await alice.privateKey.exportPkcs8Key(); + final publicSpki = await alice.publicKey.exportSpkiKey(); + final importedPrivate = await EcdhPrivateKey.importPkcs8Key( + privatePkcs8, + EllipticCurve.p256, + ); + final importedPublic = await EcdhPublicKey.importSpkiKey( + publicSpki, + EllipticCurve.p256, + ); + expect( + await importedPrivate.deriveBits(256, importedPublic), + hasLength(32), + ); + + final privateJwk = await alice.privateKey.exportJsonWebKey(); + final publicJwk = await alice.publicKey.exportJsonWebKey(); + final importedPrivateJwk = await EcdhPrivateKey.importJsonWebKey( + privateJwk, + EllipticCurve.p256, + ); + final importedPublicJwk = await EcdhPublicKey.importJsonWebKey( + publicJwk, + EllipticCurve.p256, + ); + expect( + await importedPrivateJwk.deriveBits(256, importedPublicJwk), + hasLength(32), + ); + + await _applyMemoryPressure(); + } + + final rsa = await RsaOaepPrivateKey.generateKey( + 2048, + BigInt.from(65537), + Hash.sha256, + ); + final privatePkcs8 = await rsa.privateKey.exportPkcs8Key(); + final publicSpki = await rsa.publicKey.exportSpkiKey(); + final privateKey = await RsaOaepPrivateKey.importPkcs8Key( + privatePkcs8, + Hash.sha256, + ); + final publicKey = await RsaOaepPublicKey.importSpkiKey( + publicSpki, + Hash.sha256, + ); + + for (var i = 0; i < 3; i++) { + final message = _bytes(32 + i, 500 + i); + final label = _bytes(8, 600 + i); + final ciphertext = await publicKey.encryptBytes(message, label: label); + expect(await privateKey.decryptBytes(ciphertext, label: label), message); + + final privateJwk = await privateKey.exportJsonWebKey(); + final publicJwk = await publicKey.exportJsonWebKey(); + final privateJwkKey = await RsaOaepPrivateKey.importJsonWebKey( + privateJwk, + Hash.sha256, + ); + final publicJwkKey = await RsaOaepPublicKey.importJsonWebKey( + publicJwk, + Hash.sha256, + ); + final jwkCiphertext = await publicJwkKey.encryptBytes( + message, + label: label, + ); + expect( + await privateJwkKey.decryptBytes(jwkCiphertext, label: label), + message, + ); + + await _applyMemoryPressure(); + } + }); + + test('failed imports leave later native operations usable', () async { + final ecdh = await EcdhPrivateKey.generateKey(EllipticCurve.p256); + final privatePkcs8 = await ecdh.privateKey.exportPkcs8Key(); + final publicSpki = await ecdh.publicKey.exportSpkiKey(); + + await expectLater( + EcdhPrivateKey.importPkcs8Key( + _withCorruptedByte(privatePkcs8), + EllipticCurve.p256, + ), + throwsFormatException, + ); + await expectLater( + EcdhPublicKey.importSpkiKey( + _withCorruptedByte(publicSpki), + EllipticCurve.p256, + ), + throwsFormatException, + ); + + await _applyMemoryPressure(); + + final validPrivate = await EcdhPrivateKey.importPkcs8Key( + privatePkcs8, + EllipticCurve.p256, + ); + final validPublic = await EcdhPublicKey.importSpkiKey( + publicSpki, + EllipticCurve.p256, + ); + expect(await validPrivate.deriveBits(256, validPublic), hasLength(32)); + + final rsa = await RsaOaepPrivateKey.generateKey( + 2048, + BigInt.from(65537), + Hash.sha256, + ); + final rsaPrivatePkcs8 = await rsa.privateKey.exportPkcs8Key(); + final rsaPublicSpki = await rsa.publicKey.exportSpkiKey(); + + await expectLater( + RsaOaepPrivateKey.importPkcs8Key( + _withCorruptedByte(rsaPrivatePkcs8), + Hash.sha256, + ), + throwsFormatException, + ); + await expectLater( + RsaOaepPublicKey.importSpkiKey( + _withCorruptedByte(rsaPublicSpki), + Hash.sha256, + ), + throwsFormatException, + ); + + await _applyMemoryPressure(); + + final validRsaPrivate = await RsaOaepPrivateKey.importPkcs8Key( + rsaPrivatePkcs8, + Hash.sha256, + ); + final validRsaPublic = await RsaOaepPublicKey.importSpkiKey( + rsaPublicSpki, + Hash.sha256, + ); + final message = _bytes(32, 700); + final ciphertext = await validRsaPublic.encryptBytes(message); + expect(await validRsaPrivate.decryptBytes(ciphertext), message); + }); +} + +Uint8List _bytes(int length, int seed) { + var state = seed & 0x7fffffff; + final bytes = Uint8List(length); + for (var i = 0; i < bytes.length; i++) { + state = (state * 1103515245 + 12345) & 0x7fffffff; + bytes[i] = state & 0xff; + } + return bytes; +} + +Uint8List _withCorruptedByte(List bytes) { + final corrupted = Uint8List.fromList(bytes); + corrupted[0] ^= 0xff; + return corrupted; +} + +Future _applyMemoryPressure() async { + final garbage = []; + var checksum = 0; + for (var i = 0; i < 128; i++) { + final block = Uint8List(4096); + block[0] = i; + checksum ^= block[0]; + garbage.add(block); + } + expect(garbage, hasLength(128)); + expect(checksum, isNonNegative); + await Future.delayed(Duration.zero); +} From e47b46fa130e4e3bf0670bbcb96f3c0cde61e2e1 Mon Sep 17 00:00:00 2001 From: HamdaanAliQuatil Date: Sat, 4 Jul 2026 18:06:46 +0530 Subject: [PATCH 2/3] test: separate Valgrind runner from FFI workload --- .github/workflows/test.yml | 10 +- test/ffi/valgrind_target.dart | 319 +++++++++++++++++++++++++++++++ test/ffi/valgrind_test.dart | 55 ++++++ test/ffi_memory_safety_test.dart | 261 ------------------------- 4 files changed, 375 insertions(+), 270 deletions(-) create mode 100644 test/ffi/valgrind_target.dart create mode 100644 test/ffi/valgrind_test.dart delete mode 100644 test/ffi_memory_safety_test.dart diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fece7c96..2c2de382 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -71,15 +71,7 @@ jobs: sudo apt-get install -y valgrind - run: dart pub get --no-example - name: Run FFI memory-safety tests under Valgrind - run: >- - valgrind - --tool=memcheck - --leak-check=full - --show-leak-kinds=definite,possible - --errors-for-leak-kinds=definite - --track-origins=yes - --error-exitcode=1 - dart test -p vm test/ffi_memory_safety_test.dart + run: dart test -p vm test/ffi/valgrind_test.dart macos: name: MacOS desktop / Chrome runs-on: macos-15 # Test with xcode 16 diff --git a/test/ffi/valgrind_target.dart b/test/ffi/valgrind_target.dart new file mode 100644 index 00000000..0b975823 --- /dev/null +++ b/test/ffi/valgrind_target.dart @@ -0,0 +1,319 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:webcrypto/webcrypto.dart'; + +Future main() async { + await _exerciseSymmetricOperations(); + await _exerciseAsymmetricKeyOwnership(); + await _exerciseCleanupAfterFailedImports(); + // ignore: avoid_print + print('Cryptographic operations survived memory pressure.'); +} + +Future _exerciseSymmetricOperations() async { + for (var i = 0; i < 16; i++) { + final message = _bytes(64 + i, i); + final digest = await Hash.sha256.digestBytes(message); + _expectLength(digest, 32, 'SHA-256 digest'); + + final aesKeyData = _bytes(16, 100 + i); + final aesKey = await AesGcmSecretKey.importRawKey(aesKeyData); + final iv = _bytes(12, 200 + i); + final additionalData = _bytes(8, 300 + i); + final ciphertext = await aesKey.encryptBytes( + message, + iv, + additionalData: additionalData, + ); + final plaintext = await aesKey.decryptBytes( + ciphertext, + iv, + additionalData: additionalData, + ); + _expectBytes(plaintext, message, 'AES-GCM plaintext'); + _expectBytes(await aesKey.exportRawKey(), aesKeyData, 'AES-GCM raw key'); + + final aesJwk = await aesKey.exportJsonWebKey(); + final aesJwkKey = await AesGcmSecretKey.importJsonWebKey(aesJwk); + _expectBytes(await aesJwkKey.exportRawKey(), aesKeyData, 'AES-GCM JWK key'); + + final hmacKeyData = _bytes(32, 400 + i); + final hmacKey = await HmacSecretKey.importRawKey(hmacKeyData, Hash.sha256); + final signature = await hmacKey.signBytes(message); + _check( + await hmacKey.verifyBytes(signature, message), + 'HMAC signature verification failed.', + ); + _expectBytes(await hmacKey.exportRawKey(), hmacKeyData, 'HMAC raw key'); + + final hmacJwk = await hmacKey.exportJsonWebKey(); + final hmacJwkKey = await HmacSecretKey.importJsonWebKey( + hmacJwk, + Hash.sha256, + ); + _check( + await hmacJwkKey.verifyBytes(signature, message), + 'HMAC JWK signature verification failed.', + ); + + if (i.isEven) { + await _applyMemoryPressure(); + } + } +} + +Future _exerciseAsymmetricKeyOwnership() async { + for (var i = 0; i < 4; i++) { + final alice = await EcdhPrivateKey.generateKey(EllipticCurve.p256); + final bob = await EcdhPrivateKey.generateKey(EllipticCurve.p256); + + final aliceSecret = await alice.privateKey.deriveBits(256, bob.publicKey); + final bobSecret = await bob.privateKey.deriveBits(256, alice.publicKey); + _expectBytes(aliceSecret, bobSecret, 'ECDH shared secret'); + + final privatePkcs8 = await alice.privateKey.exportPkcs8Key(); + final publicSpki = await alice.publicKey.exportSpkiKey(); + final importedPrivate = await EcdhPrivateKey.importPkcs8Key( + privatePkcs8, + EllipticCurve.p256, + ); + final importedPublic = await EcdhPublicKey.importSpkiKey( + publicSpki, + EllipticCurve.p256, + ); + _expectLength( + await importedPrivate.deriveBits(256, importedPublic), + 32, + 'Imported ECDH shared secret', + ); + + final privateJwk = await alice.privateKey.exportJsonWebKey(); + final publicJwk = await alice.publicKey.exportJsonWebKey(); + final importedPrivateJwk = await EcdhPrivateKey.importJsonWebKey( + privateJwk, + EllipticCurve.p256, + ); + final importedPublicJwk = await EcdhPublicKey.importJsonWebKey( + publicJwk, + EllipticCurve.p256, + ); + _expectLength( + await importedPrivateJwk.deriveBits(256, importedPublicJwk), + 32, + 'Imported ECDH JWK shared secret', + ); + + await _applyMemoryPressure(); + } + + final rsa = await RsaOaepPrivateKey.generateKey( + 2048, + BigInt.from(65537), + Hash.sha256, + ); + final privatePkcs8 = await rsa.privateKey.exportPkcs8Key(); + final publicSpki = await rsa.publicKey.exportSpkiKey(); + final privateKey = await RsaOaepPrivateKey.importPkcs8Key( + privatePkcs8, + Hash.sha256, + ); + final publicKey = await RsaOaepPublicKey.importSpkiKey( + publicSpki, + Hash.sha256, + ); + + for (var i = 0; i < 3; i++) { + final message = _bytes(32 + i, 500 + i); + final label = _bytes(8, 600 + i); + final ciphertext = await publicKey.encryptBytes(message, label: label); + _expectBytes( + await privateKey.decryptBytes(ciphertext, label: label), + message, + 'RSA-OAEP plaintext', + ); + + final privateJwk = await privateKey.exportJsonWebKey(); + final publicJwk = await publicKey.exportJsonWebKey(); + final privateJwkKey = await RsaOaepPrivateKey.importJsonWebKey( + privateJwk, + Hash.sha256, + ); + final publicJwkKey = await RsaOaepPublicKey.importJsonWebKey( + publicJwk, + Hash.sha256, + ); + final jwkCiphertext = await publicJwkKey.encryptBytes( + message, + label: label, + ); + _expectBytes( + await privateJwkKey.decryptBytes(jwkCiphertext, label: label), + message, + 'RSA-OAEP JWK plaintext', + ); + + await _applyMemoryPressure(); + } +} + +Future _exerciseCleanupAfterFailedImports() async { + final ecdh = await EcdhPrivateKey.generateKey(EllipticCurve.p256); + final privatePkcs8 = await ecdh.privateKey.exportPkcs8Key(); + final publicSpki = await ecdh.publicKey.exportSpkiKey(); + + await _expectFormatException( + EcdhPrivateKey.importPkcs8Key( + _withCorruptedByte(privatePkcs8), + EllipticCurve.p256, + ), + 'corrupt ECDH PKCS8 import', + ); + await _expectFormatException( + EcdhPublicKey.importSpkiKey( + _withCorruptedByte(publicSpki), + EllipticCurve.p256, + ), + 'corrupt ECDH SPKI import', + ); + + await _applyMemoryPressure(); + + final validPrivate = await EcdhPrivateKey.importPkcs8Key( + privatePkcs8, + EllipticCurve.p256, + ); + final validPublic = await EcdhPublicKey.importSpkiKey( + publicSpki, + EllipticCurve.p256, + ); + _expectLength( + await validPrivate.deriveBits(256, validPublic), + 32, + 'Valid ECDH operation after failed imports', + ); + + final rsa = await RsaOaepPrivateKey.generateKey( + 2048, + BigInt.from(65537), + Hash.sha256, + ); + final rsaPrivatePkcs8 = await rsa.privateKey.exportPkcs8Key(); + final rsaPublicSpki = await rsa.publicKey.exportSpkiKey(); + + await _expectFormatException( + RsaOaepPrivateKey.importPkcs8Key( + _withCorruptedByte(rsaPrivatePkcs8), + Hash.sha256, + ), + 'corrupt RSA PKCS8 import', + ); + await _expectFormatException( + RsaOaepPublicKey.importSpkiKey( + _withCorruptedByte(rsaPublicSpki), + Hash.sha256, + ), + 'corrupt RSA SPKI import', + ); + + await _applyMemoryPressure(); + + final validRsaPrivate = await RsaOaepPrivateKey.importPkcs8Key( + rsaPrivatePkcs8, + Hash.sha256, + ); + final validRsaPublic = await RsaOaepPublicKey.importSpkiKey( + rsaPublicSpki, + Hash.sha256, + ); + final message = _bytes(32, 700); + final ciphertext = await validRsaPublic.encryptBytes(message); + _expectBytes( + await validRsaPrivate.decryptBytes(ciphertext), + message, + 'Valid RSA operation after failed imports', + ); +} + +Uint8List _bytes(int length, int seed) { + var state = seed & 0x7fffffff; + final bytes = Uint8List(length); + for (var i = 0; i < bytes.length; i++) { + state = (state * 1103515245 + 12345) & 0x7fffffff; + bytes[i] = state & 0xff; + } + return bytes; +} + +Uint8List _withCorruptedByte(List bytes) { + final corrupted = Uint8List.fromList(bytes); + corrupted[0] ^= 0xff; + return corrupted; +} + +Future _expectFormatException( + Future operation, + String description, +) async { + try { + await operation; + } on FormatException { + return; + } + throw StateError('$description did not throw FormatException.'); +} + +void _expectBytes(List actual, List expected, String description) { + if (actual.length != expected.length) { + throw StateError( + '$description had length ${actual.length}; expected ${expected.length}.', + ); + } + for (var i = 0; i < actual.length; i++) { + if (actual[i] != expected[i]) { + throw StateError('$description differed at byte $i.'); + } + } +} + +void _expectLength(List value, int length, String description) { + if (value.length != length) { + throw StateError( + '$description had length ${value.length}; expected $length.', + ); + } +} + +void _check(bool condition, String message) { + if (!condition) { + throw StateError(message); + } +} + +Future _applyMemoryPressure() async { + final garbage = []; + var checksum = 0; + for (var i = 0; i < 128; i++) { + final block = Uint8List(4096); + block[0] = i; + checksum ^= block[0]; + garbage.add(block); + } + _check(garbage.length == 128, 'Memory-pressure allocation was incomplete.'); + _check(checksum >= 0, 'Memory-pressure checksum was invalid.'); + await Future.delayed(Duration.zero); +} diff --git a/test/ffi/valgrind_test.dart b/test/ffi/valgrind_test.dart new file mode 100644 index 00000000..3416e961 --- /dev/null +++ b/test/ffi/valgrind_test.dart @@ -0,0 +1,55 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +@TestOn('vm') +@Timeout(Duration(minutes: 3)) +library; + +import 'dart:io'; + +import 'package:test/test.dart'; + +void main() { + test('FFI operations are memory-safe under Valgrind', () async { + if (!Platform.isLinux) { + markTestSkipped('Valgrind testing is only supported on Linux.'); + return; + } + + final valgrind = Process.runSync('which', ['valgrind']); + if (valgrind.exitCode != 0) { + markTestSkipped('Valgrind is not installed.'); + return; + } + + final result = await Process.run('valgrind', [ + '--tool=memcheck', + '--leak-check=full', + '--show-leak-kinds=definite,possible', + '--errors-for-leak-kinds=definite', + '--track-origins=yes', + '--error-exitcode=1', + Platform.resolvedExecutable, + 'run', + 'test/ffi/valgrind_target.dart', + ]); + + expect( + result.exitCode, + 0, + reason: + 'Valgrind failed.\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}', + ); + }); +} diff --git a/test/ffi_memory_safety_test.dart b/test/ffi_memory_safety_test.dart deleted file mode 100644 index d6317933..00000000 --- a/test/ffi_memory_safety_test.dart +++ /dev/null @@ -1,261 +0,0 @@ -// Copyright 2026 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -@TestOn('vm') -@Timeout(Duration(minutes: 3)) -library; - -import 'dart:async'; -import 'dart:typed_data'; - -import 'package:test/test.dart'; -import 'package:webcrypto/webcrypto.dart'; - -void main() { - test('symmetric operations survive memory pressure', () async { - for (var i = 0; i < 16; i++) { - final message = _bytes(64 + i, i); - final digest = await Hash.sha256.digestBytes(message); - expect(digest, hasLength(32)); - - final aesKeyData = _bytes(16, 100 + i); - final aesKey = await AesGcmSecretKey.importRawKey(aesKeyData); - final iv = _bytes(12, 200 + i); - final additionalData = _bytes(8, 300 + i); - final ciphertext = await aesKey.encryptBytes( - message, - iv, - additionalData: additionalData, - ); - final plaintext = await aesKey.decryptBytes( - ciphertext, - iv, - additionalData: additionalData, - ); - expect(plaintext, equals(message)); - expect(await aesKey.exportRawKey(), equals(aesKeyData)); - - final aesJwk = await aesKey.exportJsonWebKey(); - final aesJwkKey = await AesGcmSecretKey.importJsonWebKey(aesJwk); - expect(await aesJwkKey.exportRawKey(), equals(aesKeyData)); - - final hmacKeyData = _bytes(32, 400 + i); - final hmacKey = await HmacSecretKey.importRawKey( - hmacKeyData, - Hash.sha256, - ); - final signature = await hmacKey.signBytes(message); - expect(await hmacKey.verifyBytes(signature, message), isTrue); - expect(await hmacKey.exportRawKey(), equals(hmacKeyData)); - - final hmacJwk = await hmacKey.exportJsonWebKey(); - final hmacJwkKey = await HmacSecretKey.importJsonWebKey( - hmacJwk, - Hash.sha256, - ); - expect(await hmacJwkKey.verifyBytes(signature, message), isTrue); - - if (i.isEven) { - await _applyMemoryPressure(); - } - } - }); - - test('asymmetric key ownership survives memory pressure', () async { - for (var i = 0; i < 4; i++) { - final alice = await EcdhPrivateKey.generateKey(EllipticCurve.p256); - final bob = await EcdhPrivateKey.generateKey(EllipticCurve.p256); - - final aliceSecret = await alice.privateKey.deriveBits(256, bob.publicKey); - final bobSecret = await bob.privateKey.deriveBits(256, alice.publicKey); - expect(aliceSecret, equals(bobSecret)); - - final privatePkcs8 = await alice.privateKey.exportPkcs8Key(); - final publicSpki = await alice.publicKey.exportSpkiKey(); - final importedPrivate = await EcdhPrivateKey.importPkcs8Key( - privatePkcs8, - EllipticCurve.p256, - ); - final importedPublic = await EcdhPublicKey.importSpkiKey( - publicSpki, - EllipticCurve.p256, - ); - expect( - await importedPrivate.deriveBits(256, importedPublic), - hasLength(32), - ); - - final privateJwk = await alice.privateKey.exportJsonWebKey(); - final publicJwk = await alice.publicKey.exportJsonWebKey(); - final importedPrivateJwk = await EcdhPrivateKey.importJsonWebKey( - privateJwk, - EllipticCurve.p256, - ); - final importedPublicJwk = await EcdhPublicKey.importJsonWebKey( - publicJwk, - EllipticCurve.p256, - ); - expect( - await importedPrivateJwk.deriveBits(256, importedPublicJwk), - hasLength(32), - ); - - await _applyMemoryPressure(); - } - - final rsa = await RsaOaepPrivateKey.generateKey( - 2048, - BigInt.from(65537), - Hash.sha256, - ); - final privatePkcs8 = await rsa.privateKey.exportPkcs8Key(); - final publicSpki = await rsa.publicKey.exportSpkiKey(); - final privateKey = await RsaOaepPrivateKey.importPkcs8Key( - privatePkcs8, - Hash.sha256, - ); - final publicKey = await RsaOaepPublicKey.importSpkiKey( - publicSpki, - Hash.sha256, - ); - - for (var i = 0; i < 3; i++) { - final message = _bytes(32 + i, 500 + i); - final label = _bytes(8, 600 + i); - final ciphertext = await publicKey.encryptBytes(message, label: label); - expect(await privateKey.decryptBytes(ciphertext, label: label), message); - - final privateJwk = await privateKey.exportJsonWebKey(); - final publicJwk = await publicKey.exportJsonWebKey(); - final privateJwkKey = await RsaOaepPrivateKey.importJsonWebKey( - privateJwk, - Hash.sha256, - ); - final publicJwkKey = await RsaOaepPublicKey.importJsonWebKey( - publicJwk, - Hash.sha256, - ); - final jwkCiphertext = await publicJwkKey.encryptBytes( - message, - label: label, - ); - expect( - await privateJwkKey.decryptBytes(jwkCiphertext, label: label), - message, - ); - - await _applyMemoryPressure(); - } - }); - - test('failed imports leave later native operations usable', () async { - final ecdh = await EcdhPrivateKey.generateKey(EllipticCurve.p256); - final privatePkcs8 = await ecdh.privateKey.exportPkcs8Key(); - final publicSpki = await ecdh.publicKey.exportSpkiKey(); - - await expectLater( - EcdhPrivateKey.importPkcs8Key( - _withCorruptedByte(privatePkcs8), - EllipticCurve.p256, - ), - throwsFormatException, - ); - await expectLater( - EcdhPublicKey.importSpkiKey( - _withCorruptedByte(publicSpki), - EllipticCurve.p256, - ), - throwsFormatException, - ); - - await _applyMemoryPressure(); - - final validPrivate = await EcdhPrivateKey.importPkcs8Key( - privatePkcs8, - EllipticCurve.p256, - ); - final validPublic = await EcdhPublicKey.importSpkiKey( - publicSpki, - EllipticCurve.p256, - ); - expect(await validPrivate.deriveBits(256, validPublic), hasLength(32)); - - final rsa = await RsaOaepPrivateKey.generateKey( - 2048, - BigInt.from(65537), - Hash.sha256, - ); - final rsaPrivatePkcs8 = await rsa.privateKey.exportPkcs8Key(); - final rsaPublicSpki = await rsa.publicKey.exportSpkiKey(); - - await expectLater( - RsaOaepPrivateKey.importPkcs8Key( - _withCorruptedByte(rsaPrivatePkcs8), - Hash.sha256, - ), - throwsFormatException, - ); - await expectLater( - RsaOaepPublicKey.importSpkiKey( - _withCorruptedByte(rsaPublicSpki), - Hash.sha256, - ), - throwsFormatException, - ); - - await _applyMemoryPressure(); - - final validRsaPrivate = await RsaOaepPrivateKey.importPkcs8Key( - rsaPrivatePkcs8, - Hash.sha256, - ); - final validRsaPublic = await RsaOaepPublicKey.importSpkiKey( - rsaPublicSpki, - Hash.sha256, - ); - final message = _bytes(32, 700); - final ciphertext = await validRsaPublic.encryptBytes(message); - expect(await validRsaPrivate.decryptBytes(ciphertext), message); - }); -} - -Uint8List _bytes(int length, int seed) { - var state = seed & 0x7fffffff; - final bytes = Uint8List(length); - for (var i = 0; i < bytes.length; i++) { - state = (state * 1103515245 + 12345) & 0x7fffffff; - bytes[i] = state & 0xff; - } - return bytes; -} - -Uint8List _withCorruptedByte(List bytes) { - final corrupted = Uint8List.fromList(bytes); - corrupted[0] ^= 0xff; - return corrupted; -} - -Future _applyMemoryPressure() async { - final garbage = []; - var checksum = 0; - for (var i = 0; i < 128; i++) { - final block = Uint8List(4096); - block[0] = i; - checksum ^= block[0]; - garbage.add(block); - } - expect(garbage, hasLength(128)); - expect(checksum, isNonNegative); - await Future.delayed(Duration.zero); -} From e6a821c5f342f8174f70e167f5a1c3752f959e26 Mon Sep 17 00:00:00 2001 From: HamdaanAliQuatil Date: Sat, 4 Jul 2026 19:15:18 +0530 Subject: [PATCH 3/3] ci: run Valgrind in existing Linux job --- .github/workflows/test.yml | 12 +----------- test/ffi/valgrind_target.dart | 3 +++ test/ffi/valgrind_test.dart | 2 ++ 3 files changed, 6 insertions(+), 11 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2c2de382..4b7fa2ce 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -53,15 +53,6 @@ jobs: name: Linux Dart / Chrome / Firefox runs-on: ubuntu-latest timeout-minutes: 15 - steps: - - uses: actions/checkout@v4 - - uses: dart-lang/setup-dart@v1 - - run: dart pub get --no-example - - run: xvfb-run dart test --exclude-tags browser-interop -p vm,chrome,firefox -c dart2js,dart2wasm - linux-ffi-memory-safety: - name: Linux FFI memory safety - runs-on: ubuntu-latest - timeout-minutes: 30 steps: - uses: actions/checkout@v4 - uses: dart-lang/setup-dart@v1 @@ -70,8 +61,7 @@ jobs: sudo apt-get update -y sudo apt-get install -y valgrind - run: dart pub get --no-example - - name: Run FFI memory-safety tests under Valgrind - run: dart test -p vm test/ffi/valgrind_test.dart + - run: xvfb-run dart test --exclude-tags browser-interop -p vm,chrome,firefox -c dart2js,dart2wasm macos: name: MacOS desktop / Chrome runs-on: macos-15 # Test with xcode 16 diff --git a/test/ffi/valgrind_target.dart b/test/ffi/valgrind_target.dart index 0b975823..b30291e0 100644 --- a/test/ffi/valgrind_target.dart +++ b/test/ffi/valgrind_target.dart @@ -17,6 +17,9 @@ import 'dart:typed_data'; import 'package:webcrypto/webcrypto.dart'; +/// Exercises FFI ownership and cleanup paths while Valgrind inspects the +/// process. This deliberately avoids `package:test` so the monitored process +/// contains only the crypto workload and its memory-pressure allocations. Future main() async { await _exerciseSymmetricOperations(); await _exerciseAsymmetricKeyOwnership(); diff --git a/test/ffi/valgrind_test.dart b/test/ffi/valgrind_test.dart index 3416e961..dada26f1 100644 --- a/test/ffi/valgrind_test.dart +++ b/test/ffi/valgrind_test.dart @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +/// Runs [valgrind_target.dart] under Valgrind when the test suite runs on +/// Linux. Other platforms, and Linux environments without Valgrind, skip it. @TestOn('vm') @Timeout(Duration(minutes: 3)) library;