Skip to content

chore: replace checked-in certificates with single-purpose generated ones - #4882

Merged
richard-salac merged 20 commits into
v3.x.xfrom
reboot/chore/keystore_replacement_alt
Aug 13, 2026
Merged

chore: replace checked-in certificates with single-purpose generated ones#4882
richard-salac merged 20 commits into
v3.x.xfrom
reboot/chore/keystore_replacement_alt

Conversation

@richard-salac

@richard-salac richard-salac commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Summary

Replaces every checked-in certificate and keystore with single-purpose ones generated on demand. No private key material remains in the repository.

The repository previously carried 37 certificate artifacts signed by 6 certificate authorities. Three of those authorities shared an identical subject DN, and the CA private key that shipped in the repository belonged to a different authority than the one that had signed the default service certificate — so nothing could actually be re-issued.

Now there are 12 certificates from 3 authorities, all produced by keystore/generate-certificates.sh.

Layout

Directories group artifacts by purpose rather than by deployment profile, since one service certificate now covers every hostname API ML is reached by in any profile:

Directory Holds
ca/ service-ca, client-ca, untrusted-ca
service/ the service identity, the serverAuth-only and clientAuth-only split-role identities, the default truststore
client/ APIMTST, USER, UNKNOWNUSER
negative/ untrusted authority, hostname mismatch, self-signed — one per failure mode
public_ca/ real third-party roots, the only certificate material that is not generated

Each certificate's DN, key usage and SANs live in one committed openssl .ext file beside the keystore it produces: a CSR per certificate, signed with openssl x509 -req, packaged into PKCS12, truststores assembled with keytool.

Nothing with a private key is tracked

Only two pieces of certificate material remain in git, both holding public entries only and neither derived from anything generated here:

  • keystore/public_ca/public-roots.p12 — real third-party roots. The generator merges these into service/service.truststore.p12, because API ML validates live HTTPS endpoints with its own truststore rather than the JVM default one: the OIDC support fetches a JWKS over TLS through HttpConfig.getSecureSslContextWithoutKeystore() (see HttpsJwksProvider). public-roots.p12 stays the maintained source, since those certificates expire on their own schedule.
  • zaas-client/src/test/resources/mockserver-ca.cer — MockServer's own authority, needed by one test that starts MockServer over HTTPS. It was previously an invisible entry inside a committed binary truststore.

Public certificates and truststores derived from our own keys are generated too, not committed: regenerating mints fresh CA keypairs, so committed anchors would be stale on arrival in a fresh clone and would not match its own keys.

How generation is wired

Locally, Gradle runs the generator before every test task and before the jib image builds, so a clone-and-test needs no manual step. The task is output-driven, so it runs once and then stays out of the way.

In CI, the set is generated once per workflow run and shared. This is required rather than cosmetic: every run mints a fresh certificate authority, and everything taking part in a mutual TLS handshake has to trust the same one. A new GenerateCertificates job produces the set and uploads it; the 32 other jobs restore it through .github/actions/restore-certificates. Without this, the container images carry one authority while the test runner presents certificates from another — same subject DN, so path building succeeds and validation then fails with PKIX path validation failed: signature check failed.

A restored set also sets APIML_CERTIFICATES_PREGENERATED, which the Gradle task honours: a fresh checkout has no task history, so it would otherwise regenerate and replace what was just restored.

What was merged away

  • localhost, localhost-multi and docker/all-services differed only in their SAN list → one service certificate holding the union
  • six truststores → one, holding the two project authorities plus the public roots; three unreferenced CA Inc anchors dropped
  • the zaas-client keystore pair were stale duplicates on a different authority, carrying a dead jwtsecret entry

Removals that replaced nothing, because nothing referenced them: selfsigned/localhost-untrusted.*, local_ca/zowe-dev-ca.cer (byte-identical to localhost/localca.cer), Zowe_Service_..._Certificate_Authority_.cer (duplicate of an already-exported leaf), and zss-sample-service/cert.der — a real Broadcom-internal certificate for usilca32.lvn.broadcom.net, DigiCert-issued, expired 2021-09-25.

Defects fixed

  • the committed CA key did not match the authority that signed the default service certificate, so nothing it had signed could be re-issued
  • three authorities shared one subject DN
  • 127.0.0.1 was a DNS SAN rather than an IP SAN, which no TLS stack matches against the literal address
  • all-services.ext set CN twice where it meant C, so those certificates had no country
  • server-only and client-cert subject DNs differed only by letter case
  • config/local/otel/config.yaml referenced filenames that never existed in this repository

Environment support

