No description
  • Zig 99.3%
  • Nix 0.7%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jeffrey C. Ollie 7d24d756b4
Build with Zig 0.17.0, and release 0.2.0
The toolchain is nixpkgs' Zig 0.17.0, from a new flake whose devshell
carries it and reuse, so `nix develop -c zig build test` is how the
project is built and checked. The library has no dependencies, so there
is no build.zig.zon.nix to regenerate.

The test binary is compiled with LLVM, which is what gives
`zig build test --fuzz` and kcov coverage to work with.

In the source, @typeInfo hands out field_names rather than fields, and
the two `**` repetitions in tests become @splat, since 0.17 has no
array repetition operator.

Zig 0.16 users are pointed at the zig-0.16 branch and the v0.1.0 tag.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012g9sa6tkGMWgpDJd9YDetP
2026-10-04 11:18:22 -05:00
LICENSES Sketch the SASL mechanism interface 2026-09-12 15:34:39 -05:00
src Build with Zig 0.17.0, and release 0.2.0 2026-10-04 11:18:22 -05:00
.gitignore Sketch the SASL mechanism interface 2026-09-12 15:34:39 -05:00
build.zig Build with Zig 0.17.0, and release 0.2.0 2026-10-04 11:18:22 -05:00
build.zig.zon Build with Zig 0.17.0, and release 0.2.0 2026-10-04 11:18:22 -05:00
flake.lock Build with Zig 0.17.0, and release 0.2.0 2026-10-04 11:18:22 -05:00
flake.nix Build with Zig 0.17.0, and release 0.2.0 2026-10-04 11:18:22 -05:00
README.md Build with Zig 0.17.0, and release 0.2.0 2026-10-04 11:18:22 -05:00
REUSE.toml Build with Zig 0.17.0, and release 0.2.0 2026-10-04 11:18:22 -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.

Requires Zig 0.17.0; the zig-0.16 branch and the v0.1.0 tag build with Zig 0.16.0 (see Building).

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,
    cleartext: *const fn (ctx: *anyopaque) bool,
};

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

cleartext is the same property the client half reports, and must be the same answer: it belongs to the mechanism, not to the half of it you happen to be holding. The two halves want it for opposite reasons — a client decides what to send, a server decides what to advertise — and RFC 4954 §4 is explicit about the second: a server "MUST implement a configuration in which it does NOT permit any plaintext password mechanisms, unless either the STARTTLS command has been negotiated or some other mechanism that protects the session from password snooping has been provided". Server.offerable(encrypted) is that rule, mirroring Client.select's, and a server filters its advertised list with it rather than refusing the command later — a client that sees a mechanism advertised sends the credential before finding out it was not welcome, and by then it is on the wire. A test asserts the two halves of every mechanism agree.

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

The main branch needs Zig 0.17.0, which the Nix devshell provides along with reuse:

nix develop -c zig build test     # the interface, the mechanisms, and the exchange argument
nix develop -c zig build check    # compile everything without running it

For Zig 0.16.0, take the zig-0.16 branch, which holds the last of zig-sasl to build with it, or the v0.1.0 tag:

zig fetch --save git+https://git.jcollie.dev/jeff/zig-sasl.git#zig-0.16
zig fetch --save git+https://git.jcollie.dev/jeff/zig-sasl.git#v0.1.0