Files
certctl/internal/cms/channelbinding.go
T
shankar0123 aa139ee0d9 EST RFC 7030 hardening master bundle Phases 2-4: end-to-end mTLS sibling
route + RFC 9266 channel binding + HTTP Basic enrollment-password +
per-source-IP failed-auth limit + per-(CN, sourceIP) sliding-window cap.

Two new shared packages so EST + Intune share infrastructure:
- internal/cms/ — RFC 9266 tls-exporter extractor (ExtractTLSExporter
  with stdlib-panic recovery for synthetic ConnectionStates) +
  CSR-side channel-binding parser via raw TBSCertificationRequestInfo
  walk (the stdlib's csr.Attributes can't represent the OCTET STRING
  binding value), VerifyChannelBinding composite, EmbedChannel-
  BindingAttribute fixture helper, typed sentinel errors for missing
  / mismatch / not-TLS-1.3 mapped to HTTP 400 / 409 / 426 in handler.
- internal/trustanchor/ — extracted from scep/intune/trust_anchor*.go
  so the EST mTLS sibling route + Intune dispatcher share the same
  SIGHUP-reloadable PEM bundle primitive. intune.TrustAnchorHolder
  is now `= trustanchor.Holder` (type alias) + NewTrustAnchorHolder =
  trustanchor.New (function alias) — every existing call site compiles
  unchanged. Intune's LoadTrustAnchor is a thin wrapper over
  trustanchor.LoadBundle. White-box tests moved to the new package.
- internal/ratelimit/ — extracted from scep/intune/rate_limit.go (this
  was Phase 4.1, in the same bundle). intune.PerDeviceRateLimiter
  is now a thin wrapper preserving the (subject, issuer)→key
  composition; EST handler reaches for SlidingWindowLimiter directly.

ESTHandler grew six optional fields wired by per-profile setters
(SetMTLSTrust / SetChannelBindingRequired / SetEnrollmentPassword /
SetSourceIPRateLimiter / SetPerPrincipalRateLimiter / SetLabelForLog)
plus four new mTLS-route methods (CACertsMTLS / SimpleEnrollMTLS /
SimpleReEnrollMTLS / CSRAttrsMTLS); shared internal pipeline
handleEnrollOrReEnroll(reEnroll, viaMTLS) keeps the auth/binding/
rate-limit gates DRY. New router method RegisterESTMTLSHandlers
registers /.well-known/est-mtls/<PathID>/{cacerts,simpleenroll,
simplereenroll,csrattrs}; AuthExemptDispatchPrefixes extends the
no-auth chain to /.well-known/est-mtls.

cmd/server/main.go's EST loop wires per-profile mTLS holder +
channel-binding policy + per-principal limiter + (when EnrollmentPassword
non-empty) Basic + source-IP limiter; new preflightESTMTLSClientCATrust-
Bundle returns *trustanchor.Holder so SIGHUP rotates the EST mTLS
bundle live without restart. SCEP + EST mTLS profiles now share a
single union mtlsUnionPoolForTLS passed to buildServerTLSConfigWithMTLS
(replaces the protocol-specific scepMTLSUnionPoolForTLS); per-handler
re-verify enforces "cert must chain to THIS profile's bundle" so
cross-protocol bleed is blocked at the application layer even though
the TLS layer trusts certs from either pool's union.

Phase 3.3 source-IP failed-Basic limiter defaults: 10 attempts / 1h
/ 50k tracked IPs (no env var; tunable in a follow-up). Phase 4.2
per-principal limiter cap from CERTCTL_EST_PROFILE_<NAME>_RATE_
LIMIT_PER_PRINCIPAL_24H (existing field, Phase 1 shipped).

New tests:
- internal/cms/channelbinding_test.go: extractor + CSR-side parser +
  composite + TLS-1.3 round-trip end-to-end + EmbedChannelBinding-
  Attribute round-trip
- internal/trustanchor/holder_test.go: parseBundlePEM white-box +
  LoadBundle + Holder Get/Pool/SetLabelForLog/Reload-happy/
  Reload-keeps-old-on-failure/Reload-keeps-old-on-expired/
  WatchSIGHUP-reloads-pool/WatchSIGHUP-stop-clean
