Return the right certificate from the reuse check #21

Merged
gsadmin merged 2 commits from dev into main 2026-07-30 23:59:02 +00:00
Owner

Follow-up to #20. Two independent defects in the reuse check, both of which handed back a certificate that did not satisfy the request that was made.

Wrong issuer

The reuse search filtered on common name and status only:

new InfisicalCertificateSearchQuery { ProjectId, CommonName, Status = "active", Limit = 50 }

So WEB01 issued by a server-authentication profile and WEB01 issued by a client-authentication profile are indistinguishable. Pointing the same script at a client-auth profile on a host already holding a server-auth certificate returned the existing one — same subject, wrong extended key usages, no indication anything was off:

VERBOSE: Reusing existing certificate (Thumbprint=B37BB7D7..., NotAfter=2026-10-28 19:36:49Z).

The search is now scoped by -CertificateProfileId or -CertificateAuthorityId. Both filters already existed on InfisicalCertificateSearchQuery and serialize as profileIds/caIds; the reuse path simply never set them. The subscriber path needs no filter, because a subscriber pins its own common name — a name match is already a subscriber match.

A second defect compounded this one. InfisicalLocalCertificateLookup.FindMatch applied its serial filter only when the candidate set was non-empty:

if (serialSet.Count > 0) { ...filter by serial... }

After scoping, switching profiles correctly returns zero candidates — and that emptiness disabled the filter entirely, degrading to a name-only local match. That is precisely the path that returns another issuer's certificate, so fixing the scope alone would have left the defect in place. A completed search that finds nothing is now a definite "nothing to reuse".

Missing subject alternative names

Extending -DnsName or -IpAddress and re-running returned the existing certificate, which lacked the name just added. A candidate must now carry every requested name, and the disqualifying name is reported:

VERBOSE: An existing certificate for CN=WEB01 does not carry the requested name DNS:api.contoso.com;
         requesting a new certificate rather than reusing one that would fail validation for it.

Reading SANs back off an installed certificate needed a decoder. netstandard2.0 has no X509SubjectAlternativeNameExtension, and X509Extension.Format returns localized text — on a non-English host every comparison would silently fail — so the extension is decoded from its DER bytes with BouncyCastle, already carried for CSR generation. No new dependency.

The rule is coverage rather than equality, since a certificate carrying more names than requested still satisfies the request. DNS compares case-insensitively and IP addresses are normalized through IPAddress, so ::1 and 0:0:0:0:0:0:0:1 are the same name. FindMatch picks the longest-lived covering candidate rather than picking the newest and then rejecting it, and keeps its original four-argument overload so existing callers are unaffected.

Offline behaviour

The lenient name-only fallback survives only for the case where Infisical cannot be reached — failing a renewal because the API is down is worse than a loose match — but it now announces itself as a warning rather than being silent.

Verification

298 tests pass, up from 287. The new SAN tests build certificates through the module's own CSR builder, so the reader is exercised against genuine DER rather than a hand-rolled approximation.

Unit tests alone would not prove the store path works, so the decision matrix was also run against a live CurrentUser\My store holding a certificate with SANPROBE, SANPROBE.contoso.com, 10.20.30.40:

exact same SAN set                      -> REUSE
subset of the SAN set                   -> REUSE
NEW dns name added  (the reported gap)  -> REISSUE (missing DNS:api.contoso.com)
NEW ip address added                    -> REISSUE (missing IP:10.20.30.41)
different casing                        -> REUSE
no SANs requested at all                -> REUSE

A further test asserts the issuer scope survives serialization to profileIds on the wire — a scope that never reaches the API would be worse than none.

Full build.ps1 -RunTests green, including module import, manifest, and help validation across 53 cmdlets.

Behaviour change worth knowing

Reuse is stricter in one direction only. Trimming a SAN list still reuses the existing certificate, because it covers everything requested; only extending forces reissue. Use -Force when the SAN set needs narrowing. Documented in the README and cmdlet help.

Co-Authored-By: Claude Opus 5 noreply@anthropic.com

Generated with Claude Code

