Skip to content

fix(tools): harden mTLS material tooling - #80

Merged
LDLDL merged 2 commits into
mainfrom
fix/mtls-tooling
Oct 5, 2026
Merged

LDLDL merged 2 commits into
mainfrom
fix/mtls-tooling

Conversation

@sosyz

@sosyz sosyz commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Serial numbers: the self-signed CA now gets a random positive 128-bit serial instead of 0, and entity serials start at 1. A counter file left at 0 by an older version gets bumped to 1. The counter is now written before the bundle, so a crash between the two writes skips a serial instead of reusing one. The certificate also takes a copy of the counter rather than aliasing it.
  • CA path length: the CA sets MaxPathLen: 0 / MaxPathLenZero: true, so it can sign entity certs but not intermediates.
  • --valid-for (optional) on make-ca, make-server and make-client, passed through as tools.WithLifetime. Default is unchanged: without the flag, certificates are valid until 2100-01-01.
  • Leaf expiry never passes the CA's: without --valid-for, a leaf's expiry is capped at the CA's and a notice is printed. An explicit --valid-for that would end after the CA expires is rejected before anything is written.
  • pkg/mtls: the bundle must contain at least one real CA certificate (BasicConstraintsValid && IsCA) after the entity cert and key. Otherwise it now fails at load with an error that names the file. Before, it produced a pool with no usable anchor, and every handshake failed with an unclear error.
  • pkg/paths: MtlsBundlePath and ACMEPrivateKey now reject values that are not plain filename components: path separators, ., .., NUL or an empty string. This stops them from writing outside mtls/ or private/.
  • Docs: docs/tools.md and docs/setup.md now cover --valid-for, the cap at the CA's expiry, and the bundle-name rule.

Review feedback from #75 addressed

  1. LDLDL (docs/setup.md), keep the 2100 default. The two-year (entity) and ten-year (CA) defaults are gone, along with DefaultLeafLifetime and DefaultCALifetime and the docs text about them. Without --valid-for, NotAfter is still 2100-01-01T00:00:00Z, as in main. The flag defaults to "unset", and help and docs show the default as "valid until 2100-01-01".
  2. Codex P1 (pkg/tools/cert.go), cert could be expired when issued. NotBefore is still backdated to the start of the hour, but NotAfter is now issuance time + lifetime. It used to be NotBefore + lifetime, so issuing at 12:45 with --valid-for 30m produced a cert that expired at 12:30. MakeCA and makeCert both get this fix. Non-positive lifetimes are rejected, not silently ignored:
    • The CLI returns --valid-for must be positive, got 0s when the flag is given explicitly with a value <= 0. It uses fs.Changed to tell "not given" apart from "given as 0".
    • tools.WithLifetime(d <= 0) makes MakeCA / MakeServerCert / MakeClientCert return an error before anything is written.
    • TestShortLifetimeIsNotAlreadyExpired pins the clock to 12:45 and issues with a 30m lifetime. It fails against the old logic (NotAfter 12:30) and passes now (NotAfter 13:15).

