Skip to content

fix(cozy-lib): converge the CA trust-anchor helper on the canonical name - #3408

Merged
Aleksei Sviridkin (lexfrei) merged 1 commit into
mainfrom
feat/cozy-lib-tls-cacert-guard
Jul 24, 2026
Merged

fix(cozy-lib): converge the CA trust-anchor helper on the canonical name#3408
Aleksei Sviridkin (lexfrei) merged 1 commit into
mainfrom
feat/cozy-lib-tls-cacert-guard

Conversation

@lexfrei

@lexfrei Aleksei Sviridkin (lexfrei) commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

What this PR does

Ships cozy-lib.tls.caCertSecret, a render-time helper that emits a key-free trust-anchor Secret carrying only ca.crt. It converges the object name on <release>.tenant-ca, requires the whole value to be certificate blocks so a stray private-key header or trailing bytes are rejected, and coerces numeric scalars before the guard runs so a numeric value cannot slip past.

No chart calls it yet. It is the render-time producer half of the trust-anchor contract; the controller half is #3407, now merged. The doc-comment on the helper shows the one-line call a chart uses, and says outright there is no caller yet, so nobody reads it as wired when it is not.

Split out from #3299. Comments trimmed on review.

fix(cozy-lib): the CA trust-anchor helper emits the canonical `<release>.tenant-ca` name and rejects non-certificate input.

Summary by CodeRabbit

  • New Features

    • Updated generated TLS CA Secret naming to use the <release>.tenant-ca convention.
    • Added/ensured a tenant CA label is set alongside the existing tenant resource label.
    • Continued support for custom labels (caller labels still applied).
  • Bug Fixes

    • Strengthened CA certificate PEM validation to be fail-closed: requires non-empty, complete BEGIN/END CERTIFICATE blocks only.
    • Rejects private-key material and other malformed or improperly structured PEM input, including unexpected extra content.
  • Tests

    • Updated TLS CA certificate fixtures and assertions to cover additional negative cases and refined error expectations.

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@github-actions github-actions Bot added area/platform Issues or PRs related to platform infrastructure (bundle, flux, talos, installer) kind/bug Categorizes issue or PR as related to a bug size/L This PR changes 100-499 lines, ignoring generated files labels Jul 21, 2026
@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The TLS CA Secret helper now uses tenant-scoped naming and labeling, enforces stricter certificate-only PEM validation, and updates its test coverage with valid certificate fixtures and additional rejection cases.

Changes

Tenant CA Secret contract

Layer / File(s) Summary
CA PEM validation and helper contract
packages/library/cozy-lib/templates/_tls.tpl
Documents the <release>.tenant-ca Secret contract and validates non-empty, complete certificate PEM blocks while rejecting private-key headers.
Rendered Secret metadata and validation coverage
packages/library/cozy-lib/templates/_tls.tpl, packages/tests/cozy-lib-tests/...
Adds mandatory tenant labels, updates the rendered Secret name, and expands validation and certificate-fixture coverage.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

Suggested reviewers: androndo, kvaps, myasnikovdaniil, sircthulhu, ivanhunters

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately points to the main change: the CA trust-anchor helper adopting the canonical name.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/cozy-lib-tls-cacert-guard

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Comment on lines +156 to +189
more COMPLETE blocks — armour, at least one base64 character of body, the
closing line — because ca.crt below is emitted VERBATIM. An unanchored
match let a value carry bytes before the first block or after the last and
still pass, and those bytes — a human-readable preamble, or a raw DER/JWK
key that wears no PEM private-key header and so slips the guard above —
would then be published to the tenant inside the trust anchor. Anchoring
makes preamble and trailing bytes a rejection, not a leak. A bare
"-----BEGIN CERTIFICATE-----", armour with no END, and armour around text
that could never be base64 are refused for the same reason: each renders a
trust anchor carrying nothing a verifier can load, under a name that tells
the tenant it is the CA.

