// 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:." and // returns the 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:. 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 }