Skip to content

MacOS Certificate Generator Fix#5941

Open
afifi-ins wants to merge 34 commits into
dotnet:mainfrom
afifi-ins:macos-cert-issue
Open

MacOS Certificate Generator Fix#5941
afifi-ins wants to merge 34 commits into
dotnet:mainfrom
afifi-ins:macos-cert-issue

Conversation

@afifi-ins
Copy link
Copy Markdown
Contributor

No description provided.

afifi-ins and others added 30 commits May 6, 2026 19:13
Remove the custom SafeKeychainHandle-based keychain approach that was
causing 'The X509 certificate store has not been opened' errors on macOS.

Changes:
- CertificateHelper.GetX509Store: macOS now uses standard X509Store with
  StoreLocation.CurrentUser (same as Linux) instead of custom keychain
- Remove GetMacOSX509Store, OSXCustomKeychainFilePath,
  OSXCustomKeychainPassword, and EnsureStoreIsOpened
- Remove !IsMacOS() guards that skipped store.Open(ReadWrite) in
  CertificateManager and CertificateGeneratorLibrary
- Remove SafeKeychainHandle.cs linked file from csproj

Co-authored-by: Copilot <[email protected]>
On macOS, the Root and TrustedPeople certificate stores use Apple's
trust infrastructure and cannot be opened with ReadWrite via the .NET
X509Store API. Instead of failing and catching exceptions, handle this
upfront by checking the platform and store type before attempting
operations.

Changes:
- CertificateHelper: Add AddTrustedCertOnMacOS/RemoveTrustedCertOnMacOS
  helpers that use the macOS 'security' CLI to manage certificate trust
- CertificateManager.AddToStoreIfNeeded: Route macOS Root/TrustedPeople
  operations directly to the security CLI without attempting X509Store
- CertificateGeneratorLibrary.RemoveCertificatesFromStore: Check macOS
  and store type upfront, use security CLI for read-only stores

Co-authored-by: Copilot <[email protected]>
The macOS login keychain requires user interaction ('User interaction
is not allowed') when adding certificates via the .NET X509Store API,
which fails in CI/headless environments.

Replace all X509Store operations on macOS with a custom unlocked
keychain managed via the 'security' CLI:

- CertificateHelper: Create/manage a dedicated 'wcf-test.keychain-db'
  that is unlocked and added to the search list. All cert imports and
  trust operations target this keychain.
- CertificateManager.AddToStoreIfNeeded: On macOS, route My store
  operations to ImportCertToMacOSKeychain (security import) and
  Root/TrustedPeople to AddTrustedCertOnMacOS (security add-trusted-cert)
- CertificateGeneratorLibrary: On macOS, cleanup deletes the entire
  custom keychain instead of iterating per-store

Co-authored-by: Copilot <[email protected]>
BouncyCastle's default Pkcs12StoreBuilder uses RC2-40-CBC for cert
encryption, which is not supported by macOS's Security framework,
causing 'Import/Export format unsupported' when loading PFX bytes
into X509Certificate2.

Configure the PKCS12 builder to use 3DES (PbeWithShaAnd3KeyTripleDesCbc)
for both key and cert encryption, which is supported across all platforms.

Co-authored-by: Copilot <[email protected]>
…mport

On macOS, the .NET X509Certificate2 constructor cannot load BouncyCastle-generated
PKCS12 due to format incompatibilities with the Apple Security framework
(Import/Export format unsupported error).

Instead of loading PFX into X509Certificate2 on macOS:
- Create X509Certificate2 from DER-encoded public cert only (for metadata)
- Pass raw PFX bytes directly to the security CLI import command
- Updated AddToStoreIfNeeded and InstallCertificateToMyStore to accept
  optional PFX bytes for macOS keychain import
- Changed ImportCertToMacOSKeychain to accept byte[] instead of X509Certificate2

Co-authored-by: Copilot <[email protected]>
…certs

On macOS, new X509Certificate2(cert.GetEncoded()) also fails with
'Unknown format in import' for non-authority certs (machine/user certs
with SANs and other extensions). The Apple Security framework cannot
parse the DER encoding from BouncyCastle for these certs.

Fix: On macOS for non-authority certs, skip X509Certificate2 creation
entirely. Compute thumbprint (SHA1 of DER) directly from BouncyCastle
cert. Container.Certificate is null on macOS — all store operations
already use PFX bytes (My store) or DER bytes (Root/TrustedPeople)
via the security CLI.

Changes:
- CertificateGenerator: compute thumbprint from SHA1 on macOS, null cert
- CertificateHelper: added AddTrustedCertOnMacOS(byte[]) overload
- CertificateManager: added certDerBytes parameter for trust stores,
  handle null certificate gracefully on macOS

Co-authored-by: Copilot <[email protected]>
certificate is null on macOS (we skip X509Certificate2 creation).
Use null-conditional operator for Thumbprint access.

Co-authored-by: Copilot <[email protected]>
…ve cert

After importing PFX to the custom keychain via CLI, the cert needs to be
findable by .NET's X509Store API so Kestrel can use it for HTTPS and
TestHost.CertificateFromFriendlyName can locate it.

Changes:
- Set custom keychain as default keychain (security default-keychain -s)
  so X509Store(My, CurrentUser) searches it
- After importing host cert to keychain, retrieve it via X509Store by
  thumbprint to populate s_localCertificate on macOS
- Added FindCertificateInStore helper method

Co-authored-by: Copilot <[email protected]>
The root cause of all macOS cert issues is that BouncyCastle's PKCS12
and X.509 DER encodings are incompatible with Apple's Security framework.
Both the .NET X509Certificate2 constructor and the 'security import' CLI
use the same Apple APIs which reject BouncyCastle's output.

Fix: On macOS, after BouncyCastle generates the cert and key, export them
as PEM and use openssl (available on macOS) to create a compatible PFX.
The openssl-generated PFX loads correctly with X509Certificate2, and
imports correctly into the macOS keychain.

This means container.Certificate is now a real X509Certificate2 with
private key on macOS, so all downstream code (ServiceCredentials,
Kestrel HTTPS, CertificateFromFriendlyName lookups) works correctly.

Co-authored-by: Copilot <[email protected]>
The macOS issue stemmed from Apple's Security framework rejecting
BouncyCastle's X.509 cert DER encoding (LoadX509Der fails with
'Import/Export format unsupported'). Workarounds via custom keychain,
3DES PKCS12, and openssl repackaging all preserve the offending DER
bytes and fail.