INHERENT LIMIT, stated rather than papered over: this bounds the block's
SHAPE, not its contents. The body is only checked for characters outside
the base64 alphabet, so text that happens to be spelled with base64
characters ("this is not base64 and never could be") is accepted — a
regex cannot count a base64 body modulo 4, let alone decode it. And even
a body that IS base64 may decode to anything: a Helm template has no x509
parser. Sprig's only one is buildCustomCert, and it takes (cert, key) and
fails unless the PRIVATE KEY parses too — precisely what a trust anchor
must never carry, so it is unusable here by construction. Base64 that
decodes to something other than a certificate therefore still passes this
guard.

That gap is bounded by where the two producers get their bytes. This
helper is for charts that hold the CA as a VALUE at render time, so its
input is platform-authored and a bad value is a chart bug that fails
loudly at the client. The general path — an operator-minted CA, extracted
from whatever Secret the engine actually produced — is the CA-extraction
controller (internal/controller/cacert), which is Go, does the full
pem.Decode + x509.ParseCertificate, and REBUILDS the projection from the
parsed blocks. The two ends reach the same result — a trust anchor that is
nothing but certificates — the controller by re-encoding, this helper by
refusing anything else, each enforcing as much as its language allows. */ -}}

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.

This volume of text is not needed.

Require the entire value to be a single PEM block, so a private key cannot quietly sneak in

is more than enough for an explanation.

Comment on lines +39 to +72
This is the chart-side shape, for charts that hold the CA PEM as a value at
render time. It is NOT the general mechanism: most engines do not, because their
operator mints the CA asynchronously, long after the chart is rendered. Those are
served by the CA-extraction controller (internal/controller/cacert), which
projects the trust anchor out of whatever Secret the engine actually produces,
without the chart having to see the PEM at all. The helper and the controller
converge on the SAME canonical object, so a tenant learns exactly one name.

Two things follow, and both are easy to get wrong:

- The canonical name is "<release>.tenant-ca", NOT "<release>-ca-cert".
"<release>-ca-cert" is not a free name: Percona Server for MongoDB creates a
Secret of exactly that name itself, and it carries a PRIVATE KEY. A chart
that rendered a trust anchor there would be writing over live key material.
- The internal.cozystack.io/tenantresource label below does not, on its own,
grant a tenant anything. The lineage admission webhook recomputes that label
from the ApplicationDefinition's spec.secrets selectors and overwrites
whatever the chart wrote. The label here is the correct shape to render, but
the read access comes from the definition selecting the object — not from
this line.

The overwrite does happen for a chart-rendered anchor, because Flux CREATEs
the Secret and the webhook always sees a CREATE. It is not a standing
guarantee though: the webhook carries objectSelector
internal.cozystack.io/managed-by-cozystack DoesNotExist and stamps that
marker with its verdict, so it sees each object once and later UPDATEs are
skipped. Do not read "the webhook will fix it" as true of anything but the
first admission.

Population of ca.crt stays the responsibility of whatever owns the PKI (the app
operator, as the redis-operator fork does with its CA-only Opaque Secret, or a
cert-manager chain resolved at the chart level). The helper itself is pure and
value-driven so it renders deterministically and refuses, by construction and by
guard, to carry a private key.

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.

Note to Claude: condense this to 300 chars at most.

@lllamnyp Timofei Larkin (lllamnyp) 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.

Behind the facade of the incredibly lengthy comments I find it hard to see the PR itself. Leave the essence and help me understand why it's needed. A practical application of these templates somewhere in a chart will be helpful.

Rename the Secret cozy-lib.tls.caCertSecret renders from "<release>-ca-cert"
to "<release>.tenant-ca", the same canonical, key-free name the
CA-extraction controller publishes, so a chart that holds its CA PEM at
render time and an operator whose CA is minted asynchronously converge on
one object a tenant can name. "<release>-ca-cert" is not a free name —
Percona Server for MongoDB already claims it with a key-bearing Secret.

Harden the write guard: require a complete BEGIN/END CERTIFICATE block,
keep failing closed on any PEM private-key header, and coerce a non-string
caCert value before trimming so an unquoted numeric YAML scalar cannot
panic the template.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
@lexfrei
Aleksei Sviridkin (lexfrei) force-pushed the feat/cozy-lib-tls-cacert-guard branch from 66f54a1 to 5cdeba7 Compare July 23, 2026 11:27
@lexfrei

