An SMTP client and server library for Zig implementing RFC 5321.
0

Configure Feed

Select the types of activity you want to include in your feed.

Take the AUTH mechanisms from zig-sasl

PLAIN, LOGIN and CRAM-MD5 are gone from the client. They were three of
the four copies in this tree -- zig-pop3 has its own, an IMAP library
would have made a third, and zig-scram's SCRAM was reachable from none of
them. They live in zig-sasl now, re-exported here as `zsmtp.sasl` so a
caller does not need a second import to say `sasl.Plain`.

What is left behind is the part that was ever specific to SMTP, and it is
one loop: the AUTH command, the 334 challenges, the `*` that cancels, the
235 that ends it. `authenticate` takes a `sasl.Client` and drives it.
`Extensions.auth` is now the mechanism names as the server sent them,
which is what `sasl.Client.selectFromList` reads -- so the preference
order and the don't-send-a-password-in-the-clear rule are one
implementation instead of one per protocol.

Two things the old code could not express now work. A mechanism may
answer a challenge with nothing, which is how SCRAM acknowledges the
server's proof and how XOAUTH2 acknowledges a failure report. And
`error.ServerNotAuthenticated` is the server reporting success while the
mechanism says it never finished proving what it set out to -- for SCRAM,
a peer that took the client's proof and offered none of its own, which is
precisely what something in the middle without the verifier would do.
Nothing here could tell that from a real success before.

A mechanism failing mid-exchange now cancels with `*` and reads the 501,
rather than leaving the server waiting for a line that is not coming.

The server side is unchanged: it still implements PLAIN and LOGIN itself,
against a plaintext password, because zig-sasl's server side does not yet
reach past PLAIN. That is the next thing.

Verified against postfix, exim and dovecot: the whole interop suite
passes, including AUTH PLAIN and LOGIN to exim over both a cleartext
opt-in and STARTTLS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SDrB41sGu5k1ubD1ufbxqC

+508 -371
+57 -29
README.md
··· 159 159 160 160 ### Authentication 161 161 162 - `hello` reports the server's advertised mechanisms in `extensions.auth`; 163 - `authenticate` picks the best one, or use `authPlain`/`authLogin`/ 164 - `authCramMd5` directly. A 535 rejection surfaces as 165 - `error.AuthenticationFailed` with the reply in `last_reply`. 162 + The mechanisms themselves live in 163 + [zig-sasl](https://git.jcollie.dev/jeff/zig-sasl), re-exported here as 164 + `zsmtp.sasl`, because nothing about PLAIN or CRAM-MD5 or XOAUTH2 is specific 165 + to SMTP — POP3 and IMAP want the same ones, and one implementation of each is 166 + better than three. What is specific to SMTP is `authenticate`: the `AUTH` 167 + command, the 334 challenges, the `*` that cancels, and the 235 that ends it. 168 + 169 + `hello` reports the server's advertised mechanism names in `extensions.auth`, 170 + exactly as it sent them, for `sasl.Client.selectFromList`: 166 171 167 172 ```zig 173 + var plain: zsmtp.sasl.Plain = .init("user", "password"); 174 + var cram: zsmtp.sasl.CramMd5 = .init("user", "password"); 175 + 168 176 const extensions = try client.hello("my-host.example.com"); 169 - try client.authenticate(extensions, "user", "password"); 177 + const mechanism = zsmtp.sasl.Client.selectFromList( 178 + &.{ plain.client(), cram.client() }, // in order of preference 179 + extensions.auth, 180 + client.security == .encrypted, 181 + ) orelse return error.NoSupportedMechanism; 182 + try client.authenticate(mechanism); 170 183 ``` 171 184 172 - PLAIN and LOGIN send the password in the clear — base64 is not encryption — 173 - so the client refuses them unless `client.security` is `.encrypted`, 174 - returning `error.InsecureTransport` instead. The library is handed a reader 175 - and a writer and cannot see what is underneath them, so it assumes the worst: 176 - `setTransport` records the answer for a STARTTLS upgrade, and a session 177 - speaking TLS from the first byte sets `client.security = .encrypted` itself. 178 - Which mechanism `authenticate` picks follows from that — PLAIN, then LOGIN, 179 - then CRAM-MD5 once encrypted, and CRAM-MD5 first when it is not, since that 180 - is the one mechanism of the three that never puts the password on the wire. 185 + A 535 rejection surfaces as `error.AuthenticationFailed` with the reply in 186 + `last_reply`. 181 187 182 - For a connection protected by something the library cannot see — a unix 183 - socket, an SSH tunnel, a loopback test — `client.allow_cleartext_auth = true` 184 - permits the cleartext mechanisms without claiming the transport is encrypted. 188 + PLAIN, LOGIN and the OAuth mechanisms put a credential on the wire that an 189 + eavesdropper could reuse — base64 is not encryption, and a bearer token is 190 + worth more than a password because it authorizes elsewhere too. The client 191 + refuses those unless `client.security` is `.encrypted`, returning 192 + `error.InsecureTransport` before anything is sent, and `selectFromList` 193 + skips them for the same reason: on a plaintext session the preference order 194 + above falls through PLAIN to CRAM-MD5, which sends a proof rather than the 195 + secret. 196 + 197 + The library is handed a reader and a writer and cannot see what is underneath 198 + them, so it assumes the worst: `setTransport` records the answer for a 199 + STARTTLS upgrade, and a session speaking TLS from the first byte sets 200 + `client.security = .encrypted` itself. For a connection protected by 201 + something the library cannot see — a unix socket, an SSH tunnel, a loopback 202 + test — `client.allow_cleartext_auth = true` permits them without claiming the 203 + transport is encrypted. 204 + 205 + One error is worth knowing about even if it never fires for PLAIN: 206 + `error.ServerNotAuthenticated` means the server reported success while the 207 + mechanism had not finished proving what it set out to prove. For a one-way 208 + mechanism that cannot happen. For SCRAM (via 209 + [zig-scram](https://git.jcollie.dev/jeff/zig-scram)'s `scram-sasl` module) it 210 + means the server never produced its own signature — which is what something 211 + in the middle, holding no verifier, would do. 185 212 186 213 ### TLS 187 214 ··· 407 434 408 435 ### Protocol 409 436 410 - - **Modern SASL** — no XOAUTH2 or OAUTHBEARER 411 - ([RFC 7628](https://datatracker.ietf.org/doc/html/rfc7628)), which is what 412 - Gmail and Microsoft 365 now require; no SCRAM-SHA-256 413 - ([RFC 7677](https://datatracker.ietf.org/doc/html/rfc7677)), no EXTERNAL, 414 - no `AUTH=` on MAIL FROM. CRAM-MD5 is the most modern mechanism present. 437 + - **`AUTH=` on MAIL FROM** ([RFC 4954 §5](https://datatracker.ietf.org/doc/html/rfc4954#section-5)), 438 + which a trusted relay uses to forward the identity that originally 439 + authenticated. The client-side *mechanisms* are no longer a gap — PLAIN, 440 + LOGIN, CRAM-MD5, EXTERNAL, XOAUTH2, OAUTHBEARER and SCRAM all come from 441 + zig-sasl — but the **server** still understands only PLAIN and LOGIN, and 442 + only against a plaintext password. 415 443 - **Client certificates** — neither side can present or verify one. 416 444 - **No enhanced status code accessor** — the server emits `x.y.z` on every 417 445 reply, but `Reply` exposes only `code` and the raw text. ··· 489 517 (SMTPS): client (`Tls` before any SMTP traffic) and server 490 518 (`.mode = .implicit`). 491 519 - [RFC 4954](https://datatracker.ietf.org/doc/html/rfc4954) — AUTH: client 492 - and server, including initial responses and `*` cancellation. 493 - - [RFC 4616](https://datatracker.ietf.org/doc/html/rfc4616) — the PLAIN 494 - SASL mechanism (client and server). 495 - - [RFC 2195](https://datatracker.ietf.org/doc/html/rfc2195) — CRAM-MD5 496 - (client only; the server would need plaintext-equivalent credentials). 497 - - [draft-murchison-sasl-login](https://datatracker.ietf.org/doc/html/draft-murchison-sasl-login-00) 498 - — the de-facto AUTH LOGIN mechanism (client and server). 520 + and server, including initial responses, empty challenges and `*` 521 + cancellation. The client drives any mechanism from 522 + [zig-sasl](https://git.jcollie.dev/jeff/zig-sasl); the server still 523 + implements PLAIN ([RFC 4616](https://datatracker.ietf.org/doc/html/rfc4616)) 524 + and the de-facto 525 + [LOGIN](https://datatracker.ietf.org/doc/html/draft-murchison-sasl-login-00) 526 + itself, because zig-sasl's server side does not yet reach past PLAIN. 499 527 - [RFC 3463](https://datatracker.ietf.org/doc/html/rfc3463) / 500 528 [RFC 2034](https://datatracker.ietf.org/doc/html/rfc2034) — enhanced 501 529 status codes: carried in every server reply and advertised via
+9
build.zig
··· 31 31 // to our consumers. We must give it a name because a Zig package can expose 32 32 // multiple modules and consumers will need to be able to specify which 33 33 // module they want to access. 34 + // SASL mechanisms (PLAIN, LOGIN, CRAM-MD5, XOAUTH2, ...) live outside 35 + // this library, so that SMTP, POP3 and IMAP clients share one 36 + // implementation of each instead of keeping three. 37 + const sasl_dep = b.dependency("sasl", .{ 38 + .target = target, 39 + .optimize = optimize, 40 + }); 41 + 34 42 const tls_dep = b.dependency("tls", .{ 35 43 .target = target, 36 44 .optimize = optimize, ··· 49 57 .target = target, 50 58 .imports = &.{ 51 59 .{ .name = "tls", .module = tls_dep.module("tls") }, 60 + .{ .name = "sasl", .module = sasl_dep.module("sasl") }, 52 61 }, 53 62 }); 54 63
+4
build.zig.zon
··· 53 53 .hash = "N-V-__8AAI5RBAAIsFSqnDYObnOsBo3t9cQMXnvu0vQQRSAf", 54 54 .lazy = true, 55 55 }, 56 + .sasl = .{ 57 + .url = "git+https://git.jcollie.dev/jeff/zig-sasl.git#7c423d857ba07482e6fdc44d2c6581c25206b272", 58 + .hash = "sasl-0.0.0-s3YcODkwAQCo2OfGTBE8FKq0VIv6xhQIY_tay5YvReJl", 59 + }, 56 60 }, 57 61 .paths = .{ 58 62 "build.zig",
+94 -30
build.zig.zon.nix
··· 23 23 '' 24 24 # workaround https://codeberg.org/ziglang/zig/issues/31866 25 25 # https://github.com/Cloudef/zig2nix/issues/54 26 - mkdir "$TMPDIR/src" "$TMPDIR/cache" 26 + mkdir "$TMPDIR/src" "$TMPDIR/cache" "$TMPDIR/cache/tmp" 27 27 touch "$TMPDIR/src/build.zig" 28 28 hash="$(cd "$TMPDIR/src" && zig fetch --global-cache-dir "$TMPDIR/cache" ${artifact})" 29 29 mkdir "$out" ··· 101 101 }; 102 102 in 103 103 fetcher.${proto}; 104 + # The packages, as real directories holding symlinked files, rather than as 105 + # a farm of symlinked directories. 106 + # 107 + # Zig runs a dependency's own build steps with the working directory set to 108 + # that dependency, and points at the program to run with a path counted in 109 + # directories up from there. Through a symlink the two disagree: Zig counts 110 + # from `<farm>/<package>/`, four directories below the root, while the kernel 111 + # resolves the working directory to `/nix/store/<hash>`, which is three, so 112 + # the path lands one short of where the program is. 113 + # 114 + # It works anyway when the build directory is `/build`, because the sum then 115 + # overshoots into the root and going above the root stays there. It fails 116 + # when the build directory is under `/nix/var/nix/builds`, which is where Nix 117 + # puts it when the sandbox is off. Real directories make the two depths 118 + # agree, so it works either way. 119 + # 120 + # The files have to be real as well, which is the expensive part and cannot 121 + # be avoided. `--symbolic-link` would leave them pointing into each 122 + # dependency's own store path, so that the farm is a few megabytes rather 123 + # than a second copy of every dependency -- but Zig's 124 + # `installHeadersDirectory` walks the directory and copies only the entries 125 + # whose kind is `.file`. Symlinked headers are skipped without a word, and 126 + # the first thing to include one fails with `'dcimgui.h' not found`. 127 + # 128 + # `--link` is not the way out. A hard link into a Nix output is a file the 129 + # builder did not create: inside the Linux sandbox the store is a separate 130 + # mount and `link` fails with `Invalid cross-device link`, while on Darwin it 131 + # succeeds and leaves root-owned files in the output, which Nix refuses while 132 + # canonicalising with `invalid ownership on file`. 133 + # 134 + # So it is a real copy, with `--reflink=auto` to share the blocks on a 135 + # filesystem that can. `nix store optimise` recovers the duplication after 136 + # the fact, hard-linking identical files across the store, which is the 137 + # store's own business to do and not a build's. 138 + copyFarm = 139 + farm: entries: pathDependencyPackages: 140 + runCommandLocal farm 141 + { 142 + # The packages whose own manifest declares a dependency by `.path`. 143 + # Zig 0.16.0 cannot build these through `zig build --system`: it spins 144 + # in userspace forever, because a `.path` dependency's hash is computed 145 + # against the system package directory during the fetch and against the 146 + # real global cache afterwards, and in that mode the two disagree. A 147 + # package that wants `--system` copies each of these into its build 148 + # root and passes `--fork=`; see the README. 149 + passthru = { inherit pathDependencyPackages; }; 150 + } 151 + '' 152 + mkdir -p "$out" 153 + cp --recursive --reflink=auto --dereference --no-preserve=mode \ 154 + ${linkFarm farm entries}/. "$out/" 155 + ''; 104 156 in 105 - linkFarm name [ 106 - { 107 - name = "N-V-__8AAFtMiwHRrbE2kejFyc1FTFciBTZJlJOPGt-eIVGJ"; 108 - path = fetchZigArtifact { 109 - name = "exim"; 110 - url = "https://github.com/Exim/exim/archive/13835a3c1e057efad7da269c0f93bf2eac850205.tar.gz"; 111 - hash = "sha256-mqyGOAONL1uvPWZbraGmBjzrsTRW2424lGae0x9CR1U="; 112 - unpack = false; 113 - }; 114 - } 115 - { 116 - name = "N-V-__8AAI5RBAAIsFSqnDYObnOsBo3t9cQMXnvu0vQQRSAf"; 117 - path = fetchZigArtifact { 118 - name = "isemail"; 119 - url = "https://github.com/dominicsayers/isemail/archive/cfeefc3f2f88cb195053f6a309fa4f640cd369b5.tar.gz"; 120 - hash = "sha256-bgW2Lj00UluNZWrydCU7sx9332PmbttAAER4fq5ACmo="; 121 - unpack = false; 122 - }; 123 - } 124 - { 125 - name = "tls-0.1.0-ER2e0jGpBgCkVC-Yp12NgSdHNUtZr52MleJ8roHlUa54"; 126 - path = fetchZigArtifact { 127 - name = "tls"; 128 - url = "https://github.com/ianic/tls.zig/archive/e04ae448ce7ee70c136d4d48b059314543203809.tar.gz"; 129 - hash = "sha256-afPTfauIV49IATX0szuOdKLloyqI4XKsyNk8KkasAvg="; 130 - unpack = true; 131 - }; 132 - } 133 - ] 157 + copyFarm name 158 + [ 159 + { 160 + name = "N-V-__8AAFtMiwHRrbE2kejFyc1FTFciBTZJlJOPGt-eIVGJ"; 161 + path = fetchZigArtifact { 162 + name = "exim"; 163 + url = "https://github.com/Exim/exim/archive/13835a3c1e057efad7da269c0f93bf2eac850205.tar.gz"; 164 + hash = "sha256-mqyGOAONL1uvPWZbraGmBjzrsTRW2424lGae0x9CR1U="; 165 + unpack = false; 166 + }; 167 + } 168 + { 169 + name = "N-V-__8AAI5RBAAIsFSqnDYObnOsBo3t9cQMXnvu0vQQRSAf"; 170 + path = fetchZigArtifact { 171 + name = "isemail"; 172 + url = "https://github.com/dominicsayers/isemail/archive/cfeefc3f2f88cb195053f6a309fa4f640cd369b5.tar.gz"; 173 + hash = "sha256-bgW2Lj00UluNZWrydCU7sx9332PmbttAAER4fq5ACmo="; 174 + unpack = false; 175 + }; 176 + } 177 + { 178 + name = "sasl-0.0.0-s3YcODkwAQCo2OfGTBE8FKq0VIv6xhQIY_tay5YvReJl"; 179 + path = fetchZigArtifact { 180 + name = "sasl"; 181 + url = "git+https://git.jcollie.dev/jeff/zig-sasl.git#7c423d857ba07482e6fdc44d2c6581c25206b272"; 182 + hash = "sha256-Ft4FPCeoZmNmj8lOaE+Py4a2sJ2JzWWGFoolfoQQhGM="; 183 + unpack = true; 184 + }; 185 + } 186 + { 187 + name = "tls-0.1.0-ER2e0jGpBgCkVC-Yp12NgSdHNUtZr52MleJ8roHlUa54"; 188 + path = fetchZigArtifact { 189 + name = "tls"; 190 + url = "https://github.com/ianic/tls.zig/archive/e04ae448ce7ee70c136d4d48b059314543203809.tar.gz"; 191 + hash = "sha256-afPTfauIV49IATX0szuOdKLloyqI4XKsyNk8KkasAvg="; 192 + unpack = true; 193 + }; 194 + } 195 + ] 196 + [ 197 + ]
+298 -306
src/Client.zig
··· 20 20 const std = @import("std"); 21 21 const Io = std.Io; 22 22 const protocol = @import("protocol.zig"); 23 + const sasl = @import("sasl"); 23 24 const Reply = protocol.Reply; 24 25 25 26 reader: *Io.Reader, ··· 127 128 /// [RFC 3461](https://datatracker.ietf.org/doc/html/rfc3461) — `RET` and 128 129 /// `ENVID` on MAIL, `NOTIFY` and `ORCPT` on RCPT. 129 130 dsn: bool = false, 130 - /// AUTH mechanisms advertised by the server. 131 - auth: Auth = .{}, 131 + /// The mechanism names from the server's `AUTH` keyword, space-separated 132 + /// exactly as it sent them, for `sasl.Client.selectFromList`. 133 + /// 134 + /// A slice into the client's reply buffer, so it is valid until the next 135 + /// reply is read — which for the usual `hello` then `authenticate` 136 + /// sequence is long enough, since nothing is read in between. 137 + auth: []const u8 = "", 132 138 /// Value of the SIZE extension, if advertised with a value. 133 139 max_size: ?u64 = null, 134 - 135 - pub const Auth = struct { 136 - plain: bool = false, 137 - login: bool = false, 138 - cram_md5: bool = false, 139 - 140 - pub fn any(a: Auth) bool { 141 - return a.plain or a.login or a.cram_md5; 142 - } 143 - 144 - test any { 145 - try std.testing.expect((Auth{ .login = true }).any()); 146 - try std.testing.expect(!(Auth{}).any()); 147 - } 148 - 149 - fn parse(arg: []const u8) Auth { 150 - var auth: Auth = .{}; 151 - var it = std.mem.tokenizeScalar(u8, arg, ' '); 152 - while (it.next()) |mechanism| { 153 - if (ieql(mechanism, "PLAIN")) { 154 - auth.plain = true; 155 - } else if (ieql(mechanism, "LOGIN")) { 156 - auth.login = true; 157 - } else if (ieql(mechanism, "CRAM-MD5")) { 158 - auth.cram_md5 = true; 159 - } 160 - } 161 - return auth; 162 - } 163 - }; 164 140 165 141 fn parse(reply: Reply) Extensions { 166 142 var ext: Extensions = .{}; ··· 187 163 } else if (ieql(kw, "DSN")) { 188 164 ext.dsn = true; 189 165 } else if (ieql(kw, "AUTH")) { 190 - ext.auth = Auth.parse(arg); 166 + ext.auth = arg; 191 167 } else if (kw.len > 5 and ieql(kw[0..5], "AUTH=")) { 192 - // Some legacy servers advertise "AUTH=PLAIN LOGIN". 193 - var legacy_arg_buf: [128]u8 = undefined; 194 - const joined = std.fmt.bufPrint(&legacy_arg_buf, "{s} {s}", .{ kw[5..], arg }) catch kw[5..]; 195 - ext.auth = Auth.parse(joined); 168 + // Some servers old enough to predate RFC 4954 advertise 169 + // "AUTH=PLAIN LOGIN", with the first name jammed onto the 170 + // keyword. Taking the line from the '=' recovers the whole 171 + // list, which is why this points into the reply rather than 172 + // rebuilding it somewhere that would not outlive the call. 173 + ext.auth = line[kw_end - (kw.len - 5) ..]; 196 174 } else if (ieql(kw, "SIZE")) { 197 175 ext.max_size = std.fmt.parseInt(u64, arg, 10) catch null; 198 176 } ··· 270 248 c.security = security; 271 249 } 272 250 273 - pub const AuthError = Error || ArgumentError || error{ 274 - CredentialsTooLong, 275 - /// The transport is not encrypted and the mechanism would have put the 276 - /// password on the wire in the clear. Upgrade the session with 277 - /// `starttls`, or set `allow_cleartext_auth` if the connection is 278 - /// protected by something this library cannot see. 251 + pub const AuthError = Error || ArgumentError || sasl.Client.Error || error{ 252 + /// The transport is not encrypted and the mechanism would have put a 253 + /// reusable credential on the wire. Upgrade the session with `starttls`, 254 + /// or set `allow_cleartext_auth` if the connection is protected by 255 + /// something this library cannot see. 279 256 InsecureTransport, 280 257 /// The server rejected the credentials; see `last_reply`. 281 258 AuthenticationFailed, 282 - /// The server's CRAM-MD5 challenge was not valid base64. 259 + /// The server's challenge was not valid base64, or was longer than the 260 + /// buffer given to it. 283 261 InvalidChallenge, 284 - /// The server advertised none of the supported mechanisms. 285 - NoSupportedMechanism, 262 + /// The server accepted the exchange but the mechanism had not finished 263 + /// proving what it set out to prove. 264 + /// 265 + /// For a one-way mechanism this cannot happen. For SCRAM it means the 266 + /// server reported success without ever producing its own signature — 267 + /// which is what something in the middle, holding no verifier, would do. 268 + /// The credentials are not compromised by it, but the peer is not the 269 + /// server, and the session should be abandoned rather than used. 270 + ServerNotAuthenticated, 286 271 }; 287 272 288 - /// Authenticates with the best mechanism the server advertised, which 289 - /// depends on `security`. 273 + /// The largest SASL message this client will send or receive, before base64. 290 274 /// 291 - /// Over an encrypted transport that is PLAIN, then LOGIN, then CRAM-MD5: 292 - /// the network cannot read any of them, so the order is by how reliably 293 - /// servers implement them. Over a plaintext one the order inverts to 294 - /// CRAM-MD5 first, because it is the only one of the three that does not 295 - /// put the password on the wire; if the server does not offer it, the 296 - /// remaining mechanisms are refused with `error.InsecureTransport` rather 297 - /// than used, unless `allow_cleartext_auth` says otherwise. 298 - pub fn authenticate(c: *Client, extensions: Extensions, username: []const u8, password: []const u8) AuthError!void { 299 - if (c.security == .plaintext and extensions.auth.cram_md5) 300 - return c.authCramMd5(username, password); 301 - if (extensions.auth.plain) return c.authPlain("", username, password); 302 - if (extensions.auth.login) return c.authLogin(username, password); 303 - if (extensions.auth.cram_md5) return c.authCramMd5(username, password); 304 - return error.NoSupportedMechanism; 275 + /// [RFC 4954 §4](https://datatracker.ietf.org/doc/html/rfc4954#section-4) 276 + /// says a client "MUST be able to handle the maximum encoded size of 277 + /// challenges and responses generated by their supported authentication 278 + /// mechanisms" and offers 12288 octets as a sufficient line length; this is 279 + /// that, less the base64 expansion and the room `AUTH <mechanism> ` takes. 280 + pub const max_sasl_message = 8192; 281 + 282 + /// Runs a SASL exchange with `mechanism` 283 + /// ([RFC 4954](https://datatracker.ietf.org/doc/html/rfc4954)). 284 + /// 285 + /// The mechanisms themselves live in 286 + /// [zig-sasl](https://git.jcollie.dev/jeff/zig-sasl) — `sasl.Plain`, 287 + /// `sasl.CramMd5`, `sasl.XOAuth2` and the rest, with SCRAM in zig-scram — 288 + /// because they are shared with every other protocol that speaks SASL and 289 + /// nothing about them is specific to SMTP. What is specific to SMTP is this 290 + /// function: `AUTH`, the 334 challenges, the `*` that cancels, and 235. 291 + /// 292 + /// ```zig 293 + /// var plain: sasl.Plain = .init("alice", "secret"); 294 + /// const extensions = try client.hello("my-host.example.com"); 295 + /// const mechanism = sasl.Client.selectFromList( 296 + /// &.{ plain.client() }, 297 + /// extensions.auth, 298 + /// client.security == .encrypted, 299 + /// ) orelse return error.NoSupportedMechanism; 300 + /// try client.authenticate(mechanism); 301 + /// ``` 302 + /// 303 + /// A mechanism that would put a reusable credential on an unencrypted 304 + /// transport is refused before anything is sent, as it was when the 305 + /// mechanisms lived here. When the mechanism itself fails mid-exchange the 306 + /// session is cancelled with `*` rather than abandoned, so the connection is 307 + /// left usable and the server's 501 is read rather than waiting in the 308 + /// stream for whatever comes next. 309 + pub fn authenticate(c: *Client, mechanism: sasl.Client) AuthError!void { 310 + if (mechanism.cleartext()) try c.requireConfidentiality(); 311 + 312 + var message_buf: [max_sasl_message]u8 = undefined; 313 + var message: Io.Writer = .fixed(&message_buf); 314 + 315 + switch (try c.mechanismStep(mechanism.initial(&message))) { 316 + .none => try c.send("AUTH {s}", .{mechanism.name()}), 317 + .written => { 318 + var encoded_buf: [std.base64.standard.Encoder.calcSize(max_sasl_message)]u8 = undefined; 319 + const encoded = std.base64.standard.Encoder.encode(&encoded_buf, message.buffered()); 320 + // RFC 4954 §4: a zero-length initial response is a single `=`, 321 + // because an empty argument would be indistinguishable from 322 + // sending none at all. 323 + try c.send("AUTH {s} {s}", .{ mechanism.name(), if (encoded.len == 0) "=" else encoded }); 324 + }, 325 + } 326 + 327 + while (true) { 328 + const reply = try c.readReply(); 329 + if (reply.code == 235) break; 330 + if (reply.code != 334) return error.AuthenticationFailed; 331 + 332 + var challenge_buf: [max_sasl_message]u8 = undefined; 333 + const challenge = decodeChallenge(&challenge_buf, reply.text) orelse { 334 + try c.cancelAuth(); 335 + return error.InvalidChallenge; 336 + }; 337 + 338 + message = .fixed(&message_buf); 339 + try c.mechanismStep(mechanism.respond(challenge, &message)); 340 + try c.sendBase64(message.buffered()); 341 + } 342 + 343 + // The server says yes. Whether that means anything is the mechanism's to 344 + // say: see `ServerNotAuthenticated`. 345 + if (!mechanism.satisfied()) return error.ServerNotAuthenticated; 305 346 } 306 347 307 - /// Refuses a mechanism that would transmit the password unprotected. 348 + /// Cancels the exchange on a mechanism error and turns it into ours. 349 + /// 350 + /// A mechanism that has failed will not produce another message, so the 351 + /// server is left waiting for a line that is never coming. RFC 4954 §4 gives 352 + /// `*` for exactly this, and answers it with 501, which is read here so the 353 + /// session is clean for whatever the caller does next. 354 + fn mechanismStep(c: *Client, result: anytype) AuthError!@typeInfo(@TypeOf(result)).error_union.payload { 355 + return result catch |err| { 356 + c.cancelAuth() catch {}; 357 + return err; 358 + }; 359 + } 360 + 361 + fn cancelAuth(c: *Client) Error!void { 362 + try c.send("*", .{}); 363 + _ = c.readReply() catch {}; 364 + } 365 + 366 + /// Decodes a challenge, which may legitimately be empty: RFC 4954 §4 spells a 367 + /// zero-length challenge `334 ` — the code, a space, and nothing after it. 368 + fn decodeChallenge(buffer: []u8, text: []const u8) ?[]const u8 { 369 + if (text.len == 0) return buffer[0..0]; 370 + const len = std.base64.standard.Decoder.calcSizeForSlice(text) catch return null; 371 + if (len > buffer.len) return null; 372 + std.base64.standard.Decoder.decode(buffer[0..len], text) catch return null; 373 + return buffer[0..len]; 374 + } 375 + 376 + /// Refuses a mechanism that would transmit a reusable credential unprotected. 308 377 fn requireConfidentiality(c: *Client) AuthError!void { 309 378 if (c.security == .encrypted or c.allow_cleartext_auth) return; 310 379 return error.InsecureTransport; 311 380 } 312 381 313 - /// Authenticates with AUTH PLAIN ([RFC 4616](https://datatracker.ietf.org/doc/html/rfc4616)). 314 - /// Pass an empty `authzid` unless 315 - /// you need to act on behalf of another identity. 316 - /// 317 - /// The credentials cross the wire in the clear (base64 is not encryption), 318 - /// so this returns `error.InsecureTransport` unless `security` is 319 - /// `.encrypted` or `allow_cleartext_auth` is set. 320 - pub fn authPlain(c: *Client, authzid: []const u8, username: []const u8, password: []const u8) AuthError!void { 321 - try c.requireConfidentiality(); 322 - // NUL separates the three fields, so one hidden in a field would move 323 - // the boundaries and authenticate as somebody else. 324 - if (!protocol.isSafeArgument(authzid) or !protocol.isSafeArgument(username) or 325 - !protocol.isSafeArgument(password)) return error.UnsafeArgument; 326 - var plain_buf: [512]u8 = undefined; 327 - var plain: Io.Writer = .fixed(&plain_buf); 328 - plain.print("{s}\x00{s}\x00{s}", .{ authzid, username, password }) catch 329 - return error.CredentialsTooLong; 330 - var b64_buf: [std.base64.standard.Encoder.calcSize(plain_buf.len)]u8 = undefined; 331 - const b64 = std.base64.standard.Encoder.encode(&b64_buf, plain.buffered()); 332 - try c.send("AUTH PLAIN {s}", .{b64}); 333 - try c.expectAuthSuccess(); 334 - } 335 - 336 - /// Authenticates with AUTH LOGIN, the legacy two-step username/password 337 - /// exchange still required by some servers (no RFC; the de-facto 338 - /// [draft-murchison-sasl-login](https://datatracker.ietf.org/doc/html/draft-murchison-sasl-login-00) 339 - /// mechanism). Like AUTH PLAIN it sends the credentials in the clear, so 340 - /// it returns `error.InsecureTransport` unless `security` is `.encrypted` 341 - /// or `allow_cleartext_auth` is set. 342 - pub fn authLogin(c: *Client, username: []const u8, password: []const u8) AuthError!void { 343 - try c.requireConfidentiality(); 344 - try c.send("AUTH LOGIN", .{}); 345 - _ = try c.expect(334); // Username: prompt 346 - try c.sendBase64(username); 347 - _ = try c.expect(334); // Password: prompt 348 - try c.sendBase64(password); 349 - try c.expectAuthSuccess(); 350 - } 351 - 352 - /// Authenticates with AUTH CRAM-MD5 ([RFC 2195](https://datatracker.ietf.org/doc/html/rfc2195)): 353 - /// the password never crosses 354 - /// the wire, only an HMAC-MD5 of the server's challenge — which is why this 355 - /// one is allowed over a plaintext transport, and why `authenticate` 356 - /// prefers it there. The challenge is still replayable and MD5 is long 357 - /// past retirement, so it is a way to avoid handing over the password, not 358 - /// a substitute for TLS. 359 - pub fn authCramMd5(c: *Client, username: []const u8, password: []const u8) AuthError!void { 360 - if (!protocol.isSafeArgument(username)) return error.UnsafeArgument; 361 - try c.send("AUTH CRAM-MD5", .{}); 362 - const reply = try c.expect(334); 363 - 364 - var challenge_buf: [512]u8 = undefined; 365 - const challenge_len = std.base64.standard.Decoder.calcSizeForSlice(reply.text) catch 366 - return error.InvalidChallenge; 367 - if (challenge_len > challenge_buf.len) return error.InvalidChallenge; 368 - std.base64.standard.Decoder.decode(challenge_buf[0..challenge_len], reply.text) catch 369 - return error.InvalidChallenge; 370 - 371 - var mac: [std.crypto.auth.hmac.HmacMd5.mac_length]u8 = undefined; 372 - std.crypto.auth.hmac.HmacMd5.create(&mac, challenge_buf[0..challenge_len], password); 373 - const digest = std.fmt.bytesToHex(mac, .lower); 374 - 375 - var response_buf: [384]u8 = undefined; 376 - var response: Io.Writer = .fixed(&response_buf); 377 - response.print("{s} {s}", .{ username, digest }) catch return error.CredentialsTooLong; 378 - try c.sendBase64(response.buffered()); 379 - try c.expectAuthSuccess(); 380 - } 381 - 382 - /// Sends `bytes` base64-encoded as a bare continuation line. 382 + /// Sends `bytes` base64-encoded as a bare continuation line. An empty message 383 + /// stays empty, which is a response in its own right and not a cancellation. 383 384 fn sendBase64(c: *Client, bytes: []const u8) AuthError!void { 384 - var b64_buf: [std.base64.standard.Encoder.calcSize(384)]u8 = undefined; 385 - if (std.base64.standard.Encoder.calcSize(bytes.len) > b64_buf.len) 386 - return error.CredentialsTooLong; 387 - const b64 = std.base64.standard.Encoder.encode(&b64_buf, bytes); 388 - try c.send("{s}", .{b64}); 389 - } 390 - 391 - fn expectAuthSuccess(c: *Client) AuthError!void { 392 - const reply = try c.readReply(); 393 - if (reply.code != 235) return error.AuthenticationFailed; 385 + var encoded_buf: [std.base64.standard.Encoder.calcSize(max_sasl_message)]u8 = undefined; 386 + const encoded = std.base64.standard.Encoder.encode(&encoded_buf, bytes); 387 + try c.send("{s}", .{encoded}); 394 388 } 395 389 396 390 /// Parameters for the MAIL command. Send only what the server advertised: ··· 1046 1040 try std.testing.expectEqualStrings("EHLO client.example.org\r\n", tls_writer.buffered()); 1047 1041 } 1048 1042 1049 - test authPlain { 1050 - const responses = "235 2.7.0 Accepted\r\n"; 1051 - var reader: Io.Reader = .fixed(responses); 1052 - var out_buf: [256]u8 = undefined; 1053 - var writer: Io.Writer = .fixed(&out_buf); 1054 - var reply_buf: [256]u8 = undefined; 1055 - var client: Client = .init(&reader, &writer, &reply_buf); 1056 - client.security = .encrypted; // PLAIN is refused in the clear. 1057 - 1058 - try client.authPlain("", "user", "pass"); 1059 - // base64("\x00user\x00pass") 1060 - try std.testing.expectEqualStrings("AUTH PLAIN AHVzZXIAcGFzcw==\r\n", writer.buffered()); 1061 - } 1062 - 1063 - test authLogin { 1064 - const responses = "334 VXNlcm5hbWU6\r\n334 UGFzc3dvcmQ6\r\n235 2.7.0 Accepted\r\n"; 1065 - var reader: Io.Reader = .fixed(responses); 1066 - var out_buf: [256]u8 = undefined; 1067 - var writer: Io.Writer = .fixed(&out_buf); 1068 - var reply_buf: [256]u8 = undefined; 1069 - var client: Client = .init(&reader, &writer, &reply_buf); 1070 - client.security = .encrypted; // LOGIN is refused in the clear. 1071 - 1072 - try client.authLogin("user", "pass"); 1073 - try std.testing.expectEqualStrings( 1074 - "AUTH LOGIN\r\ndXNlcg==\r\ncGFzcw==\r\n", 1075 - writer.buffered(), 1076 - ); 1077 - } 1078 - 1079 - test authCramMd5 { 1080 - // Challenge "<1896.697170952@postoffice.reston.mci.net>", user "tim", 1081 - // password "tanstaaftanstaaf" => digest b913a602c7eda7a495b4e6e7334d3890. 1082 - const responses = "334 PDE4OTYuNjk3MTcwOTUyQHBvc3RvZmZpY2UucmVzdG9uLm1jaS5uZXQ+\r\n" ++ 1083 - "235 2.7.0 Accepted\r\n"; 1084 - var reader: Io.Reader = .fixed(responses); 1085 - var out_buf: [256]u8 = undefined; 1086 - var writer: Io.Writer = .fixed(&out_buf); 1087 - var reply_buf: [256]u8 = undefined; 1088 - var client: Client = .init(&reader, &writer, &reply_buf); 1089 - 1090 - try client.authCramMd5("tim", "tanstaaftanstaaf"); 1091 - try std.testing.expectEqualStrings( 1092 - "AUTH CRAM-MD5\r\ndGltIGI5MTNhNjAyYzdlZGE3YTQ5NWI0ZTZlNzMzNGQzODkw\r\n", 1093 - writer.buffered(), 1094 - ); 1095 - } 1096 - 1097 - test authenticate { 1098 - var out_buf: [256]u8 = undefined; 1099 - var reply_buf: [256]u8 = undefined; 1100 - { 1101 - // Only CRAM-MD5 advertised. 1102 - const responses = "334 YWJj\r\n235 ok\r\n"; 1103 - var reader: Io.Reader = .fixed(responses); 1104 - var writer: Io.Writer = .fixed(&out_buf); 1105 - var client: Client = .init(&reader, &writer, &reply_buf); 1106 - try client.authenticate(.{ .auth = .{ .cram_md5 = true } }, "u", "p"); 1107 - try std.testing.expect(std.mem.startsWith(u8, writer.buffered(), "AUTH CRAM-MD5\r\n")); 1108 - } 1109 - { 1110 - // Nothing advertised. 1111 - var reader: Io.Reader = .fixed(""); 1112 - var writer: Io.Writer = .fixed(&out_buf); 1113 - var client: Client = .init(&reader, &writer, &reply_buf); 1114 - try std.testing.expectError( 1115 - error.NoSupportedMechanism, 1116 - client.authenticate(.{}, "u", "p"), 1117 - ); 1118 - } 1119 - } 1120 - 1121 1043 test "BODY=BINARYMIME commits the transaction to BDAT" { 1122 1044 const responses = "250-mx.example.com\r\n250-CHUNKING\r\n250 BINARYMIME\r\n" ++ 1123 1045 "250 2.1.0 Ok\r\n250 2.1.5 Ok\r\n250 2.0.0 Ok\r\n"; ··· 1439 1361 try std.testing.expect(ext.dsn); 1440 1362 } 1441 1363 1364 + test authenticate { 1365 + const responses = "250-mx.example.com\r\n250 AUTH PLAIN LOGIN\r\n" ++ 1366 + "235 2.7.0 Accepted\r\n"; 1367 + var reader: Io.Reader = .fixed(responses); 1368 + var out_buf: [256]u8 = undefined; 1369 + var writer: Io.Writer = .fixed(&out_buf); 1370 + var reply_buf: [256]u8 = undefined; 1371 + var client: Client = .init(&reader, &writer, &reply_buf); 1372 + client.security = .encrypted; 1373 + 1374 + const extensions = try client.hello("client.example.org"); 1375 + var plain: sasl.Plain = .init("alice", "secret"); 1376 + const mechanism = sasl.Client.selectFromList( 1377 + &.{plain.client()}, 1378 + extensions.auth, 1379 + true, 1380 + ).?; 1381 + try client.authenticate(mechanism); 1382 + 1383 + // base64("\x00alice\x00secret"), sent as the initial response in one 1384 + // round trip rather than waiting to be asked. 1385 + try std.testing.expect(std.mem.endsWith( 1386 + u8, 1387 + writer.buffered(), 1388 + "AUTH PLAIN AGFsaWNlAHNlY3JldA==\r\n", 1389 + )); 1390 + } 1391 + 1392 + test "a challenge-response mechanism runs through the 334s" { 1393 + const responses = "250-mx.example.com\r\n250 AUTH CRAM-MD5\r\n" ++ 1394 + // base64 of RFC 2195's challenge 1395 + "334 PDE4OTYuNjk3MTcwOTUyQHBvc3RvZmZpY2UucmVzdG9uLm1jaS5uZXQ+\r\n" ++ 1396 + "235 2.7.0 Accepted\r\n"; 1397 + var reader: Io.Reader = .fixed(responses); 1398 + var out_buf: [512]u8 = undefined; 1399 + var writer: Io.Writer = .fixed(&out_buf); 1400 + var reply_buf: [256]u8 = undefined; 1401 + var client: Client = .init(&reader, &writer, &reply_buf); 1402 + 1403 + const extensions = try client.hello("client.example.org"); 1404 + var cram: sasl.CramMd5 = .init("tim", "tanstaaftanstaaf"); 1405 + // CRAM-MD5 is not cleartext, so it is usable on this plaintext session. 1406 + const mechanism = sasl.Client.selectFromList(&.{cram.client()}, extensions.auth, false).?; 1407 + try client.authenticate(mechanism); 1408 + 1409 + // No initial response, then the digest RFC 2195 publishes, base64'd. 1410 + try std.testing.expect(std.mem.indexOf(u8, writer.buffered(), "AUTH CRAM-MD5\r\n") != null); 1411 + try std.testing.expect(std.mem.endsWith( 1412 + u8, 1413 + writer.buffered(), 1414 + "dGltIGI5MTNhNjAyYzdlZGE3YTQ5NWI0ZTZlNzMzNGQzODkw\r\n", 1415 + )); 1416 + } 1417 + 1418 + test "a mechanism that sends a credential in the clear is refused first" { 1419 + var reader: Io.Reader = .fixed(""); 1420 + var out_buf: [256]u8 = undefined; 1421 + var writer: Io.Writer = .fixed(&out_buf); 1422 + var reply_buf: [64]u8 = undefined; 1423 + var client: Client = .init(&reader, &writer, &reply_buf); 1424 + 1425 + var plain: sasl.Plain = .init("alice", "secret"); 1426 + try std.testing.expectError( 1427 + error.InsecureTransport, 1428 + client.authenticate(plain.client()), 1429 + ); 1430 + // Nothing reached the wire, which is the point: the refusal happens 1431 + // before the credential is written, not after the server rejects it. 1432 + try std.testing.expectEqualStrings("", writer.buffered()); 1433 + 1434 + client.allow_cleartext_auth = true; 1435 + var accepting: Io.Reader = .fixed("235 2.7.0 Accepted\r\n"); 1436 + client.setTransport(&accepting, &writer, .plaintext); 1437 + try client.authenticate(plain.client()); 1438 + } 1439 + 1440 + test "a server accepting without finishing the exchange is not authenticated" { 1441 + // A mechanism that has not proved what it set out to prove, which is 1442 + // SCRAM's shape: `satisfied` stays false until the server's own proof 1443 + // has been verified. 1444 + const Unfinished = struct { 1445 + fn name(_: *anyopaque) []const u8 { 1446 + return "MUTUAL-TEST"; 1447 + } 1448 + fn initial(_: *anyopaque, out: *Io.Writer) sasl.Client.Error!sasl.Client.Initial { 1449 + try out.writeAll("hello"); 1450 + return .written; 1451 + } 1452 + fn respond(_: *anyopaque, _: []const u8, _: *Io.Writer) sasl.Client.Error!void {} 1453 + fn satisfied(_: *anyopaque) bool { 1454 + return false; 1455 + } 1456 + fn cleartext(_: *anyopaque) bool { 1457 + return false; 1458 + } 1459 + const vtable: sasl.Client.VTable = .{ 1460 + .name = name, 1461 + .initial = initial, 1462 + .respond = respond, 1463 + .satisfied = satisfied, 1464 + .cleartext = cleartext, 1465 + }; 1466 + }; 1467 + var nothing: u8 = 0; 1468 + const mechanism: sasl.Client = .{ .context = &nothing, .vtable = &Unfinished.vtable }; 1469 + 1470 + var reader: Io.Reader = .fixed("235 2.7.0 Accepted\r\n"); 1471 + var out_buf: [256]u8 = undefined; 1472 + var writer: Io.Writer = .fixed(&out_buf); 1473 + var reply_buf: [64]u8 = undefined; 1474 + var client: Client = .init(&reader, &writer, &reply_buf); 1475 + 1476 + // The server said yes. The mechanism disagrees, and it is the one that 1477 + // knows — this is the case nothing in this library could express before 1478 + // the mechanisms moved out of it. 1479 + try std.testing.expectError( 1480 + error.ServerNotAuthenticated, 1481 + client.authenticate(mechanism), 1482 + ); 1483 + } 1484 + 1485 + test "a mechanism that fails mid-exchange cancels rather than stranding the session" { 1486 + // PLAIN is never challenged, so a 334 makes it return BadChallenge. 1487 + const responses = "334 c29tZXRoaW5n\r\n501 5.5.2 Cancelled\r\n"; 1488 + var reader: Io.Reader = .fixed(responses); 1489 + var out_buf: [256]u8 = undefined; 1490 + var writer: Io.Writer = .fixed(&out_buf); 1491 + var reply_buf: [64]u8 = undefined; 1492 + var client: Client = .init(&reader, &writer, &reply_buf); 1493 + client.security = .encrypted; 1494 + 1495 + var plain: sasl.Plain = .init("alice", "secret"); 1496 + try std.testing.expectError(error.BadChallenge, client.authenticate(plain.client())); 1497 + // RFC 4954 §4's cancellation went out, so the server is not left waiting 1498 + // for a line that was never coming. 1499 + try std.testing.expect(std.mem.endsWith(u8, writer.buffered(), "*\r\n")); 1500 + try std.testing.expectEqual(@as(usize, 0), reader.bufferedLen()); 1501 + } 1502 + 1503 + test "a rejection surfaces as AuthenticationFailed with the reply" { 1504 + var reader: Io.Reader = .fixed("535 5.7.8 Authentication credentials invalid\r\n"); 1505 + var out_buf: [256]u8 = undefined; 1506 + var writer: Io.Writer = .fixed(&out_buf); 1507 + var reply_buf: [256]u8 = undefined; 1508 + var client: Client = .init(&reader, &writer, &reply_buf); 1509 + client.security = .encrypted; 1510 + 1511 + var plain: sasl.Plain = .init("alice", "secret"); 1512 + try std.testing.expectError( 1513 + error.AuthenticationFailed, 1514 + client.authenticate(plain.client()), 1515 + ); 1516 + try std.testing.expectEqual(@as(u16, 535), client.last_reply.?.code); 1517 + } 1518 + 1442 1519 test "an address carrying CRLF cannot inject a command" { 1443 1520 // Without the check this would put a second RCPT on the wire. 1444 1521 const smuggled = "bob@example.net>\r\nRCPT TO:<victim@example.net"; ··· 1456 1533 try std.testing.expectEqualStrings("", writer.buffered()); 1457 1534 } 1458 1535 1459 - test "a NUL in a PLAIN field cannot shift the credential boundaries" { 1460 - var reader: Io.Reader = .fixed("235 2.7.0 Accepted\r\n"); 1461 - var out_buf: [256]u8 = undefined; 1462 - var writer: Io.Writer = .fixed(&out_buf); 1463 - var reply_buf: [64]u8 = undefined; 1464 - var client: Client = .init(&reader, &writer, &reply_buf); 1465 - client.security = .encrypted; 1466 - 1467 - // Decoded by the server as authzid "", username "admin", password "x". 1468 - try std.testing.expectError( 1469 - error.UnsafeArgument, 1470 - client.authPlain("", "user\x00admin\x00x", "pass"), 1471 - ); 1472 - try std.testing.expectEqualStrings("", writer.buffered()); 1473 - } 1474 - 1475 - test "cleartext mechanisms are refused on an unencrypted transport" { 1476 - var reader: Io.Reader = .fixed(""); 1477 - var out_buf: [256]u8 = undefined; 1478 - var writer: Io.Writer = .fixed(&out_buf); 1479 - var reply_buf: [64]u8 = undefined; 1480 - var client: Client = .init(&reader, &writer, &reply_buf); 1481 - 1482 - try std.testing.expectError(error.InsecureTransport, client.authPlain("", "u", "p")); 1483 - try std.testing.expectError(error.InsecureTransport, client.authLogin("u", "p")); 1484 - // A server offering only those two leaves `authenticate` nothing to use. 1485 - const cleartext_only: Extensions = .{ .auth = .{ .plain = true, .login = true } }; 1486 - try std.testing.expectError( 1487 - error.InsecureTransport, 1488 - client.authenticate(cleartext_only, "u", "p"), 1489 - ); 1490 - try std.testing.expectEqualStrings("", writer.buffered()); 1491 - } 1492 - 1493 - test "authenticate prefers CRAM-MD5 in the clear and PLAIN once encrypted" { 1494 - const challenge = "334 PDE4OTYuNjk3MTcwOTUyQHBvc3RvZmZpY2UucmVzdG9uLm1jaS5uZXQ+\r\n" ++ 1495 - "235 2.7.0 Accepted\r\n"; 1496 - const advertised: Extensions = .{ 1497 - .auth = .{ .plain = true, .login = true, .cram_md5 = true }, 1498 - }; 1499 - 1500 - var reader: Io.Reader = .fixed(challenge); 1501 - var out_buf: [256]u8 = undefined; 1502 - var writer: Io.Writer = .fixed(&out_buf); 1503 - var reply_buf: [256]u8 = undefined; 1504 - var client: Client = .init(&reader, &writer, &reply_buf); 1505 - 1506 - // In the clear: the one mechanism that keeps the password off the wire. 1507 - try client.authenticate(advertised, "tim", "tanstaaftanstaaf"); 1508 - try std.testing.expect(std.mem.startsWith(u8, writer.buffered(), "AUTH CRAM-MD5\r\n")); 1509 - 1510 - var tls_reader: Io.Reader = .fixed("235 2.7.0 Accepted\r\n"); 1511 - var tls_out_buf: [256]u8 = undefined; 1512 - var tls_writer: Io.Writer = .fixed(&tls_out_buf); 1513 - client.setTransport(&tls_reader, &tls_writer, .encrypted); 1514 - 1515 - try client.authenticate(advertised, "user", "pass"); 1516 - try std.testing.expectEqualStrings("AUTH PLAIN AHVzZXIAcGFzcw==\r\n", tls_writer.buffered()); 1517 - } 1518 - 1519 - test "allow_cleartext_auth is the way past the refusal" { 1520 - var reader: Io.Reader = .fixed("235 2.7.0 Accepted\r\n"); 1521 - var out_buf: [256]u8 = undefined; 1522 - var writer: Io.Writer = .fixed(&out_buf); 1523 - var reply_buf: [64]u8 = undefined; 1524 - var client: Client = .init(&reader, &writer, &reply_buf); 1525 - client.allow_cleartext_auth = true; 1526 - 1527 - try client.authPlain("", "user", "pass"); 1528 - try std.testing.expectEqualStrings("AUTH PLAIN AHVzZXIAcGFzcw==\r\n", writer.buffered()); 1529 - } 1530 - 1531 - test "rejected credentials surface AuthenticationFailed" { 1532 - const responses = "535 5.7.8 Authentication credentials invalid\r\n"; 1533 - var reader: Io.Reader = .fixed(responses); 1534 - var out_buf: [256]u8 = undefined; 1535 - var writer: Io.Writer = .fixed(&out_buf); 1536 - var reply_buf: [256]u8 = undefined; 1537 - var client: Client = .init(&reader, &writer, &reply_buf); 1538 - client.security = .encrypted; 1539 - 1540 - try std.testing.expectError(error.AuthenticationFailed, client.authPlain("", "u", "p")); 1541 - try std.testing.expectEqual(@as(u16, 535), client.last_reply.?.code); 1542 - } 1543 - 1544 1536 test hello { 1545 1537 const responses = "250-mx.example.com\r\n250-AUTH PLAIN LOGIN CRAM-MD5\r\n250 8BITMIME\r\n"; 1546 1538 var reader: Io.Reader = .fixed(responses); ··· 1550 1542 var client: Client = .init(&reader, &writer, &reply_buf); 1551 1543 1552 1544 const ext = try client.hello("c.example"); 1553 - try std.testing.expect(ext.auth.plain); 1554 - try std.testing.expect(ext.auth.login); 1555 - try std.testing.expect(ext.auth.cram_md5); 1556 - try std.testing.expect(ext.auth.any()); 1545 + try std.testing.expectEqualStrings("PLAIN LOGIN CRAM-MD5", ext.auth); 1557 1546 } 1558 1547 1559 1548 test init { ··· 1727 1716 // Whatever the "server" says, the client must fail cleanly, never crash. 1728 1717 _ = client.greet() catch return; 1729 1718 const extensions = client.hello("fuzz.example.org") catch return; 1730 - client.authenticate(extensions, "user", "password") catch {}; 1719 + var plain: sasl.Plain = .init("user", "password"); 1720 + client.allow_cleartext_auth = true; 1721 + if (sasl.Client.selectFromList(&.{plain.client()}, extensions.auth, true)) |mechanism| 1722 + client.authenticate(mechanism) catch {}; 1731 1723 client.sendMail("a@example.com", &.{"b@example.net"}, ".dot\r\nbody") catch {}; 1732 1724 client.quit() catch {}; 1733 1725 } ··· 1774 1766 const extensions: Extensions = .{ .pipelining = true, .max_size = 1024 }; 1775 1767 try std.testing.expect(extensions.pipelining); 1776 1768 try std.testing.expect(!extensions.starttls); 1777 - try std.testing.expect(!extensions.auth.any()); 1769 + try std.testing.expectEqualStrings("", extensions.auth); 1778 1770 try std.testing.expectEqual(@as(?u64, 1024), extensions.max_size); 1779 1771 } 1780 1772
+40 -6
src/main.zig
··· 235 235 236 236 if (config.username) |username| { 237 237 const password = config.password.?; 238 - const result = switch (config.auth_method) { 239 - .auto => client.authenticate(extensions, username, password), 240 - .plain => client.authPlain("", username, password), 241 - .login => client.authLogin(username, password), 242 - .cram_md5 => client.authCramMd5(username, password), 238 + // The mechanisms come from zig-sasl; what is chosen from them is the 239 + // caller's business, and this one lets --auth-method force it. 240 + var plain: zsmtp.sasl.Plain = .init(username, password); 241 + var login: zsmtp.sasl.Login = .init(username, password); 242 + var cram_md5: zsmtp.sasl.CramMd5 = .init(username, password); 243 + const offered: []const zsmtp.sasl.Client = switch (config.auth_method) { 244 + // In order of preference, which `selectFromList` reads as such: 245 + // PLAIN because every server implements it correctly, CRAM-MD5 246 + // last because it is the oldest. On a carrier with no encryption 247 + // the first two are skipped and it is the only one left. 248 + .auto => &.{ plain.client(), login.client(), cram_md5.client() }, 249 + .plain => &.{plain.client()}, 250 + .login => &.{login.client()}, 251 + .cram_md5 => &.{cram_md5.client()}, 243 252 }; 244 - result catch |err| { 253 + const mechanism = zsmtp.sasl.Client.selectFromList( 254 + offered, 255 + extensions.auth, 256 + client.security == .encrypted or client.allow_cleartext_auth, 257 + ) orelse { 258 + std.log.err( 259 + "no usable mechanism; the server offers: {s}{s}", 260 + .{ 261 + if (extensions.auth.len == 0) "(none)" else extensions.auth, 262 + // The common case by far: everything on offer sends the 263 + // password, and this connection is not encrypted. 264 + if (client.security == .plaintext and !client.allow_cleartext_auth) 265 + ", and this connection is not encrypted " ++ 266 + "(use --starttls or --tls, or --allow-cleartext-auth)" 267 + else 268 + "", 269 + }, 270 + ); 271 + return error.NoSupportedMechanism; 272 + }; 273 + client.authenticate(mechanism) catch |err| { 245 274 switch (err) { 246 275 error.AuthenticationFailed => { 247 276 const reply = client.last_reply.?; 248 277 std.log.err("authentication failed: {d} {s}", .{ reply.code, reply.text }); 249 278 }, 279 + error.ServerNotAuthenticated => std.log.err( 280 + "the server accepted the login without proving itself; " ++ 281 + "this is not the server it claims to be", 282 + .{}, 283 + ), 250 284 error.InsecureTransport => std.log.err( 251 285 "refusing to send credentials over an unencrypted connection; " ++ 252 286 "use --starttls or --tls, or pass --allow-cleartext-auth",
+6
src/root.zig
··· 20 20 /// Re-export of the ianic/tls.zig library used for server-side STARTTLS, 21 21 /// e.g. `zsmtp.tls.config.CertKeyPair` for loading the server certificate. 22 22 pub const tls = @import("tls"); 23 + /// Re-export of [zig-sasl](https://git.jcollie.dev/jeff/zig-sasl), which is 24 + /// where the AUTH mechanisms live: `zsmtp.sasl.Plain`, `sasl.CramMd5`, 25 + /// `sasl.XOAuth2` and the rest, for handing to `Client.authenticate`. They 26 + /// are not here because nothing about them is specific to SMTP — POP3 and 27 + /// IMAP want the same ones. 28 + pub const sasl = @import("sasl"); 23 29 24 30 test { 25 31 _ = protocol;