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;