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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 46 additions & 4 deletions src/ScepWright.Client/CommandRouter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -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<EnrollOutcome> result;

if (args.Length < 2) { output.WriteLine("usage: poll <serverId> --issuer <dn> --subject <dn> --txn <id>"); return 2; }
if (!RejectUnknownFlags(args, output, new[] { "--issuer", "--subject", "--txn" }, System.Array.Empty<string>())) { return 2; }
if (args.Length < 2) { output.WriteLine("usage: poll <serverId> --issuer <dn> --subject <dn> --txn <id> [--key-pass <pw>]"); return 2; }
if (!RejectUnknownFlags(args, output, new[] { "--issuer", "--subject", "--txn", "--key-pass" }, System.Array.Empty<string>())) { 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})");
Expand Down Expand Up @@ -1847,7 +1889,7 @@ public static string HelpTest(string? run_as = null) {
" getcacaps <serverId>",
" getcacert <serverId> [-v] (-v shows full CA/RA cert details)",
" getnextcacert <serverId>",
" poll <serverId> --issuer <dn> --subject <dn> --txn <id>",
" poll <serverId> --issuer <dn> --subject <dn> --txn <id> [--key-pass <pw>] (completes a PENDING enroll: stores the issued cert+key in `certs list`)",
" getcert <serverId> --issuer <dn> --serial <hex>",
" getcrl <serverId> --issuer <dn> --serial <hex>",
" servers suggest <id>",
Expand Down
40 changes: 27 additions & 13 deletions src/ScepWright.Core/ScepClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -535,31 +535,38 @@ private bool BuildIssuerSerialMessage(MessageType type, string issuer_dn, string
// -------------------------------------------------------------------------

/// <summary>Polls for a pending request (CertPoll / GetCertInitial) by issuer, subject, and transaction id.</summary>
public ScepResult<EnrollOutcome> Poll(string issuer_dn, string subject_dn, string transaction_id) {
/// <summary>
/// Polls for a pending request (CertPoll / GetCertInitial) by issuer, subject, and transaction id.
/// When <paramref name="original_key"/> 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).
/// </summary>
public ScepResult<EnrollOutcome> 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<EnrollOutcome>.Fail(ScepClientResult.InvalidArgument, error);
}
return SendPkiOperationSync(message, signer_key);
}

/// <summary>Polls for a pending request (CertPoll / GetCertInitial) by issuer, subject, and transaction id.</summary>
public async Task<ScepResult<EnrollOutcome>> PollAsync(string issuer_dn, string subject_dn, string transaction_id) {
public async Task<ScepResult<EnrollOutcome>> 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<EnrollOutcome>.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!;
Expand All @@ -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;
}

Expand Down
114 changes: 114 additions & 0 deletions src/ScepWright.Core/Storage/PendingStore.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
using System.IO;
using System.Text.Json;
using ScepWright.Crypto;

namespace ScepWright.Core.Storage;

/// <summary>
/// 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 <c>poll</c> 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 <c>certs list</c>.
/// </summary>
public sealed class PendingStore {
private readonly string _root;

/// <summary>Creates a store rooted at the given data directory.</summary>
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);
}

/// <summary>
/// Persists the subject key and request metadata for a pending enrollment. The key is written
/// encrypted when <paramref name="passphrase"/> is supplied, otherwise as plaintext PKCS#8.
/// </summary>
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));
}

/// <summary>
/// Loads the subject key and metadata for a pending enrollment. A <paramref name="passphrase"/> is
/// required if the key was stored encrypted.
/// </summary>
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<PendingRecord>(File.ReadAllText(Path.Combine(dir, "request.json")))!;
return true;
}

/// <summary>Removes a pending enrollment once its certificate has been issued and persisted.</summary>
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);
}
}

/// <summary>Persisted metadata about a pending enrollment.</summary>
public sealed class PendingRecord {
/// <summary>Gets or sets the SCEP transaction id of the pending request.</summary>
public string TransactionId { get; set; } = string.Empty;
/// <summary>Gets or sets the key spec the request was enrolled with.</summary>
public string? KeySpec { get; set; }
}
}
61 changes: 61 additions & 0 deletions tests/ScepWright.Tests/CliRouterPhase2Tests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
}
Expand Down
Loading