- internal/api/handler/est_hardening_test.go: 16 named cases covering
  mTLS no-trust-pool 500 + no-cert 401 + cross-profile cert 401 +
  happy-path 200 + CACertsMTLS auth gate + CSRAttrsMTLS auth gate +
  channel-binding required-absent-rejected + not-required-absent-
  allowed + writeChannelBindingError mapping + Basic no-header 401
  + Basic wrong-password 401 + Basic correct-200 + Basic-no-password
  no-gate + per-IP failed-attempt lockout 429 + per-principal
  blocks-after-cap + different-principals-independent + no-limiter-
  unbounded.

Pre-commit verification (sandbox): gofmt clean, go vet clean
(excluding repository/postgres which the sandbox can't build —
disk-space testcontainers download), staticcheck clean for
cms/trustanchor/api/handler/api/router/scep/intune/ratelimit/
cmd/server, go test -short -count=1 green for cms/trustanchor/
api/handler/api/router/scep/intune/ratelimit/service. G-3
docs-drift guard reproduced locally clean (Phase 1 already
documented every new env var; Phases 2-4 added zero new env vars).
2026-04-29 23:15:35 +00:00

370 lines
15 KiB
Go

// Package cms implements the small subset of CMS / RFC 7030 / RFC 9266
// helpers that the EST handler needs at request-time: extracting the
// RFC 9266 tls-exporter from a *tls.ConnectionState, and pulling the
// matching value back out of an EST CSR's CMC unsignedAttribute when the
// device proved channel binding.
//
// Why a separate package (vs adding to internal/api/handler/est.go):
//
// 1. internal/api/handler depends on internal/pkcs7 already; if the EST
// mTLS hardening also pulled CMC parsing into handler we'd grow the
// handler-side dep graph by another asn1 surface that has nothing
// specific to HTTP.
//
// 2. Channel-binding extraction is testable in isolation — the unit
// tests construct a *tls.ConnectionState with raw exporter bytes and
// a *x509.CertificateRequest with the CMC unsignedAttribute already
// filled in. No HTTP plumbing required to verify the contract.
//
// 3. Future EST extensions (RFC 7030 §3.5 fullCMC, RFC 9148 EST-coaps)
// are likely to land here too — keep them out of net/http land.
//
// EST RFC 7030 hardening master bundle Phase 2.4.
package cms
import (
"bytes"
"crypto/subtle"
"crypto/tls"
"crypto/x509"
"encoding/asn1"
"errors"
"fmt"
)
// ----- RFC 9266 §3 — TLS exporter extraction -----
// TLSExporterLabel is the EXPORTER label registered by RFC 9266 §3.1
// for use as a TLS-1.3 channel binding. Constant rather than string-typed
// so a typo here is a compile error rather than a silent failure mode.
const TLSExporterLabel = "EXPORTER-Channel-Binding"
// TLSExporterLength is the 32-byte exporter length pinned by RFC 9266 §3.1
// (matches the SHA-256 output size; clients and servers MUST agree on the
// length to make the comparison meaningful).
const TLSExporterLength = 32
// ErrChannelBindingMissing is returned when the EST mTLS handler requires
// channel binding (per-profile ChannelBindingRequired=true) but the device's
// CSR has no id-aa-est-tls-exporter unsignedAttribute or the attribute is
// the wrong shape.
var ErrChannelBindingMissing = errors.New("cms: channel binding required but absent or malformed in CSR")
// ErrChannelBindingMismatch is returned when the device's CSR carried a
// channel-binding attribute but its bytes do not match the TLS-1.3 exporter
// extracted from the live connection. This is the signal of an MITM that
// terminates TLS in front of certctl: the device computed exporter X
// against the attacker, certctl sees exporter Y against itself, X≠Y.
var ErrChannelBindingMismatch = errors.New("cms: channel binding in CSR does not match TLS exporter")
// ErrChannelBindingNotTLS13 is returned when the connection is older than
// TLS 1.3 and the per-profile config still requires channel binding.
// RFC 9266's tls-exporter is a TLS-1.3 binding; pre-1.3 connections would
// need RFC 5929 tls-unique, which we deliberately don't support
// (certctl pins TLS-1.3 server-side).
var ErrChannelBindingNotTLS13 = errors.New("cms: tls-exporter channel binding requires TLS 1.3")
// ExtractTLSExporter pulls the 32-byte RFC 9266 channel-binding value from
// the TLS connection state. The connection must be TLS 1.3 + handshake-
// complete; anything else returns a typed error so the caller can map to
// HTTP 400 / 412 cleanly.
//
// Stateless on purpose: callers handle storage + comparison.
//
// Robustness note: stdlib's ConnectionState.ExportKeyingMaterial nil-derefs
// when the underlying secret-derivation closure is unset (i.e. the state
// was hand-constructed by a test fixture rather than produced by a real
// TLS handshake). The recover() below converts that panic into the same
// typed error a missing-binding state would surface, so synthetic test
// states + production TLS-1.3 connections share a single failure mode.
func ExtractTLSExporter(state *tls.ConnectionState) (out []byte, err error) {
if state == nil {
return nil, fmt.Errorf("%w: nil ConnectionState", ErrChannelBindingMissing)
}
if !state.HandshakeComplete {
return nil, fmt.Errorf("%w: handshake incomplete", ErrChannelBindingMissing)
}
// tls.VersionTLS13 == 0x0304. We use the literal so this package doesn't
// have to import "crypto/tls" twice (once for tls.VersionTLS13, once for
// the *tls.ConnectionState type — Go allows it but it's noisy).
if state.Version < 0x0304 {
return nil, fmt.Errorf("%w: negotiated 0x%04x", ErrChannelBindingNotTLS13, state.Version)
}
defer func() {
if r := recover(); r != nil {
out = nil
err = fmt.Errorf("%w: ExportKeyingMaterial unavailable on this connection state (panic=%v)", ErrChannelBindingMissing, r)
}
}()
out, err = state.ExportKeyingMaterial(TLSExporterLabel, nil, TLSExporterLength)
if err != nil {
return nil, fmt.Errorf("cms: ExportKeyingMaterial: %w", err)
}
if len(out) != TLSExporterLength {
return nil, fmt.Errorf("cms: exporter returned %d bytes, want %d", len(out), TLSExporterLength)
}
return out, nil
}
// ----- RFC 7030 §3.5 / RFC 9266 §4.1 — CSR-side channel binding -----
// OIDChannelBindingTLSExporter is the id-aa-est-tls-exporter OID from
// RFC 9266 §4.1 (registered under id-aa = 1.2.840.113549.1.9.16.2 with
// arc 56 by RFC 9266). Devices that signed channel binding into their
// CSR add a CMC unsignedAttribute with this OID + an OCTET STRING value.
//
// Note: the EST RFC 7030 §3.5 historical OID for tls-unique is
// id-aa-cmc-binding (1.2.840.113549.1.9.16.2.43). RFC 9266 §4.1 added
// arc 56 for tls-exporter. We accept BOTH OIDs on the read path so a
// device using a slightly older library that still emits the §3.5 OID
// continues to work — the value bytes are still the 32-byte exporter
// (the OID identifies the binding scheme, not the underlying wire
// format).
var (
OIDChannelBindingTLSExporter = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 16, 2, 56}
OIDCMCEnrollmentBinding = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 16, 2, 43}
)
// ExtractCSRChannelBinding looks for the RFC 9266 channel-binding
// attribute (or the legacy RFC 7030 §3.5 binding attribute) in the CSR's
// raw attributes block. Returns the raw 32-byte exporter value if
// present.
//
// Why we walk csr.RawTBSCertificateRequest manually instead of using
// csr.Attributes:
//
// - csr.Attributes is typed as []pkix.AttributeTypeAndValueSET, where
// the inner Value is [][]pkix.AttributeTypeAndValue. That shape only
// fits attributes whose AttributeValue is itself a SEQUENCE { OID,
// ANY } (e.g. the requestedExtensions attribute). RFC 9266's
// TLSExporterValue is `OCTET STRING` — a primitive, not a SEQUENCE
// — so the stdlib parse path either drops the attribute silently or
// fails the whole CSR parse depending on encoding.
//
// - The PKCS#10 challengePassword path in scep.go works by accident
// because PrintableString happens to round-trip through the
// stdlib's interface{}-typed AttributeTypeAndValue.Value. OCTET
// STRING does not — it's not in the small list of primitive types
// the stdlib's reflect-based unmarshaller handles for `any`.
//
// - Walking the raw TBS is ~30 lines of asn1.Unmarshal calls and
// gives us a stable contract independent of stdlib quirks.
//
// Returns (value, true, nil) on success; (nil, false, nil) when the
// attribute is absent (caller decides whether absence is acceptable per
// the per-profile ChannelBindingRequired flag); (nil, false, err) on
// malformed attribute (always fatal — a present-but-wrong attribute
// signals an attacker rewriting the binding into garbage).
func ExtractCSRChannelBinding(csr *x509.CertificateRequest) ([]byte, bool, error) {
if csr == nil {
return nil, false, fmt.Errorf("cms: nil CSR")
}
if len(csr.RawTBSCertificateRequest) == 0 {
// Stdlib fills RawTBSCertificateRequest on every parse path, so an
// empty value here means the caller hand-crafted the struct. Tests
// can do that — but real handler-side calls always have raw bytes.
return nil, false, nil
}
return walkCSRAttributesForBinding(csr.RawTBSCertificateRequest)
}
// walkCSRAttributesForBinding parses just enough of TBSCertificationRequestInfo
// to reach the [0] IMPLICIT Attributes field, then iterates each Attribute
// looking for the channel-binding OID. The body is intentionally low-level
// so we can keep the asn1 footprint contained to this one helper.
//
// TBSCertificationRequestInfo per RFC 2986 §4.1:
//
// TBSCertificationRequestInfo ::= SEQUENCE {
// version INTEGER (0),
// subject Name,
// subjectPKInfo SubjectPublicKeyInfo,
// attributes [0] IMPLICIT Attributes (SET OF Attribute)
// }
func walkCSRAttributesForBinding(tbs []byte) ([]byte, bool, error) {
// 1. Crack the outer SEQUENCE wrapper.
var inner asn1.RawValue
if rest, err := asn1.Unmarshal(tbs, &inner); err != nil {
return nil, false, fmt.Errorf("cms: TBS outer parse: %w", err)
} else if len(rest) > 0 {
return nil, false, fmt.Errorf("cms: TBS trailing bytes: %d", len(rest))
}
if inner.Tag != asn1.TagSequence {
return nil, false, fmt.Errorf("cms: TBS outer tag %d not SEQUENCE", inner.Tag)
}
rest := inner.Bytes
// 2. Skip version (INTEGER), subject (SEQUENCE = Name), subjectPKInfo
// (SEQUENCE). asn1.Unmarshal into asn1.RawValue advances the cursor
// without parsing the body — perfect for skipping fields we don't care
// about.
for i, label := range []string{"version", "subject", "subjectPKInfo"} {
var rv asn1.RawValue
next, err := asn1.Unmarshal(rest, &rv)
if err != nil {
return nil, false, fmt.Errorf("cms: skip TBS field %d (%s): %w", i, label, err)
}
rest = next
}
// 3. Attributes is [0] IMPLICIT — the on-wire tag is 0xA0 with class
// CONTEXT-SPECIFIC. asn1.Unmarshal into a RawValue accepts arbitrary
// tags; we then walk its Bytes as a SET OF Attribute.
var attrsField asn1.RawValue
if _, err := asn1.Unmarshal(rest, &attrsField); err != nil {
// No attributes block at all — RFC 2986 says [0] is OPTIONAL when
// empty (encoders typically omit the field rather than emit an
// empty SET). Treat as "no binding present", not as an error.
return nil, false, nil
}
if attrsField.Class != asn1.ClassContextSpecific || attrsField.Tag != 0 {
// Some non-attribute-shaped trailing field: not what we expected
// but not strictly a corruption signal — skip silently.
return nil, false, nil
}
// 4. Walk each Attribute in the SET. Each Attribute is
// SEQUENCE { OID, SET OF ANY }.
attrBytes := attrsField.Bytes
for len(attrBytes) > 0 {
var oneAttr asn1.RawValue
next, err := asn1.Unmarshal(attrBytes, &oneAttr)
if err != nil {
return nil, false, fmt.Errorf("cms: walk attributes: %w", err)
}
attrBytes = next
if oneAttr.Tag != asn1.TagSequence {
continue
}
// Inner: OID, then SET.
var oid asn1.ObjectIdentifier
afterOID, err := asn1.Unmarshal(oneAttr.Bytes, &oid)
if err != nil {
continue
}
if !oid.Equal(OIDChannelBindingTLSExporter) && !oid.Equal(OIDCMCEnrollmentBinding) {
continue
}
// Now afterOID is the SET wrapper. Crack it and pull the OCTET
// STRING out of the SET's first element.
var setWrap asn1.RawValue
if _, err := asn1.Unmarshal(afterOID, &setWrap); err != nil {
return nil, false, fmt.Errorf("cms: binding SET parse: %w (%w)", err, ErrChannelBindingMissing)
}
if setWrap.Tag != asn1.TagSet {
return nil, false, fmt.Errorf("cms: binding outer tag %d not SET (%w)", setWrap.Tag, ErrChannelBindingMissing)
}
var octet asn1.RawValue
if _, err := asn1.Unmarshal(setWrap.Bytes, &octet); err != nil {
return nil, false, fmt.Errorf("cms: binding inner parse: %w (%w)", err, ErrChannelBindingMissing)
}
if octet.Tag != asn1.TagOctetString {
return nil, false, fmt.Errorf("cms: binding inner tag %d not OCTET STRING (%w)", octet.Tag, ErrChannelBindingMissing)
}
if len(octet.Bytes) != TLSExporterLength {
return nil, false, fmt.Errorf("cms: binding length %d, want %d (%w)",
len(octet.Bytes), TLSExporterLength, ErrChannelBindingMissing)
}
return octet.Bytes, true, nil
}
return nil, false, nil
}
// VerifyChannelBinding is the convenience composite the EST mTLS handler
// calls per request: extract the exporter from the live TLS connection,
// pull the matching value from the CSR, compare in constant time.
//
// Returns:
// - nil when the binding is present + matches.
// - ErrChannelBindingMissing when the CSR has no binding attribute.
// - ErrChannelBindingMismatch when both sides have a value but they
// differ (the MITM signal).
// - Any error from the exporter extraction (TLS state is wrong, etc).
//
// The required flag controls absence-handling: when required=false a
// missing attribute returns nil (channel binding is optional for this
// profile); when required=true a missing attribute returns
// ErrChannelBindingMissing.
func VerifyChannelBinding(state *tls.ConnectionState, csr *x509.CertificateRequest, required bool) error {
live, err := ExtractTLSExporter(state)
if err != nil {
// If the profile doesn't require channel binding AND the only
// problem is "no TLS 1.3 / no handshake", we still let the request
// through — the binding is opt-in per profile. But if the CSR
// itself carries a binding attribute, the device clearly INTENDED
// to bind, so a TLS state mismatch is a genuine error.
if !required {
if _, present, _ := ExtractCSRChannelBinding(csr); !present {
return nil
}
}
return err
}
csrBinding, present, err := ExtractCSRChannelBinding(csr)
if err != nil {
return err
}
if !present {
if required {
return ErrChannelBindingMissing
}
return nil
}
if subtle.ConstantTimeCompare(live, csrBinding) != 1 {
return ErrChannelBindingMismatch
}
// Sanity: the comparison should be identical bytes for matching cases.
// The bytes.Equal call is dead code under correct subtle.Compare result;
// it's here only to make the contract obvious to readers and to pin the
// symmetry test that asserts ExtractCSRChannelBinding is byte-equivalent
// to ExtractTLSExporter when the device behaved correctly.
if !bytes.Equal(live, csrBinding) {
return ErrChannelBindingMismatch
}
return nil
}
// EmbedChannelBindingAttribute is the test helper inverse of
// ExtractCSRChannelBinding: given an exporter value, returns the DER
// bytes of the Attribute (SEQUENCE { OID, SET { OCTET STRING } }) that
// the caller can splice into the [0] IMPLICIT Attributes field of
// TBSCertificationRequestInfo. Used by the EST channel-binding tests
// AND by any external caller that wants to forge a CSR with a known
// binding for fixture generation.
func EmbedChannelBindingAttribute(exporter []byte) ([]byte, error) {
if len(exporter) != TLSExporterLength {
return nil, fmt.Errorf("cms: exporter length %d, want %d", len(exporter), TLSExporterLength)
}
octet, err := asn1.Marshal(exporter) // marshal []byte as OCTET STRING
if err != nil {
return nil, fmt.Errorf("cms: marshal exporter octet: %w", err)
}
// Wrap in SET OF.
setBody := octet
setEnvelope, err := asn1.Marshal(asn1.RawValue{
Class: asn1.ClassUniversal,
Tag: asn1.TagSet,
IsCompound: true,
Bytes: setBody,
})
if err != nil {
return nil, fmt.Errorf("cms: marshal SET: %w", err)
}
oid, err := asn1.Marshal(OIDChannelBindingTLSExporter)
if err != nil {
return nil, fmt.Errorf("cms: marshal OID: %w", err)
}
// Wrap as SEQUENCE { OID, SET }.
seqBody := append(append([]byte{}, oid...), setEnvelope...)
seqEnvelope, err := asn1.Marshal(asn1.RawValue{
Class: asn1.ClassUniversal,
Tag: asn1.TagSequence,
IsCompound: true,
Bytes: seqBody,
})
if err != nil {
return nil, fmt.Errorf("cms: marshal SEQUENCE: %w", err)
}
return seqEnvelope, nil
}