This commit replaces BouncyCastle entirely with .NET's built-in X.509
APIs which produce platform-native, Apple-compatible DER encodings:

- CertificateRequest + CreateSelfSigned/Create for cert generation
- Built-in extensions: BasicConstraints, KeyUsage, SKI, EKU, SAN
- AuthorityKeyIdentifier and CRL Distribution Points built via
  System.Formats.Asn1.AsnWriter (no built-in extension class for
  AKI prior to .NET 7; CRL DPs has no built-in)
- UPN OtherName SAN built via AsnWriter
- CRL generated via AsnWriter and signed with RSA.SignData
- PKCS12 export via cert.Export(Pkcs12, password)

CertificateCreationSettings.EKU changes from List<KeyPurposeID> to
List<string> (OID strings).

Library and EXE retargeted to net10.0 (CertificateRequest is netstandard2.1+
and CopyWithPrivateKey is .NET Core+; SelfHostedCoreWcfService consumer
is already net10.0).

CertificateManager simplified: drop pfxBytes/certDerBytes plumbing — on
macOS, export PFX from cert at point of use (now possible since cert is
Apple-compatible).

Custom macOS keychain infrastructure (CertificateHelper) is retained
because Root/TrustedPeople stores still cannot be modified via X509Store
on macOS, and login keychain may be locked in CI.

Co-authored-by: Copilot <[email protected]>
…ation

Two issues caught by Helix CI on the BouncyCastle removal:

1. macOS: 'The specified keychain could not be found' from
   X509CertificateLoader.LoadPkcs12 -> AppleCertificatePal.MoveToKeychain.
   The PFX round-trip after CreateCertificate is unnecessary now that we
   build the cert in memory; on macOS, LoadPkcs12 needs a keychain handle.
   Use the in-memory CopyWithPrivateKey result directly.

2. Linux/all: 'notBefore is earlier than issuerCertificate.NotBefore' from
   CertificateRequest.Create. BouncyCastle didn't validate child window vs
   issuer window. The expired-cert test case sets NotBefore = UtcNow-4d, but
   the authority used UtcNow-1h. Widen the authority validity to +/-10 years
   so all child certs (including intentionally expired ones) fit. Also clamp
   child windows defensively if a caller supplies dates outside issuer range.

Co-authored-by: Copilot <[email protected]>
The previous iteration removed the PFX round-trip universally to fix
macOS, but this broke Windows and Linux: on those platforms, the
ephemeral key produced by CopyWithPrivateKey isn't persisted into a key
container when the cert is added to an X509Store. Later lookups by
thumbprint return a cert without a usable private key, surfacing as:

