Skip to content

Commit 255e3ad

Browse files
committed
fix+chore: audit triage (dev-review + SOX/SOC2 compliance pass)
Combined triage of two parallel audit reports on the PR-#2 change set since commit 6a2db50. Both ran against the live codebase, not just the diff. == ca-rest-plugin-dev (technical review) == Blocker B1 — ExtractSerialFromPem hex format parity: BouncyCastle BigInteger.ToString(16) drops the leading-zero nibble in the most-significant byte, producing "A123456" where X509Certificate2.SerialNumber returns "0A123456". That mismatch breaks audit-log correlation against the serial Keyfactor Command stores at issue time. Switched to Convert.ToHexString(cert.SerialNumber.ToByteArrayUnsigned()) which preserves every byte exactly. New ExtractSerialFromPemTests pins five cases including the leading-zero-byte regression. Blocker B2 — cached-DCV path missing OrderStatusId terminal guard: PerformDcvIfNeededAsync's new "return true on Status=1" cached-DCV path could fire on a cancelled or rejected order whose domainVerification.Status was carried over from a prior validated round — sending the caller into a wasted DcvWaitForIssuanceSeconds-long GetCertificate poll. Added the same OrderStatusId "4"/"5" check that WaitForDcvVerificationAsync uses, ahead of the AlreadyValidated branch. New Dcv_Skipped_WhenOrderStatusIdIsTerminal Theory pins both states. Should-fix S1: _domainValidatorFactory marked `volatile` so cross-thread SetDomainValidatorFactory writes are immediately visible to Enroll/Sync. Should-fix S2: README compatibility note rewritten — `SetDomainValidatorFactory` is the gateway host's integration point, not something operators call. Should-fix S3: footer "Production codes 838–847" note replaced with a pointer to the per-product sandbox/production table above; the range notation was confusing because some codes (e.g. 842) appear in both columns for different products. Nit N1: clarifying comment on Times.Exactly(1) expectation in the single-shot test. Nit N2: SetDomainValidatorFactory_SecondCall_OverridesFirst converted from reflection-on-field to a behavioural test driving Enroll and asserting which factory's validator was actually used. == compliance-auditor (SOX / SOC1 / SOC2) == Critical C1: WaitForDcvVerificationAsync gained a defense-in-depth absolute deadline so a future caller passing CancellationToken.None can't make the loop unbounded (SOX CC7.3). Critical C3 (verify-and-document): added an explicit comment at the OAuth2 token-acquisition site forbidding logging of tokenResp.Content / .ErrorMessage / .ErrorException — RestSharp echoes the original request body on failure, which contains the client_secret (SOX CC6.1). Material M1: SetDomainValidatorFactory now logs every call (offered type + whether the cast succeeded) so an auditor can confirm which DNS provider drove DCV (SOX change-management). Material M2: ValidateCAConnectionInfo and ValidateProductInfo blank out ApiKey/OAuthClientSecret/Password on the transient tempConfig after the validate call so they aren't reachable from the still-rooted tempClient instance (SOC2 CC6.1 best-effort). Material M3: Synchronize gained an error-rate threshold — aborts and throws when error rate exceeds 25% over a sample of ≥50 records, so a CA-side outage cannot silently 'complete' a sync with zero useful records (SOC1 completeness/accuracy). Material M4: Revoke log line now includes ManagedThreadId for correlation against any RequestingUser scope the gateway host enriches (SOX segregation-of-duties evidence). Material M5: RenewOrReissueAsync logs the PriorCertSN probe at Information (SOC2 CC6.1 logical-access event). Material M6: BuildDefaultAgreementDetails logs a Warning when SignerIp falls back to 127.0.0.1 — submitting that to a public CA is a misrepresentation in the legal audit record (SOC1 accuracy-of-processing). Material M7: ExtractErrorMessage caps the response-body size at 64 KB before JsonDocument.Parse to prevent memory-exhaustion DoS via a hostile CA response (SOC2 CC7.2). Advisory A1: GenerateTxnId switched from Random.Shared.NextDouble to Org.BouncyCastle.Security.SecureRandom.NextLong — txn is part of the authKey HMAC input, so cryptographic randomness is appropriate. Matches the project's BouncyCastle-only crypto policy. Advisory A2: Initialize now warns when DcvEnabled=true but no factory is injected — surfaces a silent functional downgrade. Advisory A3: ExtractSerialFromPem catch-all logs the suppression at Debug (audit visibility without breaking the never-throw contract). Advisory A5: Initialize log line gained DcvEnabled, DcvTxtRecordTemplate, and DomainValidatorFactoryInjected so DCV configuration is visible on every plugin restart. Advisory A6: in-flight DCV collision log promoted from Debug to Information — concurrent attempts are security-relevant events. == Tests == 153/153 unit tests pass. Live Ping/Sync still authenticate against the sandbox (the BouncyCastle SecureRandom-based GenerateTxnId produces a valid authKey end-to-end). == Deferred follow-ups (not in this commit) == C2 (auditor): generic CERTInext error-message passthrough flagged as a theoretical leak vector. Current behaviour matches every other CERTInext API consumer; rewriting all throw sites to drop the underlying message would noticeably hurt diagnostics. Worth a follow-up issue if the compliance team wants tighter belt-and-braces. A4 (auditor): make IsDcvNotYetReady deferral distinguishable from "no DCV required" in the return code so audit trail shows deferral cause. Worth doing but requires touching every PerformDcvIfNeededAsync caller.
1 parent 8503a8f commit 255e3ad

