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.

Hand the SASL scratch buffers to the caller, and redo the gap survey

Surveying the gaps against the code rather than against the last list
turned up something the list did not have, because I had put it there:
the AUTH paths each held about 27 KB on the stack. `max_sasl_message` was
8192, base64 makes the encoded form 10924, and three such buffers were
live in one frame on both sides. Fine on a main thread; not fine on a
server handing each connection a 64 KB stack.

Both sides now take the scratch from the caller, for the same reason
`reply_buffer` is the caller's: how much room a mechanism needs is the
caller's to know, and the range is wide -- a few hundred bytes for the
classic mechanisms, several kilobytes for an OAuth token. An absent or
undersized one is `error.SaslBufferTooSmall` rather than a hidden
allocation or an array the caller cannot see.

Three buffers became two, and the two take turns. The split is
four-to-three, which is base64's expansion exactly, so the coded half
always holds the encoding of a full plaintext half. A challenge decodes
into the coded half; the mechanism consumes it while writing its answer
into the plain half; the answer encodes back over the challenge, which is
finished with. There is a test that runs a real CRAM-MD5 exchange through
the 896-byte minimum, where those halves are 512 and 384.

The survey also found that `Extensions.auth` is the one field of
`Extensions` that borrows -- it points into the reply buffer -- where
zig-pop3 answered the same question with a bounded copy. That one is
written down rather than fixed: the two libraries disagree on purpose
until one of them gives way.

And the README's code samples said `zig-smtp.Client`, which is not an
identifier. The rename replaced the name everywhere including inside the
examples; they say `smtp.` now, matching `@import("smtp")`.

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

