diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 56306d3..af3481c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,18 +32,88 @@ 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 + # 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. + # `|| 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." + exit 1 + fi + 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: $passed 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 \ 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/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/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 @@ - + 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;