Repository navigation
feat(bep): issue leaf certificates under a name-constrained root and a signing CA - #2438
norrietaylor merged 11 commits into
Conversation
Add the three-layer interception CA of BEP-013. The self-signed root carries the registered union, less the fixed deny set, as its permitted names. The signing CA issues per-hostname leaves for names in the union only. A change to the union re-issues the root under the same key and subject. The CA signs through a CaKey trait, so the keys stay in the key store.
|
Important Review skippedBot user detected. To trigger a single review, invoke the ⚙️ Run configuration
You can disable this status message by setting the Use the checkbox below for a quick retry:
Comment |
|
Reviewed: no findings; approval withheld, 19 threads unresolved at APPROVE withheld: 19 unresolved threads — minimal-arch[bot] crates/bep/src/ca.rs:81, minimal-arch[bot] crates/bep/src/ca.rs:52, minimal-arch[bot] crates/bep/src/ca.rs:1214, minimal-arch[bot] crates/bep/src/ca.rs:606, minimal-arch[bot] crates/bep/src/ca.rs, …. Resolving the last thread releases the approval on this head. Resolve the open threads. Resolving the last one releases the approval on this head; no new review is required. Clobber: none seen. |
There was a problem hiding this comment.
arch-review: PASS_WITH_CONDITIONS · 3 conditions · the full ruling
The CA conforms to Gatehouse §6.10 line 839. The root is self-signed and carries the constraints, and it is re-issued under the same key and subject. Two pieces of work stay undone and have no issue. No keychain key implements CaKey (C1), and DENY_SET is provisional against the categories of line 849 (C2). File each issue and link it from the code. Also make the test prove the keyid-only AKID of line 821 (C3).
Ruled at a6bd821 against gominimal/arch@3dab1097cda1 by claude-opus-5-5.
A leaf now ends no later than the signing CA that issued it, so a late leaf does not outlive its chain. The union skips registered authorities that are IP literals or bracketed hosts, because a DNS name constraint cannot carry them.
|
Round 1 of 10 on the runtime's review findings, pushed as
Verify Review threads the coder judged:
A leaf for each GitHub host validates against the root alone, and a leaf for any other name does not. A leaf also ends no later than its signing CA, and an IP literal never becomes a permitted name in the root. Notes from the agent: Revise round 1. Two new tests in crates/bep/src/ca.rs cover the fixes: leaf_never_outlives_signing_ca and ip_literal_authorities_are_not_in_the_union. Both pass with the rest of the ca tests. The union now writes a warn log line for each IP literal that it skips. cargo clippy -p bep --all-targets is clean. |
There was a problem hiding this comment.
arch-review: CONDITIONS · 1 condition · the full ruling
The leaf cap and the IP-literal skip in this push conform to Gatehouse §6.10 line 839. One gap remains on the anchor. The root constrains dNSName only, so it admits a leaf with an iPAddress SAN for any address. Line 839 puts the constraints on the root itself, never only on the signing CA. C1 adds iPAddress excluded subtrees for all of IPv4 and IPv6, with a test that proves them.
Ruled at b0d9c1e against gominimal/arch@3dab1097cda1 by claude-opus-5-5.
The root now excludes 0.0.0.0/0 and ::/0, so an IP-address leaf fails validation. Union::new skips wildcards, single-label names, empty labels and non-ASCII names. Tests assert the keyid-only AKID and the IP refusal. Doc comments mark the deny set as provisional and say that no keychain key implements CaKey yet.
|
Round 2 of 10 on the runtime's review findings, pushed as
Verify Review threads the coder judged:
A leaf for each GitHub host validates against the root alone. A leaf for any other name, including a leaf with an IP-address SAN that the signing CA signs directly, fails validation against the root. Notes from the agent: Two threads (C1 at ca.rs:66, C2 at ca.rs:43) ask for GitHub issues to be filed and linked. I have no gh credential, so a person must file them. The doc comments now state the gap: no keychain key implements CaKey (the implementation belongs in keys.rs, outside this task's layers), and DENY_SET is provisional until one set serves this check and BEP-034. I checked that ip_address_leaf_fails_against_the_root fails when the IP exclusion is removed. All 10 ca:: tests pass, and clippy -p bep --all-targets is clean. |
There was a problem hiding this comment.
arch-review: PASS_WITH_CONDITIONS · 2 conditions · the full ruling
This head closes the open items of the ruling on b0d9c1ea. The root excludes all IP subtrees, the union skips unsafe names, and a test proves the keyid-only AKID of Gatehouse §6.10 line 821. Two interims link no issue. DENY_SET is a partial reading of the fixed deny set (line 849), and no keychain key implements CaKey (line 839). File an issue for each, then link it in the doc comment and in the PR body, as C1 and C2 say.
Ruled at a9730c0 against gominimal/arch@3dab1097cda1 by claude-opus-5-5.
A leaf signed after the signing CA's notAfter carries a notAfter before its notBefore, and no client accepts it. Return CaError::SigningExpired instead of issuing it.
|
Round 3 of 10 on the runtime's review findings, pushed as
Verify Review threads the coder judged:
A leaf for each GitHub host validates against the root alone, and a leaf for any other name does not. The CA now also refuses a leaf with CaError::SigningExpired once the signing CA has expired, in place of a leaf that no client accepts. Notes from the agent: Revise round 3: one thread addressed. I also ran |
There was a problem hiding this comment.
arch-review: PASS_WITH_CONDITIONS · 2 conditions · the full ruling
The new commit refuses a leaf after the signing CA expires. That is fail-closed and conforms to section 6.10 line 839. The ruling on a9730c0 stands for the rest of the change. Two items are still open, and no issue owns them. C1: file the incomplete deny set (line 849) and name the issue on DENY_SET. C2: file the keychain CaKey implementation (line 839) and name the issue on CaKey.
Ruled at 7d9e7b5 against gominimal/arch@3dab1097cda1 by claude-opus-5-5.
The Ca stored the root parameters but never read them. The DENY_SET and CaKey doc comments now name every missing part and the flip to the BEP-034 deny set.
|
Round 4 of 10 on the runtime's review findings, pushed as
Verify Review threads the coder judged:
A leaf for each GitHub host validates against the root alone, and a leaf for any other name does not. This round does not change that behaviour. It removes an unread field and makes the two interim doc comments complete. Notes from the agent: I cannot file GitHub issues because this session has no gh credential. So the four minimal-arch threads get updated doc comments but no issue URLs. A person must file two issues and put their URLs into the DENY_SET and CaKey doc comments and the PR body: one for the full shared deny set (BEP-034) and one for CaKey on the Keychain and MemoryKey keys in keys.rs. The PR body must name the second one as a residual. cargo clippy -p bep --all-targets is clean. |
There was a problem hiding this comment.
arch-review: PASS_WITH_CONDITIONS · 3 conditions · the full ruling
The CA conforms to Gatehouse §6.10. This push changes only doc comments and drops an unread field. Two interims still name no issue: the CaKey keychain implementation and the full deny set. File both issues and put the URLs in the doc comments and the PR body (C1, C2). The DENY_SET comment also says that the deny set includes the deep module host sets. Line 839 keeps those hosts in the root's union, so correct the flip text (C3).
Ruled at 5d4834e against gominimal/arch@3dab1097cda1 by claude-opus-5-5.
Add Ca::renew_signing and Ca::signing_expiry so a long-running caller can re-issue the signing CA before it expires, without a rebuild.
|
Round 5 of 10 on the runtime's review findings, pushed as
Verify Review threads the coder judged:
A leaf for each GitHub host validates against the root alone, and a leaf for any other name is refused and does not validate. A long-running caller can now renew the signing CA with Ca::renew_signing and continue to issue leaves after the first signing CA expires. Notes from the agent: Round 5: added Ca::renew_signing and Ca::signing_expiry, and moved the signing CA issuance into one helper, issue_signing. A new test signing_ca_renews_under_same_key_and_subject proves that a renewed signing CA keeps its subject and key and issues leaves again. The CA does not renew itself. The caller must call renew_signing before signing_expiry. The keychain CaKey and the full deny set are still open residuals from earlier rounds. |
There was a problem hiding this comment.
arch-review: PASS_WITH_CONDITIONS · 2 conditions · the full ruling
The signing-CA renewal conforms to Gatehouse §6.10 line 839. It re-issues the signing CA under the same key and subject from an unchanged root, so boxes see nothing. Two interims from the earlier ruling are still open. The CaKey and DENY_SET doc comments say that no issue tracks them. File the two issues and link them in the code and the PR body, as C1 and C2 say.
Ruled at b107535 against gominimal/arch@3dab1097cda1 by claude-opus-5-5.
renew_signing now clamps the signing CA not_after to the root in use and refuses with RootExpired once the root expired. The DENY_SET doc no longer lists the module host sets, which stay in the union.
|
Round 6 of 10 on the runtime's review findings, pushed as
Verify Review threads the coder judged:
A leaf for each GitHub host validates against the root alone, and a leaf for any other name does not. A signing CA renewed late in the root's life now expires with the root, so its leaves still validate against the root. Notes from the agent: Round 6. renew_signing now clamps the signing CA not_after to the expiry of the root in use. It refuses with CaError::RootExpired once that root expired. The new test renewed_signing_ca_never_outlives_the_root covers this. The DENY_SET doc no longer lists the module host sets. The C1 and C2 threads ask for issue URLs. This session has no issue-tracker credential, so a person must file two issues: (1) CaKey implementations for the Keychain and MemoryKey keys in keys.rs, (2) the full fixed deny set shared with BEP-034. Then put the URLs in the DENY_SET and CaKey doc comments. clippy -p bep --all-targets was clean. All 12 ca:: tests passed. |
There was a problem hiding this comment.
arch-review: PASS_WITH_CONDITIONS · 2 conditions · the full ruling
The certificate design conforms to Gatehouse §6.10 line 839. The new root-expiry cap on the signing CA fails closed. Two interims remain without an issue. They are the keychain CaKey implementations and the full deny set. Rule 6 refuses a residual that no issue owns. File the two issues and link them in the comments, as C1 and C2 say.
Ruled at ad01f99 against gominimal/arch@3dab1097cda1 by claude-opus-5-5.
dns_host now refuses a registered host longer than 253 bytes. A leaf for a name longer than 64 bytes carries the name in its SAN only, because the subject common name cannot hold it.
|
Round 7 of 10 on the runtime's review findings, pushed as
Verify Review threads the coder judged:
A leaf for each GitHub host validates against the root alone, and a leaf for any other name does not. A registered host longer than 253 bytes now stays out of the union, and a leaf for a name longer than 64 bytes carries the name in its SAN only. Notes from the agent: Round 7. I fixed the dns_host length finding and added the test long_names_stay_within_dns_and_common_name_limits. The two C1/C2 threads ask me to file issues. This session has no credential for gh or the GitHub API, so I cannot file an issue or write its URL. A person must file the two issues: one for the full DENY_SET of Gatehouse 6.10, and one for the Keychain and MemoryKey implementations of CaKey. Then the person must replace the 'not filed yet' text at ca.rs:50 and ca.rs:79 with the URLs. I did not run just clippy-strict. |
There was a problem hiding this comment.
arch-review: PASS_WITH_CONDITIONS · 2 conditions · the full ruling
The new commit conforms. The 253-byte name cap keeps the union fail-closed, and a leaf with no CN for a name over 64 bytes still carries its name in the SAN. Two interims are still open. No keychain key implements CaKey, so the signing CA is not yet keychain-held (§6.10 line 839). DENY_SET is a partial deny set. The code says that the issues for both are not filed. File the issues and put their URLs in the doc comments, as C1 and C2 say.
Ruled at 47b905e against gominimal/arch@3dab1097cda1 by claude-opus-5-5.
Ca::new now checks that the root key holds the root role, the signing key the signing role, and that neither is exportable. One key for both roles, or the sealing key, no longer reaches the CA.
|
Round 8 of 10 on the runtime's review findings, pushed as
Verify Review threads the coder judged:
A leaf for each GitHub host validates against the root alone, and a leaf for any other name does not. The CA now also refuses a key of the wrong role or an exportable key, so the signing CA key cannot sign a root. Notes from the agent: Round 8. Ca::new now refuses a root key that is not Role::Root, a signing key that is not Role::Signing (this also covers one key for both roles and the sealing key), and an exportable key. Two new CaError variants: WrongRole and Exportable. TestKey has an exportable field for the test. The two issue-filing threads (C1, C2) are the same request as in earlier rounds. This session has no gh credential and cannot file an issue, so the doc comments still say the issue is not filed yet. A person must file the two issues and put the URLs in the doc comments of DENY_SET and CaKey. |
There was a problem hiding this comment.
arch-review: PASS_WITH_CONDITIONS · 2 conditions · the full ruling
The new role and exportability checks in Ca::new conform to §6.10 line 839. They refuse a wrong-role key or an exportable key before any signature. The baseline ruling stands for the rest of ca.rs. Two interim notes still say that no issue is filed, but the PR body names #2440 and #2441. Link those issues in the CaKey doc (C1) and the DENY_SET doc (C2).
Ruled at 75cc457 against gominimal/arch@3dab1097cda1 by claude-opus-5-5.
There was a problem hiding this comment.
minimal-review — approval withheld
Verdict COMMENT — APPROVE withheld: 20 unresolved threads — minimal-arch[bot] crates/bep/src/ca.rs:80, minimal-arch[bot] crates/bep/src/ca.rs:51, minimal-arch[bot] crates/bep/src/ca.rs:1213, minimal-arch[bot] crates/bep/src/ca.rs:605, minimal-arch[bot] crates/bep/src/ca.rs, …. No findings.
Panel: 3 of 3 personas ran (correctness, security, supply-chain).
|
/revise minimal-arch ruled PASS_WITH_CONDITIONS on 47b905e (check run 118408111912): two filing-only conditions, and the epic owner has filed both issues. This round puts the links in and changes nothing else.
Both issues are already listed under Residuals in the PR body, so leave the body alone. Reply on the two arch threads with the commit. Generated by Claude Code |
|
Round 9 of 10 on a /revise note, pushed as
Verify Review threads the coder judged:
A leaf for each GitHub host validates against the root alone, and a leaf for any other name does not. This round changes no behaviour. The CaKey and DENY_SET doc comments in crates/bep/src/ca.rs now link issues #2440 and #2441. Notes from the agent: Round 9 changes doc comments only, in commit f1313cb. The URLs are in angle brackets to match publish.rs and to keep rustdoc's bare_urls lint quiet. The PR body is unchanged, as the note asked. |
There was a problem hiding this comment.
arch-review: PASS · the full ruling
This push links the two residual issues in the ca.rs doc comments. DENY_SET now names #2441, and CaKey now names #2440. Both issues are open and track the work that Gatehouse §6.10 line 839 still needs: a keychain-held signing CA and the full fixed deny set. The earlier conditions are closed, and the change conforms. The two residuals stay open as filed.
Ruled at f1313cb against gominimal/arch@3dab1097cda1 by claude-opus-5-5.
There was a problem hiding this comment.
minimal-review — approval withheld
Verdict COMMENT — APPROVE withheld: 20 unresolved threads — minimal-arch[bot] crates/bep/src/ca.rs:81, minimal-arch[bot] crates/bep/src/ca.rs:52, minimal-arch[bot] crates/bep/src/ca.rs:1214, minimal-arch[bot] crates/bep/src/ca.rs:606, minimal-arch[bot] crates/bep/src/ca.rs, …. No findings.
Panel: 3 of 3 personas ran (correctness, security, supply-chain).
Keep both module lines in lib.rs: audit from main, ca from this branch.
|
A merge of main before round 10, not counted against the cap (1 of 3), pushed as Resolved conflicts in:
Verify A leaf for each GitHub host validates against the root alone, and a leaf for any other name does not. The branch now merges cleanly with main. Notes from the agent: Conflict round only. In crates/bep/src/lib.rs, main added |
There was a problem hiding this comment.
arch-review: PASS · the full ruling
This push merges main into the branch and changes no line of the CA work. The new files are audit.rs from #2361 and its module line in lib.rs. The PASS on f1313cb8 stands for BEP-013 under Gatehouse §6.10. The residuals stay open in #2440 and #2441.
Ruled at 68fe08b against gominimal/arch@3dab1097cda1 by claude-opus-5-5.
There was a problem hiding this comment.
Babysitter review of 68fe08b (T3: the interception CA, bep::ca). Read as a COMMENT review so it starts no coder round.
What I checked. The root is self-signed under the root key with pathlen 1, permitted subtrees equal to the registered union (module host sets plus store-rule hosts, lowercased, ports dropped, IP literals, wildcards, single-label and malformed names skipped, deny-set names and their subdomains removed) and excluded subtrees equal to the deny set plus every IPv4 and IPv6 address, so an unconstrained name type cannot slip through. The signing CA is issued by the root with pathlen 0, and a leaf is issued only for a name the union holds exactly, with a fresh in-process P-256 key, a SAN, a bounded common name, serverAuth, and a lifetime capped by the signing CA's. Both the signing CA and the leaf carry a key-identifier-only AKID, so they chain to every issue of the root; reissue_root keeps the key and subject and only changes the constraints, and the tests show a box holding the old root still validates the names it permitted while the added name validates only against the new root. The root and signing keys are used through CaKey::sign alone, a wrong-role or exportable key is refused before anything is issued, and a store refusal surfaces as the store's own error rather than rcgen's. The proptest pins the constraint-equals-union invariant across modules, registered authorities with ports, and denied names, and validates each permitted leaf with webpki. No blocking finding from me.
Findings, none blocking
-
renew_signingandreissue_rootboth callroot_params(&self.union, now)to build the issuer, which mints a fresh serial and validity for a root that is never written out. The issuer only needs the subject and key; a small helper that builds the issuer from the current root's params would make it plain that renewal does not re-issue the root. -
SIGNING_VALIDITYis a year and the signing CA is renewed only by a caller that watchessigning_expiry; the proxy task that owns the serve loop needs to schedule that renewal, andrenew_signing's doc says so. Worth a line in #2440 or the proxy task so it is not forgotten.
Residuals. #2440 (CaKey on the keychain key) and #2441 (the full deny set, carrying the deferred design decision), both linked from the doc comments and the body, as the arch's PASS ruling records.
Generated by Claude Code
There was a problem hiding this comment.
Approving for merge: T3 after the arch PASS with #2440/#2441 linked. Gate passes on 68fe08b (arch PASS, review concluded with no open threads, lanes green, squash subject lint-clean).
Generated by Claude Code
T3 — Issue leaf certificates under a name-constrained root and a keychain-held signing CA
slice: S0
kind: task
covers: BEP-013
size: M
est: 350 code, 140 tests
executor: agent
difficulty: needs care
model: standard
diagnostics: a leaf request for a name outside the union is refused with a log line naming the name and the union it fell outside
observability: one debug log line per leaf issued with the hostname, and one info line per root re-issue naming the changed constraints
layers:
- ca:
- crates/bep/src/ca.rs (new): the three-layer CA of BEP-013: constrained self-signed root, keychain signing CA, leaves for the union alone, re-issue under the same key
- crates/bep/src/lib.rs: module line
- crates/bep/Cargo.toml: the certificate dependency
- Cargo.lock: the certificate dependency
test: cargo nextest run -p bep prop_root_constraints_equal_registered_union
test: cargo nextest run -p bep root_recertified_under_same_key_and_subject
produces:
- crates/bep/src/ca.rs
consumes:
- crates/bep/src/keys.rs
proof: A leaf for each GitHub host validates against the root alone, and a leaf for any other name does not.
depends on:
- blocked by #2259
Proof
At this branch, crates/bep/src/ca.rs issues a self-signed root whose permitted DNS names are the registered union less the deny set, a signing CA under it, and per-host leaves. The tests show that a leaf for each of github.com, api.github.com, uploads.github.com and codeload.github.com validates against the root alone (webpki, with the signing CA as the only intermediate). A leaf for any other name is refused by issue_leaf, and a leaf that the signing CA signs directly for such a name fails validation against the root.
Notes from the agent: 1. HeldKey in keys.rs has no signing method, and keys.rs is outside this task's layers. So c 8000 a.rs adds a trait CaKey: HeldKey with sign(msg) -> DER ECDSA P-256 SHA-256. No production key implements it yet. The tests use a software key. A next task must implement CaKey for keys::Keychain's KeychainKey (SecKey create_signature with ECDSASignatureMessageX962SHA256, which probe already uses) and for the test MemoryKey. 2. The spec does not list the fixed deny set. ca::DENY_SET holds min.internal and minimal.dev as a first reading of 'Minimal's own infrastructure'. The BEP-034 owner must confirm or extend it. The root also carries the deny set as excluded subtrees. 3. An empty union is refused (CaError::EmptyUnion), because rcgen leaves out an empty permitted list, and the root would then permit every name. 4. New dependencies, added directly in crates/bep/Cargo.toml and not in the workspace table, because the root Cargo.toml is outside the layers: rcgen 0.14 (default features, ring backend), time 0.3, rustls-pki-types 1, and the dev-dependency rustls-webpki 0.103 (ring), which was already in the lockfile. The leaf private key is generated in process memory with rcgen/ring. Only the root and signing keys are held keys. 5. I did not run cargo-deny or the full workspace gates.
Tests
cargo nextest run -p bep prop_root_constraints_equal_registered_unioncargo nextest run -p bep root_recertified_under_same_key_and_subjectVerify
min task run agent-verifypassed ([tasks.verify]).Closes #2265
Residuals
CaKeyfor the keychain-held root and signing CA keys (the production implementation of theca::CaKeyseam).ca::DENY_SET, shared with the BEP-034 check; carries the deferred deny-set design decision.Layer exception
Accepted by the epic owner on 2026-10-19 under the rule for required one-line changes outside the planned layers:
rcgen,time,rustls-pki-typesand the dev-dependencyrustls-webpkiare declared directly incrates/bep/Cargo.tomlbecause the rootCargo.tomlis outside the layers.Generated by Claude Code