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 server's AUTH mechanisms from zig-sasl too

`Handler.authenticate` is gone. `fn (username, password) bool` could
carry PLAIN and LOGIN and nothing else, because it assumed the server
held something a password could be compared against -- so the server
offered exactly those two and could never offer a third.

`Options.auth_mechanisms` replaces it: a list of `sasl.Server`,
advertised by name in the EHLO response and driven by the loop that is
left here, which is the SMTP part -- the 334 challenges, the `*` that
cancels, 235, and the 504 for a name nothing answers to. The server can
now do CRAM-MD5, which there is a test for against RFC 2195's published
response, and EXTERNAL, and anything else zig-sasl grows.

Where the credential comes from moved to the mechanism, which is the
whole reason this works. PLAIN and LOGIN share a `PasswordCheck` and are
told only whether a password was right; CRAM-MD5 needs a
`PasswordLookup` and gets the password itself, because it must compute
the same HMAC the client did. That has always been the argument against
offering CRAM-MD5 and it is now visible in the types.

The mechanisms hold per-exchange state, so a session needs its own set
rather than a shared one -- the demo server builds a fresh CRAM-MD5
challenge per connection for exactly that reason.

`Envelope.authenticated_as` is new, and had to be: the identity used to
reach the handler because the handler did the checking, and now the
mechanism does. It is what the mechanism reported rather than what the
client typed, which for PLAIN's authorization identity is not the same
thing, and it is what a handler deciding whether to relay actually wants.

Verified end to end: the demo server advertises PLAIN LOGIN CRAM-MD5, a
CRAM-MD5 login succeeds over a plaintext session with no cleartext
opt-in, swaks still logs in with LOGIN, and the whole interop suite
passes.

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

+353 -125
+38 -10
README.md
··· 264 264 try session.run(gpa); 265 265 ``` 266 266 267 - With an `authenticate` callback the session advertises and accepts AUTH 268 - PLAIN and AUTH LOGIN (RFC 4954); setting `Options.require_auth` rejects MAIL 269 - with 530 until the client has authenticated. 267 + `Options.auth_mechanisms` is what the session offers for AUTH (RFC 4954), 268 + advertised by name in the EHLO response and drawn from 269 + [zig-sasl](https://git.jcollie.dev/jeff/zig-sasl) — so a server can offer 270 + CRAM-MD5 or EXTERNAL, which it could not when the mechanisms were built in: 271 + 272 + ```zig 273 + const check: zsmtp.sasl.Server.PasswordCheck = .{ .context = &app, .verify = verify }; 274 + var plain: zsmtp.sasl.PlainServer = .init(check); 275 + var login: zsmtp.sasl.LoginServer = .init(check); 276 + // ... .auth_mechanisms = &.{ plain.server(), login.server() } 277 + ``` 278 + 279 + **The mechanisms hold per-exchange state, so each session needs its own.** 280 + Sharing a set between two connections would have them overwrite each other's 281 + challenges; `Server.init` is per-connection anyway, so building them beside 282 + it is the natural place. 283 + 284 + Where the credential comes from is the mechanism's business, which is why 285 + there is no longer one callback for it. PLAIN and LOGIN share a 286 + `PasswordCheck` — asked whether a password is right and told nothing, so an 287 + application may store a hash — while CRAM-MD5 needs a `PasswordLookup`, 288 + because it has to compute the same HMAC the client did and therefore needs 289 + the password itself. That is the argument against offering CRAM-MD5 at all, 290 + and it is now visible in the types rather than buried. 291 + 292 + Whatever the mechanism reports as the authenticated identity reaches every 293 + `Envelope` as `authenticated_as`, which is what a handler deciding whether to 294 + relay wants — the envelope sender is whatever the client chose to write. 295 + 296 + Setting `Options.require_auth` rejects MAIL with 530 until the client has 297 + authenticated. 270 298 271 299 Instead of `message` (which collects the whole body in memory, bounded by 272 300 `max_message_size`), a handler can set `messageReader` to stream it: the ··· 438 466 which a trusted relay uses to forward the identity that originally 439 467 authenticated. The client-side *mechanisms* are no longer a gap — PLAIN, 440 468 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. 469 + zig-sasl, and the server offers whichever of their server halves it is 470 + given. 443 471 - **Client certificates** — neither side can present or verify one. 444 472 - **No enhanced status code accessor** — the server emits `x.y.z` on every 445 473 reply, but `Reply` exposes only `code` and the raw text. ··· 519 547 - [RFC 4954](https://datatracker.ietf.org/doc/html/rfc4954) — AUTH: client 520 548 and server, including initial responses, empty challenges and `*` 521 549 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. 550 + [zig-sasl](https://git.jcollie.dev/jeff/zig-sasl), and the server offers 551 + whichever of their server halves it is handed — PLAIN 552 + ([RFC 4616](https://datatracker.ietf.org/doc/html/rfc4616)), the de-facto 553 + [LOGIN](https://datatracker.ietf.org/doc/html/draft-murchison-sasl-login-00), 554 + CRAM-MD5 and EXTERNAL among them. 527 555 - [RFC 3463](https://datatracker.ietf.org/doc/html/rfc3463) / 528 556 [RFC 2034](https://datatracker.ietf.org/doc/html/rfc2034) — enhanced 529 557 status codes: carried in every server reply and advertised via
+2 -2
build.zig.zon
··· 54 54 .lazy = true, 55 55 }, 56 56 .sasl = .{ 57 - .url = "git+https://git.jcollie.dev/jeff/zig-sasl.git#7c423d857ba07482e6fdc44d2c6581c25206b272", 58 - .hash = "sasl-0.0.0-s3YcODkwAQCo2OfGTBE8FKq0VIv6xhQIY_tay5YvReJl", 57 + .url = "git+https://git.jcollie.dev/jeff/zig-sasl.git#c6452ffcb932c6cbf34651be2acd694cc18f2fd3", 58 + .hash = "sasl-0.0.0-s3YcOPiJAQCy9rnR9LEZY_Zc_x0oK6yc1FUCD57I2jvc", 59 59 }, 60 60 }, 61 61 .paths = .{
+3 -3
build.zig.zon.nix
··· 175 175 }; 176 176 } 177 177 { 178 - name = "sasl-0.0.0-s3YcODkwAQCo2OfGTBE8FKq0VIv6xhQIY_tay5YvReJl"; 178 + name = "sasl-0.0.0-s3YcOPiJAQCy9rnR9LEZY_Zc_x0oK6yc1FUCD57I2jvc"; 179 179 path = fetchZigArtifact { 180 180 name = "sasl"; 181 - url = "git+https://git.jcollie.dev/jeff/zig-sasl.git#7c423d857ba07482e6fdc44d2c6581c25206b272"; 182 - hash = "sha256-Ft4FPCeoZmNmj8lOaE+Py4a2sJ2JzWWGFoolfoQQhGM="; 181 + url = "git+https://git.jcollie.dev/jeff/zig-sasl.git#c6452ffcb932c6cbf34651be2acd694cc18f2fd3"; 182 + hash = "sha256-6ddHRVnU7Ma6oadl/N+rQD/bgN7YTUljvDt2ZPzlxgw="; 183 183 unpack = true; 184 184 }; 185 185 }
+255 -102
src/Server.zig
··· 21 21 const Io = std.Io; 22 22 const tls = @import("tls"); 23 23 const protocol = @import("protocol.zig"); 24 + const sasl = @import("sasl"); 24 25 25 26 reader: *Io.Reader, 26 27 writer: *Io.Writer, ··· 28 29 options: Options, 29 30 /// True once a STARTTLS handshake has completed for this session. 30 31 secured: bool = false, 32 + /// The identity the client authenticated as, kept for the life of the 33 + /// session and reported on every `Envelope`. 34 + identity_buf: [255]u8 = undefined, 35 + identity_len: usize = 0, 31 36 tls_connection: tls.Connection = undefined, 32 37 tls_reader: tls.Connection.Reader = undefined, 33 38 tls_writer: tls.Connection.Writer = undefined, ··· 48 53 /// `tls.output_buffer_len` bytes, since the handshake and TLS records 49 54 /// run over them. 50 55 tls: ?TlsOptions = null, 51 - /// Reject MAIL with 530 until the client has authenticated. Requires a 52 - /// handler with an `authenticate` callback. 56 + /// The SASL mechanisms this session offers, from 57 + /// [zig-sasl](https://git.jcollie.dev/jeff/zig-sasl) — `sasl.PlainServer` 58 + /// and the rest. Advertised by name in the EHLO response, in this order. 59 + /// 60 + /// **They hold per-exchange state, so each session needs its own.** A set 61 + /// shared between two connections would have them overwrite each other's 62 + /// challenges. `Server.init` is called per connection anyway, so building 63 + /// them alongside it is the natural place. 64 + auth_mechanisms: []const sasl.Server = &.{}, 65 + /// Reject MAIL with 530 until the client has authenticated. Requires at 66 + /// least one entry in `auth_mechanisms`. 53 67 require_auth: bool = false, 54 68 }; 55 69 ··· 140 154 /// Value of the MAIL `ENVID=` parameter, xtext-decoded: an identifier 141 155 /// the sender wants quoted back in any DSN for this message. 142 156 envid: ?[]const u8 = null, 157 + /// The identity the client authenticated as, or null if it did not. 158 + /// 159 + /// This is what the mechanism reported, which is not always the username 160 + /// the client typed: PLAIN carries an authorization identity as well, so 161 + /// a mechanism that honours one reports the identity being acted as. A 162 + /// handler deciding whether to relay wants this rather than the envelope 163 + /// sender, which anybody can write. 164 + authenticated_as: ?[]const u8 = null, 143 165 }; 144 166 145 167 /// What a mail transaction accumulates between MAIL and the end of the ··· 160 182 t.* = .{}; 161 183 } 162 184 163 - fn envelope(t: Transaction) Envelope { 185 + fn envelope(t: Transaction, authenticated_as: ?[]const u8) Envelope { 164 186 return .{ 165 187 .from = t.from.?, 188 + .authenticated_as = authenticated_as, 166 189 .recipients = t.recipients.items, 167 190 .declared_size = t.declared_size, 168 191 .body = t.body, ··· 180 203 vtable: *const VTable, 181 204 182 205 pub const VTable = struct { 183 - /// Called for AUTH with the decoded credentials; return true to 184 - /// accept. When set, AUTH PLAIN and AUTH LOGIN are advertised and 185 - /// accepted ([RFC 4954](https://datatracker.ietf.org/doc/html/rfc4954)). 186 - authenticate: ?*const fn (context: ?*anyopaque, username: []const u8, password: []const u8) bool = null, 187 206 /// Called for MAIL FROM. Null accepts every sender. 188 207 mailFrom: ?*const fn (context: ?*anyopaque, from: []const u8) Decision = null, 189 208 /// Called for each RCPT TO, with the address and any DSN ··· 228 247 defer arena_state.deinit(); 229 248 const arena = arena_state.allocator(); 230 249 231 - std.debug.assert(!s.options.require_auth or s.handler.vtable.authenticate != null); 250 + std.debug.assert(!s.options.require_auth or s.options.auth_mechanisms.len != 0); 232 251 std.debug.assert((s.handler.vtable.message == null) != (s.handler.vtable.messageReader == null)); 233 252 234 253 if (s.options.tls) |config| { ··· 445 464 try s.reply(503, "5.5.1 BINARYMIME requires BDAT"); 446 465 continue; 447 466 } 448 - try s.receiveData(arena, transaction.envelope()); 467 + try s.receiveData(arena, transaction.envelope(s.identity())); 449 468 transaction.clear(); 450 469 _ = arena_state.reset(.retain_capacity); 451 470 }, ··· 460 479 try s.reply(503, "5.5.1 Need RCPT command first"); 461 480 continue; 462 481 } 463 - const outcome = try s.receiveChunked(arena, transaction.envelope(), args); 482 + const outcome = try s.receiveChunked(arena, transaction.envelope(s.identity()), args); 464 483 transaction.clear(); 465 484 _ = arena_state.reset(.retain_capacity); 466 485 switch (outcome) { ··· 504 523 return; 505 524 }, 506 525 .auth => |args| { 507 - if (s.handler.vtable.authenticate == null) { 526 + if (s.options.auth_mechanisms.len == 0) { 508 527 try s.reply(503, "5.5.1 Authentication not enabled"); 509 528 continue; 510 529 } ··· 531 550 } 532 551 } 533 552 553 + /// The largest SASL message this server will send or receive, before base64. 554 + /// See `Client.max_sasl_message`: RFC 4954 §4 suggests 12288 octets of line, 555 + /// and this is that less what base64 and the command around it take. 556 + pub const max_sasl_message = 8192; 557 + 534 558 /// Writes the EHLO or LHLO response: the hostname, then one line per 535 559 /// extension. The two are the same list — RFC 2033 gives LHLO the semantics 536 560 /// of EHLO — and it requires PIPELINING and ENHANCEDSTATUSCODES of an LMTP ··· 543 567 if (config.mode == .starttls and !s.secured) 544 568 try s.writer.writeAll("250-STARTTLS\r\n"); 545 569 } 546 - if (s.handler.vtable.authenticate != null and !authenticated) 547 - try s.writer.writeAll("250-AUTH PLAIN LOGIN\r\n"); 570 + if (s.options.auth_mechanisms.len != 0 and !authenticated) { 571 + try s.writer.writeAll("250-AUTH"); 572 + for (s.options.auth_mechanisms) |mechanism| 573 + try s.writer.print(" {s}", .{mechanism.name()}); 574 + try s.writer.writeAll("\r\n"); 575 + } 548 576 try s.writer.print("250 SIZE {d}\r\n", .{s.options.max_message_size}); 549 577 try s.writer.flush(); 550 578 } ··· 570 598 /// Runs the challenge/response exchange for AUTH PLAIN or AUTH LOGIN 571 599 /// (RFC 4954) and consults the handler's `authenticate` callback. Every 572 600 /// outcome except `disconnected` has already sent its reply. 601 + /// Runs a SASL exchange with whichever of `Options.auth_mechanisms` the 602 + /// client named ([RFC 4954](https://datatracker.ietf.org/doc/html/rfc4954)). 603 + /// 604 + /// The mechanisms come from 605 + /// [zig-sasl](https://git.jcollie.dev/jeff/zig-sasl); what is here is the 606 + /// SMTP half of it — the 334 challenges, the `*` that cancels, 235, and the 607 + /// 504 for a name nothing answers to. 573 608 fn receiveAuth(s: *Server, args: protocol.Command.AuthArgs) RunError!AuthOutcome { 574 - const callback = s.handler.vtable.authenticate.?; 609 + const mechanism = for (s.options.auth_mechanisms) |candidate| { 610 + if (std.ascii.eqlIgnoreCase(candidate.name(), args.mechanism)) break candidate; 611 + } else { 612 + try s.reply(504, "5.5.4 Unrecognized authentication type"); 613 + return .rejected; 614 + }; 575 615 576 - if (std.ascii.eqlIgnoreCase(args.mechanism, "PLAIN")) { 577 - var decoded_buf: [576]u8 = undefined; 578 - var response: []const u8 = args.initial; 579 - if (response.len == 0) { 580 - try s.reply(334, ""); 581 - response = switch (try s.takeAuthLine()) { 582 - .line => |line| line, 583 - .cancelled => return .rejected, 584 - .disconnected => return .disconnected, 585 - }; 586 - } 587 - const decoded = decodeBase64(&decoded_buf, response) orelse { 588 - try s.reply(501, "5.5.2 Invalid base64"); 589 - return .rejected; 590 - }; 591 - // authzid NUL authcid NUL password; the authzid is ignored. 592 - const first_nul = std.mem.indexOfScalar(u8, decoded, 0) orelse { 593 - try s.reply(501, "5.5.2 Malformed PLAIN response"); 594 - return .rejected; 595 - }; 596 - const after_authzid = decoded[first_nul + 1 ..]; 597 - const second_nul = std.mem.indexOfScalar(u8, after_authzid, 0) orelse { 598 - try s.reply(501, "5.5.2 Malformed PLAIN response"); 599 - return .rejected; 600 - }; 601 - return s.finishAuth(callback, after_authzid[0..second_nul], after_authzid[second_nul + 1 ..]); 602 - } 616 + var decoded_buf: [max_sasl_message]u8 = undefined; 617 + var challenge_buf: [max_sasl_message]u8 = undefined; 618 + var challenge: Io.Writer = .fixed(&challenge_buf); 603 619 604 - if (std.ascii.eqlIgnoreCase(args.mechanism, "LOGIN")) { 605 - var user_buf: [192]u8 = undefined; 606 - var pass_buf: [192]u8 = undefined; 620 + // RFC 4954 §4: no argument at all and a single `=` are different. The 621 + // first is "I have nothing to send yet", the second an initial response 622 + // that happens to be empty, and mechanisms read them differently. 623 + const initial: ?[]const u8 = if (args.initial.len == 0) null else decodeBase64( 624 + &decoded_buf, 625 + args.initial, 626 + ) orelse { 627 + try s.reply(501, "5.5.2 Invalid base64"); 628 + return .rejected; 629 + }; 607 630 608 - var username: []const u8 = undefined; 609 - if (args.initial.len > 0) { 610 - // Some clients send the username as an initial response. 611 - username = decodeBase64(&user_buf, args.initial) orelse { 612 - try s.reply(501, "5.5.2 Invalid base64"); 631 + var step = mechanism.start(initial, &challenge) catch |err| return s.authFailed(err); 632 + while (true) { 633 + switch (step) { 634 + .accepted => |who| { 635 + s.setIdentity(who); 636 + try s.reply(235, "2.7.0 Authentication successful"); 637 + return .authenticated; 638 + }, 639 + .rejected => { 640 + // No distinction between "no such user" and "wrong password" 641 + // reaches the wire: that difference is worth money to 642 + // somebody enumerating accounts. 643 + try s.reply(535, "5.7.8 Authentication credentials invalid"); 613 644 return .rejected; 614 - }; 615 - } else { 616 - try s.reply(334, "VXNlcm5hbWU6"); // base64("Username:") 617 - const line = switch (try s.takeAuthLine()) { 618 - .line => |line| line, 619 - .cancelled => return .rejected, 620 - .disconnected => return .disconnected, 621 - }; 622 - username = decodeBase64(&user_buf, line) orelse { 623 - try s.reply(501, "5.5.2 Invalid base64"); 624 - return .rejected; 625 - }; 626 - } 627 - try s.reply(334, "UGFzc3dvcmQ6"); // base64("Password:") 628 - const line = switch (try s.takeAuthLine()) { 629 - .line => |line| line, 630 - .cancelled => return .rejected, 631 - .disconnected => return .disconnected, 632 - }; 633 - const password = decodeBase64(&pass_buf, line) orelse { 634 - try s.reply(501, "5.5.2 Invalid base64"); 635 - return .rejected; 636 - }; 637 - return s.finishAuth(callback, username, password); 638 - } 645 + }, 646 + .challenge => { 647 + var encoded_buf: [std.base64.standard.Encoder.calcSize(max_sasl_message)]u8 = undefined; 648 + const encoded = std.base64.standard.Encoder.encode(&encoded_buf, challenge.buffered()); 649 + // A zero-length challenge is "334 " — the code, a space, and 650 + // nothing after it, which `reply` produces for empty text. 651 + try s.reply(334, encoded); 639 652 640 - try s.reply(504, "5.5.4 Unrecognized authentication type"); 641 - return .rejected; 653 + const line = switch (try s.takeAuthLine()) { 654 + .line => |line| line, 655 + .cancelled => return .rejected, 656 + .disconnected => return .disconnected, 657 + }; 658 + const response = decodeBase64(&decoded_buf, line) orelse { 659 + try s.reply(501, "5.5.2 Invalid base64"); 660 + return .rejected; 661 + }; 662 + challenge = .fixed(&challenge_buf); 663 + step = mechanism.respond(response, &challenge) catch |err| 664 + return s.authFailed(err); 665 + }, 666 + } 667 + } 642 668 } 643 669 644 - fn finishAuth( 645 - s: *Server, 646 - callback: *const fn (?*anyopaque, []const u8, []const u8) bool, 647 - username: []const u8, 648 - password: []const u8, 649 - ) RunError!AuthOutcome { 650 - if (callback(s.handler.context, username, password)) { 651 - try s.reply(235, "2.7.0 Authentication successful"); 652 - return .authenticated; 670 + /// A mechanism that could not make sense of what the client sent. Its own 671 + /// errors are not worth distinguishing on the wire. 672 + fn authFailed(s: *Server, err: sasl.Server.Error) RunError!AuthOutcome { 673 + switch (err) { 674 + error.OutOfMemory => return error.OutOfMemory, 675 + error.WriteFailed => return error.WriteFailed, 676 + error.BadResponse => { 677 + try s.reply(501, "5.5.2 Malformed authentication response"); 678 + return .rejected; 679 + }, 653 680 } 654 - try s.reply(535, "5.7.8 Authentication credentials invalid"); 655 - return .rejected; 681 + } 682 + 683 + /// The identity the client authenticated as, or null if it has not. 684 + pub fn identity(s: *const Server) ?[]const u8 { 685 + if (s.identity_len == 0) return null; 686 + return s.identity_buf[0..s.identity_len]; 687 + } 688 + 689 + /// Keeps the authenticated identity for the rest of the session. 690 + /// 691 + /// Copied because a mechanism may report a slice of the response it was 692 + /// handed, which lives in a buffer that does not outlive the exchange — and 693 + /// this has to survive every transaction that follows. 694 + fn setIdentity(s: *Server, who: []const u8) void { 695 + s.identity_len = @min(who.len, s.identity_buf.len); 696 + @memcpy(s.identity_buf[0..s.identity_len], who[0..s.identity_len]); 656 697 } 657 698 658 699 const AuthLine = union(enum) { line: []u8, cancelled, disconnected }; ··· 1094 1135 last_orcpt_type: std.ArrayList(u8) = .empty, 1095 1136 last_orcpt_address: std.ArrayList(u8) = .empty, 1096 1137 ret: ?protocol.Ret = null, 1138 + identity: std.ArrayList(u8) = .empty, 1097 1139 envid: std.ArrayList(u8) = .empty, 1098 1140 /// When set, enables the authenticate callback accepting user "alice" 1099 1141 /// with this password. ··· 1104 1146 h.recipients.deinit(std.testing.allocator); 1105 1147 h.data.deinit(std.testing.allocator); 1106 1148 h.envid.deinit(std.testing.allocator); 1149 + h.identity.deinit(std.testing.allocator); 1107 1150 h.last_orcpt_type.deinit(std.testing.allocator); 1108 1151 h.last_orcpt_address.deinit(std.testing.allocator); 1109 1152 } 1110 1153 1111 1154 fn handler(h: *TestHandler) Handler { 1112 - return .{ .context = h, .vtable = if (h.password != null) &.{ 1113 - .authenticate = onAuthenticate, 1114 - .rcptTo = onRcptTo, 1115 - .message = onMessage, 1116 - .recipientResult = onRecipientResult, 1117 - } else &.{ 1155 + return .{ .context = h, .vtable = &.{ 1118 1156 .rcptTo = onRcptTo, 1119 1157 .message = onMessage, 1120 1158 .recipientResult = onRecipientResult, 1121 1159 } }; 1160 + } 1161 + 1162 + /// The credential check the SASL mechanisms are built from, accepting 1163 + /// "alice" with whatever `password` holds. 1164 + fn check(h: *TestHandler) sasl.Server.PasswordCheck { 1165 + return .{ .context = h, .verify = verify }; 1166 + } 1167 + 1168 + fn verify( 1169 + context: ?*anyopaque, 1170 + authzid: []const u8, 1171 + authcid: []const u8, 1172 + password: []const u8, 1173 + ) ?[]const u8 { 1174 + const h: *TestHandler = @ptrCast(@alignCast(context.?)); 1175 + if (authzid.len != 0) return null; 1176 + if (!std.mem.eql(u8, authcid, "alice")) return null; 1177 + if (!std.mem.eql(u8, password, h.password.?)) return null; 1178 + return "alice"; 1122 1179 } 1123 1180 1124 1181 /// LMTP's per-recipient verdict: everybody is fine except the one ··· 1130 1187 if (std.mem.eql(u8, envelope.recipients[index].address, failing)) 1131 1188 return .{ .reject = .{ .code = 550, .text = "5.2.1 Mailbox disabled" } }; 1132 1189 return .accept; 1133 - } 1134 - 1135 - fn onAuthenticate(context: ?*anyopaque, username: []const u8, password: []const u8) bool { 1136 - const h: *TestHandler = @ptrCast(@alignCast(context.?)); 1137 - return std.mem.eql(u8, username, "alice") and 1138 - std.mem.eql(u8, password, h.password.?); 1139 1190 } 1140 1191 1141 1192 fn onRcptTo(context: ?*anyopaque, recipient: Recipient) Decision { ··· 1171 1222 h.body = envelope.body; 1172 1223 h.smtputf8 = envelope.smtputf8; 1173 1224 h.ret = envelope.ret; 1225 + if (envelope.authenticated_as) |who| 1226 + h.identity.appendSlice(gpa, who) catch return .{ .reject = .{} }; 1174 1227 if (envelope.envid) |envid| h.envid.appendSlice(gpa, envid) catch return .{ .reject = .{} }; 1175 1228 return .accept; 1176 1229 } ··· 1273 1326 1274 1327 try std.testing.expect(std.mem.endsWith(u8, bw.sink.items, "250 2.1.0 Ok\r\n")); 1275 1328 } 1329 + 1330 + /// The mechanisms a test session offers, built from a `TestHandler`'s 1331 + /// credential check. They hold per-exchange state, so each test makes its 1332 + /// own rather than sharing a constant. 1333 + const TestMechanisms = struct { 1334 + plain: sasl.PlainServer, 1335 + login: sasl.LoginServer, 1336 + storage: [2]sasl.Server = undefined, 1337 + 1338 + fn init(h: *TestHandler) TestMechanisms { 1339 + return .{ .plain = .init(h.check()), .login = .init(h.check()) }; 1340 + } 1341 + 1342 + fn list(m: *TestMechanisms) []const sasl.Server { 1343 + m.storage = .{ m.plain.server(), m.login.server() }; 1344 + return &m.storage; 1345 + } 1346 + }; 1276 1347 1277 1348 fn runScript(input: []const u8, out_buf: []u8, handler: Handler, options: Options) ![]const u8 { 1278 1349 var reader: Io.Reader = .fixed(input); ··· 1626 1697 test "AUTH PLAIN with initial response" { 1627 1698 var h: TestHandler = .{ .password = "secret" }; 1628 1699 defer h.deinit(); 1700 + var mechanisms: TestMechanisms = .init(&h); 1629 1701 1630 1702 var out_buf: [1024]u8 = undefined; 1631 1703 // base64("\x00alice\x00secret") ··· 1638 1710 "QUIT\r\n", 1639 1711 &out_buf, 1640 1712 h.handler(), 1641 - .{ .require_auth = true }, 1713 + .{ .require_auth = true, .auth_mechanisms = mechanisms.list() }, 1642 1714 ); 1643 1715 1644 1716 try std.testing.expect(std.mem.indexOf(u8, output, "250-AUTH PLAIN LOGIN\r\n") != null); ··· 1649 1721 test "AUTH LOGIN challenge exchange" { 1650 1722 var h: TestHandler = .{ .password = "secret" }; 1651 1723 defer h.deinit(); 1724 + var mechanisms: TestMechanisms = .init(&h); 1652 1725 1653 1726 var out_buf: [1024]u8 = undefined; 1654 1727 // base64("alice"), base64("secret") ··· 1660 1733 "QUIT\r\n", 1661 1734 &out_buf, 1662 1735 h.handler(), 1663 - .{}, 1736 + .{ .auth_mechanisms = mechanisms.list() }, 1664 1737 ); 1665 1738 1666 1739 try std.testing.expect(std.mem.indexOf(u8, output, "334 VXNlcm5hbWU6\r\n") != null); ··· 1668 1741 try std.testing.expect(std.mem.indexOf(u8, output, "235 2.7.0") != null); 1669 1742 } 1670 1743 1744 + test "the server can now offer CRAM-MD5, which it never could before" { 1745 + var h: TestHandler = .{ .password = "secret" }; 1746 + defer h.deinit(); 1747 + 1748 + // The challenge is the server's to choose; a real one would not repeat. 1749 + const challenge = "<1896.697170952@postoffice.reston.mci.net>"; 1750 + const Lookup = struct { 1751 + fn lookup(context: ?*anyopaque, username: []const u8) ?[]const u8 { 1752 + const handler: *TestHandler = @ptrCast(@alignCast(context.?)); 1753 + if (!std.mem.eql(u8, username, "tim")) return null; 1754 + _ = handler; 1755 + return "tanstaaftanstaaf"; 1756 + } 1757 + }; 1758 + var cram: sasl.CramMd5Server = .init(challenge, .{ 1759 + .context = &h, 1760 + .lookup = Lookup.lookup, 1761 + }); 1762 + const mechanisms: []const sasl.Server = &.{cram.server()}; 1763 + 1764 + var out_buf: [2048]u8 = undefined; 1765 + const output = try runScript( 1766 + "EHLO client.example.org\r\n" ++ 1767 + "AUTH CRAM-MD5\r\n" ++ 1768 + // base64("tim b913a602c7eda7a495b4e6e7334d3890"), the response 1769 + // RFC 2195 publishes for this challenge and account. 1770 + "dGltIGI5MTNhNjAyYzdlZGE3YTQ5NWI0ZTZlNzMzNGQzODkw\r\n" ++ 1771 + "MAIL FROM:<tim@example.com>\r\n" ++ 1772 + "RCPT TO:<bob@example.net>\r\n" ++ 1773 + "DATA\r\nbody\r\n.\r\nQUIT\r\n", 1774 + &out_buf, 1775 + h.handler(), 1776 + .{ .require_auth = true, .auth_mechanisms = mechanisms }, 1777 + ); 1778 + 1779 + try std.testing.expect(std.mem.indexOf(u8, output, "250-AUTH CRAM-MD5\r\n") != null); 1780 + // The challenge went out base64'd, and the login was accepted. 1781 + try std.testing.expect(std.mem.indexOf(u8, output, "334 PDE4OTYuNjk3") != null); 1782 + try std.testing.expect(std.mem.indexOf(u8, output, "235 2.7.0") != null); 1783 + try std.testing.expectEqual(@as(usize, 1), h.messages_accepted); 1784 + // And the identity the mechanism reported reached the envelope, which is 1785 + // what a handler deciding whether to relay actually needs. 1786 + try std.testing.expectEqualStrings("tim", h.identity.items); 1787 + } 1788 + 1789 + test "the advertised mechanisms are the ones offered, in order" { 1790 + var h: TestHandler = .{ .password = "secret" }; 1791 + defer h.deinit(); 1792 + var mechanisms: TestMechanisms = .init(&h); 1793 + 1794 + var out_buf: [2048]u8 = undefined; 1795 + const output = try runScript( 1796 + "EHLO client.example.org\r\nAUTH SCRAM-SHA-256\r\nQUIT\r\n", 1797 + &out_buf, 1798 + h.handler(), 1799 + .{ .auth_mechanisms = mechanisms.list() }, 1800 + ); 1801 + try std.testing.expect(std.mem.indexOf(u8, output, "250-AUTH PLAIN LOGIN\r\n") != null); 1802 + // A name nothing answers to is 504, not 535: the credentials were never 1803 + // in question. 1804 + try std.testing.expect(std.mem.indexOf(u8, output, "504 5.5.4") != null); 1805 + } 1806 + 1807 + test "a session with no mechanisms does not advertise AUTH at all" { 1808 + var h: TestHandler = .{}; 1809 + defer h.deinit(); 1810 + 1811 + var out_buf: [2048]u8 = undefined; 1812 + const output = try runScript( 1813 + "EHLO client.example.org\r\nAUTH PLAIN AGFsaWNlAHNlY3JldA==\r\nQUIT\r\n", 1814 + &out_buf, 1815 + h.handler(), 1816 + .{}, 1817 + ); 1818 + try std.testing.expect(std.mem.indexOf(u8, output, "AUTH") == null or 1819 + std.mem.indexOf(u8, output, "250-AUTH") == null); 1820 + try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Authentication not enabled") != null); 1821 + } 1822 + 1671 1823 test "AUTH failures and sequencing" { 1672 1824 var h: TestHandler = .{ .password = "secret" }; 1673 1825 defer h.deinit(); 1826 + var mechanisms: TestMechanisms = .init(&h); 1674 1827 1675 1828 var out_buf: [2048]u8 = undefined; 1676 1829 const output = try runScript( ··· 1686 1839 "QUIT\r\n", 1687 1840 &out_buf, 1688 1841 h.handler(), 1689 - .{ .require_auth = true }, 1842 + .{ .require_auth = true, .auth_mechanisms = mechanisms.list() }, 1690 1843 ); 1691 1844 1692 1845 try std.testing.expect(std.mem.indexOf(u8, output, "530 5.7.0") != null);
+55 -8
src/main.zig
··· 424 424 .password = config.password, 425 425 .fail_delivery = config.fail_delivery, 426 426 }; 427 + // The credential check, which PLAIN and LOGIN share, and the password 428 + // lookup CRAM-MD5 needs instead. Both close over the same one account. 429 + const check: smtp.sasl.Server.PasswordCheck = .{ 430 + .context = &printer, 431 + .verify = MessagePrinter.verify, 432 + }; 433 + const passwords: smtp.sasl.Server.PasswordLookup = .{ 434 + .context = &printer, 435 + .lookup = MessagePrinter.lookup, 436 + }; 437 + 438 + var connections: usize = 0; 427 439 while (true) { 428 440 const stream = try listener.accept(io); 429 441 defer stream.close(io); 442 + connections += 1; 443 + 444 + // A fresh set per connection: the mechanisms hold per-exchange state, 445 + // and CRAM-MD5's challenge must not repeat between them. 446 + // RFC 2195 wants a challenge that never repeats. A counter and the 447 + // clock is what a real server would use, plus its hostname. 448 + var challenge_buf: [128]u8 = undefined; 449 + const challenge = std.fmt.bufPrint( 450 + &challenge_buf, 451 + "<{d}.{d}@localhost>", 452 + .{ connections, Io.Clock.real.now(io).nanoseconds }, 453 + ) catch unreachable; 454 + var plain: smtp.sasl.PlainServer = .init(check); 455 + var login: smtp.sasl.LoginServer = .init(check); 456 + var cram_md5: smtp.sasl.CramMd5Server = .init(challenge, passwords); 457 + const mechanisms: []const smtp.sasl.Server = if (config.username == null) 458 + &.{} 459 + else 460 + &.{ plain.server(), login.server(), cram_md5.server() }; 461 + 430 462 // Sized for the TLS handshake, which runs over the raw stream. 431 463 const read_buf = try gpa.alloc(u8, smtp.tls.input_buffer_len); 432 464 defer gpa.free(read_buf); ··· 437 469 var session: smtp.Server = .init( 438 470 &stream_reader.interface, 439 471 &stream_writer.interface, 440 - .{ .context = &printer, .vtable = if (config.username != null) &.{ 441 - .authenticate = MessagePrinter.onAuthenticate, 442 - .message = MessagePrinter.onMessage, 443 - .recipientResult = MessagePrinter.onRecipientResult, 444 - } else &.{ 472 + .{ .context = &printer, .vtable = &.{ 445 473 .message = MessagePrinter.onMessage, 446 474 .recipientResult = MessagePrinter.onRecipientResult, 447 475 } }, ··· 449 477 .protocol = config.protocol, 450 478 .hostname = "localhost", 451 479 .tls = tls_options, 480 + .auth_mechanisms = mechanisms, 452 481 .require_auth = config.username != null, 453 482 }, 454 483 ); ··· 464 493 password: ?[]const u8 = null, 465 494 fail_delivery: ?[]const u8 = null, 466 495 467 - fn onAuthenticate(context: ?*anyopaque, username: []const u8, password: []const u8) bool { 496 + /// What PLAIN and LOGIN ask: is this password right? The answer is the 497 + /// identity to report, which for this one-account server is the username. 498 + fn verify( 499 + context: ?*anyopaque, 500 + authzid: []const u8, 501 + authcid: []const u8, 502 + password: []const u8, 503 + ) ?[]const u8 { 468 504 const printer: *MessagePrinter = @ptrCast(@alignCast(context.?)); 469 - return std.mem.eql(u8, username, printer.username.?) and 470 - std.mem.eql(u8, password, printer.password.?); 505 + // Acting as somebody else is not a thing this server does. 506 + if (authzid.len != 0) return null; 507 + if (!std.mem.eql(u8, authcid, printer.username.?)) return null; 508 + if (!std.mem.eql(u8, password, printer.password.?)) return null; 509 + return printer.username.?; 510 + } 511 + 512 + /// What CRAM-MD5 asks instead: the password itself, because it has to 513 + /// compute the same HMAC the client did. 514 + fn lookup(context: ?*anyopaque, username: []const u8) ?[]const u8 { 515 + const printer: *MessagePrinter = @ptrCast(@alignCast(context.?)); 516 + if (!std.mem.eql(u8, username, printer.username.?)) return null; 517 + return printer.password.?; 471 518 } 472 519 473 520 fn onMessage(context: ?*anyopaque, envelope: smtp.Server.Envelope, data: []const u8) smtp.Server.Decision {