No description
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jeffrey C. Ollie ed616574a3
Add ANONYMOUS, and take DIGEST-MD5 from zig-ldap
Both are here so that zig-ldap can stop keeping its own SASL mechanisms.

ANONYMOUS (RFC 4505) authenticates nobody on purpose. Its one message is
trace information, and the RFC is blunt about what that is worth --
"The trace information is not authenticated, so it can be falsified" --
so the server side hands it over clearly labelled rather than as an
identity. `cleartext` is false, which is worth a word: the transport rule
is about a credential an eavesdropper could reuse, and there is no
credential here at all.

DIGEST-MD5 (RFC 2831, historic per RFC 6331) moved wholesale from
zig-ldap, where it was the only thing in a mechanism directory. The
exchange, the message grammar and the constant-time rspauth check came
across untouched; what changed is the thirty lines of adapter at the end
and an error set of its own, mapped into the interface's terms by
`translate`.

It also settles a question the interface had not been asked yet. zig-ldap
gave its mechanisms a third method, `finish(credentials)`, because LDAP
can return SASL data alongside a successful bind -- the one profile in
this collection that can. It turns out no method is needed: a profile
with a real slot for the final message calls `respond` with it and checks
`satisfied`, discarding what gets written, and a profile without one
delivers the same bytes as a challenge and sends the empty response back.
Same two calls, and the mechanism cannot tell which it is talking to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SDrB41sGu5k1ubD1ufbxqC
2026-09-12 21:09:06 -05:00
LICENSES Sketch the SASL mechanism interface 2026-09-12 15:34:39 -05:00
src Add ANONYMOUS, and take DIGEST-MD5 from zig-ldap 2026-09-12 21:09:06 -05:00
.gitignore Sketch the SASL mechanism interface 2026-09-12 15:34:39 -05:00
build.zig Sketch the SASL mechanism interface 2026-09-12 15:34:39 -05:00
build.zig.zon Sketch the SASL mechanism interface 2026-09-12 15:34:39 -05:00
README.md Add ANONYMOUS, and take DIGEST-MD5 from zig-ldap 2026-09-12 21:09:06 -05:00
REUSE.toml Sketch the SASL mechanism interface 2026-09-12 15:34:39 -05:00

zig-sasl

SASL (RFC 4422) as a set of mechanisms that know nothing about any protocol, so that a protocol library can carry any of them without knowing what they are.

This is early. The interface is real, the tests pass, and zig-scram already implements it — but no protocol library uses it yet.

mechanism client server credential in the clear
PLAIN RFC 4616 yes yes yes
LOGIN expired draft yes yes yes
CRAM-MD5 RFC 2195 yes yes no
EXTERNAL RFC 4422 yes yes nothing to send
ANONYMOUS RFC 4505 yes yes nothing to send, and nothing proved
DIGEST-MD5 RFC 2831, historic per RFC 6331 yes no
XOAUTH2 Google yes yes — a bearer token, which is worse
OAUTHBEARER RFC 7628 yes yes, likewise
SCRAM-SHA-1, SCRAM-SHA-256 RFC 5802, RFC 7677 yes no, and authenticates the server too

SCRAM lives in zig-scram, which depends on this rather than the other way round, so this library keeps no dependencies of its own.

Where this lives

The canonical repository is on my Forgejo instance:

git clone https://git.jcollie.dev/jeff/zig-sasl.git

It is also published on Radicle, a peer-to-peer network where a repository has no canonical host — it lives on whichever nodes choose to seed it. Its Repository ID is:

rad:z3DzNWn7BtGC7Dt4ChJKxDcpchvzm

With a Radicle node running (rad node start):

rad clone rad:z3DzNWn7BtGC7Dt4ChJKxDcpchvzm

clone consults your node's routing table to find a seed holding the repository, so no host has to be named. The default branch is main, the same history you would get from the Forgejo instance. To help keep it available, seed it:

rad seed rad:z3DzNWn7BtGC7Dt4ChJKxDcpchvzm

Zig's package manager does not speak rad://, so zig fetch still wants the git+https URL above. Radicle is for getting the source, filing issues, and sending patches without a forge account.

A mirror is kept on Tangled, where the repository lives at:

https://tangled.org/jcollie.dev/zig-sasl

The problem it is for

A SASL mechanism is a state machine that exchanges opaque octet strings with its peer. A profile is one protocol's rules for carrying those strings. The mechanisms are shared; the profiles are not — and today they are not shared either:

zsmtp zig-pop3 a future IMAP zig-scram
PLAIN, LOGIN, CRAM-MD5 yes yes would be a third
XOAUTH2, EXTERNAL yes would be a second
SCRAM unreachable unreachable unreachable yes

