From cc5352f4530933f6411ac2fd73343098af66bb51 Mon Sep 17 00:00:00 2001 From: Peter Dennis Bartok Date: Sat, 20 Jun 2026 13:09:08 -0600 Subject: [PATCH 01/16] Phase 4: implementation plan (PQ & composite + provider polish) --- .../2026-06-20-scep-test-client-phase-4.md | 1461 +++++++++++++++++ ...-20-scep-test-client-phase-4.md.tasks.json | 89 + 2 files changed, 1550 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-20-scep-test-client-phase-4.md create mode 100644 docs/superpowers/plans/2026-06-20-scep-test-client-phase-4.md.tasks.json diff --git a/docs/superpowers/plans/2026-06-20-scep-test-client-phase-4.md b/docs/superpowers/plans/2026-06-20-scep-test-client-phase-4.md new file mode 100644 index 0000000..142976d --- /dev/null +++ b/docs/superpowers/plans/2026-06-20-scep-test-client-phase-4.md @@ -0,0 +1,1461 @@ +# ScepTestClient Phase 4 — PQ & Composite + Provider Polish — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development (recommended) or superpowers-extended-cc:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add post-quantum vocabulary and tier-A/B capability to the BouncyCastle provider (ML-DSA / SLH-DSA end-entity keys + catalyst alt-key), make the loaded provider's capabilities drive opinion/probe/suggest, ship `crypto info`/`crypto list`, and harden external-provider loading via `--crypto-provider`/config — all additively, with no `IScepCrypto` signature changes. + +**Architecture:** All PQ support slots in through the three additive seams validated in Phase 1 (open OID identifiers + capability advertisement; opaque `IScepKey` handles; extensible domain objects). The `IScepCrypto` *interface is not changed*. New surface is limited to: registry entries, `KeySpec` PQ parsing, one `CryptoCapabilities.PqTiers` property, one `Pkcs10.AltKey` property, and provider-internal branching in `BouncyCastleScepCrypto`/`BcCsrBuilder`. Tier C (ML-KEM CMS envelope) is advertised but **not implemented in the built-in provider** because BouncyCastle 2.5.0's CMS layer has no `KemRecipientInfoGenerator` — it is left as a clean seam an external provider fills. + +**Tech Stack:** .NET 8 (`net8.0`, `RollForward Major`); `BouncyCastle.Cryptography` 2.5.0 (only in `ScepTestClient.Crypto.BouncyCastle`); xUnit. House style enforced by `.editorconfig` (see "House style" below). + +**User decisions (already made):** +- "The design is done — do NOT re-brainstorm." Plan straight from spec §14 + §3.3/§3.4/§3.5 + §17 row 4. +- "First task: re-validate the IScepCrypto seam on paper against all 3 PQ tiers ... before changing signatures — additive only." → Task 1. +- "BC provider tiers A/B ... and tier C where BC 2.5.0 allows." → Empirically confirmed: tier A/B feasible; tier C envelope **not** feasible in BC 2.5.0 (no CMS KEM recipient generator) → advertised-only. +- "capabilities driving opinion/probe/suggest (incl. AlgorithmPosture.CuttingEdge, currently unused)." → Tasks 8, 9. +- "Keep granular task commits (no squash); I'll PR via SmartGit." +- **Deferred Phase-3 item (spec §13 simulator subject-mismatch test): KEPT DEFERRED.** Rationale: it is orthogonal to PQ, belongs to the compliance engine (Phase 3 territory), and depends on IntuneSimulator-side canned-error controls that are a separate subsystem. Folding it into an already-large PQ phase would mix concerns. Re-targeted to a Phase-3 follow-up / Phase 5. Documented here so it is not lost. + +--- + +## House style (tell EVERY subagent, verbatim) + +Write all new code in the repo `.editorconfig` style **from the first keystroke** (no reformat pass): +- **Never `var`** — always the explicit type. +- **Declare all locals at the top of the block, unassigned, then a blank line, then the assignments.** +- Same-line braces; single-line statements where the file already does. +- `snake_case` for locals/params/private fields; `PascalCase` for members. +- No exceptions for control flow: sync = result/`bool` + `out value` + `out string error`; async = `ScepResult`. +- All cryptography stays inside `ScepTestClient.Crypto.*`; never reference BouncyCastle outside that project. + +Process reminders for subagents: +- **Implementers:** stage files **explicitly** (`git add ...`); never `git add -A` (it sweeps the plan `.md`/`.tasks.json` into code commits). One commit per task. +- **Review / Explore agents:** **do NOT run `git checkout`/`git switch`** — stay on `feature/scep-test-client-phase-4`. (In Phase 2 a reviewer moved HEAD to `main` mid-run.) +- **The round-trip / e2e test is the source of truth for any BouncyCastle call.** If a BC API name/namespace below is slightly off for 2.5.0, adjust the call until it compiles and the test passes, and report the deviation. Do not change the test's intent. +- Tests instantiate the provider directly: `new BouncyCastleScepCrypto()` (there is no `TestCrypto.Load()`). `KeySpec` ctor is private → use `KeySpec.Parse(...)`. + +--- + +## BouncyCastle 2.5.0 PQ reality (empirically probed 2026-06-20 — feed to crypto agents) + +Confirmed present in `BouncyCastle.Cryptography` 2.5.0 (`lib/netstandard2.0/BouncyCastle.Cryptography.dll`): +- **ML-DSA:** `MLDsaKeyPairGenerator`, `MLDsaKeyGenerationParameters`, `MLDsaParameters` (`ml_dsa_44`/`ml_dsa_65`/`ml_dsa_87`), `MLDsaPrivateKeyParameters`/`MLDsaPublicKeyParameters`, `MLDsaSigner`/`HashMLDsaSigner`. Param-set OIDs: `id_ml_dsa_44/65/87`. +- **SLH-DSA:** `SlhDsaKeyPairGenerator`, `SlhDsaKeyGenerationParameters`, `SlhDsaParameters`, `SlhDsaPrivateKeyParameters`/`SlhDsaPublicKeyParameters`, `SlhDsaSigner`/`HashSlhDsaSigner`. +- **ML-KEM:** `MLKemKeyPairGenerator`, `MLKemParameters` (`MLKEM512/768/1024`) — **keygen + KEM primitive only**. +- **PQ ASN.1 emit/parse:** `PqcSubjectPublicKeyInfoFactory.CreateSubjectPublicKeyInfo(pub)` and `PqcPrivateKeyInfoFactory.CreatePrivateKeyInfo(priv)` (namespace `Org.BouncyCastle.Pqc.Crypto.Utilities`). `PrivateKeyFactory.CreateKey(der)` parses PQ PKCS#8 back. +- **Signature factory:** `Asn1SignatureFactory` (try `new Asn1SignatureFactory("ML-DSA-65", priv, random)`; if 2.5.0 rejects the name, wrap `MLDsaSigner` in a custom `ISignatureFactory` — round-trip test arbitrates). + +Confirmed **ABSENT / not usable** in 2.5.0: +- **No CMS KEM recipient generator.** The CMS recipient-generator family is only `CmsKeyTransRecipientInfoGenerator` / `KekRecipientInfoGenerator` / `KeyAgreeRecipientInfoGenerator` / `PasswordRecipientInfoGenerator`. `KemRecipientInfo` exists only as a raw ASN.1 type. → **Tier C ML-KEM `EnvelopedData` (RFC 9629) cannot be built with the built-in provider.** Advertise the capability (TierC=false), probe empirically, document the limitation. +- **No clean current composite signature generator.** Only the legacy Dilithium-draft composite OIDs (`id_Dilithium3_ECDSA_P256_SHA256`, etc.) and the bare `id_composite_key` OID exist — no ML-DSA composite signing path. → Treat **composite** as vocabulary + opinion only (no BC signing implementation). + +PQ namespaces in 2.5.0 are typically `Org.BouncyCastle.Pqc.Crypto.MLDsa`, `...Pqc.Crypto.SlhDsa`, `...Pqc.Crypto.MLKem`, `...Pqc.Crypto.Utilities`. Verify per build; the test arbitrates. + +--- + +## File structure + +New files: +- `docs/superpowers/analysis/2026-06-20-iscepcrypto-pq-seam-validation.md` — Task 1 paper validation (committed artifact). +- `src/ScepTestClient.CryptoApi/PqTiers.cs` — the additive `PqTiers` value carried by `CryptoCapabilities`. +- `src/ScepTestClient.Crypto.BouncyCastle/BcPqKeys.cs` — PQ keygen/SPKI/PKCS#8 helpers (keeps `BouncyCastleScepCrypto` readable). +- `src/ScepTestClient.Cli/CryptoCommand.cs` — `crypto info` / `crypto list` rendering (keeps `CommandRouter` lean). +- `tests/ScepTestClient.Tests/PqAlgorithmsTests.cs`, `PqKeySpecTests.cs`, `PqCapabilitiesTests.cs`, `PqKeyGenTests.cs`, `PqCsrTests.cs`, `AltKeyCsrTests.cs`, `PqOpinionSuggestTests.cs`, `PqProbeTests.cs`, `CryptoCommandTests.cs`, `CryptoProviderFlagTests.cs` (+ extend `ProviderLoadTests.cs`). + +Modified files (all additive): +- `src/ScepTestClient.CryptoApi/Algorithms.cs` — PQ registry entries. +- `src/ScepTestClient.CryptoApi/KeySpec.cs` — PQ parse + new `Parameter` property. +- `src/ScepTestClient.CryptoApi/CryptoCapabilities.cs` — `PqTiers` property. +- `src/ScepTestClient.CryptoApi/Pkcs10.cs` — `AltKey` property. +- `src/ScepTestClient.Crypto.BouncyCastle/BcAlgorithms.cs` — PQ OID constants. +- `src/ScepTestClient.Crypto.BouncyCastle/BouncyCastleScepCrypto.cs` — PQ keygen/export branch + PQ capabilities. +- `src/ScepTestClient.Crypto.BouncyCastle/BcCsrBuilder.cs` — PQ SPKI/signature + alt-key extension. +- `src/ScepTestClient.Core/Testing/SecurityOpinion.cs` — `ClassifySignature` (CuttingEdge). +- `src/ScepTestClient.Core/Testing/ServerSuggest.cs` — PQ suggestion driven by provider caps. +- `src/ScepTestClient.Core/Testing/TestEngine.cs` — `ProbePq` step. +- `src/ScepTestClient.Core/EnrollRequest.cs` — `AltKey` field. +- `src/ScepTestClient.Core/ScepRequestBuilder.cs` — `.AltKey(...)` + wiring. +- `src/ScepTestClient.Core/ScepClient.cs` — thread `AltKey` from `EnrollRequest` into the CSR. +- `src/ScepTestClient.Core/ProviderLoadContext.cs` / `src/ScepTestClient.Core/ScepCrypto.cs` — ALC hardening. +- `src/ScepTestClient.Core/Storage/ClientConfig.cs` — (already has `CryptoProviderPath`; add `config set` support path). +- `src/ScepTestClient.Cli/CommandRouter.cs` — `crypto` command, `--crypto-provider`/config resolution at all `ScepCrypto.Load` sites, `--alt-key-spec`, `config set`. + +--- + +## Task 1: Re-validate the IScepCrypto seam vs all 3 PQ tiers (paper) + +**Goal:** Produce a committed analysis confirming PQ support is additive-only (no `IScepCrypto` signature change), grounded in the BC 2.5.0 reality, before any code changes. + +**Files:** +- Create: `docs/superpowers/analysis/2026-06-20-iscepcrypto-pq-seam-validation.md` + +**Acceptance Criteria:** +- [ ] Doc walks each of the three tiers (A: ML-DSA/SLH-DSA end-entity; B: catalyst alt-key; C: ML-KEM envelope) against the current `IScepCrypto` (read at `src/ScepTestClient.CryptoApi/IScepCrypto.cs`). +- [ ] Doc states, per tier, the exact additive change required and confirms **no interface signature changes**. +- [ ] Doc records the BC 2.5.0 reality: tier A/B feasible; tier C envelope NOT feasible (no CMS `KemRecipientInfoGenerator`); composite = vocabulary/opinion only. +- [ ] Doc lists the precise new additive surface the rest of Phase 4 introduces (`KeySpec.Parameter`, `CryptoCapabilities.PqTiers`, `Pkcs10.AltKey`, registry entries). + +**Verify:** `test -f docs/superpowers/analysis/2026-06-20-iscepcrypto-pq-seam-validation.md && grep -q "additive-only" docs/superpowers/analysis/2026-06-20-iscepcrypto-pq-seam-validation.md` → exit 0 + +**Steps:** + +- [ ] **Step 1: Write the analysis doc** with this content (verbatim is acceptable; expand prose as desired): + +```markdown +# IScepCrypto seam validation vs PQ tiers (Phase 4 pre-flight) + +Date: 2026-06-20. Re-validates the Phase-1 on-paper conclusion before writing PQ code. +Reference interface: src/ScepTestClient.CryptoApi/IScepCrypto.cs (unchanged by Phase 4). + +## Conclusion: additive-only. No IScepCrypto signature changes. + +## Tier A — PQ end-entity key (ML-DSA / SLH-DSA) +- KeySpec.Parse gains "ml-dsa:65" / "slh-dsa:128s" (new accepted inputs + a new `Parameter` + property; existing `rsa:` callers unaffected). +- IScepCrypto.GenerateKey(KeySpec, out IScepKey, out string) — unchanged signature; returns an + IScepKey whose AlgorithmOid is the PQ OID. +- IScepCrypto.EncodeCsr(Pkcs10, ...) — unchanged; provider emits a PQ SubjectPublicKeyInfo + (PqcSubjectPublicKeyInfoFactory) and signs with the PQ signer. +- ExportPrivateKeyPkcs8 / ImportPrivateKeyPkcs8 — unchanged; PQ via PqcPrivateKeyInfoFactory / + PrivateKeyFactory. +- BC 2.5.0: FEASIBLE. + +## Tier B — Catalyst / hybrid alt-key (subjectAltPublicKeyInfo) +- New additive Pkcs10.AltKey (IScepKey?) property, ignored by existing callers. +- EncodeCsr emits the subjectAltPublicKeyInfo extension (OID 2.5.29.72) carrying the alt key's SPKI. +- No interface change. +- BC 2.5.0: alt PUBLIC KEY emission FEASIBLE; computing a conformant altSignatureValue over the CSR + is bleeding-edge and NOT done by the built-in provider — a ConformanceNote records this. + +## Tier C — PQ transport (ML-KEM EnvelopedData, RFC 9629 KEMRecipientInfo) +- EncodePkiMessage already takes the recipient via PkiMessage.RecipientCaCert — a PQ recipient + WOULD trigger KEMRecipientInfo inside the provider. No signature change. +- BC 2.5.0: NOT FEASIBLE — the CMS layer exposes only KeyTrans/Kek/KeyAgree/Password recipient + generators; KemRecipientInfo is a raw ASN.1 type with no CMS generator. The built-in provider + therefore advertises CryptoCapabilities.PqTiers.TierC = false; an external provider can implement it. + +## Composite signatures +- Only legacy Dilithium-draft composite OIDs exist in 2.5.0; no ML-DSA composite signing path. +- Scope: composite stays vocabulary + opinion (CuttingEdge), no BC signing implementation. + +## New additive surface introduced by Phase 4 +- KeySpec.Parameter (string); KeySpec.Parse PQ inputs. +- CryptoCapabilities.PqTiers (TierA/TierB/TierC bools) + PQ OIDs in Signatures/AsymmetricKeys/Kem. +- Pkcs10.AltKey (IScepKey?). +- Algorithms registry: ML-DSA-44/65/87, SLH-DSA-*, ML-KEM-512/768/1024 entries. +``` + +- [ ] **Step 2: Commit** + +```bash +git add docs/superpowers/analysis/2026-06-20-iscepcrypto-pq-seam-validation.md +git commit -m "Phase 4: validate IScepCrypto seam is additive-only across all 3 PQ tiers" +``` + +--- + +## Task 2: PQ algorithm registry entries + +**Goal:** Add PQ algorithms to the `Algorithms` registry with correct `AlgorithmKind` so name↔OID lookup and kind-filtering work for PQ. + +**Files:** +- Modify: `src/ScepTestClient.CryptoApi/Algorithms.cs` +- Modify: `src/ScepTestClient.Crypto.BouncyCastle/BcAlgorithms.cs` (add OID constants for provider use) +- Test: `tests/ScepTestClient.Tests/PqAlgorithmsTests.cs` + +**Acceptance Criteria:** +- [ ] `Algorithms.OidFor("ML-DSA-65")` → `2.16.840.1.101.3.4.3.18`; `NameFor` round-trips (case-insensitive). +- [ ] `ML-DSA-44/65/87` tagged `AlgorithmKind.Signature`; `SLH-DSA-128s` tagged `Signature`; `ML-KEM-512/768/1024` tagged `AlgorithmKind.Kem`. +- [ ] Existing entries (RSA, SHA-*, AES-*) still resolve unchanged. + +**Verify:** `dotnet test tests/ScepTestClient.Tests --filter PqAlgorithmsTests` → PASS + +**Steps:** + +- [ ] **Step 1: Write the failing test** `tests/ScepTestClient.Tests/PqAlgorithmsTests.cs`: + +```csharp +using ScepTestClient.CryptoApi; +using Xunit; + +namespace ScepTestClient.Tests; + +public sealed class PqAlgorithmsTests { + [Theory] + [InlineData("ML-DSA-44", "2.16.840.1.101.3.4.3.17", AlgorithmKind.Signature)] + [InlineData("ML-DSA-65", "2.16.840.1.101.3.4.3.18", AlgorithmKind.Signature)] + [InlineData("ML-DSA-87", "2.16.840.1.101.3.4.3.19", AlgorithmKind.Signature)] + [InlineData("ML-KEM-512", "2.16.840.1.101.3.4.4.1", AlgorithmKind.Kem)] + [InlineData("ML-KEM-768", "2.16.840.1.101.3.4.4.2", AlgorithmKind.Kem)] + [InlineData("ML-KEM-1024", "2.16.840.1.101.3.4.4.3", AlgorithmKind.Kem)] + public void Pq_entries_resolve(string name, string oid, AlgorithmKind kind) { + Assert.Equal(oid, Algorithms.OidFor(name)); + Assert.Equal(name, Algorithms.NameFor(oid)); + Assert.Equal(kind, Algorithms.KindOf(oid)); + } + + [Fact] + public void Existing_entries_unchanged() { + Assert.Equal("2.16.840.1.101.3.4.2.1", Algorithms.OidFor("SHA-256")); + Assert.Equal("1.2.840.113549.1.1.1", Algorithms.OidFor("RSA")); + } +} +``` + +- [ ] **Step 2: Run → FAIL** (`Algorithms.OidFor("ML-DSA-65")` is null). + +- [ ] **Step 3: Add entries** to the `Entries` array in `Algorithms.cs` (NIST CSOR OIDs for ML-DSA `2.16.840.1.101.3.4.3.17/18/19`, ML-KEM `2.16.840.1.101.3.4.4.1/2/3`; SLH-DSA SHA2 small set OIDs `2.16.840.1.101.3.4.3.20/21/22` for 128s/192s/256s): + +```csharp + new("ML-DSA-44", "2.16.840.1.101.3.4.3.17", AlgorithmKind.Signature), + new("ML-DSA-65", "2.16.840.1.101.3.4.3.18", AlgorithmKind.Signature), + new("ML-DSA-87", "2.16.840.1.101.3.4.3.19", AlgorithmKind.Signature), + new("SLH-DSA-128s","2.16.840.1.101.3.4.3.20", AlgorithmKind.Signature), + new("SLH-DSA-192s","2.16.840.1.101.3.4.3.21", AlgorithmKind.Signature), + new("SLH-DSA-256s","2.16.840.1.101.3.4.3.22", AlgorithmKind.Signature), + new("ML-KEM-512", "2.16.840.1.101.3.4.4.1", AlgorithmKind.Kem), + new("ML-KEM-768", "2.16.840.1.101.3.4.4.2", AlgorithmKind.Kem), + new("ML-KEM-1024", "2.16.840.1.101.3.4.4.3", AlgorithmKind.Kem), +``` + + Also add the matching OID constants to `BcAlgorithms.cs`: + +```csharp + public const string MlDsa44 = "2.16.840.1.101.3.4.3.17"; + public const string MlDsa65 = "2.16.840.1.101.3.4.3.18"; + public const string MlDsa87 = "2.16.840.1.101.3.4.3.19"; + public const string SlhDsa128s = "2.16.840.1.101.3.4.3.20"; + public const string SlhDsa192s = "2.16.840.1.101.3.4.3.21"; + public const string SlhDsa256s = "2.16.840.1.101.3.4.3.22"; + public const string MlKem512 = "2.16.840.1.101.3.4.4.1"; + public const string MlKem768 = "2.16.840.1.101.3.4.4.2"; + public const string MlKem1024 = "2.16.840.1.101.3.4.4.3"; +``` + +- [ ] **Step 4: Run → PASS.** + +- [ ] **Step 5: Commit** + +```bash +git add src/ScepTestClient.CryptoApi/Algorithms.cs src/ScepTestClient.Crypto.BouncyCastle/BcAlgorithms.cs tests/ScepTestClient.Tests/PqAlgorithmsTests.cs +git commit -m "Phase 4: add ML-DSA/SLH-DSA/ML-KEM entries to the algorithm registry" +``` + +--- + +## Task 3: KeySpec accepts PQ specs + +**Goal:** Extend `KeySpec.Parse` to accept `ml-dsa:` and `slh-dsa:` (and `ml-kem:` for completeness) additively, exposing the parameter set via a new `Parameter` property, without breaking `rsa:`. + +**Files:** +- Modify: `src/ScepTestClient.CryptoApi/KeySpec.cs` +- Test: `tests/ScepTestClient.Tests/PqKeySpecTests.cs` + +**Acceptance Criteria:** +- [ ] `KeySpec.Parse("ml-dsa:65", ...)` → `Algorithm == "ML-DSA"`, `Parameter == "65"`, `Size == 0`, `Raw == "ml-dsa:65"`. +- [ ] `KeySpec.Parse("slh-dsa:128s", ...)` → `Algorithm == "SLH-DSA"`, `Parameter == "128s"`. +- [ ] `KeySpec.Parse("rsa:2048", ...)` → `Algorithm == "RSA"`, `Size == 2048`, `Parameter == ""` (unchanged behavior). +- [ ] Unknown algorithm (`ec:p256`) and bad PQ set (`ml-dsa:99`) → `false` with a clear error. + +**Verify:** `dotnet test tests/ScepTestClient.Tests --filter PqKeySpecTests` → PASS (and existing `KeySpec`-using tests still green) + +**Steps:** + +- [ ] **Step 1: Write the failing test** `tests/ScepTestClient.Tests/PqKeySpecTests.cs`: + +```csharp +using ScepTestClient.CryptoApi; +using Xunit; + +namespace ScepTestClient.Tests; + +public sealed class PqKeySpecTests { + [Fact] + public void Parses_ml_dsa() { + KeySpec spec; + string error; + + Assert.True(KeySpec.Parse("ml-dsa:65", out spec, out error)); + Assert.Equal("ML-DSA", spec.Algorithm); + Assert.Equal("65", spec.Parameter); + Assert.Equal(0, spec.Size); + } + + [Fact] + public void Parses_slh_dsa() { + KeySpec spec; + string error; + + Assert.True(KeySpec.Parse("slh-dsa:128s", out spec, out error)); + Assert.Equal("SLH-DSA", spec.Algorithm); + Assert.Equal("128s", spec.Parameter); + } + + [Fact] + public void Rsa_unchanged() { + KeySpec spec; + string error; + + Assert.True(KeySpec.Parse("rsa:2048", out spec, out error)); + Assert.Equal("RSA", spec.Algorithm); + Assert.Equal(2048, spec.Size); + Assert.Equal(string.Empty, spec.Parameter); + } + + [Theory] + [InlineData("ec:p256")] + [InlineData("ml-dsa:99")] + [InlineData("slh-dsa:bogus")] + public void Rejects_bad(string text) { + KeySpec spec; + string error; + + Assert.False(KeySpec.Parse(text, out spec, out error)); + Assert.NotEqual(string.Empty, error); + } +} +``` + +- [ ] **Step 2: Run → FAIL** (no `Parameter`; PQ rejected). + +- [ ] **Step 3: Rewrite `KeySpec`** additively — add `Parameter`, keep the private ctor signature growing by one param: + +```csharp +namespace ScepTestClient.CryptoApi; + +public sealed class KeySpec { + private static readonly string[] MlDsaSets = { "44", "65", "87" }; + private static readonly string[] SlhDsaSets = { "128s", "192s", "256s", "128f", "192f", "256f" }; + private static readonly string[] MlKemSets = { "512", "768", "1024" }; + + public string Algorithm { get; } + public int Size { get; } + public string Parameter { get; } + public string Raw { get; } + + private KeySpec(string algorithm, int size, string parameter, string raw) { + Algorithm = algorithm; + Size = size; + Parameter = parameter; + Raw = raw; + } + + public static bool Parse(string text, out KeySpec spec, out string error) { + string[] parts; + string algo; + string param; + int bits; + + spec = null!; + error = string.Empty; + + if (string.IsNullOrWhiteSpace(text)) { + error = "key spec is empty"; + return false; + } + + parts = text.Split(':'); + if (parts.Length != 2) { + error = $"unsupported key spec '{text}' (expected 'rsa:' / 'ml-dsa:' / 'slh-dsa:')"; + return false; + } + + algo = parts[0].ToLowerInvariant(); + param = parts[1]; + + if (algo == "rsa") { + if (!int.TryParse(param, out bits) || bits < 1024) { + error = $"invalid RSA size in '{text}'"; + return false; + } + spec = new KeySpec("RSA", bits, string.Empty, text); + return true; + } + + if (algo == "ml-dsa") { + if (System.Array.IndexOf(MlDsaSets, param) < 0) { + error = $"invalid ML-DSA parameter set '{param}' (expected 44/65/87)"; + return false; + } + spec = new KeySpec("ML-DSA", 0, param, text); + return true; + } + + if (algo == "slh-dsa") { + if (System.Array.IndexOf(SlhDsaSets, param) < 0) { + error = $"invalid SLH-DSA parameter set '{param}'"; + return false; + } + spec = new KeySpec("SLH-DSA", 0, param, text); + return true; + } + + if (algo == "ml-kem") { + if (System.Array.IndexOf(MlKemSets, param) < 0) { + error = $"invalid ML-KEM parameter set '{param}' (expected 512/768/1024)"; + return false; + } + spec = new KeySpec("ML-KEM", 0, param, text); + return true; + } + + error = $"unsupported key spec '{text}'"; + return false; + } +} +``` + +- [ ] **Step 4: Run → PASS** (and run the full suite to confirm no `new KeySpec(...)` call sites broke — the ctor is private, only `Parse` constructs it). + +- [ ] **Step 5: Commit** + +```bash +git add src/ScepTestClient.CryptoApi/KeySpec.cs tests/ScepTestClient.Tests/PqKeySpecTests.cs +git commit -m "Phase 4: KeySpec.Parse accepts ml-dsa/slh-dsa/ml-kem parameter sets" +``` + +--- + +## Task 4: CryptoCapabilities PQ tiers + provider advertises PQ + +**Goal:** Add an additive `PqTiers` value to `CryptoCapabilities` and have the BouncyCastle provider advertise its PQ algorithms (ML-DSA/SLH-DSA in Signatures+AsymmetricKeys, ML-KEM in Kem) and tiers (A=true, B=true, **C=false**). + +**Files:** +- Create: `src/ScepTestClient.CryptoApi/PqTiers.cs` +- Modify: `src/ScepTestClient.CryptoApi/CryptoCapabilities.cs` +- Modify: `src/ScepTestClient.Crypto.BouncyCastle/BouncyCastleScepCrypto.cs` (the `Capabilities` initializer only) +- Test: `tests/ScepTestClient.Tests/PqCapabilitiesTests.cs` + +**Acceptance Criteria:** +- [ ] `CryptoCapabilities.PqTiers` defaults to all-false (existing/external callers unaffected). +- [ ] `new BouncyCastleScepCrypto().Capabilities.PqTiers` → `TierA == true`, `TierB == true`, `TierC == false`. +- [ ] Provider `Signatures` contains ML-DSA-65 OID; `AsymmetricKeys` contains ML-DSA-65 OID; `Kem` contains ML-KEM-768 OID. +- [ ] Existing capabilities (SHA/AES/RSA) still present. + +**Verify:** `dotnet test tests/ScepTestClient.Tests --filter PqCapabilitiesTests` → PASS (and `CapabilitiesTests` still green) + +**Steps:** + +- [ ] **Step 1: Write the failing test** `tests/ScepTestClient.Tests/PqCapabilitiesTests.cs`: + +```csharp +using System.Linq; +using ScepTestClient.Crypto.BouncyCastle; +using ScepTestClient.CryptoApi; +using Xunit; + +namespace ScepTestClient.Tests; + +public sealed class PqCapabilitiesTests { + [Fact] + public void Default_pqtiers_all_false() { + CryptoCapabilities caps; + + caps = new CryptoCapabilities(); + Assert.False(caps.PqTiers.TierA); + Assert.False(caps.PqTiers.TierB); + Assert.False(caps.PqTiers.TierC); + } + + [Fact] + public void Bc_provider_advertises_pq() { + BouncyCastleScepCrypto crypto; + + crypto = new BouncyCastleScepCrypto(); + Assert.True(crypto.Capabilities.PqTiers.TierA); + Assert.True(crypto.Capabilities.PqTiers.TierB); + Assert.False(crypto.Capabilities.PqTiers.TierC); + Assert.Contains("2.16.840.1.101.3.4.3.18", crypto.Capabilities.Signatures); + Assert.Contains("2.16.840.1.101.3.4.3.18", crypto.Capabilities.AsymmetricKeys); + Assert.Contains("2.16.840.1.101.3.4.4.2", crypto.Capabilities.Kem); + Assert.Contains("1.2.840.113549.1.1.1", crypto.Capabilities.AsymmetricKeys); + } +} +``` + +- [ ] **Step 2: Run → FAIL** (no `PqTiers`). + +- [ ] **Step 3: Create `PqTiers.cs`:** + +```csharp +namespace ScepTestClient.CryptoApi; + +// PQ tier support advertised by a provider (spec §3.4, §14). Additive — defaults to all-false so +// existing/external CryptoCapabilities consumers are unaffected. +public sealed record PqTiers(bool TierA = false, bool TierB = false, bool TierC = false); +``` + +- [ ] **Step 4: Add the property** to `CryptoCapabilities.cs`: + +```csharp + public PqTiers PqTiers { get; init; } = new PqTiers(); +``` + +- [ ] **Step 5: Extend the provider's `Capabilities` initializer** in `BouncyCastleScepCrypto.cs` (add PQ OIDs + tiers; keep existing entries): + +```csharp + public CryptoCapabilities Capabilities { get; } = new CryptoCapabilities { + Digests = new[] { BcAlgorithms.Sha1, BcAlgorithms.Sha256, BcAlgorithms.Sha512, BcAlgorithms.Md5 }, + Signatures = new[] { BcAlgorithms.Rsa, BcAlgorithms.MlDsa44, BcAlgorithms.MlDsa65, BcAlgorithms.MlDsa87, + BcAlgorithms.SlhDsa128s, BcAlgorithms.SlhDsa192s, BcAlgorithms.SlhDsa256s }, + ContentEncryption = new[] { BcAlgorithms.Aes128Cbc, BcAlgorithms.Aes256Cbc, BcAlgorithms.Des3Cbc }, + KeyTransport = new[] { BcAlgorithms.Rsa }, + Kem = new[] { BcAlgorithms.MlKem512, BcAlgorithms.MlKem768, BcAlgorithms.MlKem1024 }, + AsymmetricKeys = new[] { BcAlgorithms.Rsa, BcAlgorithms.MlDsa44, BcAlgorithms.MlDsa65, BcAlgorithms.MlDsa87, + BcAlgorithms.SlhDsa128s, BcAlgorithms.SlhDsa192s, BcAlgorithms.SlhDsa256s }, + PqTiers = new PqTiers(TierA: true, TierB: true, TierC: false), + }; +``` + +- [ ] **Step 6: Run → PASS.** + +- [ ] **Step 7: Commit** + +```bash +git add src/ScepTestClient.CryptoApi/PqTiers.cs src/ScepTestClient.CryptoApi/CryptoCapabilities.cs src/ScepTestClient.Crypto.BouncyCastle/BouncyCastleScepCrypto.cs tests/ScepTestClient.Tests/PqCapabilitiesTests.cs +git commit -m "Phase 4: advertise PQ algorithms and tiers (A/B yes, C no) via CryptoCapabilities" +``` + +--- + +## Task 5: BC provider tier A — ML-DSA/SLH-DSA key generation + PKCS#8 round-trip + +**Goal:** `GenerateKey` produces ML-DSA / SLH-DSA keypairs; `ExportPrivateKeyPkcs8`/`ImportPrivateKeyPkcs8` round-trip them. Keep `BouncyCastleScepCrypto` readable by putting PQ specifics in a new `BcPqKeys` helper. + +**Files:** +- Create: `src/ScepTestClient.Crypto.BouncyCastle/BcPqKeys.cs` +- Modify: `src/ScepTestClient.Crypto.BouncyCastle/BcKey.cs` (allow a PQ keypair; `KeyPair` stays an `AsymmetricCipherKeyPair`) +- Modify: `src/ScepTestClient.Crypto.BouncyCastle/BouncyCastleScepCrypto.cs` (`GenerateKey` branch; `ImportPrivateKeyPkcs8` PQ branch) +- Test: `tests/ScepTestClient.Tests/PqKeyGenTests.cs` + +**Acceptance Criteria:** +- [ ] `GenerateKey(ml-dsa:65)` returns an `IScepKey` with `AlgorithmOid == 2.16.840.1.101.3.4.3.18`. +- [ ] `GenerateKey(slh-dsa:128s)` succeeds. +- [ ] PKCS#8 export then import yields a key with the same `AlgorithmOid` (re-export byte-identical where BC is deterministic). +- [ ] RSA keygen/export/import unchanged. + +**Verify:** `dotnet test tests/ScepTestClient.Tests --filter PqKeyGenTests` → PASS + +**Steps:** + +- [ ] **Step 1: Write the failing test** `tests/ScepTestClient.Tests/PqKeyGenTests.cs`: + +```csharp +using ScepTestClient.Crypto.BouncyCastle; +using ScepTestClient.CryptoApi; +using Xunit; + +namespace ScepTestClient.Tests; + +public sealed class PqKeyGenTests { + [Theory] + [InlineData("ml-dsa:65", "2.16.840.1.101.3.4.3.18")] + [InlineData("ml-dsa:87", "2.16.840.1.101.3.4.3.19")] + [InlineData("slh-dsa:128s", "2.16.840.1.101.3.4.3.20")] + public void Generates_pq_key(string key_spec, string expected_oid) { + BouncyCastleScepCrypto crypto; + KeySpec spec; + IScepKey key; + string error; + + crypto = new BouncyCastleScepCrypto(); + Assert.True(KeySpec.Parse(key_spec, out spec, out error), error); + Assert.True(crypto.GenerateKey(spec, out key, out error), error); + Assert.Equal(expected_oid, key.AlgorithmOid); + } + + [Fact] + public void Pkcs8_roundtrip_ml_dsa() { + BouncyCastleScepCrypto crypto; + KeySpec spec; + IScepKey key; + IScepKey imported; + byte[] der; + byte[] der2; + string error; + + crypto = new BouncyCastleScepCrypto(); + Assert.True(KeySpec.Parse("ml-dsa:65", out spec, out error), error); + Assert.True(crypto.GenerateKey(spec, out key, out error), error); + Assert.True(crypto.ExportPrivateKeyPkcs8(key, out der, out error), error); + Assert.True(crypto.ImportPrivateKeyPkcs8(der, out imported, out error), error); + Assert.Equal(key.AlgorithmOid, imported.AlgorithmOid); + Assert.True(crypto.ExportPrivateKeyPkcs8(imported, out der2, out error), error); + Assert.Equal(der, der2); + } +} +``` + +- [ ] **Step 2: Run → FAIL** (provider rejects non-RSA). + +- [ ] **Step 3: Create `BcPqKeys.cs`** — keygen + OID mapping (BC names/namespaces arbitrated by the test): + +```csharp +using System; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Security; +using ScepTestClient.CryptoApi; + +namespace ScepTestClient.Crypto.BouncyCastle; + +internal static class BcPqKeys { + // Returns true and sets pair/oid for an ML-DSA or SLH-DSA spec; false if spec is not PQ. + public static bool TryGenerate(KeySpec spec, SecureRandom random, out AsymmetricCipherKeyPair pair, out string oid, out string error) { + pair = null!; + oid = string.Empty; + error = string.Empty; + + if (spec.Algorithm.Equals("ML-DSA", StringComparison.OrdinalIgnoreCase)) { + Org.BouncyCastle.Pqc.Crypto.MLDsa.MLDsaKeyPairGenerator generator; + Org.BouncyCastle.Pqc.Crypto.MLDsa.MLDsaParameters parameters; + + parameters = spec.Parameter switch { + "44" => Org.BouncyCastle.Pqc.Crypto.MLDsa.MLDsaParameters.ml_dsa_44, + "65" => Org.BouncyCastle.Pqc.Crypto.MLDsa.MLDsaParameters.ml_dsa_65, + "87" => Org.BouncyCastle.Pqc.Crypto.MLDsa.MLDsaParameters.ml_dsa_87, + _ => null!, + }; + if (parameters == null) { error = $"unsupported ML-DSA set '{spec.Parameter}'"; return false; } + generator = new Org.BouncyCastle.Pqc.Crypto.MLDsa.MLDsaKeyPairGenerator(); + generator.Init(new Org.BouncyCastle.Pqc.Crypto.MLDsa.MLDsaKeyGenerationParameters(random, parameters)); + pair = generator.GenerateKeyPair(); + oid = "ML-DSA-" + spec.Parameter; + return true; + } + + if (spec.Algorithm.Equals("SLH-DSA", StringComparison.OrdinalIgnoreCase)) { + Org.BouncyCastle.Pqc.Crypto.SlhDsa.SlhDsaKeyPairGenerator generator; + Org.BouncyCastle.Pqc.Crypto.SlhDsa.SlhDsaParameters parameters; + + // Map "128s"/"192s"/"256s" to the SHA2 small SlhDsaParameters fields (verify names per BC 2.5.0). + parameters = spec.Parameter switch { + "128s" => Org.BouncyCastle.Pqc.Crypto.SlhDsa.SlhDsaParameters.slh_dsa_sha2_128s, + "192s" => Org.BouncyCastle.Pqc.Crypto.SlhDsa.SlhDsaParameters.slh_dsa_sha2_192s, + "256s" => Org.BouncyCastle.Pqc.Crypto.SlhDsa.SlhDsaParameters.slh_dsa_sha2_256s, + _ => null!, + }; + if (parameters == null) { error = $"unsupported SLH-DSA set '{spec.Parameter}'"; return false; } + generator = new Org.BouncyCastle.Pqc.Crypto.SlhDsa.SlhDsaKeyPairGenerator(); + generator.Init(new Org.BouncyCastle.Pqc.Crypto.SlhDsa.SlhDsaKeyGenerationParameters(random, parameters)); + pair = generator.GenerateKeyPair(); + oid = "SLH-DSA-" + spec.Parameter; + return true; + } + + return false; + } + + // Map an OID name back through the registry; resolves a PQ private key's friendly OID for re-import. + public static string OidForName(string name) => Algorithms.OidFor(name) ?? name; + + public static bool IsPqOid(string oid) { + AlgorithmKind? kind; + + kind = Algorithms.KindOf(oid); + return kind == AlgorithmKind.Signature && oid != BcAlgorithms.Rsa; + } +} +``` + + > Note: `BcKey` stores `AlgorithmOid` as the OID string. `BcPqKeys.TryGenerate` returns the friendly name ("ML-DSA-65"); convert to OID with `Algorithms.OidFor(...)` at the `BcKey` construction site so `AlgorithmOid` is the OID (matches the RSA path which stores `BcAlgorithms.Rsa`). + +- [ ] **Step 4: Branch `GenerateKey`** in `BouncyCastleScepCrypto.cs` (PQ first, then existing RSA path): + +```csharp + public bool GenerateKey(KeySpec spec, out IScepKey key, out string error) { + RsaKeyPairGenerator generator; + AsymmetricCipherKeyPair pair; + AsymmetricCipherKeyPair pq_pair; + string pq_oid; + + key = null!; + error = string.Empty; + + if (BcPqKeys.TryGenerate(spec, _random, out pq_pair, out pq_oid, out error)) { + key = new BcKey(pq_pair, Algorithms.OidFor(pq_oid)!, 0); + return true; + } + if (!string.IsNullOrEmpty(error)) { + return false; // PQ algorithm recognized but parameter invalid + } + + if (!spec.Algorithm.Equals("RSA", StringComparison.OrdinalIgnoreCase)) { + error = $"provider does not support key algorithm '{spec.Algorithm}'"; + return false; + } + + try { + generator = new RsaKeyPairGenerator(); + generator.Init(new KeyGenerationParameters(_random, spec.Size)); + pair = generator.GenerateKeyPair(); + key = new BcKey(pair, BcAlgorithms.Rsa, spec.Size); + return true; + } catch (Exception ex) { + error = $"RSA key generation failed: {ex.Message}"; + return false; + } + } +``` + +- [ ] **Step 5: PQ export/import.** `ExportPrivateKeyPkcs8` currently uses `PrivateKeyInfoFactory.CreatePrivateKeyInfo` — for PQ keys use `PqcPrivateKeyInfoFactory.CreatePrivateKeyInfo` when `BcPqKeys.IsPqOid(bc_key.AlgorithmOid)`. In `ImportPrivateKeyPkcs8`, `PrivateKeyFactory.CreateKey(der)` returns a PQ key-params instance for PQ PKCS#8; detect non-`RsaPrivateCrtKeyParameters` and, if it is an ML-DSA/SLH-DSA private key params type, wrap it in a `BcKey` with the right OID (derive via the key's parameter set; the round-trip test arbitrates the exact type/name). Add a `BcPqKeys.TryImport(priv, out pair, out oid)` helper to keep the provider clean. + +- [ ] **Step 6: Run → PASS** (adjust BC names/namespaces until the round-trip test is green; report any deviation from the names above). + +- [ ] **Step 7: Commit** + +```bash +git add src/ScepTestClient.Crypto.BouncyCastle/BcPqKeys.cs src/ScepTestClient.Crypto.BouncyCastle/BcKey.cs src/ScepTestClient.Crypto.BouncyCastle/BouncyCastleScepCrypto.cs tests/ScepTestClient.Tests/PqKeyGenTests.cs +git commit -m "Phase 4: tier A — ML-DSA/SLH-DSA key generation + PKCS#8 round-trip" +``` + +--- + +## Task 6: BC provider tier A — PQ CSR encode (SPKI + PQ signature) + +**Goal:** `EncodeCsr` emits a PKCS#10 with a PQ `SubjectPublicKeyInfo` and a PQ signature when the subject key is ML-DSA/SLH-DSA; the produced CSR parses and its signature verifies. + +**Files:** +- Modify: `src/ScepTestClient.Crypto.BouncyCastle/BcCsrBuilder.cs` +- Test: `tests/ScepTestClient.Tests/PqCsrTests.cs` + +**Acceptance Criteria:** +- [ ] An ML-DSA-65 CSR can be built; re-parsing it (`new Pkcs10CertificationRequest(der)`) succeeds and `.Verify()` returns true. +- [ ] The CSR's SubjectPublicKeyInfo algorithm OID is the ML-DSA OID. +- [ ] Challenge password and SAN/SID/EKU extensions still emit on a PQ CSR (reuse `BuildExtensions`). +- [ ] RSA CSR path unchanged (existing `BcCsrTests` green). + +**Verify:** `dotnet test tests/ScepTestClient.Tests --filter PqCsrTests` → PASS (and `BcCsrTests` green) + +**Steps:** + +- [ ] **Step 1: Write the failing test** `tests/ScepTestClient.Tests/PqCsrTests.cs`: + +```csharp +using Org.BouncyCastle.Pkcs; +using ScepTestClient.Crypto.BouncyCastle; +using ScepTestClient.CryptoApi; +using Xunit; + +namespace ScepTestClient.Tests; + +public sealed class PqCsrTests { + [Fact] + public void Ml_dsa_csr_parses_and_verifies() { + BouncyCastleScepCrypto crypto; + KeySpec spec; + IScepKey key; + Pkcs10 csr; + byte[] der; + string error; + Pkcs10CertificationRequest parsed; + + crypto = new BouncyCastleScepCrypto(); + Assert.True(KeySpec.Parse("ml-dsa:65", out spec, out error), error); + Assert.True(crypto.GenerateKey(spec, out key, out error), error); + + csr = new Pkcs10 { Key = key, ChallengePassword = "pw" }; + csr.SetSubject("CN=pq-test", out error); + Assert.True(crypto.EncodeCsr(csr, out der, out error), error); + + parsed = new Pkcs10CertificationRequest(der); + Assert.True(parsed.Verify()); + Assert.Contains("2.16.840.1.101.3.4.3.18", + parsed.GetCertificationRequestInfo().SubjectPublicKeyInfo.Algorithm.Algorithm.Id); + } +} +``` + +- [ ] **Step 2: Run → FAIL** (`Asn1SignatureFactory("SHA256WITHRSA", pqKey)` cannot sign a PQ key). + +- [ ] **Step 3: Branch `BcCsrBuilder.Build`** on PQ vs RSA. For PQ, build the request with the PQ `SubjectPublicKeyInfo` (`PqcSubjectPublicKeyInfoFactory.CreateSubjectPublicKeyInfo(key.KeyPair.Public)`) and a PQ `ISignatureFactory`. Preferred call: `new Asn1SignatureFactory("ML-DSA-65", key.KeyPair.Private, random)`; if 2.5.0 does not map that name, implement a minimal `ISignatureFactory` over `MLDsaSigner`/`SlhDsaSigner`. The 4-arg `Pkcs10CertificationRequest(signer, subject, pubKey, attrs)` accepts a PQ `AsymmetricKeyParameter` public key. Reuse `BuildExtensions(csr)` unchanged. Sketch: + +```csharp + public static byte[] Build(Pkcs10 csr, BcKey key) { + X509Name subject; + Org.BouncyCastle.Crypto.ISignatureFactory signer; + List attributes; + X509Extensions extensions; + Asn1Set attribute_set; + Pkcs10CertificationRequest request; + + subject = new X509Name(csr.Subject); + signer = BcPqKeys.IsPqOid(key.AlgorithmOid) + ? BcPqKeys.SignatureFactory(key) // PQ signer (round-trip test arbitrates) + : new Asn1SignatureFactory("SHA256WITHRSA", key.KeyPair.Private); + attributes = new List(); + + if (!string.IsNullOrEmpty(csr.ChallengePassword)) { + attributes.Add(new AttributePkcs(new DerObjectIdentifier(ChallengePasswordOid), new DerSet(new DerPrintableString(csr.ChallengePassword)))); + } + + extensions = BuildExtensions(csr); + if (extensions is not null) { + attributes.Add(new AttributePkcs(new DerObjectIdentifier(ExtensionRequestOid), new DerSet(extensions))); + } + + attribute_set = new DerSet(attributes.ToArray()); + request = new Pkcs10CertificationRequest(signer, subject, key.KeyPair.Public, attribute_set); + return request.GetEncoded(); + } +``` + + Add `BcPqKeys.SignatureFactory(BcKey)` returning the appropriate `Asn1SignatureFactory` (or custom factory). If the friendly-name `Asn1SignatureFactory("ML-DSA-65", priv, random)` path works, inline it and skip the helper. + +- [ ] **Step 4: Run → PASS** (adjust until the PQ CSR verifies; report the exact working signer construction). + +- [ ] **Step 5: Commit** + +```bash +git add src/ScepTestClient.Crypto.BouncyCastle/BcCsrBuilder.cs tests/ScepTestClient.Tests/PqCsrTests.cs +git commit -m "Phase 4: tier A — encode PQ CSR with PQ SubjectPublicKeyInfo and signature" +``` + +--- + +## Task 7: Tier B — catalyst alt-key (subjectAltPublicKeyInfo) + --alt-key-spec + +**Goal:** Carry an optional alt public key into the CSR as the `subjectAltPublicKeyInfo` extension (OID `2.5.29.72`), expose it through `Pkcs10.AltKey`, `EnrollRequest.AltKey`, `ScepRequestBuilder.AltKey`, and the `--alt-key-spec` CLI flag. Record a `ConformanceNote` that the alt-signature value is not computed by the built-in provider. + +**Files:** +- Modify: `src/ScepTestClient.CryptoApi/Pkcs10.cs` (add `AltKey`) +- Modify: `src/ScepTestClient.Crypto.BouncyCastle/BcCsrBuilder.cs` (emit the extension) +- Modify: `src/ScepTestClient.Core/EnrollRequest.cs` (`AltKey`), `src/ScepTestClient.Core/ScepRequestBuilder.cs` (`.AltKey`), `src/ScepTestClient.Core/ScepClient.cs` (thread `EnrollRequest.AltKey` → `Pkcs10.AltKey`) +- Modify: `src/ScepTestClient.Cli/CommandRouter.cs` (`--alt-key-spec` in `RunGet`) +- Test: `tests/ScepTestClient.Tests/AltKeyCsrTests.cs` + +**Acceptance Criteria:** +- [ ] `Pkcs10.AltKey` is a nullable `IScepKey` defaulting to null (existing callers unaffected). +- [ ] An RSA-primary CSR built with an ML-DSA `AltKey` contains the `2.5.29.72` extension whose value is the ML-DSA SubjectPublicKeyInfo. +- [ ] When no `AltKey` is set, no `2.5.29.72` extension is emitted (existing CSRs byte-shape unchanged aside from existing behavior). +- [ ] CLI: `enroll --subject CN=x --key-spec rsa:2048 --alt-key-spec ml-dsa:65` generates the alt key and passes it through (covered by a router-level unit on `EnrollRequest` wiring, no live server needed). + +**Verify:** `dotnet test tests/ScepTestClient.Tests --filter AltKeyCsrTests` → PASS + +**Steps:** + +- [ ] **Step 1: Write the failing test** `tests/ScepTestClient.Tests/AltKeyCsrTests.cs`: + +```csharp +using Org.BouncyCastle.Asn1; +using Org.BouncyCastle.Pkcs; +using ScepTestClient.Crypto.BouncyCastle; +using ScepTestClient.CryptoApi; +using Xunit; + +namespace ScepTestClient.Tests; + +public sealed class AltKeyCsrTests { + [Fact] + public void Alt_key_emits_subject_alt_public_key_info() { + BouncyCastleScepCrypto crypto; + KeySpec rsa_spec; + KeySpec alt_spec; + IScepKey rsa_key; + IScepKey alt_key; + Pkcs10 csr; + byte[] der; + string error; + Pkcs10CertificationRequest parsed; + Org.BouncyCastle.Asn1.X509.X509Extensions exts; + + crypto = new BouncyCastleScepCrypto(); + Assert.True(KeySpec.Parse("rsa:2048", out rsa_spec, out error), error); + Assert.True(KeySpec.Parse("ml-dsa:65", out alt_spec, out error), error); + Assert.True(crypto.GenerateKey(rsa_spec, out rsa_key, out error), error); + Assert.True(crypto.GenerateKey(alt_spec, out alt_key, out error), error); + + csr = new Pkcs10 { Key = rsa_key, AltKey = alt_key }; + csr.SetSubject("CN=catalyst", out error); + Assert.True(crypto.EncodeCsr(csr, out der, out error), error); + + parsed = new Pkcs10CertificationRequest(der); + Assert.True(parsed.Verify()); + exts = ExtensionsFrom(parsed); + Assert.NotNull(exts.GetExtension(new DerObjectIdentifier("2.5.29.72"))); + } + + private static Org.BouncyCastle.Asn1.X509.X509Extensions ExtensionsFrom(Pkcs10CertificationRequest req) { + // Pull the extensionRequest attribute (1.2.840.113549.1.9.14) and parse X509Extensions. + foreach (Org.BouncyCastle.Asn1.Pkcs.AttributePkcs attr in req.GetAttributes()) { + if (attr.AttrType.Id == "1.2.840.113549.1.9.14") { + return Org.BouncyCastle.Asn1.X509.X509Extensions.GetInstance(attr.AttrValues[0]); + } + } + throw new System.Exception("no extensionRequest attribute"); + } +} +``` + + > If `GetAttributes()` is not the exact accessor in BC 2.5.0, use `GetCertificationRequestInfo().Attributes` and `AttributePkcs.GetInstance(enc)` (per the Phase-3 challenge-password read pattern in the BC reference memory). The test arbitrates. + +- [ ] **Step 2: Run → FAIL** (no `AltKey`; no extension). + +- [ ] **Step 3: Add `Pkcs10.AltKey`:** + +```csharp + public IScepKey? AltKey { get; set; } +``` + +- [ ] **Step 4: Emit the extension** in `BcCsrBuilder.BuildExtensions` (before the `return`): when `csr.AltKey is BcKey alt_bc`, add the alt SPKI as the `2.5.29.72` extension: + +```csharp + if (csr.AltKey is BcKey alt_bc) { + Org.BouncyCastle.Asn1.X509.SubjectPublicKeyInfo alt_spki; + + alt_spki = Org.BouncyCastle.Pqc.Crypto.Utilities.PqcSubjectPublicKeyInfoFactory.CreateSubjectPublicKeyInfo(alt_bc.KeyPair.Public); + gen.AddExtension(new DerObjectIdentifier("2.5.29.72"), false, alt_spki); + any = true; + } +``` + + > `PqcSubjectPublicKeyInfoFactory` handles PQ alt keys; for a classical alt key use `SubjectPublicKeyInfoFactory`. The built-in provider does **not** compute `altSignatureAlgorithm`/`altSignatureValue` (bleeding-edge). Record this once at the call path: in `BouncyCastleScepCrypto.EncodeCsr`, when `csr.AltKey != null`, the provider cannot add a ConformanceNote to a stateless `Pkcs10` cleanly — instead document it in code comments here and surface the limitation through the analysis doc + `crypto info` (Task 10). (No ConformanceNotes channel on `EncodeCsr`; keep it a documented limitation.) + +- [ ] **Step 5: Thread the alt key through Core + CLI.** Add `EnrollRequest.AltKey` (`IScepKey?`), set `Pkcs10.AltKey` wherever `ScepClient` builds the inner CSR from an `EnrollRequest` (find the build site in `ScepClient.cs`), add `ScepRequestBuilder.AltKey(IScepKey)` + assign to the built `Pkcs10`. In `CommandRouter.RunGet`, parse `--alt-key-spec`, generate the alt key via `crypto.GenerateKey`, set `request.AltKey`. + +- [ ] **Step 6: Run → PASS.** Run the full suite (alt-key is additive; nothing else should change). + +- [ ] **Step 7: Commit** + +```bash +git add src/ScepTestClient.CryptoApi/Pkcs10.cs src/ScepTestClient.Crypto.BouncyCastle/BcCsrBuilder.cs src/ScepTestClient.Core/EnrollRequest.cs src/ScepTestClient.Core/ScepRequestBuilder.cs src/ScepTestClient.Core/ScepClient.cs src/ScepTestClient.Cli/CommandRouter.cs tests/ScepTestClient.Tests/AltKeyCsrTests.cs +git commit -m "Phase 4: tier B — catalyst alt-key via subjectAltPublicKeyInfo + --alt-key-spec" +``` + +--- + +## Task 8: Capabilities drive opinion (CuttingEdge) + servers suggest + +**Goal:** Add PQ classification to `SecurityOpinion` (`ClassifySignature` → `CuttingEdge`) and make `servers suggest` emit a PQ enroll command when the loaded provider supports tier A. + +**Files:** +- Modify: `src/ScepTestClient.Core/Testing/SecurityOpinion.cs` (new `ClassifySignature`) +- Modify: `src/ScepTestClient.Core/Testing/ServerSuggest.cs` (overload taking provider `CryptoCapabilities`) +- Modify: `src/ScepTestClient.Cli/CommandRouter.cs` (`RunServersSuggest` passes provider caps + prints PQ posture) +- Test: `tests/ScepTestClient.Tests/PqOpinionSuggestTests.cs` + +**Acceptance Criteria:** +- [ ] `SecurityOpinion.ClassifySignature("ML-DSA-65")` and `("SLH-DSA-128s")` → `AlgorithmPosture.CuttingEdge`; `("RSA")` → `Modern`; unknown → `Unknown`. +- [ ] `ServerSuggest.For(server_id, scepCaps, cryptoCaps)` includes a `--key-spec ml-dsa:65` line when `cryptoCaps.PqTiers.TierA` is true; the existing classical lines remain. +- [ ] The existing `ServerSuggest.For(server_id, scepCaps)` signature still exists (back-compat) — delegates with default (no-PQ) caps. + +**Verify:** `dotnet test tests/ScepTestClient.Tests --filter PqOpinionSuggestTests` → PASS (and `SecurityOpinionTests`/`CliScenarioSuggestTests` green) + +**Steps:** + +- [ ] **Step 1: Write the failing test** `tests/ScepTestClient.Tests/PqOpinionSuggestTests.cs`: + +```csharp +using System.Linq; +using ScepTestClient.Core.Protocol; +using ScepTestClient.Core.Testing; +using ScepTestClient.CryptoApi; +using Xunit; + +namespace ScepTestClient.Tests; + +public sealed class PqOpinionSuggestTests { + [Theory] + [InlineData("ML-DSA-65", AlgorithmPosture.CuttingEdge)] + [InlineData("SLH-DSA-128s", AlgorithmPosture.CuttingEdge)] + [InlineData("RSA", AlgorithmPosture.Modern)] + [InlineData("bogus", AlgorithmPosture.Unknown)] + public void Classifies_signatures(string name, AlgorithmPosture expected) { + Assert.Equal(expected, SecurityOpinion.ClassifySignature(name)); + } + + [Fact] + public void Suggest_includes_pq_when_provider_supports_tier_a() { + ScepCapabilities scep_caps; + CryptoCapabilities crypto_caps; + System.Collections.Generic.IReadOnlyList lines; + + scep_caps = ScepCapabilities.Parse("SHA-256\nAES\n"); + crypto_caps = new CryptoCapabilities { PqTiers = new PqTiers(TierA: true) }; + lines = ServerSuggest.For("srv", scep_caps, crypto_caps); + Assert.Contains(lines, l => l.Contains("ml-dsa:65")); + Assert.Contains(lines, l => l.Contains("--digest SHA-256")); + } +} +``` + +- [ ] **Step 2: Run → FAIL.** + +- [ ] **Step 3: Add `ClassifySignature`** to `SecurityOpinion.cs`: + +```csharp + public static AlgorithmPosture ClassifySignature(string name) { + string upper; + + upper = (name ?? string.Empty).ToUpperInvariant(); + if (upper.StartsWith("ML-DSA") || upper.StartsWith("SLH-DSA") || upper.StartsWith("ML-KEM")) { + return AlgorithmPosture.CuttingEdge; + } + if (upper == "RSA") { return AlgorithmPosture.Modern; } + return AlgorithmPosture.Unknown; + } +``` + +- [ ] **Step 4: Add the `ServerSuggest.For` overload** taking `CryptoCapabilities`; keep the old one delegating with `new CryptoCapabilities()`: + +```csharp + public static IReadOnlyList For(string server_id, ScepCapabilities caps) => + For(server_id, caps, new CryptoCapabilities()); + + public static IReadOnlyList For(string server_id, ScepCapabilities caps, CryptoCapabilities crypto_caps) { + // ... existing classical digest/cipher cross-product (unchanged) ... + if (crypto_caps.PqTiers.TierA) { + lines.Add($"sceptest enroll {server_id} --subject \"CN=test\" --key-spec ml-dsa:65 --digest {digests[0]} --cipher {ciphers[0]}"); + } + return lines; + } +``` + +- [ ] **Step 5: Wire the CLI** — in `RunServersSuggest`, load the provider, pass `client.Crypto.Capabilities` to `ServerSuggest.For`, and add a posture line using `ClassifySignature` when `client.Crypto.Capabilities.PqTiers.TierA`. + +- [ ] **Step 6: Run → PASS.** + +- [ ] **Step 7: Commit** + +```bash +git add src/ScepTestClient.Core/Testing/SecurityOpinion.cs src/ScepTestClient.Core/Testing/ServerSuggest.cs src/ScepTestClient.Cli/CommandRouter.cs tests/ScepTestClient.Tests/PqOpinionSuggestTests.cs +git commit -m "Phase 4: capabilities drive opinion (CuttingEdge) and PQ servers-suggest" +``` + +--- + +## Task 9: Empirical PQ probe + +**Goal:** Add a `ProbePq` step to `RunProbe` that attempts an ML-DSA enroll (when the provider supports tier A) and reports the result, since GetCACaps has no PQ keyword (any success is a FINDING). + +**Files:** +- Modify: `src/ScepTestClient.Core/Testing/TestEngine.cs` +- Test: `tests/ScepTestClient.Tests/PqProbeTests.cs` +- (Maybe) Modify: `tests/ScepTestClient.Tests/Fakes/TestCa.cs` only if needed to observe a PQ attempt gracefully. + +**Acceptance Criteria:** +- [ ] `RunProbe` produces a result row named like `"probe ML-DSA enrollment"`. +- [ ] When the provider supports tier A but the server cannot issue PQ, the row is `Failed` (or `Finding` if it unexpectedly succeeds) — never throws. +- [ ] The probe is skipped with a clear `why` when `client.Crypto.Capabilities.PqTiers.TierA` is false. +- [ ] Existing probe rows (digest/POST/GetNextCACert) unchanged. + +**Verify:** `dotnet test tests/ScepTestClient.Tests --filter PqProbeTests` → PASS (and `TestEngineModesTests` green) + +**Steps:** + +- [ ] **Step 1: Write the failing test** `tests/ScepTestClient.Tests/PqProbeTests.cs` — spin up `FakeScepServer`/`TestCa` (as in `TestEngineModesTests`), run `RunProbe`, assert a PQ row exists and the run did not throw: + +```csharp +using System.Linq; +using ScepTestClient.Core.Testing; +using Xunit; + +namespace ScepTestClient.Tests; + +public sealed class PqProbeTests { + [Fact] + public async System.Threading.Tasks.Task Probe_includes_pq_row() { + // Arrange: FakeScepServer + TestCa + ScepClient (mirror TestEngineModesTests setup). + // Act: + // TestReport report = new TestEngine().RunProbe(client); + // Assert: + // Assert.Contains(report.Results, r => r.Name.Contains("ML-DSA")); + await System.Threading.Tasks.Task.CompletedTask; + Assert.True(true); // replace with the real arrange/act/assert mirroring TestEngineModesTests + } +} +``` + + > Implementer: copy the exact server/client construction from `TestEngineModesTests.cs` (it already builds a live `FakeScepServer` + `ScepClient`). Replace the placeholder body with a real assertion on the PQ row. + +- [ ] **Step 2: Run → FAIL** (no PQ row). + +- [ ] **Step 3: Add `ProbePq`** to `TestEngine.cs` and call it from `RunProbe` after `ProbeGetNextCa`: + +```csharp + private static void ProbePq(TestReport report, ScepClient client) { + Stopwatch sw; + bool worked; + CheckOutcome outcome; + string why; + ScepResult ca_result; + + if (!client.Crypto.Capabilities.PqTiers.TierA) { + report.Results.Add(new CheckResult("probe ML-DSA enrollment", CheckOutcome.Skipped, FailInfo.None, FailInfo.None, + PkiStatus.Failure, "loaded provider does not implement PQ tier A", "RFC 8894 (no PQ keyword) / spec §14", System.TimeSpan.Zero)); + return; + } + + sw = Stopwatch.StartNew(); + worked = false; + try { + ca_result = ResolveCaCert(client); + if (ca_result.IsOk) { + worked = SubmitEnrollWithKeySpec(client, ca_result.Value, "ml-dsa:65", "SHA-256"); + } + } catch (System.Exception) { + worked = false; + } + sw.Stop(); + + if (worked) { + outcome = CheckOutcome.Finding; + why = "ML-DSA enrollment succeeded though GetCACaps advertises no PQ capability (under-advertised / PQ-capable CA)"; + } else { + outcome = CheckOutcome.Failed; + why = "ML-DSA enrollment was not accepted (expected against a classical-only CA)"; + } + report.Results.Add(new CheckResult("probe ML-DSA enrollment", outcome, FailInfo.None, FailInfo.None, + worked ? PkiStatus.Success : PkiStatus.Failure, why, "spec §14 (empirical PQ probe)", sw.Elapsed)); + } +``` + + Add a `SubmitEnrollWithKeySpec(client, ca_cert, key_spec, digest)` helper (generalize the existing `SubmitEnrollWithDigest`, which hardcodes `rsa:2048`) — or add a `KeySpec` parameter to it and update its one caller. + +- [ ] **Step 4: Run → PASS.** + +- [ ] **Step 5: Commit** + +```bash +git add src/ScepTestClient.Core/Testing/TestEngine.cs tests/ScepTestClient.Tests/PqProbeTests.cs +git commit -m "Phase 4: empirical PQ probe (ML-DSA enroll → PASSED/FINDING/FAILED)" +``` + +--- + +## Task 10: `crypto info` / `crypto list` CLI + +**Goal:** Add the `crypto` command. `crypto list` prints the loaded provider's algorithms grouped by kind (friendly names); `crypto info` prints the provider DLL path and PQ tier support. + +**Files:** +- Create: `src/ScepTestClient.Cli/CryptoCommand.cs` +- Modify: `src/ScepTestClient.Cli/CommandRouter.cs` (route `crypto`, add to usage) +- Test: `tests/ScepTestClient.Tests/CryptoCommandTests.cs` + +**Acceptance Criteria:** +- [ ] `crypto list` output contains `ML-DSA-65` (under signatures/keys) and `ML-KEM-768` (under KEM) and `SHA-256` (digests). +- [ ] `crypto info` output reports `Tier A: yes`, `Tier B: yes`, `Tier C: no` and the provider source ("built-in" when default). +- [ ] Unknown `crypto ` prints usage and returns non-zero. + +**Verify:** `dotnet test tests/ScepTestClient.Tests --filter CryptoCommandTests` → PASS + +**Steps:** + +- [ ] **Step 1: Write the failing test** `tests/ScepTestClient.Tests/CryptoCommandTests.cs`: + +```csharp +using System.IO; +using ScepTestClient.Cli; +using Xunit; + +namespace ScepTestClient.Tests; + +public sealed class CryptoCommandTests { + [Fact] + public void Crypto_list_shows_pq() { + StringWriter output; + int code; + + output = new StringWriter(); + code = CommandRouter.Run(new[] { "crypto", "list" }, Path.GetTempPath(), output); + Assert.Equal(0, code); + Assert.Contains("ML-DSA-65", output.ToString()); + Assert.Contains("ML-KEM-768", output.ToString()); + Assert.Contains("SHA-256", output.ToString()); + } + + [Fact] + public void Crypto_info_shows_tiers() { + StringWriter output; + int code; + + output = new StringWriter(); + code = CommandRouter.Run(new[] { "crypto", "info" }, Path.GetTempPath(), output); + Assert.Equal(0, code); + Assert.Contains("Tier A: yes", output.ToString()); + Assert.Contains("Tier C: no", output.ToString()); + } +} +``` + +- [ ] **Step 2: Run → FAIL** (no `crypto` route). + +- [ ] **Step 3: Create `CryptoCommand.cs`** — load the provider via the same resolver as the rest of the CLI (Task 11 introduces `ResolveProviderPath`; for now call `ScepCrypto.Load(null, ...)`, and Task 11 swaps it to the resolver), group `Capabilities` by kind using `Algorithms.NameFor(oid) ?? oid`: + +```csharp +using System.IO; +using ScepTestClient.Core; +using ScepTestClient.CryptoApi; + +namespace ScepTestClient.Cli; + +internal static class CryptoCommand { + public static int Run(string[] args, string data_root, TextWriter output) { + string verb; + IScepCrypto crypto; + string error; + + if (args.Length < 2) { output.WriteLine("usage: crypto "); return 2; } + verb = args[1]; + + if (ScepCrypto.Load(null, out crypto, out error) != ScepClientResult.Ok) { + output.WriteLine($"crypto load error: {error}"); + return 1; + } + + switch (verb) { + case "list": return List(crypto, output); + case "info": return Info(crypto, output); + default: output.WriteLine("usage: crypto "); return 2; + } + } + + private static int List(IScepCrypto crypto, TextWriter output) { + PrintGroup(output, "Digests", crypto.Capabilities.Digests); + PrintGroup(output, "Signatures", crypto.Capabilities.Signatures); + PrintGroup(output, "ContentEncryption", crypto.Capabilities.ContentEncryption); + PrintGroup(output, "KeyTransport", crypto.Capabilities.KeyTransport); + PrintGroup(output, "KEM", crypto.Capabilities.Kem); + PrintGroup(output, "AsymmetricKeys", crypto.Capabilities.AsymmetricKeys); + return 0; + } + + private static int Info(IScepCrypto crypto, TextWriter output) { + output.WriteLine("Provider: built-in (BouncyCastle)"); + output.WriteLine($"Tier A: {(crypto.Capabilities.PqTiers.TierA ? "yes" : "no")}"); + output.WriteLine($"Tier B: {(crypto.Capabilities.PqTiers.TierB ? "yes" : "no")}"); + output.WriteLine($"Tier C: {(crypto.Capabilities.PqTiers.TierC ? "yes" : "no")} (ML-KEM CMS envelope — provider seam; BouncyCastle 2.5.0 has no CMS KEM recipient)"); + return 0; + } + + private static void PrintGroup(TextWriter output, string title, System.Collections.Generic.IReadOnlyCollection oids) { + System.Collections.Generic.List names; + + names = new System.Collections.Generic.List(); + foreach (string oid in oids) { names.Add(Algorithms.NameFor(oid) ?? oid); } + output.WriteLine($"{title}: {string.Join(", ", names)}"); + } +} +``` + +- [ ] **Step 4: Route `crypto`** in `CommandRouter.RunInternal` (`case "crypto": return CryptoCommand.Run(args, data_root, output);`) and add two usage lines. + +- [ ] **Step 5: Run → PASS.** + +- [ ] **Step 6: Commit** + +```bash +git add src/ScepTestClient.Cli/CryptoCommand.cs src/ScepTestClient.Cli/CommandRouter.cs tests/ScepTestClient.Tests/CryptoCommandTests.cs +git commit -m "Phase 4: crypto info / crypto list CLI" +``` + +--- + +## Task 11: `--crypto-provider` flag + config wiring at all load sites + +**Goal:** Resolve the provider DLL path from `--crypto-provider ` (else `ClientConfig.CryptoProviderPath`, else built-in) and thread it into **every** `ScepCrypto.Load` call site; add `config set ` so the provider path can be persisted. + +**Files:** +- Modify: `src/ScepTestClient.Cli/CommandRouter.cs` (add `ResolveProviderPath`; use it in `RunGetCaCaps`, `RunGet`, `BuildClient`, `RunServersSuggest`; add `config set`) +- Modify: `src/ScepTestClient.Cli/CryptoCommand.cs` (accept a resolved provider path) +- Modify: `src/ScepTestClient.Core/Storage/ClientConfig.cs` (only if a setter helper is wanted; the props already exist) +- Test: `tests/ScepTestClient.Tests/CryptoProviderFlagTests.cs` + +**Acceptance Criteria:** +- [ ] `ResolveProviderPath` returns the `--crypto-provider` value when present; else `ClientConfig.CryptoProviderPath`; else null. +- [ ] `config set crypto-provider ` persists to `config.json`; `config show` then displays it. +- [ ] All CLI commands that load crypto pass the resolved path (a passing build + a unit on `ResolveProviderPath` + a `config set`/`config show` round-trip suffice). +- [ ] A bogus `--crypto-provider /nope.dll` yields the existing "crypto provider DLL not found" error path (non-zero exit), not a crash. + +**Verify:** `dotnet test tests/ScepTestClient.Tests --filter CryptoProviderFlagTests` → PASS (and `CliRouterTests`/`CliRouterPhase2Tests` green) + +**Steps:** + +- [ ] **Step 1: Write the failing test** `tests/ScepTestClient.Tests/CryptoProviderFlagTests.cs`: + +```csharp +using System.IO; +using ScepTestClient.Cli; +using ScepTestClient.Core.Storage; +using Xunit; + +namespace ScepTestClient.Tests; + +public sealed class CryptoProviderFlagTests { + [Fact] + public void Config_set_persists_crypto_provider() { + string root; + StringWriter output; + + root = Path.Combine(Path.GetTempPath(), "sceptest-" + System.Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + + output = new StringWriter(); + Assert.Equal(0, CommandRouter.Run(new[] { "config", "set", "crypto-provider", "/tmp/p.dll" }, root, output)); + + output = new StringWriter(); + Assert.Equal(0, CommandRouter.Run(new[] { "config", "show" }, root, output)); + Assert.Contains("/tmp/p.dll", output.ToString()); + } + + [Fact] + public void Bogus_provider_path_errors_cleanly() { + StringWriter output; + int code; + + output = new StringWriter(); + code = CommandRouter.Run(new[] { "crypto", "list", "--crypto-provider", "/nope.dll" }, Path.GetTempPath(), output); + Assert.NotEqual(0, code); + Assert.Contains("not found", output.ToString()); + } +} +``` + +- [ ] **Step 2: Run → FAIL** (`config set` unrecognized; `--crypto-provider` ignored). + +- [ ] **Step 3: Add `ResolveProviderPath`** to `CommandRouter`: + +```csharp + private static string? ResolveProviderPath(string[] args, string data_root) { + string? flag; + ClientConfig config; + + flag = Opt(args, "--crypto-provider"); + if (!string.IsNullOrWhiteSpace(flag)) { return flag; } + + config = ClientConfig.Load(data_root); + return config.CryptoProviderPath; + } +``` + +- [ ] **Step 4: Swap every `ScepCrypto.Load(null, ...)`** in `RunGetCaCaps`, `RunGet`, `BuildClient`, and `CryptoCommand.Run` to `ScepCrypto.Load(ResolveProviderPath(args, data_root), ...)` (pass `args`/`data_root` into `CryptoCommand.Run`). + +- [ ] **Step 5: Add `config set`** in `RunConfig`: + +```csharp + if (args.Length >= 4 && args[1] == "set") { + ClientConfig config; + string key; + string value; + + config = ClientConfig.Load(data_root); + key = args[2]; + value = args[3]; + switch (key) { + case "crypto-provider": config.CryptoProviderPath = value; break; + case "key-spec": config.KeySpec = value; break; + case "min-rsa-bits": if (int.TryParse(value, out int bits)) { config.MinRsaKeyBits = bits; } break; + default: output.WriteLine($"unknown config key '{key}'"); return 2; + } + config.Save(data_root); + output.WriteLine($"set {key} = {value}"); + return 0; + } +``` + + Add `config set` + `config set-root` (if not already) to usage. + +- [ ] **Step 6: Run → PASS.** + +- [ ] **Step 7: Commit** + +```bash +git add src/ScepTestClient.Cli/CommandRouter.cs src/ScepTestClient.Cli/CryptoCommand.cs src/ScepTestClient.Core/Storage/ClientConfig.cs tests/ScepTestClient.Tests/CryptoProviderFlagTests.cs +git commit -m "Phase 4: --crypto-provider flag + config set, threaded to all crypto load sites" +``` + +--- + +## Task 12: External-provider ALC loading hardening + +**Goal:** Harden `ScepCrypto.Load` + `ProviderLoadContext`: reject ambiguous providers (>1 `IScepCrypto` impl) with a clear error, confirm the contract type identity is shared across the ALC boundary, and prove a path-loaded provider works end to end. + +**Files:** +- Modify: `src/ScepTestClient.Core/ScepCrypto.cs` (ambiguity check; clearer errors) +- Modify: `src/ScepTestClient.Core/ProviderLoadContext.cs` (keep CryptoApi shared; resolve provider deps; optional `Unload` note) +- Test: `tests/ScepTestClient.Tests/ProviderLoadTests.cs` (extend) + +**Acceptance Criteria:** +- [ ] Loading the built-in BC provider DLL **by explicit path** (the copy in the test output dir) via `ScepCrypto.Load(path, ...)` succeeds and the returned `crypto.Capabilities` is the shared-contract type (no `InvalidCastException`; `crypto is IScepCrypto` true). +- [ ] A path with **no** `IScepCrypto` impl → `ProviderError` with "no IScepCrypto implementation". +- [ ] When an assembly exposes **more than one** `IScepCrypto` impl, `Load` returns `ProviderError` "multiple IScepCrypto implementations" (deterministic, not first-wins). +- [ ] The contract type identity is shared: a key generated by the path-loaded provider is accepted by `EncodeCsr` of the same instance (round-trip) — proves single `IScepKey`/`IScepCrypto` `Type` identity. + +**Verify:** `dotnet test tests/ScepTestClient.Tests --filter ProviderLoad` → PASS + +**Steps:** + +- [ ] **Step 1: Write/extend the failing test** in `tests/ScepTestClient.Tests/ProviderLoadTests.cs`: + +```csharp +[Fact] +public void Loads_provider_by_explicit_path_with_shared_contract() { + string dll; + IScepCrypto crypto; + string error; + KeySpec spec; + IScepKey key; + Pkcs10 csr; + byte[] der; + + dll = Path.Combine(AppContext.BaseDirectory, "ScepTestClient.Crypto.BouncyCastle.dll"); + Assert.Equal(ScepClientResult.Ok, ScepCrypto.Load(dll, out crypto, out error)); + Assert.True(crypto is IScepCrypto); + Assert.True(KeySpec.Parse("rsa:2048", out spec, out error), error); + Assert.True(crypto.GenerateKey(spec, out key, out error), error); // shared IScepKey type identity + csr = new Pkcs10 { Key = key }; + csr.SetSubject("CN=alc", out error); + Assert.True(crypto.EncodeCsr(csr, out der, out error), error); +} +``` + + (Use the existing namespaces/usings already in `ProviderLoadTests.cs`.) + +- [ ] **Step 2: Run → FAIL** if the explicit-path load path or ambiguity check is missing; otherwise confirm the new assertions surface the gap. + +- [ ] **Step 3: Harden `ScepCrypto.Load`** — replace `FirstOrDefault` with an explicit count so >1 impl is an error: + +```csharp + System.Collections.Generic.List impls; + + impls = assembly.GetTypes() + .Where(t => typeof(IScepCrypto).IsAssignableFrom(t) && !t.IsAbstract && !t.IsInterface) + .ToList(); + if (impls.Count == 0) { + error = $"no IScepCrypto implementation found in {Path.GetFileName(path)}"; + return ScepClientResult.ProviderError; + } + if (impls.Count > 1) { + error = $"multiple IScepCrypto implementations found in {Path.GetFileName(path)} ({impls.Count})"; + return ScepClientResult.ProviderError; + } + impl_type = impls[0]; +``` + +- [ ] **Step 4:** Confirm `ProviderLoadContext.Load` already returns `null` for `ScepTestClient.CryptoApi` (it does, `:17`) so the contract stays shared. Add a short XML/inline comment documenting that this is the single-Type-identity guarantee. (No functional change unless the test reveals a gap with `GetTypes()` throwing `ReflectionTypeLoadException` — if so, catch it and surface `LoaderExceptions[0].Message`.) + +- [ ] **Step 5: Run → PASS.** + +- [ ] **Step 6: Commit** + +```bash +git add src/ScepTestClient.Core/ScepCrypto.cs src/ScepTestClient.Core/ProviderLoadContext.cs tests/ScepTestClient.Tests/ProviderLoadTests.cs +git commit -m "Phase 4: harden external-provider ALC loading (ambiguity check + shared-contract proof)" +``` + +--- + +## Final verification (after all tasks) + +- [ ] `dotnet build` → **0 warnings** (the standing gate). +- [ ] `dotnet test` → all green (100 prior ScepTestClient tests + the new PQ tests + 67 IntuneSimulator tests). +- [ ] `dotnet run --project src/ScepTestClient.Cli -- crypto list` shows ML-DSA/ML-KEM; `crypto info` shows Tier A/B yes, C no. +- [ ] Confirm no `IScepCrypto` interface signature changed (diff `src/ScepTestClient.CryptoApi/IScepCrypto.cs` — only the comment may differ, if at all). +- [ ] Re-sync the co-located `.tasks.json` statuses to `completed`. + +Then run **superpowers-extended-cc:finishing-a-development-branch**. PR via SmartGit (merge commit, granular commits preserved — no squash). + +## Self-review notes (author) + +- Spec coverage: §14 tiers A/B/C → Tasks 5/6 (A), 7 (B), 4+10 (C advertised-only, documented); §3.3 OID registry → Task 2; §3.4 capabilities driving opinion/probe/suggest → Tasks 4/8/9; §3.5 future seam (provider hardening) → Tasks 11/12; §17 row 4 (`crypto info/list`, `--crypto-provider`/config, empirical PQ probe) → Tasks 9/10/11/12. PQ-1 paper validation (§14, Phase-1 carry-over) → Task 1. +- Type consistency: `PqTiers` record (TierA/TierB/TierC) used identically in CryptoCapabilities, provider, opinion/suggest, probe, crypto info. `KeySpec.Parameter` introduced Task 3, consumed Tasks 5/6. `Pkcs10.AltKey` introduced Task 7, consumed in BcCsrBuilder same task. `ClassifySignature` introduced + consumed Task 8. +- Deferred §13 subject-mismatch test: explicitly kept deferred (header) — not silently dropped. diff --git a/docs/superpowers/plans/2026-06-20-scep-test-client-phase-4.md.tasks.json b/docs/superpowers/plans/2026-06-20-scep-test-client-phase-4.md.tasks.json new file mode 100644 index 0000000..5c10d02 --- /dev/null +++ b/docs/superpowers/plans/2026-06-20-scep-test-client-phase-4.md.tasks.json @@ -0,0 +1,89 @@ +{ + "planPath": "docs/superpowers/plans/2026-06-20-scep-test-client-phase-4.md", + "tasks": [ + { + "id": 1, + "subject": "Task 1: Validate IScepCrypto seam vs 3 PQ tiers (paper)", + "status": "pending", + "description": "Produce a committed analysis confirming PQ support is additive-only (no IScepCrypto signature change), grounded in BC 2.5.0 reality, before any code changes. USER-ORDERED GATE.\n\n```json:metadata\n{\"files\": [\"docs/superpowers/analysis/2026-06-20-iscepcrypto-pq-seam-validation.md\"], \"verifyCommand\": \"test -f docs/superpowers/analysis/2026-06-20-iscepcrypto-pq-seam-validation.md && grep -q additive-only docs/superpowers/analysis/2026-06-20-iscepcrypto-pq-seam-validation.md\", \"acceptanceCriteria\": [\"per-tier additive analysis present\", \"states no interface signature changes\", \"records BC 2.5.0 tier C infeasibility\", \"lists new additive surface\"], \"userGate\": true, \"tags\": [\"user-gate\"], \"requiresUserSpecification\": false, \"modelTier\": \"standard\"}\n```" + }, + { + "id": 2, + "subject": "Task 2: PQ algorithm registry entries", + "status": "pending", + "blockedBy": [1], + "description": "Add ML-DSA/SLH-DSA/ML-KEM entries to the Algorithms registry with correct AlgorithmKind + BcAlgorithms OID constants.\n\n```json:metadata\n{\"files\": [\"src/ScepTestClient.CryptoApi/Algorithms.cs\", \"src/ScepTestClient.Crypto.BouncyCastle/BcAlgorithms.cs\", \"tests/ScepTestClient.Tests/PqAlgorithmsTests.cs\"], \"verifyCommand\": \"dotnet test tests/ScepTestClient.Tests --filter PqAlgorithmsTests\", \"acceptanceCriteria\": [\"ML-DSA/SLH-DSA/ML-KEM resolve with correct kind\", \"existing entries unchanged\"], \"modelTier\": \"standard\"}\n```" + }, + { + "id": 3, + "subject": "Task 3: KeySpec accepts PQ specs", + "status": "pending", + "blockedBy": [1], + "description": "Extend KeySpec.Parse to accept ml-dsa/slh-dsa/ml-kem additively via a new Parameter property; rsa unchanged.\n\n```json:metadata\n{\"files\": [\"src/ScepTestClient.CryptoApi/KeySpec.cs\", \"tests/ScepTestClient.Tests/PqKeySpecTests.cs\"], \"verifyCommand\": \"dotnet test tests/ScepTestClient.Tests --filter PqKeySpecTests\", \"acceptanceCriteria\": [\"PQ specs parse with Parameter\", \"rsa unchanged\", \"bad specs rejected\"], \"modelTier\": \"standard\"}\n```" + }, + { + "id": 4, + "subject": "Task 4: CryptoCapabilities PQ tiers + provider advertises PQ", + "status": "pending", + "blockedBy": [2], + "description": "Add additive PqTiers value to CryptoCapabilities; BC provider advertises PQ algs + tiers A=true B=true C=false.\n\n```json:metadata\n{\"files\": [\"src/ScepTestClient.CryptoApi/PqTiers.cs\", \"src/ScepTestClient.CryptoApi/CryptoCapabilities.cs\", \"src/ScepTestClient.Crypto.BouncyCastle/BouncyCastleScepCrypto.cs\", \"tests/ScepTestClient.Tests/PqCapabilitiesTests.cs\"], \"verifyCommand\": \"dotnet test tests/ScepTestClient.Tests --filter PqCapabilitiesTests\", \"acceptanceCriteria\": [\"PqTiers default false\", \"BC advertises A/B true C false\", \"PQ OIDs in caps sets\"], \"modelTier\": \"standard\"}\n```" + }, + { + "id": 5, + "subject": "Task 5: BC tier A — ML-DSA/SLH-DSA keygen + PKCS#8 round-trip", + "status": "pending", + "blockedBy": [2, 3], + "description": "GenerateKey produces ML-DSA/SLH-DSA keypairs; PKCS#8 export/import round-trip. PQ specifics in BcPqKeys helper. BC names arbitrated by round-trip test.\n\n```json:metadata\n{\"files\": [\"src/ScepTestClient.Crypto.BouncyCastle/BcPqKeys.cs\", \"src/ScepTestClient.Crypto.BouncyCastle/BcKey.cs\", \"src/ScepTestClient.Crypto.BouncyCastle/BouncyCastleScepCrypto.cs\", \"tests/ScepTestClient.Tests/PqKeyGenTests.cs\"], \"verifyCommand\": \"dotnet test tests/ScepTestClient.Tests --filter PqKeyGenTests\", \"acceptanceCriteria\": [\"PQ keygen works\", \"PKCS#8 round-trip preserves oid\", \"RSA unchanged\"], \"modelTier\": \"complex\"}\n```" + }, + { + "id": 6, + "subject": "Task 6: BC tier A — PQ CSR encode (SPKI + signature)", + "status": "pending", + "blockedBy": [5], + "description": "EncodeCsr emits PQ SubjectPublicKeyInfo + PQ signature; CSR parses and verifies. Signer construction arbitrated by test.\n\n```json:metadata\n{\"files\": [\"src/ScepTestClient.Crypto.BouncyCastle/BcCsrBuilder.cs\", \"tests/ScepTestClient.Tests/PqCsrTests.cs\"], \"verifyCommand\": \"dotnet test tests/ScepTestClient.Tests --filter PqCsrTests\", \"acceptanceCriteria\": [\"PQ CSR parses and verifies\", \"SPKI oid is ML-DSA\", \"RSA CSR unchanged\"], \"modelTier\": \"complex\"}\n```" + }, + { + "id": 7, + "subject": "Task 7: Tier B — catalyst alt-key + --alt-key-spec", + "status": "pending", + "blockedBy": [6], + "description": "Carry an optional alt public key into the CSR as subjectAltPublicKeyInfo (2.5.29.72) via Pkcs10.AltKey + EnrollRequest/builder/CLI. altSignatureValue not computed (documented limitation).\n\n```json:metadata\n{\"files\": [\"src/ScepTestClient.CryptoApi/Pkcs10.cs\", \"src/ScepTestClient.Crypto.BouncyCastle/BcCsrBuilder.cs\", \"src/ScepTestClient.Core/EnrollRequest.cs\", \"src/ScepTestClient.Core/ScepRequestBuilder.cs\", \"src/ScepTestClient.Core/ScepClient.cs\", \"src/ScepTestClient.Cli/CommandRouter.cs\", \"tests/ScepTestClient.Tests/AltKeyCsrTests.cs\"], \"verifyCommand\": \"dotnet test tests/ScepTestClient.Tests --filter AltKeyCsrTests\", \"acceptanceCriteria\": [\"AltKey additive\", \"subjectAltPublicKeyInfo emitted\", \"no alt-key => no extension\", \"--alt-key-spec wired\"], \"modelTier\": \"complex\"}\n```" + }, + { + "id": 8, + "subject": "Task 8: Capabilities drive opinion (CuttingEdge) + suggest", + "status": "pending", + "blockedBy": [4], + "description": "Add SecurityOpinion.ClassifySignature (CuttingEdge for PQ) and a ServerSuggest overload that emits a PQ enroll line when provider TierA; CLI wires it.\n\n```json:metadata\n{\"files\": [\"src/ScepTestClient.Core/Testing/SecurityOpinion.cs\", \"src/ScepTestClient.Core/Testing/ServerSuggest.cs\", \"src/ScepTestClient.Cli/CommandRouter.cs\", \"tests/ScepTestClient.Tests/PqOpinionSuggestTests.cs\"], \"verifyCommand\": \"dotnet test tests/ScepTestClient.Tests --filter PqOpinionSuggestTests\", \"acceptanceCriteria\": [\"ClassifySignature CuttingEdge for PQ\", \"suggest includes PQ when TierA\", \"old overload preserved\"], \"modelTier\": \"standard\"}\n```" + }, + { + "id": 9, + "subject": "Task 9: Empirical PQ probe", + "status": "pending", + "blockedBy": [6], + "description": "Add ProbePq to RunProbe attempting an ML-DSA enroll; reports PASSED/FINDING/FAILED (no GetCACaps PQ keyword). Skipped when no TierA.\n\n```json:metadata\n{\"files\": [\"src/ScepTestClient.Core/Testing/TestEngine.cs\", \"tests/ScepTestClient.Tests/PqProbeTests.cs\"], \"verifyCommand\": \"dotnet test tests/ScepTestClient.Tests --filter PqProbeTests\", \"acceptanceCriteria\": [\"PQ probe row present\", \"graceful on failure\", \"skipped when no TierA\", \"existing rows unchanged\"], \"modelTier\": \"standard\"}\n```" + }, + { + "id": 10, + "subject": "Task 10: crypto info / crypto list CLI", + "status": "pending", + "blockedBy": [4], + "description": "Add the crypto command: list (algorithms grouped by kind, friendly names) and info (provider source + PQ tiers).\n\n```json:metadata\n{\"files\": [\"src/ScepTestClient.Cli/CryptoCommand.cs\", \"src/ScepTestClient.Cli/CommandRouter.cs\", \"tests/ScepTestClient.Tests/CryptoCommandTests.cs\"], \"verifyCommand\": \"dotnet test tests/ScepTestClient.Tests --filter CryptoCommandTests\", \"acceptanceCriteria\": [\"crypto list shows PQ + classical\", \"crypto info shows tiers\", \"bad verb -> usage non-zero\"], \"modelTier\": \"standard\"}\n```" + }, + { + "id": 11, + "subject": "Task 11: --crypto-provider flag + config set wiring", + "status": "pending", + "blockedBy": [10], + "description": "Resolve provider DLL path from --crypto-provider/config and thread into every ScepCrypto.Load site; add config set .\n\n```json:metadata\n{\"files\": [\"src/ScepTestClient.Cli/CommandRouter.cs\", \"src/ScepTestClient.Cli/CryptoCommand.cs\", \"src/ScepTestClient.Core/Storage/ClientConfig.cs\", \"tests/ScepTestClient.Tests/CryptoProviderFlagTests.cs\"], \"verifyCommand\": \"dotnet test tests/ScepTestClient.Tests --filter CryptoProviderFlagTests\", \"acceptanceCriteria\": [\"ResolveProviderPath precedence\", \"config set persists\", \"all load sites threaded\", \"bogus path clean error\"], \"modelTier\": \"standard\"}\n```" + }, + { + "id": 12, + "subject": "Task 12: Harden external-provider ALC loading", + "status": "pending", + "blockedBy": [1], + "description": "Harden ScepCrypto.Load (reject >1 impl) + ProviderLoadContext (shared-contract guarantee); prove path-loaded provider works end to end.\n\n```json:metadata\n{\"files\": [\"src/ScepTestClient.Core/ScepCrypto.cs\", \"src/ScepTestClient.Core/ProviderLoadContext.cs\", \"tests/ScepTestClient.Tests/ProviderLoadTests.cs\"], \"verifyCommand\": \"dotnet test tests/ScepTestClient.Tests --filter ProviderLoad\", \"acceptanceCriteria\": [\"explicit-path load shares contract\", \"no-impl -> error\", \"multi-impl -> error\", \"shared type identity round-trip\"], \"modelTier\": \"standard\"}\n```" + } + ], + "lastUpdated": "2026-06-20" +} From ae9a58a03a591b572f7b2c25b30ff25e94bac161 Mon Sep 17 00:00:00 2001 From: Peter Dennis Bartok Date: Sat, 20 Jun 2026 13:10:37 -0600 Subject: [PATCH 02/16] Phase 4: validate IScepCrypto seam is additive-only across all 3 PQ tiers --- ...26-06-20-iscepcrypto-pq-seam-validation.md | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 docs/superpowers/analysis/2026-06-20-iscepcrypto-pq-seam-validation.md diff --git a/docs/superpowers/analysis/2026-06-20-iscepcrypto-pq-seam-validation.md b/docs/superpowers/analysis/2026-06-20-iscepcrypto-pq-seam-validation.md new file mode 100644 index 0000000..e023299 --- /dev/null +++ b/docs/superpowers/analysis/2026-06-20-iscepcrypto-pq-seam-validation.md @@ -0,0 +1,63 @@ +# IScepCrypto seam validation vs PQ tiers (Phase 4 pre-flight) + +Date: 2026-06-20. Re-validates the Phase-1 on-paper conclusion before writing PQ code. +Reference interface: `src/ScepTestClient.CryptoApi/IScepCrypto.cs` (unchanged by Phase 4). + +## Conclusion: additive-only. No IScepCrypto signature changes. + +Every PQ tier slots in through the three seams designed in Phase 1 — open OID identifiers + +capability advertisement, opaque `IScepKey` handles, and extensible domain objects. The +`IScepCrypto` interface itself is **not** modified. The only new public surface is additive +(new properties / new registry entries) that existing and external callers ignore. + +## Tier A — PQ end-entity key (ML-DSA / SLH-DSA) + +- `KeySpec.Parse` gains `ml-dsa:65` / `slh-dsa:128s` (new accepted inputs + a new `Parameter` + property; existing `rsa:` callers unaffected, `Size` keeps working for RSA). +- `IScepCrypto.GenerateKey(KeySpec, out IScepKey, out string)` — **unchanged signature**; returns an + `IScepKey` whose `AlgorithmOid` is the PQ OID. +- `IScepCrypto.EncodeCsr(Pkcs10, ...)` — **unchanged**; the provider emits a PQ + `SubjectPublicKeyInfo` (`PqcSubjectPublicKeyInfoFactory`) and signs with the PQ signer. +- `ExportPrivateKeyPkcs8` / `ImportPrivateKeyPkcs8` — **unchanged**; PQ via + `PqcPrivateKeyInfoFactory` / `PrivateKeyFactory`. +- BC 2.5.0: **FEASIBLE** (`MLDsaKeyPairGenerator`, `SlhDsaKeyPairGenerator`, `MLDsaParameters`, + `SlhDsaParameters`, the Pqc SPKI/PKCS#8 factories all present). + +## Tier B — Catalyst / hybrid alt-key (subjectAltPublicKeyInfo) + +- New additive `Pkcs10.AltKey` (`IScepKey?`) property, defaulting null, ignored by existing callers. +- `EncodeCsr` emits the `subjectAltPublicKeyInfo` extension (OID `2.5.29.72`) carrying the alt + key's `SubjectPublicKeyInfo`. +- **No interface change.** +- BC 2.5.0: emitting the alt PUBLIC KEY is **FEASIBLE**; computing a conformant + `altSignatureValue` over the CSR is bleeding-edge and is **NOT** done by the built-in provider — + recorded as a documented limitation (surfaced via this doc + `crypto info`). + +## Tier C — PQ transport (ML-KEM EnvelopedData, RFC 9629 KEMRecipientInfo) + +- `EncodePkiMessage` already takes the recipient via `PkiMessage.RecipientCaCert` — a PQ recipient + WOULD trigger `KEMRecipientInfo` inside the provider. **No signature change.** +- BC 2.5.0: **NOT FEASIBLE.** The CMS layer exposes only KeyTrans / Kek / KeyAgree / Password + recipient generators; `KemRecipientInfo` exists only as a raw ASN.1 type with no CMS generator, + and the `MLKemKeyPairGenerator` / `*KemGenerator` types are low-level KEM primitives not wired + into `CmsEnvelopedDataGenerator`. The built-in provider therefore advertises + `CryptoCapabilities.PqTiers.TierC = false`; an external provider can implement it through the same + seam without an interface change. + +## Composite signatures + +- BC 2.5.0 carries only the legacy Dilithium-draft composite OIDs (`id_Dilithium3_ECDSA_P256_SHA256`, + etc.) and the bare `id_composite_key` OID — there is no current ML-DSA composite signing path. +- Scope: composite stays **vocabulary + opinion** (classified `CuttingEdge`); no BC signing + implementation in Phase 4. + +## New additive surface introduced by Phase 4 + +- `KeySpec.Parameter` (string); `KeySpec.Parse` PQ inputs. +- `CryptoCapabilities.PqTiers` (`TierA`/`TierB`/`TierC` bools) + PQ OIDs in + `Signatures` / `AsymmetricKeys` / `Kem`. +- `Pkcs10.AltKey` (`IScepKey?`). +- `Algorithms` registry: ML-DSA-44/65/87, SLH-DSA-128s/192s/256s, ML-KEM-512/768/1024 entries. + +None of the above alters an existing method signature; all are new members or new accepted input +values. The seam validated in Phase 1 holds. From 2cbc04c7c0b6618e28de61f4f8b80680d8c7df95 Mon Sep 17 00:00:00 2001 From: Peter Dennis Bartok Date: Sat, 20 Jun 2026 13:12:33 -0600 Subject: [PATCH 03/16] Phase 4: add ML-DSA/SLH-DSA/ML-KEM entries to the algorithm registry Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01DBF7SxuRaJgFEMv5TdznH1 --- .../BcAlgorithms.cs | 9 +++++++ src/ScepTestClient.CryptoApi/Algorithms.cs | 9 +++++++ .../ScepTestClient.Tests/PqAlgorithmsTests.cs | 25 +++++++++++++++++++ 3 files changed, 43 insertions(+) create mode 100644 tests/ScepTestClient.Tests/PqAlgorithmsTests.cs diff --git a/src/ScepTestClient.Crypto.BouncyCastle/BcAlgorithms.cs b/src/ScepTestClient.Crypto.BouncyCastle/BcAlgorithms.cs index fa57353..d596871 100644 --- a/src/ScepTestClient.Crypto.BouncyCastle/BcAlgorithms.cs +++ b/src/ScepTestClient.Crypto.BouncyCastle/BcAlgorithms.cs @@ -9,4 +9,13 @@ internal static class BcAlgorithms { public const string Aes128Cbc = "2.16.840.1.101.3.4.1.2"; public const string Aes256Cbc = "2.16.840.1.101.3.4.1.42"; public const string Des3Cbc = "1.2.840.113549.3.7"; + public const string MlDsa44 = "2.16.840.1.101.3.4.3.17"; + public const string MlDsa65 = "2.16.840.1.101.3.4.3.18"; + public const string MlDsa87 = "2.16.840.1.101.3.4.3.19"; + public const string SlhDsa128s = "2.16.840.1.101.3.4.3.20"; + public const string SlhDsa192s = "2.16.840.1.101.3.4.3.21"; + public const string SlhDsa256s = "2.16.840.1.101.3.4.3.22"; + public const string MlKem512 = "2.16.840.1.101.3.4.4.1"; + public const string MlKem768 = "2.16.840.1.101.3.4.4.2"; + public const string MlKem1024 = "2.16.840.1.101.3.4.4.3"; } diff --git a/src/ScepTestClient.CryptoApi/Algorithms.cs b/src/ScepTestClient.CryptoApi/Algorithms.cs index ddc031b..9d08e51 100644 --- a/src/ScepTestClient.CryptoApi/Algorithms.cs +++ b/src/ScepTestClient.CryptoApi/Algorithms.cs @@ -15,6 +15,15 @@ public static class Algorithms { new("AES-256-CBC", "2.16.840.1.101.3.4.1.42", AlgorithmKind.ContentEncryption), new("DES-EDE3-CBC","1.2.840.113549.3.7", AlgorithmKind.ContentEncryption), new("RSA", "1.2.840.113549.1.1.1", AlgorithmKind.AsymmetricKey), + new("ML-DSA-44", "2.16.840.1.101.3.4.3.17", AlgorithmKind.Signature), + new("ML-DSA-65", "2.16.840.1.101.3.4.3.18", AlgorithmKind.Signature), + new("ML-DSA-87", "2.16.840.1.101.3.4.3.19", AlgorithmKind.Signature), + new("SLH-DSA-128s","2.16.840.1.101.3.4.3.20", AlgorithmKind.Signature), + new("SLH-DSA-192s","2.16.840.1.101.3.4.3.21", AlgorithmKind.Signature), + new("SLH-DSA-256s","2.16.840.1.101.3.4.3.22", AlgorithmKind.Signature), + new("ML-KEM-512", "2.16.840.1.101.3.4.4.1", AlgorithmKind.Kem), + new("ML-KEM-768", "2.16.840.1.101.3.4.4.2", AlgorithmKind.Kem), + new("ML-KEM-1024", "2.16.840.1.101.3.4.4.3", AlgorithmKind.Kem), }; private static readonly Dictionary ByName = BuildIndex(static e => e.Name); diff --git a/tests/ScepTestClient.Tests/PqAlgorithmsTests.cs b/tests/ScepTestClient.Tests/PqAlgorithmsTests.cs new file mode 100644 index 0000000..75d5360 --- /dev/null +++ b/tests/ScepTestClient.Tests/PqAlgorithmsTests.cs @@ -0,0 +1,25 @@ +using ScepTestClient.CryptoApi; +using Xunit; + +namespace ScepTestClient.Tests; + +public sealed class PqAlgorithmsTests { + [Theory] + [InlineData("ML-DSA-44", "2.16.840.1.101.3.4.3.17", AlgorithmKind.Signature)] + [InlineData("ML-DSA-65", "2.16.840.1.101.3.4.3.18", AlgorithmKind.Signature)] + [InlineData("ML-DSA-87", "2.16.840.1.101.3.4.3.19", AlgorithmKind.Signature)] + [InlineData("ML-KEM-512", "2.16.840.1.101.3.4.4.1", AlgorithmKind.Kem)] + [InlineData("ML-KEM-768", "2.16.840.1.101.3.4.4.2", AlgorithmKind.Kem)] + [InlineData("ML-KEM-1024", "2.16.840.1.101.3.4.4.3", AlgorithmKind.Kem)] + public void Pq_entries_resolve(string name, string oid, AlgorithmKind kind) { + Assert.Equal(oid, Algorithms.OidFor(name)); + Assert.Equal(name, Algorithms.NameFor(oid)); + Assert.Equal(kind, Algorithms.KindOf(oid)); + } + + [Fact] + public void Existing_entries_unchanged() { + Assert.Equal("2.16.840.1.101.3.4.2.1", Algorithms.OidFor("SHA-256")); + Assert.Equal("1.2.840.113549.1.1.1", Algorithms.OidFor("RSA")); + } +} From 32eb7b2394e530382f1ad7a47e5c7c1af1b842df Mon Sep 17 00:00:00 2001 From: Peter Dennis Bartok Date: Sat, 20 Jun 2026 13:14:40 -0600 Subject: [PATCH 04/16] Phase 4: KeySpec.Parse accepts ml-dsa/slh-dsa/ml-kem parameter sets --- src/ScepTestClient.CryptoApi/KeySpec.cs | 58 +++++++++++++++++--- tests/ScepTestClient.Tests/PqKeySpecTests.cs | 50 +++++++++++++++++ 2 files changed, 100 insertions(+), 8 deletions(-) create mode 100644 tests/ScepTestClient.Tests/PqKeySpecTests.cs diff --git a/src/ScepTestClient.CryptoApi/KeySpec.cs b/src/ScepTestClient.CryptoApi/KeySpec.cs index 1a42060..dcc4507 100644 --- a/src/ScepTestClient.CryptoApi/KeySpec.cs +++ b/src/ScepTestClient.CryptoApi/KeySpec.cs @@ -1,18 +1,26 @@ namespace ScepTestClient.CryptoApi; public sealed class KeySpec { + private static readonly string[] MlDsaSets = { "44", "65", "87" }; + private static readonly string[] SlhDsaSets = { "128s", "192s", "256s", "128f", "192f", "256f" }; + private static readonly string[] MlKemSets = { "512", "768", "1024" }; + public string Algorithm { get; } public int Size { get; } + public string Parameter { get; } public string Raw { get; } - private KeySpec(string algorithm, int size, string raw) { + private KeySpec(string algorithm, int size, string parameter, string raw) { Algorithm = algorithm; Size = size; + Parameter = parameter; Raw = raw; } public static bool Parse(string text, out KeySpec spec, out string error) { string[] parts; + string algo; + string param; int bits; spec = null!; @@ -24,17 +32,51 @@ public static bool Parse(string text, out KeySpec spec, out string error) { } parts = text.Split(':'); - if (parts.Length != 2 || !parts[0].Equals("rsa", System.StringComparison.OrdinalIgnoreCase)) { - error = $"unsupported key spec '{text}' (expected 'rsa:')"; + if (parts.Length != 2) { + error = $"unsupported key spec '{text}' (expected 'rsa:' / 'ml-dsa:' / 'slh-dsa:')"; return false; } - if (!int.TryParse(parts[1], out bits) || bits < 1024) { - error = $"invalid RSA size in '{text}'"; - return false; + algo = parts[0].ToLowerInvariant(); + param = parts[1]; + + if (algo == "rsa") { + if (!int.TryParse(param, out bits) || bits < 1024) { + error = $"invalid RSA size in '{text}'"; + return false; + } + spec = new KeySpec("RSA", bits, string.Empty, text); + return true; + } + + if (algo == "ml-dsa") { + if (System.Array.IndexOf(MlDsaSets, param) < 0) { + error = $"invalid ML-DSA parameter set '{param}' (expected 44/65/87)"; + return false; + } + spec = new KeySpec("ML-DSA", 0, param, text); + return true; + } + + if (algo == "slh-dsa") { + if (System.Array.IndexOf(SlhDsaSets, param) < 0) { + error = $"invalid SLH-DSA parameter set '{param}'"; + return false; + } + spec = new KeySpec("SLH-DSA", 0, param, text); + return true; + } + + if (algo == "ml-kem") { + if (System.Array.IndexOf(MlKemSets, param) < 0) { + error = $"invalid ML-KEM parameter set '{param}' (expected 512/768/1024)"; + return false; + } + spec = new KeySpec("ML-KEM", 0, param, text); + return true; } - spec = new KeySpec("RSA", bits, text); - return true; + error = $"unsupported key spec '{text}'"; + return false; } } diff --git a/tests/ScepTestClient.Tests/PqKeySpecTests.cs b/tests/ScepTestClient.Tests/PqKeySpecTests.cs new file mode 100644 index 0000000..013d048 --- /dev/null +++ b/tests/ScepTestClient.Tests/PqKeySpecTests.cs @@ -0,0 +1,50 @@ +using ScepTestClient.CryptoApi; +using Xunit; + +namespace ScepTestClient.Tests; + +public sealed class PqKeySpecTests { + [Fact] + public void Parses_ml_dsa() { + KeySpec spec; + string error; + + Assert.True(KeySpec.Parse("ml-dsa:65", out spec, out error)); + Assert.Equal("ML-DSA", spec.Algorithm); + Assert.Equal("65", spec.Parameter); + Assert.Equal(0, spec.Size); + } + + [Fact] + public void Parses_slh_dsa() { + KeySpec spec; + string error; + + Assert.True(KeySpec.Parse("slh-dsa:128s", out spec, out error)); + Assert.Equal("SLH-DSA", spec.Algorithm); + Assert.Equal("128s", spec.Parameter); + } + + [Fact] + public void Rsa_unchanged() { + KeySpec spec; + string error; + + Assert.True(KeySpec.Parse("rsa:2048", out spec, out error)); + Assert.Equal("RSA", spec.Algorithm); + Assert.Equal(2048, spec.Size); + Assert.Equal(string.Empty, spec.Parameter); + } + + [Theory] + [InlineData("ec:p256")] + [InlineData("ml-dsa:99")] + [InlineData("slh-dsa:bogus")] + public void Rejects_bad(string text) { + KeySpec spec; + string error; + + Assert.False(KeySpec.Parse(text, out spec, out error)); + Assert.NotEqual(string.Empty, error); + } +} From 264553585612d28956141acec8a7d1db428beb9d Mon Sep 17 00:00:00 2001 From: Peter Dennis Bartok Date: Sat, 20 Jun 2026 13:16:53 -0600 Subject: [PATCH 05/16] Phase 4: advertise PQ algorithms and tiers (A/B yes, C no) via CryptoCapabilities Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01DBF7SxuRaJgFEMv5TdznH1 --- .../BouncyCastleScepCrypto.cs | 8 +++-- .../CryptoCapabilities.cs | 1 + src/ScepTestClient.CryptoApi/PqTiers.cs | 5 +++ .../PqCapabilitiesTests.cs | 32 +++++++++++++++++++ 4 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 src/ScepTestClient.CryptoApi/PqTiers.cs create mode 100644 tests/ScepTestClient.Tests/PqCapabilitiesTests.cs diff --git a/src/ScepTestClient.Crypto.BouncyCastle/BouncyCastleScepCrypto.cs b/src/ScepTestClient.Crypto.BouncyCastle/BouncyCastleScepCrypto.cs index 0fe0386..d24989d 100644 --- a/src/ScepTestClient.Crypto.BouncyCastle/BouncyCastleScepCrypto.cs +++ b/src/ScepTestClient.Crypto.BouncyCastle/BouncyCastleScepCrypto.cs @@ -15,10 +15,14 @@ public sealed class BouncyCastleScepCrypto : IScepCrypto { public CryptoCapabilities Capabilities { get; } = new CryptoCapabilities { Digests = new[] { BcAlgorithms.Sha1, BcAlgorithms.Sha256, BcAlgorithms.Sha512, BcAlgorithms.Md5 }, - Signatures = new[] { BcAlgorithms.Rsa }, + Signatures = new[] { BcAlgorithms.Rsa, BcAlgorithms.MlDsa44, BcAlgorithms.MlDsa65, BcAlgorithms.MlDsa87, + BcAlgorithms.SlhDsa128s, BcAlgorithms.SlhDsa192s, BcAlgorithms.SlhDsa256s }, ContentEncryption = new[] { BcAlgorithms.Aes128Cbc, BcAlgorithms.Aes256Cbc, BcAlgorithms.Des3Cbc }, KeyTransport = new[] { BcAlgorithms.Rsa }, - AsymmetricKeys = new[] { BcAlgorithms.Rsa }, + Kem = new[] { BcAlgorithms.MlKem512, BcAlgorithms.MlKem768, BcAlgorithms.MlKem1024 }, + AsymmetricKeys = new[] { BcAlgorithms.Rsa, BcAlgorithms.MlDsa44, BcAlgorithms.MlDsa65, BcAlgorithms.MlDsa87, + BcAlgorithms.SlhDsa128s, BcAlgorithms.SlhDsa192s, BcAlgorithms.SlhDsa256s }, + PqTiers = new PqTiers(TierA: true, TierB: true, TierC: false), }; public bool GenerateKey(KeySpec spec, out IScepKey key, out string error) { diff --git a/src/ScepTestClient.CryptoApi/CryptoCapabilities.cs b/src/ScepTestClient.CryptoApi/CryptoCapabilities.cs index cefece4..ff746bb 100644 --- a/src/ScepTestClient.CryptoApi/CryptoCapabilities.cs +++ b/src/ScepTestClient.CryptoApi/CryptoCapabilities.cs @@ -9,4 +9,5 @@ public sealed class CryptoCapabilities { public IReadOnlyCollection KeyTransport { get; init; } = System.Array.Empty(); public IReadOnlyCollection Kem { get; init; } = System.Array.Empty(); public IReadOnlyCollection AsymmetricKeys { get; init; } = System.Array.Empty(); + public PqTiers PqTiers { get; init; } = new PqTiers(); } diff --git a/src/ScepTestClient.CryptoApi/PqTiers.cs b/src/ScepTestClient.CryptoApi/PqTiers.cs new file mode 100644 index 0000000..de53993 --- /dev/null +++ b/src/ScepTestClient.CryptoApi/PqTiers.cs @@ -0,0 +1,5 @@ +namespace ScepTestClient.CryptoApi; + +// PQ tier support advertised by a provider (spec §3.4, §14). Additive — defaults to all-false so +// existing/external CryptoCapabilities consumers are unaffected. +public sealed record PqTiers(bool TierA = false, bool TierB = false, bool TierC = false); diff --git a/tests/ScepTestClient.Tests/PqCapabilitiesTests.cs b/tests/ScepTestClient.Tests/PqCapabilitiesTests.cs new file mode 100644 index 0000000..dcec248 --- /dev/null +++ b/tests/ScepTestClient.Tests/PqCapabilitiesTests.cs @@ -0,0 +1,32 @@ +using System.Linq; +using ScepTestClient.Crypto.BouncyCastle; +using ScepTestClient.CryptoApi; +using Xunit; + +namespace ScepTestClient.Tests; + +public sealed class PqCapabilitiesTests { + [Fact] + public void Default_pqtiers_all_false() { + CryptoCapabilities caps; + + caps = new CryptoCapabilities(); + Assert.False(caps.PqTiers.TierA); + Assert.False(caps.PqTiers.TierB); + Assert.False(caps.PqTiers.TierC); + } + + [Fact] + public void Bc_provider_advertises_pq() { + BouncyCastleScepCrypto crypto; + + crypto = new BouncyCastleScepCrypto(); + Assert.True(crypto.Capabilities.PqTiers.TierA); + Assert.True(crypto.Capabilities.PqTiers.TierB); + Assert.False(crypto.Capabilities.PqTiers.TierC); + Assert.Contains("2.16.840.1.101.3.4.3.18", crypto.Capabilities.Signatures); + Assert.Contains("2.16.840.1.101.3.4.3.18", crypto.Capabilities.AsymmetricKeys); + Assert.Contains("2.16.840.1.101.3.4.4.2", crypto.Capabilities.Kem); + Assert.Contains("1.2.840.113549.1.1.1", crypto.Capabilities.AsymmetricKeys); + } +} From 7ec1fb4430dcdf936f861f1d79c553db57fd9d57 Mon Sep 17 00:00:00 2001 From: Peter Dennis Bartok Date: Sat, 20 Jun 2026 13:42:48 -0600 Subject: [PATCH 06/16] =?UTF-8?q?Phase=204:=20tier=20A=20=E2=80=94=20ML-DS?= =?UTF-8?q?A/SLH-DSA=20key=20generation=20+=20PKCS#8=20round-trip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01DBF7SxuRaJgFEMv5TdznH1 --- .../BcPqKeys.cs | 144 ++++++++++++++++++ .../BouncyCastleScepCrypto.cs | 30 ++++ tests/ScepTestClient.Tests/PqKeyGenTests.cs | 43 ++++++ 3 files changed, 217 insertions(+) create mode 100644 src/ScepTestClient.Crypto.BouncyCastle/BcPqKeys.cs create mode 100644 tests/ScepTestClient.Tests/PqKeyGenTests.cs diff --git a/src/ScepTestClient.Crypto.BouncyCastle/BcPqKeys.cs b/src/ScepTestClient.Crypto.BouncyCastle/BcPqKeys.cs new file mode 100644 index 0000000..0057e1a --- /dev/null +++ b/src/ScepTestClient.Crypto.BouncyCastle/BcPqKeys.cs @@ -0,0 +1,144 @@ +using System; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.Crypto.Parameters; +using Org.BouncyCastle.Security; +using ScepTestClient.CryptoApi; + +namespace ScepTestClient.Crypto.BouncyCastle; + +// Post-quantum (tier A) key generation and PKCS#8 import helpers, kept separate +// from BouncyCastleScepCrypto to keep the RSA paths readable. +internal static class BcPqKeys { + // Returns true and sets pair/oid_name for an ML-DSA or SLH-DSA spec. + // Returns false with an EMPTY error when the spec is not PQ (caller falls through to RSA). + // Returns false WITH an error when a PQ algorithm is recognized but the parameter set is unsupported. + public static bool TryGenerate(KeySpec spec, SecureRandom random, out AsymmetricCipherKeyPair pair, out string oid_name, out string error) { + pair = null!; + oid_name = string.Empty; + error = string.Empty; + + if (spec.Algorithm.Equals("ML-DSA", StringComparison.OrdinalIgnoreCase)) { + MLDsaParameters parameters; + MLDsaKeyPairGenerator generator; + + switch (spec.Parameter) { + case "44": + parameters = MLDsaParameters.ml_dsa_44; + oid_name = "ML-DSA-44"; + break; + case "65": + parameters = MLDsaParameters.ml_dsa_65; + oid_name = "ML-DSA-65"; + break; + case "87": + parameters = MLDsaParameters.ml_dsa_87; + oid_name = "ML-DSA-87"; + break; + default: + error = $"unsupported ML-DSA parameter set '{spec.Parameter}'"; + return false; + } + + generator = new MLDsaKeyPairGenerator(); + generator.Init(new MLDsaKeyGenerationParameters(random, parameters)); + pair = generator.GenerateKeyPair(); + return true; + } + + if (spec.Algorithm.Equals("SLH-DSA", StringComparison.OrdinalIgnoreCase)) { + SlhDsaParameters parameters; + SlhDsaKeyPairGenerator generator; + + switch (spec.Parameter) { + case "128s": + parameters = SlhDsaParameters.slh_dsa_sha2_128s; + oid_name = "SLH-DSA-128s"; + break; + case "192s": + parameters = SlhDsaParameters.slh_dsa_sha2_192s; + oid_name = "SLH-DSA-192s"; + break; + case "256s": + parameters = SlhDsaParameters.slh_dsa_sha2_256s; + oid_name = "SLH-DSA-256s"; + break; + default: + error = $"unsupported SLH-DSA parameter set '{spec.Parameter}'"; + return false; + } + + generator = new SlhDsaKeyPairGenerator(); + generator.Init(new SlhDsaKeyGenerationParameters(random, parameters)); + pair = generator.GenerateKeyPair(); + return true; + } + + return false; + } + + // If priv is an ML-DSA / SLH-DSA private key, derive its public key, build the keypair, + // and set oid_name from the parameter set. Returns false with an EMPTY error when priv is + // not a PQ type (caller falls through to the RSA import path). + public static bool TryImport(AsymmetricKeyParameter priv, out AsymmetricCipherKeyPair pair, out string oid_name, out string error) { + pair = null!; + oid_name = string.Empty; + error = string.Empty; + + if (priv is MLDsaPrivateKeyParameters ml_priv) { + MLDsaPublicKeyParameters ml_pub; + + ml_pub = ml_priv.GetPublicKey(); + oid_name = MlDsaNameFor(ml_priv.Parameters); + if (oid_name.Length == 0) { + error = $"unsupported ML-DSA parameter set '{ml_priv.Parameters.Name}'"; + return false; + } + pair = new AsymmetricCipherKeyPair(ml_pub, ml_priv); + return true; + } + + if (priv is SlhDsaPrivateKeyParameters slh_priv) { + SlhDsaPublicKeyParameters slh_pub; + + slh_pub = slh_priv.GetPublicKey(); + oid_name = SlhDsaNameFor(slh_priv.Parameters); + if (oid_name.Length == 0) { + error = $"unsupported SLH-DSA parameter set '{slh_priv.Parameters.Name}'"; + return false; + } + pair = new AsymmetricCipherKeyPair(slh_pub, slh_priv); + return true; + } + + return false; + } + + // Map a BC ML-DSA parameter set instance to the registry-friendly name. + private static string MlDsaNameFor(MLDsaParameters parameters) { + if (ReferenceEquals(parameters, MLDsaParameters.ml_dsa_44)) { + return "ML-DSA-44"; + } + if (ReferenceEquals(parameters, MLDsaParameters.ml_dsa_65)) { + return "ML-DSA-65"; + } + if (ReferenceEquals(parameters, MLDsaParameters.ml_dsa_87)) { + return "ML-DSA-87"; + } + return string.Empty; + } + + // Map a BC SLH-DSA parameter set instance to the registry-friendly name. + private static string SlhDsaNameFor(SlhDsaParameters parameters) { + if (ReferenceEquals(parameters, SlhDsaParameters.slh_dsa_sha2_128s)) { + return "SLH-DSA-128s"; + } + if (ReferenceEquals(parameters, SlhDsaParameters.slh_dsa_sha2_192s)) { + return "SLH-DSA-192s"; + } + if (ReferenceEquals(parameters, SlhDsaParameters.slh_dsa_sha2_256s)) { + return "SLH-DSA-256s"; + } + return string.Empty; + } +} diff --git a/src/ScepTestClient.Crypto.BouncyCastle/BouncyCastleScepCrypto.cs b/src/ScepTestClient.Crypto.BouncyCastle/BouncyCastleScepCrypto.cs index d24989d..2ffc603 100644 --- a/src/ScepTestClient.Crypto.BouncyCastle/BouncyCastleScepCrypto.cs +++ b/src/ScepTestClient.Crypto.BouncyCastle/BouncyCastleScepCrypto.cs @@ -28,10 +28,27 @@ public sealed class BouncyCastleScepCrypto : IScepCrypto { public bool GenerateKey(KeySpec spec, out IScepKey key, out string error) { RsaKeyPairGenerator generator; AsymmetricCipherKeyPair pair; + AsymmetricCipherKeyPair pq_pair; + string pq_oid_name; + string pq_error; key = null!; error = string.Empty; + try { + if (BcPqKeys.TryGenerate(spec, _random, out pq_pair, out pq_oid_name, out pq_error)) { + key = new BcKey(pq_pair, Algorithms.OidFor(pq_oid_name)!, 0); + return true; + } + } catch (Exception ex) { + error = $"{spec.Algorithm} key generation failed: {ex.Message}"; + return false; + } + if (pq_error.Length > 0) { + error = pq_error; + return false; + } + if (!spec.Algorithm.Equals("RSA", StringComparison.OrdinalIgnoreCase)) { error = $"provider does not support key algorithm '{spec.Algorithm}'"; return false; @@ -159,6 +176,8 @@ public bool ExportPrivateKeyPkcs8(IScepKey key, out byte[] der, out string error } try { + // The mainline PrivateKeyInfoFactory handles both RSA and the BC 2.5.0 + // Org.BouncyCastle.Crypto.Parameters ML-DSA / SLH-DSA key types. der = Org.BouncyCastle.Pkcs.PrivateKeyInfoFactory.CreatePrivateKeyInfo(bc_key.KeyPair.Private).GetDerEncoded(); return true; } catch (System.Exception ex) { @@ -172,12 +191,23 @@ public bool ImportPrivateKeyPkcs8(byte[] der, out IScepKey key, out string error Org.BouncyCastle.Crypto.Parameters.RsaPrivateCrtKeyParameters rsa_priv; Org.BouncyCastle.Crypto.Parameters.RsaKeyParameters pub; Org.BouncyCastle.Crypto.AsymmetricCipherKeyPair pair; + Org.BouncyCastle.Crypto.AsymmetricCipherKeyPair pq_pair; + string pq_oid_name; + string pq_error; key = null!; error = string.Empty; try { priv = Org.BouncyCastle.Security.PrivateKeyFactory.CreateKey(der); + if (BcPqKeys.TryImport(priv, out pq_pair, out pq_oid_name, out pq_error)) { + key = new BcKey(pq_pair, Algorithms.OidFor(pq_oid_name)!, 0); + return true; + } + if (pq_error.Length > 0) { + error = pq_error; + return false; + } if (priv is not Org.BouncyCastle.Crypto.Parameters.RsaPrivateCrtKeyParameters crt) { error = "only RSA PKCS#8 keys are supported by this provider"; return false; diff --git a/tests/ScepTestClient.Tests/PqKeyGenTests.cs b/tests/ScepTestClient.Tests/PqKeyGenTests.cs new file mode 100644 index 0000000..a0f27fb --- /dev/null +++ b/tests/ScepTestClient.Tests/PqKeyGenTests.cs @@ -0,0 +1,43 @@ +using ScepTestClient.Crypto.BouncyCastle; +using ScepTestClient.CryptoApi; +using Xunit; + +namespace ScepTestClient.Tests; + +public sealed class PqKeyGenTests { + [Theory] + [InlineData("ml-dsa:65", "2.16.840.1.101.3.4.3.18")] + [InlineData("ml-dsa:87", "2.16.840.1.101.3.4.3.19")] + [InlineData("slh-dsa:128s", "2.16.840.1.101.3.4.3.20")] + public void Generates_pq_key(string key_spec, string expected_oid) { + BouncyCastleScepCrypto crypto; + KeySpec spec; + IScepKey key; + string error; + + crypto = new BouncyCastleScepCrypto(); + Assert.True(KeySpec.Parse(key_spec, out spec, out error), error); + Assert.True(crypto.GenerateKey(spec, out key, out error), error); + Assert.Equal(expected_oid, key.AlgorithmOid); + } + + [Fact] + public void Pkcs8_roundtrip_ml_dsa() { + BouncyCastleScepCrypto crypto; + KeySpec spec; + IScepKey key; + IScepKey imported; + byte[] der; + byte[] der2; + string error; + + crypto = new BouncyCastleScepCrypto(); + Assert.True(KeySpec.Parse("ml-dsa:65", out spec, out error), error); + Assert.True(crypto.GenerateKey(spec, out key, out error), error); + Assert.True(crypto.ExportPrivateKeyPkcs8(key, out der, out error), error); + Assert.True(crypto.ImportPrivateKeyPkcs8(der, out imported, out error), error); + Assert.Equal(key.AlgorithmOid, imported.AlgorithmOid); + Assert.True(crypto.ExportPrivateKeyPkcs8(imported, out der2, out error), error); + Assert.Equal(der, der2); + } +} From 46bf49b8e73c68ae03bfc3ed9d71ad498a1e46f1 Mon Sep 17 00:00:00 2001 From: Peter Dennis Bartok Date: Sat, 20 Jun 2026 13:48:22 -0600 Subject: [PATCH 07/16] =?UTF-8?q?Phase=204:=20tier=20A=20=E2=80=94=20encod?= =?UTF-8?q?e=20PQ=20CSR=20with=20PQ=20SubjectPublicKeyInfo=20and=20signatu?= =?UTF-8?q?re?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01DBF7SxuRaJgFEMv5TdznH1 --- .../BcCsrBuilder.cs | 4 ++- .../BcPqKeys.cs | 32 +++++++++++++++++++ tests/ScepTestClient.Tests/PqCsrTests.cs | 32 +++++++++++++++++++ 3 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 tests/ScepTestClient.Tests/PqCsrTests.cs diff --git a/src/ScepTestClient.Crypto.BouncyCastle/BcCsrBuilder.cs b/src/ScepTestClient.Crypto.BouncyCastle/BcCsrBuilder.cs index ae0964f..78bebd6 100644 --- a/src/ScepTestClient.Crypto.BouncyCastle/BcCsrBuilder.cs +++ b/src/ScepTestClient.Crypto.BouncyCastle/BcCsrBuilder.cs @@ -23,7 +23,9 @@ public static byte[] Build(Pkcs10 csr, BcKey key) { Pkcs10CertificationRequest request; subject = new X509Name(csr.Subject); - signer = new Asn1SignatureFactory("SHA256WITHRSA", key.KeyPair.Private); + signer = BcPqKeys.IsPq(key) + ? BcPqKeys.SignatureFactory(key) + : new Asn1SignatureFactory("SHA256WITHRSA", key.KeyPair.Private); attributes = new List(); if (!string.IsNullOrEmpty(csr.ChallengePassword)) { diff --git a/src/ScepTestClient.Crypto.BouncyCastle/BcPqKeys.cs b/src/ScepTestClient.Crypto.BouncyCastle/BcPqKeys.cs index 0057e1a..d61597e 100644 --- a/src/ScepTestClient.Crypto.BouncyCastle/BcPqKeys.cs +++ b/src/ScepTestClient.Crypto.BouncyCastle/BcPqKeys.cs @@ -1,6 +1,7 @@ using System; using Org.BouncyCastle.Crypto; using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.Crypto.Operators; using Org.BouncyCastle.Crypto.Parameters; using Org.BouncyCastle.Security; using ScepTestClient.CryptoApi; @@ -114,6 +115,37 @@ public static bool TryImport(AsymmetricKeyParameter priv, out AsymmetricCipherKe return false; } + // True when the key is a post-quantum (ML-DSA / SLH-DSA) key. Detected via the + // private key's runtime type, which is the most robust signal across import/keygen paths. + public static bool IsPq(BcKey key) { + return key.KeyPair.Private is MLDsaPrivateKeyParameters || key.KeyPair.Private is SlhDsaPrivateKeyParameters; + } + + // Build a PQ ISignatureFactory for the key, using the friendly algorithm name + // (e.g. "ML-DSA-65", "SLH-DSA-128s") that BC's Asn1SignatureFactory recognizes. + // The resulting factory emits the correct PQ AlgorithmIdentifier for the SPKI/CSR. + public static ISignatureFactory SignatureFactory(BcKey key) { + string algorithm_name; + + algorithm_name = NameFor(key.KeyPair.Private); + if (algorithm_name.Length == 0) { + throw new InvalidOperationException("private key is not a supported post-quantum type"); + } + + return new Asn1SignatureFactory(algorithm_name, key.KeyPair.Private, new SecureRandom()); + } + + // Resolve the friendly signature algorithm name from a PQ private key's parameter set. + private static string NameFor(AsymmetricKeyParameter priv) { + if (priv is MLDsaPrivateKeyParameters ml_priv) { + return MlDsaNameFor(ml_priv.Parameters); + } + if (priv is SlhDsaPrivateKeyParameters slh_priv) { + return SlhDsaNameFor(slh_priv.Parameters); + } + return string.Empty; + } + // Map a BC ML-DSA parameter set instance to the registry-friendly name. private static string MlDsaNameFor(MLDsaParameters parameters) { if (ReferenceEquals(parameters, MLDsaParameters.ml_dsa_44)) { diff --git a/tests/ScepTestClient.Tests/PqCsrTests.cs b/tests/ScepTestClient.Tests/PqCsrTests.cs new file mode 100644 index 0000000..eda01c3 --- /dev/null +++ b/tests/ScepTestClient.Tests/PqCsrTests.cs @@ -0,0 +1,32 @@ +using Org.BouncyCastle.Pkcs; +using ScepTestClient.Crypto.BouncyCastle; +using ScepTestClient.CryptoApi; +using Xunit; + +namespace ScepTestClient.Tests; + +public sealed class PqCsrTests { + [Fact] + public void Ml_dsa_csr_parses_and_verifies() { + BouncyCastleScepCrypto crypto; + KeySpec spec; + IScepKey key; + Pkcs10 csr; + byte[] der; + string error; + Pkcs10CertificationRequest parsed; + + crypto = new BouncyCastleScepCrypto(); + Assert.True(KeySpec.Parse("ml-dsa:65", out spec, out error), error); + Assert.True(crypto.GenerateKey(spec, out key, out error), error); + + csr = new Pkcs10 { Key = key, ChallengePassword = "pw" }; + csr.SetSubject("CN=pq-test", out error); + Assert.True(crypto.EncodeCsr(csr, out der, out error), error); + + parsed = new Pkcs10CertificationRequest(der); + Assert.True(parsed.Verify()); + Assert.Contains("2.16.840.1.101.3.4.3.18", + parsed.GetCertificationRequestInfo().SubjectPublicKeyInfo.Algorithm.Algorithm.Id); + } +} From 88407e9c6bb640540d39f875cbaa3c950b99354a Mon Sep 17 00:00:00 2001 From: Peter Dennis Bartok Date: Sat, 20 Jun 2026 13:53:19 -0600 Subject: [PATCH 08/16] =?UTF-8?q?Phase=204:=20tier=20B=20=E2=80=94=20catal?= =?UTF-8?q?yst=20alt-key=20via=20subjectAltPublicKeyInfo=20+=20--alt-key-s?= =?UTF-8?q?pec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01DBF7SxuRaJgFEMv5TdznH1 --- src/ScepTestClient.Cli/CommandRouter.cs | 25 +++++- src/ScepTestClient.Core/EnrollRequest.cs | 1 + src/ScepTestClient.Core/ScepClient.cs | 1 + src/ScepTestClient.Core/ScepRequestBuilder.cs | 4 +- .../BcCsrBuilder.cs | 12 +++ src/ScepTestClient.CryptoApi/Pkcs10.cs | 1 + tests/ScepTestClient.Tests/AltKeyCsrTests.cs | 90 +++++++++++++++++++ 7 files changed, 131 insertions(+), 3 deletions(-) create mode 100644 tests/ScepTestClient.Tests/AltKeyCsrTests.cs diff --git a/src/ScepTestClient.Cli/CommandRouter.cs b/src/ScepTestClient.Cli/CommandRouter.cs index 816f8ff..c1a2332 100644 --- a/src/ScepTestClient.Cli/CommandRouter.cs +++ b/src/ScepTestClient.Cli/CommandRouter.cs @@ -289,6 +289,7 @@ private static int RunGet(string[] args, string data_root, TextWriter output) { string? subject; string? challenge; string? key_spec_str; + string? alt_key_spec_str; string? sid; int verbosity; bool encrypt_keys; @@ -302,6 +303,7 @@ private static int RunGet(string[] args, string data_root, TextWriter output) { string key_spec_error; IScepKey key; string key_error; + IScepKey? alt_key; ScepClient client; string client_error; ScepClientResult create_result; @@ -313,7 +315,7 @@ private static int RunGet(string[] args, string data_root, TextWriter output) { string challenge_error; if (args.Length < 2) { - output.WriteLine("usage: get --subject \"CN=x\" [--challenge ] [--simulator ] [--ndes --ndes-user --ndes-password

[--ndes-admin-url ]] [--key-spec rsa:2048] [--sid ] [-v]"); + output.WriteLine("usage: get --subject \"CN=x\" [--challenge ] [--simulator ] [--ndes --ndes-user --ndes-password

[--ndes-admin-url ]] [--key-spec rsa:2048] [--alt-key-spec ml-dsa:65] [--sid ] [-v]"); return 2; } @@ -321,6 +323,7 @@ private static int RunGet(string[] args, string data_root, TextWriter output) { subject = Opt(args, "--subject"); challenge = null; key_spec_str = Opt(args, "--key-spec") ?? "rsa:2048"; + alt_key_spec_str = Opt(args, "--alt-key-spec"); sid = Opt(args, "--sid"); verbosity = CountFlag(args, "-v"); encrypt_keys = HasFlag(args, "--encrypt-keys"); @@ -369,6 +372,23 @@ private static int RunGet(string[] args, string data_root, TextWriter output) { return 1; } + alt_key = null; + if (!string.IsNullOrWhiteSpace(alt_key_spec_str)) { + KeySpec alt_spec; + string alt_spec_error; + string alt_key_error; + + if (!KeySpec.Parse(alt_key_spec_str!, out alt_spec, out alt_spec_error)) { + output.WriteLine($"invalid alt key spec: {alt_spec_error}"); + return 2; + } + + if (!crypto.GenerateKey(alt_spec, out alt_key, out alt_key_error)) { + output.WriteLine($"alt key generation failed: {alt_key_error}"); + return 1; + } + } + config = new ServerConfig { Id = stored.Id, Url = new Uri(stored.Url), @@ -388,6 +408,7 @@ private static int RunGet(string[] args, string data_root, TextWriter output) { request = new EnrollRequest { Subject = subject, Key = key, + AltKey = alt_key, ChallengePassword = challenge, Sid = sid, }; @@ -938,7 +959,7 @@ private static int WriteUsage(TextWriter output) { output.WriteLine(" servers show "); output.WriteLine(" servers suggest "); output.WriteLine(" getcacaps "); - output.WriteLine(" get --subject \"CN=x\" [--challenge ] [--key-spec rsa:2048] [--sid ] [-v]"); + output.WriteLine(" get --subject \"CN=x\" [--challenge ] [--key-spec rsa:2048] [--alt-key-spec ml-dsa:65] [--sid ] [-v]"); output.WriteLine(" certs list [serverId]"); output.WriteLine(" getcacert "); output.WriteLine(" getnextcacert "); diff --git a/src/ScepTestClient.Core/EnrollRequest.cs b/src/ScepTestClient.Core/EnrollRequest.cs index 22911f5..d874149 100644 --- a/src/ScepTestClient.Core/EnrollRequest.cs +++ b/src/ScepTestClient.Core/EnrollRequest.cs @@ -7,6 +7,7 @@ namespace ScepTestClient.Core; public sealed class EnrollRequest { public required string Subject { get; init; } public required IScepKey Key { get; init; } + public IScepKey? AltKey { get; init; } public string? ChallengePassword { get; init; } public string DigestOid { get; init; } = Algorithms.OidFor("SHA-256")!; public string ContentEncryptionOid { get; init; } = Algorithms.OidFor("AES-128-CBC")!; diff --git a/src/ScepTestClient.Core/ScepClient.cs b/src/ScepTestClient.Core/ScepClient.cs index 5d2c4c8..8e19a2e 100644 --- a/src/ScepTestClient.Core/ScepClient.cs +++ b/src/ScepTestClient.Core/ScepClient.cs @@ -677,6 +677,7 @@ private ScepResult BuildPkiMessage(EnrollRequest request, out Pki csr = new Pkcs10(); csr.SetSubject(request.Subject, out _); csr.Key = request.Key; + csr.AltKey = request.AltKey; csr.ChallengePassword = request.ChallengePassword; csr.Sid = request.Sid; diff --git a/src/ScepTestClient.Core/ScepRequestBuilder.cs b/src/ScepTestClient.Core/ScepRequestBuilder.cs index c0c323a..b7e41ab 100644 --- a/src/ScepTestClient.Core/ScepRequestBuilder.cs +++ b/src/ScepTestClient.Core/ScepRequestBuilder.cs @@ -20,6 +20,7 @@ public sealed class ScepRequestBuilder { private X509Certificate2? _signer_cert; private IScepKey? _signer_key; private IScepKey? _subject_key; + private IScepKey? _alt_key; private string? _key_spec_text; private string? _subject; private string? _sid; @@ -53,6 +54,7 @@ private ScepRequestBuilder(IScepCrypto crypto) { public ScepRequestBuilder Challenge(string challenge) { _challenge = challenge; return this; } public ScepRequestBuilder KeySpec(string key_spec_text) { _key_spec_text = key_spec_text; return this; } public ScepRequestBuilder SubjectKey(IScepKey key) { _subject_key = key; return this; } + public ScepRequestBuilder AltKey(IScepKey key) { _alt_key = key; return this; } public ScepRequestBuilder SignerCertificate(X509Certificate2 cert) { _signer_cert = cert; return this; } public ScepRequestBuilder SignerKey(IScepKey key) { _signer_key = key; return this; } public ScepRequestBuilder IssuerAndSerial(string issuer_name, string serial_hex) { _issuer_name = issuer_name; _serial = serial_hex; return this; } @@ -150,7 +152,7 @@ private bool BuildCsrMessage(IScepKey subject_key, out PkiMessage message, out s return false; } - csr = new Pkcs10 { Key = subject_key, ChallengePassword = _challenge, Sid = _sid }; + csr = new Pkcs10 { Key = subject_key, AltKey = _alt_key, ChallengePassword = _challenge, Sid = _sid }; csr.SetSubject(_subject!, out _); foreach (string dns in _dns_names) { csr.DnsNames.Add(dns); } foreach (string upn in _upns) { csr.Upns.Add(upn); } diff --git a/src/ScepTestClient.Crypto.BouncyCastle/BcCsrBuilder.cs b/src/ScepTestClient.Crypto.BouncyCastle/BcCsrBuilder.cs index 78bebd6..7ac67ba 100644 --- a/src/ScepTestClient.Crypto.BouncyCastle/BcCsrBuilder.cs +++ b/src/ScepTestClient.Crypto.BouncyCastle/BcCsrBuilder.cs @@ -79,6 +79,18 @@ private static X509Extensions BuildExtensions(Pkcs10 csr) { any = true; } + if (csr.AltKey is BcKey alt_bc) { + Org.BouncyCastle.Asn1.X509.SubjectPublicKeyInfo alt_spki; + + // subjectAltPublicKeyInfo (id-ce-subjectAltPublicKeyInfo, 2.5.29.72). Carries the alt public + // key only; the built-in provider does NOT compute altSignatureAlgorithm/altSignatureValue + // (bleeding-edge). Mainline X509.SubjectPublicKeyInfoFactory handles both classical and the + // mainline PQ key types (Pqc.PqcSubjectPublicKeyInfoFactory throws for the latter). + alt_spki = Org.BouncyCastle.X509.SubjectPublicKeyInfoFactory.CreateSubjectPublicKeyInfo(alt_bc.KeyPair.Public); + gen.AddExtension(new DerObjectIdentifier("2.5.29.72"), false, alt_spki); + any = true; + } + return any ? gen.Generate() : null!; } } diff --git a/src/ScepTestClient.CryptoApi/Pkcs10.cs b/src/ScepTestClient.CryptoApi/Pkcs10.cs index f6a13c7..834246d 100644 --- a/src/ScepTestClient.CryptoApi/Pkcs10.cs +++ b/src/ScepTestClient.CryptoApi/Pkcs10.cs @@ -5,6 +5,7 @@ namespace ScepTestClient.CryptoApi; public sealed class Pkcs10 { public string Subject { get; private set; } = string.Empty; public IScepKey? Key { get; set; } + public IScepKey? AltKey { get; set; } public string? ChallengePassword { get; set; } public List DnsNames { get; } = new(); public List Upns { get; } = new(); diff --git a/tests/ScepTestClient.Tests/AltKeyCsrTests.cs b/tests/ScepTestClient.Tests/AltKeyCsrTests.cs new file mode 100644 index 0000000..2021f36 --- /dev/null +++ b/tests/ScepTestClient.Tests/AltKeyCsrTests.cs @@ -0,0 +1,90 @@ +using Org.BouncyCastle.Asn1; +using Org.BouncyCastle.Pkcs; +using ScepTestClient.Crypto.BouncyCastle; +using ScepTestClient.CryptoApi; +using Xunit; + +namespace ScepTestClient.Tests; + +public sealed class AltKeyCsrTests { + [Fact] + public void Alt_key_emits_subject_alt_public_key_info() { + BouncyCastleScepCrypto crypto; + KeySpec rsa_spec; + KeySpec alt_spec; + IScepKey rsa_key; + IScepKey alt_key; + Pkcs10 csr; + byte[] der; + string error; + Pkcs10CertificationRequest parsed; + Org.BouncyCastle.Asn1.X509.X509Extensions? exts; + + crypto = new BouncyCastleScepCrypto(); + Assert.True(KeySpec.Parse("rsa:2048", out rsa_spec, out error), error); + Assert.True(KeySpec.Parse("ml-dsa:65", out alt_spec, out error), error); + Assert.True(crypto.GenerateKey(rsa_spec, out rsa_key, out error), error); + Assert.True(crypto.GenerateKey(alt_spec, out alt_key, out error), error); + + csr = new Pkcs10 { Key = rsa_key, AltKey = alt_key }; + csr.SetSubject("CN=catalyst", out error); + Assert.True(crypto.EncodeCsr(csr, out der, out error), error); + + parsed = new Pkcs10CertificationRequest(der); + Assert.True(parsed.Verify()); + exts = ExtensionsFrom(parsed); + Assert.NotNull(exts!.GetExtension(new DerObjectIdentifier("2.5.29.72"))); + } + + [Fact] + public void No_alt_key_no_extension() { + BouncyCastleScepCrypto crypto; + KeySpec rsa_spec; + IScepKey rsa_key; + Pkcs10 csr; + byte[] der; + string error; + Pkcs10CertificationRequest parsed; + Org.BouncyCastle.Asn1.X509.X509Extensions? exts; + + crypto = new BouncyCastleScepCrypto(); + Assert.True(KeySpec.Parse("rsa:2048", out rsa_spec, out error), error); + Assert.True(crypto.GenerateKey(rsa_spec, out rsa_key, out error), error); + + csr = new Pkcs10 { Key = rsa_key }; + csr.SetSubject("CN=no-alt", out error); + Assert.True(crypto.EncodeCsr(csr, out der, out error), error); + + parsed = new Pkcs10CertificationRequest(der); + exts = ExtensionsFromOrNull(parsed); + if (exts is not null) { + Assert.Null(exts.GetExtension(new DerObjectIdentifier("2.5.29.72"))); + } + } + + private static Org.BouncyCastle.Asn1.X509.X509Extensions ExtensionsFrom(Pkcs10CertificationRequest req) { + Org.BouncyCastle.Asn1.X509.X509Extensions? exts; + + exts = ExtensionsFromOrNull(req); + Assert.NotNull(exts); + return exts!; + } + + private static Org.BouncyCastle.Asn1.X509.X509Extensions? ExtensionsFromOrNull(Pkcs10CertificationRequest req) { + Org.BouncyCastle.Asn1.Pkcs.CertificationRequestInfo info; + Org.BouncyCastle.Asn1.Asn1Set? attrs; + + info = req.GetCertificationRequestInfo(); + attrs = info.Attributes; + if (attrs is null) { return null; } + foreach (Org.BouncyCastle.Asn1.Asn1Encodable enc in attrs) { + Org.BouncyCastle.Asn1.Pkcs.AttributePkcs attr; + + attr = Org.BouncyCastle.Asn1.Pkcs.AttributePkcs.GetInstance(enc); + if (attr.AttrType.Id == "1.2.840.113549.1.9.14") { + return Org.BouncyCastle.Asn1.X509.X509Extensions.GetInstance(attr.AttrValues[0]); + } + } + return null; + } +} From 1b338b4d1c3d4aff3b2a973607982284f13b559f Mon Sep 17 00:00:00 2001 From: Peter Dennis Bartok Date: Sat, 20 Jun 2026 13:56:26 -0600 Subject: [PATCH 09/16] Phase 4: capabilities drive opinion (CuttingEdge) and PQ servers-suggest Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01DBF7SxuRaJgFEMv5TdznH1 --- src/ScepTestClient.Cli/CommandRouter.cs | 5 ++- .../Testing/SecurityOpinion.cs | 11 +++++ .../Testing/ServerSuggest.cs | 9 ++++ .../PqOpinionSuggestTests.cs | 42 +++++++++++++++++++ 4 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 tests/ScepTestClient.Tests/PqOpinionSuggestTests.cs diff --git a/src/ScepTestClient.Cli/CommandRouter.cs b/src/ScepTestClient.Cli/CommandRouter.cs index c1a2332..26375d9 100644 --- a/src/ScepTestClient.Cli/CommandRouter.cs +++ b/src/ScepTestClient.Cli/CommandRouter.cs @@ -129,7 +129,7 @@ private static int RunServersSuggest(string[] args, string data_root, TextWriter server_id = args[2]; if (!BuildClient(server_id, data_root, output, out client, out stored)) { return 1; } caps = client.GetCaCaps().Value ?? ScepCapabilities.Parse(string.Empty); - lines = ScepTestClient.Core.Testing.ServerSuggest.For(server_id, caps); + lines = ScepTestClient.Core.Testing.ServerSuggest.For(server_id, caps, client.Crypto.Capabilities); foreach (string line in lines) { output.WriteLine(line); } config = ClientConfig.Load(data_root); @@ -141,6 +141,9 @@ private static int RunServersSuggest(string[] args, string data_root, TextWriter if (caps.Aes) { output.WriteLine($"posture: AES-128-CBC {ScepTestClient.Core.Testing.SecurityOpinion.ClassifyCipher("AES-128-CBC")}"); } if (caps.Des3) { output.WriteLine($"posture: DES-EDE3-CBC {ScepTestClient.Core.Testing.SecurityOpinion.ClassifyCipher("DES-EDE3-CBC")}"); } output.WriteLine($"posture: RSA-{thresholds.MinRsaKeyBits} {ScepTestClient.Core.Testing.SecurityOpinion.ClassifyRsa(thresholds.MinRsaKeyBits, thresholds)}"); + if (client.Crypto.Capabilities.PqTiers.TierA) { + output.WriteLine($"posture: ML-DSA-65 {ScepTestClient.Core.Testing.SecurityOpinion.ClassifySignature("ML-DSA-65")}"); + } return 0; } diff --git a/src/ScepTestClient.Core/Testing/SecurityOpinion.cs b/src/ScepTestClient.Core/Testing/SecurityOpinion.cs index 23d1450..0abfab8 100644 --- a/src/ScepTestClient.Core/Testing/SecurityOpinion.cs +++ b/src/ScepTestClient.Core/Testing/SecurityOpinion.cs @@ -32,4 +32,15 @@ public static AlgorithmPosture ClassifyRsa(int bits, OpinionThresholds threshold if (bits < thresholds.MinRsaKeyBits) { return AlgorithmPosture.LegacyWeak; } return AlgorithmPosture.Modern; } + + public static AlgorithmPosture ClassifySignature(string name) { + string upper; + + upper = (name ?? string.Empty).ToUpperInvariant(); + if (upper.StartsWith("ML-DSA") || upper.StartsWith("SLH-DSA") || upper.StartsWith("ML-KEM")) { + return AlgorithmPosture.CuttingEdge; + } + if (upper == "RSA") { return AlgorithmPosture.Modern; } + return AlgorithmPosture.Unknown; + } } diff --git a/src/ScepTestClient.Core/Testing/ServerSuggest.cs b/src/ScepTestClient.Core/Testing/ServerSuggest.cs index 111a3c9..edb740c 100644 --- a/src/ScepTestClient.Core/Testing/ServerSuggest.cs +++ b/src/ScepTestClient.Core/Testing/ServerSuggest.cs @@ -1,10 +1,15 @@ using System.Collections.Generic; using ScepTestClient.Core.Protocol; +using ScepTestClient.CryptoApi; namespace ScepTestClient.Core.Testing; public static class ServerSuggest { public static IReadOnlyList For(string server_id, ScepCapabilities caps) { + return For(server_id, caps, new CryptoCapabilities()); + } + + public static IReadOnlyList For(string server_id, ScepCapabilities caps, CryptoCapabilities crypto_caps) { List lines; List digests; List ciphers; @@ -27,6 +32,10 @@ public static IReadOnlyList For(string server_id, ScepCapabilities caps) lines.Add($"sceptest enroll {server_id} --subject \"CN=test\" --key-spec rsa:2048 --digest {digest} --cipher {cipher}"); } } + + if (crypto_caps.PqTiers.TierA) { + lines.Add($"sceptest enroll {server_id} --subject \"CN=test\" --key-spec ml-dsa:65 --digest {digests[0]} --cipher {ciphers[0]}"); + } return lines; } } diff --git a/tests/ScepTestClient.Tests/PqOpinionSuggestTests.cs b/tests/ScepTestClient.Tests/PqOpinionSuggestTests.cs new file mode 100644 index 0000000..21130ee --- /dev/null +++ b/tests/ScepTestClient.Tests/PqOpinionSuggestTests.cs @@ -0,0 +1,42 @@ +using System.Linq; +using ScepTestClient.Core.Protocol; +using ScepTestClient.Core.Testing; +using ScepTestClient.CryptoApi; +using Xunit; + +namespace ScepTestClient.Tests; + +public sealed class PqOpinionSuggestTests { + [Theory] + [InlineData("ML-DSA-65", AlgorithmPosture.CuttingEdge)] + [InlineData("SLH-DSA-128s", AlgorithmPosture.CuttingEdge)] + [InlineData("RSA", AlgorithmPosture.Modern)] + [InlineData("bogus", AlgorithmPosture.Unknown)] + public void Classifies_signatures(string name, AlgorithmPosture expected) { + Assert.Equal(expected, SecurityOpinion.ClassifySignature(name)); + } + + [Fact] + public void Suggest_includes_pq_when_provider_supports_tier_a() { + ScepCapabilities scep_caps; + CryptoCapabilities crypto_caps; + System.Collections.Generic.IReadOnlyList lines; + + scep_caps = ScepCapabilities.Parse("SHA-256\nAES\n"); + crypto_caps = new CryptoCapabilities { PqTiers = new PqTiers(TierA: true) }; + lines = ServerSuggest.For("srv", scep_caps, crypto_caps); + Assert.Contains(lines, l => l.Contains("ml-dsa:65")); + Assert.Contains(lines, l => l.Contains("--digest SHA-256")); + } + + [Fact] + public void Suggest_old_overload_still_works_without_pq() { + ScepCapabilities scep_caps; + System.Collections.Generic.IReadOnlyList lines; + + scep_caps = ScepCapabilities.Parse("SHA-256\nAES\n"); + lines = ServerSuggest.For("srv", scep_caps); + Assert.DoesNotContain(lines, l => l.Contains("ml-dsa:65")); + Assert.Contains(lines, l => l.Contains("--digest SHA-256")); + } +} From 5518ffc52a124e677f13ee99162955aa10729652 Mon Sep 17 00:00:00 2001 From: Peter Dennis Bartok Date: Sat, 20 Jun 2026 14:06:16 -0600 Subject: [PATCH 10/16] Phase 4: empirical PQ probe (ML-DSA enroll -> PASSED/FINDING/FAILED) --- src/ScepTestClient.Core/Testing/TestEngine.cs | 47 +++++++++++++++++-- tests/ScepTestClient.Tests/PqProbeTests.cs | 39 +++++++++++++++ 2 files changed, 82 insertions(+), 4 deletions(-) create mode 100644 tests/ScepTestClient.Tests/PqProbeTests.cs diff --git a/src/ScepTestClient.Core/Testing/TestEngine.cs b/src/ScepTestClient.Core/Testing/TestEngine.cs index 20883a8..abcd45d 100644 --- a/src/ScepTestClient.Core/Testing/TestEngine.cs +++ b/src/ScepTestClient.Core/Testing/TestEngine.cs @@ -81,6 +81,7 @@ public TestReport RunProbe(ScepClient client) { ProbeDigest(report, client, caps); ProbePost(report, client, caps); ProbeGetNextCa(report, client, caps); + ProbePq(report, client); total.Stop(); report.TotalElapsed = total.Elapsed; @@ -177,7 +178,7 @@ private static void ProbeDigest(TestReport report, ScepClient client, ScepCapabi try { ca_result = ResolveCaCert(client); if (ca_result.IsOk) { - worked = SubmitEnrollWithDigest(client, ca_result.Value, "SHA-256"); + worked = SubmitEnrollWithKeySpec(client, ca_result.Value, "rsa:2048", "SHA-256"); } } catch (System.Exception) { worked = false; @@ -214,7 +215,7 @@ private static void ProbePost(TestReport report, ScepClient client, ScepCapabili if (client.Server.PreferPost) { ca_result = ResolveCaCert(client); if (ca_result.IsOk) { - worked = SubmitEnrollWithDigest(client, ca_result.Value, "SHA-256"); + worked = SubmitEnrollWithKeySpec(client, ca_result.Value, "rsa:2048", "SHA-256"); } } } catch (System.Exception) { @@ -272,6 +273,44 @@ private static void ProbeGetNextCa(TestReport report, ScepClient client, ScepCap worked ? PkiStatus.Success : PkiStatus.Failure, why, "RFC 8894 §4.6", sw.Elapsed)); } + // GetCACaps has no PQ keyword, so any ML-DSA success is a FINDING (PQ-capable / under-advertised + // CA); a failure against a classical-only CA is the expected FAILED. Wrapped so it never throws. + private static void ProbePq(TestReport report, ScepClient client) { + Stopwatch sw; + bool worked; + CheckOutcome outcome; + string why; + ScepResult ca_result; + + if (!client.Crypto.Capabilities.PqTiers.TierA) { + report.Results.Add(new CheckResult("probe ML-DSA enrollment", CheckOutcome.Skipped, FailInfo.None, FailInfo.None, + PkiStatus.Failure, "loaded provider does not implement PQ tier A", "spec §14 (empirical PQ probe)", System.TimeSpan.Zero)); + return; + } + + sw = Stopwatch.StartNew(); + worked = false; + try { + ca_result = ResolveCaCert(client); + if (ca_result.IsOk) { + worked = SubmitEnrollWithKeySpec(client, ca_result.Value, "ml-dsa:65", "SHA-256"); + } + } catch (System.Exception) { + worked = false; + } + sw.Stop(); + + if (worked) { + outcome = CheckOutcome.Finding; + why = "ML-DSA enrollment succeeded though GetCACaps advertises no PQ capability (under-advertised / PQ-capable CA)"; + } else { + outcome = CheckOutcome.Failed; + why = "ML-DSA enrollment was not accepted (expected against a classical-only CA)"; + } + report.Results.Add(new CheckResult("probe ML-DSA enrollment", outcome, FailInfo.None, FailInfo.None, + worked ? PkiStatus.Success : PkiStatus.Failure, why, "spec §14 (empirical PQ probe)", sw.Elapsed)); + } + private static ScepResult ResolveCaCert(ScepClient client) { ScepResult> result; @@ -283,7 +322,7 @@ private static ScepResult ResolveCaCert(ScepClient client) { return ScepResult.Ok(result.Value[0]); } - private static bool SubmitEnrollWithDigest(ScepClient client, X509Certificate2 ca_cert, string digest) { + private static bool SubmitEnrollWithKeySpec(ScepClient client, X509Certificate2 ca_cert, string key_spec, string digest) { ScepRequestBuilder builder; PkiMessage message; IScepKey subject_key; @@ -294,7 +333,7 @@ private static bool SubmitEnrollWithDigest(ScepClient client, X509Certificate2 c .CaCertificate(ca_cert) .MessageType(MessageType.PkcsReq) .Subject("CN=probe-" + System.Guid.NewGuid().ToString("N").Substring(0, 8)) - .KeySpec("rsa:2048") + .KeySpec(key_spec) .Digest(digest); if (!builder.Build(out message, out subject_key, out error)) { return false; diff --git a/tests/ScepTestClient.Tests/PqProbeTests.cs b/tests/ScepTestClient.Tests/PqProbeTests.cs new file mode 100644 index 0000000..d6bea68 --- /dev/null +++ b/tests/ScepTestClient.Tests/PqProbeTests.cs @@ -0,0 +1,39 @@ +using System.Linq; +using System.Threading.Tasks; +using ScepTestClient.Core; +using ScepTestClient.Core.Testing; +using ScepTestClient.Crypto.BouncyCastle; +using ScepTestClient.Tests.Fakes; +using Xunit; + +namespace ScepTestClient.Tests; + +public sealed class PqProbeTests { + [Fact] + public async Task Probe_includes_ml_dsa_row() { + FakeScepServer server; + ScepClient client; + TestReport report; + + server = await FakeScepServer.StartAsync(); + try { + client = BuildClientFor(server); + report = new TestEngine().RunProbe(client); + + // Built-in provider supports tier A, so the PQ row must exist and must NOT be Skipped. + Assert.Contains(report.Results, r => r.Name.Contains("ML-DSA")); + Assert.DoesNotContain(report.Results, r => r.Name.Contains("ML-DSA") && r.Outcome == CheckOutcome.Skipped); + } finally { + await server.DisposeAsync(); + } + } + + private static ScepClient BuildClientFor(FakeScepServer server) { + BouncyCastleScepCrypto crypto; + ScepClient client; + + crypto = new BouncyCastleScepCrypto(); + ScepClient.Create(new ServerConfig { Id = "fake", Url = server.ScepUrl, PreferPost = true }, crypto, handler: null, out client, out _); + return client; + } +} From 8f5447b26ab00abaf36400a54da7956708c4de21 Mon Sep 17 00:00:00 2001 From: Peter Dennis Bartok Date: Sat, 20 Jun 2026 14:08:37 -0600 Subject: [PATCH 11/16] Phase 4: crypto info / crypto list CLI Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01DBF7SxuRaJgFEMv5TdznH1 --- src/ScepTestClient.Cli/CommandRouter.cs | 5 ++ src/ScepTestClient.Cli/CryptoCommand.cs | 65 +++++++++++++++++++ .../CryptoCommandTests.cs | 42 ++++++++++++ 3 files changed, 112 insertions(+) create mode 100644 src/ScepTestClient.Cli/CryptoCommand.cs create mode 100644 tests/ScepTestClient.Tests/CryptoCommandTests.cs diff --git a/src/ScepTestClient.Cli/CommandRouter.cs b/src/ScepTestClient.Cli/CommandRouter.cs index 26375d9..be1a960 100644 --- a/src/ScepTestClient.Cli/CommandRouter.cs +++ b/src/ScepTestClient.Cli/CommandRouter.cs @@ -75,6 +75,9 @@ private static int RunInternal(string[] args, string data_root, TextWriter outpu case "config": return RunConfig(args, data_root, output); + case "crypto": + return CryptoCommand.Run(args, data_root, output); + case "--help": case "-h": case "help": @@ -974,6 +977,8 @@ private static int WriteUsage(TextWriter output) { output.WriteLine(" test [--report-format junit|trx|json|md] [--jamf-max-wait ]"); output.WriteLine(" run [--report-format junit|trx|json|md]"); output.WriteLine(" config show"); + output.WriteLine(" crypto info"); + output.WriteLine(" crypto list"); return 2; } diff --git a/src/ScepTestClient.Cli/CryptoCommand.cs b/src/ScepTestClient.Cli/CryptoCommand.cs new file mode 100644 index 0000000..7ce0526 --- /dev/null +++ b/src/ScepTestClient.Cli/CryptoCommand.cs @@ -0,0 +1,65 @@ +using System.Collections.Generic; +using System.IO; +using ScepTestClient.Core; +using ScepTestClient.CryptoApi; + +namespace ScepTestClient.Cli; + +internal static class CryptoCommand { + public static int Run(string[] args, string data_root, TextWriter output) { + string verb; + IScepCrypto crypto; + string error; + + if (args.Length < 2) { + output.WriteLine("usage: crypto "); + return 2; + } + verb = args[1]; + + if (ScepCrypto.Load(null, out crypto, out error) != ScepClientResult.Ok) { + output.WriteLine($"crypto load error: {error}"); + return 1; + } + + switch (verb) { + case "list": + return List(crypto, output); + + case "info": + return Info(crypto, output); + + default: + output.WriteLine("usage: crypto "); + return 2; + } + } + + private static int List(IScepCrypto crypto, TextWriter output) { + PrintGroup(output, "Digests", crypto.Capabilities.Digests); + PrintGroup(output, "Signatures", crypto.Capabilities.Signatures); + PrintGroup(output, "ContentEncryption", crypto.Capabilities.ContentEncryption); + PrintGroup(output, "KeyTransport", crypto.Capabilities.KeyTransport); + PrintGroup(output, "KEM", crypto.Capabilities.Kem); + PrintGroup(output, "AsymmetricKeys", crypto.Capabilities.AsymmetricKeys); + return 0; + } + + private static int Info(IScepCrypto crypto, TextWriter output) { + output.WriteLine("Provider: built-in (BouncyCastle)"); + output.WriteLine($"Tier A: {(crypto.Capabilities.PqTiers.TierA ? "yes" : "no")}"); + output.WriteLine($"Tier B: {(crypto.Capabilities.PqTiers.TierB ? "yes" : "no")}"); + output.WriteLine($"Tier C: {(crypto.Capabilities.PqTiers.TierC ? "yes" : "no")} (ML-KEM CMS envelope — provider seam; BouncyCastle 2.5.0 has no CMS KEM recipient)"); + return 0; + } + + private static void PrintGroup(TextWriter output, string title, IReadOnlyCollection oids) { + List names; + + names = new List(); + foreach (string oid in oids) { + names.Add(Algorithms.NameFor(oid) ?? oid); + } + output.WriteLine($"{title}: {string.Join(", ", names)}"); + } +} diff --git a/tests/ScepTestClient.Tests/CryptoCommandTests.cs b/tests/ScepTestClient.Tests/CryptoCommandTests.cs new file mode 100644 index 0000000..c2e8c8f --- /dev/null +++ b/tests/ScepTestClient.Tests/CryptoCommandTests.cs @@ -0,0 +1,42 @@ +using System.IO; +using ScepTestClient.Cli; +using Xunit; + +namespace ScepTestClient.Tests; + +public sealed class CryptoCommandTests { + [Fact] + public void Crypto_list_shows_pq() { + StringWriter output; + int code; + + output = new StringWriter(); + code = CommandRouter.Run(new[] { "crypto", "list" }, Path.GetTempPath(), output); + Assert.Equal(0, code); + Assert.Contains("ML-DSA-65", output.ToString()); + Assert.Contains("ML-KEM-768", output.ToString()); + Assert.Contains("SHA-256", output.ToString()); + } + + [Fact] + public void Crypto_info_shows_tiers() { + StringWriter output; + int code; + + output = new StringWriter(); + code = CommandRouter.Run(new[] { "crypto", "info" }, Path.GetTempPath(), output); + Assert.Equal(0, code); + Assert.Contains("Tier A: yes", output.ToString()); + Assert.Contains("Tier C: no", output.ToString()); + } + + [Fact] + public void Crypto_unknown_verb_is_nonzero() { + StringWriter output; + int code; + + output = new StringWriter(); + code = CommandRouter.Run(new[] { "crypto", "bogus" }, Path.GetTempPath(), output); + Assert.NotEqual(0, code); + } +} From 1cba6825e9e909b94dcde7f1b4c5626fa409eb00 Mon Sep 17 00:00:00 2001 From: Peter Dennis Bartok Date: Sat, 20 Jun 2026 14:26:47 -0600 Subject: [PATCH 12/16] Phase 4: --crypto-provider flag + config set, threaded to all crypto load sites Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01DBF7SxuRaJgFEMv5TdznH1 --- src/ScepTestClient.Cli/CommandRouter.cs | 63 ++++++++++++++----- src/ScepTestClient.Cli/CryptoCommand.cs | 2 +- .../CryptoProviderFlagTests.cs | 34 ++++++++++ 3 files changed, 84 insertions(+), 15 deletions(-) create mode 100644 tests/ScepTestClient.Tests/CryptoProviderFlagTests.cs diff --git a/src/ScepTestClient.Cli/CommandRouter.cs b/src/ScepTestClient.Cli/CommandRouter.cs index be1a960..049776a 100644 --- a/src/ScepTestClient.Cli/CommandRouter.cs +++ b/src/ScepTestClient.Cli/CommandRouter.cs @@ -130,7 +130,7 @@ private static int RunServersSuggest(string[] args, string data_root, TextWriter if (args.Length < 3) { output.WriteLine("usage: servers suggest "); return 2; } server_id = args[2]; - if (!BuildClient(server_id, data_root, output, out client, out stored)) { return 1; } + if (!BuildClient(args, server_id, data_root, output, out client, out stored)) { return 1; } caps = client.GetCaCaps().Value ?? ScepCapabilities.Parse(string.Empty); lines = ScepTestClient.Core.Testing.ServerSuggest.For(server_id, caps, client.Crypto.Capabilities); foreach (string line in lines) { output.WriteLine(line); } @@ -257,7 +257,7 @@ private static int RunGetCaCaps(string[] args, string data_root, TextWriter outp return 2; } - load_result = ScepCrypto.Load(null, out crypto, out crypto_error); + load_result = ScepCrypto.Load(ResolveProviderPath(args, data_root), out crypto, out crypto_error); if (load_result != ScepClientResult.Ok) { output.WriteLine($"crypto load error: {crypto_error}"); return 1; @@ -362,7 +362,7 @@ private static int RunGet(string[] args, string data_root, TextWriter output) { output.WriteLine($"challenge: {Redaction.Hash(challenge)}"); } - load_result = ScepCrypto.Load(null, out crypto, out crypto_error); + load_result = ScepCrypto.Load(ResolveProviderPath(args, data_root), out crypto, out crypto_error); if (load_result != ScepClientResult.Ok) { output.WriteLine($"crypto load error: {crypto_error}"); return 1; @@ -436,7 +436,7 @@ private static int RunGet(string[] args, string data_root, TextWriter output) { // Phase-2 operations: getcacert, getnextcacert, renew, getcert, getcrl, poll // ------------------------------------------------------------------------- - private static bool BuildClient(string server_id, string data_root, TextWriter output, out ScepClient client, out StoredServer stored) { + private static bool BuildClient(string[] args, string server_id, string data_root, TextWriter output, out ScepClient client, out StoredServer stored) { ServerRegistry registry; StoredServer? found; IScepCrypto crypto; @@ -455,7 +455,7 @@ private static bool BuildClient(string server_id, string data_root, TextWriter o } stored = found; - if (ScepCrypto.Load(null, out crypto, out crypto_error) != ScepClientResult.Ok) { + if (ScepCrypto.Load(ResolveProviderPath(args, data_root), out crypto, out crypto_error) != ScepClientResult.Ok) { output.WriteLine($"crypto load error: {crypto_error}"); return false; } @@ -480,7 +480,7 @@ private static int RunGetCaCert(string[] args, string data_root, TextWriter outp ScepResult> result; if (args.Length < 2) { output.WriteLine("usage: getcacert "); return 2; } - if (!BuildClient(args[1], data_root, output, out client, out stored)) { return 2; } + if (!BuildClient(args, args[1], data_root, output, out client, out stored)) { return 2; } result = client.GetCaCert(); if (!result.IsOk) { output.WriteLine($"getcacert failed: {result.Status} {result.Error}"); return 1; } @@ -496,7 +496,7 @@ private static int RunGetNextCaCert(string[] args, string data_root, TextWriter ScepResult> result; if (args.Length < 2) { output.WriteLine("usage: getnextcacert "); return 2; } - if (!BuildClient(args[1], data_root, output, out client, out stored)) { return 2; } + if (!BuildClient(args, args[1], data_root, output, out client, out stored)) { return 2; } result = client.GetNextCaCert(); if (!result.IsOk) { output.WriteLine($"getnextcacert failed: {result.Status} {result.Error}"); return 1; } @@ -530,7 +530,7 @@ private static int RunRenew(string[] args, string data_root, TextWriter output) server_id = store.FindServerForCert(cert_id); if (server_id is null) { output.WriteLine($"no stored certificate '{cert_id}'"); return 2; } - if (!BuildClient(server_id, data_root, output, out client, out stored)) { return 2; } + if (!BuildClient(args, server_id, data_root, output, out client, out stored)) { return 2; } if (variant == RenewalVariant.Proper) { result = client.RenewCertificate(cert_id, store, new UseRecordLog(data_root)); @@ -588,7 +588,7 @@ private static int RunGetCert(string[] args, string data_root, TextWriter output issuer = Opt(args, "--issuer"); serial = Opt(args, "--serial"); if (string.IsNullOrWhiteSpace(issuer) || string.IsNullOrWhiteSpace(serial)) { output.WriteLine("--issuer and --serial are required"); return 2; } - if (!BuildClient(args[1], data_root, output, out client, out stored)) { return 2; } + if (!BuildClient(args, args[1], data_root, output, out client, out stored)) { return 2; } result = client.GetCert(issuer!, serial!); if (!result.IsOk) { output.WriteLine($"getcert failed: {result.Status} {result.Error}"); return 1; } @@ -607,7 +607,7 @@ private static int RunGetCrl(string[] args, string data_root, TextWriter output) issuer = Opt(args, "--issuer"); serial = Opt(args, "--serial"); if (string.IsNullOrWhiteSpace(issuer) || string.IsNullOrWhiteSpace(serial)) { output.WriteLine("--issuer and --serial are required"); return 2; } - if (!BuildClient(args[1], data_root, output, out client, out stored)) { return 2; } + if (!BuildClient(args, args[1], data_root, output, out client, out stored)) { return 2; } result = client.GetCrl(issuer!, serial!); if (!result.IsOk) { output.WriteLine($"getcrl failed: {result.Status} {result.Error}"); return 1; } @@ -628,7 +628,7 @@ private static int RunPoll(string[] args, string data_root, TextWriter output) { subject = Opt(args, "--subject"); txn = Opt(args, "--txn"); if (string.IsNullOrWhiteSpace(issuer) || string.IsNullOrWhiteSpace(subject) || string.IsNullOrWhiteSpace(txn)) { output.WriteLine("--issuer, --subject and --txn are required"); return 2; } - if (!BuildClient(args[1], data_root, output, out client, out stored)) { return 2; } + if (!BuildClient(args, args[1], data_root, output, out client, out stored)) { return 2; } result = client.Poll(issuer!, subject!, txn!); if (result.IsOk && result.Value.Certificate is not null) { @@ -656,7 +656,7 @@ private static int RunTest(string[] args, string data_root, TextWriter output) { verb = args[1]; server_id = args[2]; - if (!BuildClient(server_id, data_root, output, out client, out stored)) { return 1; } + if (!BuildClient(args, server_id, data_root, output, out client, out stored)) { return 1; } engine = new ScepTestClient.Core.Testing.TestEngine(); switch (verb) { @@ -807,7 +807,7 @@ private static int RunScenario(string[] args, string data_root, TextWriter outpu if (!File.Exists(path)) { output.WriteLine($"scenario not found: {path}"); return 2; } json = File.ReadAllText(path); if (!ScepTestClient.Core.Testing.ScenarioRunner.Parse(json, out scenario, out parse_error)) { output.WriteLine($"bad scenario: {parse_error}"); return 2; } - if (!BuildClient(server_id, data_root, output, out client, out stored)) { return 1; } + if (!BuildClient(args, server_id, data_root, output, out client, out stored)) { return 1; } ca = client.GetCaCert(); if (!ca.IsOk) { output.WriteLine($"GetCACert failed: {ca.Status} {ca.Error}"); return 1; } @@ -935,12 +935,35 @@ private static void PrintCertsForServer(string servers_root, string server_id, T } // ------------------------------------------------------------------------- - // config show + // config set / show // ------------------------------------------------------------------------- private static int RunConfig(string[] args, string data_root, TextWriter output) { ClientConfig config; + if (args.Length >= 4 && args[1] == "set") { + string key; + string value; + int bits; + + config = ClientConfig.Load(data_root); + key = args[2]; + value = args[3]; + + switch (key) { + case "crypto-provider": config.CryptoProviderPath = value; break; + case "key-spec": config.KeySpec = value; break; + case "min-rsa-bits": + if (int.TryParse(value, out bits)) { config.MinRsaKeyBits = bits; } + break; + default: output.WriteLine($"unknown config key '{key}'"); return 2; + } + + config.Save(data_root); + output.WriteLine($"set {key} = {value}"); + return 0; + } + if (args.Length < 2 || args[1] != "show") { return WriteUsage(output); } @@ -977,6 +1000,7 @@ private static int WriteUsage(TextWriter output) { output.WriteLine(" test [--report-format junit|trx|json|md] [--jamf-max-wait ]"); output.WriteLine(" run [--report-format junit|trx|json|md]"); output.WriteLine(" config show"); + output.WriteLine(" config set (keys: crypto-provider, key-spec, min-rsa-bits)"); output.WriteLine(" crypto info"); output.WriteLine(" crypto list"); return 2; @@ -986,6 +1010,17 @@ private static int WriteUsage(TextWriter output) { // Option helpers // ------------------------------------------------------------------------- + internal static string? ResolveProviderPath(string[] args, string data_root) { + string? flag; + ClientConfig config; + + flag = Opt(args, "--crypto-provider"); + if (!string.IsNullOrWhiteSpace(flag)) { return flag; } + + config = ClientConfig.Load(data_root); + return config.CryptoProviderPath; + } + private static string? Opt(string[] args, string name) { int i; diff --git a/src/ScepTestClient.Cli/CryptoCommand.cs b/src/ScepTestClient.Cli/CryptoCommand.cs index 7ce0526..7143033 100644 --- a/src/ScepTestClient.Cli/CryptoCommand.cs +++ b/src/ScepTestClient.Cli/CryptoCommand.cs @@ -17,7 +17,7 @@ public static int Run(string[] args, string data_root, TextWriter output) { } verb = args[1]; - if (ScepCrypto.Load(null, out crypto, out error) != ScepClientResult.Ok) { + if (ScepCrypto.Load(CommandRouter.ResolveProviderPath(args, data_root), out crypto, out error) != ScepClientResult.Ok) { output.WriteLine($"crypto load error: {error}"); return 1; } diff --git a/tests/ScepTestClient.Tests/CryptoProviderFlagTests.cs b/tests/ScepTestClient.Tests/CryptoProviderFlagTests.cs new file mode 100644 index 0000000..40b66ba --- /dev/null +++ b/tests/ScepTestClient.Tests/CryptoProviderFlagTests.cs @@ -0,0 +1,34 @@ +using System.IO; +using ScepTestClient.Cli; +using Xunit; + +namespace ScepTestClient.Tests; + +public sealed class CryptoProviderFlagTests { + [Fact] + public void Config_set_persists_crypto_provider() { + string root; + StringWriter output; + + root = Path.Combine(Path.GetTempPath(), "sceptest-" + System.Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + + output = new StringWriter(); + Assert.Equal(0, CommandRouter.Run(new[] { "config", "set", "crypto-provider", "/tmp/p.dll" }, root, output)); + + output = new StringWriter(); + Assert.Equal(0, CommandRouter.Run(new[] { "config", "show" }, root, output)); + Assert.Contains("/tmp/p.dll", output.ToString()); + } + + [Fact] + public void Bogus_provider_path_errors_cleanly() { + StringWriter output; + int code; + + output = new StringWriter(); + code = CommandRouter.Run(new[] { "crypto", "list", "--crypto-provider", "/nope.dll" }, Path.GetTempPath(), output); + Assert.NotEqual(0, code); + Assert.Contains("not found", output.ToString()); + } +} From c71735300cc9ecc15591c04d7348c1ca146f0331 Mon Sep 17 00:00:00 2001 From: Peter Dennis Bartok Date: Sat, 20 Jun 2026 14:30:25 -0600 Subject: [PATCH 13/16] Phase 4: harden external-provider ALC loading (ambiguity check + shared-contract proof) Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01DBF7SxuRaJgFEMv5TdznH1 --- .../ProviderLoadContext.cs | 9 +- src/ScepTestClient.Core/ScepCrypto.cs | 48 +++++++++- .../ScepTestClient.Core.csproj | 4 + .../ScepTestClient.Tests/ProviderLoadTests.cs | 91 +++++++++++++++++++ 4 files changed, 146 insertions(+), 6 deletions(-) diff --git a/src/ScepTestClient.Core/ProviderLoadContext.cs b/src/ScepTestClient.Core/ProviderLoadContext.cs index 5c4f9aa..74c1c38 100644 --- a/src/ScepTestClient.Core/ProviderLoadContext.cs +++ b/src/ScepTestClient.Core/ProviderLoadContext.cs @@ -13,7 +13,14 @@ public ProviderLoadContext(string provider_path) : base(isCollectible: true) { protected override Assembly? Load(AssemblyName name) { string? path; - // Share the contract assembly with the host so IScepCrypto keeps one Type identity. + // Returning null here forces the contract assembly to resolve to the host's + // already-loaded copy (via AssemblyLoadContext.Default) instead of being loaded a + // second time into this collectible ALC. In .NET, Type identity = assembly + the + // ALC that loaded it, so a second copy would yield a *distinct* IScepCrypto / + // IScepKey Type that fails (InvalidCastException) when cast across the boundary. + // Sharing the host's copy guarantees a single Type identity for the contract types, + // letting a path-loaded provider's IScepCrypto/IScepKey instances flow back to the + // host transparently. if (name.Name == "ScepTestClient.CryptoApi") { return null; } diff --git a/src/ScepTestClient.Core/ScepCrypto.cs b/src/ScepTestClient.Core/ScepCrypto.cs index e8bec74..58eed01 100644 --- a/src/ScepTestClient.Core/ScepCrypto.cs +++ b/src/ScepTestClient.Core/ScepCrypto.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; @@ -28,20 +29,57 @@ public static ScepClientResult Load(string? configured_dll_path, out IScepCrypto } try { + Type[] candidates; + ScepClientResult select_result; + assembly = string.IsNullOrWhiteSpace(configured_dll_path) ? AssemblyLoadContext.Default.LoadFromAssemblyPath(path) : new ProviderLoadContext(path).LoadFromAssemblyPath(path); - impl_type = assembly.GetTypes().FirstOrDefault(t => - typeof(IScepCrypto).IsAssignableFrom(t) && !t.IsAbstract && !t.IsInterface); - if (impl_type is null) { - error = $"no IScepCrypto implementation found in {Path.GetFileName(path)}"; + + try { + candidates = assembly.GetTypes(); + } catch (ReflectionTypeLoadException ex) { + error = $"failed to inspect provider '{Path.GetFileName(path)}': {ex.LoaderExceptions.FirstOrDefault()?.Message ?? ex.Message}"; return ScepClientResult.ProviderError; } - crypto = (IScepCrypto)Activator.CreateInstance(impl_type)!; + + select_result = SelectImplType(candidates, Path.GetFileName(path), out impl_type, out error); + if (select_result != ScepClientResult.Ok) { + return select_result; + } + + crypto = (IScepCrypto)Activator.CreateInstance(impl_type!)!; return ScepClientResult.Ok; } catch (Exception ex) { error = $"failed to load crypto provider '{path}': {ex.Message}"; return ScepClientResult.ProviderError; } } + + // Deterministically pick the single concrete IScepCrypto implementation among the + // candidate types. Zero or more-than-one is an error (never first-wins), so an + // ambiguous provider assembly is rejected with a clear message instead of silently + // binding to whichever type the reflection enumeration happened to return first. + internal static ScepClientResult SelectImplType(Type[] candidates, string file, out Type? impl, out string error) { + List impls; + + impl = null; + error = string.Empty; + + impls = candidates + .Where(t => typeof(IScepCrypto).IsAssignableFrom(t) && !t.IsAbstract && !t.IsInterface) + .ToList(); + + if (impls.Count == 0) { + error = $"no IScepCrypto implementation found in {file}"; + return ScepClientResult.ProviderError; + } + if (impls.Count > 1) { + error = $"multiple IScepCrypto implementations found in {file} ({impls.Count})"; + return ScepClientResult.ProviderError; + } + + impl = impls[0]; + return ScepClientResult.Ok; + } } diff --git a/src/ScepTestClient.Core/ScepTestClient.Core.csproj b/src/ScepTestClient.Core/ScepTestClient.Core.csproj index 99bd614..82a3ec3 100644 --- a/src/ScepTestClient.Core/ScepTestClient.Core.csproj +++ b/src/ScepTestClient.Core/ScepTestClient.Core.csproj @@ -4,6 +4,10 @@ + + + + net8.0 enable diff --git a/tests/ScepTestClient.Tests/ProviderLoadTests.cs b/tests/ScepTestClient.Tests/ProviderLoadTests.cs index 295cb84..d571926 100644 --- a/tests/ScepTestClient.Tests/ProviderLoadTests.cs +++ b/tests/ScepTestClient.Tests/ProviderLoadTests.cs @@ -1,3 +1,5 @@ +using System; +using System.IO; using ScepTestClient.Core; using ScepTestClient.CryptoApi; using Xunit; @@ -22,4 +24,93 @@ public void Missing_dll_fails_without_throwing() { Assert.Equal(ScepClientResult.ProviderError, ScepCrypto.Load("/does/not/exist.dll", out crypto, out error)); Assert.NotEqual(string.Empty, error); } + + [Fact] + public void Loads_provider_by_explicit_path_with_shared_contract() { + string dll; + IScepCrypto crypto; + string error; + KeySpec spec; + IScepKey key; + Pkcs10 csr; + byte[] der; + + dll = Path.Combine(AppContext.BaseDirectory, "ScepTestClient.Crypto.BouncyCastle.dll"); + Assert.Equal(ScepClientResult.Ok, ScepCrypto.Load(dll, out crypto, out error)); + Assert.True(crypto is IScepCrypto); + Assert.True(KeySpec.Parse("rsa:2048", out spec, out error), error); + Assert.True(crypto.GenerateKey(spec, out key, out error), error); // shared IScepKey type identity + csr = new Pkcs10 { Key = key }; + csr.SetSubject("CN=alc", out error); + Assert.True(crypto.EncodeCsr(csr, out der, out error), error); + } + + [Fact] + public void Missing_impl_reports_no_implementation() { + string dll; + IScepCrypto crypto; + string error; + + // An assembly with no IScepCrypto impl: use the contract assembly itself (CryptoApi has none). + dll = Path.Combine(AppContext.BaseDirectory, "ScepTestClient.CryptoApi.dll"); + Assert.Equal(ScepClientResult.ProviderError, ScepCrypto.Load(dll, out crypto, out error)); + Assert.Contains("no IScepCrypto implementation", error); + } + + [Fact] + public void SelectImplType_with_no_candidates_reports_no_implementation() { + Type? impl; + string error; + + Assert.Equal( + ScepClientResult.ProviderError, + ScepCrypto.SelectImplType(Array.Empty(), "empty.dll", out impl, out error)); + Assert.Null(impl); + Assert.Contains("no IScepCrypto implementation", error); + } + + [Fact] + public void SelectImplType_with_one_candidate_succeeds() { + Type? impl; + string error; + + Assert.Equal( + ScepClientResult.Ok, + ScepCrypto.SelectImplType(new[] { typeof(DummyCryptoA) }, "one.dll", out impl, out error)); + Assert.Equal(typeof(DummyCryptoA), impl); + Assert.Equal(string.Empty, error); + } + + [Fact] + public void SelectImplType_with_multiple_candidates_reports_ambiguity() { + Type? impl; + string error; + + Assert.Equal( + ScepClientResult.ProviderError, + ScepCrypto.SelectImplType(new[] { typeof(DummyCryptoA), typeof(DummyCryptoB) }, "two.dll", out impl, out error)); + Assert.Null(impl); + Assert.Contains("multiple IScepCrypto implementations", error); + } + + // Two concrete IScepCrypto implementations used only to exercise the + // ambiguity / single-impl selection logic deterministically (no second DLL needed). + private abstract class DummyCryptoBase : IScepCrypto { + public CryptoCapabilities Capabilities => throw new NotImplementedException(); + public bool GenerateKey(KeySpec spec, out IScepKey key, out string error) => throw new NotImplementedException(); + public bool EncodeCsr(Pkcs10 csr, out byte[] der, out string error) => throw new NotImplementedException(); + public bool EncodePkiMessage(PkiMessage message, FaultDirectives? faults, out byte[] der, out string error) => throw new NotImplementedException(); + public bool DecodePkiMessage(byte[] der, IScepKey recipient_key, CodecOptions options, out PkiMessage message, out string error) => throw new NotImplementedException(); + public bool ParseCaCertificates(byte[] der, out System.Collections.Generic.IReadOnlyList certs, out string error) => throw new NotImplementedException(); + public bool ExportPrivateKeyPkcs8(IScepKey key, out byte[] der, out string error) => throw new NotImplementedException(); + public bool ImportPrivateKeyPkcs8(byte[] der, out IScepKey key, out string error) => throw new NotImplementedException(); + public bool ExportPrivateKeyPkcs8Encrypted(IScepKey key, string passphrase, out byte[] der, out string error) => throw new NotImplementedException(); + public bool ImportPrivateKeyPkcs8Encrypted(byte[] der, string passphrase, out IScepKey key, out string error) => throw new NotImplementedException(); + } + + private sealed class DummyCryptoA : DummyCryptoBase { + } + + private sealed class DummyCryptoB : DummyCryptoBase { + } } From e5b39eff6ed4e02b652e18cb4493e7152cc1f979 Mon Sep 17 00:00:00 2001 From: Peter Dennis Bartok Date: Sat, 20 Jun 2026 14:37:50 -0600 Subject: [PATCH 14/16] =?UTF-8?q?Phase=204:=20review=20polish=20=E2=80=94?= =?UTF-8?q?=20drop=20unsupported=20SLH-DSA=20f-sets=20from=20KeySpec;=20cr?= =?UTF-8?q?ypto=20info=20shows=20resolved=20provider=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ScepTestClient.Cli/CryptoCommand.cs | 12 ++++++++---- src/ScepTestClient.CryptoApi/KeySpec.cs | 2 +- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/ScepTestClient.Cli/CryptoCommand.cs b/src/ScepTestClient.Cli/CryptoCommand.cs index 7143033..3ebc3db 100644 --- a/src/ScepTestClient.Cli/CryptoCommand.cs +++ b/src/ScepTestClient.Cli/CryptoCommand.cs @@ -8,6 +8,7 @@ namespace ScepTestClient.Cli; internal static class CryptoCommand { public static int Run(string[] args, string data_root, TextWriter output) { string verb; + string? provider_path; IScepCrypto crypto; string error; @@ -16,8 +17,9 @@ public static int Run(string[] args, string data_root, TextWriter output) { return 2; } verb = args[1]; + provider_path = CommandRouter.ResolveProviderPath(args, data_root); - if (ScepCrypto.Load(CommandRouter.ResolveProviderPath(args, data_root), out crypto, out error) != ScepClientResult.Ok) { + if (ScepCrypto.Load(provider_path, out crypto, out error) != ScepClientResult.Ok) { output.WriteLine($"crypto load error: {error}"); return 1; } @@ -27,7 +29,7 @@ public static int Run(string[] args, string data_root, TextWriter output) { return List(crypto, output); case "info": - return Info(crypto, output); + return Info(crypto, provider_path, output); default: output.WriteLine("usage: crypto "); @@ -45,8 +47,10 @@ private static int List(IScepCrypto crypto, TextWriter output) { return 0; } - private static int Info(IScepCrypto crypto, TextWriter output) { - output.WriteLine("Provider: built-in (BouncyCastle)"); + private static int Info(IScepCrypto crypto, string? provider_path, TextWriter output) { + output.WriteLine(string.IsNullOrWhiteSpace(provider_path) + ? "Provider: built-in (BouncyCastle)" + : $"Provider: {provider_path}"); output.WriteLine($"Tier A: {(crypto.Capabilities.PqTiers.TierA ? "yes" : "no")}"); output.WriteLine($"Tier B: {(crypto.Capabilities.PqTiers.TierB ? "yes" : "no")}"); output.WriteLine($"Tier C: {(crypto.Capabilities.PqTiers.TierC ? "yes" : "no")} (ML-KEM CMS envelope — provider seam; BouncyCastle 2.5.0 has no CMS KEM recipient)"); diff --git a/src/ScepTestClient.CryptoApi/KeySpec.cs b/src/ScepTestClient.CryptoApi/KeySpec.cs index dcc4507..e539512 100644 --- a/src/ScepTestClient.CryptoApi/KeySpec.cs +++ b/src/ScepTestClient.CryptoApi/KeySpec.cs @@ -2,7 +2,7 @@ namespace ScepTestClient.CryptoApi; public sealed class KeySpec { private static readonly string[] MlDsaSets = { "44", "65", "87" }; - private static readonly string[] SlhDsaSets = { "128s", "192s", "256s", "128f", "192f", "256f" }; + private static readonly string[] SlhDsaSets = { "128s", "192s", "256s" }; private static readonly string[] MlKemSets = { "512", "768", "1024" }; public string Algorithm { get; } From b9648735f5ce70059ce30669b7527f16b9272e81 Mon Sep 17 00:00:00 2001 From: Peter Dennis Bartok Date: Sat, 20 Jun 2026 14:39:21 -0600 Subject: [PATCH 15/16] Phase 4: sync plan task statuses to completed --- .../2026-06-20-scep-test-client-phase-3.md | 2679 +++++++++++++++++ ...-20-scep-test-client-phase-3.md.tasks.json | 94 + ...-20-scep-test-client-phase-4.md.tasks.json | 24 +- 3 files changed, 2785 insertions(+), 12 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-20-scep-test-client-phase-3.md create mode 100644 docs/superpowers/plans/2026-06-20-scep-test-client-phase-3.md.tasks.json diff --git a/docs/superpowers/plans/2026-06-20-scep-test-client-phase-3.md b/docs/superpowers/plans/2026-06-20-scep-test-client-phase-3.md new file mode 100644 index 0000000..4c16c1d --- /dev/null +++ b/docs/superpowers/plans/2026-06-20-scep-test-client-phase-3.md @@ -0,0 +1,2679 @@ +# ScepTestClient Phase 3 — Test & Compliance Engine — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Turn the working enroll/renew client into a thorough SCEP server-testing tool: deliberate fault injection, a compliance fault-matrix → expected `failInfo`, `test lifecycle/full/probe` modes, security opinion + `servers suggest`, jamf timing sim, report emitters (JUnit/TRX/JSON/MD + console), a scenario/playlist runner, and challenge sources (`--simulator`, `--ndes`). + +**Architecture:** Faults are carried by a `FaultDirectives` object that the fluent builder attaches via `.AllowFaults(...)`; the BouncyCastle provider applies them only inside a single `if (faults != null)` branch at encode time (3 clean deletion points: the class, `.AllowFaults`, the provider branch). A new `ScepTestClient.Core/Testing` namespace holds the engine (`ComplianceEngine`, `TestEngine`, `JamfSimulator`, `ScenarioRunner`) producing a `TestReport`; `ScepTestClient.Core/Reporting` emitters render it; `ScepTestClient.Core/Challenge` abstracts challenge sources. The in-test `TestCa`/`FakeScepServer` are extended to detect each deliberate fault and answer the expected `failInfo`. CLI grows `test`, `run`, `servers suggest`, and the report/challenge/jamf flags. + +**Tech Stack:** .NET 8 (`net8.0`, `RollForward Major`), xUnit, BouncyCastle.Cryptography 2.5.0 (provider only), ASP.NET Core Kestrel (test fakes only). No new external dependencies. + +**User decisions (already made):** +- "The design is done — do NOT re-brainstorm." Implement strictly from the spec (§7/§10/§11/§12/§13/§17 row 3). +- "Write all new code in my .editorconfig style from the first keystroke (no var, same-line braces, single-line statements, snake_case locals/params/fields)." Tell every subagent; no reformat pass. +- "Keep granular task commits (no squash); I'll PR via SmartGit." Stage files explicitly — never `git add -A`. +- Tell every review/Explore subagent: "stay on branch `feature/scep-test-client-phase-3`; do NOT `git checkout`/`git switch`." +- The IntuneSimulator does NOT issue SCEP certs — all client tests run against the in-test loopback `FakeScepServer`/`TestCa`. + +--- + +## House style (every task, every keystroke) + +From `CLAUDE.md` + `.editorconfig`. New ScepTestClient code MUST: +- **Never `var`** — always the explicit type. +- **Declare locals at the top of the block, unassigned, then a blank line, then the assignments.** +- Same-line braces, single-line statements. +- **snake_case** for locals, parameters, and private fields; **PascalCase** for public members. +- No exceptions for control flow: sync = result enum + `out value` + `out string error`; async = `ScepResult`; static `Create()/Load()` factories. +- All cryptography goes through `IScepCrypto` / lives only under `ScepTestClient.Crypto.*`. Engine/reporting/CLI code never references BouncyCastle. + +Reference for BC 2.5.0 API specifics is the memory note `scep-bouncycastle-cms-reference`. **The round-trip / e2e test is the source of truth — adapt the BC call until it compiles and the test passes, and flag any deviation from the plan code.** + +--- + +## File Structure + +**Crypto contract / provider (fault injection):** +- Modify `src/ScepTestClient.CryptoApi/FaultDirectives.cs` — fill in the (currently empty) directive fields. +- Modify `src/ScepTestClient.Crypto.BouncyCastle/BcPkiMessage.cs` — add `faults` param to `EncodePkiOperation` + the single `if (faults != null)` branch; add a `SigningTime` attribute helper. +- Modify `src/ScepTestClient.Crypto.BouncyCastle/BouncyCastleScepCrypto.cs` — thread `faults` into `EncodePkiOperation`. + +**Core — builder + client plumbing:** +- Modify `src/ScepTestClient.Core/ScepRequestBuilder.cs` — `.AllowFaults()` + `Faults` property. +- Modify `src/ScepTestClient.Core/ScepClient.cs` — `faults` param on the send core + public `SubmitPkiOperation(...)`. + +**Core — testing engine (`ScepTestClient.Core/Testing/`):** +- Create `CheckOutcome.cs`, `CheckResult.cs`, `TestReport.cs` — result model. +- Create `FaultKind.cs`, `ComplianceCheck.cs`, `ComplianceEngine.cs` — the fault matrix. +- Create `TestEngine.cs` — `lifecycle` / `probe` modes (and a `full` entry delegating to `ComplianceEngine`). +- Create `SecurityOpinion.cs`, `OpinionThresholds.cs`, `ServerSuggest.cs` — opinion + suggest. +- Create `JamfSimulator.cs` — `--jamf-max-wait` timing sim. +- Create `ScenarioFile.cs`, `ScenarioRunner.cs` — playlist runner. + +**Core — reporting (`ScepTestClient.Core/Reporting/`):** +- Create `JUnitReport.cs`, `TrxReport.cs`, `JsonReport.cs`, `MarkdownReport.cs`, `ConsoleSummary.cs`. + +**Core — challenge sources (`ScepTestClient.Core/Challenge/`):** +- Create `IChallengeSource.cs`, `ExplicitChallengeSource.cs`, `SimulatorChallengeSource.cs`, `NdesChallengeSource.cs`, `NdesAdminUrl.cs`. + +**Core — config:** +- Modify `src/ScepTestClient.Core/Storage/ClientConfig.cs` — add `MinRsaKeyBits` + opinion threshold fields. + +**CLI:** +- Modify `src/ScepTestClient.Cli/CommandRouter.cs` — `test lifecycle/full/probe`, `run`, `servers suggest`, `--report-format` (repeatable), `--jamf-max-wait`, `--simulator`/`--ndes*` on enroll; write reports under `/runs/`. + +**Test fakes (extend):** +- Modify `tests/ScepTestClient.Tests/Fakes/TestCa.cs` — fault detection → expected `failInfo`; `PendingMode`; `ExpectedChallenge`; `BuildPendingCertRep`. +- Modify `tests/ScepTestClient.Tests/Fakes/FakeScepServer.cs` — surface caps toggles; pending route behavior. +- Create a small `tests/ScepTestClient.Tests/Fakes/FakeHttpEndpoint.cs` — minimal Kestrel endpoint for simulator/NDES challenge tests. + +**New test files:** one per task (named in each task). + +--- + +## Task 1: FaultDirectives + provider fault branch + +**Goal:** Fill in `FaultDirectives` and apply it inside one `if (faults != null)` branch of the BouncyCastle encoder, so a request can carry a corrupted signature, a skewed signingTime, or corrupted inner content. + +**Files:** +- Modify: `src/ScepTestClient.CryptoApi/FaultDirectives.cs` +- Modify: `src/ScepTestClient.Crypto.BouncyCastle/BcPkiMessage.cs` +- Modify: `src/ScepTestClient.Crypto.BouncyCastle/BouncyCastleScepCrypto.cs` +- Test: `tests/ScepTestClient.Tests/FaultInjectionTests.cs` (create) + +**Acceptance Criteria:** +- [ ] `FaultDirectives` exposes `CorruptSignature` (bool), `SigningTimeSkew` (`TimeSpan?`), `CorruptInnerContent` (bool). +- [ ] With `faults == null`, encoded bytes are byte-identical to the pre-change encoder for the same inputs (normal path untouched; no signingTime attribute added). +- [ ] `CorruptSignature` produces a structurally valid CMS whose signer signature does NOT verify against the embedded signer cert. +- [ ] `SigningTimeSkew = +2h` adds a CMS `signingTime` (OID 1.2.840.113549.1.9.5) authenticated attribute ≈ now+2h. +- [ ] `CorruptInnerContent` garbles the enveloped inner payload so the recipient cannot parse a PKCS#10 out of it. +- [ ] 0 warnings; all existing tests still pass. + +**Verify:** `dotnet test tests/ScepTestClient.Tests/ScepTestClient.Tests.csproj --filter FullyQualifiedName~FaultInjection` → PASS + +**Steps:** + +- [ ] **Step 1: Fill in `FaultDirectives`.** Replace the empty body: + +```csharp +namespace ScepTestClient.CryptoApi; + +// The only place deliberate faults live. Attached to a request via ScepRequestBuilder.AllowFaults(...) +// and applied ONLY inside the provider's `if (faults != null)` encode branch. Deleting this type, +// the builder method, and that branch makes the library production-pure. +public sealed class FaultDirectives { + // Sign the CMS with a throwaway key so the signature fails to verify -> badMessageCheck. + public bool CorruptSignature { get; set; } + + // Add a CMS signingTime authenticated attribute offset from now (e.g. +2h) -> badTime. + public System.TimeSpan? SigningTimeSkew { get; set; } + + // Garble the inner payload before enveloping so no PKCS#10 can be parsed -> badRequest. + public bool CorruptInnerContent { get; set; } +} +``` + +- [ ] **Step 2: Write the failing test** `tests/ScepTestClient.Tests/FaultInjectionTests.cs`: + +```csharp +using System.Security.Cryptography.X509Certificates; +using Org.BouncyCastle.Cms; +using ScepTestClient.CryptoApi; +using ScepTestClient.Tests.Fakes; + +namespace ScepTestClient.Tests; + +public sealed class FaultInjectionTests { + private static PkiMessage BuildPkcsReq(IScepCrypto crypto, X509Certificate2 ca_cert, out IScepKey subject_key) { + Pkcs10 csr; + PkiMessage message; + IScepKey key; + string error; + + crypto.GenerateKey(new KeySpec("rsa", 2048), out key, out error); + subject_key = key; + + csr = new Pkcs10(); + csr.SetSubject("CN=fault-test", out _); + csr.Key = key; + + message = new PkiMessage { + MessageType = MessageType.PkcsReq, + InnerCsr = csr, + SignerKey = key, + RecipientCaCert = ca_cert, + TransactionId = "TXNFAULT0001", + }; + return message; + } + + [Fact] + public void NullFaults_LeavesBytesUnchanged() { + IScepCrypto crypto; + TestCa ca; + PkiMessage message; + IScepKey subject_key; + byte[] a; + byte[] b; + string e1; + string e2; + + crypto = TestCrypto.Load(); + ca = TestCa.Create(); + message = BuildPkcsReq(crypto, ca.CertificateBcl, out subject_key); + + Assert.True(crypto.EncodePkiMessage(message, null, out a, out e1), e1); + message.TransactionId = "TXNFAULT0001"; + Assert.True(crypto.EncodePkiMessage(message, new FaultDirectives(), out b, out e2), e2); + // An all-false FaultDirectives must not add a signingTime attribute nor otherwise diverge in shape. + Assert.Equal(SignedAttrCount(a, crypto, subject_key), SignedAttrCount(b, crypto, subject_key)); + } + + [Fact] + public void CorruptSignature_DoesNotVerify() { + IScepCrypto crypto; + TestCa ca; + PkiMessage message; + IScepKey subject_key; + byte[] der; + string error; + + crypto = TestCrypto.Load(); + ca = TestCa.Create(); + message = BuildPkcsReq(crypto, ca.CertificateBcl, out subject_key); + + Assert.True(crypto.EncodePkiMessage(message, new FaultDirectives { CorruptSignature = true }, out der, out error), error); + Assert.False(ca.VerifyOuterSignature(der), "corrupted signature must not verify"); + } + + [Fact] + public void SigningTimeSkew_AddsSkewedAttribute() { + IScepCrypto crypto; + TestCa ca; + PkiMessage message; + IScepKey subject_key; + byte[] der; + string error; + System.DateTime? signing_time; + + crypto = TestCrypto.Load(); + ca = TestCa.Create(); + message = BuildPkcsReq(crypto, ca.CertificateBcl, out subject_key); + + Assert.True(crypto.EncodePkiMessage(message, new FaultDirectives { SigningTimeSkew = System.TimeSpan.FromHours(2) }, out der, out error), error); + signing_time = ca.ReadSigningTime(der); + Assert.NotNull(signing_time); + Assert.True(signing_time!.Value > System.DateTime.UtcNow.AddHours(1), "signingTime should be ~2h ahead"); + } + + [Fact] + public void CorruptInnerContent_PkcsReqUnparseable() { + IScepCrypto crypto; + TestCa ca; + PkiMessage message; + IScepKey subject_key; + byte[] der; + string error; + + crypto = TestCrypto.Load(); + ca = TestCa.Create(); + message = BuildPkcsReq(crypto, ca.CertificateBcl, out subject_key); + + Assert.True(crypto.EncodePkiMessage(message, new FaultDirectives { CorruptInnerContent = true }, out der, out error), error); + Assert.False(ca.InnerCsrParses(der), "garbled inner payload must not parse as PKCS#10"); + } + + private static int SignedAttrCount(byte[] der, IScepCrypto crypto, IScepKey key) { + CmsSignedData signed; + SignerInformation signer; + + signed = new CmsSignedData(der); + signer = (SignerInformation)System.Linq.Enumerable.First(System.Linq.Enumerable.Cast(signed.GetSignerInfos().GetSigners())); + return signer.SignedAttributes == null ? 0 : signer.SignedAttributes.Count; + } +} +``` + +> `TestCrypto.Load()` is the existing test helper that loads the BC provider; if it does not yet exist, use the same provider-load call other tests use (`ScepCrypto.Load(null, ...)`), matching their pattern. `TestCa.VerifyOuterSignature`, `ReadSigningTime`, `InnerCsrParses` are added in **Task 3**; until then this test is written but its server-side helpers are stubbed there. To keep Task 1 self-verifying, the three helpers can be added to `TestCa` as part of this task's test support (move them to Task 3's section if collisions arise) — the simplest path is to add them now in `TestCa` and let Task 3 build on them. + +- [ ] **Step 3: Run the test — expect FAIL** (helpers/branch missing): `dotnet test --filter FullyQualifiedName~FaultInjection`. + +- [ ] **Step 4: Thread `faults` through the provider.** In `BouncyCastleScepCrypto.EncodePkiMessage`, pass `faults` to every `BcPkiMessage.EncodePkiOperation(...)` call (4 call sites). Change the encoder signature in `BcPkiMessage.cs`: + +```csharp +public static byte[] EncodePkiOperation(PkiMessage message, byte[] inner_payload_der, BcKey signer_key, string message_type_number, FaultDirectives? faults) { +``` + +- [ ] **Step 5: Apply the single fault branch in `EncodePkiOperation`.** Add a `using ScepTestClient.CryptoApi;` if needed. Just before building the enveloped data, garble the payload when asked; when signing, swap the key and/or add a skewed signingTime — all inside ONE guarded region: + +```csharp + byte[] payload_for_envelope; + Org.BouncyCastle.Crypto.AsymmetricKeyParameter signing_private_key; + + payload_for_envelope = inner_payload_der; + signing_private_key = signer_key.KeyPair.Private; + + // ---- BEGIN deliberate-fault branch (delete to make production-pure) ---- + if (faults != null) { + if (faults.CorruptInnerContent) { + payload_for_envelope = (byte[])inner_payload_der.Clone(); + for (int i = 0; i < payload_for_envelope.Length; i++) { + payload_for_envelope[i] ^= 0xFF; + } + } + if (faults.CorruptSignature) { + Org.BouncyCastle.Crypto.Generators.RsaKeyPairGenerator throwaway_gen; + Org.BouncyCastle.Crypto.AsymmetricCipherKeyPair throwaway; + + throwaway_gen = new Org.BouncyCastle.Crypto.Generators.RsaKeyPairGenerator(); + throwaway_gen.Init(new Org.BouncyCastle.Crypto.KeyGenerationParameters(Random, 2048)); + throwaway = throwaway_gen.GenerateKeyPair(); + signing_private_key = throwaway.Private; + } + if (faults.SigningTimeSkew.HasValue) { + signed_attrs[new DerObjectIdentifier(SigningTimeOid)] = new Org.BouncyCastle.Asn1.Cms.Attribute( + new DerObjectIdentifier(SigningTimeOid), + new DerSet(new Org.BouncyCastle.Asn1.Cms.Time(new DerUtcTime(System.DateTime.UtcNow.Add(faults.SigningTimeSkew.Value))))); + signed_attr_table = new AttributeTable(signed_attrs); + } + } + // ---- END deliberate-fault branch ---- +``` + +Then change the existing enveloping line to use `payload_for_envelope` and the `AddSigner` line to use `signing_private_key`: + +```csharp + enveloped = enveloped_gen.Generate(new CmsProcessableByteArray(payload_for_envelope), message.ContentEncryptionAlgorithmOid); + ... + signed_gen.AddSigner(signing_private_key, signer_cert, message.DigestAlgorithmOid, signed_attr_table, null); +``` + +> Note ordering: `signed_attrs`/`signed_attr_table` are built BEFORE this branch in the current code — keep that, and the branch mutates `signed_attrs` then rebuilds `signed_attr_table`. Move the fault branch to sit AFTER `signed_attr_table` is first assigned and BEFORE `AddSigner`. Add the constant near the top of `BcPkiMessage`: `private const string SigningTimeOid = "1.2.840.113549.1.9.5";`. If BC exposes `Org.BouncyCastle.Asn1.Cms.CmsAttributes.SigningTime`, prefer that OID constant — adapt per the BC reference. + +- [ ] **Step 6: Run the test — expect PASS** (after Task 3 helpers exist). If running Task 1 standalone, add the three `TestCa` helpers now (see Task 3 Step for their bodies). + +- [ ] **Step 7: Confirm the normal path is byte-stable.** Run the full suite: `dotnet test tests/ScepTestClient.Tests/ScepTestClient.Tests.csproj`. Existing `BcEncodeTests`/`BcDecodeTests` must stay green (the fault branch is inert when `faults` is null or all-false). + +- [ ] **Step 8: Commit** (stage exact files): + +```bash +git add src/ScepTestClient.CryptoApi/FaultDirectives.cs \ + src/ScepTestClient.Crypto.BouncyCastle/BcPkiMessage.cs \ + src/ScepTestClient.Crypto.BouncyCastle/BouncyCastleScepCrypto.cs \ + tests/ScepTestClient.Tests/FaultInjectionTests.cs +git commit -m "Crypto: FaultDirectives + single provider fault-injection branch" +``` + +--- + +## Task 2: Builder `.AllowFaults()` + client `SubmitPkiOperation` + +**Goal:** Expose faults through the fluent builder and let the client submit a (possibly faulted) PKIOperation through the existing unified send core. + +**Files:** +- Modify: `src/ScepTestClient.Core/ScepRequestBuilder.cs` +- Modify: `src/ScepTestClient.Core/ScepClient.cs` +- Test: `tests/ScepTestClient.Tests/AllowFaultsTests.cs` (create) + +**Acceptance Criteria:** +- [ ] `ScepRequestBuilder.AllowFaults(FaultDirectives faults)` returns the builder and stores the directives; `Faults` property exposes them (null by default). +- [ ] `ScepClient.SubmitPkiOperation(message, subject_key, faults)` and `...Async` encode WITH the faults, post, and decode to an `EnrollOutcome` (same path as `Enroll`). +- [ ] `Enroll`/`Renew`/`Poll` behavior is unchanged (they pass `faults: null`). +- [ ] 0 warnings; existing tests pass. + +**Verify:** `dotnet test --filter FullyQualifiedName~AllowFaults` → PASS + +**Steps:** + +- [ ] **Step 1: Add `.AllowFaults` to `ScepRequestBuilder`.** Add a private field with the other fields and the method with the other fluent methods: + +```csharp + private FaultDirectives? _faults; + + public ScepRequestBuilder AllowFaults(FaultDirectives faults) { _faults = faults; return this; } + + public FaultDirectives? Faults => _faults; +``` + +- [ ] **Step 2: Add `faults` to the send core in `ScepClient`.** Give `SendPkiOperationSync` / `SendPkiOperationAsync` a trailing `FaultDirectives? faults = null` parameter and change their encode line from `pki_message.Encode(Crypto, out der, out encode_error)` to `pki_message.Encode(Crypto, faults, out der, out encode_error)`. Existing callers (`Enroll`/`Renew`/`Poll`) keep calling without the argument. + +- [ ] **Step 3: Add the public submit wrappers** near `Enroll` in `ScepClient`: + +```csharp + public ScepResult SubmitPkiOperation(PkiMessage message, IScepKey subject_key, FaultDirectives? faults = null) => + SendPkiOperationSync(message, subject_key, faults); + + public Task> SubmitPkiOperationAsync(PkiMessage message, IScepKey subject_key, FaultDirectives? faults = null) => + SendPkiOperationAsync(message, subject_key, faults); +``` + +- [ ] **Step 4: Write the test** `tests/ScepTestClient.Tests/AllowFaultsTests.cs`: + +```csharp +using ScepTestClient.Core; +using ScepTestClient.CryptoApi; + +namespace ScepTestClient.Tests; + +public sealed class AllowFaultsTests { + [Fact] + public void Builder_CarriesFaults() { + IScepCrypto crypto; + FaultDirectives faults; + ScepRequestBuilder builder; + + crypto = TestCrypto.Load(); + faults = new FaultDirectives { CorruptSignature = true }; + builder = ScepRequestBuilder.For(crypto).AllowFaults(faults); + + Assert.Same(faults, builder.Faults); + } + + [Fact] + public void Builder_NoFaults_NullByDefault() { + IScepCrypto crypto; + + crypto = TestCrypto.Load(); + Assert.Null(ScepRequestBuilder.For(crypto).Faults); + } +} +``` + +- [ ] **Step 5: Run — expect FAIL then PASS** after Steps 1–3 compile: `dotnet test --filter FullyQualifiedName~AllowFaults`. + +- [ ] **Step 6: Commit:** + +```bash +git add src/ScepTestClient.Core/ScepRequestBuilder.cs \ + src/ScepTestClient.Core/ScepClient.cs \ + tests/ScepTestClient.Tests/AllowFaultsTests.cs +git commit -m "Core: builder AllowFaults + ScepClient SubmitPkiOperation faults plumbing" +``` + +--- + +## Task 3: Extend `TestCa`/`FakeScepServer` for fault detection + +**Goal:** Teach the in-test CA to verify the outer signature, inspect signingTime/digest/challenge/inner-CSR, and answer the RFC-expected `failInfo`; add a PENDING mode and an expected-challenge for downstream tasks. + +**Files:** +- Modify: `tests/ScepTestClient.Tests/Fakes/TestCa.cs` +- Modify: `tests/ScepTestClient.Tests/Fakes/FakeScepServer.cs` +- Test: `tests/ScepTestClient.Tests/FaultMatrixServerTests.cs` (create) + +**Acceptance Criteria:** +- [ ] `TestCa.HandlePkiOperation` returns, in priority order: badMessageCheck (`"1"`) for a non-verifying signature; badTime (`"3"`) when a signingTime attr is present and beyond ±5 min; badAlg (`"0"`) when the signer digest is MD5; badRequest (`"2"`) when the inner CSR will not parse; FAILURE with badRequest when `ExpectedChallenge` is set and the request's challenge differs; else success. +- [ ] `TestCa.HandleGetCert`/`HandlePoll` still return badCertId (`"4"`) for an unknown serial/subject (already true — keep). +- [ ] `TestCa.PendingMode` makes `HandlePkiOperation` and `HandlePoll` answer pkiStatus PENDING (`3`) via a new `BuildPendingCertRep`. +- [ ] Public test helpers exist: `bool VerifyOuterSignature(byte[] der)`, `System.DateTime? ReadSigningTime(byte[] der)`, `bool InnerCsrParses(byte[] der)`, and a settable `string? ExpectedChallenge`, `bool PendingMode`. +- [ ] Each fault, POSTed through `FakeScepServer`, yields the expected `failInfo` decoded by `ScepClient`. + +**Verify:** `dotnet test --filter FullyQualifiedName~FaultMatrixServer` → PASS + +**Steps:** + +- [ ] **Step 1: Add helper state + accessors to `TestCa`.** Add private fields `_pending_mode`, `_expected_challenge` and public auto-properties: + +```csharp + public bool PendingMode { get; set; } + public string? ExpectedChallenge { get; set; } +``` + +- [ ] **Step 2: Add the inspection helpers to `TestCa`** (used by Task 1's tests and by `HandlePkiOperation`): + +```csharp + public bool VerifyOuterSignature(byte[] der) { + CmsSignedData signed; + SignerInformation signer; + Org.BouncyCastle.X509.X509Certificate signer_cert; + + signed = new CmsSignedData(der); + signer = First(signed); + signer_cert = FirstCert(signed, signer); + try { + return signer.Verify(signer_cert.GetPublicKey()); + } catch (System.Exception) { + return false; + } + } + + public System.DateTime? ReadSigningTime(byte[] der) { + CmsSignedData signed; + SignerInformation signer; + Org.BouncyCastle.Asn1.Cms.Attribute? attr; + + signed = new CmsSignedData(der); + signer = First(signed); + if (signer.SignedAttributes == null) { return null; } + attr = signer.SignedAttributes[new Org.BouncyCastle.Asn1.DerObjectIdentifier("1.2.840.113549.1.9.5")]; + if (attr == null) { return null; } + return Org.BouncyCastle.Asn1.Cms.Time.GetInstance(attr.AttrValues[0]).Date; + } + + public bool InnerCsrParses(byte[] der) { + byte[] inner; + + try { + inner = DecryptInner(der); + Org.BouncyCastle.Pkcs.Pkcs10CertificationRequest parsed; + parsed = new Org.BouncyCastle.Pkcs.Pkcs10CertificationRequest(inner); + return parsed.Verify(); + } catch (System.Exception) { + return false; + } + } +``` + +> `First(signed)` / `FirstCert(signed, signer)` / `DecryptInner(der)` are small private helpers factored out of the existing `DecodeRequest`. Reuse the existing decrypt logic (the CA private key recipient) — see `scep-bouncycastle-cms-reference` for `RecipientInfos.GetRecipients()` / `recipient.GetContent(caKey)`. The signer digest OID for the badAlg check is `signer.DigestAlgOid`. + +- [ ] **Step 3: Rework `HandlePkiOperation` to the priority ladder.** Sketch (adapt to the existing method body / variable names): + +```csharp + public byte[] HandlePkiOperation(byte[] der) { + X509Certificate2 requester_cert; + string trans_id; + byte[] sender_nonce; + byte[] inner_payload; + CmsSignedData signed; + SignerInformation signer; + + signed = new CmsSignedData(der); + signer = First(signed); + + // 1. Signature integrity. + if (!VerifyOuterSignature(der)) { + return BuildFailureCertRep(RecipientFrom(signed, signer), TransIdFrom(signer), NonceFrom(signer), "1"); + } + // 2. signingTime window (+-5 min). + System.DateTime? when; + when = ReadSigningTime(der); + if (when.HasValue && System.Math.Abs((System.DateTime.UtcNow - when.Value).TotalMinutes) > 5) { + return BuildFailureCertRep(RecipientFrom(signed, signer), TransIdFrom(signer), NonceFrom(signer), "3"); + } + // 3. Forbidden digest (MD5). + if (signer.DigestAlgOid == "1.2.840.113549.2.5") { + return BuildFailureCertRep(RecipientFrom(signed, signer), TransIdFrom(signer), NonceFrom(signer), "0"); + } + // 4. Inner CSR must parse. + if (!InnerCsrParses(der)) { + return BuildFailureCertRep(RecipientFrom(signed, signer), TransIdFrom(signer), NonceFrom(signer), "2"); + } + + DecodeRequest(der, out requester_cert, out trans_id, out sender_nonce, out inner_payload); + + // 5. Expected challenge. + if (ExpectedChallenge != null && ChallengeFrom(inner_payload) != ExpectedChallenge) { + return BuildFailureCertRep(requester_cert, trans_id, sender_nonce, "2"); + } + // 6. Expired signer cert (existing behavior — keep). + if (SignerCertExpired(requester_cert)) { + return BuildFailureCertRep(requester_cert, trans_id, sender_nonce, "2"); + } + // 7. PENDING mode. + if (PendingMode) { + return BuildPendingCertRep(requester_cert, trans_id, sender_nonce); + } + // Success (existing issue path). + return IssueAndBuildSuccess(inner_payload, requester_cert, trans_id, sender_nonce); + } +``` + +> Keep the EXISTING success/issue body — only prepend the ladder. `RecipientFrom/TransIdFrom/NonceFrom/ChallengeFrom/SignerCertExpired/IssueAndBuildSuccess` are thin wrappers over code already in the file; factor minimally, don't rewrite working logic. `ChallengeFrom` reads the PKCS#9 challengePassword attr (OID 1.2.840.113549.1.9.7) from the parsed CSR; if absent return `""`. + +- [ ] **Step 4: Add `BuildPendingCertRep`** mirroring `BuildSuccessCertRep` but with pkiStatus `"3"` and no enveloped cert (empty degenerate PKCS#7 or omit the EnvelopedData content). Reuse `EnvelopeAndSign`/the existing CertRep assembly; set the pkiStatus signed attribute to `"3"` and messageType `"3"`. + +- [ ] **Step 5: Expose caps toggles on `FakeScepServer`.** Add settable properties so probe/lifecycle tests can shape advertised caps and the pending route: + +```csharp + public string CaCapsBody { get; set; } = "POSTPKIOperation\nSHA-256\nAES\n"; + public TestCa Ca { get; } // already present +``` + +Change the GET `GetCACaps` handler to respond with `CaCapsBody`. (POST routing already dispatches by `PeekMessageType`; PENDING is handled inside `TestCa`.) + +- [ ] **Step 6: Write the test** `tests/ScepTestClient.Tests/FaultMatrixServerTests.cs`. For each fault, build via the builder, submit, assert failInfo: + +```csharp +using ScepTestClient.Core; +using ScepTestClient.CryptoApi; +using ScepTestClient.Tests.Fakes; + +namespace ScepTestClient.Tests; + +public sealed class FaultMatrixServerTests { + private static async Task<(ScepClient client, FakeScepServer server, X509CertHolder)> Setup() { /* build client against server */ } + + [Fact] + public async Task CorruptSignature_YieldsBadMessageCheck() { + await RunFault(new FaultDirectives { CorruptSignature = true }, FailInfo.BadMessageCheck); + } + + [Fact] + public async Task SkewedSigningTime_YieldsBadTime() { + await RunFault(new FaultDirectives { SigningTimeSkew = System.TimeSpan.FromHours(2) }, FailInfo.BadTime); + } + + [Fact] + public async Task CorruptInner_YieldsBadRequest() { + await RunFault(new FaultDirectives { CorruptInnerContent = true }, FailInfo.BadRequest); + } + + [Fact] + public async Task Md5Digest_YieldsBadAlg() { + // Build with .Digest("MD5"); no FaultDirectives needed. + await RunDigestFault("MD5", FailInfo.BadAlg); + } + + private static async Task RunFault(FaultDirectives faults, FailInfo expected) { + FakeScepServer server; + ScepClient client; + PkiMessage message; + IScepKey subject_key; + string error; + ScepResult result; + + server = await FakeScepServer.StartAsync(); + try { + client = BuildClientFor(server, out _); + ScepRequestBuilder.For(client.Crypto) + .CaCertificate(server.Ca.CertificateBcl) + .MessageType(MessageType.PkcsReq) + .Subject("CN=fault-client") + .KeySpec("rsa:2048") + .AllowFaults(faults) + .Build(out message, out subject_key, out error); + result = client.SubmitPkiOperation(message, subject_key, faults); + Assert.Equal(PkiStatus.Failure, result.Value.PkiStatus); + Assert.Equal(expected, result.Value.FailInfo); + } finally { + await server.DisposeAsync(); + } + } + + // RunDigestFault: same but .Digest("MD5"), faults: null. + // BuildClientFor: reuse the existing test helper used by EndToEndTests to construct a ScepClient pointed at server.ScepUrl. +} +``` + +> Reuse whatever helper `EndToEndTests`/`GetCertCrlTests` already use to build a `ScepClient` against `FakeScepServer` (look there for the exact construction — `ScepClient.Create(serverConfig, crypto, handler, ...)` with a real `HttpClient` to `server.ScepUrl`). Do NOT invent a new construction pattern. + +- [ ] **Step 7: Run — expect PASS:** `dotnet test --filter FullyQualifiedName~FaultMatrixServer`. Also re-run the full suite to confirm no regression in `EndToEndTests`/`PollTests`/`GetCertCrlTests`. + +- [ ] **Step 8: Commit:** + +```bash +git add tests/ScepTestClient.Tests/Fakes/TestCa.cs \ + tests/ScepTestClient.Tests/Fakes/FakeScepServer.cs \ + tests/ScepTestClient.Tests/FaultMatrixServerTests.cs +git commit -m "Tests: TestCa fault ladder (failInfo) + PENDING mode + expected-challenge" +``` + +--- + +## Task 4: Test result model + ComplianceEngine (`full` matrix) + +**Goal:** Define the shared result model and the compliance fault-matrix engine that runs each deliberate fault, compares the server's `failInfo` to the RFC-expected value, and classifies each as PASSED/FAILED/FINDING. + +**Files:** +- Create: `src/ScepTestClient.Core/Testing/CheckOutcome.cs` +- Create: `src/ScepTestClient.Core/Testing/CheckResult.cs` +- Create: `src/ScepTestClient.Core/Testing/TestReport.cs` +- Create: `src/ScepTestClient.Core/Testing/FaultKind.cs` +- Create: `src/ScepTestClient.Core/Testing/ComplianceCheck.cs` +- Create: `src/ScepTestClient.Core/Testing/ComplianceEngine.cs` +- Test: `tests/ScepTestClient.Tests/ComplianceEngineTests.cs` (create) + +**Acceptance Criteria:** +- [ ] `CheckOutcome` = `{ Passed, Failed, Skipped, Finding }`. +- [ ] `CheckResult` carries `Name`, `Outcome`, `Expected` (`FailInfo`), `Got` (`FailInfo`), `GotStatus` (`PkiStatus`), `Why`, `RfcReference`, `Elapsed`. +- [ ] `TestReport` carries `ServerId`, `Mode`, `Results` (list), `TotalElapsed`, and computed counts `Passed`/`Failed`/`Skipped`/`Findings`. +- [ ] The matrix covers all 7 rows of spec §10.1: forbidden-algo→badAlg, corrupt-sig→badMessageCheck, signingTime-skew→badTime, wrong-challenge→FAILURE, unknown-certId→badCertId, malformed→badRequest, renewal-not-advertised→rejection-or-finding. +- [ ] `ComplianceEngine.RunFull(client, ca_cert, caps)` returns a `TestReport`; when the server is more lenient than RFC (e.g. accepts the fault), the row is a FINDING not a FAILED. +- [ ] Running against `FakeScepServer` yields PASSED for the 5 well-defined failInfo rows. + +**Verify:** `dotnet test --filter FullyQualifiedName~ComplianceEngine` → PASS + +**Steps:** + +- [ ] **Step 1: Result model.** `CheckOutcome.cs`: + +```csharp +namespace ScepTestClient.Core.Testing; + +public enum CheckOutcome { Passed, Failed, Skipped, Finding } +``` + +`CheckResult.cs`: + +```csharp +using ScepTestClient.CryptoApi; + +namespace ScepTestClient.Core.Testing; + +public sealed record CheckResult( + string Name, + CheckOutcome Outcome, + FailInfo Expected, + FailInfo Got, + PkiStatus GotStatus, + string Why, + string RfcReference, + System.TimeSpan Elapsed); +``` + +`TestReport.cs`: + +```csharp +using System.Collections.Generic; +using System.Linq; + +namespace ScepTestClient.Core.Testing; + +public sealed class TestReport { + public string ServerId { get; init; } = string.Empty; + public string Mode { get; init; } = string.Empty; + public List Results { get; } = new(); + public System.TimeSpan TotalElapsed { get; set; } + + public int Passed => Results.Count(r => r.Outcome == CheckOutcome.Passed); + public int Failed => Results.Count(r => r.Outcome == CheckOutcome.Failed); + public int Skipped => Results.Count(r => r.Outcome == CheckOutcome.Skipped); + public int Findings => Results.Count(r => r.Outcome == CheckOutcome.Finding); +} +``` + +- [ ] **Step 2: `FaultKind.cs` + `ComplianceCheck.cs`:** + +```csharp +namespace ScepTestClient.Core.Testing; + +public enum FaultKind { + ForbiddenAlgorithm, // MD5 digest + CorruptedSignature, + SkewedSigningTime, + WrongChallenge, + UnknownCertId, // GetCert unknown serial + MalformedRequest, // corrupt inner CSR + RenewalNotAdvertised, // RenewalReq when Renewal cap absent +} +``` + +```csharp +using ScepTestClient.CryptoApi; + +namespace ScepTestClient.Core.Testing; + +public sealed record ComplianceCheck(string Name, FaultKind Kind, FailInfo Expected, string RfcReference); +``` + +- [ ] **Step 3: `ComplianceEngine.cs`.** The engine owns the matrix and one `RunCheck` per row. Full structure: + +```csharp +using System.Collections.Generic; +using System.Diagnostics; +using System.Security.Cryptography.X509Certificates; +using ScepTestClient.Core.Protocol; +using ScepTestClient.CryptoApi; + +namespace ScepTestClient.Core.Testing; + +public sealed class ComplianceEngine { + private static readonly ComplianceCheck[] Matrix = + { + new("forbidden algorithm (MD5)", FaultKind.ForbiddenAlgorithm, FailInfo.BadAlg, "RFC 8894 §2.9"), + new("corrupted CMS signature", FaultKind.CorruptedSignature, FailInfo.BadMessageCheck,"RFC 8894 §3.2"), + new("signingTime skew (+2h)", FaultKind.SkewedSigningTime, FailInfo.BadTime, "RFC 8894 §3.2.1"), + new("wrong challenge password", FaultKind.WrongChallenge, FailInfo.None, "RFC 8894 §3.2"), + new("GetCert unknown serial", FaultKind.UnknownCertId, FailInfo.BadCertId, "RFC 8894 §3.2"), + new("malformed PKCS#10", FaultKind.MalformedRequest, FailInfo.BadRequest, "RFC 8894 §3.2"), + new("RenewalReq when not advertised", FaultKind.RenewalNotAdvertised, FailInfo.None, "RFC 8894 §3.2"), + }; + + public TestReport RunFull(ScepClient client, X509Certificate2 ca_cert, ScepCapabilities caps) { + TestReport report; + Stopwatch total; + + report = new TestReport { ServerId = client.Server.Id, Mode = "full" }; + total = Stopwatch.StartNew(); + foreach (ComplianceCheck check in Matrix) { + report.Results.Add(RunCheck(client, ca_cert, caps, check)); + } + total.Stop(); + report.TotalElapsed = total.Elapsed; + return report; + } + + private CheckResult RunCheck(ScepClient client, X509Certificate2 ca_cert, ScepCapabilities caps, ComplianceCheck check) { + Stopwatch sw; + FailInfo got; + PkiStatus status; + + sw = Stopwatch.StartNew(); + Execute(client, ca_cert, caps, check, out status, out got); + sw.Stop(); + return Classify(check, status, got, sw.Elapsed); + } + + private void Execute(ScepClient client, X509Certificate2 ca_cert, ScepCapabilities caps, ComplianceCheck check, + out PkiStatus status, out FailInfo got) { + ScepResult result; + + switch (check.Kind) { + case FaultKind.ForbiddenAlgorithm: + result = SubmitEnroll(client, ca_cert, digest: "MD5", challenge: null, faults: null); + break; + case FaultKind.CorruptedSignature: + result = SubmitEnroll(client, ca_cert, digest: null, challenge: null, faults: new FaultDirectives { CorruptSignature = true }); + break; + case FaultKind.SkewedSigningTime: + result = SubmitEnroll(client, ca_cert, digest: null, challenge: null, faults: new FaultDirectives { SigningTimeSkew = System.TimeSpan.FromHours(2) }); + break; + case FaultKind.WrongChallenge: + result = SubmitEnroll(client, ca_cert, digest: null, challenge: "definitely-wrong-" + System.Guid.NewGuid().ToString("N"), faults: null); + break; + case FaultKind.MalformedRequest: + result = SubmitEnroll(client, ca_cert, digest: null, challenge: null, faults: new FaultDirectives { CorruptInnerContent = true }); + break; + case FaultKind.RenewalNotAdvertised: + result = SubmitEnroll(client, ca_cert, digest: null, challenge: null, faults: null, message_type: MessageType.RenewalReq); + break; + case FaultKind.UnknownCertId: + ScepResult cert_result; + cert_result = client.GetCert(ca_cert.Subject, "00DEADBEEF"); + status = cert_result.IsOk ? PkiStatus.Success : PkiStatus.Failure; + got = cert_result.IsOk ? FailInfo.None : ExtractFailInfo(cert_result.Error); + return; + default: + status = PkiStatus.Failure; + got = FailInfo.None; + return; + } + status = result.Value?.PkiStatus ?? PkiStatus.Failure; + got = result.Value?.FailInfo ?? FailInfo.None; + } + + private static ScepResult SubmitEnroll(ScepClient client, X509Certificate2 ca_cert, string? digest, + string? challenge, FaultDirectives? faults, + MessageType message_type = MessageType.PkcsReq) { + ScepRequestBuilder builder; + PkiMessage message; + IScepKey subject_key; + string error; + + builder = ScepRequestBuilder.For(client.Crypto) + .CaCertificate(ca_cert) + .MessageType(message_type) + .Subject("CN=compliance-" + System.Guid.NewGuid().ToString("N").Substring(0, 8)) + .KeySpec("rsa:2048"); + if (digest != null) { builder.Digest(digest); } + if (challenge != null) { builder.Challenge(challenge); } + if (faults != null) { builder.AllowFaults(faults); } + + if (!builder.Build(out message, out subject_key, out error)) { + return ScepResult.Fail(ScepClientResult.InvalidArgument, error); + } + return client.SubmitPkiOperation(message, subject_key, builder.Faults); + } + + private static CheckResult Classify(ComplianceCheck check, PkiStatus status, FailInfo got, System.TimeSpan elapsed) { + CheckOutcome outcome; + string why; + + if (check.Expected == FailInfo.None) { + // Expect a generic FAILURE (server-specific failInfo). Acceptance = the server rejected at all. + if (status == PkiStatus.Failure) { + outcome = CheckOutcome.Passed; + why = $"server rejected as expected (failInfo {got})"; + } else { + outcome = CheckOutcome.Finding; + why = "server accepted a request the RFC lets it reject (more lenient than spec)"; + } + } else if (status == PkiStatus.Failure && got == check.Expected) { + outcome = CheckOutcome.Passed; + why = $"got expected {check.Expected}"; + } else if (status == PkiStatus.Success) { + outcome = CheckOutcome.Finding; + why = $"expected {check.Expected}, but server SUCCEEDED (more lenient than RFC 8894)"; + } else { + outcome = CheckOutcome.Failed; + why = $"expected {check.Expected}, got {got} (status {status})"; + } + return new CheckResult(check.Name, outcome, check.Expected, got, status, why, check.RfcReference, elapsed); + } + + private static FailInfo ExtractFailInfo(string error) { + // ProjectCert failures embed "failInfo X" in the message; map back. Default None. + if (error.Contains("BadCertId")) { return FailInfo.BadCertId; } + if (error.Contains("BadRequest")) { return FailInfo.BadRequest; } + return FailInfo.None; + } +} +``` + +> The `UnknownCertId` row goes through `client.GetCert(...)`, whose failure surfaces in `ScepResult.Error`. To make the failInfo legible, have `ProjectCert`/`ProjectCrl` already include the `FailInfo` enum name in the error string (they do: `$"...failInfo {sent.Value.FailInfo}"`). Adjust `ExtractFailInfo` to match the actual text, or — cleaner — add an internal `client.GetCertRaw(...)` returning the decoded `PkiMessage`. Pick the lower-churn option and note it; the test asserts the outcome, not the mechanism. + +- [ ] **Step 4: Write the test** `tests/ScepTestClient.Tests/ComplianceEngineTests.cs`: + +```csharp +using ScepTestClient.Core; +using ScepTestClient.Core.Protocol; +using ScepTestClient.Core.Testing; +using ScepTestClient.Tests.Fakes; + +namespace ScepTestClient.Tests; + +public sealed class ComplianceEngineTests { + [Fact] + public async Task RunFull_ProducesExpectedOutcomes() { + FakeScepServer server; + ScepClient client; + ScepCapabilities caps; + ComplianceEngine engine; + TestReport report; + + server = await FakeScepServer.StartAsync(); + try { + server.Ca.ExpectedChallenge = "s3cret"; + client = BuildClientFor(server, out _); + caps = client.GetCaCaps().Value; + engine = new ComplianceEngine(); + report = engine.RunFull(client, server.Ca.CertificateBcl, caps); + + Assert.Equal("full", report.Mode); + // The five well-defined failInfo rows pass against the fake: + Assert.Equal(CheckOutcome.Passed, Find(report, "forbidden algorithm (MD5)").Outcome); + Assert.Equal(CheckOutcome.Passed, Find(report, "corrupted CMS signature").Outcome); + Assert.Equal(CheckOutcome.Passed, Find(report, "signingTime skew (+2h)").Outcome); + Assert.Equal(CheckOutcome.Passed, Find(report, "GetCert unknown serial").Outcome); + Assert.Equal(CheckOutcome.Passed, Find(report, "malformed PKCS#10").Outcome); + // The fake handles renewal though caps omit Renewal -> a finding, not a failure. + Assert.Equal(CheckOutcome.Finding, Find(report, "RenewalReq when not advertised").Outcome); + } finally { + await server.DisposeAsync(); + } + } + + private static CheckResult Find(TestReport report, string name) => + report.Results.First(r => r.Name == name); +} +``` + +> `BuildClientFor` is the shared helper from Task 3. The wrong-challenge row passes only because Task 3 wires `ExpectedChallenge`; if the fake instead returns success when no challenge attr matches, this row becomes a FINDING — adjust the assertion to whatever the fake actually does and keep the engine logic intact. + +- [ ] **Step 5: Run — expect PASS:** `dotnet test --filter FullyQualifiedName~ComplianceEngine`. + +- [ ] **Step 6: Commit:** + +```bash +git add src/ScepTestClient.Core/Testing/CheckOutcome.cs \ + src/ScepTestClient.Core/Testing/CheckResult.cs \ + src/ScepTestClient.Core/Testing/TestReport.cs \ + src/ScepTestClient.Core/Testing/FaultKind.cs \ + src/ScepTestClient.Core/Testing/ComplianceCheck.cs \ + src/ScepTestClient.Core/Testing/ComplianceEngine.cs \ + tests/ScepTestClient.Tests/ComplianceEngineTests.cs +git commit -m "Core: compliance fault-matrix engine + test result model" +``` + +--- + +## Task 5: TestEngine — `lifecycle` + `probe` modes + +**Goal:** Add the happy-path `lifecycle` smoke and the beyond-advertised `probe`, both producing a `TestReport`; `full` delegates to `ComplianceEngine`. + +**Files:** +- Create: `src/ScepTestClient.Core/Testing/TestEngine.cs` +- Test: `tests/ScepTestClient.Tests/TestEngineModesTests.cs` (create) + +**Acceptance Criteria:** +- [ ] `TestEngine.RunLifecycle(client, store, log)`: GetCACaps → GetCACert → enroll → poll-if-pending → renew → GetCRL; each step is a `CheckResult`; a step is `Skipped` only when its prerequisite failed. +- [ ] `TestEngine.RunProbe(client)`: attempts SHA-256 when only SHA-1 advertised, POST when `POSTPKIOperation` unadvertised, and `GetNextCACert`; reports each as PASSED (worked) / FINDING (worked beyond advertised) / FAILED. +- [ ] `TestEngine.RunFull(...)` delegates to `ComplianceEngine.RunFull`. +- [ ] Lifecycle against `FakeScepServer` ends with mostly PASSED; probe reports SHA-256/POST as working. + +**Verify:** `dotnet test --filter FullyQualifiedName~TestEngineModes` → PASS + +**Steps:** + +- [ ] **Step 1: `TestEngine.cs`.** Full sketch (lifecycle + probe; reuse `ComplianceEngine` for full): + +```csharp +using System.Diagnostics; +using System.Security.Cryptography.X509Certificates; +using ScepTestClient.Core.Protocol; +using ScepTestClient.Core.Storage; +using ScepTestClient.CryptoApi; + +namespace ScepTestClient.Core.Testing; + +public sealed class TestEngine { + public TestReport RunFull(ScepClient client, X509Certificate2 ca_cert, ScepCapabilities caps) => + new ComplianceEngine().RunFull(client, ca_cert, caps); + + public TestReport RunLifecycle(ScepClient client, CertStore store, UseRecordLog log) { + TestReport report; + Stopwatch total; + bool caps_ok; + bool ca_ok; + bool enroll_ok; + X509Certificate2? issued; + string? cert_id; + + report = new TestReport { ServerId = client.Server.Id, Mode = "lifecycle" }; + total = Stopwatch.StartNew(); + + caps_ok = Step(report, "GetCACaps", () => client.GetCaCaps().IsOk); + ca_ok = Step(report, "GetCACert", () => client.GetCaCert().IsOk); + + issued = null; + cert_id = null; + if (!ca_ok) { + Skip(report, "enroll", "GetCACert failed"); + Skip(report, "renew", "enroll skipped"); + Skip(report, "GetCRL", "enroll skipped"); + total.Stop(); report.TotalElapsed = total.Elapsed; return report; + } + + enroll_ok = StepEnroll(report, client, store, log, out issued, out cert_id); + if (!enroll_ok || cert_id == null) { + Skip(report, "renew", "enroll failed"); + } else { + Step(report, "renew", () => client.RenewCertificate(cert_id!, store, log).IsOk); + } + Step(report, "GetCRL", () => client.GetCrl(client.GetCaCert().Value[0].Subject, "01").Status != ScepClientResult.NetworkError); + + total.Stop(); + report.TotalElapsed = total.Elapsed; + return report; + } + + public TestReport RunProbe(ScepClient client) { + TestReport report; + Stopwatch total; + ScepCapabilities caps; + + report = new TestReport { ServerId = client.Server.Id, Mode = "probe" }; + total = Stopwatch.StartNew(); + caps = client.GetCaCaps().Value ?? ScepCapabilities.Parse(string.Empty); + + ProbeDigest(report, client, caps); + ProbeGetNextCa(report, client, caps); + + total.Stop(); + report.TotalElapsed = total.Elapsed; + return report; + } + + // Step/Skip/StepEnroll/ProbeDigest/ProbeGetNextCa: small private helpers below. + private static bool Step(TestReport report, string name, System.Func action) { + Stopwatch sw; + bool ok; + + sw = Stopwatch.StartNew(); + try { ok = action(); } catch (System.Exception) { ok = false; } + sw.Stop(); + report.Results.Add(new CheckResult(name, ok ? CheckOutcome.Passed : CheckOutcome.Failed, + FailInfo.None, FailInfo.None, ok ? PkiStatus.Success : PkiStatus.Failure, + ok ? "ok" : "step failed", "RFC 8894", sw.Elapsed)); + return ok; + } + + private static void Skip(TestReport report, string name, string why) { + report.Results.Add(new CheckResult(name, CheckOutcome.Skipped, FailInfo.None, FailInfo.None, + PkiStatus.Failure, why, "RFC 8894", System.TimeSpan.Zero)); + } +} +``` + +> `StepEnroll` builds a minimal `EnrollRequest` (RSA-2048, `CN=lifecycle-`), calls `client.GetNewCertificate(request, store, log)`, records PASSED/FAILED, and out-returns the issued cert + its stored cert-id (derive the cert-id the same way `certs list` does — from the store path; reuse `CertStore`/`FindServerForCert`). `ProbeDigest` enrolls with `.Digest("SHA-256")`; if caps say only SHA-1 and it still succeeds → FINDING; if advertised and succeeds → PASSED. `ProbeGetNextCa` calls `client.GetNextCaCert()`; success while `!caps.GetNextCaCert` → FINDING. + +- [ ] **Step 2: Write the test** `tests/ScepTestClient.Tests/TestEngineModesTests.cs`: + +```csharp +using ScepTestClient.Core; +using ScepTestClient.Core.Testing; +using ScepTestClient.Tests.Fakes; + +namespace ScepTestClient.Tests; + +public sealed class TestEngineModesTests { + [Fact] + public async Task Lifecycle_AllStepsRun() { + FakeScepServer server; + ScepClient client; + TestEngine engine; + TestReport report; + + server = await FakeScepServer.StartAsync(); + try { + client = BuildClientWithStore(server, out var store, out var log); + engine = new TestEngine(); + report = engine.RunLifecycle(client, store, log); + + Assert.Equal("lifecycle", report.Mode); + Assert.Contains(report.Results, r => r.Name == "GetCACaps" && r.Outcome == CheckOutcome.Passed); + Assert.Contains(report.Results, r => r.Name == "enroll"); + Assert.DoesNotContain(report.Results, r => r.Name == "renew" && r.Outcome == CheckOutcome.Skipped); + } finally { + await server.DisposeAsync(); + } + } + + [Fact] + public async Task Probe_Sha256_Works() { + FakeScepServer server; + ScepClient client; + TestReport report; + + server = await FakeScepServer.StartAsync(); + try { + client = BuildClientFor(server, out _); + report = new TestEngine().RunProbe(client); + Assert.Equal("probe", report.Mode); + Assert.Contains(report.Results, r => r.Name.Contains("SHA-256")); + } finally { + await server.DisposeAsync(); + } + } +} +``` + +> `BuildClientWithStore` builds a `ScepClient` plus a temp-dir `CertStore`/`UseRecordLog` (use `Path.GetTempPath()` + a guid, like `StorageTests` already do — reuse their pattern). + +- [ ] **Step 3: Run — expect PASS.** **Step 4: Commit:** + +```bash +git add src/ScepTestClient.Core/Testing/TestEngine.cs \ + tests/ScepTestClient.Tests/TestEngineModesTests.cs +git commit -m "Core: TestEngine lifecycle + probe modes" +``` + +--- + +## Task 6: Security opinion + thresholds + `servers suggest` + +**Goal:** Classify algorithms by posture, hold thresholds in `config.json`, and generate the exact `enroll` command per algorithm the server actually supports. + +**Files:** +- Create: `src/ScepTestClient.Core/Testing/OpinionThresholds.cs` +- Create: `src/ScepTestClient.Core/Testing/SecurityOpinion.cs` +- Create: `src/ScepTestClient.Core/Testing/ServerSuggest.cs` +- Modify: `src/ScepTestClient.Core/Storage/ClientConfig.cs` +- Test: `tests/ScepTestClient.Tests/SecurityOpinionTests.cs` (create) + +**Acceptance Criteria:** +- [ ] `AlgorithmPosture` = `{ MustNot, LegacyWeak, Modern, CuttingEdge, Unknown }`. +- [ ] `SecurityOpinion.ClassifyDigest/ClassifyCipher` map: MD5→MustNot, SHA-1→LegacyWeak, SHA-256/512→Modern; single-DES→MustNot, 3DES→LegacyWeak, AES→Modern. +- [ ] `SecurityOpinion.ClassifyRsa(bits, thresholds)` → `MustNot`/`LegacyWeak` when `bits < thresholds.MinRsaKeyBits`, else `Modern`. +- [ ] `ClientConfig` gains `MinRsaKeyBits` (default 2048), round-trips through `config.json`. +- [ ] `ServerSuggest.For(server_id, server_url, caps)` returns one `sceptest enroll ...` line per supported digest×cipher the server advertises (modern first). + +**Verify:** `dotnet test --filter FullyQualifiedName~SecurityOpinion` → PASS + +**Steps:** + +- [ ] **Step 1: `OpinionThresholds.cs`:** + +```csharp +namespace ScepTestClient.Core.Testing; + +public sealed class OpinionThresholds { + public int MinRsaKeyBits { get; init; } = 2048; +} +``` + +- [ ] **Step 2: `SecurityOpinion.cs`:** + +```csharp +namespace ScepTestClient.Core.Testing; + +public enum AlgorithmPosture { MustNot, LegacyWeak, Modern, CuttingEdge, Unknown } + +public static class SecurityOpinion { + public static AlgorithmPosture ClassifyDigest(string name) { + switch ((name ?? string.Empty).ToUpperInvariant()) { + case "MD5": return AlgorithmPosture.MustNot; + case "SHA-1": return AlgorithmPosture.LegacyWeak; + case "SHA-256": + case "SHA-512": return AlgorithmPosture.Modern; + default: return AlgorithmPosture.Unknown; + } + } + + public static AlgorithmPosture ClassifyCipher(string name) { + switch ((name ?? string.Empty).ToUpperInvariant()) { + case "DES": + case "DES-CBC": return AlgorithmPosture.MustNot; + case "DES-EDE3-CBC": + case "DES3": + case "3DES": return AlgorithmPosture.LegacyWeak; + case "AES-128-CBC": + case "AES-256-CBC": + case "AES": return AlgorithmPosture.Modern; + default: return AlgorithmPosture.Unknown; + } + } + + public static AlgorithmPosture ClassifyRsa(int bits, OpinionThresholds thresholds) { + if (bits < 1024) { return AlgorithmPosture.MustNot; } + if (bits < thresholds.MinRsaKeyBits) { return AlgorithmPosture.LegacyWeak; } + return AlgorithmPosture.Modern; + } +} +``` + +- [ ] **Step 3: `ServerSuggest.cs`:** + +```csharp +using System.Collections.Generic; +using ScepTestClient.Core.Protocol; + +namespace ScepTestClient.Core.Testing; + +public static class ServerSuggest { + public static IReadOnlyList For(string server_id, ScepCapabilities caps) { + List lines; + List digests; + List ciphers; + + lines = new List(); + digests = new List(); + ciphers = new List(); + + if (caps.Sha256) { digests.Add("SHA-256"); } + if (caps.Sha512) { digests.Add("SHA-512"); } + if (caps.Sha1) { digests.Add("SHA-1"); } + if (digests.Count == 0) { digests.Add("SHA-256"); } + + if (caps.Aes) { ciphers.Add("AES-128-CBC"); } + if (caps.Des3) { ciphers.Add("DES-EDE3-CBC"); } + if (ciphers.Count == 0) { ciphers.Add("AES-128-CBC"); } + + foreach (string digest in digests) { + foreach (string cipher in ciphers) { + lines.Add($"sceptest enroll {server_id} --subject \"CN=test\" --key-spec rsa:2048 --digest {digest} --cipher {cipher}"); + } + } + return lines; + } +} +``` + +- [ ] **Step 4: Extend `ClientConfig`.** Add the property with the others: + +```csharp + public int MinRsaKeyBits { get; set; } = 2048; +``` + +- [ ] **Step 5: Write the test** `tests/ScepTestClient.Tests/SecurityOpinionTests.cs`: + +```csharp +using System.IO; +using ScepTestClient.Core.Protocol; +using ScepTestClient.Core.Storage; +using ScepTestClient.Core.Testing; + +namespace ScepTestClient.Tests; + +public sealed class SecurityOpinionTests { + [Fact] + public void Digest_Postures() { + Assert.Equal(AlgorithmPosture.MustNot, SecurityOpinion.ClassifyDigest("MD5")); + Assert.Equal(AlgorithmPosture.LegacyWeak, SecurityOpinion.ClassifyDigest("SHA-1")); + Assert.Equal(AlgorithmPosture.Modern, SecurityOpinion.ClassifyDigest("SHA-256")); + } + + [Fact] + public void Rsa_BelowThreshold_IsWeak() { + OpinionThresholds thresholds; + + thresholds = new OpinionThresholds { MinRsaKeyBits = 2048 }; + Assert.Equal(AlgorithmPosture.LegacyWeak, SecurityOpinion.ClassifyRsa(1024, thresholds)); + Assert.Equal(AlgorithmPosture.Modern, SecurityOpinion.ClassifyRsa(2048, thresholds)); + } + + [Fact] + public void Config_MinRsaKeyBits_RoundTrips() { + string root; + ClientConfig config; + ClientConfig reloaded; + + root = Path.Combine(Path.GetTempPath(), "sceptest-" + System.Guid.NewGuid().ToString("N")); + config = new ClientConfig { MinRsaKeyBits = 3072 }; + config.Save(root); + reloaded = ClientConfig.Load(root); + Assert.Equal(3072, reloaded.MinRsaKeyBits); + } + + [Fact] + public void Suggest_EmitsCommandsForAdvertisedAlgorithms() { + ScepCapabilities caps; + System.Collections.Generic.IReadOnlyList lines; + + caps = ScepCapabilities.Parse("SHA-256\nAES\n"); + lines = ServerSuggest.For("testhost", caps); + Assert.Contains(lines, l => l.Contains("--digest SHA-256") && l.Contains("--cipher AES-128-CBC")); + } +} +``` + +- [ ] **Step 6: Run — expect PASS. Step 7: Commit:** + +```bash +git add src/ScepTestClient.Core/Testing/OpinionThresholds.cs \ + src/ScepTestClient.Core/Testing/SecurityOpinion.cs \ + src/ScepTestClient.Core/Testing/ServerSuggest.cs \ + src/ScepTestClient.Core/Storage/ClientConfig.cs \ + tests/ScepTestClient.Tests/SecurityOpinionTests.cs +git commit -m "Core: security opinion postures + thresholds + servers suggest" +``` + +--- + +## Task 7: Jamf timing simulation (`--jamf-max-wait`) + +**Goal:** Reproduce Jamf's "doesn't poll properly" behavior: when enrollment goes PENDING and needs CertPoll, fail once the wait exceeds the threshold; record timing regardless. + +**Files:** +- Create: `src/ScepTestClient.Core/Testing/JamfResult.cs` +- Create: `src/ScepTestClient.Core/Testing/JamfSimulator.cs` +- Test: `tests/ScepTestClient.Tests/JamfSimulatorTests.cs` (create) + +**Acceptance Criteria:** +- [ ] `JamfResult` carries `TimedOut` (bool), `FinalStatus` (`PkiStatus`), `Elapsed`, `PollCount`, `Certificate`. +- [ ] `JamfSimulator.Run(client, request, issuer_dn, max_wait, poll_interval)`: enroll; if not PENDING, return immediately (`TimedOut=false`); if PENDING, poll every `poll_interval` until success or until elapsed > `max_wait` → `TimedOut=true`. +- [ ] Against `FakeScepServer` with `PendingMode=true` and a short `max_wait`, returns `TimedOut=true`. +- [ ] With `PendingMode=false`, returns `TimedOut=false` and an issued cert. + +**Verify:** `dotnet test --filter FullyQualifiedName~JamfSimulator` → PASS + +**Steps:** + +- [ ] **Step 1: `JamfResult.cs`:** + +```csharp +using System.Security.Cryptography.X509Certificates; +using ScepTestClient.CryptoApi; + +namespace ScepTestClient.Core.Testing; + +public sealed record JamfResult( + bool TimedOut, + PkiStatus FinalStatus, + System.TimeSpan Elapsed, + int PollCount, + X509Certificate2? Certificate); +``` + +- [ ] **Step 2: `JamfSimulator.cs`:** + +```csharp +using System.Diagnostics; +using System.Threading; +using ScepTestClient.CryptoApi; + +namespace ScepTestClient.Core.Testing; + +public static class JamfSimulator { + public static JamfResult Run(ScepClient client, EnrollRequest request, string issuer_dn, + System.TimeSpan max_wait, System.TimeSpan poll_interval) { + Stopwatch sw; + ScepResult enroll; + int polls; + ScepResult poll; + + sw = Stopwatch.StartNew(); + enroll = client.Enroll(request); + if (enroll.Status != ScepClientResult.Pending) { + sw.Stop(); + return new JamfResult(false, enroll.Value?.PkiStatus ?? PkiStatus.Failure, sw.Elapsed, 0, enroll.Value?.Certificate); + } + + polls = 0; + while (true) { + if (sw.Elapsed > max_wait) { + sw.Stop(); + return new JamfResult(true, PkiStatus.Pending, sw.Elapsed, polls, null); + } + Thread.Sleep(poll_interval); + polls++; + poll = client.Poll(issuer_dn, request.Subject, enroll.Value!.TransactionId); + if (poll.Status != ScepClientResult.Pending) { + sw.Stop(); + return new JamfResult(false, poll.Value?.PkiStatus ?? PkiStatus.Failure, sw.Elapsed, polls, poll.Value?.Certificate); + } + } + } +} +``` + +> `Thread.Sleep` is acceptable here (deliberate timing sim, not a control-flow hack). Tests use tiny intervals (e.g. 20 ms) and a small `max_wait` (e.g. 60 ms) so the loop exits quickly. + +- [ ] **Step 3: Write the test** `tests/ScepTestClient.Tests/JamfSimulatorTests.cs`: + +```csharp +using ScepTestClient.Core; +using ScepTestClient.Core.Testing; +using ScepTestClient.CryptoApi; +using ScepTestClient.Tests.Fakes; + +namespace ScepTestClient.Tests; + +public sealed class JamfSimulatorTests { + [Fact] + public async Task Pending_ExceedsMaxWait_TimesOut() { + FakeScepServer server; + ScepClient client; + EnrollRequest request; + JamfResult result; + + server = await FakeScepServer.StartAsync(); + try { + server.Ca.PendingMode = true; + client = BuildClientFor(server, out IScepCrypto crypto); + request = BuildEnrollRequest(crypto, server.Ca.CertificateBcl); + result = JamfSimulator.Run(client, request, server.Ca.CertificateBcl.Subject, + System.TimeSpan.FromMilliseconds(60), System.TimeSpan.FromMilliseconds(20)); + Assert.True(result.TimedOut); + Assert.True(result.PollCount >= 1); + } finally { + await server.DisposeAsync(); + } + } + + [Fact] + public async Task Inline_Issue_DoesNotTimeOut() { + FakeScepServer server; + ScepClient client; + EnrollRequest request; + JamfResult result; + + server = await FakeScepServer.StartAsync(); + try { + client = BuildClientFor(server, out IScepCrypto crypto); + request = BuildEnrollRequest(crypto, server.Ca.CertificateBcl); + result = JamfSimulator.Run(client, request, server.Ca.CertificateBcl.Subject, + System.TimeSpan.FromSeconds(2), System.TimeSpan.FromMilliseconds(20)); + Assert.False(result.TimedOut); + Assert.NotNull(result.Certificate); + } finally { + await server.DisposeAsync(); + } + } +} +``` + +> `BuildEnrollRequest` builds an RSA-2048 `EnrollRequest` with `CaCertificate` set to the fake CA cert (so `Enroll` doesn't re-fetch). Reuse the `EndToEndTests` enroll-request construction. + +- [ ] **Step 4: Run — expect PASS. Step 5: Commit:** + +```bash +git add src/ScepTestClient.Core/Testing/JamfResult.cs \ + src/ScepTestClient.Core/Testing/JamfSimulator.cs \ + tests/ScepTestClient.Tests/JamfSimulatorTests.cs +git commit -m "Core: jamf --jamf-max-wait PENDING timing simulation" +``` + +--- + +## Task 8: Report emitters — JUnit + TRX (XML) + +**Goal:** Render a `TestReport` as JUnit XML (primary interchange) and TRX (vstest-native). + +**Files:** +- Create: `src/ScepTestClient.Core/Reporting/JUnitReport.cs` +- Create: `src/ScepTestClient.Core/Reporting/TrxReport.cs` +- Test: `tests/ScepTestClient.Tests/XmlReportTests.cs` (create) + +**Acceptance Criteria:** +- [ ] `JUnitReport.Emit(report)` returns well-formed XML: one `` with `tests/failures/skipped` counts; one `` per result; FAILED → `` with the expected/got/why message; FINDING → a `` note (counted as passing but annotated). +- [ ] `TrxReport.Emit(report)` returns well-formed `` XML with ``/`` and an outcome (`Passed`/`Failed`) per result. +- [ ] Both parse via `System.Xml.Linq.XDocument.Parse` without throwing. + +**Verify:** `dotnet test --filter FullyQualifiedName~XmlReport` → PASS + +**Steps:** + +- [ ] **Step 1: `JUnitReport.cs`** (use `System.Xml.Linq`, no string concatenation of unescaped values): + +```csharp +using System.Linq; +using System.Xml.Linq; +using ScepTestClient.Core.Testing; + +namespace ScepTestClient.Core.Reporting; + +public static class JUnitReport { + public static string Emit(TestReport report) { + XElement suite; + + suite = new XElement("testsuite", + new XAttribute("name", $"scep-{report.ServerId}-{report.Mode}"), + new XAttribute("tests", report.Results.Count), + new XAttribute("failures", report.Failed), + new XAttribute("skipped", report.Skipped), + new XAttribute("time", report.TotalElapsed.TotalSeconds)); + + foreach (CheckResult result in report.Results) { + XElement test_case; + + test_case = new XElement("testcase", + new XAttribute("name", result.Name), + new XAttribute("classname", $"scep.{report.Mode}"), + new XAttribute("time", result.Elapsed.TotalSeconds)); + + if (result.Outcome == CheckOutcome.Failed) { + test_case.Add(new XElement("failure", + new XAttribute("message", $"expected {result.Expected}, got {result.Got}"), + result.Why + " (" + result.RfcReference + ")")); + } else if (result.Outcome == CheckOutcome.Skipped) { + test_case.Add(new XElement("skipped", new XAttribute("message", result.Why))); + } else if (result.Outcome == CheckOutcome.Finding) { + test_case.Add(new XElement("system-out", "FINDING: " + result.Why + " (" + result.RfcReference + ")")); + } + suite.Add(test_case); + } + + return new XDocument(new XElement("testsuites", suite)).ToString(); + } +} +``` + +- [ ] **Step 2: `TrxReport.cs`** (minimal valid TRX; namespace `http://microsoft.com/schemas/VisualStudio/TeamTest/2010`): + +```csharp +using System.Xml.Linq; +using ScepTestClient.Core.Testing; + +namespace ScepTestClient.Core.Reporting; + +public static class TrxReport { + private static readonly XNamespace Ns = "http://microsoft.com/schemas/VisualStudio/TeamTest/2010"; + + public static string Emit(TestReport report) { + XElement results; + XElement run; + + results = new XElement(Ns + "Results"); + foreach (CheckResult result in report.Results) { + results.Add(new XElement(Ns + "UnitTestResult", + new XAttribute("testName", result.Name), + new XAttribute("outcome", result.Outcome == CheckOutcome.Failed ? "Failed" : "Passed"), + new XAttribute("duration", result.Elapsed.ToString()), + new XElement(Ns + "Output", new XElement(Ns + "StdOut", result.Why)))); + } + + run = new XElement(Ns + "TestRun", + new XAttribute("name", $"scep-{report.ServerId}-{report.Mode}"), + results); + return new XDocument(run).ToString(); + } +} +``` + +- [ ] **Step 3: Write the test** `tests/ScepTestClient.Tests/XmlReportTests.cs`: + +```csharp +using System.Xml.Linq; +using ScepTestClient.Core.Reporting; +using ScepTestClient.Core.Testing; +using ScepTestClient.CryptoApi; + +namespace ScepTestClient.Tests; + +public sealed class XmlReportTests { + private static TestReport Sample() { + TestReport report; + + report = new TestReport { ServerId = "testhost", Mode = "full" }; + report.Results.Add(new CheckResult("ok check", CheckOutcome.Passed, FailInfo.None, FailInfo.None, PkiStatus.Failure, "got expected", "RFC 8894", System.TimeSpan.FromMilliseconds(10))); + report.Results.Add(new CheckResult("bad check", CheckOutcome.Failed, FailInfo.BadTime, FailInfo.None, PkiStatus.Success, "server accepted skew", "RFC 8894 §3.2.1", System.TimeSpan.FromMilliseconds(20))); + return report; + } + + [Fact] + public void JUnit_IsWellFormed_WithFailure() { + string xml; + XDocument doc; + + xml = JUnitReport.Emit(Sample()); + doc = XDocument.Parse(xml); + Assert.NotNull(doc.Root); + Assert.Contains("bad check", xml); + Assert.Contains("failure", xml); + } + + [Fact] + public void Trx_IsWellFormed() { + string xml; + + xml = TrxReport.Emit(Sample()); + Assert.NotNull(XDocument.Parse(xml).Root); + Assert.Contains("Failed", xml); + } +} +``` + +- [ ] **Step 4: Run — expect PASS. Step 5: Commit:** + +```bash +git add src/ScepTestClient.Core/Reporting/JUnitReport.cs \ + src/ScepTestClient.Core/Reporting/TrxReport.cs \ + tests/ScepTestClient.Tests/XmlReportTests.cs +git commit -m "Core: JUnit + TRX report emitters" +``` + +--- + +## Task 9: Report emitters — JSON + Markdown + console summary + +**Goal:** Render a `TestReport` as machine JSON, a Markdown summary, and the styled console summary block from spec §12. + +**Files:** +- Create: `src/ScepTestClient.Core/Reporting/JsonReport.cs` +- Create: `src/ScepTestClient.Core/Reporting/MarkdownReport.cs` +- Create: `src/ScepTestClient.Core/Reporting/ConsoleSummary.cs` +- Test: `tests/ScepTestClient.Tests/TextReportTests.cs` (create) + +**Acceptance Criteria:** +- [ ] `JsonReport.Emit(report)` returns JSON with `serverId`, `mode`, `totals` (passed/failed/skipped/findings), and a `results` array (name/outcome/expected/got/why/elapsedMs). +- [ ] `MarkdownReport.Emit(report)` returns a Markdown doc with a heading and a table of results. +- [ ] `ConsoleSummary.Format(report)` returns the `PASSED n / FAILED n / SKIPPED n / FINDINGS n` block, then a `FAILED:` section (expected/got/why per failure) and a `FINDINGS:` section. + +**Verify:** `dotnet test --filter FullyQualifiedName~TextReport` → PASS + +**Steps:** + +- [ ] **Step 1: `JsonReport.cs`** (use `System.Text.Json` with a projected shape so the wire format is stable): + +```csharp +using System.Linq; +using System.Text.Json; +using ScepTestClient.Core.Testing; + +namespace ScepTestClient.Core.Reporting; + +public static class JsonReport { + public static string Emit(TestReport report) { + object payload; + + payload = new { + serverId = report.ServerId, + mode = report.Mode, + totals = new { passed = report.Passed, failed = report.Failed, skipped = report.Skipped, findings = report.Findings }, + totalElapsedMs = (long)report.TotalElapsed.TotalMilliseconds, + results = report.Results.Select(r => new { + name = r.Name, + outcome = r.Outcome.ToString(), + expected = r.Expected.ToString(), + got = r.Got.ToString(), + status = r.GotStatus.ToString(), + why = r.Why, + rfc = r.RfcReference, + elapsedMs = (long)r.Elapsed.TotalMilliseconds, + }).ToArray(), + }; + return JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true }); + } +} +``` + +- [ ] **Step 2: `MarkdownReport.cs`:** + +```csharp +using System.Text; +using ScepTestClient.Core.Testing; + +namespace ScepTestClient.Core.Reporting; + +public static class MarkdownReport { + public static string Emit(TestReport report) { + StringBuilder sb; + + sb = new StringBuilder(); + sb.AppendLine($"# SCEP test run — {report.ServerId} — {report.Mode}"); + sb.AppendLine(); + sb.AppendLine($"PASSED {report.Passed} · FAILED {report.Failed} · SKIPPED {report.Skipped} · FINDINGS {report.Findings} · {report.TotalElapsed.TotalSeconds:0.0}s"); + sb.AppendLine(); + sb.AppendLine("| Check | Outcome | Expected | Got | Why |"); + sb.AppendLine("|---|---|---|---|---|"); + foreach (CheckResult r in report.Results) { + sb.AppendLine($"| {r.Name} | {r.Outcome} | {r.Expected} | {r.Got} | {r.Why} |"); + } + return sb.ToString(); + } +} +``` + +- [ ] **Step 3: `ConsoleSummary.cs`** (matches the §12 example layout): + +```csharp +using System.Text; +using ScepTestClient.Core.Testing; + +namespace ScepTestClient.Core.Reporting; + +public static class ConsoleSummary { + public static string Format(TestReport report) { + StringBuilder sb; + + sb = new StringBuilder(); + sb.AppendLine($"SCEP test run — {report.ServerId} — {report.Mode} {report.TotalElapsed.TotalSeconds:0.0}s"); + sb.AppendLine($" PASSED {report.Passed}"); + sb.AppendLine($" FAILED {report.Failed}"); + sb.AppendLine($" SKIPPED {report.Skipped}"); + sb.AppendLine($" FINDINGS {report.Findings}"); + + if (report.Failed > 0) { + sb.AppendLine(); + sb.AppendLine("FAILED:"); + foreach (CheckResult r in report.Results) { + if (r.Outcome == CheckOutcome.Failed) { + sb.AppendLine($" ✗ {r.Name} → expected {r.Expected}, got {r.Got}"); + sb.AppendLine($" {r.Why} ({r.RfcReference})"); + } + } + } + if (report.Findings > 0) { + sb.AppendLine(); + sb.AppendLine("FINDINGS:"); + foreach (CheckResult r in report.Results) { + if (r.Outcome == CheckOutcome.Finding) { + sb.AppendLine($" • {r.Name}: {r.Why}"); + } + } + } + return sb.ToString(); + } +} +``` + +- [ ] **Step 4: Write the test** `tests/ScepTestClient.Tests/TextReportTests.cs`: + +```csharp +using System.Text.Json; +using ScepTestClient.Core.Reporting; +using ScepTestClient.Core.Testing; +using ScepTestClient.CryptoApi; + +namespace ScepTestClient.Tests; + +public sealed class TextReportTests { + private static TestReport Sample() { + TestReport report; + + report = new TestReport { ServerId = "testhost", Mode = "full" }; + report.Results.Add(new CheckResult("ok", CheckOutcome.Passed, FailInfo.BadAlg, FailInfo.BadAlg, PkiStatus.Failure, "got expected BadAlg", "RFC 8894 §2.9", System.TimeSpan.FromMilliseconds(5))); + report.Results.Add(new CheckResult("skew", CheckOutcome.Failed, FailInfo.BadTime, FailInfo.None, PkiStatus.Success, "server accepted +2h skew", "RFC 8894 §3.2.1", System.TimeSpan.FromMilliseconds(7))); + report.Results.Add(new CheckResult("lenient", CheckOutcome.Finding, FailInfo.None, FailInfo.None, PkiStatus.Success, "SHA-256 works though only SHA-1 advertised", "under-advertised", System.TimeSpan.FromMilliseconds(3))); + return report; + } + + [Fact] + public void Json_HasTotalsAndResults() { + string json; + JsonDocument doc; + + json = JsonReport.Emit(Sample()); + doc = JsonDocument.Parse(json); + Assert.Equal(1, doc.RootElement.GetProperty("totals").GetProperty("failed").GetInt32()); + Assert.Equal("testhost", doc.RootElement.GetProperty("serverId").GetString()); + } + + [Fact] + public void Console_ShowsFailedAndFindings() { + string text; + + text = ConsoleSummary.Format(Sample()); + Assert.Contains("FAILED 1", text); + Assert.Contains("FINDINGS 1", text); + Assert.Contains("expected BadTime, got None", text); + } + + [Fact] + public void Markdown_HasTable() { + string md; + + md = MarkdownReport.Emit(Sample()); + Assert.Contains("| Check | Outcome", md); + } +} +``` + +- [ ] **Step 5: Run — expect PASS. Step 6: Commit:** + +```bash +git add src/ScepTestClient.Core/Reporting/JsonReport.cs \ + src/ScepTestClient.Core/Reporting/MarkdownReport.cs \ + src/ScepTestClient.Core/Reporting/ConsoleSummary.cs \ + tests/ScepTestClient.Tests/TextReportTests.cs +git commit -m "Core: JSON + Markdown + console-summary report emitters" +``` + +--- + +## Task 10: Scenario / playlist runner + +**Goal:** Execute a declarative JSON playlist of steps (each `run` + `args` + `expect`) in order, aggregating all into one `TestReport`. + +**Files:** +- Create: `src/ScepTestClient.Core/Testing/ScenarioFile.cs` +- Create: `src/ScepTestClient.Core/Testing/ScenarioRunner.cs` +- Test: `tests/ScepTestClient.Tests/ScenarioRunnerTests.cs` (create) + +**Acceptance Criteria:** +- [ ] `ScenarioFile` deserializes `{ name, steps:[{ name, run, server, args:{...}, expect }] }` from JSON; `expect` is one of `pass`/`fail`/a `failInfo` token (`badAlg`/`badMessageCheck`/`badTime`/`badRequest`/`badCertId`). +- [ ] `ScenarioRunner.Run(client, scenario, ca_cert)` executes each step (`getcacaps`, `enroll`, `probe`) and records a `CheckResult` whose `Outcome` is Passed when the actual result matches `expect`, else Failed. +- [ ] All steps aggregate into one `TestReport` with `Mode = "scenario"`. + +**Verify:** `dotnet test --filter FullyQualifiedName~ScenarioRunner` → PASS + +**Steps:** + +- [ ] **Step 1: `ScenarioFile.cs`:** + +```csharp +using System.Collections.Generic; + +namespace ScepTestClient.Core.Testing; + +public sealed class ScenarioFile { + public string Name { get; set; } = string.Empty; + public List Steps { get; set; } = new(); +} + +public sealed class ScenarioStep { + public string Name { get; set; } = string.Empty; + public string Run { get; set; } = string.Empty; + public string? Server { get; set; } + public Dictionary Args { get; set; } = new(); + public string Expect { get; set; } = "pass"; +} +``` + +- [ ] **Step 2: `ScenarioRunner.cs`** (maps each step's actual outcome to the `expect`): + +```csharp +using System.Diagnostics; +using System.Security.Cryptography.X509Certificates; +using System.Text.Json; +using ScepTestClient.CryptoApi; + +namespace ScepTestClient.Core.Testing; + +public static class ScenarioRunner { + public static bool Parse(string json, out ScenarioFile scenario, out string error) { + ScenarioFile? parsed; + + scenario = null!; + error = string.Empty; + try { + parsed = JsonSerializer.Deserialize(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + } catch (System.Exception ex) { + error = ex.Message; + return false; + } + if (parsed == null) { error = "empty scenario"; return false; } + scenario = parsed; + return true; + } + + public static TestReport Run(ScepClient client, ScenarioFile scenario, X509Certificate2 ca_cert) { + TestReport report; + Stopwatch total; + + report = new TestReport { ServerId = client.Server.Id, Mode = "scenario" }; + total = Stopwatch.StartNew(); + foreach (ScenarioStep step in scenario.Steps) { + report.Results.Add(RunStep(client, ca_cert, step)); + } + total.Stop(); + report.TotalElapsed = total.Elapsed; + return report; + } + + private static CheckResult RunStep(ScepClient client, X509Certificate2 ca_cert, ScenarioStep step) { + Stopwatch sw; + PkiStatus status; + FailInfo got; + bool matched; + string why; + + sw = Stopwatch.StartNew(); + ExecuteStep(client, ca_cert, step, out status, out got); + sw.Stop(); + + matched = Matches(step.Expect, status, got); + why = matched ? $"matched expect '{step.Expect}'" : $"expected '{step.Expect}', got status {status} failInfo {got}"; + return new CheckResult(step.Name, matched ? CheckOutcome.Passed : CheckOutcome.Failed, + ExpectToFailInfo(step.Expect), got, status, why, "scenario", sw.Elapsed); + } + + private static void ExecuteStep(ScepClient client, X509Certificate2 ca_cert, ScenarioStep step, out PkiStatus status, out FailInfo got) { + status = PkiStatus.Failure; + got = FailInfo.None; + switch (step.Run.ToLowerInvariant()) { + case "getcacaps": + status = client.GetCaCaps().IsOk ? PkiStatus.Success : PkiStatus.Failure; + return; + case "enroll": + case "probe": + ScepRequestBuilder builder; + PkiMessage message; + IScepKey key; + string error; + ScepResult result; + + builder = ScepRequestBuilder.For(client.Crypto) + .CaCertificate(ca_cert) + .MessageType(MessageType.PkcsReq) + .Subject(step.Args.TryGetValue("subject", out string? subj) ? subj : "CN=scenario") + .KeySpec("rsa:2048"); + if (step.Args.TryGetValue("digest", out string? digest)) { builder.Digest(digest); } + if (step.Args.TryGetValue("cipher", out string? cipher)) { builder.Cipher(cipher); } + if (step.Args.TryGetValue("challenge", out string? ch)) { builder.Challenge(ch); } + if (!builder.Build(out message, out key, out error)) { return; } + result = client.SubmitPkiOperation(message, key, builder.Faults); + status = result.Value?.PkiStatus ?? PkiStatus.Failure; + got = result.Value?.FailInfo ?? FailInfo.None; + return; + default: + return; + } + } + + private static bool Matches(string expect, PkiStatus status, FailInfo got) { + switch ((expect ?? "pass").ToLowerInvariant()) { + case "pass": return status == PkiStatus.Success; + case "fail": return status != PkiStatus.Success; + default: return status != PkiStatus.Success && got == ExpectToFailInfo(expect); + } + } + + private static FailInfo ExpectToFailInfo(string expect) { + switch ((expect ?? string.Empty).ToLowerInvariant()) { + case "badalg": return FailInfo.BadAlg; + case "badmessagecheck": return FailInfo.BadMessageCheck; + case "badtime": return FailInfo.BadTime; + case "badrequest": return FailInfo.BadRequest; + case "badcertid": return FailInfo.BadCertId; + default: return FailInfo.None; + } + } +} +``` + +- [ ] **Step 3: Write the test** `tests/ScepTestClient.Tests/ScenarioRunnerTests.cs`: + +```csharp +using ScepTestClient.Core; +using ScepTestClient.Core.Testing; +using ScepTestClient.Tests.Fakes; + +namespace ScepTestClient.Tests; + +public sealed class ScenarioRunnerTests { + [Fact] + public void Parse_ReadsSteps() { + string json; + ScenarioFile scenario; + string error; + + json = "{ \"name\": \"sweep\", \"steps\": [ { \"name\": \"caps\", \"run\": \"getcacaps\", \"expect\": \"pass\" }, { \"name\": \"md5\", \"run\": \"enroll\", \"args\": { \"digest\": \"MD5\" }, \"expect\": \"badAlg\" } ] }"; + Assert.True(ScenarioRunner.Parse(json, out scenario, out error), error); + Assert.Equal(2, scenario.Steps.Count); + Assert.Equal("badAlg", scenario.Steps[1].Expect); + } + + [Fact] + public async Task Run_AggregatesIntoOneReport() { + FakeScepServer server; + ScepClient client; + ScenarioFile scenario; + TestReport report; + string error; + + server = await FakeScepServer.StartAsync(); + try { + client = BuildClientFor(server, out _); + ScenarioRunner.Parse("{ \"name\": \"s\", \"steps\": [ { \"name\": \"caps\", \"run\": \"getcacaps\", \"expect\": \"pass\" }, { \"name\": \"md5\", \"run\": \"enroll\", \"args\": { \"digest\": \"MD5\" }, \"expect\": \"badAlg\" } ] }", out scenario, out error); + report = ScenarioRunner.Run(client, scenario, server.Ca.CertificateBcl); + Assert.Equal("scenario", report.Mode); + Assert.Equal(2, report.Results.Count); + Assert.All(report.Results, r => Assert.Equal(CheckOutcome.Passed, r.Outcome)); + } finally { + await server.DisposeAsync(); + } + } +} +``` + +- [ ] **Step 4: Run — expect PASS. Step 5: Commit:** + +```bash +git add src/ScepTestClient.Core/Testing/ScenarioFile.cs \ + src/ScepTestClient.Core/Testing/ScenarioRunner.cs \ + tests/ScepTestClient.Tests/ScenarioRunnerTests.cs +git commit -m "Core: scenario/playlist runner aggregating into one report" +``` + +--- + +## Task 11: Challenge sources (Explicit / Simulator / NDES) + +**Goal:** Abstract the challenge-password source so the enroll flow is identical regardless of where the password comes from: explicit, IntuneSimulator `POST /challenge`, or scraped NDES `mscep_admin`. + +**Files:** +- Create: `src/ScepTestClient.Core/Challenge/IChallengeSource.cs` +- Create: `src/ScepTestClient.Core/Challenge/ExplicitChallengeSource.cs` +- Create: `src/ScepTestClient.Core/Challenge/NdesAdminUrl.cs` +- Create: `src/ScepTestClient.Core/Challenge/SimulatorChallengeSource.cs` +- Create: `src/ScepTestClient.Core/Challenge/NdesChallengeSource.cs` +- Create: `tests/ScepTestClient.Tests/Fakes/FakeHttpEndpoint.cs` +- Test: `tests/ScepTestClient.Tests/ChallengeSourceTests.cs` (create) + +**Acceptance Criteria:** +- [ ] `IChallengeSource` exposes `bool TryGet(out string challenge, out string error)` and `Task> GetAsync()`. +- [ ] `ExplicitChallengeSource` returns its constructor value. +- [ ] `NdesAdminUrl.Derive(scepUrl)` swaps the `mscep` path segment for `mscep_admin` (e.g. `.../certsrv/mscep/pkiclient.exe` → `.../certsrv/mscep_admin/`); honors an explicit override. +- [ ] `SimulatorChallengeSource` does `POST /challenge`, reads `challengePassword` from the JSON body. +- [ ] `NdesChallengeSource` GETs the admin URL with HTTP Basic auth and scrapes the challenge from the returned HTML (the hex token after "enrollment challenge password"). +- [ ] Network sources are exercised against `FakeHttpEndpoint`; the scraped/fetched value is redacted via `Redaction.Hash` when logged (assert the helper is used, not the format of the wire call). + +**Verify:** `dotnet test --filter FullyQualifiedName~ChallengeSource` → PASS + +**Steps:** + +- [ ] **Step 1: `IChallengeSource.cs` + `ExplicitChallengeSource.cs`:** + +```csharp +using System.Threading.Tasks; +using ScepTestClient.CryptoApi; + +namespace ScepTestClient.Core.Challenge; + +public interface IChallengeSource { + bool TryGet(out string challenge, out string error); + Task> GetAsync(); +} +``` + +```csharp +using System.Threading.Tasks; +using ScepTestClient.CryptoApi; + +namespace ScepTestClient.Core.Challenge; + +public sealed class ExplicitChallengeSource : IChallengeSource { + private readonly string _value; + + public ExplicitChallengeSource(string value) { _value = value; } + + public bool TryGet(out string challenge, out string error) { + challenge = _value; + error = string.Empty; + return true; + } + + public Task> GetAsync() => Task.FromResult(ScepResult.Ok(_value)); +} +``` + +- [ ] **Step 2: `NdesAdminUrl.cs`** (pure string derivation, unit-testable without HTTP): + +```csharp +namespace ScepTestClient.Core.Challenge; + +public static class NdesAdminUrl { + public static string Derive(string scep_url, string? explicit_admin_url = null) { + System.Uri uri; + string path; + + if (!string.IsNullOrEmpty(explicit_admin_url)) { return explicit_admin_url!; } + + uri = new System.Uri(scep_url); + path = uri.AbsolutePath; + // Replace the 'mscep' segment with 'mscep_admin' and drop a trailing pkiclient.exe. + path = path.Replace("/mscep/", "/mscep_admin/", System.StringComparison.OrdinalIgnoreCase); + if (path.EndsWith("/mscep", System.StringComparison.OrdinalIgnoreCase)) { + path = path.Substring(0, path.Length - "/mscep".Length) + "/mscep_admin/"; + } + if (path.EndsWith("pkiclient.exe", System.StringComparison.OrdinalIgnoreCase)) { + path = path.Substring(0, path.Length - "pkiclient.exe".Length); + } + return new System.UriBuilder(uri) { Path = path, Query = string.Empty }.Uri.ToString(); + } +} +``` + +- [ ] **Step 3: `SimulatorChallengeSource.cs`:** + +```csharp +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using ScepTestClient.CryptoApi; + +namespace ScepTestClient.Core.Challenge; + +public sealed class SimulatorChallengeSource : IChallengeSource { + private readonly HttpClient _http; + private readonly string _base_url; + + public SimulatorChallengeSource(HttpClient http, string base_url) { + _http = http; + _base_url = base_url.TrimEnd('/'); + } + + public bool TryGet(out string challenge, out string error) { + ScepResult result; + + result = GetAsync().GetAwaiter().GetResult(); + challenge = result.IsOk ? result.Value : string.Empty; + error = result.Error; + return result.IsOk; + } + + public async Task> GetAsync() { + HttpResponseMessage response; + string body; + JsonDocument doc; + System.Text.Json.JsonElement element; + + try { + response = await _http.PostAsync(_base_url + "/challenge", new StringContent("{}", System.Text.Encoding.UTF8, "application/json")).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) { + return ScepResult.Fail(ScepClientResult.NetworkError, $"simulator returned {(int)response.StatusCode}"); + } + body = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + doc = JsonDocument.Parse(body); + if (!doc.RootElement.TryGetProperty("challengePassword", out element)) { + return ScepResult.Fail(ScepClientResult.ProtocolError, "no challengePassword in response"); + } + return ScepResult.Ok(element.GetString() ?? string.Empty); + } catch (System.Exception ex) { + return ScepResult.Fail(ScepClientResult.NetworkError, ex.Message); + } + } +} +``` + +> The synchronous `TryGet` wraps the async HTTP call via `GetAwaiter().GetResult()`. This is the one acceptable use of that pattern (reading an HTTP response in a sync façade) per the memory; it is NOT a control-flow shortcut. If a genuinely-sync `HttpClient.Send` path is preferred, mirror the Phase-2 transport approach — but the network challenge sources are not on the hot SCEP path, so the wrapper is fine. + +- [ ] **Step 4: `NdesChallengeSource.cs`** (Basic auth + HTML scrape): + +```csharp +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using ScepTestClient.CryptoApi; + +namespace ScepTestClient.Core.Challenge; + +public sealed class NdesChallengeSource : IChallengeSource { + private readonly HttpClient _http; + private readonly string _admin_url; + private readonly string _user; + private readonly string _password; + + public NdesChallengeSource(HttpClient http, string admin_url, string user, string password) { + _http = http; + _admin_url = admin_url; + _user = user; + _password = password; + } + + public bool TryGet(out string challenge, out string error) { + ScepResult result; + + result = GetAsync().GetAwaiter().GetResult(); + challenge = result.IsOk ? result.Value : string.Empty; + error = result.Error; + return result.IsOk; + } + + public async Task> GetAsync() { + HttpRequestMessage request; + HttpResponseMessage response; + string html; + string token; + + try { + request = new HttpRequestMessage(HttpMethod.Get, _admin_url); + request.Headers.Authorization = new AuthenticationHeaderValue("Basic", + System.Convert.ToBase64String(Encoding.UTF8.GetBytes($"{_user}:{_password}"))); + response = await _http.SendAsync(request).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) { + return ScepResult.Fail(ScepClientResult.NetworkError, $"NDES admin returned {(int)response.StatusCode}"); + } + html = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + token = Scrape(html); + if (token.Length == 0) { + return ScepResult.Fail(ScepClientResult.ProtocolError, "no challenge found in NDES page"); + } + return ScepResult.Ok(token); + } catch (System.Exception ex) { + return ScepResult.Fail(ScepClientResult.NetworkError, ex.Message); + } + } + + // NDES renders the challenge as a bold 8/16/32 hex run near "enrollment challenge password". + private static string Scrape(string html) { + Match m; + + m = Regex.Match(html ?? string.Empty, "([0-9A-Fa-f]{8,40})"); + return m.Success ? m.Groups[1].Value : string.Empty; + } +} +``` + +- [ ] **Step 5: `FakeHttpEndpoint.cs`** — a tiny Kestrel app serving `/challenge` (JSON) and `/certsrv/mscep_admin/` (HTML + Basic auth check), mirroring `FakeScepServer`'s shape: + +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; + +namespace ScepTestClient.Tests.Fakes; + +public sealed class FakeHttpEndpoint : System.IAsyncDisposable { + private readonly WebApplication _app; + + public System.Uri BaseUrl { get; } + + private FakeHttpEndpoint(WebApplication app, System.Uri base_url) { + _app = app; + BaseUrl = base_url; + } + + public static async Task StartAsync(string challenge) { + WebApplicationBuilder builder; + WebApplication app; + System.Uri url; + + builder = WebApplication.CreateBuilder(); + builder.WebHost.UseUrls("http://127.0.0.1:0"); + app = builder.Build(); + + app.MapPost("/challenge", async (HttpContext ctx) => { + await ctx.Response.WriteAsync($"{{ \"challengePassword\": \"{challenge}\" }}"); + }); + app.MapGet("/certsrv/mscep_admin/", async (HttpContext ctx) => { + if (!ctx.Request.Headers.ContainsKey("Authorization")) { + ctx.Response.StatusCode = 401; + return; + } + await ctx.Response.WriteAsync($"enrollment challenge password is {challenge}"); + }); + + await app.StartAsync(); + url = new System.Uri(app.Urls.First()); + return new FakeHttpEndpoint(app, url); + } + + public async ValueTask DisposeAsync() { await _app.DisposeAsync(); } +} +``` + +- [ ] **Step 6: Write the test** `tests/ScepTestClient.Tests/ChallengeSourceTests.cs`: + +```csharp +using System.Net.Http; +using ScepTestClient.Core.Challenge; +using ScepTestClient.Tests.Fakes; + +namespace ScepTestClient.Tests; + +public sealed class ChallengeSourceTests { + [Fact] + public void Explicit_ReturnsValue() { + IChallengeSource source; + string challenge; + string error; + + source = new ExplicitChallengeSource("s3cret"); + Assert.True(source.TryGet(out challenge, out error)); + Assert.Equal("s3cret", challenge); + } + + [Theory] + [InlineData("http://ndes.example/certsrv/mscep/pkiclient.exe", "http://ndes.example/certsrv/mscep_admin/")] + [InlineData("https://host/certsrv/mscep", "https://host/certsrv/mscep_admin/")] + public void AdminUrl_Derives(string scep, string expected) { + Assert.Equal(expected, NdesAdminUrl.Derive(scep)); + } + + [Fact] + public async Task Simulator_ReadsChallengePassword() { + FakeHttpEndpoint endpoint; + SimulatorChallengeSource source; + var http = new HttpClient(); + + endpoint = await FakeHttpEndpoint.StartAsync("sim-challenge-01"); + try { + source = new SimulatorChallengeSource(http, endpoint.BaseUrl.ToString()); + ScepTestClient.CryptoApi.ScepResult result; + result = await source.GetAsync(); + Assert.True(result.IsOk, result.Error); + Assert.Equal("sim-challenge-01", result.Value); + } finally { + await endpoint.DisposeAsync(); + } + } + + [Fact] + public async Task Ndes_ScrapesChallengeWithBasicAuth() { + FakeHttpEndpoint endpoint; + NdesChallengeSource source; + var http = new HttpClient(); + + endpoint = await FakeHttpEndpoint.StartAsync("DEADBEEFCAFE1234"); + try { + source = new NdesChallengeSource(http, endpoint.BaseUrl + "certsrv/mscep_admin/", "ndesadmin", "pw"); + ScepTestClient.CryptoApi.ScepResult result; + result = await source.GetAsync(); + Assert.True(result.IsOk, result.Error); + Assert.Equal("DEADBEEFCAFE1234", result.Value); + } finally { + await endpoint.DisposeAsync(); + } + } +} +``` + +> The two `HttpClient` locals use `var` ONLY because they appear in test code; the house no-`var` rule binds ScepTestClient production code. If the test project's `.editorconfig` also forbids `var`, declare them as `HttpClient http;` at the top of the block per house style — match whatever the existing test files do. + +- [ ] **Step 7: Run — expect PASS. Step 8: Commit:** + +```bash +git add src/ScepTestClient.Core/Challenge/ \ + tests/ScepTestClient.Tests/Fakes/FakeHttpEndpoint.cs \ + tests/ScepTestClient.Tests/ChallengeSourceTests.cs +git commit -m "Core: challenge sources (explicit/simulator/NDES) + admin-URL derivation" +``` + +--- + +## Task 12: CLI — `test lifecycle/full/probe` + reports + `--jamf-max-wait` + +**Goal:** Wire the `test` noun to the engine, print the console summary, and write the selected report formats under `/runs/--.`; add the jamf timing flag. + +**Files:** +- Modify: `src/ScepTestClient.Cli/CommandRouter.cs` +- Test: `tests/ScepTestClient.Tests/CliTestCommandTests.cs` (create) + +**Acceptance Criteria:** +- [ ] `test lifecycle `, `test full `, `test probe ` build a client, run the matching engine method, and print `ConsoleSummary.Format(report)` to `output`. +- [ ] `--report-format junit|trx|json|md` is repeatable; each writes one file to `/runs/` named `--.` (ts passed in / derived deterministically — use `DateTime.UtcNow` formatted `yyyyMMdd-HHmmss`). +- [ ] Exit code: `0` when `report.Failed == 0`, else `1`. +- [ ] `test full --jamf-max-wait ` runs a jamf sim step and includes its outcome in the printed summary. +- [ ] Unknown `test` subverb → usage, exit `2`. + +**Verify:** `dotnet test --filter FullyQualifiedName~CliTestCommand` → PASS + +**Steps:** + +- [ ] **Step 1: Add the noun.** In `RunInternal`'s switch add `case "test": return RunTest(args, data_root, output);` and `case "run": return RunScenario(args, data_root, output);` (the latter implemented in Task 13). + +- [ ] **Step 2: `RunTest` + helpers.** Add to `CommandRouter` (house style; reuse existing `BuildClient`, `Opt`, `HasFlag`, repeatable-flag reader): + +```csharp + private static int RunTest(string[] args, string data_root, TextWriter output) { + string verb; + string server_id; + ScepClient client; + ScepTestClient.Core.Testing.TestEngine engine; + ScepTestClient.Core.Testing.TestReport report; + System.Collections.Generic.List formats; + + if (args.Length < 3) { output.WriteLine("usage: test [--report-format junit|trx|json|md] [--jamf-max-wait ]"); return 2; } + verb = args[1]; + server_id = args[2]; + + if (!BuildClient(server_id, data_root, output, out client)) { return 1; } + engine = new ScepTestClient.Core.Testing.TestEngine(); + + switch (verb) { + case "lifecycle": + ScepTestClient.Core.Storage.CertStore store; + ScepTestClient.Core.Storage.UseRecordLog log; + + store = new ScepTestClient.Core.Storage.CertStore(data_root); + log = new ScepTestClient.Core.Storage.UseRecordLog(data_root); + report = engine.RunLifecycle(client, store, log); + break; + case "full": + report = RunFullWithOptionalJamf(args, client); + break; + case "probe": + report = engine.RunProbe(client); + break; + default: + output.WriteLine("usage: test "); + return 2; + } + + output.Write(ScepTestClient.Core.Reporting.ConsoleSummary.Format(report)); + formats = ReadRepeated(args, "--report-format"); + WriteReports(report, formats, data_root, server_id, output); + return report.Failed == 0 ? 0 : 1; + } + + private static ScepTestClient.Core.Testing.TestReport RunFullWithOptionalJamf(string[] args, ScepClient client) { + ScepTestClient.Core.Testing.TestEngine engine; + ScepTestClient.Core.Testing.TestReport report; + ScepResult> ca; + ScepTestClient.Core.Protocol.ScepCapabilities caps; + + engine = new ScepTestClient.Core.Testing.TestEngine(); + ca = client.GetCaCert(); + caps = client.GetCaCaps().Value ?? ScepTestClient.Core.Protocol.ScepCapabilities.Parse(string.Empty); + report = engine.RunFull(client, ca.Value[0], caps); + + // --jamf-max-wait appends a timing step. + string? jamf; + jamf = Opt(args, "--jamf-max-wait"); + if (jamf != null && int.TryParse(jamf, out int ms)) { + // Build a minimal enroll request and run the jamf sim; record one CheckResult. + // (Kept lightweight: a NetworkError or timeout => Failed; success => Passed.) + } + return report; + } + + private static System.Collections.Generic.List ReadRepeated(string[] args, string name) { + System.Collections.Generic.List values; + + values = new System.Collections.Generic.List(); + for (int i = 0; i < args.Length - 1; i++) { + if (args[i] == name) { values.Add(args[i + 1]); } + } + return values; + } + + private static void WriteReports(ScepTestClient.Core.Testing.TestReport report, System.Collections.Generic.List formats, string data_root, string server_id, TextWriter output) { + string runs_dir; + string stamp; + + if (formats.Count == 0) { return; } + runs_dir = System.IO.Path.Combine(data_root, "runs"); + System.IO.Directory.CreateDirectory(runs_dir); + stamp = System.DateTime.UtcNow.ToString("yyyyMMdd-HHmmss"); + + foreach (string format in formats) { + string content; + string ext; + + switch (format.ToLowerInvariant()) { + case "junit": content = ScepTestClient.Core.Reporting.JUnitReport.Emit(report); ext = "junit.xml"; break; + case "trx": content = ScepTestClient.Core.Reporting.TrxReport.Emit(report); ext = "trx"; break; + case "json": content = ScepTestClient.Core.Reporting.JsonReport.Emit(report); ext = "json"; break; + case "md": content = ScepTestClient.Core.Reporting.MarkdownReport.Emit(report); ext = "md"; break; + default: output.WriteLine($"unknown report format: {format}"); continue; + } + System.IO.File.WriteAllText(System.IO.Path.Combine(runs_dir, $"{stamp}-{server_id}-{report.Mode}.{ext}"), content); + } + } +``` + +> The `--jamf-max-wait` body is intentionally a light add-on; implement it by building a minimal `EnrollRequest` (RSA-2048, `CN=jamf-probe`) with `CaCertificate` resolved, calling `JamfSimulator.Run(...)`, and appending a `CheckResult` (`Passed` if `!TimedOut`, else `Failed` with `"jamf poll exceeded {ms}ms"`). Keep it inside `RunFullWithOptionalJamf`. The engine/report types make this a few lines. + +- [ ] **Step 3: Write the test** `tests/ScepTestClient.Tests/CliTestCommandTests.cs`. The CLI is exercised the same way `CliRouterPhase2Tests` does — add a server to a temp data-root that points at a live `FakeScepServer`, then invoke `CommandRouter.Run`: + +```csharp +using System.IO; +using ScepTestClient.Cli; +using ScepTestClient.Tests.Fakes; + +namespace ScepTestClient.Tests; + +public sealed class CliTestCommandTests { + [Fact] + public async Task TestProbe_PrintsSummary_AndWritesJunit() { + FakeScepServer server; + string root; + StringWriter output; + int code; + + server = await FakeScepServer.StartAsync(); + try { + root = Path.Combine(Path.GetTempPath(), "sceptest-" + System.Guid.NewGuid().ToString("N")); + AddServer(root, "testhost", server.ScepUrl.ToString()); // reuse the helper CliRouterPhase2Tests uses + output = new StringWriter(); + + code = CommandRouter.Run(new[] { "test", "probe", "testhost", "--report-format", "junit" }, root, output); + + Assert.Contains("SCEP test run", output.ToString()); + Assert.True(Directory.Exists(Path.Combine(root, "runs"))); + Assert.NotEmpty(Directory.GetFiles(Path.Combine(root, "runs"), "*.junit.xml")); + Assert.InRange(code, 0, 1); + } finally { + await server.DisposeAsync(); + } + } + + [Fact] + public void Test_UnknownVerb_Usage() { + StringWriter output; + int code; + + output = new StringWriter(); + code = CommandRouter.Run(new[] { "test", "bogus", "x" }, Path.Combine(Path.GetTempPath(), "sceptest-" + System.Guid.NewGuid().ToString("N")), output); + Assert.Equal(2, code); + } +} +``` + +> `AddServer(root, id, url)` is the pattern `CliRouterPhase2Tests` already uses to seed a server registry entry (it calls `servers add` via `CommandRouter.Run` or writes `server.json` directly). Reuse it verbatim — do not invent a new seeding path. + +- [ ] **Step 4: Run — expect PASS. Step 5: Commit:** + +```bash +git add src/ScepTestClient.Cli/CommandRouter.cs \ + tests/ScepTestClient.Tests/CliTestCommandTests.cs +git commit -m "CLI: test lifecycle/full/probe + report-format writing + jamf-max-wait" +``` + +--- + +## Task 13: CLI — `run `, `servers suggest`, challenge-source flags + +**Goal:** Finish the CLI surface: the scenario runner, the `servers suggest` command, and `--simulator`/`--ndes*` challenge sourcing on `enroll`/`get` (including the simulator-driven subject-mismatch note). + +**Files:** +- Modify: `src/ScepTestClient.Cli/CommandRouter.cs` +- Test: `tests/ScepTestClient.Tests/CliScenarioSuggestTests.cs` (create) + +**Acceptance Criteria:** +- [ ] `run ` parses the file via `ScenarioRunner.Parse`, runs it, prints the console summary, honors `--report-format`, exit `0`/`1` by `report.Failed`. +- [ ] `servers suggest ` prints one `sceptest enroll ...` line per advertised algorithm combo (via `ServerSuggest.For`). +- [ ] `enroll`/`get` accept `--simulator ` (resolve challenge via `SimulatorChallengeSource`) and `--ndes` with `--ndes-user`/`--ndes-password`/`--ndes-admin-url` (resolve via `NdesChallengeSource`, admin URL derived from the server URL when not given); the resolved challenge is redacted (`Redaction.Hash`) wherever it is logged. +- [ ] Precedence: explicit `--challenge` beats `--simulator` beats `--ndes`. + +**Verify:** `dotnet test --filter FullyQualifiedName~CliScenarioSuggest` → PASS + +**Steps:** + +- [ ] **Step 1: `RunScenario`** in `CommandRouter`: + +```csharp + private static int RunScenario(string[] args, string data_root, TextWriter output) { + string path; + string server_id; + string json; + ScepTestClient.Core.Testing.ScenarioFile scenario; + string parse_error; + ScepClient client; + ScepResult> ca; + ScepTestClient.Core.Testing.TestReport report; + System.Collections.Generic.List formats; + + if (args.Length < 3) { output.WriteLine("usage: run [--report-format ...]"); return 2; } + path = args[1]; + server_id = args[2]; + if (!System.IO.File.Exists(path)) { output.WriteLine($"scenario not found: {path}"); return 2; } + json = System.IO.File.ReadAllText(path); + if (!ScepTestClient.Core.Testing.ScenarioRunner.Parse(json, out scenario, out parse_error)) { output.WriteLine($"bad scenario: {parse_error}"); return 2; } + if (!BuildClient(server_id, data_root, output, out client)) { return 1; } + + ca = client.GetCaCert(); + if (!ca.IsOk) { output.WriteLine($"GetCACert failed: {ca.Error}"); return 1; } + report = ScepTestClient.Core.Testing.ScenarioRunner.Run(client, scenario, ca.Value[0]); + + output.Write(ScepTestClient.Core.Reporting.ConsoleSummary.Format(report)); + formats = ReadRepeated(args, "--report-format"); + WriteReports(report, formats, data_root, server_id, output); + return report.Failed == 0 ? 0 : 1; + } +``` + +- [ ] **Step 2: `servers suggest`.** In `RunServers`, add `case "suggest": return RunServersSuggest(args, data_root, output);`: + +```csharp + private static int RunServersSuggest(string[] args, string data_root, TextWriter output) { + string server_id; + ScepClient client; + ScepTestClient.Core.Protocol.ScepCapabilities caps; + System.Collections.Generic.IReadOnlyList lines; + + if (args.Length < 3) { output.WriteLine("usage: servers suggest "); return 2; } + server_id = args[2]; + if (!BuildClient(server_id, data_root, output, out client)) { return 1; } + caps = client.GetCaCaps().Value ?? ScepTestClient.Core.Protocol.ScepCapabilities.Parse(string.Empty); + lines = ScepTestClient.Core.Testing.ServerSuggest.For(server_id, caps); + foreach (string line in lines) { output.WriteLine(line); } + return 0; + } +``` + +- [ ] **Step 3: Challenge-source resolution on enroll/get.** Add a helper that resolves the challenge from the flags (explicit > simulator > ndes) and use it where `enroll`/`get` currently read `--challenge`: + +```csharp + private static bool ResolveChallenge(string[] args, string server_url, System.Net.Http.HttpClient http, out string? challenge, out string error) { + string? explicit_pw; + string? simulator; + ScepTestClient.Core.Challenge.IChallengeSource source; + + challenge = null; + error = string.Empty; + + explicit_pw = Opt(args, "--challenge"); + if (explicit_pw != null) { challenge = explicit_pw; return true; } + + simulator = Opt(args, "--simulator"); + if (simulator != null) { + source = new ScepTestClient.Core.Challenge.SimulatorChallengeSource(http, simulator); + return source.TryGet(out challenge, out error); + } + + if (HasFlag(args, "--ndes")) { + string admin_url; + string user; + string password; + + admin_url = ScepTestClient.Core.Challenge.NdesAdminUrl.Derive(server_url, Opt(args, "--ndes-admin-url")); + user = Opt(args, "--ndes-user") ?? string.Empty; + password = Opt(args, "--ndes-password") ?? string.Empty; + source = new ScepTestClient.Core.Challenge.NdesChallengeSource(http, admin_url, user, password); + return source.TryGet(out challenge, out error); + } + return true; // no challenge source; null is fine + } +``` + +> Wire `ResolveChallenge` into the existing `RunGet`/enroll handler: replace the direct `Opt(args, "--challenge")` read with a call to this helper, passing the resolved server URL and a shared `HttpClient`. When the challenge is logged anywhere (trace/history), pass it through `ScepTestClient.Core.Storage.Redaction.Hash(...)` — never the raw value. The simulator flag additionally unlocks the subject-mismatch finding: when `--simulator` is present, the enroll handler may add a note that a `CN=poodle` mismatch test is available; for Phase 3 a single info line printed to `output` ("simulator challenge in use; subject-mismatch tests available via 'test full'") satisfies the surface — the mismatch assertion itself lives in the simulator-driven compliance path and can be deferred if the simulator endpoint is not reachable in CI. + +- [ ] **Step 4: Write the test** `tests/ScepTestClient.Tests/CliScenarioSuggestTests.cs`: + +```csharp +using System.IO; +using ScepTestClient.Cli; +using ScepTestClient.Tests.Fakes; + +namespace ScepTestClient.Tests; + +public sealed class CliScenarioSuggestTests { + [Fact] + public async Task ServersSuggest_PrintsEnrollCommands() { + FakeScepServer server; + string root; + StringWriter output; + int code; + + server = await FakeScepServer.StartAsync(); + try { + root = Path.Combine(Path.GetTempPath(), "sceptest-" + System.Guid.NewGuid().ToString("N")); + AddServer(root, "testhost", server.ScepUrl.ToString()); + output = new StringWriter(); + code = CommandRouter.Run(new[] { "servers", "suggest", "testhost" }, root, output); + Assert.Equal(0, code); + Assert.Contains("sceptest enroll testhost", output.ToString()); + Assert.Contains("--digest SHA-256", output.ToString()); + } finally { + await server.DisposeAsync(); + } + } + + [Fact] + public async Task RunScenario_AggregatesAndExits() { + FakeScepServer server; + string root; + string scenario_path; + StringWriter output; + int code; + + server = await FakeScepServer.StartAsync(); + try { + root = Path.Combine(Path.GetTempPath(), "sceptest-" + System.Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + AddServer(root, "testhost", server.ScepUrl.ToString()); + scenario_path = Path.Combine(root, "s.json"); + File.WriteAllText(scenario_path, "{ \"name\": \"s\", \"steps\": [ { \"name\": \"caps\", \"run\": \"getcacaps\", \"expect\": \"pass\" } ] }"); + output = new StringWriter(); + code = CommandRouter.Run(new[] { "run", scenario_path, "testhost" }, root, output); + Assert.Equal(0, code); + Assert.Contains("SCEP test run", output.ToString()); + } finally { + await server.DisposeAsync(); + } + } +} +``` + +- [ ] **Step 5: Run — expect PASS. Step 6: Final full-suite regression** — `dotnet test` (whole solution) must be green with 0 warnings. + +- [ ] **Step 7: Commit:** + +```bash +git add src/ScepTestClient.Cli/CommandRouter.cs \ + tests/ScepTestClient.Tests/CliScenarioSuggestTests.cs +git commit -m "CLI: run scenario + servers suggest + simulator/NDES challenge sources" +``` + +--- + +## Self-Review (completed during planning) + +**Spec coverage (§17 row 3 deliverables):** +- `FaultDirectives` + builder `AllowFaults()` + provider fault branch → **Tasks 1–2**. +- Compliance matrix → expected `failInfo` (badAlg/badMessageCheck/badTime/badRequest/badCertId) → **Tasks 3–4** (fake detection + engine). +- `test lifecycle`/`full`/`probe` → **Task 5** (+ CLI **Task 12**). +- Opinion thresholds + `servers suggest` → **Task 6** (+ CLI **Task 13**). +- Jamf `--jamf-max-wait` timing sim → **Task 7** (+ CLI **Task 12**). +- Report emitters JUnit/TRX/JSON/MD + console summary → **Tasks 8–9** (+ CLI writing **Task 12**). +- Scenario/playlist runner → **Task 10** (+ CLI **Task 13**). +- Challenge sources (`--simulator` auto-challenge + subject-mismatch, `--ndes` mscep_admin scrape) → **Task 11** (+ CLI **Task 13**). + +**Type consistency:** `TestReport`/`CheckResult`/`CheckOutcome` defined in Task 4 are consumed unchanged by Tasks 5–13. `FaultDirectives` fields (`CorruptSignature`/`SigningTimeSkew`/`CorruptInnerContent`) defined in Task 1 are the only fault knobs referenced anywhere. `ScepCapabilities` (existing) is read in Tasks 5/6/13. `FailInfo`/`PkiStatus`/`ScepClientResult` (existing enums) used throughout. + +**Known adaptation points (flag, don't guess):** BC signing-time attribute construction (Task 1 Step 5), CMS signature verification + inner-CSR decrypt in `TestCa` (Task 3 Steps 2–3), and the `GetCert` failInfo surfacing (Task 4 Step 3) are the spots where the existing BC/test code is the source of truth — adapt the call to compile + pass the test and note any deviation. None are placeholders: each has concrete code and a concrete test asserting the behavior. + +**Open question deferred to execution:** whether the wrong-challenge row resolves as PASSED or FINDING depends on exactly how the Task-3 fake treats a missing/blank challenge attr; the engine handles both and the test assertion is set to match the fake's actual behavior (noted in Task 4 Step 4). No user input required. + +--- + +## Execution notes for the coordinator + +- Every subagent prompt MUST include: house style (no `var`, declare-at-top, same-line braces, single-line statements, snake_case locals/params/private fields), "stay on branch `feature/scep-test-client-phase-3` — do NOT `git checkout`/`git switch`", "stage the exact files listed — never `git add -A`", and "the round-trip/e2e test is the source of truth; adapt BC calls per the `scep-bouncycastle-cms-reference` facts and flag any deviation." +- Tasks 1→2→3 are sequential (each builds on the prior). Tasks 4–11 depend on 1–3 but are largely independent of each other; 12 depends on 4/5/7/8/9; 13 depends on 6/10/11. Run in numeric order for simplicity. +- After each task: `dotnet test` the filter, then a quick full-suite check before the commit. 0 warnings is a release gate. diff --git a/docs/superpowers/plans/2026-06-20-scep-test-client-phase-3.md.tasks.json b/docs/superpowers/plans/2026-06-20-scep-test-client-phase-3.md.tasks.json new file mode 100644 index 0000000..ba3015a --- /dev/null +++ b/docs/superpowers/plans/2026-06-20-scep-test-client-phase-3.md.tasks.json @@ -0,0 +1,94 @@ +{ + "planPath": "docs/superpowers/plans/2026-06-20-scep-test-client-phase-3.md", + "tasks": [ + { + "id": 1, + "subject": "Task 1: FaultDirectives + provider fault branch", + "status": "completed", + "description": "Fill in FaultDirectives (CorruptSignature/SigningTimeSkew/CorruptInnerContent) + single `if (faults != null)` branch in BcPkiMessage.EncodePkiOperation; thread faults from BouncyCastleScepCrypto. Verify: dotnet test --filter FullyQualifiedName~FaultInjection. Files: src/ScepTestClient.CryptoApi/FaultDirectives.cs, src/ScepTestClient.Crypto.BouncyCastle/BcPkiMessage.cs, src/ScepTestClient.Crypto.BouncyCastle/BouncyCastleScepCrypto.cs, tests/ScepTestClient.Tests/FaultInjectionTests.cs" + }, + { + "id": 2, + "subject": "Task 2: Builder AllowFaults + client SubmitPkiOperation", + "status": "completed", + "blockedBy": [1], + "description": "ScepRequestBuilder.AllowFaults + Faults property; faults param on SendPkiOperationSync/Async; public SubmitPkiOperation(+Async). Verify: dotnet test --filter FullyQualifiedName~AllowFaults. Files: src/ScepTestClient.Core/ScepRequestBuilder.cs, src/ScepTestClient.Core/ScepClient.cs, tests/ScepTestClient.Tests/AllowFaultsTests.cs" + }, + { + "id": 3, + "subject": "Task 3: Extend TestCa/FakeScepServer for fault detection", + "status": "completed", + "blockedBy": [2], + "description": "HandlePkiOperation priority ladder -> failInfo (badMessageCheck/badTime/badAlg/badRequest); PendingMode + BuildPendingCertRep; ExpectedChallenge; VerifyOuterSignature/ReadSigningTime/InnerCsrParses helpers. Verify: dotnet test --filter FullyQualifiedName~FaultMatrixServer. Files: tests/ScepTestClient.Tests/Fakes/TestCa.cs, tests/ScepTestClient.Tests/Fakes/FakeScepServer.cs, tests/ScepTestClient.Tests/FaultMatrixServerTests.cs" + }, + { + "id": 4, + "subject": "Task 4: Test result model + ComplianceEngine (full matrix)", + "status": "completed", + "blockedBy": [3], + "description": "CheckOutcome/CheckResult/TestReport/FaultKind/ComplianceCheck/ComplianceEngine; 7-row §10.1 matrix; RunFull classifies PASSED/FAILED/FINDING. Verify: dotnet test --filter FullyQualifiedName~ComplianceEngine. Files under src/ScepTestClient.Core/Testing/ + tests/ScepTestClient.Tests/ComplianceEngineTests.cs" + }, + { + "id": 5, + "subject": "Task 5: TestEngine — lifecycle + probe modes", + "status": "completed", + "blockedBy": [4], + "description": "TestEngine.RunLifecycle (GetCACaps->GetCACert->enroll->poll->renew->GetCRL, skip on prereq fail) + RunProbe (beyond-advertised) + RunFull delegate. Verify: dotnet test --filter FullyQualifiedName~TestEngineModes. Files: src/ScepTestClient.Core/Testing/TestEngine.cs, tests/ScepTestClient.Tests/TestEngineModesTests.cs" + }, + { + "id": 6, + "subject": "Task 6: Security opinion + thresholds + servers suggest", + "status": "completed", + "description": "OpinionThresholds/SecurityOpinion (postures) + ServerSuggest; ClientConfig.MinRsaKeyBits round-trip. Verify: dotnet test --filter FullyQualifiedName~SecurityOpinion. Files under src/ScepTestClient.Core/Testing/ + Storage/ClientConfig.cs + tests/ScepTestClient.Tests/SecurityOpinionTests.cs" + }, + { + "id": 7, + "subject": "Task 7: Jamf timing simulation (--jamf-max-wait)", + "status": "completed", + "blockedBy": [3], + "description": "JamfResult + JamfSimulator.Run: enroll; if PENDING poll until success or max_wait exceeded -> TimedOut. Verify: dotnet test --filter FullyQualifiedName~JamfSimulator. Files: src/ScepTestClient.Core/Testing/JamfResult.cs, JamfSimulator.cs, tests/ScepTestClient.Tests/JamfSimulatorTests.cs" + }, + { + "id": 8, + "subject": "Task 8: Report emitters — JUnit + TRX (XML)", + "status": "completed", + "blockedBy": [4], + "description": "JUnitReport + TrxReport via System.Xml.Linq; well-formed; FAILED->failure, FINDING->system-out. Verify: dotnet test --filter FullyQualifiedName~XmlReport. Files: src/ScepTestClient.Core/Reporting/JUnitReport.cs, TrxReport.cs, tests/ScepTestClient.Tests/XmlReportTests.cs" + }, + { + "id": 9, + "subject": "Task 9: Report emitters — JSON + Markdown + console summary", + "status": "completed", + "blockedBy": [4], + "description": "JsonReport + MarkdownReport + ConsoleSummary (§12 block). Verify: dotnet test --filter FullyQualifiedName~TextReport. Files: src/ScepTestClient.Core/Reporting/JsonReport.cs, MarkdownReport.cs, ConsoleSummary.cs, tests/ScepTestClient.Tests/TextReportTests.cs" + }, + { + "id": 10, + "subject": "Task 10: Scenario / playlist runner", + "status": "completed", + "blockedBy": [4], + "description": "ScenarioFile + ScenarioRunner (Parse + Run): steps run+args+expect aggregated into one TestReport. Verify: dotnet test --filter FullyQualifiedName~ScenarioRunner. Files: src/ScepTestClient.Core/Testing/ScenarioFile.cs, ScenarioRunner.cs, tests/ScepTestClient.Tests/ScenarioRunnerTests.cs" + }, + { + "id": 11, + "subject": "Task 11: Challenge sources (Explicit/Simulator/NDES)", + "status": "completed", + "description": "IChallengeSource + Explicit/Simulator/NDES + NdesAdminUrl.Derive; FakeHttpEndpoint test fixture; redact via Redaction.Hash. Verify: dotnet test --filter FullyQualifiedName~ChallengeSource. Files under src/ScepTestClient.Core/Challenge/ + tests/ScepTestClient.Tests/Fakes/FakeHttpEndpoint.cs + ChallengeSourceTests.cs" + }, + { + "id": 12, + "subject": "Task 12: CLI — test lifecycle/full/probe + reports + jamf-max-wait", + "status": "completed", + "blockedBy": [5, 7, 8, 9], + "description": "test noun -> engine; ConsoleSummary print; --report-format repeatable -> /runs/; exit by Failed; --jamf-max-wait step. Verify: dotnet test --filter FullyQualifiedName~CliTestCommand. Files: src/ScepTestClient.Cli/CommandRouter.cs, tests/ScepTestClient.Tests/CliTestCommandTests.cs" + }, + { + "id": 13, + "subject": "Task 13: CLI — run scenario, servers suggest, challenge-source flags", + "status": "completed", + "blockedBy": [6, 10, 11], + "description": "run ; servers suggest; --simulator/--ndes* challenge resolution on enroll/get (precedence challenge>simulator>ndes; redacted). Final full-suite green 0 warnings. Verify: dotnet test --filter FullyQualifiedName~CliScenarioSuggest then full dotnet test. Files: src/ScepTestClient.Cli/CommandRouter.cs, tests/ScepTestClient.Tests/CliScenarioSuggestTests.cs" + } + ], + "lastUpdated": "2026-06-20" +} diff --git a/docs/superpowers/plans/2026-06-20-scep-test-client-phase-4.md.tasks.json b/docs/superpowers/plans/2026-06-20-scep-test-client-phase-4.md.tasks.json index 5c10d02..ea6f057 100644 --- a/docs/superpowers/plans/2026-06-20-scep-test-client-phase-4.md.tasks.json +++ b/docs/superpowers/plans/2026-06-20-scep-test-client-phase-4.md.tasks.json @@ -4,83 +4,83 @@ { "id": 1, "subject": "Task 1: Validate IScepCrypto seam vs 3 PQ tiers (paper)", - "status": "pending", + "status": "completed", "description": "Produce a committed analysis confirming PQ support is additive-only (no IScepCrypto signature change), grounded in BC 2.5.0 reality, before any code changes. USER-ORDERED GATE.\n\n```json:metadata\n{\"files\": [\"docs/superpowers/analysis/2026-06-20-iscepcrypto-pq-seam-validation.md\"], \"verifyCommand\": \"test -f docs/superpowers/analysis/2026-06-20-iscepcrypto-pq-seam-validation.md && grep -q additive-only docs/superpowers/analysis/2026-06-20-iscepcrypto-pq-seam-validation.md\", \"acceptanceCriteria\": [\"per-tier additive analysis present\", \"states no interface signature changes\", \"records BC 2.5.0 tier C infeasibility\", \"lists new additive surface\"], \"userGate\": true, \"tags\": [\"user-gate\"], \"requiresUserSpecification\": false, \"modelTier\": \"standard\"}\n```" }, { "id": 2, "subject": "Task 2: PQ algorithm registry entries", - "status": "pending", + "status": "completed", "blockedBy": [1], "description": "Add ML-DSA/SLH-DSA/ML-KEM entries to the Algorithms registry with correct AlgorithmKind + BcAlgorithms OID constants.\n\n```json:metadata\n{\"files\": [\"src/ScepTestClient.CryptoApi/Algorithms.cs\", \"src/ScepTestClient.Crypto.BouncyCastle/BcAlgorithms.cs\", \"tests/ScepTestClient.Tests/PqAlgorithmsTests.cs\"], \"verifyCommand\": \"dotnet test tests/ScepTestClient.Tests --filter PqAlgorithmsTests\", \"acceptanceCriteria\": [\"ML-DSA/SLH-DSA/ML-KEM resolve with correct kind\", \"existing entries unchanged\"], \"modelTier\": \"standard\"}\n```" }, { "id": 3, "subject": "Task 3: KeySpec accepts PQ specs", - "status": "pending", + "status": "completed", "blockedBy": [1], "description": "Extend KeySpec.Parse to accept ml-dsa/slh-dsa/ml-kem additively via a new Parameter property; rsa unchanged.\n\n```json:metadata\n{\"files\": [\"src/ScepTestClient.CryptoApi/KeySpec.cs\", \"tests/ScepTestClient.Tests/PqKeySpecTests.cs\"], \"verifyCommand\": \"dotnet test tests/ScepTestClient.Tests --filter PqKeySpecTests\", \"acceptanceCriteria\": [\"PQ specs parse with Parameter\", \"rsa unchanged\", \"bad specs rejected\"], \"modelTier\": \"standard\"}\n```" }, { "id": 4, "subject": "Task 4: CryptoCapabilities PQ tiers + provider advertises PQ", - "status": "pending", + "status": "completed", "blockedBy": [2], "description": "Add additive PqTiers value to CryptoCapabilities; BC provider advertises PQ algs + tiers A=true B=true C=false.\n\n```json:metadata\n{\"files\": [\"src/ScepTestClient.CryptoApi/PqTiers.cs\", \"src/ScepTestClient.CryptoApi/CryptoCapabilities.cs\", \"src/ScepTestClient.Crypto.BouncyCastle/BouncyCastleScepCrypto.cs\", \"tests/ScepTestClient.Tests/PqCapabilitiesTests.cs\"], \"verifyCommand\": \"dotnet test tests/ScepTestClient.Tests --filter PqCapabilitiesTests\", \"acceptanceCriteria\": [\"PqTiers default false\", \"BC advertises A/B true C false\", \"PQ OIDs in caps sets\"], \"modelTier\": \"standard\"}\n```" }, { "id": 5, "subject": "Task 5: BC tier A — ML-DSA/SLH-DSA keygen + PKCS#8 round-trip", - "status": "pending", + "status": "completed", "blockedBy": [2, 3], "description": "GenerateKey produces ML-DSA/SLH-DSA keypairs; PKCS#8 export/import round-trip. PQ specifics in BcPqKeys helper. BC names arbitrated by round-trip test.\n\n```json:metadata\n{\"files\": [\"src/ScepTestClient.Crypto.BouncyCastle/BcPqKeys.cs\", \"src/ScepTestClient.Crypto.BouncyCastle/BcKey.cs\", \"src/ScepTestClient.Crypto.BouncyCastle/BouncyCastleScepCrypto.cs\", \"tests/ScepTestClient.Tests/PqKeyGenTests.cs\"], \"verifyCommand\": \"dotnet test tests/ScepTestClient.Tests --filter PqKeyGenTests\", \"acceptanceCriteria\": [\"PQ keygen works\", \"PKCS#8 round-trip preserves oid\", \"RSA unchanged\"], \"modelTier\": \"complex\"}\n```" }, { "id": 6, "subject": "Task 6: BC tier A — PQ CSR encode (SPKI + signature)", - "status": "pending", + "status": "completed", "blockedBy": [5], "description": "EncodeCsr emits PQ SubjectPublicKeyInfo + PQ signature; CSR parses and verifies. Signer construction arbitrated by test.\n\n```json:metadata\n{\"files\": [\"src/ScepTestClient.Crypto.BouncyCastle/BcCsrBuilder.cs\", \"tests/ScepTestClient.Tests/PqCsrTests.cs\"], \"verifyCommand\": \"dotnet test tests/ScepTestClient.Tests --filter PqCsrTests\", \"acceptanceCriteria\": [\"PQ CSR parses and verifies\", \"SPKI oid is ML-DSA\", \"RSA CSR unchanged\"], \"modelTier\": \"complex\"}\n```" }, { "id": 7, "subject": "Task 7: Tier B — catalyst alt-key + --alt-key-spec", - "status": "pending", + "status": "completed", "blockedBy": [6], "description": "Carry an optional alt public key into the CSR as subjectAltPublicKeyInfo (2.5.29.72) via Pkcs10.AltKey + EnrollRequest/builder/CLI. altSignatureValue not computed (documented limitation).\n\n```json:metadata\n{\"files\": [\"src/ScepTestClient.CryptoApi/Pkcs10.cs\", \"src/ScepTestClient.Crypto.BouncyCastle/BcCsrBuilder.cs\", \"src/ScepTestClient.Core/EnrollRequest.cs\", \"src/ScepTestClient.Core/ScepRequestBuilder.cs\", \"src/ScepTestClient.Core/ScepClient.cs\", \"src/ScepTestClient.Cli/CommandRouter.cs\", \"tests/ScepTestClient.Tests/AltKeyCsrTests.cs\"], \"verifyCommand\": \"dotnet test tests/ScepTestClient.Tests --filter AltKeyCsrTests\", \"acceptanceCriteria\": [\"AltKey additive\", \"subjectAltPublicKeyInfo emitted\", \"no alt-key => no extension\", \"--alt-key-spec wired\"], \"modelTier\": \"complex\"}\n```" }, { "id": 8, "subject": "Task 8: Capabilities drive opinion (CuttingEdge) + suggest", - "status": "pending", + "status": "completed", "blockedBy": [4], "description": "Add SecurityOpinion.ClassifySignature (CuttingEdge for PQ) and a ServerSuggest overload that emits a PQ enroll line when provider TierA; CLI wires it.\n\n```json:metadata\n{\"files\": [\"src/ScepTestClient.Core/Testing/SecurityOpinion.cs\", \"src/ScepTestClient.Core/Testing/ServerSuggest.cs\", \"src/ScepTestClient.Cli/CommandRouter.cs\", \"tests/ScepTestClient.Tests/PqOpinionSuggestTests.cs\"], \"verifyCommand\": \"dotnet test tests/ScepTestClient.Tests --filter PqOpinionSuggestTests\", \"acceptanceCriteria\": [\"ClassifySignature CuttingEdge for PQ\", \"suggest includes PQ when TierA\", \"old overload preserved\"], \"modelTier\": \"standard\"}\n```" }, { "id": 9, "subject": "Task 9: Empirical PQ probe", - "status": "pending", + "status": "completed", "blockedBy": [6], "description": "Add ProbePq to RunProbe attempting an ML-DSA enroll; reports PASSED/FINDING/FAILED (no GetCACaps PQ keyword). Skipped when no TierA.\n\n```json:metadata\n{\"files\": [\"src/ScepTestClient.Core/Testing/TestEngine.cs\", \"tests/ScepTestClient.Tests/PqProbeTests.cs\"], \"verifyCommand\": \"dotnet test tests/ScepTestClient.Tests --filter PqProbeTests\", \"acceptanceCriteria\": [\"PQ probe row present\", \"graceful on failure\", \"skipped when no TierA\", \"existing rows unchanged\"], \"modelTier\": \"standard\"}\n```" }, { "id": 10, "subject": "Task 10: crypto info / crypto list CLI", - "status": "pending", + "status": "completed", "blockedBy": [4], "description": "Add the crypto command: list (algorithms grouped by kind, friendly names) and info (provider source + PQ tiers).\n\n```json:metadata\n{\"files\": [\"src/ScepTestClient.Cli/CryptoCommand.cs\", \"src/ScepTestClient.Cli/CommandRouter.cs\", \"tests/ScepTestClient.Tests/CryptoCommandTests.cs\"], \"verifyCommand\": \"dotnet test tests/ScepTestClient.Tests --filter CryptoCommandTests\", \"acceptanceCriteria\": [\"crypto list shows PQ + classical\", \"crypto info shows tiers\", \"bad verb -> usage non-zero\"], \"modelTier\": \"standard\"}\n```" }, { "id": 11, "subject": "Task 11: --crypto-provider flag + config set wiring", - "status": "pending", + "status": "completed", "blockedBy": [10], "description": "Resolve provider DLL path from --crypto-provider/config and thread into every ScepCrypto.Load site; add config set .\n\n```json:metadata\n{\"files\": [\"src/ScepTestClient.Cli/CommandRouter.cs\", \"src/ScepTestClient.Cli/CryptoCommand.cs\", \"src/ScepTestClient.Core/Storage/ClientConfig.cs\", \"tests/ScepTestClient.Tests/CryptoProviderFlagTests.cs\"], \"verifyCommand\": \"dotnet test tests/ScepTestClient.Tests --filter CryptoProviderFlagTests\", \"acceptanceCriteria\": [\"ResolveProviderPath precedence\", \"config set persists\", \"all load sites threaded\", \"bogus path clean error\"], \"modelTier\": \"standard\"}\n```" }, { "id": 12, "subject": "Task 12: Harden external-provider ALC loading", - "status": "pending", + "status": "completed", "blockedBy": [1], "description": "Harden ScepCrypto.Load (reject >1 impl) + ProviderLoadContext (shared-contract guarantee); prove path-loaded provider works end to end.\n\n```json:metadata\n{\"files\": [\"src/ScepTestClient.Core/ScepCrypto.cs\", \"src/ScepTestClient.Core/ProviderLoadContext.cs\", \"tests/ScepTestClient.Tests/ProviderLoadTests.cs\"], \"verifyCommand\": \"dotnet test tests/ScepTestClient.Tests --filter ProviderLoad\", \"acceptanceCriteria\": [\"explicit-path load shares contract\", \"no-impl -> error\", \"multi-impl -> error\", \"shared type identity round-trip\"], \"modelTier\": \"standard\"}\n```" } From fe35932e5383fba14c26a6d69916daeb0cbf1ebe Mon Sep 17 00:00:00 2001 From: Peter Dennis Bartok Date: Sat, 20 Jun 2026 14:48:17 -0600 Subject: [PATCH 16/16] Phase 4: full SLH-DSA SHA2 matrix (s+f) with correct NIST OIDs; sign PQ CSRs by OID MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BouncyCastle 2.5.0 DOES support the SLH-DSA fast (f) variants. Restore the six SHA2 sets (128s/128f/192s/192f/256s/256f) in KeySpec/registry/provider, and fix the 192s/256s registry OIDs (were .21/.22; the NIST CSOR arc .20-.25 is sequential across s/f, so correct values are .22/.24). Sign PQ CSRs via the algorithm OID (Asn1SignatureFactory does not recognize the SLH-DSA friendly names) — also closes a latent gap: no SLH-DSA CSR was previously exercised. --- .../BcAlgorithms.cs | 7 +- .../BcPqKeys.cs | 47 +++++++------ .../BouncyCastleScepCrypto.cs | 6 +- src/ScepTestClient.CryptoApi/Algorithms.cs | 7 +- src/ScepTestClient.CryptoApi/KeySpec.cs | 4 +- .../SlhDsaVariantsTests.cs | 66 +++++++++++++++++++ 6 files changed, 111 insertions(+), 26 deletions(-) create mode 100644 tests/ScepTestClient.Tests/SlhDsaVariantsTests.cs diff --git a/src/ScepTestClient.Crypto.BouncyCastle/BcAlgorithms.cs b/src/ScepTestClient.Crypto.BouncyCastle/BcAlgorithms.cs index d596871..20b847b 100644 --- a/src/ScepTestClient.Crypto.BouncyCastle/BcAlgorithms.cs +++ b/src/ScepTestClient.Crypto.BouncyCastle/BcAlgorithms.cs @@ -13,8 +13,11 @@ internal static class BcAlgorithms { public const string MlDsa65 = "2.16.840.1.101.3.4.3.18"; public const string MlDsa87 = "2.16.840.1.101.3.4.3.19"; public const string SlhDsa128s = "2.16.840.1.101.3.4.3.20"; - public const string SlhDsa192s = "2.16.840.1.101.3.4.3.21"; - public const string SlhDsa256s = "2.16.840.1.101.3.4.3.22"; + public const string SlhDsa128f = "2.16.840.1.101.3.4.3.21"; + public const string SlhDsa192s = "2.16.840.1.101.3.4.3.22"; + public const string SlhDsa192f = "2.16.840.1.101.3.4.3.23"; + public const string SlhDsa256s = "2.16.840.1.101.3.4.3.24"; + public const string SlhDsa256f = "2.16.840.1.101.3.4.3.25"; public const string MlKem512 = "2.16.840.1.101.3.4.4.1"; public const string MlKem768 = "2.16.840.1.101.3.4.4.2"; public const string MlKem1024 = "2.16.840.1.101.3.4.4.3"; diff --git a/src/ScepTestClient.Crypto.BouncyCastle/BcPqKeys.cs b/src/ScepTestClient.Crypto.BouncyCastle/BcPqKeys.cs index d61597e..dab124f 100644 --- a/src/ScepTestClient.Crypto.BouncyCastle/BcPqKeys.cs +++ b/src/ScepTestClient.Crypto.BouncyCastle/BcPqKeys.cs @@ -56,14 +56,26 @@ public static bool TryGenerate(KeySpec spec, SecureRandom random, out Asymmetric parameters = SlhDsaParameters.slh_dsa_sha2_128s; oid_name = "SLH-DSA-128s"; break; + case "128f": + parameters = SlhDsaParameters.slh_dsa_sha2_128f; + oid_name = "SLH-DSA-128f"; + break; case "192s": parameters = SlhDsaParameters.slh_dsa_sha2_192s; oid_name = "SLH-DSA-192s"; break; + case "192f": + parameters = SlhDsaParameters.slh_dsa_sha2_192f; + oid_name = "SLH-DSA-192f"; + break; case "256s": parameters = SlhDsaParameters.slh_dsa_sha2_256s; oid_name = "SLH-DSA-256s"; break; + case "256f": + parameters = SlhDsaParameters.slh_dsa_sha2_256f; + oid_name = "SLH-DSA-256f"; + break; default: error = $"unsupported SLH-DSA parameter set '{spec.Parameter}'"; return false; @@ -121,29 +133,17 @@ public static bool IsPq(BcKey key) { return key.KeyPair.Private is MLDsaPrivateKeyParameters || key.KeyPair.Private is SlhDsaPrivateKeyParameters; } - // Build a PQ ISignatureFactory for the key, using the friendly algorithm name - // (e.g. "ML-DSA-65", "SLH-DSA-128s") that BC's Asn1SignatureFactory recognizes. - // The resulting factory emits the correct PQ AlgorithmIdentifier for the SPKI/CSR. + // Build a PQ ISignatureFactory for the key. Asn1SignatureFactory accepts a string that is either + // a registered algorithm name OR a dotted OID. ML-DSA registers the friendly name ("ML-DSA-65"), + // but SLH-DSA does NOT register "SLH-DSA-128f" et al. — only the OID resolves. BcKey.AlgorithmOid + // is the dotted NIST OID for the (now correct) registry entry, which Asn1SignatureFactory accepts + // for both families, so sign by OID uniformly. Emits the correct PQ AlgorithmIdentifier. public static ISignatureFactory SignatureFactory(BcKey key) { - string algorithm_name; - - algorithm_name = NameFor(key.KeyPair.Private); - if (algorithm_name.Length == 0) { + if (key.KeyPair.Private is not MLDsaPrivateKeyParameters && key.KeyPair.Private is not SlhDsaPrivateKeyParameters) { throw new InvalidOperationException("private key is not a supported post-quantum type"); } - return new Asn1SignatureFactory(algorithm_name, key.KeyPair.Private, new SecureRandom()); - } - - // Resolve the friendly signature algorithm name from a PQ private key's parameter set. - private static string NameFor(AsymmetricKeyParameter priv) { - if (priv is MLDsaPrivateKeyParameters ml_priv) { - return MlDsaNameFor(ml_priv.Parameters); - } - if (priv is SlhDsaPrivateKeyParameters slh_priv) { - return SlhDsaNameFor(slh_priv.Parameters); - } - return string.Empty; + return new Asn1SignatureFactory(key.AlgorithmOid, key.KeyPair.Private, new SecureRandom()); } // Map a BC ML-DSA parameter set instance to the registry-friendly name. @@ -165,12 +165,21 @@ private static string SlhDsaNameFor(SlhDsaParameters parameters) { if (ReferenceEquals(parameters, SlhDsaParameters.slh_dsa_sha2_128s)) { return "SLH-DSA-128s"; } + if (ReferenceEquals(parameters, SlhDsaParameters.slh_dsa_sha2_128f)) { + return "SLH-DSA-128f"; + } if (ReferenceEquals(parameters, SlhDsaParameters.slh_dsa_sha2_192s)) { return "SLH-DSA-192s"; } + if (ReferenceEquals(parameters, SlhDsaParameters.slh_dsa_sha2_192f)) { + return "SLH-DSA-192f"; + } if (ReferenceEquals(parameters, SlhDsaParameters.slh_dsa_sha2_256s)) { return "SLH-DSA-256s"; } + if (ReferenceEquals(parameters, SlhDsaParameters.slh_dsa_sha2_256f)) { + return "SLH-DSA-256f"; + } return string.Empty; } } diff --git a/src/ScepTestClient.Crypto.BouncyCastle/BouncyCastleScepCrypto.cs b/src/ScepTestClient.Crypto.BouncyCastle/BouncyCastleScepCrypto.cs index 2ffc603..f7ec947 100644 --- a/src/ScepTestClient.Crypto.BouncyCastle/BouncyCastleScepCrypto.cs +++ b/src/ScepTestClient.Crypto.BouncyCastle/BouncyCastleScepCrypto.cs @@ -16,12 +16,14 @@ public sealed class BouncyCastleScepCrypto : IScepCrypto { public CryptoCapabilities Capabilities { get; } = new CryptoCapabilities { Digests = new[] { BcAlgorithms.Sha1, BcAlgorithms.Sha256, BcAlgorithms.Sha512, BcAlgorithms.Md5 }, Signatures = new[] { BcAlgorithms.Rsa, BcAlgorithms.MlDsa44, BcAlgorithms.MlDsa65, BcAlgorithms.MlDsa87, - BcAlgorithms.SlhDsa128s, BcAlgorithms.SlhDsa192s, BcAlgorithms.SlhDsa256s }, + BcAlgorithms.SlhDsa128s, BcAlgorithms.SlhDsa128f, BcAlgorithms.SlhDsa192s, + BcAlgorithms.SlhDsa192f, BcAlgorithms.SlhDsa256s, BcAlgorithms.SlhDsa256f }, ContentEncryption = new[] { BcAlgorithms.Aes128Cbc, BcAlgorithms.Aes256Cbc, BcAlgorithms.Des3Cbc }, KeyTransport = new[] { BcAlgorithms.Rsa }, Kem = new[] { BcAlgorithms.MlKem512, BcAlgorithms.MlKem768, BcAlgorithms.MlKem1024 }, AsymmetricKeys = new[] { BcAlgorithms.Rsa, BcAlgorithms.MlDsa44, BcAlgorithms.MlDsa65, BcAlgorithms.MlDsa87, - BcAlgorithms.SlhDsa128s, BcAlgorithms.SlhDsa192s, BcAlgorithms.SlhDsa256s }, + BcAlgorithms.SlhDsa128s, BcAlgorithms.SlhDsa128f, BcAlgorithms.SlhDsa192s, + BcAlgorithms.SlhDsa192f, BcAlgorithms.SlhDsa256s, BcAlgorithms.SlhDsa256f }, PqTiers = new PqTiers(TierA: true, TierB: true, TierC: false), }; diff --git a/src/ScepTestClient.CryptoApi/Algorithms.cs b/src/ScepTestClient.CryptoApi/Algorithms.cs index 9d08e51..e5ecaee 100644 --- a/src/ScepTestClient.CryptoApi/Algorithms.cs +++ b/src/ScepTestClient.CryptoApi/Algorithms.cs @@ -19,8 +19,11 @@ public static class Algorithms { new("ML-DSA-65", "2.16.840.1.101.3.4.3.18", AlgorithmKind.Signature), new("ML-DSA-87", "2.16.840.1.101.3.4.3.19", AlgorithmKind.Signature), new("SLH-DSA-128s","2.16.840.1.101.3.4.3.20", AlgorithmKind.Signature), - new("SLH-DSA-192s","2.16.840.1.101.3.4.3.21", AlgorithmKind.Signature), - new("SLH-DSA-256s","2.16.840.1.101.3.4.3.22", AlgorithmKind.Signature), + new("SLH-DSA-128f","2.16.840.1.101.3.4.3.21", AlgorithmKind.Signature), + new("SLH-DSA-192s","2.16.840.1.101.3.4.3.22", AlgorithmKind.Signature), + new("SLH-DSA-192f","2.16.840.1.101.3.4.3.23", AlgorithmKind.Signature), + new("SLH-DSA-256s","2.16.840.1.101.3.4.3.24", AlgorithmKind.Signature), + new("SLH-DSA-256f","2.16.840.1.101.3.4.3.25", AlgorithmKind.Signature), new("ML-KEM-512", "2.16.840.1.101.3.4.4.1", AlgorithmKind.Kem), new("ML-KEM-768", "2.16.840.1.101.3.4.4.2", AlgorithmKind.Kem), new("ML-KEM-1024", "2.16.840.1.101.3.4.4.3", AlgorithmKind.Kem), diff --git a/src/ScepTestClient.CryptoApi/KeySpec.cs b/src/ScepTestClient.CryptoApi/KeySpec.cs index e539512..44c8677 100644 --- a/src/ScepTestClient.CryptoApi/KeySpec.cs +++ b/src/ScepTestClient.CryptoApi/KeySpec.cs @@ -2,7 +2,9 @@ namespace ScepTestClient.CryptoApi; public sealed class KeySpec { private static readonly string[] MlDsaSets = { "44", "65", "87" }; - private static readonly string[] SlhDsaSets = { "128s", "192s", "256s" }; + // SLH-DSA (FIPS 205) SHA2 family, both small (s) and fast (f). A bare set token maps to the + // SHA2 variant; the SHAKE family would need a distinct token scheme and is not exposed here. + private static readonly string[] SlhDsaSets = { "128s", "128f", "192s", "192f", "256s", "256f" }; private static readonly string[] MlKemSets = { "512", "768", "1024" }; public string Algorithm { get; } diff --git a/tests/ScepTestClient.Tests/SlhDsaVariantsTests.cs b/tests/ScepTestClient.Tests/SlhDsaVariantsTests.cs new file mode 100644 index 0000000..d590720 --- /dev/null +++ b/tests/ScepTestClient.Tests/SlhDsaVariantsTests.cs @@ -0,0 +1,66 @@ +using Org.BouncyCastle.Pkcs; +using ScepTestClient.Crypto.BouncyCastle; +using ScepTestClient.CryptoApi; +using Xunit; + +namespace ScepTestClient.Tests; + +// SLH-DSA (FIPS 205) SHA2 family: all six small (s) and fast (f) parameter sets. +// NIST CSOR sigAlgs arc 2.16.840.1.101.3.4.3.{20..25} is sequential across the SHA2 sets. +public sealed class SlhDsaVariantsTests { + [Theory] + [InlineData("slh-dsa:128s", "2.16.840.1.101.3.4.3.20")] + [InlineData("slh-dsa:128f", "2.16.840.1.101.3.4.3.21")] + [InlineData("slh-dsa:192s", "2.16.840.1.101.3.4.3.22")] + [InlineData("slh-dsa:192f", "2.16.840.1.101.3.4.3.23")] + [InlineData("slh-dsa:256s", "2.16.840.1.101.3.4.3.24")] + [InlineData("slh-dsa:256f", "2.16.840.1.101.3.4.3.25")] + public void Generates_all_sha2_sets_with_correct_oid(string key_spec, string expected_oid) { + BouncyCastleScepCrypto crypto; + KeySpec spec; + IScepKey key; + string error; + + crypto = new BouncyCastleScepCrypto(); + Assert.True(KeySpec.Parse(key_spec, out spec, out error), error); + Assert.True(crypto.GenerateKey(spec, out key, out error), error); + Assert.Equal(expected_oid, key.AlgorithmOid); + } + + [Fact] + public void Fast_variant_pkcs8_roundtrips() { + BouncyCastleScepCrypto crypto; + KeySpec spec; + IScepKey key; + IScepKey imported; + byte[] der; + string error; + + crypto = new BouncyCastleScepCrypto(); + Assert.True(KeySpec.Parse("slh-dsa:128f", out spec, out error), error); + Assert.True(crypto.GenerateKey(spec, out key, out error), error); + Assert.True(crypto.ExportPrivateKeyPkcs8(key, out der, out error), error); + Assert.True(crypto.ImportPrivateKeyPkcs8(der, out imported, out error), error); + Assert.Equal(key.AlgorithmOid, imported.AlgorithmOid); + } + + [Fact] + public void Fast_variant_csr_parses_and_verifies() { + BouncyCastleScepCrypto crypto; + KeySpec spec; + IScepKey key; + Pkcs10 csr; + byte[] der; + string error; + Pkcs10CertificationRequest parsed; + + crypto = new BouncyCastleScepCrypto(); + Assert.True(KeySpec.Parse("slh-dsa:128f", out spec, out error), error); + Assert.True(crypto.GenerateKey(spec, out key, out error), error); + csr = new Pkcs10 { Key = key }; + csr.SetSubject("CN=slh-test", out error); + Assert.True(crypto.EncodeCsr(csr, out der, out error), error); + parsed = new Pkcs10CertificationRequest(der); + Assert.True(parsed.Verify()); + } +}