Follow-up to #20. Two independent defects in the reuse check, both of which handed back a certificate that did not satisfy the request that was made. ## Wrong issuer The reuse search filtered on common name and status only: ```csharp new InfisicalCertificateSearchQuery { ProjectId, CommonName, Status = "active", Limit = 50 } ``` So `WEB01` issued by a server-authentication profile and `WEB01` issued by a client-authentication profile are indistinguishable. Pointing the same script at a client-auth profile on a host already holding a server-auth certificate returned the existing one — same subject, wrong extended key usages, no indication anything was off: ```text VERBOSE: Reusing existing certificate (Thumbprint=B37BB7D7..., NotAfter=2026-10-28 19:36:49Z). ``` The search is now scoped by `-CertificateProfileId` or `-CertificateAuthorityId`. Both filters already existed on `InfisicalCertificateSearchQuery` and serialize as `profileIds`/`caIds`; the reuse path simply never set them. The subscriber path needs no filter, because a subscriber pins its own common name — a name match is already a subscriber match. A second defect compounded this one. `InfisicalLocalCertificateLookup.FindMatch` applied its serial filter only when the candidate set was non-empty: ```csharp if (serialSet.Count > 0) { ...filter by serial... } ``` After scoping, switching profiles correctly returns *zero* candidates — and that emptiness disabled the filter entirely, degrading to a name-only local match. That is precisely the path that returns another issuer's certificate, so fixing the scope alone would have left the defect in place. A completed search that finds nothing is now a definite "nothing to reuse". ## Missing subject alternative names Extending `-DnsName` or `-IpAddress` and re-running returned the existing certificate, which lacked the name just added. A candidate must now carry every requested name, and the disqualifying name is reported: ```text VERBOSE: An existing certificate for CN=WEB01 does not carry the requested name DNS:api.contoso.com; requesting a new certificate rather than reusing one that would fail validation for it. ``` Reading SANs back off an installed certificate needed a decoder. netstandard2.0 has no `X509SubjectAlternativeNameExtension`, and `X509Extension.Format` returns localized text — on a non-English host every comparison would silently fail — so the extension is decoded from its DER bytes with BouncyCastle, already carried for CSR generation. No new dependency. The rule is coverage rather than equality, since a certificate carrying more names than requested still satisfies the request. DNS compares case-insensitively and IP addresses are normalized through `IPAddress`, so `::1` and `0:0:0:0:0:0:0:1` are the same name. `FindMatch` picks the longest-lived *covering* candidate rather than picking the newest and then rejecting it, and keeps its original four-argument overload so existing callers are unaffected. ## Offline behaviour The lenient name-only fallback survives only for the case where Infisical cannot be reached — failing a renewal because the API is down is worse than a loose match — but it now announces itself as a warning rather than being silent. ## Verification 298 tests pass, up from 287. The new SAN tests build certificates through the module's own CSR builder, so the reader is exercised against genuine DER rather than a hand-rolled approximation. Unit tests alone would not prove the store path works, so the decision matrix was also run against a live `CurrentUser\My` store holding a certificate with `SANPROBE`, `SANPROBE.contoso.com`, `10.20.30.40`: ```text exact same SAN set -> REUSE subset of the SAN set -> REUSE NEW dns name added (the reported gap) -> REISSUE (missing DNS:api.contoso.com) NEW ip address added -> REISSUE (missing IP:10.20.30.41) different casing -> REUSE no SANs requested at all -> REUSE ``` A further test asserts the issuer scope survives serialization to `profileIds` on the wire — a scope that never reaches the API would be worse than none. Full `build.ps1 -RunTests` green, including module import, manifest, and help validation across 53 cmdlets. ## Behaviour change worth knowing Reuse is stricter in one direction only. **Trimming** a SAN list still reuses the existing certificate, because it covers everything requested; only **extending** forces reissue. Use `-Force` when the SAN set needs narrowing. Documented in the README and cmdlet help. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Generated with [Claude Code](https://claude.com/claude-code)
gsadmin added 2 commits 2026-07-30 23:57:56 +00:00
Switching certificate profiles returned the existing certificate instead of
issuing a new one. The reuse check searched Infisical by common name and status
only, so a host already holding a server-authentication certificate for its own
name was handed that certificate back when asking a client-authentication
profile - same subject, wrong extended key usages.

The search is now scoped by -CertificateProfileId or -CertificateAuthorityId.
Both filters already existed on InfisicalCertificateSearchQuery and serialize as
profileIds/caIds; the reuse path simply never set them. The subscriber path
needs no filter because a subscriber pins its own common name, so matching the
name is already equivalent to matching the subscriber.

A second defect compounded it: InfisicalLocalCertificateLookup.FindMatch only
applies its serial filter when the candidate set is non-empty, so a search that
legitimately returned nothing degraded into a name-only local match - exactly
the case that hands back another issuer's certificate. A completed search that
finds nothing is now a definite "nothing to reuse".

The lenient fallback is kept for the case where Infisical cannot be reached,
since failing a renewal because the API is down is worse, but it now announces
itself as a warning rather than being silent.

Reuse still does not compare subject alternative names; that gap is documented
with -Force as the workaround rather than half-addressed here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Require reuse candidates to carry every requested subject alternative name
Publish to PowerShell Gallery / build (pull_request) Successful in 43s
Publish to PowerShell Gallery / release (pull_request) Successful in 12s
Publish to PowerShell Gallery / publish (pull_request) Successful in 9s
d5c9eec3fb
Extending -DnsName or -IpAddress and re-running returned the existing
certificate, which lacked the name that had just been added - the reuse check
compared only the common name. A candidate must now carry every requested name,
and the name that disqualified it is reported so the reissue is explainable.

Reading SANs back off an installed certificate needs a decoder: netstandard2.0
has no X509SubjectAlternativeNameExtension, and X509Extension.Format produces
localized text that cannot be compared. The extension is decoded from its DER
bytes with BouncyCastle, already carried for CSR generation.

The rule is coverage rather than equality, since a certificate carrying more
names than requested still satisfies the request. DNS names compare
case-insensitively and IP addresses are normalized through IPAddress, so ::1 and
0:0:0:0:0:0:0:1 are the same name. Trimming the SAN set therefore still reuses;
-Force covers that case.

FindMatch keeps its original four-argument overload so existing callers are
unaffected, and only reports a rejected name when no candidate qualified.

Verified against a live CurrentUser\My store with a certificate carrying
SANPROBE, SANPROBE.contoso.com and 10.20.30.40: identical and subset requests
reuse, a new DNS name or IP forces reissue naming the missing entry, differing
case reuses, and an empty request reuses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
gsadmin merged commit e8113ec073 into main 2026-07-30 23:59:02 +00:00
Sign in to join this conversation.