Three copies of PLAIN, about to be four, and a SCRAM implementation no client can use. Every fix — a cleartext-transport policy, SASLprep on a password — has to be made once per protocol.

The shape the profiles agree on

SMTP (4954) POP3 (5034) IMAP (9051)
command AUTH mech [ir] AUTH mech [ir] AUTHENTICATE mech [ir]
challenge 334 <base64> + <base64> + <base64>
empty challenge 334 + +
response bare base64 line bare base64 line bare base64 line
empty initial response = = =
cancel * → 501 *-ERR * → tagged BAD
success 235 +OK tagged OK
failure 535; 504 unknown mech -ERR tagged NO/BAD
line limit 12288 suggested 255 on the command none stated
data with success no no no

Two rows in that table did the design work.

None of the three can return data with a successful outcome. RFC 4954 says it outright — "the SMTP protocol doesn't support the SASL feature of returning additional data with a successful outcome" — and IMAP's tagged OK carries a CAPABILITY response code, not SASL data. So a mechanism whose last message is the server proving itself, which is what SCRAM is for, must have that message arrive as a final challenge and answer it with a zero-length response before the success reply. A mechanism therefore needs to be able to send an empty response that is not a cancellation, and to say "the server said yes and I have not verified it" — the case a man in the middle produces. src/exchange.zig drives exactly that and asserts it.

POP3 allows 255 octets on the AUTH command line. An OAuth bearer token does not fit, so the profile has to be free to take a mechanism's initial response and send it as the answer to the first challenge instead. The mechanism cannot know this, so it does not decide it.

The interface

pub const Initial = enum { none, written };

pub const VTable = struct {
    name: *const fn (ctx: *anyopaque) []const u8,
    initial: *const fn (ctx: *anyopaque, out: *Io.Writer) Error!Initial,
    respond: *const fn (ctx: *anyopaque, challenge: []const u8, out: *Io.Writer) Error!void,
    satisfied: *const fn (ctx: *anyopaque) bool,
    cleartext: *const fn (ctx: *anyopaque) bool,
};
  • initial returning .none means "ask me first"; returning .written having written nothing means "an initial response that happens to be empty", which every profile spells =. LOGIN is the first, PLAIN the second, and the two are different on the wire.
  • respond writing nothing is a legal answer, not a refusal. Cancelling is the caller's decision and its spelling is the profile's, so it is not here.
  • satisfied is checked when the protocol reports success. Success with false must be treated as failure.
  • cleartext moves the "don't send a password over a plaintext carrier" rule out of each protocol's hardcoded list of mechanism names.
  • Error.Rejected is the peer reporting failure inside the mechanism — SCRAM's e= attribute. It overlaps with the rejection the protocol is about to report without being redundant with it: the mechanism learns why, the profile only learns that.

Mechanisms write into a writer and never see a length, so they compose with print and writeAll and do no arithmetic. The profile knows the line limit, so the profile sizes the buffer behind that writer, and a message too long for it fails as error.WriteFailed. This needs no streaming base64 encoder, which was the objection to it: the profile writes plaintext into a fixed writer over its own scratch and then encodes buffered() into the line.

The mechanism owns its own memory: the caller constructs it — scram.Client.init(gpa, io, …) allocates, Plain does not — and hands over the Client. A protocol library that is allocation-free stays that way, because it only calls through a vtable into buffers it already had.

The server side inverts, and cannot be fn (username, password) bool — which is what zsmtp and zig-pop3 both have. SCRAM's server holds a verifier it cannot recover a password from; OAUTHBEARER's holds no credential at all and validates a token against an issuer. So the credential lookup belongs to the mechanism, which knows what shape of secret it needs:

pub const Step = union(enum) { challenge, accepted: []const u8, rejected };
pub const VTable = struct {
    name: *const fn (ctx: *anyopaque) []const u8,
    start: *const fn (ctx: *anyopaque, initial: ?[]const u8, out: *Io.Writer) Error!Step,
    respond: *const fn (ctx: *anyopaque, response: []const u8, out: *Io.Writer) Error!Step,
};

challenge carries no payload — the bytes went to the writer — while accepted keeps the identity, which is not sent anywhere.

What a profile looks like on top

The same loop three times, differing only in framing. SMTP:

var buf: [12288]u8 = undefined;            // RFC 4954's suggested line limit
var out: Io.Writer = .fixed(&buf);
switch (try mech.initial(&out)) {
    .written => try c.send("AUTH {s} {b64}", .{ mech.name(), out.buffered() }), // "=" when empty
    .none => try c.send("AUTH {s}", .{mech.name()}),
}
while (true) {
    const reply = try c.readReply();
    switch (reply.code) {
        334 => {
            const challenge = try decodeBase64(&chal, reply.text);
            out = .fixed(&buf);
            try mech.respond(challenge, &out);
            try c.sendBase64(out.buffered());            // empty stays empty
        },
        235 => break,
        else => return error.AuthenticationFailed,
    }
}
if (!mech.satisfied()) return error.ServerNotAuthenticated;