6 files changed

Lines changed: 404 additions & 35 deletions

File tree

CERTInext.Tests/CERTInextCAPluginDcvTests.cs

Lines changed: 74 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -371,28 +371,85 @@ public async Task SetDomainValidatorFactory_AfterConstruction_WiresFactoryForSub
371371
}
372372

373373
[Fact]
374-
public void SetDomainValidatorFactory_SecondCall_OverridesFirst()
374+
public async Task SetDomainValidatorFactory_SecondCall_OverridesFirst()
375375
{
376376
// Property-style setter semantics: the most recent SetDomainValidatorFactory
377377
// call wins. Important for gateway hosts that may resolve a fresh factory
378-
// per-initialize cycle.
379-
var plugin = new CERTInextCAPlugin();
378+
// per-initialize cycle. Tested behaviorally — drive Enroll() and assert
379+
// the SECOND factory's validator received the TXT staging call (no reflection
380+
// on internal fields).
381+
var (mock, _) = HappyPathMocks();
380382
var firstValidator = new FakeDomainValidator();
381383
var secondValidator = new FakeDomainValidator();
382384

385+
var plugin = new CERTInextCAPlugin(
386+
mock.Object,
387+
domainValidatorFactory: null,
388+
DcvConfig(dcvWaitForIssuanceSeconds: 10));
389+
390+
// First setter call is ignored by the override; only the second factory's
391+
// validator should ever see traffic.
383392
plugin.SetDomainValidatorFactory(new FakeDomainValidatorFactory(firstValidator));
384393
plugin.SetDomainValidatorFactory(new FakeDomainValidatorFactory(secondValidator));
385394

386-
// The plugin uses _domainValidatorFactory through internal methods; we reach
387-
// the field via reflection to assert the second factory is the one stored.
388-
var field = typeof(CERTInextCAPlugin)
389-
.GetField("_domainValidatorFactory",
390-
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
391-
field.Should().NotBeNull();
392-
var stored = field!.GetValue(plugin) as FakeDomainValidatorFactory;
393-
stored.Should().NotBeNull();
394-
stored!.PrimaryValidator.Should().BeSameAs(secondValidator,
395-
"the most recent SetDomainValidatorFactory call must replace the earlier one");
395+
var result = await Enroll(plugin);
396+
397+
result.Status.Should().Be((int)EndEntityStatus.GENERATED);
398+
firstValidator.StagedRecords.Should().BeEmpty(
399+
"the first factory must be replaced — its validator should never be called");
400+
secondValidator.StagedRecords.Should().NotBeEmpty(
401+
"the second SetDomainValidatorFactory call must replace the first; its validator drives DCV");
402+
}
403+
404+
// ---------------------------------------------------------------------------
405+
// Cancelled/rejected orders short-circuit even with validated DCV state
406+
// ---------------------------------------------------------------------------
407+
408+
[Theory]
409+
[InlineData("4")] // OrderStatusId 4 = Order Cancelled
410+
[InlineData("5")] // OrderStatusId 5 = Order Rejected
411+
public async Task Dcv_Skipped_WhenOrderStatusIdIsTerminal_EvenIfDcvValidated(string terminalOrderStatusId)
412+
{
413+
// Regression guard for the cached-DCV path: a cancelled or rejected order
414+
// can still have domainVerification.Status="1" carried over from a prior
415+
// validated round. Without this guard the plugin would return true from
416+
// PerformDcvIfNeededAsync and the caller would spend the full
417+
// DcvWaitForIssuanceSeconds budget polling GetCertificate for a cert that
418+
// is never going to issue. Per audit report B2 on PR #2.
419+
var mock = NewMock();
420+
mock.Setup(c => c.EnrollCertificateAsync(It.IsAny<EnrollCertificateRequest>(), It.IsAny<CancellationToken>()))
421+
.ReturnsAsync(new EnrollCertificateResponse { Id = MockCertificateData.DcvOrderId, Status = "pending" });
422+
423+
mock.Setup(c => c.TrackOrderAsync(MockCertificateData.DcvOrderId, It.IsAny<CancellationToken>()))
424+
.ReturnsAsync(new TrackOrderResponse
425+
{
426+
OrderDetails = new TrackOrderResponseDetails
427+
{
428+
OrderStatusId = terminalOrderStatusId,
429+
CertificateStatusId = "1",
430+
// Validated DCV state — without the OrderStatusId guard this would
431+
// erroneously trigger the issuance-wait path.
432+
DomainVerification = new TrackOrderDomainVerification
433+
{
434+
Status = Constants.Dcv.StatusValidated
435+
}
436+
}
437+
});
438+
439+
var validator = new FakeDomainValidator();
440+
// Issuance-wait budget > 0 so a wrong-path entry would manifest as a
441+
// GetCertificate call we DON'T expect.
442+
var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator),
443+
DcvConfig(dcvWaitForIssuanceSeconds: 10));
444+
445+
await Enroll(plugin);
446+
447+
mock.Verify(c => c.GetCertificateAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()),
448+
Times.Never,
449+
"Enroll must not enter WaitForIssuanceAfterDcvAsync when the order is " +
450+
"cancelled/rejected, even if DCV happens to be in a 'validated' state");
451+
validator.StagedRecords.Should().BeEmpty(
452+
"DCV staging must not run for a cancelled/rejected order");
396453
}
397454

