From 0f0ddbf106bafb97fc1953e66f833e6886c9bd74 Mon Sep 17 00:00:00 2001 From: Peter Dennis Bartok Date: Tue, 23 Jun 2026 17:46:44 -0600 Subject: [PATCH] - We have to allow manual pending/poll/retrieve without losing the data. Now persisting things even if we get a pending to have the later poll properly retrieve it --- src/ScepWright.Client/CommandRouter.cs | 50 +++++++- src/ScepWright.Core/ScepClient.cs | 40 ++++-- src/ScepWright.Core/Storage/PendingStore.cs | 114 ++++++++++++++++++ .../ScepWright.Tests/CliRouterPhase2Tests.cs | 61 ++++++++++ tests/ScepWright.Tests/PendingStoreTests.cs | 76 ++++++++++++ 5 files changed, 324 insertions(+), 17 deletions(-) create mode 100644 src/ScepWright.Core/Storage/PendingStore.cs create mode 100644 tests/ScepWright.Tests/PendingStoreTests.cs diff --git a/src/ScepWright.Client/CommandRouter.cs b/src/ScepWright.Client/CommandRouter.cs index aec3d4a..000e075 100644 --- a/src/ScepWright.Client/CommandRouter.cs +++ b/src/ScepWright.Client/CommandRouter.cs @@ -552,6 +552,16 @@ private static int RunGet(string[] args, string data_root, TextWriter output) { } if (outcome.Status == ScepClientResult.Pending) { + string pending_txn; + + // Persist the subject key + request so `poll` can later sign the CertPoll with this key + // (RFC 8894 §3.3.2) and store the issued cert paired with it. Without this the polled cert + // would have no matching key and never appear in `certs list`. + pending_txn = outcome.Value?.TransactionId ?? string.Empty; + if (pending_txn.Length > 0) { + new PendingStore(data_root).Save(stored.Id, pending_txn, request.Key, client.Crypto, + key_spec_text: request.KeySpecText, passphrase: encrypt_keys ? key_pass : null); + } return ReportPending(client, outcome.Value, subject!, output); } @@ -1037,19 +1047,51 @@ private static int RunPoll(string[] args, string data_root, TextWriter output) { string? issuer; string? subject; string? txn; + string? key_pass; + PendingStore pending; + IScepKey? original_key; + PendingStore.PendingRecord? pending_record; + IScepKey loaded_key; + PendingStore.PendingRecord loaded_record; + string load_error; ScepResult result; - if (args.Length < 2) { output.WriteLine("usage: poll --issuer --subject --txn "); return 2; } - if (!RejectUnknownFlags(args, output, new[] { "--issuer", "--subject", "--txn" }, System.Array.Empty())) { return 2; } + if (args.Length < 2) { output.WriteLine("usage: poll --issuer --subject --txn [--key-pass ]"); return 2; } + if (!RejectUnknownFlags(args, output, new[] { "--issuer", "--subject", "--txn", "--key-pass" }, System.Array.Empty())) { return 2; } issuer = Opt(args, "--issuer"); subject = Opt(args, "--subject"); txn = Opt(args, "--txn"); + key_pass = Opt(args, "--key-pass"); if (string.IsNullOrWhiteSpace(issuer) || string.IsNullOrWhiteSpace(subject) || string.IsNullOrWhiteSpace(txn)) { output.WriteLine("--issuer, --subject and --txn are required"); return 2; } if (!BuildClient(args, args[1], data_root, output, out client, out stored)) { return 2; } - result = client.Poll(issuer!, subject!, txn!); + // Recover the original enrollment key for this transaction (saved when the request went PENDING), + // so the CertPoll is signed with it (RFC 8894 §3.3.2) and the issued cert can be stored as a + // usable cert+key pair that shows up in `certs list`. + pending = new PendingStore(data_root); + original_key = null; + pending_record = null; + if (pending.TryLoad(stored.Id, txn!, client.Crypto, out loaded_key, out loaded_record, out load_error, passphrase: key_pass)) { + original_key = loaded_key; + pending_record = loaded_record; + } else if (Directory.Exists(Path.Combine(data_root, "servers", stored.Id, "pending", txn!))) { + output.WriteLine($"note: {load_error} — polling without it; the issued cert will NOT be stored."); + } + + result = client.Poll(issuer!, subject!, txn!, original_key); if (result.IsOk && result.Value.Certificate is not null) { output.WriteLine($"polled: {result.Value.Certificate.Subject}"); + if (original_key is not null) { + string cert_id; + + cert_id = new CertStore(data_root).Save(stored.Id, result.Value.Certificate, original_key, client.Crypto, + challenge_password: null, renewed_from: null, transaction_id: txn, + passphrase: string.IsNullOrEmpty(key_pass) ? null : key_pass, key_spec_text: pending_record?.KeySpec); + pending.Delete(stored.Id, txn!); + output.WriteLine($" stored: {stored.Id}/{cert_id} (now in `certs list`; use with `renew` / `certs export`)"); + } else { + output.WriteLine(" note: no pending enrollment found for this --txn, so the cert was received but NOT stored (no matching private key). Run the original `get`/`enroll` first."); + } return 0; } output.WriteLine($"poll status: {result.Status} (pkiStatus {result.Value?.PkiStatus})"); @@ -1847,7 +1889,7 @@ public static string HelpTest(string? run_as = null) { " getcacaps ", " getcacert [-v] (-v shows full CA/RA cert details)", " getnextcacert ", - " poll --issuer --subject --txn ", + " poll --issuer --subject --txn [--key-pass ] (completes a PENDING enroll: stores the issued cert+key in `certs list`)", " getcert --issuer --serial ", " getcrl --issuer --serial ", " servers suggest ", diff --git a/src/ScepWright.Core/ScepClient.cs b/src/ScepWright.Core/ScepClient.cs index d91c609..eafdd2f 100644 --- a/src/ScepWright.Core/ScepClient.cs +++ b/src/ScepWright.Core/ScepClient.cs @@ -535,31 +535,38 @@ private bool BuildIssuerSerialMessage(MessageType type, string issuer_dn, string // ------------------------------------------------------------------------- /// Polls for a pending request (CertPoll / GetCertInitial) by issuer, subject, and transaction id. - public ScepResult Poll(string issuer_dn, string subject_dn, string transaction_id) { + /// + /// Polls for a pending request (CertPoll / GetCertInitial) by issuer, subject, and transaction id. + /// When is supplied (the key from the PENDING enrollment), the poll is + /// signed with it per RFC 8894 §3.3.2 so the CA returns the cert bound to that key; otherwise a + /// transient transport key is used (standalone manual poll, response decrypt only). + /// + public ScepResult Poll(string issuer_dn, string subject_dn, string transaction_id, IScepKey? original_key = null) { PkiMessage message; IScepKey signer_key; string error; - if (!BuildPollMessage(issuer_dn, subject_dn, transaction_id, out message, out signer_key, out error)) { + if (!BuildPollMessage(issuer_dn, subject_dn, transaction_id, original_key, out message, out signer_key, out error)) { return ScepResult.Fail(ScepClientResult.InvalidArgument, error); } return SendPkiOperationSync(message, signer_key); } /// Polls for a pending request (CertPoll / GetCertInitial) by issuer, subject, and transaction id. - public async Task> PollAsync(string issuer_dn, string subject_dn, string transaction_id) { + public async Task> PollAsync(string issuer_dn, string subject_dn, string transaction_id, IScepKey? original_key = null) { PkiMessage message; IScepKey signer_key; string error; - if (!BuildPollMessage(issuer_dn, subject_dn, transaction_id, out message, out signer_key, out error)) { + if (!BuildPollMessage(issuer_dn, subject_dn, transaction_id, original_key, out message, out signer_key, out error)) { return ScepResult.Fail(ScepClientResult.InvalidArgument, error); } return await SendPkiOperationAsync(message, signer_key).ConfigureAwait(false); } - private bool BuildPollMessage(string issuer_dn, string subject_dn, string transaction_id, out PkiMessage message, out IScepKey signer_key, out string error) { + private bool BuildPollMessage(string issuer_dn, string subject_dn, string transaction_id, IScepKey? original_key, out PkiMessage message, out IScepKey signer_key, out string error) { X509Certificate2 ca_cert; + ScepRequestBuilder builder; message = null!; signer_key = null!; @@ -568,14 +575,21 @@ private bool BuildPollMessage(string issuer_dn, string subject_dn, string transa return false; } - if (!ScepRequestBuilder.For(Crypto) - .CaCertificate(ca_cert) - .MessageType(MessageType.CertPoll) - // Deliberate fixed transport key: CertPoll carries no subject key, so this is only the - // transient signer that decrypts the CMS response; rsa:2048 is the safe baseline. - .KeySpec("rsa:2048") - .IssuerAndSubject(issuer_dn, subject_dn) - .Build(out message, out signer_key, out error)) { + builder = ScepRequestBuilder.For(Crypto) + .CaCertificate(ca_cert) + .MessageType(MessageType.CertPoll) + .IssuerAndSubject(issuer_dn, subject_dn); + if (original_key is not null) { + // RFC 8894 §3.3.2: sign GetCertInitial with the original enrollment key so the CA returns + // the certificate bound to it (and the response enveloped back is decryptable by it). + builder.SubjectKey(original_key); + } else { + // Standalone manual poll: CertPoll carries no subject key, so this is only the transient + // signer that decrypts the CMS response; rsa:2048 is the safe baseline. + builder.KeySpec("rsa:2048"); + } + + if (!builder.Build(out message, out signer_key, out error)) { return false; } diff --git a/src/ScepWright.Core/Storage/PendingStore.cs b/src/ScepWright.Core/Storage/PendingStore.cs new file mode 100644 index 0000000..a3b7501 --- /dev/null +++ b/src/ScepWright.Core/Storage/PendingStore.cs @@ -0,0 +1,114 @@ +using System.IO; +using System.Text.Json; +using ScepWright.Crypto; + +namespace ScepWright.Core.Storage; + +/// +/// On-disk store of PENDING enrollments awaiting CA approval, keyed by server and SCEP transaction id. +/// Holds the original subject private key and request metadata so a later poll can sign the +/// CertPoll with that key (RFC 8894 §3.3.2) and persist the issued certificate paired with it — without +/// this, the polled certificate has no matching key on disk and never appears in certs list. +/// +public sealed class PendingStore { + private readonly string _root; + + /// Creates a store rooted at the given data directory. + public PendingStore(string root) { + _root = root; + } + + private string Dir(string server_id, string transaction_id) { + return Path.Combine(_root, "servers", server_id, "pending", transaction_id); + } + + /// + /// Persists the subject key and request metadata for a pending enrollment. The key is written + /// encrypted when is supplied, otherwise as plaintext PKCS#8. + /// + public void Save(string server_id, string transaction_id, IScepKey key, IScepCrypto crypto, + string? key_spec_text, string? passphrase = null) { + string dir; + byte[] key_der; + string key_error; + PendingRecord record; + + dir = Dir(server_id, transaction_id); + Directory.CreateDirectory(dir); + + if (!string.IsNullOrEmpty(passphrase)) { + if (crypto.ExportPrivateKeyPkcs8Encrypted(key, passphrase!, out key_der, out key_error)) { + File.WriteAllBytes(Path.Combine(dir, "key.pkcs8.enc"), key_der); + } + } else if (crypto.ExportPrivateKeyPkcs8(key, out key_der, out key_error)) { + File.WriteAllBytes(Path.Combine(dir, "key.pkcs8"), key_der); + } + + record = new PendingRecord { + TransactionId = transaction_id, + KeySpec = key_spec_text, + }; + File.WriteAllText(Path.Combine(dir, "request.json"), JsonSerializer.Serialize(record)); + } + + /// + /// Loads the subject key and metadata for a pending enrollment. A is + /// required if the key was stored encrypted. + /// + public bool TryLoad(string server_id, string transaction_id, IScepCrypto crypto, + out IScepKey key, out PendingRecord record, out string error, string? passphrase = null) { + string dir; + string plain_path; + string enc_path; + + key = null!; + record = null!; + error = string.Empty; + + dir = Dir(server_id, transaction_id); + if (!Directory.Exists(dir)) { + error = $"no pending enrollment for transaction '{transaction_id}' under server '{server_id}'"; + return false; + } + + plain_path = Path.Combine(dir, "key.pkcs8"); + enc_path = Path.Combine(dir, "key.pkcs8.enc"); + if (File.Exists(enc_path)) { + if (string.IsNullOrEmpty(passphrase)) { + error = $"pending enrollment '{transaction_id}' has an encrypted key; a passphrase is required"; + return false; + } + if (!crypto.ImportPrivateKeyPkcs8Encrypted(File.ReadAllBytes(enc_path), passphrase!, out key, out error)) { + return false; + } + } else if (File.Exists(plain_path)) { + if (!crypto.ImportPrivateKeyPkcs8(File.ReadAllBytes(plain_path), out key, out error)) { + return false; + } + } else { + error = $"no stored key for pending enrollment '{transaction_id}'"; + return false; + } + + record = JsonSerializer.Deserialize(File.ReadAllText(Path.Combine(dir, "request.json")))!; + return true; + } + + /// Removes a pending enrollment once its certificate has been issued and persisted. + public void Delete(string server_id, string transaction_id) { + string dir; + + dir = Dir(server_id, transaction_id); + if (Directory.Exists(dir)) { + Directory.Delete(dir, recursive: true); + } + } + + /// Persisted metadata about a pending enrollment. + public sealed class PendingRecord { + /// Gets or sets the SCEP transaction id of the pending request. + public string TransactionId { get; set; } = string.Empty; + /// Gets or sets the key spec the request was enrolled with. + public string? KeySpec { get; set; } + } +} diff --git a/tests/ScepWright.Tests/CliRouterPhase2Tests.cs b/tests/ScepWright.Tests/CliRouterPhase2Tests.cs index 894c63d..ff3b363 100644 --- a/tests/ScepWright.Tests/CliRouterPhase2Tests.cs +++ b/tests/ScepWright.Tests/CliRouterPhase2Tests.cs @@ -636,6 +636,67 @@ public async Task Diagnose_accepts_verbose_flag() { Assert.Contains("GetCACaps", diag.ToString()); } + // The PENDING→poll lifecycle must leave a USABLE cert: get goes PENDING (nothing in `certs list`), + // then after approval `poll` pairs the issued cert with the saved subject key and persists it, so it + // lists AND exports as a PFX (which throws if cert and key don't match — proving the key is right). + [Fact] + public async Task Pending_enroll_then_poll_persists_a_usable_cert_in_certs_list() { + await using ScepServerApp server = await ScepServerApp.StartAsync(); + string root; + StringWriter outw; + StringWriter getw; + StringWriter list_before; + StringWriter pollw; + StringWriter list_after; + StringWriter exportw; + int get_code; + string txn; + string issuer; + int poll_code; + string cert_id; + int export_code; + + server.Ca.PendingMode = true; + root = Directory.CreateTempSubdirectory().FullName; + outw = new StringWriter(); + CommandRouter.Run(new[] { "servers", "add", server.ScepUrl.ToString(), "--name", "fake" }, root, outw); + + // 1. get -> PENDING: no certificate issued, nothing listed yet. + getw = new StringWriter(); + get_code = CommandRouter.Run(new[] { "get", "fake", "--subject", "CN=kevin5", "--challenge", "pw" }, root, getw); + Assert.Equal(0, get_code); + Assert.Contains("PENDING", getw.ToString()); + + list_before = new StringWriter(); + CommandRouter.Run(new[] { "certs", "list", "fake" }, root, list_before); + Assert.DoesNotContain("kevin5", list_before.ToString()); + + // The pending record was written, keyed by the transaction id. + txn = Path.GetFileName(Directory.GetDirectories(Path.Combine(root, "servers", "fake", "pending"))[0]); + + // 2. CA approves. + server.Ca.PendingMode = false; + + // 3. poll -> receives the cert and persists it paired with the saved key. + issuer = server.Ca.Certificate.SubjectDN.ToString(); + pollw = new StringWriter(); + poll_code = CommandRouter.Run(new[] { "poll", "fake", "--issuer", issuer, "--subject", "CN=kevin5", "--txn", txn }, root, pollw); + Assert.Equal(0, poll_code); + Assert.Contains("polled:", pollw.ToString()); + + // 4. it now appears in `certs list`. + list_after = new StringWriter(); + CommandRouter.Run(new[] { "certs", "list", "fake" }, root, list_after); + Assert.Contains("kevin5", list_after.ToString()); + + // 5. and it is usable: PFX export pairs cert+key (CopyWithPrivateKey throws on mismatch). + cert_id = FirstCertId(root, "fake"); + exportw = new StringWriter(); + export_code = CommandRouter.Run(new[] { "certs", "export", $"fake/{cert_id}", "--format", "pfx", "--out", Path.Combine(root, "kevin5.p12"), "--key-pass", "s3cret" }, root, exportw); + Assert.Equal(0, export_code); + Assert.True(File.Exists(Path.Combine(root, "kevin5.p12"))); + } + private static string FirstCertId(string root, string server) { return Path.GetFileName(Directory.GetDirectories(Path.Combine(root, "servers", server, "certificates"))[0]); } diff --git a/tests/ScepWright.Tests/PendingStoreTests.cs b/tests/ScepWright.Tests/PendingStoreTests.cs new file mode 100644 index 0000000..b195fa7 --- /dev/null +++ b/tests/ScepWright.Tests/PendingStoreTests.cs @@ -0,0 +1,76 @@ +using System.IO; +using ScepWright.Core.Storage; +using ScepWright.Crypto; +using ScepWright.Crypto.BouncyCastle; +using Xunit; + +namespace ScepWright.Tests; + +// A PENDING enrollment must persist the subject key + request so a later `poll` can pair the issued +// cert with its key. Without this, the polled cert has no key on disk and never appears in `certs list`. +public class PendingStoreTests { + [Fact] + public void Save_then_load_round_trips_the_key_and_metadata() { + BouncyCastleScepCrypto crypto; + KeySpec spec; + IScepKey key; + IScepKey loaded; + PendingStore.PendingRecord rec; + string root; + string error; + PendingStore store; + byte[] original_der; + byte[] loaded_der; + + crypto = new BouncyCastleScepCrypto(); + KeySpec.Parse("rsa:2048", out spec, out _); + crypto.GenerateKey(spec, out key, out _); + root = Directory.CreateTempSubdirectory().FullName; + store = new PendingStore(root); + + store.Save("fake", "txnabc", key, crypto, key_spec_text: "rsa:2048"); + + Assert.True(store.TryLoad("fake", "txnabc", crypto, out loaded, out rec, out error), error); + Assert.Equal("rsa:2048", rec.KeySpec); + crypto.ExportPrivateKeyPkcs8(key, out original_der, out _); + crypto.ExportPrivateKeyPkcs8(loaded, out loaded_der, out _); + Assert.Equal(original_der, loaded_der); + } + + [Fact] + public void Delete_removes_the_pending_record() { + BouncyCastleScepCrypto crypto; + KeySpec spec; + IScepKey key; + string root; + string error; + PendingStore store; + + crypto = new BouncyCastleScepCrypto(); + KeySpec.Parse("rsa:2048", out spec, out _); + crypto.GenerateKey(spec, out key, out _); + root = Directory.CreateTempSubdirectory().FullName; + store = new PendingStore(root); + + store.Save("fake", "txnabc", key, crypto, key_spec_text: "rsa:2048"); + store.Delete("fake", "txnabc"); + + Assert.False(store.TryLoad("fake", "txnabc", crypto, out _, out _, out error)); + Assert.Contains("txnabc", error); + } + + [Fact] + public void TryLoad_missing_returns_false_naming_the_transaction() { + BouncyCastleScepCrypto crypto; + string root; + string error; + PendingStore store; + + crypto = new BouncyCastleScepCrypto(); + root = Directory.CreateTempSubdirectory().FullName; + store = new PendingStore(root); + + Assert.False(store.TryLoad("fake", "nope", crypto, out _, out _, out error)); + Assert.Contains("nope", error); + } +}