mtls.go 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  1. // Package auth provides mTLS client-certificate verification for the
  2. // opt-in source-side mTLS feature (M14 W2, SPEC §15). HMAC + API key
  3. // are still the default; mTLS layers on top with an AND relationship
  4. // when source.mtls_required=true.
  5. //
  6. // The verifier wraps a *x509.CertPool (the intermediate CA that
  7. // issues source certs) and a callback for CN/SAN → source_id
  8. // resolution. It is consumed by ingestd's HTTP listener when the
  9. // source is configured for mTLS.
  10. //
  11. // Threading: Verifier is safe for concurrent use. The cert pool is
  12. // read-only after construction; rotation creates a new Verifier and
  13. // atomically swaps the pointer (see internal/auth/mtls_rotation.go,
  14. // not in this file).
  15. package auth
  16. import (
  17. "crypto/x509"
  18. "errors"
  19. "fmt"
  20. "strings"
  21. "sync"
  22. "time"
  23. )
  24. // timeNow is overridable in tests.
  25. var timeNow = time.Now
  26. // contains is a tiny helper to keep imports clean.
  27. func contains(s, substr string) bool {
  28. return strings.Contains(s, substr)
  29. }
  30. // SourceIDResolver maps a verified client cert to the source_id it
  31. // represents. Implementations typically look up the CN or SAN in
  32. // Postgres. The Verifier calls this after chain + expiry checks
  33. // succeed.
  34. //
  35. // Returning a non-nil error rejects the connection. The error is
  36. // logged by the caller and surfaced to the client as 401.
  37. type SourceIDResolver interface {
  38. // ResolveSourceID returns the source_id for a verified cert.
  39. // cert.Subject.CommonName is the CN, cert.DNSNames / cert.URIs
  40. // are the SANs.
  41. ResolveSourceID(cert *x509.Certificate) (sourceID string, err error)
  42. }
  43. // StaticSourceIDResolver is the simplest implementation: it accepts
  44. // any cert where the CN matches "source:<id>.<company_slug>" and
  45. // returns the <id> as the source_id. Suitable for testing and for
  46. // environments without a Postgres lookup. Production should use a
  47. // resolver that verifies the source_id is active in the DB.
  48. type StaticSourceIDResolver struct{}
  49. func (StaticSourceIDResolver) ResolveSourceID(cert *x509.Certificate) (string, error) {
  50. const prefix = "source:"
  51. if !strings.HasPrefix(cert.Subject.CommonName, prefix) {
  52. return "", fmt.Errorf("mtls: CN %q does not start with %q", cert.Subject.CommonName, prefix)
  53. }
  54. rest := cert.Subject.CommonName[len(prefix):]
  55. dot := strings.Index(rest, ".")
  56. if dot <= 0 {
  57. return "", fmt.Errorf("mtls: CN %q missing company_slug suffix", cert.Subject.CommonName)
  58. }
  59. return rest[:dot], nil
  60. }
  61. // Verifier checks a client cert against the intermediate CA pool and
  62. // resolves it to a source_id. Construct once at startup, then reuse
  63. // across all requests.
  64. type Verifier struct {
  65. // Intermediates is the pool of CA certs that sign source certs.
  66. // In production this is the intermediate from scripts/cert-manager/ca-init.sh.
  67. Intermediates *x509.CertPool
  68. // Resolver maps a verified cert to a source_id.
  69. Resolver SourceIDResolver
  70. // Optional: a list of revoked cert serials (in-memory CRL). For
  71. // production, use cert-manager's CRL or OCSP responder.
  72. revokedMu sync.RWMutex
  73. revoked map[string]struct{} // serial hex → {}
  74. }
  75. // NewVerifier constructs a Verifier with the given CA pool.
  76. // resolver must be non-nil.
  77. func NewVerifier(intermediates *x509.CertPool, resolver SourceIDResolver) *Verifier {
  78. if resolver == nil {
  79. resolver = StaticSourceIDResolver{}
  80. }
  81. return &Verifier{
  82. Intermediates: intermediates,
  83. Resolver: resolver,
  84. revoked: make(map[string]struct{}),
  85. }
  86. }
  87. // ErrCertExpired is returned when the client cert is past its
  88. // notAfter or before its notBefore.
  89. var ErrCertExpired = errors.New("mtls: client certificate expired or not yet valid")
  90. // ErrUntrustedIssuer is returned when the cert chain doesn't lead to
  91. // any cert in the Intermediates pool.
  92. var ErrUntrustedIssuer = errors.New("mtls: client certificate signed by untrusted CA")
  93. // ErrMissingClientUsage is returned when the cert doesn't have
  94. // ExtKeyUsageClientAuth set.
  95. var ErrMissingClientUsage = errors.New("mtls: client certificate missing ExtKeyUsageClientAuth")
  96. // ErrRevoked is returned when the cert serial is in the revoked set.
  97. var ErrRevoked = errors.New("mtls: client certificate revoked")
  98. // VerifyChain checks the cert against the intermediate pool. It does
  99. // NOT resolve to a source_id — call ResolveSourceID separately.
  100. // Returns nil on success, or one of the sentinel errors above.
  101. func (v *Verifier) VerifyChain(cert *x509.Certificate) error {
  102. if cert == nil {
  103. return errors.New("mtls: nil certificate")
  104. }
  105. // Check expiry first — cheap, no pool needed.
  106. now := timeNow()
  107. if now.Before(cert.NotBefore) || now.After(cert.NotAfter) {
  108. return fmt.Errorf("%w (notBefore=%s, notAfter=%s, now=%s)",
  109. ErrCertExpired, cert.NotBefore, cert.NotAfter, now)
  110. }
  111. // Build a chain of certs to verify. The peer cert + any
  112. // intermediates the caller passes (we don't have those here in
  113. // the basic Verifier — the caller wires the chain from the TLS
  114. // handshake).
  115. intermediates := x509.NewCertPool()
  116. // The trust anchor is the intermediate CA pool.
  117. opts := x509.VerifyOptions{
  118. Roots: v.Intermediates,
  119. Intermediates: intermediates,
  120. KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
  121. CurrentTime: now,
  122. }
  123. if _, err := cert.Verify(opts); err != nil {
  124. // x509 verification failures are not all the same. The Go
  125. // stdlib returns stringified errors (no sentinel), so we
  126. // match on the error message. For better matching, the
  127. // caller should use VerifyHostname + their own checks.
  128. msg := err.Error()
  129. if contains(msg, "expired") || contains(msg, "not yet valid") {
  130. return fmt.Errorf("%w: %s", ErrCertExpired, err)
  131. }
  132. return fmt.Errorf("%w: %s", ErrUntrustedIssuer, err)
  133. }
  134. // Check key usage explicitly. Verify() with KeyUsages set does
  135. // check this, but only if the cert has ExtKeyUsage at all — a
  136. // cert without ExtKeyUsage passes. We want to be strict for
  137. // client auth, so we double-check.
  138. hasClientAuth := false
  139. for _, usage := range cert.ExtKeyUsage {
  140. if usage == x509.ExtKeyUsageClientAuth {
  141. hasClientAuth = true
  142. break
  143. }
  144. }
  145. if !hasClientAuth {
  146. return ErrMissingClientUsage
  147. }
  148. // If ExtKeyUsage is empty AND no ExtKeyUsageServerAuth either, some
  149. // old certs rely on KeyUsage only. We accept KeyUsage=DigitalSignature
  150. // as a fallback.
  151. if len(cert.ExtKeyUsage) == 0 {
  152. if cert.KeyUsage&x509.KeyUsageDigitalSignature == 0 {
  153. return ErrMissingClientUsage
  154. }
  155. }
  156. // Check revocation list.
  157. v.revokedMu.RLock()
  158. _, isRevoked := v.revoked[cert.SerialNumber.Text(16)]
  159. v.revokedMu.RUnlock()
  160. if isRevoked {
  161. return ErrRevoked
  162. }
  163. return nil
  164. }
  165. // VerifyResult is the outcome of a successful Verify call.
  166. type VerifyResult struct {
  167. SourceID string
  168. CompanySlug string
  169. SerialHex string
  170. NotAfter time.Time
  171. }
  172. // Verify is the high-level entry point: chain check + resolve to
  173. // source_id. Returns a VerifyResult on success.
  174. func (v *Verifier) Verify(cert *x509.Certificate) (*VerifyResult, error) {
  175. if err := v.VerifyChain(cert); err != nil {
  176. return nil, err
  177. }
  178. sourceID, err := v.Resolver.ResolveSourceID(cert)
  179. if err != nil {
  180. return nil, err
  181. }
  182. // Company slug from CN: source:<id>.<company_slug>
  183. companySlug := ""
  184. if rest, ok := strings.CutPrefix(cert.Subject.CommonName, "source:"); ok {
  185. if dot := strings.Index(rest, "."); dot > 0 {
  186. companySlug = rest[dot+1:]
  187. }
  188. }
  189. return &VerifyResult{
  190. SourceID: sourceID,
  191. CompanySlug: companySlug,
  192. SerialHex: cert.SerialNumber.Text(16),
  193. NotAfter: cert.NotAfter,
  194. }, nil
  195. }
  196. // Revoke marks a cert serial as revoked. The next Verify call for a
  197. // cert with this serial returns ErrRevoked. Idempotent.
  198. func (v *Verifier) Revoke(serialHex string) {
  199. v.revokedMu.Lock()
  200. v.revoked[serialHex] = struct{}{}
  201. v.revokedMu.Unlock()
  202. }
  203. // Unrevoke removes a serial from the revoked set. Useful for
  204. // recovering from a misissued revoke.
  205. func (v *Verifier) Unrevoke(serialHex string) {
  206. v.revokedMu.Lock()
  207. delete(v.revoked, serialHex)
  208. v.revokedMu.Unlock()
  209. }
  210. // IsRevoked returns true if the serial is in the revoked set.
  211. func (v *Verifier) IsRevoked(serialHex string) bool {
  212. v.revokedMu.RLock()
  213. defer v.revokedMu.RUnlock()
  214. _, ok := v.revoked[serialHex]
  215. return ok
  216. }