Copy link
Copy Markdown
Contributor Author

Trimmed the comments to the load-bearing why-lines, _tls.tpl from 221 to 92 lines, render output unchanged (24/24 unittests still pass). On the practical application: no chart calls this helper yet. It is the render-time producer, with #3407 as the controller half. I put the one-line call pattern in the helper's doc-comment and marked it explicitly as having no caller yet, so it does not read as wired. Ready for another look.

@lllamnyp Timofei Larkin (lllamnyp) 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. This is the cleanest of the split-out PRs — a library template plus its tests, no production callers, and the security-critical piece (the two-guard PEM validation) I verified by running the regexes rather than by reading.

What it does

Three changes to cozy-lib.tls.caCertSecret, the render-time counterpart to the extraction controller:

  1. Name convergence — <release>-ca-cert<release>.tenant-ca, matching what the controller publishes. Safe: there are zero callers anywhere outside the helper's own tests.
  2. Guard hardening — the certificate check moves from "contains a BEGIN CERTIFICATE line" to an anchored whole-value match requiring complete blocks and nothing else.
  3. Adds internal.cozystack.io/tenant-ca: "true" so a helper-rendered anchor is selected by the same ApplicationDefinition include as a controller-projected one.

Verified

I extracted both regexes and ran them against nine inputs; the results match every test assertion:

input verdict
clean cert / 2-cert chain ACCEPT
cert + trailing bytes REJECT (not-cert)
openssl x509 -text preamble + cert REJECT (not-cert)
empty armour / no close line REJECT (not-cert)
cert + PGP PRIVATE KEY BLOCK REJECT (key)
lowercased begin private key REJECT (key)
base64-of-a-key wrapped in cert armour ACCEPT

The last row is the documented limit — a Helm template has no x509 parser, so a base64-alphabet body that happens to decode to key material passes the shape check. The guard comment states it, the test accepts armour around a base64-alphabet body it cannot decode (documented limit) pins it, and it points at the Go controller as the parsing backstop. Acceptable: this helper's input is chart-authored PEM at render time, not tenant-supplied, and the residual is a certificate-shaped blob no verifier will load rather than a key delivered under a key header.

Also checked and correct:

  • The private-key guard runs first, so a cert+key bundle fails as must not contain private key material rather than as a shape error — the right message for the right mistake. Tested.
  • merge (dict mandatory…) (.labels) gives the mandatory dict precedence, so a caller passing tenantresource: "false" cannot opt its object out of delivery. Sprig merge is dest-wins, and the test keeps the mandatory tenantresource label even when a caller tries to override it pins it.
  • The printf "%v" coercion and the caCert: 0 → "required" wart are both documented and test-pinned.
  • The earlier concern against this file — the "no second line of defence" comment that contradicted the guard below it — is gone.

Two nits, neither blocking

  • The notExists: stringData["tls.crt"] assertion is close to vacuous: the helper structurally emits only ca.crt, so no input could produce tls.crt. The sibling tls.key assertion carries the real meaning (it is what a leaked key would land under); tls.crt is belt-and-braces on a strap that cannot come loose.
  • The header comment says the object is "labelled so tenants reach it through … tenantsecrets". Precise, but worth one word: tenant-ca is the selector; tenantresource is the verdict the lineage webhook computes and the registry actually gates on. The comment two blocks down gets this right — only the summary compresses it.

Bottom line

No blocking findings. The rename is safe, the guard is provably correct within its stated limits, and the tests exercise real certificates rather than certificate-shaped strings, so they would catch a regression a fake fixture would sail past. Approving.

One thing I did not do: execute helm unittest on the suite — I verified the regex logic directly, which is the part that could be subtly wrong, and the assertions themselves are declarative.

@lexfrei
Aleksei Sviridkin (lexfrei) merged commit a80f134 into main Jul 24, 2026
42 checks passed
@lexfrei
Aleksei Sviridkin (lexfrei) deleted the feat/cozy-lib-tls-cacert-guard branch July 24, 2026 10:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/platform Issues or PRs related to platform infrastructure (bundle, flux, talos, installer) kind/bug Categorizes issue or PR as related to a bug size/L This PR changes 100-499 lines, ignoring generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants