From cb78fe74c7ea1f38751a3869745e56556878224b Mon Sep 17 00:00:00 2001 From: Paul Clark Date: Wed, 19 Aug 2026 02:32:04 -0400 Subject: [PATCH 1/5] fix(security): bump System.Security.Cryptography.Xml 8.0.3 -> 8.0.4 8.0.3 is now affected by five published high-severity advisories, all of which are first patched in 8.0.4: GHSA-23rf-6693-g89p CVE-2026-50648 .NET Denial of Service GHSA-8q5v-6pqq-x66h CVE-2026-50525 .NET Denial of Service GHSA-cvvh-rhrc-wg4q CVE-2026-47302 .NET Denial of Service GHSA-g8r8-53c2-pm3f CVE-2026-47304 .NET Security Feature Bypass GHSA-mmjf-rqrv-855v CVE-2026-50527 .NET Denial of Service This is why CI has failed on every main commit since 2026-06-04: NuGetAudit raises NU1903 as an error during restore, so build-and-test never gets past the restore step. Releases 1.0.0 and 1.0.1 were both cut from that red build, and PostQuantum.DataProtection 1.0.1 on nuget.org declares a direct dependency on the vulnerable 8.0.3 -- inherited by .Aws, .AzureKeyVault, .Cli, .Fips, .OpenTelemetry, .Redis and .Testing. 8.0.4 stays inside the 8.0.x line and ships lib/net8.0, so this preserves the net8.0;net9.0;net10.0 target set. Dependabot's alternative (PR #21, bumping to 10.0.9) would both drop net8.0/net9.0 support and remain vulnerable, since the 10.x line is only patched in 10.0.10. Co-Authored-By: Claude Opus 5 --- .../PostQuantum.DataProtection.csproj | 11 +++++++---- .../PostQuantum.DataProtection.Tests.csproj | 2 +- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/PostQuantum.DataProtection/PostQuantum.DataProtection.csproj b/src/PostQuantum.DataProtection/PostQuantum.DataProtection.csproj index e89d6f8..9e56bdf 100644 --- a/src/PostQuantum.DataProtection/PostQuantum.DataProtection.csproj +++ b/src/PostQuantum.DataProtection/PostQuantum.DataProtection.csproj @@ -80,11 +80,14 @@ - + diff --git a/tests/PostQuantum.DataProtection.Tests/PostQuantum.DataProtection.Tests.csproj b/tests/PostQuantum.DataProtection.Tests/PostQuantum.DataProtection.Tests.csproj index 443a9b7..104e53f 100644 --- a/tests/PostQuantum.DataProtection.Tests/PostQuantum.DataProtection.Tests.csproj +++ b/tests/PostQuantum.DataProtection.Tests/PostQuantum.DataProtection.Tests.csproj @@ -20,7 +20,7 @@ - + From 9339f5d553f813bd9d1a6d0f0faacf8bcd578a61 Mon Sep 17 00:00:00 2001 From: Paul Clark Date: Wed, 19 Aug 2026 03:00:40 -0400 Subject: [PATCH 2/5] test: skip ML-KEM-dependent tests on hosts without the primitive With the NU1903 restore block lifted, CI reaches the test step on macOS for the first time since 2026-06-04 -- and 63 tests fail there with: System.PlatformNotSupportedException : System.Security.Cryptography.MLKem is not available on this platform. macOS has no .NET 10 ML-KEM backend, so any test performing a real encapsulation cannot run there. This is pre-existing and unrelated to the System.Security.Cryptography.Xml bump; restore simply never got far enough to reveal it. Windows passes the full suite. Adds PqcFactAttribute / PqcTheoryAttribute, which set Skip when MLKem.IsSupported is false, and applies them to exactly the 56 affected test methods across 18 classes. The remaining 43 tests still run on macOS, so platform coverage of the non-crypto paths is preserved. This mirrors the PqcFactAttribute already used in postquantum-aspnetcore: a test that cannot run its crypto skips with a reason, never silently passes. The Linux and Windows legs continue to execute the full suite, so a real regression cannot hide behind these skips. Co-Authored-By: Claude Opus 5 --- .../AcvpKatExtendedTests.cs | 8 ++-- .../AcvpKatTests.cs | 6 +-- .../CloudStoreConcurrencyTests.cs | 6 +-- .../CombinerKnownAnswerTests.cs | 4 +- .../ConcurrencyTests.cs | 6 +-- .../DataProtectionIntegrationTests.cs | 4 +- .../DecryptorFailClosedTests.cs | 4 +- .../EnvelopeTamperingTests.cs | 8 ++-- .../FilePostQuantumKeyStoreTests.cs | 12 ++--- .../HealthCheckTests.cs | 4 +- .../KeyDescriptorTests.cs | 4 +- .../MlKemKatTests.cs | 12 ++--- .../MlKemTests.cs | 10 ++-- .../ParameterSetAndFormatSafetyTests.cs | 2 +- .../ParameterSetTests.cs | 4 +- .../PqcFact.cs | 46 +++++++++++++++++++ .../PruneTests.cs | 4 +- .../RoundtripTests.cs | 8 ++-- .../TelemetryTests.cs | 6 +-- 19 files changed, 102 insertions(+), 56 deletions(-) create mode 100644 tests/PostQuantum.DataProtection.Tests/PqcFact.cs diff --git a/tests/PostQuantum.DataProtection.Tests/AcvpKatExtendedTests.cs b/tests/PostQuantum.DataProtection.Tests/AcvpKatExtendedTests.cs index 860cd4d..e1d56a0 100644 --- a/tests/PostQuantum.DataProtection.Tests/AcvpKatExtendedTests.cs +++ b/tests/PostQuantum.DataProtection.Tests/AcvpKatExtendedTests.cs @@ -27,7 +27,7 @@ public sealed class AcvpKatExtendedTests private const string K1024_Ct_Hex = "707d18cabcf89670c80003b47d2b8678ad0da4aaad781a3351e82ac3e447e7019af5cb86020fb2ac727e79654155835b45de9b8af5e5c7efa01295da8988131dc8d6edb0645c3e4116aab080c5a571e760416fe8e299c89811963cf9776e0c828751701f90e26435897f8fdf7a7afaad987009c0eb12fb914dfaab1fce9264f55ee0cb4d89449385dd081b02dace9b179a54513d281529b8fd61c53e3bcfc9e2c3ae72339a6197119f97d44b05d3f36a5abe0a3f7ccbdbb91d24b25856462a649b6c1d46ae6e05e999274da9239e674525a522f6141cb95de96b93b39f0f7d090d4b1c0546ef6fc3fd08e90b228f657af31eb933c9cfce634af6bf820d5e185f3157efbc926a8ae420f29300513baf30236cc11447e74aed4e262799d77a5cf22b0a7da1264225fcd68cf07670bbf5b04f3173d6142dc9426d9006ddedc5d59443a11c8d20453c2867c10434ab6b05b4f0b467b67a4203037c16ea720945de474102e168d5f79d7f530f61d6b7973ed6457e877fb2b45e321ded8ffd30368259b3ea60b4cf9d11c5a0087f27ed40d5365c3d0b8ba2e8cdea59afa35149970488cea9e07c9434923e96e6a99cf3a0be11016a86dc9eaa539fecbddc6cfd597a28aecae2757234340194f4fac7e5a204821382d5928c8e035833c7cad49bba4304e826d5c024c7cca2acf95c6cbbd68c3848554a855695c248add50c87dae4a49d11cdbc7c757ab1e00b8fd21e7854a897dc21b40f087b3dfb131773d9d87401e5d5017ab1f86430f889ff66cc9e04be863e7e38ad527e7644c8ce93d0bc2e255cdc3be2d3784ed0c12649f5febbabac0c8e4c917c8bef29775fa3db1ea23feddc609539c23354dce722aee71c45407b34da006f24452ea05e766e6b5dcac937ef743aa908a7b7cf2f3ed8931c61cdd343dfdf6c588d1b1f4099ba5be53e7691099660877463c4310a36767ee27e0dbd3132acf6888421e1012edf383d4eec9643e8a10b50e10527c5a1474458b35e54b841fc02599ed07c190154525c47b85e69b09e8208dcf36f7e38d9f1090b8daf0a324d7cb48ace08dac11be460585dba9061abe7ef724343a4baf1c74090e8d7918648d0ddc43743bc031eec9e3a2912d870f50edcdb6e292456c400006ef65eb2c24e038ca3ddf975e2a2611a8b187e33e52aaddc119ef9e6403c61924a7cf229f6619ef9baceee7c04f2675996174c938078f50ccaf6d6580fced01564723d2979b33b77f689951b7c77b650e48a840853932612c080f9b55bfc78de641e0902c5503b2f6cc450664771b94d5590957b669ea08b96f368e11eb905427e2650ed185d6003ab704e23889300cdd920060d3934b44074783db397172838c9be318b5f892058e67d9ec94019870d758229e63018cd85d4492e0eb8c0a90d3a666e7616ddab331601ec9611572929ee74bce8d3bfb8a708858fdbf661bea1f0927f6e5192a86151fd03b001936c82da6d8c147a1af2e91b22a402ce758fd743226a870f3e3635231a390a20c6aee172818979dcc4db5e8bd9802bc5c0bffda35c288d027cbade7d615996879a09292bb3ca2c9720e741e51959d818c9df6903bb711a93ba67b845397dd1d578c6b937f8c3eee4675046f3c3004ab104a436c551352e70d5179c43ce48c5ee710744de4596b3c4cdb12181d0d96ac0ec69aa1451159fabcf3fb8899809aecc4d4f097e86b4b735215c89d13b0dafe75df0020b22bef4973b457f548b4a8998dff79232ec446951aa7ebc92212bae1a06f782be9925a532e10dd02aada8948766c427ed5a579936ebbf5c7a2c9762fdbfe14c70b2c0118378acd644056fec83c774b54867e3889c2af117661086aa5abdc1bd1be41c0f66343e0ec517a7a59849b2b5a37a990ce4487b098dbdd9178b288411f4373654c873ee8ed018156bb3401153b096f853aa14c365b2c3215413ebce8dc07c4bafdd02613e1659056a4d010e970b38dc7e10072403d8c9622eebf4030b6ab640f7022d5c2c938c0ca2156bc16164b2320663de1f904a5c0d9f63d46eb413081c809af2cbc247d26bb6cb74c58022cbc9b7d60b17d03255e736024b6146ab9f1c1c6d648b9fdf256a602b7c469da04bffb6b7355728a05308b9336a49a1522d7e7ee0ad2fd2472bb9cf882a4851f5ba5b433def666fb0bfed4358afdf5b6005051e74c16244f4579288a0da8c2f49598f74"; private const string K1024_Ss_Hex = "23f211b84a6ee20c8c29f6e5314c91b414e940513d380add17bd724ab3a13a52"; - [Fact] + [PqcFact] public void Kem512_keygen_matches_NIST_vector() { byte[] seed = [.. Hex(K512_D_Hex), .. Hex(K512_Z_Hex)]; @@ -36,14 +36,14 @@ public void Kem512_keygen_matches_NIST_vector() Assert.Equal(Hex(K512_Sk_Hex), sk); } - [Fact] + [PqcFact] public void Kem512_decapsulate_against_NIST_ciphertext_recovers_NIST_shared_secret() { byte[] recovered = MlKem.Decapsulate(Hex(K512_Sk_Hex), Hex(K512_Ct_Hex), MlKemParameterSet.Kem512); Assert.Equal(Hex(K512_Ss_Hex), recovered); } - [Fact] + [PqcFact] public void Kem1024_keygen_matches_NIST_vector() { byte[] seed = [.. Hex(K1024_D_Hex), .. Hex(K1024_Z_Hex)]; @@ -52,7 +52,7 @@ public void Kem1024_keygen_matches_NIST_vector() Assert.Equal(Hex(K1024_Sk_Hex), sk); } - [Fact] + [PqcFact] public void Kem1024_decapsulate_against_NIST_ciphertext_recovers_NIST_shared_secret() { byte[] recovered = MlKem.Decapsulate(Hex(K1024_Sk_Hex), Hex(K1024_Ct_Hex), MlKemParameterSet.Kem1024); diff --git a/tests/PostQuantum.DataProtection.Tests/AcvpKatTests.cs b/tests/PostQuantum.DataProtection.Tests/AcvpKatTests.cs index b6e662e..dcbee33 100644 --- a/tests/PostQuantum.DataProtection.Tests/AcvpKatTests.cs +++ b/tests/PostQuantum.DataProtection.Tests/AcvpKatTests.cs @@ -44,7 +44,7 @@ public sealed class AcvpKatTests private static byte[] Hex(string s) => Convert.FromHexString(s); - [Fact] + [PqcFact] public void Keygen_from_d_concat_z_matches_NIST_public_key() { // FIPS 203 ML-KEM.KeyGen uses d (32 bytes) for K-PKE.KeyGen and concatenates z (32 bytes) @@ -60,7 +60,7 @@ public void Keygen_from_d_concat_z_matches_NIST_public_key() Assert.Equal(expectedPk, derivedPk); } - [Fact] + [PqcFact] public void Keygen_from_d_concat_z_matches_NIST_secret_key() { byte[] seed = [.. Hex(D_Hex), .. Hex(Z_Hex)]; @@ -72,7 +72,7 @@ public void Keygen_from_d_concat_z_matches_NIST_secret_key() Assert.Equal(expectedSk, derivedSk); } - [Fact] + [PqcFact] public void Decapsulate_against_NIST_ciphertext_recovers_NIST_shared_secret() { // This is the load-bearing KAT: independent of our keygen, given the NIST sk and ct, diff --git a/tests/PostQuantum.DataProtection.Tests/CloudStoreConcurrencyTests.cs b/tests/PostQuantum.DataProtection.Tests/CloudStoreConcurrencyTests.cs index efd4341..af4e496 100644 --- a/tests/PostQuantum.DataProtection.Tests/CloudStoreConcurrencyTests.cs +++ b/tests/PostQuantum.DataProtection.Tests/CloudStoreConcurrencyTests.cs @@ -14,7 +14,7 @@ namespace PostQuantum.DataProtection.Tests; /// public sealed class CloudStoreConcurrencyTests { - [Fact] + [PqcFact] public async Task File_store_under_concurrent_load_serves_consistent_active_id() { const int threads = 32; @@ -52,7 +52,7 @@ await Parallel.ForEachAsync( } } - [Fact] + [PqcFact] public async Task Parallel_rotations_serialise_correctly_and_grow_the_keyring_monotonically() { const int rotators = 8; @@ -97,7 +97,7 @@ await Parallel.ForEachAsync( } } - [Fact] + [PqcFact] public async Task PruneAsync_under_concurrent_rotations_never_deletes_the_active_keypair() { string tempDir = TestDefaults.CreateTempDirectory(); diff --git a/tests/PostQuantum.DataProtection.Tests/CombinerKnownAnswerTests.cs b/tests/PostQuantum.DataProtection.Tests/CombinerKnownAnswerTests.cs index 030ef4e..2dda97c 100644 --- a/tests/PostQuantum.DataProtection.Tests/CombinerKnownAnswerTests.cs +++ b/tests/PostQuantum.DataProtection.Tests/CombinerKnownAnswerTests.cs @@ -50,7 +50,7 @@ public void Hybrid_matches_spec() Assert.Equal(expected, actual); } - [Fact] + [PqcFact] public void XWingHybrid_matches_spec_and_binds_ciphertext() { byte[] actual = HybridCombiner.DeriveXWingHybrid(MlKemSharedSecret(), ClassicalSharedSecret(), MlKemCiphertext(), Salt()); @@ -74,7 +74,7 @@ .. Salt(), Assert.NotEqual(actual, withOtherCt); } - [Fact] + [PqcFact] public void The_three_modes_derive_distinct_keys_from_identical_secrets() { // Domain separation: same shared secrets, different mode → different derived key. diff --git a/tests/PostQuantum.DataProtection.Tests/ConcurrencyTests.cs b/tests/PostQuantum.DataProtection.Tests/ConcurrencyTests.cs index c555380..0bc5e71 100644 --- a/tests/PostQuantum.DataProtection.Tests/ConcurrencyTests.cs +++ b/tests/PostQuantum.DataProtection.Tests/ConcurrencyTests.cs @@ -14,7 +14,7 @@ namespace PostQuantum.DataProtection.Tests; /// public sealed class ConcurrencyTests { - [Fact] + [PqcFact] public async Task Many_threads_can_encrypt_and_decrypt_against_one_key_manager() { const int threadCount = 16; @@ -76,7 +76,7 @@ await Parallel.ForEachAsync( } } - [Fact] + [PqcFact] public async Task Encryptions_concurrent_with_rotations_all_succeed_and_remain_decryptable() { // Tests the load-bearing claim: a rotation in flight does not break or corrupt an @@ -166,7 +166,7 @@ await Parallel.ForEachAsync( } } - [Fact] + [PqcFact] public async Task First_run_under_concurrent_load_creates_exactly_one_keypair() { // The first GetActiveKeyIdAsync triggers EnsureLoadedAsync -> RotateCoreAsync. If multiple diff --git a/tests/PostQuantum.DataProtection.Tests/DataProtectionIntegrationTests.cs b/tests/PostQuantum.DataProtection.Tests/DataProtectionIntegrationTests.cs index ba35aba..d7cc62f 100644 --- a/tests/PostQuantum.DataProtection.Tests/DataProtectionIntegrationTests.cs +++ b/tests/PostQuantum.DataProtection.Tests/DataProtectionIntegrationTests.cs @@ -13,7 +13,7 @@ namespace PostQuantum.DataProtection.Tests; /// public sealed class DataProtectionIntegrationTests { - [Fact] + [PqcFact] public void Protect_then_Unprotect_round_trips_through_ASP_NET_Core_Data_Protection() { string tempDir = TestDefaults.CreateTempDirectory(); @@ -46,7 +46,7 @@ public void Protect_then_Unprotect_round_trips_through_ASP_NET_Core_Data_Protect } } - [Fact] + [PqcFact] public void Persisted_DP_key_file_contains_a_pqEnvelope_element() { string tempDir = TestDefaults.CreateTempDirectory(); diff --git a/tests/PostQuantum.DataProtection.Tests/DecryptorFailClosedTests.cs b/tests/PostQuantum.DataProtection.Tests/DecryptorFailClosedTests.cs index 612ae8c..88cfa54 100644 --- a/tests/PostQuantum.DataProtection.Tests/DecryptorFailClosedTests.cs +++ b/tests/PostQuantum.DataProtection.Tests/DecryptorFailClosedTests.cs @@ -19,7 +19,7 @@ public sealed class DecryptorFailClosedTests private static XElement Wrap(string payload) => new(XName.Get(PostQuantumXmlEncryptor.XmlElementName, PostQuantumXmlEncryptor.XmlNamespace), payload); - [Theory] + [PqcTheory] [InlineData("!!! not base64url !!!")] [InlineData("AAAA")] // decodes to bytes but is a truncated/invalid envelope public async Task Malformed_token_fails_closed_as_CryptographicException(string garbage) @@ -43,7 +43,7 @@ public async Task Malformed_token_fails_closed_as_CryptographicException(string } } - [Fact] + [PqcFact] public async Task Wrong_sized_kem_ciphertext_fails_closed_as_CryptographicException() { using LocalContentKeyProvider keys = TestDefaults.CreateContentKeyProvider(); diff --git a/tests/PostQuantum.DataProtection.Tests/EnvelopeTamperingTests.cs b/tests/PostQuantum.DataProtection.Tests/EnvelopeTamperingTests.cs index 88860da..f5a393f 100644 --- a/tests/PostQuantum.DataProtection.Tests/EnvelopeTamperingTests.cs +++ b/tests/PostQuantum.DataProtection.Tests/EnvelopeTamperingTests.cs @@ -9,7 +9,7 @@ namespace PostQuantum.DataProtection.Tests; public sealed class EnvelopeTamperingTests { - [Fact] + [PqcFact] public async Task Tampered_ciphertext_byte_fails_authentication() { using LocalContentKeyProvider keys = TestDefaults.CreateContentKeyProvider(); @@ -43,7 +43,7 @@ public async Task Tampered_ciphertext_byte_fails_authentication() } } - [Fact] + [PqcFact] public async Task Tampered_tag_fails_authentication() { using LocalContentKeyProvider keys = TestDefaults.CreateContentKeyProvider(); @@ -70,7 +70,7 @@ public async Task Tampered_tag_fails_authentication() } } - [Fact] + [PqcFact] public async Task Tampered_kem_ciphertext_fails_decapsulation() { using LocalContentKeyProvider keys = TestDefaults.CreateContentKeyProvider(); @@ -100,7 +100,7 @@ public async Task Tampered_kem_ciphertext_fails_decapsulation() } } - [Fact] + [PqcFact] public async Task Truncated_envelope_throws_FormatException_on_decode() { using LocalContentKeyProvider keys = TestDefaults.CreateContentKeyProvider(); diff --git a/tests/PostQuantum.DataProtection.Tests/FilePostQuantumKeyStoreTests.cs b/tests/PostQuantum.DataProtection.Tests/FilePostQuantumKeyStoreTests.cs index 4ec00eb..595bc5e 100644 --- a/tests/PostQuantum.DataProtection.Tests/FilePostQuantumKeyStoreTests.cs +++ b/tests/PostQuantum.DataProtection.Tests/FilePostQuantumKeyStoreTests.cs @@ -7,7 +7,7 @@ namespace PostQuantum.DataProtection.Tests; public sealed class FilePostQuantumKeyStoreTests { - [Fact] + [PqcFact] public async Task First_run_creates_keystore_with_a_fresh_keypair() { string tempDir = TestDefaults.CreateTempDirectory(); @@ -31,7 +31,7 @@ public async Task First_run_creates_keystore_with_a_fresh_keypair() } } - [Fact] + [PqcFact] public async Task The_raw_keystore_file_contains_no_plaintext_ml_kem_secret_key() { // We cannot inspect the SK directly without unwrapping. The check we can make is that @@ -58,7 +58,7 @@ public async Task The_raw_keystore_file_contains_no_plaintext_ml_kem_secret_key( } } - [Fact] + [PqcFact] public async Task Second_load_rehydrates_existing_keypair_and_keeps_active_id() { string tempDir = TestDefaults.CreateTempDirectory(); @@ -85,7 +85,7 @@ public async Task Second_load_rehydrates_existing_keypair_and_keeps_active_id() } } - [Fact] + [PqcFact] public async Task Rotate_changes_active_key_and_keeps_old_keypair_loadable() { string tempDir = TestDefaults.CreateTempDirectory(); @@ -112,7 +112,7 @@ public async Task Rotate_changes_active_key_and_keeps_old_keypair_loadable() } } - [Fact] + [PqcFact] public async Task Atomic_write_leaves_no_temp_files_after_a_normal_save() { string tempDir = TestDefaults.CreateTempDirectory(); @@ -135,7 +135,7 @@ public async Task Atomic_write_leaves_no_temp_files_after_a_normal_save() } } - [Fact] + [PqcFact] public async Task Payload_encrypted_under_old_keypair_still_decrypts_after_rotation() { string tempDir = TestDefaults.CreateTempDirectory(); diff --git a/tests/PostQuantum.DataProtection.Tests/HealthCheckTests.cs b/tests/PostQuantum.DataProtection.Tests/HealthCheckTests.cs index 8bc3b5a..6676a50 100644 --- a/tests/PostQuantum.DataProtection.Tests/HealthCheckTests.cs +++ b/tests/PostQuantum.DataProtection.Tests/HealthCheckTests.cs @@ -11,7 +11,7 @@ namespace PostQuantum.DataProtection.Tests; public sealed class HealthCheckTests { - [Fact] + [PqcFact] public async Task Returns_healthy_when_roundtrip_succeeds() { using LocalContentKeyProvider keys = TestDefaults.CreateContentKeyProvider(); @@ -34,7 +34,7 @@ public async Task Returns_healthy_when_roundtrip_succeeds() } } - [Fact] + [PqcFact] public async Task AddPostQuantumDataProtection_registers_the_check_in_the_DI_container() { var services = new ServiceCollection(); diff --git a/tests/PostQuantum.DataProtection.Tests/KeyDescriptorTests.cs b/tests/PostQuantum.DataProtection.Tests/KeyDescriptorTests.cs index 7121d33..f1a4bca 100644 --- a/tests/PostQuantum.DataProtection.Tests/KeyDescriptorTests.cs +++ b/tests/PostQuantum.DataProtection.Tests/KeyDescriptorTests.cs @@ -6,7 +6,7 @@ namespace PostQuantum.DataProtection.Tests; public sealed class KeyDescriptorTests { - [Fact] + [PqcFact] public async Task ListKeysAsync_returns_one_active_keypair_after_first_run() { using LocalContentKeyProvider keys = TestDefaults.CreateContentKeyProvider(); @@ -31,7 +31,7 @@ public async Task ListKeysAsync_returns_one_active_keypair_after_first_run() } } - [Fact] + [PqcFact] public async Task ListKeysAsync_after_rotation_returns_old_and_new_with_only_new_active() { using LocalContentKeyProvider keys = TestDefaults.CreateContentKeyProvider(); diff --git a/tests/PostQuantum.DataProtection.Tests/MlKemKatTests.cs b/tests/PostQuantum.DataProtection.Tests/MlKemKatTests.cs index bb8097c..ef059e8 100644 --- a/tests/PostQuantum.DataProtection.Tests/MlKemKatTests.cs +++ b/tests/PostQuantum.DataProtection.Tests/MlKemKatTests.cs @@ -33,7 +33,7 @@ public sealed class MlKemKatTests 0x50, 0x72, 0x6f, 0x74, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x20, 0x4b, 0x41, 0x54, 0x20, 0x32, ]; - [Fact] + [PqcFact] public void Same_seed_yields_the_same_private_key_bytes() { (byte[] _, byte[] a) = MlKem.GenerateKeyPairFromSeed(MlKemParameterSet.Kem768, PinnedSeed); @@ -43,7 +43,7 @@ public void Same_seed_yields_the_same_private_key_bytes() Assert.Equal(a, b); } - [Fact] + [PqcFact] public void Same_seed_yields_the_same_public_key_bytes() { (byte[] aPk, byte[] _) = MlKem.GenerateKeyPairFromSeed(MlKemParameterSet.Kem768, PinnedSeed); @@ -53,7 +53,7 @@ public void Same_seed_yields_the_same_public_key_bytes() Assert.Equal(aPk, bPk); } - [Fact] + [PqcFact] public void Seeded_keypair_supports_encapsulate_decapsulate_roundtrip() { (byte[] pk, byte[] sk) = MlKem.GenerateKeyPairFromSeed(MlKemParameterSet.Kem768, PinnedSeed); @@ -66,7 +66,7 @@ public void Seeded_keypair_supports_encapsulate_decapsulate_roundtrip() Assert.Equal(sentSecret, recovered); } - [Fact] + [PqcFact] public void Seeded_keypair_public_key_hash_is_pinned() { // Pins the public-key byte encoding via its SHA-256. Same value across BC (net8/9) and BCL @@ -80,7 +80,7 @@ public void Seeded_keypair_public_key_hash_is_pinned() Assert.Equal(ExpectedHash.PublicKeySha256, actual); } - [Fact] + [PqcFact] public void Seeded_keypair_private_key_hash_is_pinned() { (byte[] _, byte[] sk) = MlKem.GenerateKeyPairFromSeed(MlKemParameterSet.Kem768, PinnedSeed); @@ -90,7 +90,7 @@ public void Seeded_keypair_private_key_hash_is_pinned() Assert.Equal(ExpectedHash.PrivateKeySha256, actual); } - [Fact] + [PqcFact] public void Two_different_seeds_yield_different_public_keys() { byte[] otherSeed = (byte[])PinnedSeed.Clone(); diff --git a/tests/PostQuantum.DataProtection.Tests/MlKemTests.cs b/tests/PostQuantum.DataProtection.Tests/MlKemTests.cs index 63dcbdb..c0b6e8e 100644 --- a/tests/PostQuantum.DataProtection.Tests/MlKemTests.cs +++ b/tests/PostQuantum.DataProtection.Tests/MlKemTests.cs @@ -6,7 +6,7 @@ namespace PostQuantum.DataProtection.Tests; public sealed class MlKemTests { - [Fact] + [PqcFact] public void GenerateKeyPair_produces_keys_with_FIPS_203_sizes() { (byte[] pk, byte[] sk) = MlKem.GenerateKeyPair(); @@ -17,7 +17,7 @@ public void GenerateKeyPair_produces_keys_with_FIPS_203_sizes() Assert.Equal(2400, sk.Length); } - [Fact] + [PqcFact] public void Encapsulate_and_Decapsulate_recover_the_same_shared_secret() { (byte[] pk, byte[] sk) = MlKem.GenerateKeyPair(); @@ -31,7 +31,7 @@ public void Encapsulate_and_Decapsulate_recover_the_same_shared_secret() Assert.Equal(sharedSecret, recovered); } - [Fact] + [PqcFact] public void Decapsulate_with_a_different_secret_key_yields_a_different_shared_secret() { // ML-KEM is IND-CCA2: decapsulating against the "wrong" SK does not throw, it just yields @@ -54,7 +54,7 @@ public void Encapsulate_rejects_wrong_size_public_key() Assert.Throws(() => MlKem.Encapsulate(tooShort)); } - [Fact] + [PqcFact] public void Decapsulate_rejects_wrong_size_private_key_and_ciphertext() { (byte[] pk, byte[] sk) = MlKem.GenerateKeyPair(); @@ -64,7 +64,7 @@ public void Decapsulate_rejects_wrong_size_private_key_and_ciphertext() Assert.Throws(() => MlKem.Decapsulate(sk, new byte[10])); } - [Fact] + [PqcFact] public void Two_encapsulations_against_the_same_public_key_produce_different_outputs() { (byte[] pk, byte[] _) = MlKem.GenerateKeyPair(); diff --git a/tests/PostQuantum.DataProtection.Tests/ParameterSetAndFormatSafetyTests.cs b/tests/PostQuantum.DataProtection.Tests/ParameterSetAndFormatSafetyTests.cs index 648f0d3..07125e9 100644 --- a/tests/PostQuantum.DataProtection.Tests/ParameterSetAndFormatSafetyTests.cs +++ b/tests/PostQuantum.DataProtection.Tests/ParameterSetAndFormatSafetyTests.cs @@ -14,7 +14,7 @@ namespace PostQuantum.DataProtection.Tests; /// public sealed class ParameterSetAndFormatSafetyTests { - [Theory] + [PqcTheory] [InlineData(MlKemParameterSet.Kem512, "pq-mlkem512-")] [InlineData(MlKemParameterSet.Kem768, "pq-mlkem768-")] [InlineData(MlKemParameterSet.Kem1024, "pq-mlkem1024-")] diff --git a/tests/PostQuantum.DataProtection.Tests/ParameterSetTests.cs b/tests/PostQuantum.DataProtection.Tests/ParameterSetTests.cs index 74480ed..3d25713 100644 --- a/tests/PostQuantum.DataProtection.Tests/ParameterSetTests.cs +++ b/tests/PostQuantum.DataProtection.Tests/ParameterSetTests.cs @@ -8,7 +8,7 @@ namespace PostQuantum.DataProtection.Tests; public sealed class ParameterSetTests { - [Theory] + [PqcTheory] [InlineData(MlKemParameterSet.Kem512)] [InlineData(MlKemParameterSet.Kem768)] [InlineData(MlKemParameterSet.Kem1024)] @@ -39,7 +39,7 @@ public async Task Each_parameter_set_produces_envelopes_that_roundtrip(MlKemPara } } - [Fact] + [PqcFact] public async Task A_keypair_minted_under_kem512_still_decrypts_after_switching_to_kem1024() { using LocalContentKeyProvider keys = TestDefaults.CreateContentKeyProvider(); diff --git a/tests/PostQuantum.DataProtection.Tests/PqcFact.cs b/tests/PostQuantum.DataProtection.Tests/PqcFact.cs new file mode 100644 index 0000000..c5403a7 --- /dev/null +++ b/tests/PostQuantum.DataProtection.Tests/PqcFact.cs @@ -0,0 +1,46 @@ +using System.Security.Cryptography; +using Xunit; + +namespace PostQuantum.DataProtection.Tests; + +/// +/// A that skips the test (with a stated reason) when the host +/// runtime lacks the native ML-KEM primitive. macOS has no .NET 10 ML-KEM backend, so every +/// test that performs a real encapsulation would otherwise fail with +/// rather than skip. +/// +/// Mirrors the discipline used across these repositories: a test that cannot run its crypto +/// skips with a reason, never silently passes. The Linux leg still runs the full suite, so a +/// genuine regression cannot hide behind these skips. +/// +/// +public sealed class PqcFactAttribute : FactAttribute +{ + public PqcFactAttribute() + { + if (!MLKem.IsSupported) + { + Skip = PqcSupport.SkipReason; + } + } +} + +/// +/// The counterpart to . +/// +public sealed class PqcTheoryAttribute : TheoryAttribute +{ + public PqcTheoryAttribute() + { + if (!MLKem.IsSupported) + { + Skip = PqcSupport.SkipReason; + } + } +} + +internal static class PqcSupport +{ + internal const string SkipReason = + "ML-KEM not supported on this host (needs .NET 10 BCL on OpenSSL 3.5+ or recent Windows)."; +} diff --git a/tests/PostQuantum.DataProtection.Tests/PruneTests.cs b/tests/PostQuantum.DataProtection.Tests/PruneTests.cs index 098dff6..1d2cd67 100644 --- a/tests/PostQuantum.DataProtection.Tests/PruneTests.cs +++ b/tests/PostQuantum.DataProtection.Tests/PruneTests.cs @@ -6,7 +6,7 @@ namespace PostQuantum.DataProtection.Tests; public sealed class PruneTests { - [Fact] + [PqcFact] public async Task PruneOlderThanAsync_removes_old_inactive_keypairs_only() { using LocalContentKeyProvider keys = TestDefaults.CreateContentKeyProvider(); @@ -38,7 +38,7 @@ public async Task PruneOlderThanAsync_removes_old_inactive_keypairs_only() } } - [Fact] + [PqcFact] public async Task DeleteAsync_on_active_keypair_throws_clear_error() { using LocalContentKeyProvider keys = TestDefaults.CreateContentKeyProvider(); diff --git a/tests/PostQuantum.DataProtection.Tests/RoundtripTests.cs b/tests/PostQuantum.DataProtection.Tests/RoundtripTests.cs index 64b0025..48891aa 100644 --- a/tests/PostQuantum.DataProtection.Tests/RoundtripTests.cs +++ b/tests/PostQuantum.DataProtection.Tests/RoundtripTests.cs @@ -8,7 +8,7 @@ namespace PostQuantum.DataProtection.Tests; public sealed class RoundtripTests { - [Theory] + [PqcTheory] [InlineData(HybridKemMode.Hybrid)] [InlineData(HybridKemMode.MlKemOnly)] [InlineData(HybridKemMode.XWingHybrid)] @@ -40,7 +40,7 @@ public async Task Encrypt_then_Decrypt_yields_the_original_element(HybridKemMode } } - [Fact] + [PqcFact] public async Task Two_encryptions_of_the_same_input_produce_different_envelopes() { using LocalContentKeyProvider keys = TestDefaults.CreateContentKeyProvider(); @@ -65,7 +65,7 @@ public async Task Two_encryptions_of_the_same_input_produce_different_envelopes( } } - [Fact] + [PqcFact] public async Task EncryptedElement_uses_pinned_xml_namespace_and_element_name() { using LocalContentKeyProvider keys = TestDefaults.CreateContentKeyProvider(); @@ -89,7 +89,7 @@ public async Task EncryptedElement_uses_pinned_xml_namespace_and_element_name() } } - [Fact] + [PqcFact] public async Task EncryptedXmlInfo_names_the_PostQuantumXmlDecryptor_type() { using LocalContentKeyProvider keys = TestDefaults.CreateContentKeyProvider(); diff --git a/tests/PostQuantum.DataProtection.Tests/TelemetryTests.cs b/tests/PostQuantum.DataProtection.Tests/TelemetryTests.cs index 93483d3..b0652be 100644 --- a/tests/PostQuantum.DataProtection.Tests/TelemetryTests.cs +++ b/tests/PostQuantum.DataProtection.Tests/TelemetryTests.cs @@ -10,7 +10,7 @@ namespace PostQuantum.DataProtection.Tests; public sealed class TelemetryTests { - [Fact] + [PqcFact] public async Task Encrypt_emits_encryption_counter_and_duration_histogram() { long encryptionCount = 0; @@ -65,7 +65,7 @@ public async Task Encrypt_emits_encryption_counter_and_duration_histogram() Assert.True(sawEncryptDuration); } - [Fact] + [PqcFact] public async Task Decrypt_emits_decryption_counter() { long decryptionCount = 0; @@ -109,7 +109,7 @@ public async Task Decrypt_emits_decryption_counter() Assert.True(decryptionCount >= 1, $"Expected ≥ 1 decryption event; saw {decryptionCount}."); } - [Fact] + [PqcFact] public async Task Rotation_emits_rotation_counter() { long rotationCount = 0; From 74e72dfa5cba5c3f015fb89aa2337e698a39588f Mon Sep 17 00:00:00 2001 From: Paul Clark Date: Wed, 19 Aug 2026 07:14:56 -0400 Subject: [PATCH 3/5] ci: give the Linux lane PQ-capable OpenSSL, and fail if PQ tests skip With NU1903 cleared, CI reaches the test step on Linux for the first time since 2026-06-04 -- and reports: Passed: 45 Skipped: 56 MLKem.IsSupported is false on the ubuntu runner: the stock OpenSSL there predates 3.5, so the .NET 10 ML-KEM backend is unavailable. The PqcFact guard added in the previous commit therefore skips the entire ML-KEM suite on Linux rather than only on macOS, coverage collapses below the 85% line gate, and the gate fails. Skipping was the wrong outcome. For a post-quantum library a silently-skipped crypto suite is indistinguishable from a passing one -- the sibling repository postquantum-aspnetcore carries a comment noting this exact failure mode once hid a broken Linux PQ lane. Two changes: 1. Install OpenSSL 3.5+ from conda-forge on the Linux leg and point LD_LIBRARY_PATH at it for both the test and coverage runs, so the ML-KEM tests actually execute there. A probe step prints MLKem.IsSupported before the suite runs, so if this ever regresses the log answers the first question directly. 2. Add a zero-skip gate on the Linux and Windows lanes. Both have the primitive available, so any skip there means the PQ paths went unproven and the job should fail. macOS is exempt -- it has no ML-KEM backend at all, which is what PqcFact legitimately covers. This mirrors the linux-pq-required lane already in postquantum-aspnetcore. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 69 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 56306d3..59cd646 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,18 +32,85 @@ jobs: 9.0.x 10.0.x + - name: Set up miniconda (Linux — ML-KEM needs OpenSSL 3.5+) + if: matrix.os == 'ubuntu-latest' + uses: conda-incubator/setup-miniconda@v4 + with: + auto-update-conda: false + activate-environment: pq + channels: conda-forge + + - name: Install OpenSSL 3.5+ from conda-forge + if: matrix.os == 'ubuntu-latest' + shell: bash -el {0} + run: | + conda install -y -n pq -c conda-forge "openssl>=3.5,<4" + "$CONDA/envs/pq/bin/openssl" version + echo "PQ_OPENSSL_LIB=$CONDA/envs/pq/lib" >> "$GITHUB_ENV" + + - name: Probe ML-KEM availability + if: matrix.os == 'ubuntu-latest' + shell: bash + run: | + set -euo pipefail + mkdir -p /tmp/pqprobe && cd /tmp/pqprobe + cat > pqprobe.cs <<'EOF' + using System.Security.Cryptography; + Console.WriteLine($"MLKem.IsSupported = {MLKem.IsSupported}"); + EOF + LD_LIBRARY_PATH="$PQ_OPENSSL_LIB${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" dotnet run pqprobe.cs + - name: Restore run: dotnet restore PostQuantum.DataProtection.slnx - name: Build (Release, all targets, zero warnings) run: dotnet build PostQuantum.DataProtection.slnx -c Release --no-restore + # LD_LIBRARY_PATH points the runtime at the conda OpenSSL 3.5+ on Linux so the + # ML-KEM tests actually execute rather than skipping. PQ_OPENSSL_LIB is only set + # on the Linux leg; on Windows and macOS this expands to nothing and the runner's + # native primitives are used. - name: Test - run: dotnet test tests/PostQuantum.DataProtection.Tests/PostQuantum.DataProtection.Tests.csproj -c Release --no-build --logger "console;verbosity=normal" + shell: bash + run: | + set -o pipefail + if [[ -n "${PQ_OPENSSL_LIB:-}" ]]; then + export LD_LIBRARY_PATH="$PQ_OPENSSL_LIB${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" + fi + dotnet test tests/PostQuantum.DataProtection.Tests/PostQuantum.DataProtection.Tests.csproj \ + -c Release --no-build --logger "console;verbosity=normal" | tee test-output.log + + # A PqcFact skip is correct where the primitive genuinely cannot exist, but a + # silently-skipped crypto suite is indistinguishable from a passing one. Linux and + # Windows are the PQ-required lanes: if anything skipped there, the ML-KEM paths + # were not proven and the job must fail. + - name: Require zero skipped tests on the PQ-required lanes + if: matrix.os != 'macos-latest' + shell: bash + run: | + set -euo pipefail + summaries=$(grep -E '^(Passed!|Failed!)' test-output.log || true) + if [[ -z "$summaries" ]]; then + echo "::error::Could not locate a dotnet test summary line." + exit 1 + fi + echo "$summaries" + total=0 + while IFS= read -r line; do + s=$(echo "$line" | grep -oE 'Skipped:[[:space:]]*[0-9]+' | grep -oE '[0-9]+' | tail -1) + total=$((total + ${s:-0})) + done <<< "$summaries" + if [[ "$total" -ne 0 ]]; then + echo "::error::$total test(s) skipped on a PQ-required lane — ML-KEM was unavailable to the runner." + exit 1 + fi + echo "PQ-required check passed: 0 skipped." - name: Test with coverage (Linux only) if: matrix.os == 'ubuntu-latest' + shell: bash run: | + export LD_LIBRARY_PATH="$PQ_OPENSSL_LIB${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" rm -rf artifacts/coverage dotnet test tests/PostQuantum.DataProtection.Tests/PostQuantum.DataProtection.Tests.csproj \ -c Release --no-build \ From 3e4b800f220bc34198a5fc98d63e21cb3dbd94fa Mon Sep 17 00:00:00 2001 From: Paul Clark Date: Wed, 19 Aug 2026 07:26:59 -0400 Subject: [PATCH 4/5] ci: fix the zero-skip gate's summary parsing The gate matched the older one-line VSTest summary ("Passed! - Failed: 0, Skipped: 0, ..."), but this SDK prints an indented per-project form: Passed: 108 and omits the Skipped line entirely when nothing skipped. Both lanes therefore failed with "Could not locate a dotnet test summary line" even though the run was clean. The substantive change in the previous commit is confirmed working -- the Linux lane now reports: MLKem.IsSupported = True Passed: 108 against 45 passed / 56 skipped before it, so the conda OpenSSL 3.5+ makes the ML-KEM suite actually execute on Linux. Now sums Passed and Skipped across every summary line, handles both shapes, and additionally fails when no passing tests are found at all -- an empty or crashed run must not slip through a gate whose only job is to prove the suite ran. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 59cd646..b8f00b9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -89,22 +89,22 @@ jobs: shell: bash run: | set -euo pipefail - summaries=$(grep -E '^(Passed!|Failed!)' test-output.log || true) - if [[ -z "$summaries" ]]; then - echo "::error::Could not locate a dotnet test summary line." + # This SDK prints an indented per-project summary (" Passed: 108") and + # omits the Skipped line entirely when nothing skipped -- not the older + # one-line "Passed! - Failed: 0, Skipped: 0, ...". Parse both shapes, and + # require a Passed count so an empty or crashed run cannot pass silently. + passed=$(grep -oE 'Passed:[[:space:]]*[0-9]+' test-output.log | grep -oE '[0-9]+' | awk '{s+=$1} END {print s+0}') + skipped=$(grep -oE 'Skipped:[[:space:]]*[0-9]+' test-output.log | grep -oE '[0-9]+' | awk '{s+=$1} END {print s+0}') + echo "passed=$passed skipped=$skipped" + if [[ "${passed:-0}" -eq 0 ]]; then + echo "::error::No passing tests found in the log — the suite did not run." exit 1 fi - echo "$summaries" - total=0 - while IFS= read -r line; do - s=$(echo "$line" | grep -oE 'Skipped:[[:space:]]*[0-9]+' | grep -oE '[0-9]+' | tail -1) - total=$((total + ${s:-0})) - done <<< "$summaries" - if [[ "$total" -ne 0 ]]; then - echo "::error::$total test(s) skipped on a PQ-required lane — ML-KEM was unavailable to the runner." + if [[ "${skipped:-0}" -ne 0 ]]; then + echo "::error::$skipped test(s) skipped on a PQ-required lane — ML-KEM was unavailable to the runner." exit 1 fi - echo "PQ-required check passed: 0 skipped." + echo "PQ-required check passed: $passed passed, 0 skipped." - name: Test with coverage (Linux only) if: matrix.os == 'ubuntu-latest' From 6df2a2204cbc39eaff03dc423fcec3eb74dcf016 Mon Sep 17 00:00:00 2001 From: Paul Clark Date: Wed, 19 Aug 2026 07:31:58 -0400 Subject: [PATCH 5/5] ci: stop the zero-skip gate aborting when nothing skipped With set -euo pipefail, `skipped=$(grep -oE 'Skipped:...' ...)` aborts the step when the log contains no "Skipped:" line -- which is exactly the clean case this gate exists to confirm. grep exits 1, pipefail propagates it, set -e kills the step, and nothing is printed, so both lanes failed with no diagnostic output despite 108 passing tests and zero skips. Guards both counts with `|| true`. The explicit passed==0 check still catches a genuinely empty or crashed run, so the gate keeps its purpose. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b8f00b9..af3481c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,8 +93,11 @@ jobs: # omits the Skipped line entirely when nothing skipped -- not the older # one-line "Passed! - Failed: 0, Skipped: 0, ...". Parse both shapes, and # require a Passed count so an empty or crashed run cannot pass silently. - passed=$(grep -oE 'Passed:[[:space:]]*[0-9]+' test-output.log | grep -oE '[0-9]+' | awk '{s+=$1} END {print s+0}') - skipped=$(grep -oE 'Skipped:[[:space:]]*[0-9]+' test-output.log | grep -oE '[0-9]+' | awk '{s+=$1} END {print s+0}') + # `|| true` matters: with no skips there is no "Skipped:" line at all, so grep + # exits 1, pipefail propagates it and set -e kills the step -- the gate would + # fail exactly when it should pass. + passed=$(grep -oE 'Passed:[[:space:]]*[0-9]+' test-output.log | grep -oE '[0-9]+' | awk '{s+=$1} END {print s+0}' || true) + skipped=$(grep -oE 'Skipped:[[:space:]]*[0-9]+' test-output.log | grep -oE '[0-9]+' | awk '{s+=$1} END {print s+0}' || true) echo "passed=$passed skipped=$skipped" if [[ "${passed:-0}" -eq 0 ]]; then echo "::error::No passing tests found in the log — the suite did not run."