POP3 differs in three lines: + instead of 334 , +OK/-ERR instead of 235/535, and the 255-octet check that demotes an over-long initial response. IMAP differs in three others: the command is AUTHENTICATE, the initial response needs RFC 4959, and the terminal reply is tagged. The if (!mech.satisfied()) line is identical in all three and is the part none of them has today.

Deliberately absent

Security layers. Mechanisms may negotiate integrity or confidentiality over the carrier, and each profile has a rule for it — SMTP resets to the state after its 220 greeting, IMAP requires a fresh CAPABILITY — but every one of those rules is conditional on a layer actually being negotiated, and none of PLAIN, LOGIN, CRAM-MD5, EXTERNAL, SCRAM, XOAUTH2 or OAUTHBEARER negotiates one. Building it would be building for a user that does not exist.

FTP. RFC 2228's AUTH/ADAT is shaped like a SASL exchange and is not one: it predates SASL, mentions it nowhere, names GSSAPI and KERBEROS_V4 as its mechanisms, and defines no username/password mechanism over ADAT at all. A mechanism from here plugged into ADAT would interoperate with nothing.

A challenge is not always a challenge

Three mechanisms here answer one with something other than a real message, and between them they are why respond may write nothing, why it may write something fixed, and why satisfied exists at all.

SCRAM is challenged with the server's own proof, because the carrier had nowhere else to put it. Answering it is an acknowledgement; the exchange then succeeds, and a client that never received it has authenticated to something that authenticated nothing back.

XOAUTH2 is challenged with a failure report — a JSON object naming the status and the scope it wanted — and Google's specification requires an empty response to it before the server will send its actual error.

OAUTHBEARER does the same thing and wants a different answer: RFC 7628 section 3.2.3 requires "a single %x01 (control A) character", which is AQ== after base64. Same idea, different spelling, and an interface that could only express "answer with silence" would carry one of them and not the other.

For both OAuth mechanisms respond succeeds, keeps the report for the caller (report()), and stops being satisfied. Returning an error instead would strand the session waiting for a line that was never coming, and throw away the one message that says whether the token had expired or merely lacked a scope.

EXTERNAL makes the other distinction concrete. With no authorization identity to assert it sends an initial response of zero bytes, which is not the same as declining to send one — every profile spells the first = and the second as leaving the argument off the command.

Where the secret comes from

The server side is the reason this cannot be one interface with one callback. fn (username, password) bool — which is what the protocol libraries had — carries PLAIN and LOGIN and nothing else, because it assumes the server holds something a password can be compared against. So the credential source is part of the mechanism, and there are three of them:

who wants it what it hands over
PasswordCheck PLAIN, LOGIN nothing: it is asked whether a password is right, so the application may store a one-way hash
PasswordLookup CRAM-MD5 the password itself
IdentityCheck EXTERNAL nothing: there is no secret, only whether an identity may be asserted

That PasswordLookup exists at all is the argument against offering CRAM-MD5: a server has to compute the same HMAC the client did, so it must store passwords it can read back. SCRAM was designed to escape exactly that, and its server would want a fourth — a salt, an iteration count and two keys — which is why it belongs in zig-scram rather than here.

PLAIN and LOGIN share PasswordCheck deliberately: an application writes one check and offers both mechanisms from it.

The profile that can do what the others cannot

satisfied() and the answer-a-challenge-with-nothing convention exist because SMTP, POP3 and IMAP cannot return SASL data alongside a success. LDAP can: a BindResponse carries serverSaslCreds on a success result, not only on saslBindInProgress.

The interface needs no extra method for it. A profile with a real slot for the final message calls respond with it and then checks satisfied, discarding what gets written — which for DIGEST-MD5 and SCRAM is nothing, because there was never anything left to say. A profile without one delivers the same bytes as a challenge and sends the empty response back. Same two calls, and the mechanism cannot tell the difference.

Still to do

  • Server halves for the OAuth mechanisms, which need a token introspection endpoint and so want an application that has one.
  • The SCRAM server, which is zig-scram's to write — its README already lists the server half as absent.
  • Moving PLAIN, LOGIN and CRAM-MD5 out of zsmtp and zig-pop3, which is decided: keeping built-in copies would preserve the duplication this exists to end. Both gain a dependency on this library to authenticate at all.

Building

zig build test     # the interface, two mechanisms, and the exchange argument
zig build check    # compile everything without running it