+234 -64
+57 -29
README.md
··· 19 19 - <https://tangled.org/jcollie.dev/zig-smtp> 20 20 21 21 ```sh 22 - git clone https://git.jcollie.dev/jeff/zig-smtp.git 22 + git clone https://git.jcollie.dev/jeff/smtp.git 23 23 ``` 24 24 25 25 On [Radicle](https://radicle.xyz/), the peer-to-peer forge, the repository is ··· 41 41 ## Client 42 42 43 43 ```zig 44 - const zig-smtp = @import("smtp"); 44 + const smtp = @import("smtp"); 45 45 46 46 var reply_buf: [1024]u8 = undefined; 47 - var client: zig-smtp.Client = .init(&stream_reader.interface, &stream_writer.interface, &reply_buf); 47 + var client: smtp.Client = .init(&stream_reader.interface, &stream_writer.interface, &reply_buf); 48 48 49 49 _ = try client.greet(); // read the 220 greeting 50 50 _ = try client.hello("my-host.example.com"); // EHLO (HELO fallback), returns extensions ··· 161 161 162 162 The mechanisms themselves live in 163 163 [zig-sasl](https://git.jcollie.dev/jeff/zig-sasl), re-exported here as 164 - `zig-smtp.sasl`, because nothing about PLAIN or CRAM-MD5 or XOAUTH2 is specific 164 + `smtp.sasl`, because nothing about PLAIN or CRAM-MD5 or XOAUTH2 is specific 165 165 to SMTP — POP3 and IMAP want the same ones, and one implementation of each is 166 166 better than three. What is specific to SMTP is `authenticate`: the `AUTH` 167 167 command, the 334 challenges, the `*` that cancels, and the 235 that ends it. ··· 170 170 exactly as it sent them, for `sasl.Client.selectFromList`: 171 171 172 172 ```zig 173 - var plain: zig-smtp.sasl.Plain = .init("user", "password"); 174 - var cram: zig-smtp.sasl.CramMd5 = .init("user", "password"); 173 + var sasl_scratch: [smtp.Client.sasl_buffer_suggested]u8 = undefined; 174 + client.sasl_buffer = &sasl_scratch; 175 + 176 + var plain: smtp.sasl.Plain = .init("user", "password"); 177 + var cram: smtp.sasl.CramMd5 = .init("user", "password"); 175 178 176 179 const extensions = try client.hello("my-host.example.com"); 177 - const mechanism = zig-smtp.sasl.Client.selectFromList( 180 + const mechanism = smtp.sasl.Client.selectFromList( 178 181 &.{ plain.client(), cram.client() }, // in order of preference 179 182 extensions.auth, 180 183 client.security == .encrypted, 181 184 ) orelse return error.NoSupportedMechanism; 182 185 try client.authenticate(mechanism); 183 186 ``` 187 + 188 + The scratch buffer is the caller's, like `reply_buffer`: how much room a 189 + mechanism needs is the caller's to know, and the range is wide — the classic 190 + mechanisms want a few hundred bytes, an OAuth token several kilobytes. It is 191 + split four-to-three between base64 and plaintext, which is base64's expansion 192 + exactly, and the two halves take turns rather than coexisting: a challenge 193 + decodes into the coded half, the answer is written into the plain half, and 194 + that answer encodes back over the challenge. `sasl_buffer_min` is the floor 195 + and `sasl_buffer_suggested` fits everything short of an unusually fat token. 196 + `Server.Options.sasl_buffer` is the same arrangement on the other side. 184 197 185 198 A 535 rejection surfaces as `error.AuthenticationFailed` with the reply in 186 199 `last_reply`. ··· 212 225 213 226 ### TLS 214 227 215 - `zig-smtp.Tls` wraps [ianic/tls.zig](https://github.com/ianic/tls.zig) and 228 + `smtp.Tls` wraps [ianic/tls.zig](https://github.com/ianic/tls.zig) and 216 229 verifies against the system trust store by default (a caller-managed CA 217 230 bundle and an insecure mode are also available). The stream reader/writer 218 - handed to it need buffers of at least `zig-smtp.Tls.min_buffer_len` bytes, and 231 + handed to it need buffers of at least `smtp.Tls.min_buffer_len` bytes, and 219 232 `init` must run at the value's final address (the connection holds interior 220 233 pointers). The standard library's TLS client is deliberately not used: it 221 234 requires the optional TLS 1.3 middlebox-compatibility ChangeCipherSpec ··· 224 237 Implicit TLS (port 465) — handshake first, then speak SMTP: 225 238 226 239 ```zig 227 - var tls: zig-smtp.Tls = undefined; 240 + var tls: smtp.Tls = undefined; 228 241 try tls.init(io, gpa, &stream_reader.interface, &stream_writer.interface, .{ 229 242 .host = "smtp.example.com", 230 243 }); 231 244 defer tls.deinit(gpa); 232 - var client: zig-smtp.Client = .init(tls.reader(), tls.writer(), &reply_buf); 245 + var client: smtp.Client = .init(tls.reader(), tls.writer(), &reply_buf); 233 246 client.security = .encrypted; // the transport is TLS; `init` cannot tell 234 247 // ... greet, hello, sendMail ... 235 248 try client.quit(); ··· 242 255 _ = try client.greet(); 243 256 _ = try client.hello("my-host.example.com"); // check .starttls in the result 244 257 try client.starttls(); 245 - var tls: zig-smtp.Tls = undefined; 258 + var tls: smtp.Tls = undefined; 246 259 try tls.init(io, gpa, &stream_reader.interface, &stream_writer.interface, .{ 247 260 .host = "smtp.example.com", 248 261 }); ··· 253 266 ## Server 254 267 255 268 ```zig 256 - var session: zig-smtp.Server = .init(&stream_reader.interface, &stream_writer.interface, .{ 269 + var session: smtp.Server = .init(&stream_reader.interface, &stream_writer.interface, .{ 257 270 .context = &my_state, 258 271 .vtable = &.{ 259 272 .authenticate = onAuth, // optional; enables AUTH PLAIN and LOGIN ··· 270 283 CRAM-MD5 or EXTERNAL, which it could not when the mechanisms were built in: 271 284 272 285 ```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); 286 + const check: smtp.sasl.Server.PasswordCheck = .{ .context = &app, .verify = verify }; 287 + var plain: smtp.sasl.PlainServer = .init(check); 288 + var login: smtp.sasl.LoginServer = .init(check); 276 289 // ... .auth_mechanisms = &.{ plain.server(), login.server() } 277 290 ``` 278 291 ··· 325 338 callback supplies each verdict: 326 339 327 340 ```zig 328 - fn onRecipientResult(ctx: ?*anyopaque, envelope: zig-smtp.Server.Envelope, index: usize) zig-smtp.Server.Decision { 341 + fn onRecipientResult(ctx: ?*anyopaque, envelope: smtp.Server.Envelope, index: usize) smtp.Server.Decision { 329 342 return if (mailboxIsFull(envelope.recipients[index].address)) 330 343 .{ .reject = .{ .code = 452, .text = "4.2.2 Mailbox full" } } 331 344 else ··· 356 369 357 370 To advertise and accept STARTTLS (TLS 1.3, via 358 371 [ianic/tls.zig](https://github.com/ianic/tls.zig)), pass a certificate key 359 - pair; the stream buffers must then be at least `zig-smtp.tls.input_buffer_len` / 360 - `zig-smtp.tls.output_buffer_len` bytes, since the handshake runs over them: 372 + pair; the stream buffers must then be at least `smtp.tls.input_buffer_len` / 373 + `smtp.tls.output_buffer_len` bytes, since the handshake runs over them: 361 374 362 375 ```zig 363 - var auth: zig-smtp.tls.config.CertKeyPair = 376 + var auth: smtp.tls.config.CertKeyPair = 364 377 try .fromFilePath(gpa, io, .cwd(), "cert.pem", "key.pem"); 365 378 defer auth.deinit(gpa); 366 379 367 - var session: zig-smtp.Server = .init(&stream_reader.interface, &stream_writer.interface, handler, .{ 380 + var session: smtp.Server = .init(&stream_reader.interface, &stream_writer.interface, handler, .{ 368 381 .hostname = "mx.example.com", 369 382 .tls = .{ .io = io, .auth = &auth }, 370 383 }); ··· 377 390 before the greeting (SMTPS, port 465 style): 378 391 379 392 ```zig 380 - var session: zig-smtp.Server = .init(&stream_reader.interface, &stream_writer.interface, handler, .{ 393 + var session: smtp.Server = .init(&stream_reader.interface, &stream_writer.interface, handler, .{ 381 394 .hostname = "mx.example.com", 382 395 .tls = .{ .io = io, .auth = &auth, .mode = .implicit }, 383 396 }); ··· 428 441 429 442 TLS is supported on both sides via 430 443 [ianic/tls.zig](https://github.com/ianic/tls.zig): the client does implicit 431 - TLS and STARTTLS via `zig-smtp.Tls`, and the server accepts both STARTTLS and 444 + TLS and STARTTLS via `smtp.Tls`, and the server accepts both STARTTLS and 432 445 implicit TLS (TLS 1.3 only). AUTH covers PLAIN, LOGIN, and CRAM-MD5 on the 433 446 client and PLAIN and LOGIN on the server. Message bodies can be streamed on 434 447 both sides, and the server validates MAIL and RCPT parameters (SIZE=, BODY=, ··· 481 494 - **No `Received:` header.** 482 495 [RFC 5321 §4.4](https://datatracker.ietf.org/doc/html/rfc5321#section-4.4) 483 496 requires a receiving server to stamp one. 484 - - **The handler never sees the connection** — no connect callback, no peer 485 - address, no TLS state. Greylisting, DNSBLs, SPF and per-IP policy cannot 486 - be built on top, and a `Received:` header cannot be written without it. 497 + - **The handler sees the identity but not the connection.** 498 + `Envelope.authenticated_as` and `Server.identity()` say who authenticated; 499 + nothing says where from. No connect callback, no peer address, no TLS 500 + state — so greylisting, DNSBLs, SPF and per-IP policy cannot be built on 501 + top, and a `Received:` header cannot be written without it. 487 502 - **No timeouts**, so a client that connects and says nothing holds the 488 503 session forever; 489 504 [RFC 5321 §4.5.3.2](https://datatracker.ietf.org/doc/html/rfc5321#section-4.5.3.2) 490 - specifies per-command limits. 491 - - **No abuse limits** beyond `max_recipients`: unlimited failed AUTH 492 - attempts, no error-count disconnect, no command budget. 505 + specifies per-command limits. This matters more since LMTP arrived: an 506 + LMTP server is what a queueing MTA hands mail to, so it is likelier to be 507 + somewhere a stuck peer costs something. 508 + - **No abuse limits** beyond `max_recipients`: no error-count disconnect, no 509 + command budget, and no cap on failed AUTH attempts — which also matters 510 + more now, since a session may offer several mechanisms and a client can 511 + try each in turn without limit. 493 512 - **No `require_tls`** to go with `require_auth`. 494 513 - **No PROXY protocol, XCLIENT or XFORWARD**, so the real peer address is 495 514 lost behind a load balancer. ··· 508 527 before transmitting it. 509 528 - No MX resolution or connect helper, no 4xx retry or backoff, no connection 510 529 reuse helper. 530 + - **`Extensions.auth` is the one field that borrows.** It points into the 531 + client's reply buffer and is valid only until the next reply is read, which 532 + is long enough for the `hello`-then-`authenticate` sequence and no longer. 533 + Everything else on `Extensions` is self-contained, so a caller storing one 534 + across commands gets a dangling slice with no compiler help. zig-pop3 535 + answered the same question the other way, with a bounded copy, because its 536 + `capabilities()` promises nothing borrows the read buffer — the two 537 + libraries disagree about this on purpose, and one of them should probably 538 + give way. 511 539 512 540 ## Standards 513 541
+118 -18
src/Client.zig
··· 27 27 writer: *Io.Writer, 28 28 /// Backing storage for reply text; `last_reply.text` points into it. 29 29 reply_buffer: []u8, 30 + /// Scratch for the AUTH exchange, needed only by `authenticate` — a client 31 + /// that never authenticates may leave it empty. 32 + /// 33 + /// It is the caller's for the same reason `reply_buffer` is: how much room a 34 + /// mechanism needs is the caller's to know, and the difference is large. The 35 + /// classic mechanisms want a few hundred bytes; an OAuth bearer token can be 36 + /// several kilobytes on its own. `sasl_buffer_suggested` is a size that fits 37 + /// everything short of an unusually fat token. 38 + /// 39 + /// It is split four-to-three between base64 and plaintext, which is the 40 + /// ratio base64 expands by — so the usable message is about three sevenths 41 + /// of what is given. 42 + sasl_buffer: []u8 = &.{}, 30 43 /// The most recent reply read from the server. Useful for reporting the 31 44 /// server's actual response after an `error.UnexpectedReply`. 32 45 last_reply: ?Reply = null, ··· 259 272 /// The server's challenge was not valid base64, or was longer than the 260 273 /// buffer given to it. 261 274 InvalidChallenge, 275 + /// `sasl_buffer` was empty or smaller than `sasl_buffer_min`. It is not 276 + /// allocated here for the same reason `reply_buffer` is not: how much a 277 + /// mechanism needs is the caller's to know. 278 + SaslBufferTooSmall, 262 279 /// The server accepted the exchange but the mechanism had not finished 263 280 /// proving what it set out to prove. 264 281 /// ··· 270 287 ServerNotAuthenticated, 271 288 }; 272 289 273 - /// The largest SASL message this client will send or receive, before base64. 290 + /// The smallest `sasl_buffer` worth offering: enough plaintext for PLAIN, 291 + /// LOGIN, CRAM-MD5, EXTERNAL, ANONYMOUS, DIGEST-MD5 and SCRAM, none of which 292 + /// send more than a few hundred bytes. 293 + pub const sasl_buffer_min = 896; 294 + 295 + /// A `sasl_buffer` size that fits everything, including an OAuth token of a 296 + /// couple of kilobytes. 274 297 /// 275 298 /// [RFC 4954 §4](https://datatracker.ietf.org/doc/html/rfc4954#section-4) 276 299 /// says a client "MUST be able to handle the maximum encoded size of 277 300 /// 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; 301 + /// mechanisms" and offers 12288 octets as a sufficient line length. Seven 302 + /// thousand here is a plaintext message of three thousand, which encodes to 303 + /// four — comfortably inside that. 304 + pub const sasl_buffer_suggested = 7168; 281 305 282 306 /// Runs a SASL exchange with `mechanism` 283 307 /// ([RFC 4954](https://datatracker.ietf.org/doc/html/rfc4954)). ··· 308 332 /// stream for whatever comes next. 309 333 pub fn authenticate(c: *Client, mechanism: sasl.Client) AuthError!void { 310 334 if (mechanism.cleartext()) try c.requireConfidentiality(); 335 + const scratch = try splitSaslBuffer(c.sasl_buffer); 311 336 312 - var message_buf: [max_sasl_message]u8 = undefined; 313 - var message: Io.Writer = .fixed(&message_buf); 337 + var message: Io.Writer = .fixed(scratch.plain); 314 338 315 339 switch (try c.mechanismStep(mechanism.initial(&message))) { 316 340 .none => try c.send("AUTH {s}", .{mechanism.name()}), 317 341 .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()); 342 + const encoded = std.base64.standard.Encoder.encode(scratch.coded, message.buffered()); 320 343 // RFC 4954 §4: a zero-length initial response is a single `=`, 321 344 // because an empty argument would be indistinguishable from 322 345 // sending none at all. ··· 329 352 if (reply.code == 235) break; 330 353 if (reply.code != 334) return error.AuthenticationFailed; 331 354 332 - var challenge_buf: [max_sasl_message]u8 = undefined; 333 - const challenge = decodeChallenge(&challenge_buf, reply.text) orelse { 355 + // The challenge decodes into the coded half, which is free: whatever 356 + // was encoded there has already gone out. 357 + const challenge = decodeChallenge(scratch.coded, reply.text) orelse { 334 358 try c.cancelAuth(); 335 359 return error.InvalidChallenge; 336 360 }; 337 361 338 - message = .fixed(&message_buf); 362 + message = .fixed(scratch.plain); 339 363 try c.mechanismStep(mechanism.respond(challenge, &message)); 340 - try c.sendBase64(message.buffered()); 364 + // ...and the response encodes back over it, the challenge having 365 + // been consumed by `respond`. 366 + try c.send("{s}", .{std.base64.standard.Encoder.encode(scratch.coded, message.buffered())}); 341 367 } 342 368 343 369 // The server says yes. Whether that means anything is the mechanism's to ··· 379 405 return error.InsecureTransport; 380 406 } 381 407 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. 384 - fn sendBase64(c: *Client, bytes: []const u8) AuthError!void { 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}); 408 + /// The two halves of `sasl_buffer`. 409 + /// 410 + /// `coded` is four sevenths and `plain` three, which is base64's expansion 411 + /// exactly — so `coded` always holds the encoding of a full `plain`. They 412 + /// never hold anything at the same time: a challenge decodes into `coded`, 413 + /// is consumed by the mechanism writing into `plain`, and the answer encodes 414 + /// back over it. 415 + const SaslScratch = struct { coded: []u8, plain: []u8 }; 416 + 417 + fn splitSaslBuffer(buffer: []u8) AuthError!SaslScratch { 418 + if (buffer.len < sasl_buffer_min) return error.SaslBufferTooSmall; 419 + const unit = buffer.len / 7; 420 + return .{ .coded = buffer[0 .. unit * 4], .plain = buffer[unit * 4 ..][0 .. unit * 3] }; 388 421 } 389 422 390 423 /// Parameters for the MAIL command. Send only what the server advertised: ··· 1356 1389 var writer: Io.Writer = .fixed(&out_buf); 1357 1390 var reply_buf: [256]u8 = undefined; 1358 1391 var client: Client = .init(&reader, &writer, &reply_buf); 1392 + var sasl_buf: [Client.sasl_buffer_suggested]u8 = undefined; 1393 + client.sasl_buffer = &sasl_buf; 1359 1394 1360 1395 const ext = try client.hello("client.example.org"); 1361 1396 try std.testing.expect(ext.dsn); ··· 1369 1404 var writer: Io.Writer = .fixed(&out_buf); 1370 1405 var reply_buf: [256]u8 = undefined; 1371 1406 var client: Client = .init(&reader, &writer, &reply_buf); 1407 + var sasl_buf: [Client.sasl_buffer_suggested]u8 = undefined; 1408 + client.sasl_buffer = &sasl_buf; 1372 1409 client.security = .encrypted; 1373 1410 1374 1411 const extensions = try client.hello("client.example.org"); ··· 1399 1436 var writer: Io.Writer = .fixed(&out_buf); 1400 1437 var reply_buf: [256]u8 = undefined; 1401 1438 var client: Client = .init(&reader, &writer, &reply_buf); 1439 + var sasl_buf: [Client.sasl_buffer_suggested]u8 = undefined; 1440 + client.sasl_buffer = &sasl_buf; 1402 1441 1403 1442 const extensions = try client.hello("client.example.org"); 1404 1443 var cram: sasl.CramMd5 = .init("tim", "tanstaaftanstaaf"); ··· 1421 1460 var writer: Io.Writer = .fixed(&out_buf); 1422 1461 var reply_buf: [64]u8 = undefined; 1423 1462 var client: Client = .init(&reader, &writer, &reply_buf); 1463 + var sasl_buf: [Client.sasl_buffer_suggested]u8 = undefined; 1464 + client.sasl_buffer = &sasl_buf; 1424 1465 1425 1466 var plain: sasl.Plain = .init("alice", "secret"); 1426 1467 try std.testing.expectError( ··· 1472 1513 var writer: Io.Writer = .fixed(&out_buf); 1473 1514 var reply_buf: [64]u8 = undefined; 1474 1515 var client: Client = .init(&reader, &writer, &reply_buf); 1516 + var sasl_buf: [Client.sasl_buffer_suggested]u8 = undefined; 1517 + client.sasl_buffer = &sasl_buf; 1475 1518 1476 1519 // The server said yes. The mechanism disagrees, and it is the one that 1477 1520 // knows — this is the case nothing in this library could express before ··· 1490 1533 var writer: Io.Writer = .fixed(&out_buf); 1491 1534 var reply_buf: [64]u8 = undefined; 1492 1535 var client: Client = .init(&reader, &writer, &reply_buf); 1536 + var sasl_buf: [Client.sasl_buffer_suggested]u8 = undefined; 1537 + client.sasl_buffer = &sasl_buf; 1493 1538 client.security = .encrypted; 1494 1539 1495 1540 var plain: sasl.Plain = .init("alice", "secret"); ··· 1500 1545 try std.testing.expectEqual(@as(usize, 0), reader.bufferedLen()); 1501 1546 } 1502 1547 1548 + test "authenticate needs a buffer, and says so rather than overrunning one" { 1549 + var reader: Io.Reader = .fixed(""); 1550 + var out_buf: [256]u8 = undefined; 1551 + var writer: Io.Writer = .fixed(&out_buf); 1552 + var reply_buf: [64]u8 = undefined; 1553 + var client: Client = .init(&reader, &writer, &reply_buf); 1554 + client.security = .encrypted; 1555 + 1556 + var plain: sasl.Plain = .init("alice", "secret"); 1557 + // No buffer at all: this is the default, and it is an error rather than 1558 + // a hidden allocation or a stack array the caller cannot see. 1559 + try std.testing.expectError( 1560 + error.SaslBufferTooSmall, 1561 + client.authenticate(plain.client()), 1562 + ); 1563 + 1564 + var tiny: [sasl_buffer_min - 1]u8 = undefined; 1565 + client.sasl_buffer = &tiny; 1566 + try std.testing.expectError( 1567 + error.SaslBufferTooSmall, 1568 + client.authenticate(plain.client()), 1569 + ); 1570 + try std.testing.expectEqualStrings("", writer.buffered()); 1571 + } 1572 + 1573 + test "the two halves of the buffer take turns rather than coexist" { 1574 + // A challenge decodes into the coded half, the mechanism's answer is 1575 + // written into the plain half, and the answer encodes back over the 1576 + // challenge. The minimum buffer is enough to run a real exchange, which 1577 + // is what this checks: at 896 bytes there are 512 coded and 384 plain. 1578 + const responses = "334 PDE4OTYuNjk3MTcwOTUyQHBvc3RvZmZpY2UucmVzdG9uLm1jaS5uZXQ+\r\n" ++ 1579 + "235 2.7.0 Accepted\r\n"; 1580 + var reader: Io.Reader = .fixed(responses); 1581 + var out_buf: [512]u8 = undefined; 1582 + var writer: Io.Writer = .fixed(&out_buf); 1583 + var reply_buf: [256]u8 = undefined; 1584 + var client: Client = .init(&reader, &writer, &reply_buf); 1585 + var scratch: [sasl_buffer_min]u8 = undefined; 1586 + client.sasl_buffer = &scratch; 1587 + 1588 + var cram: sasl.CramMd5 = .init("tim", "tanstaaftanstaaf"); 1589 + try client.authenticate(cram.client()); 1590 + try std.testing.expect(std.mem.endsWith( 1591 + u8, 1592 + writer.buffered(), 1593 + "dGltIGI5MTNhNjAyYzdlZGE3YTQ5NWI0ZTZlNzMzNGQzODkw\r\n", 1594 + )); 1595 + } 1596 + 1503 1597 test "a rejection surfaces as AuthenticationFailed with the reply" { 1504 1598 var reader: Io.Reader = .fixed("535 5.7.8 Authentication credentials invalid\r\n"); 1505 1599 var out_buf: [256]u8 = undefined; 1506 1600 var writer: Io.Writer = .fixed(&out_buf); 1507 1601 var reply_buf: [256]u8 = undefined; 1508 1602 var client: Client = .init(&reader, &writer, &reply_buf); 1603 + var sasl_buf: [Client.sasl_buffer_suggested]u8 = undefined; 1604 + client.sasl_buffer = &sasl_buf; 1509 1605 client.security = .encrypted; 1510 1606 1511 1607 var plain: sasl.Plain = .init("alice", "secret"); ··· 1688 1784 var writer: Io.Writer = .fixed(&out_buf); 1689 1785 var reply_buf: [64]u8 = undefined; 1690 1786 var client: Client = .init(&reader, &writer, &reply_buf); 1787 + var sasl_buf: [Client.sasl_buffer_suggested]u8 = undefined; 1788 + client.sasl_buffer = &sasl_buf; 1691 1789 1692 1790 var message: Io.Reader = .fixed("Subject: hi\n\n.streamed body\n"); 1693 1791 try client.sendMessageReader(&message); ··· 1712 1810 var writer: Io.Writer = .fixed(&out_buf); 1713 1811 var reply_buf: [256]u8 = undefined; 1714 1812 var client: Client = .init(&reader, &writer, &reply_buf); 1813 + var sasl_buf: [Client.sasl_buffer_suggested]u8 = undefined; 1814 + client.sasl_buffer = &sasl_buf; 1715 1815 1716 1816 // Whatever the "server" says, the client must fail cleanly, never crash. 1717 1817 _ = client.greet() catch return;
+53 -17
src/Server.zig
··· 62 62 /// challenges. `Server.init` is called per connection anyway, so building 63 63 /// them alongside it is the natural place. 64 64 auth_mechanisms: []const sasl.Server = &.{}, 65 + /// Scratch for the AUTH exchange, needed only when `auth_mechanisms` is 66 + /// not empty. 67 + /// 68 + /// It is the caller's for the same reason the stream buffers are: how 69 + /// much room a mechanism needs is the caller's to know, and the 70 + /// difference is large — the classic mechanisms want a few hundred 71 + /// bytes, an OAuth token several kilobytes. `sasl_buffer_suggested` fits 72 + /// everything short of an unusually fat token, and `sasl_buffer_min` is 73 + /// the floor. 74 + /// 75 + /// Split four-to-three between base64 and plaintext, which is the ratio 76 + /// base64 expands by, so the usable message is about three sevenths of 77 + /// what is given. 78 + sasl_buffer: []u8 = &.{}, 65 79 /// Reject MAIL with 530 until the client has authenticated. Requires at 66 80 /// least one entry in `auth_mechanisms`. 67 81 require_auth: bool = false, ··· 248 262 const arena = arena_state.allocator(); 249 263 250 264 std.debug.assert(!s.options.require_auth or s.options.auth_mechanisms.len != 0); 265 + // A session that offers mechanisms and no room to run them would answer 266 + // every AUTH with a temporary failure, which is worth catching here. 267 + std.debug.assert(s.options.auth_mechanisms.len == 0 or 268 + s.options.sasl_buffer.len >= sasl_buffer_min); 251 269 std.debug.assert((s.handler.vtable.message == null) != (s.handler.vtable.messageReader == null)); 252 270 253 271 if (s.options.tls) |config| { ··· 550 568 } 551 569 } 552 570 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; 571 + /// The smallest `Options.sasl_buffer` worth offering: enough plaintext for 572 + /// PLAIN, LOGIN, CRAM-MD5, EXTERNAL, ANONYMOUS and DIGEST-MD5. 573 + pub const sasl_buffer_min = 896; 574 + 575 + /// A `Options.sasl_buffer` size that fits everything, OAuth tokens included. 576 + /// See `Client.sasl_buffer_suggested`, which says where the number is from. 577 + pub const sasl_buffer_suggested = 7168; 557 578 558 579 /// Writes the EHLO or LHLO response: the hostname, then one line per 559 580 /// extension. The two are the same list — RFC 2033 gives LHLO the semantics ··· 613 634 return .rejected; 614 635 }; 615 636 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); 637 + // The two halves never hold anything at once: a challenge is written as 638 + // plaintext and encoded into `coded`, and the client's answer decodes 639 + // back over it once that has gone out. 640 + if (s.options.sasl_buffer.len < sasl_buffer_min) { 641 + try s.reply(454, "4.7.0 Temporary authentication failure"); 642 + return .rejected; 643 + } 644 + const unit = s.options.sasl_buffer.len / 7; 645 + const coded = s.options.sasl_buffer[0 .. unit * 4]; 646 + const plain = s.options.sasl_buffer[unit * 4 ..][0 .. unit * 3]; 647 + var challenge: Io.Writer = .fixed(plain); 619 648 620 649 // RFC 4954 §4: no argument at all and a single `=` are different. The 621 650 // first is "I have nothing to send yet", the second an initial response 622 651 // that happens to be empty, and mechanisms read them differently. 623 652 const initial: ?[]const u8 = if (args.initial.len == 0) null else decodeBase64( 624 - &decoded_buf, 653 + coded, 625 654 args.initial, 626 655 ) orelse { 627 656 try s.reply(501, "5.5.2 Invalid base64"); ··· 644 673 return .rejected; 645 674 }, 646 675 .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()); 676 + const encoded = std.base64.standard.Encoder.encode(coded, challenge.buffered()); 649 677 // A zero-length challenge is "334 " — the code, a space, and 650 678 // nothing after it, which `reply` produces for empty text. 651 679 try s.reply(334, encoded); ··· 655 683 .cancelled => return .rejected, 656 684 .disconnected => return .disconnected, 657 685 }; 658 - const response = decodeBase64(&decoded_buf, line) orelse { 686 + const response = decodeBase64(coded, line) orelse { 659 687 try s.reply(501, "5.5.2 Invalid base64"); 660 688 return .rejected; 661 689 }; 662 - challenge = .fixed(&challenge_buf); 690 + challenge = .fixed(plain); 663 691 step = mechanism.respond(response, &challenge) catch |err| 664 692 return s.authFailed(err); 665 693 }, ··· 1334 1362 plain: sasl.PlainServer, 1335 1363 login: sasl.LoginServer, 1336 1364 storage: [2]sasl.Server = undefined, 1365 + /// The scratch a session needs to run them, which `Options` takes from 1366 + /// the caller rather than putting on the stack. 1367 + buffer: [sasl_buffer_suggested]u8 = undefined, 1337 1368 1338 1369 fn init(h: *TestHandler) TestMechanisms { 1339 1370 return .{ .plain = .init(h.check()), .login = .init(h.check()) }; ··· 1342 1373 fn list(m: *TestMechanisms) []const sasl.Server { 1343 1374 m.storage = .{ m.plain.server(), m.login.server() }; 1344 1375 return &m.storage; 1376 + } 1377 + 1378 + fn scratch(m: *TestMechanisms) []u8 { 1379 + return &m.buffer; 1345 1380 } 1346 1381 }; 1347 1382 ··· 1710 1745 "QUIT\r\n", 1711 1746 &out_buf, 1712 1747 h.handler(), 1713 - .{ .require_auth = true, .auth_mechanisms = mechanisms.list() }, 1748 + .{ .require_auth = true, .auth_mechanisms = mechanisms.list(), .sasl_buffer = mechanisms.scratch() }, 1714 1749 ); 1715 1750 1716 1751 try std.testing.expect(std.mem.indexOf(u8, output, "250-AUTH PLAIN LOGIN\r\n") != null); ··· 1733 1768 "QUIT\r\n", 1734 1769 &out_buf, 1735 1770 h.handler(), 1736 - .{ .auth_mechanisms = mechanisms.list() }, 1771 + .{ .auth_mechanisms = mechanisms.list(), .sasl_buffer = mechanisms.scratch() }, 1737 1772 ); 1738 1773 1739 1774 try std.testing.expect(std.mem.indexOf(u8, output, "334 VXNlcm5hbWU6\r\n") != null); ··· 1760 1795 .lookup = Lookup.lookup, 1761 1796 }); 1762 1797 const mechanisms: []const sasl.Server = &.{cram.server()}; 1798 + var sasl_scratch: [sasl_buffer_suggested]u8 = undefined; 1763 1799 1764 1800 var out_buf: [2048]u8 = undefined; 1765 1801 const output = try runScript( ··· 1773 1809 "DATA\r\nbody\r\n.\r\nQUIT\r\n", 1774 1810 &out_buf, 1775 1811 h.handler(), 1776 - .{ .require_auth = true, .auth_mechanisms = mechanisms }, 1812 + .{ .require_auth = true, .auth_mechanisms = mechanisms, .sasl_buffer = &sasl_scratch }, 1777 1813 ); 1778 1814 1779 1815 try std.testing.expect(std.mem.indexOf(u8, output, "250-AUTH CRAM-MD5\r\n") != null); ··· 1796 1832 "EHLO client.example.org\r\nAUTH SCRAM-SHA-256\r\nQUIT\r\n", 1797 1833 &out_buf, 1798 1834 h.handler(), 1799 - .{ .auth_mechanisms = mechanisms.list() }, 1835 + .{ .auth_mechanisms = mechanisms.list(), .sasl_buffer = mechanisms.scratch() }, 1800 1836 ); 1801 1837 try std.testing.expect(std.mem.indexOf(u8, output, "250-AUTH PLAIN LOGIN\r\n") != null); 1802 1838 // A name nothing answers to is 504, not 535: the credentials were never ··· 1839 1875 "QUIT\r\n", 1840 1876 &out_buf, 1841 1877 h.handler(), 1842 - .{ .require_auth = true, .auth_mechanisms = mechanisms.list() }, 1878 + .{ .require_auth = true, .auth_mechanisms = mechanisms.list(), .sasl_buffer = mechanisms.scratch() }, 1843 1879 ); 1844 1880 1845 1881 try std.testing.expect(std.mem.indexOf(u8, output, "530 5.7.0") != null);
+6
src/main.zig
··· 214 214 var reply_buf: [1024]u8 = undefined; 215 215 var client: smtp.Client = .init(&stream_reader.interface, &stream_writer.interface, &reply_buf); 216 216 client.allow_cleartext_auth = config.allow_cleartext_auth; 217 + // The SASL scratch is the caller's; a mechanism never allocates one for 218 + // itself and nothing puts one on the stack behind your back. 219 + var sasl_scratch: [smtp.Client.sasl_buffer_suggested]u8 = undefined; 220 + client.sasl_buffer = &sasl_scratch; 217 221 client.mode = config.protocol; 218 222 219 223 if (config.mode == .tls) { ··· 451 455 "<{d}.{d}@localhost>", 452 456 .{ connections, Io.Clock.real.now(io).nanoseconds }, 453 457 ) catch unreachable; 458 + var sasl_scratch: [smtp.Server.sasl_buffer_suggested]u8 = undefined; 454 459 var plain: smtp.sasl.PlainServer = .init(check); 455 460 var login: smtp.sasl.LoginServer = .init(check); 456 461 var cram_md5: smtp.sasl.CramMd5Server = .init(challenge, passwords); ··· 478 483 .hostname = "localhost", 479 484 .tls = tls_options, 480 485 .auth_mechanisms = mechanisms, 486 + .sasl_buffer = &sasl_scratch, 481 487 .require_auth = config.username != null, 482 488 }, 483 489 );