Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 71 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
11 changes: 7 additions & 4 deletions src/PostQuantum.DataProtection/PostQuantum.DataProtection.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,14 @@

<!--
Microsoft.AspNetCore.DataProtection.Extensions transitively brings in
System.Security.Cryptography.Xml. The 8.0.x line pre-8.0.3 carries published high-severity
advisories (GHSA-37gx-xxp4-5rgx, GHSA-w3x6-4m5h-cxqf); we pin to the latest patched 8.0.x
explicitly so a transitive resolution cannot land on the unpatched version under any nuget.
System.Security.Cryptography.Xml. The 8.0.x line through 8.0.3 carries published
high-severity advisories (GHSA-23rf-6693-g89p, GHSA-8q5v-6pqq-x66h, GHSA-cvvh-rhrc-wg4q,
GHSA-g8r8-53c2-pm3f, GHSA-mmjf-rqrv-855v), all first patched in 8.0.4; earlier ones
(GHSA-37gx-xxp4-5rgx, GHSA-w3x6-4m5h-cxqf) were patched in 8.0.3. We pin to the latest
patched 8.0.x explicitly so a transitive resolution cannot land on an unpatched version
under any nuget.
-->
<PackageReference Include="System.Security.Cryptography.Xml" Version="8.0.3" />
<PackageReference Include="System.Security.Cryptography.Xml" Version="8.0.4" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -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)];
Expand All @@ -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)];
Expand All @@ -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);
Expand Down
6 changes: 3 additions & 3 deletions tests/PostQuantum.DataProtection.Tests/AcvpKatTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)];
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ namespace PostQuantum.DataProtection.Tests;
/// </summary>
public sealed class CloudStoreConcurrencyTests
{
[Fact]
[PqcFact]
public async Task File_store_under_concurrent_load_serves_consistent_active_id()
{
const int threads = 32;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand All @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions tests/PostQuantum.DataProtection.Tests/ConcurrencyTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ namespace PostQuantum.DataProtection.Tests;
/// </summary>
public sealed class ConcurrencyTests
{
[Fact]
[PqcFact]
public async Task Many_threads_can_encrypt_and_decrypt_against_one_key_manager()
{
const int threadCount = 16;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ namespace PostQuantum.DataProtection.Tests;
/// </summary>
public sealed class DataProtectionIntegrationTests
{
[Fact]
[PqcFact]
public void Protect_then_Unprotect_round_trips_through_ASP_NET_Core_Data_Protection()
{
string tempDir = TestDefaults.CreateTempDirectory();
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand All @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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
Expand All @@ -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();
Expand All @@ -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();
Expand All @@ -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();
Expand All @@ -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();
Expand Down
4 changes: 2 additions & 2 deletions tests/PostQuantum.DataProtection.Tests/HealthCheckTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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();
Expand Down
Loading
Loading