Generating at build time rather than committing means several things a git checkout used to provide for free now have to be arranged explicitly:

  • openssl is installed by .github/actions/setup, because the jobs that run in a bare ubuntu:latest container do not have it. The generator also checks for openssl and keytool up front and names whichever is missing.
  • File permissions — openssl creates private keys owner-only, but a checkout produced 0644. The OpenTelemetry collector bind-mounts keystore/ and runs unprivileged, so every generated artifact is relaxed to 0644.
  • Windows — the generator runs from both Git Bash and PowerShell. .gitattributes pins the script to LF, since bash cannot execute a CRLF script; Gradle locates the bash that ships with Git for Windows rather than the WSL launcher in System32; and the script avoids find, whose name collides with an unrelated System32 tool when the Windows PATH is inherited.
  • License check — certificate and key extensions are excluded, since a base64 key blob cannot carry a comment header.

Verification

The generator self-checks every chain before finishing: that each authority's keystore holds the certificate published beside it, that each leaf validates against its issuer, and that the untrusted leaf does not validate against the service CA.

Review notes

The first commit removes the checked-in certificates and does not build on its own; everything after it adds the generator, the .ext configuration, the Gradle and CI wiring, and repoints the paths across configs, tests and docs.

Most of the diff is mechanical path repointing. The parts worth real attention are keystore/generate-certificates.sh, gradle/certificates.gradle, .github/actions/restore-certificates/, and the GenerateCertificates job.

TODO

🤖 Generated with Claude Code

@github-actions github-actions Bot added the Sensitive Sensitive change that requires peer review label Aug 10, 2026
Removes the 37 certificate artifacts that were committed to the repository,
together with the two generation configurations they were produced from
(keystore/docker/generate_cert.sh and keystore/client_cert/openssl.conf).

Several of these were not replaced by anything, because nothing used them:

- keystore/selfsigned/localhost-untrusted.* had no references in the repository
- keystore/local_ca/zowe-dev-ca.cer was byte-identical to
  keystore/localhost/localca.cer
- keystore/localhost/Zowe_Service_..._Certificate_Authority_.cer duplicated the
  leaf certificate already exported as localhost.keystore.cer
- keystore/selfsigned/localhost.truststore.p12 was the sole carrier of a
  certificate authority that signed nothing
- zss-sample-service/cert.der was a real Broadcom-internal certificate for
  usilca32.lvn.broadcom.net, issued by DigiCert and expired on 2021-09-25

Also untracks the module-local copies that are generated from now on:
common-service-core jwt-public-key.pub and the zaas-client keystore pair.

This commit on its own leaves the build unable to resolve its certificates.
The replacement follows in the next commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@richard-salac
richard-salac force-pushed the reboot/chore/keystore_replacement_alt branch 3 times, most recently from dbdfaeb to 382fe10 Compare August 10, 2026 11:31
Signed-off-by: Richard Salac <richard.salac@broadcom.com>
@richard-salac
richard-salac force-pushed the reboot/chore/keystore_replacement_alt branch from b4ff4ed to 77b46c9 Compare August 10, 2026 13:17
richard-salac and others added 11 commits August 10, 2026 15:40
Running the build from Git Bash or PowerShell on Windows failed with:

    Task :generateCertificates
    WSL ERROR: CreateProcessCommon:800: execvpe(/bin/bash) failed:
        No such file or directory

The task invoked 'bash', and on Windows a bare bash on the PATH is normally
C:\Windows\System32\bash.exe - the WSL launcher, not a shell. With no WSL
distribution installed it cannot run anything. 'sh' is no better, because it is
usually absent from the PATH outside Git Bash.

Gradle now locates the bash that ships with Git for Windows, searching the PATH and
the usual install locations while skipping anything under System32 or Sysnative, and
fails with an actionable message if none is found. -Papiml.bash= or APIML_BASH
override the choice on any platform. Other platforms continue to use sh, which the
script is written for.

Verified on this platform: the default resolves to sh, an override is honoured, and
an override pointing at a missing shell fails the build rather than passing silently.
The Windows branch itself is unverified - it needs a run from Git Bash and from
PowerShell.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Richard Salac <richard.salac@broadcom.com>
Generation failed after "Relaxing permissions for use inside containers" when the
build was started from PowerShell, reporting that no *.p12 file was found. From Git
Bash the same task succeeded.

The permissions step used find(1). When Gradle launches the script from PowerShell,
the shell inherits the Windows PATH, which has C:\Windows\System32 ahead of Git's
usr/bin - and System32 contains an unrelated find.exe that searches files for a
string. It rejects the findutils arguments with "File not found". Git Bash works
because its own bin directories come first there.

Replaced with shell globbing, so the step relies only on chmod. find was the only
command in the script whose name collides with a different Windows tool; cat, chmod,
cp, grep, keytool, mkdir, mktemp, openssl and rm all exist solely in Git's usr/bin
and resolve correctly.

Still POSIX: passes sh -n and bash --posix -n, and every generated artifact still
ends up 0644.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
path: . relied on the reader knowing that download-artifact unpacks relative to the
workspace. Uses ${{ github.workspace }} instead, which says so.

