Skip to content

Phase AUTH-5: SASL EXTERNAL (certificate-based auth) — phased plan #615

Description

@0kaba0hub

Implementation plan for SASL EXTERNAL (RFC 4422 §A), the first mech of Phase AUTH-5 (parent: #245). Identity is derived from the verified TLS client certificate; there is no password to compare.

Architecture (why it splits the way it does)

yarilo has two independent TLS-terminating surfaces, and EXTERNAL must live wherever the client TLS is terminated (that is the only place the peer cert is reachable):

  • Login-proxy (internal/login/, app/yarilo-*-login) — director/backend (production) mode. Terminates client TLS (internal/login/server.go:266), then calls yarilo-auth with only username+password. Advertises PLAIN/LOGIN + OAUTHBEARER/XOAUTH2 (internal/login/preamble.go:17-31). No SCRAM, no EXTERNAL today.
  • Session-server (internal/imap/server.go, internal/pop3/session.go, internal/submission/server.go) — standalone mode terminates TLS itself and runs the full go-sasl dispatch (this is where SCRAM lives). Behind a login proxy it gets plain bytes over a Unix socket (no TLS knowledge).

go-sasl fork already ships the server: sasl.External + sasl.NewExternalServer(func(identity string) error) (0kaba0hub/go-sasl .../external.go). No fork change needed — we wire the existing server into the dispatch switches. Current client-facing TLS does not request client certs (config.BuildTLSConfig, pkg/config/config.go:1590-1612) — that plumbing is the main new work.

Confirmed decisions

  1. Two PRs. PR-1 (foundation + standalone session-server) is self-contained, fully unit-testable, and changes no wire protocol. PR-2 (login-proxy + yarilo-auth password-less path) touches the auth service, where the cost of a mistake is higher (authentication asserted over the network without a password). PR-1 is not "half a feature": for a self-contained (standalone) topology, standalone EXTERNAL is the complete, production-usable mechanism.
  2. ssl_cert_username_field default cn (matches Dovecot auth_ssl_username_from_cert), with email (SAN rfc822) and dn as options.
  3. ClientAuth: VerifyClientCertIfGiven — NOT RequireAndVerifyClientCert, which would break every existing PLAIN/OAUTHBEARER/SCRAM connection on the same listener (the TLS handshake would fail before SASL even starts). This is the only mode that lets cert and password mechs coexist.
  4. authzid v1: accept an empty authzid (= use the cert identity) or one equal to the cert identity; explicitly reject a non-empty authzid that differs (do not silently ignore it — an ignored authzid is a quiet RFC-4422 §3.4.1 violation / authz hole). Full authzid-override (master-user-style, cert authorizing another identity) is a later phase.

PR-1 — foundation + standalone session-server EXTERNAL

Config (pkg/config/config.go)

  • Add AuthConfig.Mechanisms []string (~:940) — the explicit mechanisms: [...] enable knob from Phase AUTH-5: missing SASL mechs — EXTERNAL, CRAM-MD5, GSSAPI #245. EXTERNAL only advertises/dispatches when listed.
  • Add to SSLConfig (:299-306): ssl_ca (PEM CA bundle for client-cert verification), ssl_verify_client (off | if_given), ssl_cert_username_field (cn | email | dn).
  • BuildTLSConfig (:1590-1612): when ssl_ca set, load it into ClientCAs and set ClientAuth: tls.VerifyClientCertIfGiven. Model: pkg/mtls/mtls.go:16-31 (already does ClientCAs + verify for internal mTLS).
  • helm/values.yaml: expose the three ssl: keys + auth.mechanisms.

Cert → username helper (new, shared)

  • Reads state.PeerCertificates[0], extracts CN / SAN-email / DN per ssl_cert_username_field. Reused by both surfaces. Model: tlsExporter() (internal/imap/server.go:820-838) for reaching the *tls.Conn.

Session-server dispatch (standalone)

  • AuthenticateMechanisms() (internal/imap/server.go:701-720): advertise EXTERNAL gated on (mech in config) AND (a client cert is present on the terminated conn).
  • Authenticate(mech) (:732-803): add case sasl.External:sasl.NewExternalServer(cb), where cb derives the username from the peer cert, enforces the authzid rule (decision 4), and calls completeLogin.
  • Mirror in POP3 (internal/pop3/session.go:202-231), Submission (internal/submission/server.go), ManageSieve.

Tests (clone internal/imap/oauthbearer_test.go / scram_test.go per surface)

  • advertise-when-enabled-and-cert-present / not-advertised-when-disabled / not-advertised-without-cert;
  • success: cert identity → matching user logs in;
  • authzid mismatch (non-empty != cert identity) → reject (decision 4);
  • coexistence regression test: a client with no cert still completes PLAIN on a listener where ssl_ca is configured (decision 3 — easy to regress);
  • negative test: a cert present but not signed by the ssl_ca pool → VerifyClientCertIfGiven rejects at the TLS layer; assert it surfaces as a clean SASL/auth reject, not a bare TCP drop (usability for the CI/automation use-case Phase AUTH-5: missing SASL mechs — EXTERNAL, CRAM-MD5, GSSAPI #245 targets).

PR-2 — login-proxy + yarilo-auth password-less path (production/director)

Login proxy (internal/login/)

  • Add an ExternalEnabled flag (or the Mechanisms set) to login.Options, propagated like OAuth2Enabled drives advertising today.
  • Advertise EXTERNAL in the preamble caps: IMAP (preamble.go:17-31), POP3 (:234-237), SMTP/Submission (:378-384), ManageSieve (managesieve_preamble.go:18,32).
  • Add case "EXTERNAL" to each dispatch (IMAP preamble.go:129-197, POP3 :263-314, SMTP handleSMTPAuth :423-497, ManageSieve msHandleAuthenticate :124-164): pull the cert identity from the terminated tls.Conn (server.go:266), apply the authzid rule, set preamble.username, and use a password-less auth call.

yarilo-auth wire (internal/auth/protocol/protocol.go)

  • Advertise MECH\tEXTERNAL in the handshake (:1027-1029).
  • Branch in handleAuth (:1077) for EXTERNAL: a userdb-style lookup (user exists + active), no password compare — trusting the cert verification the proxy already performed at TLS level. Ties into the AUTH-1 Userdb interface (docs/AUTH_REVIEW.md:147-176).
  • Security note for review: the proxy asserts an authenticated identity to yarilo-auth without a shared secret — the trust boundary is the proxy↔auth mTLS link (already RequireAndVerifyClientCert, pkg/mtls), so the request must be rejected if it does not arrive over that authenticated internal channel.

Tests

  • login-proxy preamble tests (advertise/dispatch) per protocol (internal/login/*_preamble_test.go);
  • auth-service wire test for the EXTERNAL branch (internal/auth/protocol/*_test.go);
  • mTLS smoke check in app/smoketest-e2e/main.go (needs a client cert + CA-configured listener).

Out of scope (later Phase AUTH-5 mechs / follow-ups)

  • Full authzid override (cert authorizing a different identity, master-user semantics).
  • CRAM-MD5 (server impl staged in the go-sasl yarilo-patches branch) and GSSAPI — separate mini-phases per Phase AUTH-5: missing SASL mechs — EXTERNAL, CRAM-MD5, GSSAPI #245.
  • Postfix SASL path (internal/sasllogin/) — only if Postfix ever needs cert auth.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions