You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
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.
ssl_cert_username_field default cn (matches Dovecot auth_ssl_username_from_cert), with email (SAN rfc822) and dn as options.
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.
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
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)
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).
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.
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).
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):
internal/login/,app/yarilo-*-login) — director/backend (production) mode. Terminates client TLS (internal/login/server.go:266), then callsyarilo-authwith onlyusername+password. Advertises PLAIN/LOGIN + OAUTHBEARER/XOAUTH2 (internal/login/preamble.go:17-31). No SCRAM, no EXTERNAL today.internal/imap/server.go,internal/pop3/session.go,internal/submission/server.go) — standalone mode terminates TLS itself and runs the fullgo-sasldispatch (this is where SCRAM lives). Behind a login proxy it gets plain bytes over a Unix socket (no TLS knowledge).go-saslfork 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
ssl_cert_username_fielddefaultcn(matches Dovecotauth_ssl_username_from_cert), withemail(SAN rfc822) anddnas options.ClientAuth: VerifyClientCertIfGiven— NOTRequireAndVerifyClientCert, 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.PR-1 — foundation + standalone session-server EXTERNAL
Config (
pkg/config/config.go)AuthConfig.Mechanisms []string(~:940) — the explicitmechanisms: [...]enable knob from Phase AUTH-5: missing SASL mechs — EXTERNAL, CRAM-MD5, GSSAPI #245. EXTERNAL only advertises/dispatches when listed.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): whenssl_caset, load it intoClientCAsand setClientAuth: tls.VerifyClientCertIfGiven. Model:pkg/mtls/mtls.go:16-31(already doesClientCAs+ verify for internal mTLS).helm/values.yaml: expose the threessl:keys +auth.mechanisms.Cert → username helper (new, shared)
state.PeerCertificates[0], extracts CN / SAN-email / DN perssl_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): advertiseEXTERNALgated on (mech in config) AND (a client cert is present on the terminated conn).Authenticate(mech)(:732-803): addcase sasl.External:→sasl.NewExternalServer(cb), wherecbderives the username from the peer cert, enforces the authzid rule (decision 4), and callscompleteLogin.internal/pop3/session.go:202-231), Submission (internal/submission/server.go), ManageSieve.Tests (clone
internal/imap/oauthbearer_test.go/scram_test.goper surface)ssl_cais configured (decision 3 — easy to regress);ssl_capool →VerifyClientCertIfGivenrejects 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/)ExternalEnabledflag (or theMechanismsset) tologin.Options, propagated likeOAuth2Enableddrives advertising today.EXTERNALin the preamble caps: IMAP (preamble.go:17-31), POP3 (:234-237), SMTP/Submission (:378-384), ManageSieve (managesieve_preamble.go:18,32).case "EXTERNAL"to each dispatch (IMAPpreamble.go:129-197, POP3:263-314, SMTPhandleSMTPAuth :423-497, ManageSievemsHandleAuthenticate :124-164): pull the cert identity from the terminatedtls.Conn(server.go:266), apply the authzid rule, setpreamble.username, and use a password-less auth call.yarilo-auth wire (
internal/auth/protocol/protocol.go)MECH\tEXTERNALin the handshake (:1027-1029).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-1Userdbinterface (docs/AUTH_REVIEW.md:147-176).RequireAndVerifyClientCert,pkg/mtls), so the request must be rejected if it does not arrive over that authenticated internal channel.Tests
internal/login/*_preamble_test.go);internal/auth/protocol/*_test.go);app/smoketest-e2e/main.go(needs a client cert + CA-configured listener).Out of scope (later Phase AUTH-5 mechs / follow-ups)
go-saslyarilo-patchesbranch) and GSSAPI — separate mini-phases per Phase AUTH-5: missing SASL mechs — EXTERNAL, CRAM-MD5, GSSAPI #245.internal/sasllogin/) — only if Postfix ever needs cert auth.