- Windows: ArgumentNullException 'serverCertificate' in Kestrel UseHttps
- macOS (now passing the same path): The service certificate is not
  provided in CoreWCF ServiceCredentials

Make the round-trip platform-conditional:
- macOS: use the in-memory CopyWithPrivateKey result directly
  (LoadPkcs12 fails there with 'keychain could not be found')
- Windows/Linux: round-trip through X509CertificateLoader.LoadPkcs12
  with PersistKeySet so the private key is persisted

Also tightened disposal: on Windows/Linux the round-tripped outputCert is
a separate instance, so the in-memory certWithKey must be disposed.

Co-authored-by: Copilot <[email protected]>
…on Windows

Comparing with the original BouncyCastle code (commit 02ddc7b) revealed two
regressions in the .NET CertificateRequest port:

1. PFX round-trip on Windows/Linux was missing the MachineKeySet storage flag.
   The original used MachineKeySet | Exportable | PersistKeySet. Without
   MachineKeySet the private key lands in the user's CNG container while the
   cert is added to LocalMachine\My, so subsequent X509Store lookups return a
   cert with HasPrivateKey=false and Kestrel.UseHttps throws ArgumentNullException.

2. The original BouncyCastle Pkcs12Store.SetKeyEntry set the bag alias to the
   friendly name, which Windows surfaces as cert.FriendlyName when the PFX is
   loaded. .NET's cert.Export(Pkcs12) does not set an alias, so explicitly set
   outputCert.FriendlyName on Windows after loading. This restores
   TestHost.CertificateFromFriendlyName lookups.

Added diagnostic Console.WriteLine in CertificateHelper.ImportCertToMacOSKeychain,
ImportPublicCertToMacOSKeychain, and TestHost.CertificateFromFriendlyName so the
next macOS CI run shows what's installed and what the lookup is matching against.

Co-authored-by: Copilot <[email protected]>
The setter is wrapped in IsWindows() but the analyzer can't see through the
helper method. Repo treats CA1416 as error in CI.

Co-authored-by: Copilot <[email protected]>
…okups converge

The TrustedPeople lookup on macOS was returning 0 candidates even though certs
had been imported into the custom keychain. macOS does not have proper per-store
separation like Windows: .NET's X509Store(TrustedPeople|Root, CurrentUser) does
not enumerate certs imported via the 'security' CLI into the user's default
keychain.

Route all storeName values through StoreName.My on macOS so:
 - CertificateManager imports targeting My/Root/TrustedPeople all land in the
   custom keychain (already the case for My; now also for Root/TrustedPeople
   via ImportPublicCertToMacOSKeychain).
 - TestHost.CertificateFromFriendlyName looking up in TrustedPeople finds the
   imported certs in the same keychain.

For Root specifically, also call AddTrustedCertOnMacOS so chain validation
still works via OS-level trust settings.

Co-authored-by: Copilot <[email protected]>
The TrustedPeople branch was importing the cert as public-only, but the peer
cert used by CoreWCF for service credentials needs a private key for key
exchange. CoreWCF.SecurityUtils.EnsureCertificateCanDoKeyExchange threw:
  'It is likely that certificate ... may not have a private key that is
   capable of key exchange or the process may not have access rights for
   the private key'

Now always import the PFX (with key) when certificate.HasPrivateKey is true,
regardless of target store. Still call AddTrustedCertOnMacOS for Root certs
so OS-level chain validation works.

Co-authored-by: Copilot <[email protected]>
On macOS the self-signed root cert in our custom test keychain is not seen as
trusted by .NET's chain builder, so Find(..., validOnly:true) filters it out
and /TestHost.svc/RootCert returns 500. The companion CertificateFromFriendlyName
already uses validOnly:false; align the two helpers.

Co-authored-by: Copilot <[email protected]>
… on macOS (issue dotnet#2870)

These two tests trigger HTTPS handshakes that require the test root cert to be
trusted by the OS chain validator. On macOS, .NET defers SSL chain validation
to the OS keychain trust store, which is not populated by the in-process .NET
AddToStore calls (and the InstallRootCertificate.sh sudo path is not run by
Helix). All sibling tests in these files already carry [Issue(2870, OS=OSX)];
add the same attribute to these two so they skip on macOS instead of failing
with the unavoidable PartialChain error.

Co-authored-by: Copilot <[email protected]>
- Remove unused FindCertificateInStore helper from CertificateManager.
- Remove unused RemoveTrustedCertOnMacOS and RemoveCertsFromMacOSKeychain
  helpers from CertificateHelper (DeleteMacOSKeychain covers cleanup).
- Drop the now-resolved diagnostic Console.WriteLine blocks in
  TestHost.CertificateFromFriendlyName and CertificateHelper imports.
  Trace.WriteLine equivalents remain for log capture.

Cert configuration verified against the original BouncyCastle generator:
KeyUsage, BasicConstraints, EKU, SAN, CRL, PKCS12 load flags, and
FriendlyName are equivalent across platforms.

Co-authored-by: Copilot <[email protected]>
The CertificateGenerator project was retargeted from net471 to net10.0 as
part of the cert-generator port to native .NET APIs, so its build output
now lands at artifacts\bin\CertificateGenerator\Release\net10.0. Update
all .cmd scripts that invoke CertificateGenerator.exe accordingly:

- CleanUpWCFSelfHostedSvc.cmd (existence check + -Uninstall calls)
- RefreshServerCertificates.cmd
- SetupWcfIISHostedService.cmd
- StartWCFSelfHostedSvcDoWork.cmd

Co-authored-by: Copilot <[email protected]>
Replaces the loose 'private const string Oid...' constants with named
static readonly Oid instances that carry a friendly name, which renders
helpfully in certificate viewers and improves call-site readability:

  ekuOids.Add(ServerAuthEkuOid);
  new X509Extension(CrlDistributionPointsExtensionOid, ..., critical: false);
  WriteExtension(w, CrlNumberExtensionOid, ..., value);

WriteExtension now takes an Oid; AsnWriter.WriteObjectIdentifier still
requires a string and is fed via Oid.Value. Drops the unused
OidExtAuthorityInfoAccess constant.

Co-authored-by: Copilot <[email protected]>
…veNameBuilder.AddUserPrincipalName

Replaces hand-rolled AKI and UPN-SAN extension construction with .NET
built-in helpers (X509AuthorityKeyIdentifierExtension.CreateFromSubjectKeyIdentifier
from .NET 7+, SubjectAlternativeNameBuilder.AddUserPrincipalName from
.NET 9+). The CRL path also uses the AKI helper's RawData to obtain
the encoded extnValue, eliminating the local BuildAuthorityKeyIdentifierValue
helper. Drops the now-unused SAN and UPN OID constants.

Co-authored-by: Copilot <[email protected]>
Replaces hand-rolled TBSCertList ASN.1 encoding with the built-in
CertificateRevocationListBuilder (.NET 9+), which handles signing,
CRL Number, AKI, time encoding (UTCTime/GeneralizedTime), and DER
layout. Drops ~140 lines of helpers (BuildTbsCertList, BuildCrlNumberValue,
WriteExtension, WriteSha256RsaAlgorithmIdentifier, WriteX509Time) and
the AKI/CrlNumber/Sha256-RSA OID constants that were only used by the
hand-rolled CRL path.

Co-authored-by: Copilot <[email protected]>
- ComputeSerialNumber: use BigInteger ctor + ToByteArray with isUnsigned/isBigEndian instead of manual sign/reverse logic
- SerialToHex: use Convert.ToHexString
- HexToBytes: use Convert.FromHexString (drop unused whitespace/hyphen stripping)
- Remove unused 'using System.IO'

Co-authored-by: Copilot <[email protected]>
The fedora-41 Helix image (mcr.microsoft.com/dotnet-buildtools/prereqs:fedora-41-helix)
does not ship the 'which' utility, causing every lookup in the script
to fail and the root CA install to bail with 'Could not find update-ca-trust'.
Replace the three 'which' invocations with the POSIX-portable 'command -v',
which is a shell builtin and always available.

Co-authored-by: Copilot <[email protected]>
CertificateRevocationListBuilder.AddEntry(byte[]) writes the supplied
bytes verbatim as the INTEGER content of the revocation entry; it only
rejects redundant 0x00/0xFF padding. ComputeSerialNumber returns the
minimal unsigned big-endian encoding (no sign byte), which means that
when the high bit of the first byte is set the CRL ends up with an
INTEGER that DER interprets as negative, while CertificateRequest.Create
encodes the certificate's serial with a leading 0x00 sign byte and ends
up positive. The two encodings never match, so the OS revocation check
never fires (TCP_ServiceCertRevoked_Throw_SecurityNegotiationException
failed because the chain reported the cert as valid).

Prepend a 0x00 sign byte when the high bit is set so the CRL serial
INTEGER matches the certificate's serial INTEGER for all values.

Co-authored-by: Copilot <[email protected]>
Add MacOSKeychain helper that wraps the 'security' CLI: create + unlock a dedicated custom keychain, make it the default user keychain, import certs with -A, and apply 'add-trusted-cert -r trustRoot -p ssl' so the WCF test root CA validates as fully trusted (was landing in the user keychain without an SSL policy, i.e. partial trust, which broke TLS handshakes on macOS).

CertificateManager.AddToStoreIfNeeded routes through the helper on macOS before falling through to X509Store.Add, so managed thumbprint lookups keep working.

InstallRootCertificate.sh: macOS branch now uses a user-domain custom keychain (no sudo) with the same trust policy.

Centralize OIDs in a new Oids.cs constants file for CertificateGenerator.

Remove all 25 [Issue(2870, OS = OSID.OSX)] skip attributes across HTTPS/TCP/UDS/WSFederation/WSHttp/WS2007Http/WSNetTcp/BasicHttp(s) tests.

Co-authored-by: Copilot <[email protected]>
The previous attempt over-reached: it created a user-domain custom keychain and called add-trusted-cert from C#. Helix runs InstallRootCertificate.sh under 'sudo -E -n', so the user-domain keychain dance fails ('UID=0 does not own /Users/helix-runner'), and add-trusted-cert to a user keychain always requires a GUI TCC prompt, even as root ('SecTrustSettingsSetTrustSettings: authorization denied').

Real root cause is simpler: the original 'security add-trusted-cert -d -r trustRoot -k System.keychain' call omitted '-p ssl', so the root CA landed without an SSL trust policy and macOS reported the chain as partial trust, breaking TLS.

Revert the keychain-juggling. Just add '-p ssl' to the existing System.keychain install. Drop MacOSKeychain.cs and the matching CertificateManager hook (they could not work non-interactively for the test user).

Co-authored-by: Copilot <[email protected]>
The Helix pre-command invoked InstallRootCertificate.sh with
`--service-host $(ServiceHost)` but the ServiceHost MSBuild property was
never defined, so the call expanded to `--service-host --cert-file ...`.
The install script then parsed `--cert-file` as the service host and curl
failed with `Could not resolve host: --cert-file`, meaning the root CA
was never downloaded or trusted on the Helix macOS machine. Every TLS test
then failed with PartialChain (dotnet#2870).

Default ServiceHost to localhost so the script can fetch and install the
root CA, completing the partial-trust fix together with the existing
`-p ssl` change in InstallRootCertificate.sh.

Co-authored-by: Copilot <[email protected]>
afifi-ins and others added 4 commits May 20, 2026 05:00
The previous commit added an XML comment containing `--` which is
illegal inside XML comments. MSBuild failed to load the project on all
platforms with MSB4025. Reword the comment to avoid `--` sequences;
no functional change.

Co-authored-by: Copilot <[email protected]>
Roll back the ServiceHost=localhost default from b7b29cc/9769ee02 (the
InstallRootCertificate.sh invocation is still needed for the Linux+CoreWCF
leg as-is) and instead address the second macOS partial-trust symptom:
client certificate installation fails with errSecNoSuchKeychain on Helix
because the non-interactive 'helix-runner' user has no login keychain.

Add a macOS-only HelixPreCommands block that creates, unlocks, and
registers ~/Library/Keychains/login.keychain-db before any tests run so
that X509Store(My, CurrentUser) can be opened, completing the dotnet#2870
macOS cert install fix together with the `-p ssl` change in
InstallRootCertificate.sh.

Co-authored-by: Copilot <[email protected]>
The previous attempt failed because Helix macOS workers already have a
login.keychain-db with an unknown password set by the infrastructure;
`create-keychain` was a no-op, then `set-keychain-settings` /
`unlock-keychain` failed with `user interaction is not allowed` and
`passphrase ... is not correct`, leaving the keychain locked and
X509Store(My, CurrentUser) still failing on the un-skipped dotnet#2870 tests.

Delete any pre-existing login keychain first, then create a fresh one
with an empty password we control.

Co-authored-by: Copilot <[email protected]>
…sudo

Move AddTrustedCertOnMacOS from a custom user keychain (which macOS's TLS chain
evaluator doesn't reliably honor) to /Library/Keychains/System.keychain in the
admin trust domain via 'sudo -n security add-trusted-cert -d -r trustRoot -p
ssl'. Helix macOS workers have passwordless sudo, so this stays non-interactive.

Surface security CLI failures to stderr so they're visible in Helix console
logs.

Co-authored-by: Copilot <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant