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.

Add SMTP AUTH to client and server

Client (RFC 4954/4616/2195):
- Extensions.auth parses the advertised mechanism list (including the
legacy AUTH= form) into plain/login/cram_md5 flags
- authLogin and authCramMd5 join authPlain; CRAM-MD5 is verified against
the RFC 2195 example vector
- authenticate() picks PLAIN, then LOGIN, then CRAM-MD5; a 535 surfaces
as error.AuthenticationFailed with the reply in last_reply

Server:
- an optional authenticate handler callback enables AUTH PLAIN and
LOGIN: initial responses, 334 challenges, "*" cancellation, bad
base64 (501), unknown mechanism (504), re-auth/mid-transaction (503)
- Options.require_auth rejects MAIL with 530 until authenticated;
STARTTLS resets auth state

CLI: send grew --user/--password/--auth-method, serve grew
--auth user:pass (implies require_auth).

VM interop additions: zsmtp client authenticates to Exim via PLAIN and
LOGIN (its plaintext authenticator; CRAM-MD5 is not compiled into
nixpkgs exim) with a wrong-password rejection, and swaks authenticates
to the auth-required zsmtp server via PLAIN and LOGIN with
wrong-password and unauthenticated rejections.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx

+664 -20
+23 -5
README.md
··· 28 28 Line endings in the message are normalized to CRLF and leading dots are 29 29 stuffed automatically. On `error.UnexpectedReply`, `client.last_reply` holds 30 30 the server's actual code and text. `mailFrom`/`rcptTo`/`sendMessage` are also 31 - available individually, as is `authPlain`. 31 + available individually. 32 + 33 + ### Authentication 34 + 35 + `hello` reports the server's advertised mechanisms in `extensions.auth`; 36 + `authenticate` picks the best one (PLAIN, then LOGIN, then CRAM-MD5), or use 37 + `authPlain`/`authLogin`/`authCramMd5` directly. PLAIN and LOGIN send 38 + credentials unprotected, so use TLS on real networks. A 535 rejection 39 + surfaces as `error.AuthenticationFailed` with the reply in `last_reply`. 40 + 41 + ```zig 42 + const extensions = try client.hello("my-host.example.com"); 43 + try client.authenticate(extensions, "user", "password"); 44 + ``` 32 45 33 46 ### TLS 34 47 ··· 75 88 var session: zsmtp.Server = .init(&stream_reader.interface, &stream_writer.interface, .{ 76 89 .context = &my_state, 77 90 .vtable = &.{ 91 + .authenticate = onAuth, // optional; enables AUTH PLAIN and LOGIN 78 92 .rcptTo = onRcptTo, // optional; accept/reject each recipient 79 93 .message = onMessage, // required; receives envelope + message data 80 94 }, 81 95 }, .{ .hostname = "mx.example.com" }); 82 96 try session.run(gpa); 83 97 ``` 98 + 99 + With an `authenticate` callback the session advertises and accepts AUTH 100 + PLAIN and AUTH LOGIN (RFC 4954); setting `Options.require_auth` rejects MAIL 101 + with 530 until the client has authenticated. 84 102 85 103 `run` serves one connection until QUIT or disconnect, enforcing command 86 104 sequencing, recipient and message-size limits, and un-stuffing message data. ··· 131 149 TLS is supported on both sides via 132 150 [ianic/tls.zig](https://github.com/ianic/tls.zig): the client does implicit 133 151 TLS and STARTTLS via `zsmtp.Tls`, and the server accepts STARTTLS (TLS 1.3 134 - only). Not yet 135 - implemented: implicit TLS on the server side, streaming (non-slice) message 136 - bodies, AUTH beyond PLAIN, and ESMTP parameter handling (SIZE=, BODY=) on 137 - the server side. 152 + only). AUTH covers PLAIN, LOGIN, and CRAM-MD5 on the client and PLAIN and 153 + LOGIN on the server. Not yet implemented: implicit TLS on the server side, 154 + streaming (non-slice) message bodies, and ESMTP parameter handling (SIZE=, 155 + BODY=) on the server side. 138 156 139 157 ## Tests 140 158
+85
nix/interop-test.nix
··· 99 99 delivery_date_add 100 100 envelope_to_add 101 101 return_path_add 102 + 103 + begin authenticators 104 + 105 + plain_server: 106 + driver = plaintext 107 + public_name = PLAIN 108 + server_prompts = : 109 + server_condition = ''${if and{{eq{$auth2}{alice}}{eq{$auth3}{secret}}}} 110 + server_set_id = $auth2 111 + 112 + login_server: 113 + driver = plaintext 114 + public_name = LOGIN 115 + server_prompts = Username:: : Password:: 116 + server_condition = ''${if and{{eq{$auth1}{alice}}{eq{$auth2}{secret}}}} 117 + server_set_id = $auth1 102 118 ''; 103 119 }; 104 120 ··· 123 139 DynamicUser = true; 124 140 }; 125 141 }; 142 + 143 + systemd.services.zsmtp-server-auth = { 144 + description = "zsmtp debug server (authentication required)"; 145 + wantedBy = [ "multi-user.target" ]; 146 + serviceConfig = { 147 + ExecStart = "${zsmtp}/bin/zsmtp serve --auth alice:secret 2527"; 148 + DynamicUser = true; 149 + }; 150 + }; 126 151 }; 127 152 128 153 testScript = '' ··· 134 159 machine.wait_for_open_port(2626) 135 160 machine.wait_for_unit("zsmtp-server.service") 136 161 machine.wait_for_unit("zsmtp-server-tls.service") 162 + machine.wait_for_unit("zsmtp-server-auth.service") 137 163 machine.wait_for_open_port(2525) 138 164 machine.wait_for_open_port(2526) 165 + machine.wait_for_open_port(2527) 139 166 140 167 141 168 def deliver(flags, port, needle, mailbox): ··· 163 190 164 191 with subtest(f"zsmtp client to {name}, implicit TLS"): 165 192 deliver("--tls --insecure", tls_port, f"zsmtp to {name} smtps", mailbox) 193 + 194 + with subtest("zsmtp client to exim, AUTH PLAIN"): 195 + deliver( 196 + "--user alice --password secret --auth-method plain", 197 + 2625, 198 + "zsmtp to exim auth plain", 199 + "/var/spool/exim-mail/alice", 200 + ) 201 + 202 + with subtest("zsmtp client to exim, AUTH LOGIN"): 203 + deliver( 204 + "--user alice --password secret --auth-method login", 205 + 2625, 206 + "zsmtp to exim auth login", 207 + "/var/spool/exim-mail/alice", 208 + ) 209 + 210 + with subtest("zsmtp client to exim, wrong password is rejected"): 211 + machine.fail( 212 + "printf 'Subject: interop\\r\\n\\r\\nnope\\r\\n'" 213 + " | zsmtp send --user alice --password wrong 127.0.0.1 2625" 214 + " bob@example.com alice@localhost" 215 + ) 216 + 217 + with subtest("swaks to zsmtp server, AUTH PLAIN"): 218 + machine.succeed( 219 + "swaks --server 127.0.0.1:2527 --auth PLAIN --auth-user alice" 220 + " --auth-password secret --from bob@example.com" 221 + " --to alice@example.net --body 'swaks to zsmtp auth plain'" 222 + ) 223 + machine.wait_until_succeeds( 224 + "journalctl -u zsmtp-server-auth | grep 'swaks to zsmtp auth plain'", 225 + timeout=60, 226 + ) 227 + 228 + with subtest("swaks to zsmtp server, AUTH LOGIN"): 229 + machine.succeed( 230 + "swaks --server 127.0.0.1:2527 --auth LOGIN --auth-user alice" 231 + " --auth-password secret --from bob@example.com" 232 + " --to alice@example.net --body 'swaks to zsmtp auth login'" 233 + ) 234 + machine.wait_until_succeeds( 235 + "journalctl -u zsmtp-server-auth | grep 'swaks to zsmtp auth login'", 236 + timeout=60, 237 + ) 238 + 239 + with subtest("swaks to zsmtp server, wrong password is rejected"): 240 + machine.fail( 241 + "swaks --server 127.0.0.1:2527 --auth PLAIN --auth-user alice" 242 + " --auth-password wrong --from bob@example.com" 243 + " --to alice@example.net --body nope" 244 + ) 245 + 246 + with subtest("unauthenticated mail to auth-required server is rejected"): 247 + machine.fail( 248 + "printf 'Subject: interop\\r\\n\\r\\nnope\\r\\n'" 249 + " | zsmtp send 127.0.0.1 2527 bob@example.com alice@example.net" 250 + ) 166 251 167 252 with subtest("swaks to zsmtp server, plaintext"): 168 253 machine.succeed(
+188 -4
src/Client.zig
··· 48 48 starttls: bool = false, 49 49 smtputf8: bool = false, 50 50 enhanced_status_codes: bool = false, 51 - auth: bool = false, 51 + /// AUTH mechanisms advertised by the server. 52 + auth: Auth = .{}, 52 53 /// Value of the SIZE extension, if advertised with a value. 53 54 max_size: ?u64 = null, 55 + 56 + pub const Auth = struct { 57 + plain: bool = false, 58 + login: bool = false, 59 + cram_md5: bool = false, 60 + 61 + pub fn any(a: Auth) bool { 62 + return a.plain or a.login or a.cram_md5; 63 + } 64 + 65 + fn parse(arg: []const u8) Auth { 66 + var auth: Auth = .{}; 67 + var it = std.mem.tokenizeScalar(u8, arg, ' '); 68 + while (it.next()) |mechanism| { 69 + if (ieql(mechanism, "PLAIN")) { 70 + auth.plain = true; 71 + } else if (ieql(mechanism, "LOGIN")) { 72 + auth.login = true; 73 + } else if (ieql(mechanism, "CRAM-MD5")) { 74 + auth.cram_md5 = true; 75 + } 76 + } 77 + return auth; 78 + } 79 + }; 54 80 55 81 fn parse(reply: Reply) Extensions { 56 82 var ext: Extensions = .{}; ··· 71 97 } else if (ieql(kw, "ENHANCEDSTATUSCODES")) { 72 98 ext.enhanced_status_codes = true; 73 99 } else if (ieql(kw, "AUTH")) { 74 - ext.auth = true; 100 + ext.auth = Auth.parse(arg); 101 + } else if (kw.len > 5 and ieql(kw[0..5], "AUTH=")) { 102 + // Some legacy servers advertise "AUTH=PLAIN LOGIN". 103 + var legacy_arg_buf: [128]u8 = undefined; 104 + const joined = std.fmt.bufPrint(&legacy_arg_buf, "{s} {s}", .{ kw[5..], arg }) catch kw[5..]; 105 + ext.auth = Auth.parse(joined); 75 106 } else if (ieql(kw, "SIZE")) { 76 107 ext.max_size = std.fmt.parseInt(u64, arg, 10) catch null; 77 108 } ··· 127 158 c.writer = writer; 128 159 } 129 160 161 + pub const AuthError = Error || error{ 162 + CredentialsTooLong, 163 + /// The server rejected the credentials; see `last_reply`. 164 + AuthenticationFailed, 165 + /// The server's CRAM-MD5 challenge was not valid base64. 166 + InvalidChallenge, 167 + /// The server advertised none of the supported mechanisms. 168 + NoSupportedMechanism, 169 + }; 170 + 171 + /// Authenticates with the best mechanism the server advertised (PLAIN, 172 + /// then LOGIN, then CRAM-MD5). Note that PLAIN and LOGIN send credentials 173 + /// unprotected: use TLS on real networks. 174 + pub fn authenticate(c: *Client, extensions: Extensions, username: []const u8, password: []const u8) AuthError!void { 175 + if (extensions.auth.plain) return c.authPlain("", username, password); 176 + if (extensions.auth.login) return c.authLogin(username, password); 177 + if (extensions.auth.cram_md5) return c.authCramMd5(username, password); 178 + return error.NoSupportedMechanism; 179 + } 180 + 130 181 /// Authenticates with AUTH PLAIN (RFC 4616). Pass an empty `authzid` unless 131 182 /// you need to act on behalf of another identity. Note that sending 132 183 /// credentials over an unencrypted connection exposes them to the network. 133 - pub fn authPlain(c: *Client, authzid: []const u8, username: []const u8, password: []const u8) (Error || error{CredentialsTooLong})!void { 184 + pub fn authPlain(c: *Client, authzid: []const u8, username: []const u8, password: []const u8) AuthError!void { 134 185 var plain_buf: [512]u8 = undefined; 135 186 var plain: Io.Writer = .fixed(&plain_buf); 136 187 plain.print("{s}\x00{s}\x00{s}", .{ authzid, username, password }) catch ··· 138 189 var b64_buf: [std.base64.standard.Encoder.calcSize(plain_buf.len)]u8 = undefined; 139 190 const b64 = std.base64.standard.Encoder.encode(&b64_buf, plain.buffered()); 140 191 try c.send("AUTH PLAIN {s}", .{b64}); 141 - _ = try c.expect(235); 192 + try c.expectAuthSuccess(); 193 + } 194 + 195 + /// Authenticates with AUTH LOGIN, the legacy two-step username/password 196 + /// exchange still required by some servers. 197 + pub fn authLogin(c: *Client, username: []const u8, password: []const u8) AuthError!void { 198 + try c.send("AUTH LOGIN", .{}); 199 + _ = try c.expect(334); // Username: prompt 200 + try c.sendBase64(username); 201 + _ = try c.expect(334); // Password: prompt 202 + try c.sendBase64(password); 203 + try c.expectAuthSuccess(); 204 + } 205 + 206 + /// Authenticates with AUTH CRAM-MD5 (RFC 2195): the password never crosses 207 + /// the wire, only an HMAC-MD5 of the server's challenge. 208 + pub fn authCramMd5(c: *Client, username: []const u8, password: []const u8) AuthError!void { 209 + try c.send("AUTH CRAM-MD5", .{}); 210 + const reply = try c.expect(334); 211 + 212 + var challenge_buf: [512]u8 = undefined; 213 + const challenge_len = std.base64.standard.Decoder.calcSizeForSlice(reply.text) catch 214 + return error.InvalidChallenge; 215 + if (challenge_len > challenge_buf.len) return error.InvalidChallenge; 216 + std.base64.standard.Decoder.decode(challenge_buf[0..challenge_len], reply.text) catch 217 + return error.InvalidChallenge; 218 + 219 + var mac: [std.crypto.auth.hmac.HmacMd5.mac_length]u8 = undefined; 220 + std.crypto.auth.hmac.HmacMd5.create(&mac, challenge_buf[0..challenge_len], password); 221 + const digest = std.fmt.bytesToHex(mac, .lower); 222 + 223 + var response_buf: [384]u8 = undefined; 224 + var response: Io.Writer = .fixed(&response_buf); 225 + response.print("{s} {s}", .{ username, digest }) catch return error.CredentialsTooLong; 226 + try c.sendBase64(response.buffered()); 227 + try c.expectAuthSuccess(); 228 + } 229 + 230 + /// Sends `bytes` base64-encoded as a bare continuation line. 231 + fn sendBase64(c: *Client, bytes: []const u8) AuthError!void { 232 + var b64_buf: [std.base64.standard.Encoder.calcSize(384)]u8 = undefined; 233 + if (std.base64.standard.Encoder.calcSize(bytes.len) > b64_buf.len) 234 + return error.CredentialsTooLong; 235 + const b64 = std.base64.standard.Encoder.encode(&b64_buf, bytes); 236 + try c.send("{s}", .{b64}); 237 + } 238 + 239 + fn expectAuthSuccess(c: *Client) AuthError!void { 240 + const reply = try c.readReply(); 241 + if (reply.code != 235) return error.AuthenticationFailed; 142 242 } 143 243 144 244 /// Starts a mail transaction. An empty `from` sends the null reverse-path ··· 327 427 try client.authPlain("", "user", "pass"); 328 428 // base64("\x00user\x00pass") 329 429 try std.testing.expectEqualStrings("AUTH PLAIN AHVzZXIAcGFzcw==\r\n", writer.buffered()); 430 + } 431 + 432 + test "authLogin exchange" { 433 + const responses = "334 VXNlcm5hbWU6\r\n334 UGFzc3dvcmQ6\r\n235 2.7.0 Accepted\r\n"; 434 + var reader: Io.Reader = .fixed(responses); 435 + var out_buf: [256]u8 = undefined; 436 + var writer: Io.Writer = .fixed(&out_buf); 437 + var reply_buf: [256]u8 = undefined; 438 + var client: Client = .init(&reader, &writer, &reply_buf); 439 + 440 + try client.authLogin("user", "pass"); 441 + try std.testing.expectEqualStrings( 442 + "AUTH LOGIN\r\ndXNlcg==\r\ncGFzcw==\r\n", 443 + writer.buffered(), 444 + ); 445 + } 446 + 447 + test "authCramMd5 matches the RFC 2195 example" { 448 + // Challenge "<1896.697170952@postoffice.reston.mci.net>", user "tim", 449 + // password "tanstaaftanstaaf" => digest b913a602c7eda7a495b4e6e7334d3890. 450 + const responses = "334 PDE4OTYuNjk3MTcwOTUyQHBvc3RvZmZpY2UucmVzdG9uLm1jaS5uZXQ+\r\n" ++ 451 + "235 2.7.0 Accepted\r\n"; 452 + var reader: Io.Reader = .fixed(responses); 453 + var out_buf: [256]u8 = undefined; 454 + var writer: Io.Writer = .fixed(&out_buf); 455 + var reply_buf: [256]u8 = undefined; 456 + var client: Client = .init(&reader, &writer, &reply_buf); 457 + 458 + try client.authCramMd5("tim", "tanstaaftanstaaf"); 459 + try std.testing.expectEqualStrings( 460 + "AUTH CRAM-MD5\r\ndGltIGI5MTNhNjAyYzdlZGE3YTQ5NWI0ZTZlNzMzNGQzODkw\r\n", 461 + writer.buffered(), 462 + ); 463 + } 464 + 465 + test "authenticate picks an advertised mechanism" { 466 + var out_buf: [256]u8 = undefined; 467 + var reply_buf: [256]u8 = undefined; 468 + { 469 + // Only CRAM-MD5 advertised. 470 + const responses = "334 YWJj\r\n235 ok\r\n"; 471 + var reader: Io.Reader = .fixed(responses); 472 + var writer: Io.Writer = .fixed(&out_buf); 473 + var client: Client = .init(&reader, &writer, &reply_buf); 474 + try client.authenticate(.{ .auth = .{ .cram_md5 = true } }, "u", "p"); 475 + try std.testing.expect(std.mem.startsWith(u8, writer.buffered(), "AUTH CRAM-MD5\r\n")); 476 + } 477 + { 478 + // Nothing advertised. 479 + var reader: Io.Reader = .fixed(""); 480 + var writer: Io.Writer = .fixed(&out_buf); 481 + var client: Client = .init(&reader, &writer, &reply_buf); 482 + try std.testing.expectError( 483 + error.NoSupportedMechanism, 484 + client.authenticate(.{}, "u", "p"), 485 + ); 486 + } 487 + } 488 + 489 + test "rejected credentials surface AuthenticationFailed" { 490 + const responses = "535 5.7.8 Authentication credentials invalid\r\n"; 491 + var reader: Io.Reader = .fixed(responses); 492 + var out_buf: [256]u8 = undefined; 493 + var writer: Io.Writer = .fixed(&out_buf); 494 + var reply_buf: [256]u8 = undefined; 495 + var client: Client = .init(&reader, &writer, &reply_buf); 496 + 497 + try std.testing.expectError(error.AuthenticationFailed, client.authPlain("", "u", "p")); 498 + try std.testing.expectEqual(@as(u16, 535), client.last_reply.?.code); 499 + } 500 + 501 + test "EHLO advertises auth mechanisms" { 502 + const responses = "250-mx.example.com\r\n250-AUTH PLAIN LOGIN CRAM-MD5\r\n250 8BITMIME\r\n"; 503 + var reader: Io.Reader = .fixed(responses); 504 + var out_buf: [256]u8 = undefined; 505 + var writer: Io.Writer = .fixed(&out_buf); 506 + var reply_buf: [256]u8 = undefined; 507 + var client: Client = .init(&reader, &writer, &reply_buf); 508 + 509 + const ext = try client.hello("c.example"); 510 + try std.testing.expect(ext.auth.plain); 511 + try std.testing.expect(ext.auth.login); 512 + try std.testing.expect(ext.auth.cram_md5); 513 + try std.testing.expect(ext.auth.any()); 330 514 }
+265 -1
src/Server.zig
··· 45 45 /// `tls.input_buffer_len` and `tls.output_buffer_len` bytes, since the 46 46 /// handshake and TLS records run over them. 47 47 starttls: ?StartTls = null, 48 + /// Reject MAIL with 530 until the client has authenticated. Requires a 49 + /// handler with an `authenticate` callback. 50 + require_auth: bool = false, 48 51 }; 49 52 50 53 pub const StartTls = struct { ··· 78 81 vtable: *const VTable, 79 82 80 83 pub const VTable = struct { 84 + /// Called for AUTH with the decoded credentials; return true to 85 + /// accept. When set, AUTH PLAIN and AUTH LOGIN are advertised and 86 + /// accepted (RFC 4954). 87 + authenticate: ?*const fn (context: ?*anyopaque, username: []const u8, password: []const u8) bool = null, 81 88 /// Called for MAIL FROM. Null accepts every sender. 82 89 mailFrom: ?*const fn (context: ?*anyopaque, from: []const u8) Decision = null, 83 90 /// Called for each RCPT TO. Null accepts every recipient. ··· 102 109 defer arena_state.deinit(); 103 110 const arena = arena_state.allocator(); 104 111 112 + std.debug.assert(!s.options.require_auth or s.handler.vtable.authenticate != null); 113 + 105 114 var greeted = false; 115 + var authenticated = false; 106 116 var from: ?[]const u8 = null; 107 117 var recipients: std.ArrayList([]const u8) = .empty; 108 118 ··· 139 149 try s.writer.print("250-{s}\r\n250-PIPELINING\r\n250-8BITMIME\r\n", .{s.options.hostname}); 140 150 if (s.options.starttls != null and !s.secured) 141 151 try s.writer.writeAll("250-STARTTLS\r\n"); 152 + if (s.handler.vtable.authenticate != null and !authenticated) 153 + try s.writer.writeAll("250-AUTH PLAIN LOGIN\r\n"); 142 154 try s.writer.print("250 SIZE {d}\r\n", .{s.options.max_message_size}); 143 155 try s.writer.flush(); 144 156 }, 145 157 .mail => |args| { 146 158 if (!greeted) { 147 159 try s.reply(503, "5.5.1 Send EHLO first"); 160 + continue; 161 + } 162 + if (s.options.require_auth and !authenticated) { 163 + try s.reply(530, "5.7.0 Authentication required"); 148 164 continue; 149 165 } 150 166 if (from != null) { ··· 230 246 // RFC 3207 §4.2: both sides return to their initial state; 231 247 // the client must EHLO again. 232 248 greeted = false; 249 + authenticated = false; 233 250 from = null; 234 251 recipients = .empty; 235 252 _ = arena_state.reset(.retain_capacity); ··· 239 256 if (s.secured) s.tls_connection.close() catch {}; 240 257 return; 241 258 }, 259 + .auth => |args| { 260 + if (s.handler.vtable.authenticate == null) { 261 + try s.reply(503, "5.5.1 Authentication not enabled"); 262 + continue; 263 + } 264 + if (!greeted) { 265 + try s.reply(503, "5.5.1 Send EHLO first"); 266 + continue; 267 + } 268 + if (authenticated) { 269 + try s.reply(503, "5.5.1 Already authenticated"); 270 + continue; 271 + } 272 + if (from != null) { 273 + try s.reply(503, "5.5.1 MAIL transaction in progress"); 274 + continue; 275 + } 276 + switch (try s.receiveAuth(args)) { 277 + .authenticated => authenticated = true, 278 + .rejected => {}, 279 + .disconnected => return, 280 + } 281 + }, 242 282 .unknown => try s.reply(500, "5.5.2 Command not recognized"), 243 283 } 244 284 } 285 + } 286 + 287 + const AuthOutcome = enum { authenticated, rejected, disconnected }; 288 + 289 + /// Runs the challenge/response exchange for AUTH PLAIN or AUTH LOGIN 290 + /// (RFC 4954) and consults the handler's `authenticate` callback. Every 291 + /// outcome except `disconnected` has already sent its reply. 292 + fn receiveAuth(s: *Server, args: protocol.Command.AuthArgs) RunError!AuthOutcome { 293 + const callback = s.handler.vtable.authenticate.?; 294 + 295 + if (std.ascii.eqlIgnoreCase(args.mechanism, "PLAIN")) { 296 + var decoded_buf: [576]u8 = undefined; 297 + var response: []const u8 = args.initial; 298 + if (response.len == 0) { 299 + try s.reply(334, ""); 300 + response = switch (try s.takeAuthLine()) { 301 + .line => |line| line, 302 + .cancelled => return .rejected, 303 + .disconnected => return .disconnected, 304 + }; 305 + } 306 + const decoded = decodeBase64(&decoded_buf, response) orelse { 307 + try s.reply(501, "5.5.2 Invalid base64"); 308 + return .rejected; 309 + }; 310 + // authzid NUL authcid NUL password; the authzid is ignored. 311 + const first_nul = std.mem.indexOfScalar(u8, decoded, 0) orelse { 312 + try s.reply(501, "5.5.2 Malformed PLAIN response"); 313 + return .rejected; 314 + }; 315 + const after_authzid = decoded[first_nul + 1 ..]; 316 + const second_nul = std.mem.indexOfScalar(u8, after_authzid, 0) orelse { 317 + try s.reply(501, "5.5.2 Malformed PLAIN response"); 318 + return .rejected; 319 + }; 320 + return s.finishAuth(callback, after_authzid[0..second_nul], after_authzid[second_nul + 1 ..]); 321 + } 322 + 323 + if (std.ascii.eqlIgnoreCase(args.mechanism, "LOGIN")) { 324 + var user_buf: [192]u8 = undefined; 325 + var pass_buf: [192]u8 = undefined; 326 + 327 + var username: []const u8 = undefined; 328 + if (args.initial.len > 0) { 329 + // Some clients send the username as an initial response. 330 + username = decodeBase64(&user_buf, args.initial) orelse { 331 + try s.reply(501, "5.5.2 Invalid base64"); 332 + return .rejected; 333 + }; 334 + } else { 335 + try s.reply(334, "VXNlcm5hbWU6"); // base64("Username:") 336 + const line = switch (try s.takeAuthLine()) { 337 + .line => |line| line, 338 + .cancelled => return .rejected, 339 + .disconnected => return .disconnected, 340 + }; 341 + username = decodeBase64(&user_buf, line) orelse { 342 + try s.reply(501, "5.5.2 Invalid base64"); 343 + return .rejected; 344 + }; 345 + } 346 + try s.reply(334, "UGFzc3dvcmQ6"); // base64("Password:") 347 + const line = switch (try s.takeAuthLine()) { 348 + .line => |line| line, 349 + .cancelled => return .rejected, 350 + .disconnected => return .disconnected, 351 + }; 352 + const password = decodeBase64(&pass_buf, line) orelse { 353 + try s.reply(501, "5.5.2 Invalid base64"); 354 + return .rejected; 355 + }; 356 + return s.finishAuth(callback, username, password); 357 + } 358 + 359 + try s.reply(504, "5.5.4 Unrecognized authentication type"); 360 + return .rejected; 361 + } 362 + 363 + fn finishAuth( 364 + s: *Server, 365 + callback: *const fn (?*anyopaque, []const u8, []const u8) bool, 366 + username: []const u8, 367 + password: []const u8, 368 + ) RunError!AuthOutcome { 369 + if (callback(s.handler.context, username, password)) { 370 + try s.reply(235, "2.7.0 Authentication successful"); 371 + return .authenticated; 372 + } 373 + try s.reply(535, "5.7.8 Authentication credentials invalid"); 374 + return .rejected; 375 + } 376 + 377 + const AuthLine = union(enum) { line: []u8, cancelled, disconnected }; 378 + 379 + /// Reads one continuation line of an AUTH exchange. `cancelled` covers both 380 + /// an explicit "*" and an overlong line; its reply has already been sent. 381 + fn takeAuthLine(s: *Server) RunError!AuthLine { 382 + const line = protocol.readLine(s.reader) catch |err| switch (err) { 383 + error.EndOfStream => return .disconnected, 384 + error.ReadFailed => return error.ReadFailed, 385 + error.LineTooLong => { 386 + try s.discardLine(); 387 + try s.reply(501, "5.5.2 Response too long"); 388 + return .cancelled; 389 + }, 390 + }; 391 + if (std.mem.eql(u8, line, "*")) { 392 + try s.reply(501, "5.7.0 Authentication cancelled"); 393 + return .cancelled; 394 + } 395 + return .{ .line = line }; 396 + } 397 + 398 + /// Decodes a base64 AUTH argument; "=" denotes an empty response. 399 + fn decodeBase64(out: []u8, encoded: []const u8) ?[]u8 { 400 + if (std.mem.eql(u8, encoded, "=")) return out[0..0]; 401 + const len = std.base64.standard.Decoder.calcSizeForSlice(encoded) catch return null; 402 + if (len > out.len) return null; 403 + std.base64.standard.Decoder.decode(out[0..len], encoded) catch return null; 404 + return out[0..len]; 245 405 } 246 406 247 407 /// Reads message content after DATA up to the terminating ".\r\n", ··· 303 463 data: std.ArrayList(u8) = .empty, 304 464 messages_accepted: usize = 0, 305 465 reject_recipient: ?[]const u8 = null, 466 + /// When set, enables the authenticate callback accepting user "alice" 467 + /// with this password. 468 + password: ?[]const u8 = null, 306 469 307 470 fn deinit(h: *TestHandler) void { 308 471 h.from.deinit(std.testing.allocator); ··· 311 474 } 312 475 313 476 fn handler(h: *TestHandler) Handler { 314 - return .{ .context = h, .vtable = &.{ 477 + return .{ .context = h, .vtable = if (h.password != null) &.{ 478 + .authenticate = onAuthenticate, 479 + .rcptTo = onRcptTo, 480 + .message = onMessage, 481 + } else &.{ 315 482 .rcptTo = onRcptTo, 316 483 .message = onMessage, 317 484 } }; 485 + } 486 + 487 + fn onAuthenticate(context: ?*anyopaque, username: []const u8, password: []const u8) bool { 488 + const h: *TestHandler = @ptrCast(@alignCast(context.?)); 489 + return std.mem.eql(u8, username, "alice") and 490 + std.mem.eql(u8, password, h.password.?); 318 491 } 319 492 320 493 fn onRcptTo(context: ?*anyopaque, to: []const u8) Decision { ··· 434 607 try std.testing.expect(std.mem.indexOf(u8, output, "550 5.1.1 No such user") != null); 435 608 try std.testing.expectEqualStrings("bob@example.net;", h.recipients.items); 436 609 try std.testing.expectEqual(@as(usize, 1), h.messages_accepted); 610 + } 611 + 612 + test "AUTH PLAIN with initial response" { 613 + var h: TestHandler = .{ .password = "secret" }; 614 + defer h.deinit(); 615 + 616 + var out_buf: [1024]u8 = undefined; 617 + // base64("\x00alice\x00secret") 618 + const output = try runScript( 619 + "EHLO client.example.org\r\n" ++ 620 + "AUTH PLAIN AGFsaWNlAHNlY3JldA==\r\n" ++ 621 + "MAIL FROM:<alice@example.com>\r\n" ++ 622 + "RCPT TO:<bob@example.net>\r\n" ++ 623 + "DATA\r\nauthed mail\r\n.\r\n" ++ 624 + "QUIT\r\n", 625 + &out_buf, 626 + h.handler(), 627 + .{ .require_auth = true }, 628 + ); 629 + 630 + try std.testing.expect(std.mem.indexOf(u8, output, "250-AUTH PLAIN LOGIN\r\n") != null); 631 + try std.testing.expect(std.mem.indexOf(u8, output, "235 2.7.0") != null); 632 + try std.testing.expectEqual(@as(usize, 1), h.messages_accepted); 633 + } 634 + 635 + test "AUTH LOGIN challenge exchange" { 636 + var h: TestHandler = .{ .password = "secret" }; 637 + defer h.deinit(); 638 + 639 + var out_buf: [1024]u8 = undefined; 640 + // base64("alice"), base64("secret") 641 + const output = try runScript( 642 + "EHLO client.example.org\r\n" ++ 643 + "AUTH LOGIN\r\n" ++ 644 + "YWxpY2U=\r\n" ++ 645 + "c2VjcmV0\r\n" ++ 646 + "QUIT\r\n", 647 + &out_buf, 648 + h.handler(), 649 + .{}, 650 + ); 651 + 652 + try std.testing.expect(std.mem.indexOf(u8, output, "334 VXNlcm5hbWU6\r\n") != null); 653 + try std.testing.expect(std.mem.indexOf(u8, output, "334 UGFzc3dvcmQ6\r\n") != null); 654 + try std.testing.expect(std.mem.indexOf(u8, output, "235 2.7.0") != null); 655 + } 656 + 657 + test "AUTH failures and sequencing" { 658 + var h: TestHandler = .{ .password = "secret" }; 659 + defer h.deinit(); 660 + 661 + var out_buf: [2048]u8 = undefined; 662 + const output = try runScript( 663 + "EHLO client.example.org\r\n" ++ 664 + "MAIL FROM:<alice@example.com>\r\n" ++ // before auth: 530 665 + "AUTH PLAIN AGFsaWNlAHdyb25n\r\n" ++ // wrong password: 535 666 + "AUTH GSSAPI\r\n" ++ // unsupported: 504 667 + "AUTH PLAIN not!base64\r\n" ++ // 501 668 + "AUTH LOGIN\r\n" ++ 669 + "*\r\n" ++ // cancelled: 501 670 + "AUTH PLAIN AGFsaWNlAHNlY3JldA==\r\n" ++ // correct: 235 671 + "AUTH PLAIN AGFsaWNlAHNlY3JldA==\r\n" ++ // again: 503 672 + "QUIT\r\n", 673 + &out_buf, 674 + h.handler(), 675 + .{ .require_auth = true }, 676 + ); 677 + 678 + try std.testing.expect(std.mem.indexOf(u8, output, "530 5.7.0") != null); 679 + try std.testing.expect(std.mem.indexOf(u8, output, "535 5.7.8") != null); 680 + try std.testing.expect(std.mem.indexOf(u8, output, "504 5.5.4") != null); 681 + try std.testing.expect(std.mem.indexOf(u8, output, "501 5.5.2 Invalid base64") != null); 682 + try std.testing.expect(std.mem.indexOf(u8, output, "501 5.7.0 Authentication cancelled") != null); 683 + try std.testing.expect(std.mem.indexOf(u8, output, "235 2.7.0") != null); 684 + try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Already authenticated") != null); 685 + } 686 + 687 + test "AUTH without a handler is refused" { 688 + var h: TestHandler = .{}; 689 + defer h.deinit(); 690 + 691 + var out_buf: [1024]u8 = undefined; 692 + const output = try runScript( 693 + "EHLO client.example.org\r\nAUTH PLAIN AGEAYg==\r\nQUIT\r\n", 694 + &out_buf, 695 + h.handler(), 696 + .{}, 697 + ); 698 + 699 + try std.testing.expect(std.mem.indexOf(u8, output, "250-AUTH") == null); 700 + try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Authentication not enabled") != null); 437 701 } 438 702 439 703 test "oversize message is rejected but session continues" {
+75 -10
src/main.zig
··· 3 3 4 4 //! Demo CLI for the zsmtp library. 5 5 //! 6 - //! zsmtp send [--tls|--starttls] [--insecure] <host> <port> <from> <to>... 6 + //! zsmtp send [--tls|--starttls] [--insecure] [--user <u> --password <p>] 7 + //! [--auth-method plain|login|cram-md5] <host> <port> <from> <to>... 7 8 //! send a message read from stdin; --tls speaks TLS from the first 8 9 //! byte (port 465 style), --starttls upgrades after EHLO (port 587 9 - //! style), --insecure skips certificate verification 10 - //! zsmtp serve [--tls-cert <pem> --tls-key <pem>] <port> 10 + //! style), --insecure skips certificate verification, --user/--password 11 + //! authenticate with the best advertised mechanism (or the one forced 12 + //! by --auth-method) 13 + //! zsmtp serve [--tls-cert <pem> --tls-key <pem>] [--auth <user>:<pass>] <port> 11 14 //! run a debug server on 127.0.0.1 that prints received messages; 15 + //! --auth requires authentication with the given credentials; 12 16 //! with a certificate and key it advertises and accepts STARTTLS 13 17 14 18 const std = @import("std"); ··· 30 34 config.mode = .starttls; 31 35 } else if (std.mem.eql(u8, rest[0], "--insecure")) { 32 36 config.insecure = true; 37 + } else if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--user")) { 38 + config.username = rest[1]; 39 + rest = rest[1..]; 40 + } else if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--password")) { 41 + config.password = rest[1]; 42 + rest = rest[1..]; 43 + } else if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--auth-method")) { 44 + config.auth_method = std.meta.stringToEnum( 45 + @TypeOf(config.auth_method), 46 + rest[1], 47 + ) orelse if (std.mem.eql(u8, rest[1], "cram-md5")) .cram_md5 else return usage(); 48 + rest = rest[1..]; 33 49 } else { 34 50 return usage(); 35 51 } 36 52 rest = rest[1..]; 37 53 } 54 + if ((config.username == null) != (config.password == null)) return usage(); 38 55 if (rest.len < 4) return usage(); 39 56 return send(io, arena, config, rest[0], rest[1], rest[2], rest[3..]); 40 57 } ··· 46 63 config.cert_path = rest[1]; 47 64 } else if (std.mem.eql(u8, rest[0], "--tls-key")) { 48 65 config.key_path = rest[1]; 66 + } else if (std.mem.eql(u8, rest[0], "--auth")) { 67 + const sep = std.mem.indexOfScalar(u8, rest[1], ':') orelse return usage(); 68 + config.username = rest[1][0..sep]; 69 + config.password = rest[1][sep + 1 ..]; 49 70 } else { 50 71 return usage(); 51 72 } ··· 61 82 const ServeConfig = struct { 62 83 cert_path: ?[]const u8 = null, 63 84 key_path: ?[]const u8 = null, 85 + username: ?[]const u8 = null, 86 + password: ?[]const u8 = null, 64 87 }; 65 88 66 89 const SendConfig = struct { 67 90 mode: enum { plain, tls, starttls } = .plain, 68 91 insecure: bool = false, 92 + username: ?[]const u8 = null, 93 + password: ?[]const u8 = null, 94 + auth_method: enum { auto, plain, login, cram_md5 } = .auto, 69 95 }; 70 96 71 97 fn usage() noreturn { 72 98 std.log.err( 73 99 \\usage: 74 - \\ zsmtp send [--tls|--starttls] [--insecure] <host> <port> <from> <to>... 100 + \\ zsmtp send [--tls|--starttls] [--insecure] [--user <u> --password <p>] 101 + \\ [--auth-method plain|login|cram-md5] <host> <port> <from> <to>... 75 102 \\ (message is read from stdin) 76 - \\ zsmtp serve [--tls-cert <pem> --tls-key <pem>] <port> 103 + \\ zsmtp serve [--tls-cert <pem> --tls-key <pem>] [--auth <user>:<pass>] <port> 77 104 , .{}); 78 105 std.process.exit(1); 79 106 } ··· 123 150 } 124 151 125 152 _ = try client.greet(); 126 - _ = try client.hello("localhost"); 153 + var extensions = try client.hello("localhost"); 127 154 128 155 if (config.mode == .starttls) { 129 156 try client.starttls(); 130 157 try tls.init(arena, io, &stream_reader.interface, &stream_writer.interface, tls_options); 131 158 tls_active = true; 132 159 client.setTransport(tls.reader(), tls.writer()); 133 - _ = try client.hello("localhost"); 160 + extensions = try client.hello("localhost"); 161 + } 162 + 163 + if (config.username) |username| { 164 + const password = config.password.?; 165 + const result = switch (config.auth_method) { 166 + .auto => client.authenticate(extensions, username, password), 167 + .plain => client.authPlain("", username, password), 168 + .login => client.authLogin(username, password), 169 + .cram_md5 => client.authCramMd5(username, password), 170 + }; 171 + result catch |err| { 172 + if (err == error.AuthenticationFailed) { 173 + const reply = client.last_reply.?; 174 + std.log.err("authentication failed: {d} {s}", .{ reply.code, reply.text }); 175 + } 176 + return err; 177 + }; 134 178 } 135 179 136 180 client.sendMail(from, recipients, message) catch |err| { ··· 163 207 var stdout_buf: [4096]u8 = undefined; 164 208 var stdout: Io.File.Writer = .init(.stdout(), io, &stdout_buf); 165 209 166 - var printer: MessagePrinter = .{ .out = &stdout.interface }; 210 + var printer: MessagePrinter = .{ 211 + .out = &stdout.interface, 212 + .username = config.username, 213 + .password = config.password, 214 + }; 167 215 while (true) { 168 216 const stream = try listener.accept(io); 169 217 defer stream.close(io); ··· 177 225 var session: zsmtp.Server = .init( 178 226 &stream_reader.interface, 179 227 &stream_writer.interface, 180 - .{ .context = &printer, .vtable = &.{ .message = MessagePrinter.onMessage } }, 181 - .{ .hostname = "localhost", .starttls = starttls }, 228 + .{ .context = &printer, .vtable = if (config.username != null) &.{ 229 + .authenticate = MessagePrinter.onAuthenticate, 230 + .message = MessagePrinter.onMessage, 231 + } else &.{ 232 + .message = MessagePrinter.onMessage, 233 + } }, 234 + .{ 235 + .hostname = "localhost", 236 + .starttls = starttls, 237 + .require_auth = config.username != null, 238 + }, 182 239 ); 183 240 session.run(gpa) catch |err| { 184 241 std.log.warn("session ended with error: {t}", .{err}); ··· 188 245 189 246 const MessagePrinter = struct { 190 247 out: *Io.Writer, 248 + username: ?[]const u8 = null, 249 + password: ?[]const u8 = null, 250 + 251 + fn onAuthenticate(context: ?*anyopaque, username: []const u8, password: []const u8) bool { 252 + const printer: *MessagePrinter = @ptrCast(@alignCast(context.?)); 253 + return std.mem.eql(u8, username, printer.username.?) and 254 + std.mem.eql(u8, password, printer.password.?); 255 + } 191 256 192 257 fn onMessage(context: ?*anyopaque, envelope: zsmtp.Server.Envelope, data: []const u8) zsmtp.Server.Decision { 193 258 const printer: *MessagePrinter = @ptrCast(@alignCast(context.?));
+28
src/protocol.zig
··· 113 113 vrfy: []const u8, 114 114 help, 115 115 starttls, 116 + /// AUTH (RFC 4954). 117 + auth: AuthArgs, 116 118 /// Unrecognized command verb; the payload is the full line. 117 119 unknown: []const u8, 120 + 121 + pub const AuthArgs = struct { 122 + mechanism: []const u8, 123 + /// Raw base64 initial response, if the client sent one ("=" denotes 124 + /// an empty initial response). 125 + initial: []const u8 = "", 126 + }; 118 127 119 128 pub const PathArgs = struct { 120 129 /// The mailbox, with angle brackets and any obsolete source route ··· 151 160 if (ieql(verb, "VRFY")) return .{ .vrfy = rest }; 152 161 if (ieql(verb, "HELP")) return .help; 153 162 if (ieql(verb, "STARTTLS")) return .starttls; 163 + if (ieql(verb, "AUTH")) { 164 + const mech_end = std.mem.indexOfAny(u8, rest, " \t") orelse rest.len; 165 + if (mech_end == 0) return error.Syntax; 166 + return .{ .auth = .{ 167 + .mechanism = rest[0..mech_end], 168 + .initial = std.mem.trimStart(u8, rest[mech_end..], " \t"), 169 + } }; 170 + } 154 171 return .{ .unknown = line }; 155 172 } 156 173 ··· 277 294 try std.testing.expectEqual(Command.quit, cmd); 278 295 } 279 296 { 297 + const cmd = try Command.parse("AUTH PLAIN AHVzZXIAcGFzcw=="); 298 + try std.testing.expectEqualStrings("PLAIN", cmd.auth.mechanism); 299 + try std.testing.expectEqualStrings("AHVzZXIAcGFzcw==", cmd.auth.initial); 300 + } 301 + { 302 + const cmd = try Command.parse("auth login"); 303 + try std.testing.expectEqualStrings("login", cmd.auth.mechanism); 304 + try std.testing.expectEqualStrings("", cmd.auth.initial); 305 + } 306 + { 280 307 const cmd = try Command.parse("MADE UP"); 281 308 try std.testing.expectEqualStrings("MADE UP", cmd.unknown); 282 309 } 310 + try std.testing.expectError(error.Syntax, Command.parse("AUTH")); 283 311 try std.testing.expectError(error.Syntax, Command.parse("MAIL TO:<a@b>")); 284 312 try std.testing.expectError(error.Syntax, Command.parse("RCPT TO:")); 285 313 try std.testing.expectError(error.Syntax, Command.parse("HELO"));