Skip to content

feat(gateway,api): CSR-based client-certificate issuance (Phase 2) - #3

Open
marcorivm wants to merge 1 commit into
worktree-agent-abc6d62ceacdb6f8cfrom
phase2/client-cert-issuance
Open

feat(gateway,api): CSR-based client-certificate issuance (Phase 2)#3
marcorivm wants to merge 1 commit into
worktree-agent-abc6d62ceacdb6f8cfrom
phase2/client-cert-issuance

Conversation

@marcorivm

Copy link
Copy Markdown
Member

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.

  • Gateway: new client_ca_authority.rsClientCa load-or-generate (0600 key), and sign_csr. New internal route POST /v1/internal/client-cert/issue behind an inbound X-Gateway-Secret guard.
  • Web API: POST /v1/gateway/client-cert (authenticated) resolves tenant from the auth context, ensures a per-host ClientHost row, forwards the CSR, and audits via withAudit.
  • DB: ClientHost model + migration (the host↔tenant mapping Phase 5 will enforce against).

Security properties

  • Identity is server-authoritative. sign_csr uses 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_use so misuse shows up in a diff). SAN (spiffe://onecli/host/<id>), CN, clientAuth EKU, is_ca=false, and validity are all set server-side from values the web API supplies. Signs via CertificateParams::signed_by(&public_key, …), never the CSR-copying CertificateSigningRequestParams::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.
  • Possession proof: rcgen's from_pem verifies the CSR self-signature (guarded by a tampered-CSR test; requires the x509-parser feature).
  • No private key is ever returned, logged, or placed in audit metadata (metadata is hostId/spiffe/serial/notAfter only) — asserted structurally on both response shapes.
  • Two independent auth boundaries: public route uses authMiddleware (session or oc_ key); the internal mint route uses a constant-time X-Gateway-Secret check that fails closed on an empty/unset secret.
  • Tenant isolation: host_id/spiffe_uri are server-derived; renewal by hostId is a single tenant-scoped where clause (no cross-tenant IDOR — dedicated test).
  • Fail-closed: client CA load/generate failure aborts startup; a cert-only (no key) GATEWAY_CLIENT_CA disables 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). hostId present = renewal (reuse the host's identity); absent = new host.

Testing

  • Gateway 613 tests, web API 1110 tests; cargo clippy --all-targets -- -D warnings, cargo fmt --check, web check-types/lint/prettier all clean.
  • Covers: identity-override (parsed X.509); IDOR renewal rejection; malformed/tampered/oversized/Ed25519 CSRs; no-key-in-response; internal-secret fail-closed incl. empty-header-vs-configured-secret; minted leaf verifies through Phase 1's verifier via real handshakes; external-DN CA chains correctly.

Notes for reviewers

  • cargo build --features cloud fails with 27 pre-existing errors in the ee/*.rs stubs on this branch (3 surface on unchanged gateway.rs context lines due to the broken ee imports they reference) — reproduced on the base commit; none originate in this PR's code. Validated on the OSS build.
  • Deferred follow-up (noted, not in this PR): rate-limiting the authenticated enroll route.
  • Pipeline: Opus plan (rcgen CSR-signing verified against source) → Sonnet dev → Opus security review (no blockers) → fixes → Sonnet QA (GO) → gap-closing pass.

https://claude.ai/code/session_01BgJuqEJqf7ZiUdqHWxi6bt

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
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.

1 participant