Also records the coupling between the two halves, which was invisible from either
file: the upload lists paths across several top level directories, so the artifact is
rooted at the repository root and keeps its entries repository-relative, and the
restore depends on that when it unpacks. Narrowing the upload to a single directory
would move the artifact root and restore the files to the wrong place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t_alt' into reboot/chore/keystore_replacement_alt
Signed-off-by: Richard Salac <richard.salac@broadcom.com>
Signed-off-by: Richard Salac <richard.salac@broadcom.com>
…t_alt' into reboot/chore/keystore_replacement_alt
richard-salac and others added 4 commits August 11, 2026 16:31
Adds a section covering the two cases a developer onboarding their own service
against a local API ML runs into.

The first is that no new certificate is usually needed: the service certificate
already covers localhost, the multi-instance names, the loopback address and every
container hostname, with both clientAuth and serverAuth, so pointing at the existing
pair is enough.

The second is issuing one from the same authority when the hostname is not covered or
a distinct identity is wanted. Declares the subject and the names inline rather than
adding another .ext file, and notes the two things that are easy to get wrong:
-copy_extensions copy, without which the names are dropped and API ML rejects the
certificate on hostname verification, and 127.0.0.1 needing to be an IP entry rather
than a DNS one.

Every command was run as written to confirm the result chains to the service CA and
keeps its extensions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Refers throughout to "the onboarding service" rather than "your service", so it is
clear the subject is a service being onboarded to API ML rather than API ML itself,
and states the first case as a fact about the existing certificate - it can be reused
when the onboarding service runs on localhost - rather than as advice. Second person
is gone from the section.

Commands are unchanged and were run again as written.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three certificates carried OU=MSD and O=Broadcom in their subjects, and the README
example and one integration test assertion repeated them. They now use the values the
rest of the set already uses, OU=API Mediation Layer and O=Zowe Sample, so no
certificate in the repository names a vendor:

  server-only         CN=Zowe Component,        OU=API Mediation Layer, O=Zowe Sample
  client-cert         CN=Zowe Component Client, OU=API Mediation Layer, O=OMP
  hostname-mismatch   CN=nonlocalhost.local,    OU=API Mediation Layer, O=Zowe Sample

X509SchemeTest asserts on the client certificate's distinguished name and is updated
with it. Verified that the regenerated certificate still matches the assertion in the
RFC 2253 form the test compares against.

Remaining occurrences elsewhere in the repository are unrelated to certificates -
maintainer addresses, support URLs, documentation links and contributor guidance - and
are left alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Richard Salac <richard.salac@broadcom.com>
Comment thread zaas-client/src/test/resources/mockserver-ca.cer
Comment thread keystore/generate-certificates.sh Outdated
Comment thread keystore/generate-certificates.sh Outdated
Comment thread keystore/README.md Outdated
Comment thread .github/actions/setup/action.yml
Comment thread keystore/README.md
…cally

Validity drops from five years for certificates and ten for authorities to 90 days
for both. These are development certificates regenerated on demand rather than
committed, so a long validity buys nothing, and an authority outliving the
certificates it signs serves no purpose when both are minted in the same run.

That alone would trade one failure for another: the generation task is driven by its
outputs, so a working copy left alone for three months would keep a complete set that
no longer validates, and report itself up to date. The task now also considers how
long the certificates remain valid, and Gradle reissues them like any other stale
output:

    > Task :generateCertificates
    Certificates expire on Mon Nov 09 16:14:01 CET 2026 - regenerating

Renewal starts shortly before expiry rather than after it, so a certificate cannot
lapse midway through a long test run or under a running local instance. The margin
defaults to seven days and -Papiml.keystore.renewBeforeDays=0 renews only once they
have expired. The check reads the public certificates, so it needs no passwords.

A set restored from a CI artifact is never renewed, because every job in a workflow
run has to keep trusting the same authority.

Verified: a fresh set is valid for 90 days and stays up to date on the next
invocation; a set inside the renewal window is reissued; a planted expired
certificate is reissued; and a restored set is left alone even with a renewal window
wider than the validity.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Richard Salac <richard.salac@broadcom.com>
@richard-salac
richard-salac force-pushed the reboot/chore/keystore_replacement_alt branch from dec884f to 9b83f96 Compare August 11, 2026 15:42
Signed-off-by: Richard Salac <richard.salac@broadcom.com>
@sonarqubecloud

Copy link
Copy Markdown

@richard-salac
richard-salac merged commit 7f55bf2 into v3.x.x Aug 13, 2026
128 of 131 checks passed
@richard-salac
richard-salac deleted the reboot/chore/keystore_replacement_alt branch August 13, 2026 09:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Sensitive Sensitive change that requires peer review size/XXL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants