- Zig 100%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
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 |
||
| LICENSES | ||
| src | ||
| .gitignore | ||
| build.zig | ||
| build.zig.zon | ||
| README.md | ||
| REUSE.toml | ||
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 |
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,
};
initialreturning.nonemeans "ask me first"; returning.writtenhaving 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.respondwriting 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.satisfiedis checked when the protocol reports success. Success withfalsemust be treated as failure.cleartextmoves the "don't send a password over a plaintext carrier" rule out of each protocol's hardcoded list of mechanism names.Error.Rejectedis the peer reporting failure inside the mechanism — SCRAM'se=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