398455
// ---------------------------------------------------------------------------
@@ -426,7 +483,10 @@ public async Task SyncDcvRetry_DoesSingleShotTrackOrder_WhenChallengeNotReady()
426483

427484
// GetSingleRecord calls GetCertificateAsync first to materialize the record;
428485
// the sync-DCV-retry kicks in afterwards. The pending response keeps the
429-
// retry path engaged so we exercise the override.
486+
// retry path engaged so we exercise the override. The assertion below pins
487+
// Times.Exactly(1) on TrackOrderAsync: with override=0, the polling loop
488+
// takes one TrackOrder call, sees domainVerification null, and bails — no
489+
// further polls inside the 60s budget the config nominally allows.
430490
mock.Setup(c => c.GetCertificateAsync(MockCertificateData.DcvOrderId, It.IsAny<CancellationToken>()))
431491
.ReturnsAsync(MockCertificateData.PendingCertRecord(MockCertificateData.DcvOrderId));
432492

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
// Copyright 2024 Keyfactor
2+
// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
3+
// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
4+
// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS,
5+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions
6+
// and limitations under the License.
7+
8+
using System;
9+
using System.Reflection;
10+
using FluentAssertions;
11+
using Org.BouncyCastle.Asn1.X509;
12+
using Org.BouncyCastle.Crypto;
13+
using Org.BouncyCastle.Crypto.Generators;
14+
using Org.BouncyCastle.Crypto.Operators;
15+
using Org.BouncyCastle.Math;
16+
using Org.BouncyCastle.Security;
17+
using Org.BouncyCastle.X509;
18+
using Xunit;
19+
20+
namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests
21+
{
22+
/// <summary>
23+
/// Regression tests for the private <c>CERTInextCAPlugin.ExtractSerialFromPem</c>
24+
/// helper, which feeds the audit-log SerialNumber field. After the BouncyCastle
25+
/// migration (replacing <c>X509Certificate2.SerialNumber</c>) we need to pin the
26+
/// format invariants — particularly the leading-zero-byte case where the old BCL
27+
/// behaviour and a naive <c>BigInteger.ToString(16)</c> diverge.
28+
/// </summary>
29+
public class ExtractSerialFromPemTests
30+
{
31+
private static string InvokeExtractSerialFromPem(string pem)
32+
{
33+
var method = typeof(CERTInextCAPlugin)
34+
.GetMethod("ExtractSerialFromPem", BindingFlags.NonPublic | BindingFlags.Static);
35+
method.Should().NotBeNull("test pins the format produced by ExtractSerialFromPem");
36+
return (string)method!.Invoke(null, new object[] { pem })!;
37+
}
38+
39+
/// <summary>
40+
/// Generates a self-signed PEM cert with the specified serial number. Uses
41+
/// BouncyCastle throughout — no BCL crypto — per the project's crypto policy.
42+
/// </summary>
43+
private static string GeneratePemWithSerial(BigInteger serial)
44+
{
45+
var keyGen = new RsaKeyPairGenerator();
46+
keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048));
47+
AsymmetricCipherKeyPair keyPair = keyGen.GenerateKeyPair();
48+
49+
var subject = new X509Name("CN=test-serial-parity");
50+
var notBefore = DateTime.UtcNow.AddMinutes(-1);
51+
var notAfter = notBefore.AddDays(1);
52+
53+
var builder = new X509V3CertificateGenerator();
54+
builder.SetSerialNumber(serial);
55+
builder.SetIssuerDN(subject);
56+
builder.SetSubjectDN(subject);
57+
builder.SetNotBefore(notBefore);
58+
builder.SetNotAfter(notAfter);
59+
builder.SetPublicKey(keyPair.Public);
60+
61+
var signerFactory = new Asn1SignatureFactory("SHA256withRSA", keyPair.Private);
62+
X509Certificate cert = builder.Generate(signerFactory);
63+
64+
return "-----BEGIN CERTIFICATE-----\n"
65+
+ Convert.ToBase64String(cert.GetEncoded(), Base64FormattingOptions.InsertLineBreaks)
66+
+ "\n-----END CERTIFICATE-----";
67+
}
68+
69+
[Fact]
70+
public void ExtractSerialFromPem_PreservesLeadingZeroByte()
71+
{
72+
// Serial bytes 0x00 0x0A 0xFF 0xFF as an unsigned big-endian integer = 720895
73+
// X509Certificate2.SerialNumber would produce "0AFFFF" (sign byte stripped,
74+
// remaining bytes hex-encoded, leading-zero NIBBLE preserved within byte boundary).
75+
// A naive BigInteger.ToString(16) would produce "afff" (a 4-digit hex, dropping
76+
// the leading zero nibble), which mis-correlates with Command's stored serial.
77+
//
78+
// Use a serial that has a leading-zero nibble in its first non-zero byte:
79+
// 0x0A123456 → unsigned hex "0A123456" (8 nibbles). Anything that drops the
80+
// leading zero produces "A123456" (7 nibbles).
81+
var serial = new BigInteger("0A123456", 16);
82+
string pem = GeneratePemWithSerial(serial);
83+
84+
string result = InvokeExtractSerialFromPem(pem);
85+
86+
result.Should().Be("0A123456",
87+
"the serial must preserve the leading-zero nibble within its first byte " +
88+
"so audit-log correlation against Command's stored serial succeeds");
89+
}
90+
91+
[Fact]
92+
public void ExtractSerialFromPem_NormalSerial_UppercaseHexNoLeadingZero()
93+
{
94+
// Plain mid-range serial; just confirms format is uppercase hex without separators.
95+
var serial = new BigInteger("DEADBEEFCAFE", 16);
96+
string pem = GeneratePemWithSerial(serial);
97+
98+
string result = InvokeExtractSerialFromPem(pem);
99+
100+
result.Should().Be("DEADBEEFCAFE");
101+
}
102+
103+
[Fact]
104+
public void ExtractSerialFromPem_LongSerial_AllBytesPreservedUppercase()
105+
{
106+
// 20-byte serial (the max CA/B Forum permits). Each byte must be uppercase
107+
// hex, no separators, no leading-zero loss.
108+
var serial = new BigInteger("01020304050607080910111213141516171819FA", 16);
109+
string pem = GeneratePemWithSerial(serial);
110+
111+
string result = InvokeExtractSerialFromPem(pem);
112+
113+
result.Should().Be("01020304050607080910111213141516171819FA");
114+
}
115+
116+
[Fact]
117+
public void ExtractSerialFromPem_GarbageInput_ReturnsParseError()
118+
{
119+
// Robustness — audit-log path must never throw, only mark the failure.
120+
InvokeExtractSerialFromPem("not a pem")
121+
.Should().Be("(parse-error)");
122+
}
123+
124+
[Fact]
125+
public void ExtractSerialFromPem_EmptyBody_ReturnsEmptyPem()
126+
{
127+
InvokeExtractSerialFromPem("-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----")
128+
.Should().Be("(empty-pem)");
129+
}
130+
}
131+
}

0 commit comments

Comments
 (0)