| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241 |
- // Package auth provides mTLS client-certificate verification for the
- // opt-in source-side mTLS feature (M14 W2, SPEC §15). HMAC + API key
- // are still the default; mTLS layers on top with an AND relationship
- // when source.mtls_required=true.
- //
- // The verifier wraps a *x509.CertPool (the intermediate CA that
- // issues source certs) and a callback for CN/SAN → source_id
- // resolution. It is consumed by ingestd's HTTP listener when the
- // source is configured for mTLS.
- //
- // Threading: Verifier is safe for concurrent use. The cert pool is
- // read-only after construction; rotation creates a new Verifier and
- // atomically swaps the pointer (see internal/auth/mtls_rotation.go,
- // not in this file).
- package auth
- import (
- "crypto/x509"
- "errors"
- "fmt"
- "strings"
- "sync"
- "time"
- )
- // timeNow is overridable in tests.
- var timeNow = time.Now
- // contains is a tiny helper to keep imports clean.
- func contains(s, substr string) bool {
- return strings.Contains(s, substr)
- }
- // SourceIDResolver maps a verified client cert to the source_id it
- // represents. Implementations typically look up the CN or SAN in
- // Postgres. The Verifier calls this after chain + expiry checks
- // succeed.
- //
- // Returning a non-nil error rejects the connection. The error is
- // logged by the caller and surfaced to the client as 401.
- type SourceIDResolver interface {
- // ResolveSourceID returns the source_id for a verified cert.
- // cert.Subject.CommonName is the CN, cert.DNSNames / cert.URIs
- // are the SANs.
- ResolveSourceID(cert *x509.Certificate) (sourceID string, err error)
- }
- // StaticSourceIDResolver is the simplest implementation: it accepts
- // any cert where the CN matches "source:<id>.<company_slug>" and
- // returns the <id> as the source_id. Suitable for testing and for
- // environments without a Postgres lookup. Production should use a
- // resolver that verifies the source_id is active in the DB.
- type StaticSourceIDResolver struct{}
- func (StaticSourceIDResolver) ResolveSourceID(cert *x509.Certificate) (string, error) {
- const prefix = "source:"
- if !strings.HasPrefix(cert.Subject.CommonName, prefix) {
- return "", fmt.Errorf("mtls: CN %q does not start with %q", cert.Subject.CommonName, prefix)
- }
- rest := cert.Subject.CommonName[len(prefix):]
- dot := strings.Index(rest, ".")
- if dot <= 0 {
- return "", fmt.Errorf("mtls: CN %q missing company_slug suffix", cert.Subject.CommonName)
- }
- return rest[:dot], nil
- }
- // Verifier checks a client cert against the intermediate CA pool and
- // resolves it to a source_id. Construct once at startup, then reuse
- // across all requests.
- type Verifier struct {
- // Intermediates is the pool of CA certs that sign source certs.
- // In production this is the intermediate from scripts/cert-manager/ca-init.sh.
- Intermediates *x509.CertPool
- // Resolver maps a verified cert to a source_id.
- Resolver SourceIDResolver
- // Optional: a list of revoked cert serials (in-memory CRL). For
- // production, use cert-manager's CRL or OCSP responder.
- revokedMu sync.RWMutex
- revoked map[string]struct{} // serial hex → {}
- }
- // NewVerifier constructs a Verifier with the given CA pool.
- // resolver must be non-nil.
- func NewVerifier(intermediates *x509.CertPool, resolver SourceIDResolver) *Verifier {
- if resolver == nil {
- resolver = StaticSourceIDResolver{}
- }
- return &Verifier{
- Intermediates: intermediates,
- Resolver: resolver,
- revoked: make(map[string]struct{}),
- }
- }
- // ErrCertExpired is returned when the client cert is past its
- // notAfter or before its notBefore.
- var ErrCertExpired = errors.New("mtls: client certificate expired or not yet valid")
- // ErrUntrustedIssuer is returned when the cert chain doesn't lead to
- // any cert in the Intermediates pool.
- var ErrUntrustedIssuer = errors.New("mtls: client certificate signed by untrusted CA")
- // ErrMissingClientUsage is returned when the cert doesn't have
- // ExtKeyUsageClientAuth set.
- var ErrMissingClientUsage = errors.New("mtls: client certificate missing ExtKeyUsageClientAuth")
- // ErrRevoked is returned when the cert serial is in the revoked set.
- var ErrRevoked = errors.New("mtls: client certificate revoked")
- // VerifyChain checks the cert against the intermediate pool. It does
- // NOT resolve to a source_id — call ResolveSourceID separately.
- // Returns nil on success, or one of the sentinel errors above.
- func (v *Verifier) VerifyChain(cert *x509.Certificate) error {
- if cert == nil {
- return errors.New("mtls: nil certificate")
- }
- // Check expiry first — cheap, no pool needed.
- now := timeNow()
- if now.Before(cert.NotBefore) || now.After(cert.NotAfter) {
- return fmt.Errorf("%w (notBefore=%s, notAfter=%s, now=%s)",
- ErrCertExpired, cert.NotBefore, cert.NotAfter, now)
- }
- // Build a chain of certs to verify. The peer cert + any
- // intermediates the caller passes (we don't have those here in
- // the basic Verifier — the caller wires the chain from the TLS
- // handshake).
- intermediates := x509.NewCertPool()
- // The trust anchor is the intermediate CA pool.
- opts := x509.VerifyOptions{
- Roots: v.Intermediates,
- Intermediates: intermediates,
- KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
- CurrentTime: now,
- }
- if _, err := cert.Verify(opts); err != nil {
- // x509 verification failures are not all the same. The Go
- // stdlib returns stringified errors (no sentinel), so we
- // match on the error message. For better matching, the
- // caller should use VerifyHostname + their own checks.
- msg := err.Error()
- if contains(msg, "expired") || contains(msg, "not yet valid") {
- return fmt.Errorf("%w: %s", ErrCertExpired, err)
- }
- return fmt.Errorf("%w: %s", ErrUntrustedIssuer, err)
- }
- // Check key usage explicitly. Verify() with KeyUsages set does
- // check this, but only if the cert has ExtKeyUsage at all — a
- // cert without ExtKeyUsage passes. We want to be strict for
- // client auth, so we double-check.
- hasClientAuth := false
- for _, usage := range cert.ExtKeyUsage {
- if usage == x509.ExtKeyUsageClientAuth {
- hasClientAuth = true
- break
- }
- }
- if !hasClientAuth {
- return ErrMissingClientUsage
- }
- // If ExtKeyUsage is empty AND no ExtKeyUsageServerAuth either, some
- // old certs rely on KeyUsage only. We accept KeyUsage=DigitalSignature
- // as a fallback.
- if len(cert.ExtKeyUsage) == 0 {
- if cert.KeyUsage&x509.KeyUsageDigitalSignature == 0 {
- return ErrMissingClientUsage
- }
- }
- // Check revocation list.
- v.revokedMu.RLock()
- _, isRevoked := v.revoked[cert.SerialNumber.Text(16)]
- v.revokedMu.RUnlock()
- if isRevoked {
- return ErrRevoked
- }
- return nil
- }
- // VerifyResult is the outcome of a successful Verify call.
- type VerifyResult struct {
- SourceID string
- CompanySlug string
- SerialHex string
- NotAfter time.Time
- }
- // Verify is the high-level entry point: chain check + resolve to
- // source_id. Returns a VerifyResult on success.
- func (v *Verifier) Verify(cert *x509.Certificate) (*VerifyResult, error) {
- if err := v.VerifyChain(cert); err != nil {
- return nil, err
- }
- sourceID, err := v.Resolver.ResolveSourceID(cert)
- if err != nil {
- return nil, err
- }
- // Company slug from CN: source:<id>.<company_slug>
- companySlug := ""
- if rest, ok := strings.CutPrefix(cert.Subject.CommonName, "source:"); ok {
- if dot := strings.Index(rest, "."); dot > 0 {
- companySlug = rest[dot+1:]
- }
- }
- return &VerifyResult{
- SourceID: sourceID,
- CompanySlug: companySlug,
- SerialHex: cert.SerialNumber.Text(16),
- NotAfter: cert.NotAfter,
- }, nil
- }
- // Revoke marks a cert serial as revoked. The next Verify call for a
- // cert with this serial returns ErrRevoked. Idempotent.
- func (v *Verifier) Revoke(serialHex string) {
- v.revokedMu.Lock()
- v.revoked[serialHex] = struct{}{}
- v.revokedMu.Unlock()
- }
- // Unrevoke removes a serial from the revoked set. Useful for
- // recovering from a misissued revoke.
- func (v *Verifier) Unrevoke(serialHex string) {
- v.revokedMu.Lock()
- delete(v.revoked, serialHex)
- v.revokedMu.Unlock()
- }
- // IsRevoked returns true if the serial is in the revoked set.
- func (v *Verifier) IsRevoked(serialHex string) bool {
- v.revokedMu.RLock()
- defer v.revokedMu.RUnlock()
- _, ok := v.revoked[serialHex]
- return ok
- }
|