Review feedback on this PR addressed

  1. Codex P2 (pkg/tools/cert.go), a leaf could outlive its CA.
    • With the default expiry, the leaf's NotAfter is capped at caCert.NotAfter and a notice is printed.
    • An explicit --valid-for that overshoots the CA returns an error. No bundle is written and the counter is not advanced.
    • Tests: TestLeafDefaultExpiryCappedAtCA and TestLeafExplicitLifetimeBeyondCARejected.
  2. Codex P2 (pkg/mtls/mtls.go), a non-CA cert passed the missing-CA check. Each trust cert is now parsed, and only certs with BasicConstraintsValid && IsCA count toward the check. Every parseable cert is still added to the pool, as before.
    • Test: TestParseBundleNonCATrustCert (the bundle's second cert is a non-CA leaf).
    • Bundles from make-server / make-client still load; the full e2e suite passes.

Breaking changes

None for defaults: expiry is still 2100, and certificates that already exist are unaffected.

Behaviour changes worth noting:

  • make-server / make-client now reject -n values that contain / or \, or that are . / ... Before, a name like a/b was joined into a path, which failed or wrote into a subdirectory. The ACME email and provider values that make up the account key file name get the same check.
  • An mTLS bundle with no CA certificate (a cert with IsCA) now fails at startup instead of at every handshake.
  • New CAs have a random serial and a path length of 0. Entity serials start at 1, not 0.
  • Issuing a leaf under a CA made with a short --valid-for: the leaf now expires with the CA (default), or the command errors out (explicit --valid-for that ends after the CA).

Test results

  • gofmt -l pkg exec test: clean
  • go vet ./pkg/...: clean
  • go test -race -count=1 ./pkg/... (root): all ok
  • exec/tools: go vet ./... && go build ./... && go test ./...: ok (new TestValidForOptions)
  • go build ./... in exec/caddytls, exec/client and exec/server: ok
  • test/e2e: go vet -tags e2e ./... and full go test -tags e2e -count=1 ./...: ok (268s)
  • Manual run of the built binary (before the review fixes on this PR):
    • Default CA and client both show notAfter=Jan 1 00:00:00 2100 GMT, with CA serial random and entity serials 01, 02.
    • Issued at 08:12 with --valid-for 30m: NotAfter is 08:42.
    • --valid-for 0 is rejected.
    • -n ../x is rejected.

Split from #75.

🤖 Generated with Claude Code

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-29T08:18:54.046243Z 5ddce3c PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5ddce3c3f4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread pkg/tools/cert.go
serial.SetInt64(firstEntitySerial)
}

notBefore, notAfter := o.validity(now())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep leaf validity within the CA lifetime

When a CA is created with a short --valid-for and a later make-server or make-client invocation uses either the default expiry or a longer lifetime, this computes a leaf NotAfter later than caCert.NotAfter. x509.CreateCertificate permits that, but certificate-chain verification still rejects the expired CA, so the generated bundle stops working before the leaf's advertised expiry. Reject such a lifetime or cap the leaf expiry to the issuing CA's expiry.

Useful? React with 👍 / 👎.

Comment thread pkg/mtls/mtls.go Outdated
Comment on lines +47 to +48
if pool.AppendCertsFromPEM(pem.EncodeToMemory(block)) {
peers++

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Verify that appended trust certificates are CAs

If a bundle contains a parseable second certificate that is not a CA, such as a duplicated leaf or an unrelated entity certificate, AppendCertsFromPEM still returns true and increments peers. The new missing-CA guard therefore accepts the malformed bundle, but its trust pool cannot authenticate normal peers and the failure remains deferred to the TLS handshake. Parse appended certificates and require the expected CA constraints before counting them.

Useful? React with 👍 / 👎.

sosyz and others added 2 commits September 29, 2026 17:51
- Issue positive, unique serials: the CA gets a random 128-bit serial
  and entity serials start at 1; a legacy counter of 0 is bumped. The
  counter is persisted before the bundle so a crash burns a serial
  rather than reusing one.
- Mark the CA with pathLenConstraint 0 (MaxPathLen/MaxPathLenZero).
- Add an optional --valid-for to make-ca/make-server/make-client via
  tools.WithLifetime. Default expiry stays 2100-01-01. NotAfter counts
  from the actual issuance time, so a short lifetime can no longer
  yield an already-expired certificate; non-positive values are
  rejected.
- mtls: fail at load when a bundle yields an empty CA pool instead of
  rejecting every peer at handshake time.
- paths: reject bundle names and ACME email/provider values that are
  not plain filename components (separators, ".", "..", NUL).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…undles

- makeCert caps a default (until 2100) leaf expiry at the issuing CA's
  NotAfter and prints a notice; an explicit --valid-for that would end
  after the CA expires is rejected before anything is written.
- mtls: only certificates with BasicConstraintsValid && IsCA satisfy the
  missing-CA check, so a bundle whose trust section is just another leaf
  fails at load.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@LDLDL LDLDL left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@LDLDL
LDLDL merged commit f313179 into main Oct 5, 2026
2 checks passed
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.

2 participants