feat(gateway,api): CSR-based client-certificate issuance (Phase 2) - #3
Open
marcorivm wants to merge 1 commit into
Open
feat(gateway,api): CSR-based client-certificate issuance (Phase 2)#3marcorivm wants to merge 1 commit into
marcorivm wants to merge 1 commit into
Conversation
Move client-cert issuance in-process so operators no longer hand-run openssl. The client generates its own keypair and sends a CSR; the gateway signs the CSR's PUBLIC KEY with a host-resident client CA and returns the leaf + issuing CA. The private key never leaves the client. Gateway: - New client_ca_authority.rs: ClientCa load-or-generate (0600 key, separate from the MITM CA), and sign_csr. - SECURITY: sign_csr uses ONLY the CSR public key and DISCARDS the CSR's requested subject/SAN/EKU entirely — identity (SAN spiffe://onecli/ host/<id>, CN, clientAuth EKU, is_ca=false, validity) is server-set from values the web API supplies. Signs via CertificateParams:: signed_by(&public_key,..), never CertificateSigningRequestParams:: signed_by. rcgen from_pem verifies the CSR self-signature (possession). - New POST /v1/internal/client-cert/issue behind an inbound X-Gateway-Secret guard (constant-time, fail-closed on empty), 16KB body cap, lifetime clamp (24h default, 7d max). - from_pem CSR parsing requires the rcgen x509-parser feature. Web API + DB: - POST /v1/gateway/client-cert (authMiddleware) resolves tenant from the authenticated context, ensures a per-host ClientHost row (renewal by hostId is tenant-scoped — no cross-tenant IDOR), forwards the CSR, and audits via withAudit (metadata: hostId/spiffe/serial/notAfter, never key material). New ClientHost model + migration. No private key is ever returned, logged, or placed in audit metadata. Stacked on Phase 1 (mTLS listener). Gateway 613 tests, web API 1110, clippy --all-targets -D warnings + fmt clean. cargo build --features cloud unchanged (27 pre-existing ee/*.rs errors, none from this change). Claude-Session: https://claude.ai/code/session_01BgJuqEJqf7ZiUdqHWxi6bt
This was referenced Jul 30, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Phase 2 of remote-gateway hardening — CSR-based client-cert issuance
Stacked on #2 (Phase 1) — base is
worktree-agent-abc6d62ceacdb6f8c, not the integration branch. Review/merge #2 first; this diff shows only Phase 2. Cross-repo: gateway (Rust) + web API (TS/Hono) + a Prisma migration.What this PR does
Moves client-cert issuance in-process so operators no longer hand-run
openssl. The client generates its own keypair and sends a CSR; the private key never leaves the client. The gateway signs the CSR's public key with a host-resident client CA (distinct from the MITM CA) and returns the leaf + issuing CA.client_ca_authority.rs—ClientCaload-or-generate (0600 key), andsign_csr. New internal routePOST /v1/internal/client-cert/issuebehind an inboundX-Gateway-Secretguard.POST /v1/gateway/client-cert(authenticated) resolves tenant from the auth context, ensures a per-hostClientHostrow, forwards the CSR, and audits viawithAudit.ClientHostmodel + migration (the host↔tenant mapping Phase 5 will enforce against).Security properties
sign_csruses ONLY the CSR's public key and discards the CSR's requested subject/SAN/EKU entirely (the binding names the field_csr_params_discarded_do_not_useso misuse shows up in a diff). SAN (spiffe://onecli/host/<id>), CN,clientAuthEKU,is_ca=false, and validity are all set server-side from values the web API supplies. Signs viaCertificateParams::signed_by(&public_key, …), never the CSR-copyingCertificateSigningRequestParams::signed_by. A client cannot choose its own identity — proven by a negative test that parses the issued X.509 and asserts the attacker-requested CN/SAN are absent.from_pemverifies the CSR self-signature (guarded by a tampered-CSR test; requires thex509-parserfeature).hostId/spiffe/serial/notAfteronly) — asserted structurally on both response shapes.authMiddleware(session oroc_key); the internal mint route uses a constant-timeX-Gateway-Secretcheck that fails closed on an empty/unset secret.host_id/spiffe_uriare server-derived; renewal byhostIdis a single tenant-scopedwhereclause (no cross-tenant IDOR — dedicated test).GATEWAY_CLIENT_CAdisables minting with a 503, never a silent downgrade. 16KB CSR cap at both layers; 24h default / 7d max lifetime.API contract
POST /v1/gateway/client-cert→ req{ label?, hostId?, csrPem }→{ identity, hostId, certPem, caPem, serial, notAfter }(no key).hostIdpresent = renewal (reuse the host's identity); absent = new host.Testing
cargo clippy --all-targets -- -D warnings,cargo fmt --check, webcheck-types/lint/prettierall clean.Notes for reviewers
cargo build --features cloudfails with 27 pre-existing errors in theee/*.rsstubs on this branch (3 surface on unchangedgateway.rscontext lines due to the brokeneeimports they reference) — reproduced on the base commit; none originate in this PR's code. Validated on the OSS build.https://claude.ai/code/session_01BgJuqEJqf7ZiUdqHWxi6bt