- Zig 94.2%
- Nix 5%
- Shell 0.8%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
All checks were successful
test / test (push) Successful in 4m36s
No change here beyond the pin, but it has to move: zig-ldap depends on both this library and zig-sasl, and two different pins of zig-sasl in one dependency graph are two different `sasl.Client` types. The adapter then does not fit the interface the caller is holding, with an error message that says "expected type 'Client', found 'Client'". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SDrB41sGu5k1ubD1ufbxqC |
||
| .forgejo/workflows | ||
| completions | ||
| LICENSES | ||
| src | ||
| .gitignore | ||
| build.zig | ||
| build.zig.zon | ||
| build.zig.zon.nix | ||
| flake.lock | ||
| flake.nix | ||
| package.nix | ||
| README.md | ||
| REUSE.toml | ||
zig-scram
SCRAM in Zig — RFC 5802 and RFC 7677: the four-message authentication exchange, channel binding, and the password verifiers it authenticates against, over any hash those RFCs name.
It is a library first, with a small CLI alongside it for the one job that is awkward to do any other way: computing a PostgreSQL verifier by hand.
scram.Client |
The client half of the exchange, as a state machine with no transport in it. |
scram.Secret |
A verifier — what a server stores instead of a password. |
scram.Mechanism(Hash) |
The same thing over some other hash; scram is Mechanism(Sha256). |
scram.saslprep |
RFC 4013, which SCRAM runs over a password before hashing it. |
A verifier is the half of SCRAM that PostgreSQL puts in front of its users:
SCRAM-SHA-256$<iterations>:<base64 salt>$<base64 StoredKey>:<base64 ServerKey>
That is the string it keeps in pg_authid.rolpassword. Computing it
client-side means CREATE ROLE / ALTER ROLE can be issued with the verifier
in place of the password, so the plaintext never crosses the wire, never lands
in the server log, and never reaches pg_stat_activity.
ALTER ROLE alice PASSWORD 'SCRAM-SHA-256$4096:AAECAwQFBgcICQoLDA0ODw==$...';
Requires Zig 0.16.0.
Install
zig fetch --save git+https://git.jcollie.dev/jeff/zig-scram.git
// build.zig
const scram = b.dependency("scram", .{
.target = target,
.optimize = optimize,
});
exe.root_module.addImport("scram", scram.module("scram"));
The only dependency is uucode, for the Unicode character data behind the SASLprep step. It is built with just the four fields this module reads.
Where this lives
The canonical repository is on my Forgejo instance:
git clone https://git.jcollie.dev/jeff/zig-scram.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:z3p1EVd76fZgybgAPzCpM25LAUCwP
With a Radicle node running (rad node start):
rad clone rad:z3p1EVd76fZgybgAPzCpM25LAUCwP
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:z3p1EVd76fZgybgAPzCpM25LAUCwP
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-scram
Verifiers
const std = @import("std");
const scram = @import("scram");
var threaded: std.Io.Threaded = .init(gpa, .{});
defer threaded.deinit();
// Fresh random salt, PostgreSQL's own defaults: 16 salt bytes, 4096 rounds.
const secret = try scram.generate(gpa, threaded.io(), "hunter2", .{});
std.debug.print("{f}\n", .{secret});
// SCRAM-SHA-256$4096:6/+BSuGpL8lOjQ12JyH+JQ==$immDsh...:nWB1yw...
Secret is a plain value with no owned memory, so it can be copied, stored, and
returned freely. Render it with the {f} placeholder, or:
var buf: [scram.max_encoded_length]u8 = undefined;
const text = try secret.bufPrint(&buf); // no allocation
const owned = try secret.toOwnedString(gpa); // caller frees
Read one back, and check a password against it:
const stored = try scram.Secret.parse(rolpassword);
if (try stored.verify(gpa, attempt, .saslprep)) {
// Recomputed with the stored salt and iteration count, compared in
// constant time.
}
For a specific salt and round count — reproducing an existing verifier, or testing:
const secret = try scram.compute(gpa, password, salt, 4096, .saslprep);
generate takes Options:
| field | default | |
|---|---|---|
iterations |
4096 |
PBKDF2 rounds. PostgreSQL 16+ exposes this as the scram_iterations GUC. |
salt_length |
16 |
Random salt bytes, up to max_salt_length (64). |
normalization |
.saslprep |
See SASLprep. |
The allocator is only touched when SASLprep has real work to do, which means
never for an all-ASCII password and never for .raw.
A Secret holds no secret material: the plaintext cannot be recovered from it.
It is still enough to impersonate the server to a client, though, so a
verifier is sensitive even where it is not a password.
The text format is PostgreSQL's. The RFCs describe what a server has to know,
not how to write it down, so while ScramSha1.Secret renders the same shape
under a SCRAM-SHA-1$ tag, only the SHA-256 spelling is a string PostgreSQL
will accept.
Authenticating
Client is the client half of the exchange, as a state machine with no
transport in it: you hand it what arrived and send what it hands back. It never
opens a socket and never touches TLS.
var client: scram.Client = try .init(gpa, io, .{
.username = "user",
.password = "pencil",
});
defer client.deinit();
try conn.send(client.clientFirst());
try client.handleServerFirst(try conn.receive());
try conn.send(try client.clientFinal());
try client.handleServerFinal(try conn.receive());
Both ends prove themselves. Returning from that last call is what
authenticates the server — it has shown it knows ServerKey, which only
the holder of the verifier does. A client that sends its proof and then treats
the connection as good without getting there has proved itself to a stranger
and learned nothing in return.
Every slice handed back is owned by the Client and lives until deinit;
every slice passed in is copied if it is needed later, so your buffers are free
immediately. The password is wiped as soon as the key schedule has run, and the
keys as soon as the proofs are computed.
Client.Options:
| field | default | |
|---|---|---|
username |
— | May be empty. PostgreSQL sends n=, because libpq has already named the user in the startup packet. |
password |
— | |
authzid |
null |
The authorization identity, when it differs from the authentication one. |
channel_binding |
.none |
See below. |
normalization |
.saslprep |
Applied to the username and authzid as well as the password. |
nonce_length |
24 |
Random bytes to draw; the nonce is sent as base64 of them. |
nonce |
null |
Use this nonce verbatim. Only for reproducing published vectors. |
minimum_iterations |
4096 |
Refuse a server that asks for fewer rounds than this, which RFC 7677 §4 makes the floor. |
client.mechanism() gives the name to negotiate, and client.authenticated()
answers whether the exchange completed.
Channel binding
Set channel_binding and the mechanism to negotiate becomes
SCRAM-SHA-256-PLUS.
var client: scram.Client = try .init(gpa, io, .{
.username = "user",
.password = "pencil",
.channel_binding = .{ .bound = .{
.type = "tls-server-end-point",
.data = cert_hash, // you compute this from your TLS stack
} },
});
The binding data is a property of the TLS connection, not of SCRAM, so it is
passed in rather than derived here — which means tls-server-end-point,
tls-exporter and tls-unique all work without this library depending on a
TLS implementation.
The three variants are not preferences. They are assertions about what the server advertised, and the server checks them:
.none |
This client does not support channel binding. |
.unsupported_by_server |
It does, but the server's mechanism list had no -PLUS variant. If that list was tampered with, the server knows what it really advertised and will abort. |
.bound |
Bind the exchange to the transport. |
Other hashes
RFC 5802 defines SCRAM generically and instantiates it once, as SCRAM-SHA-1;
RFC 7677 instantiates it again as SCRAM-SHA-256, changing nothing but the hash.
Mechanism(Hash) is that parameter made explicit, and scram is
Mechanism(Sha256) under a shorter name.
const ScramSha1 = scram.ScramSha1; // RFC 5802's own
const ScramSha512 = scram.Mechanism(std.crypto.hash.sha2.Sha512);
var client: ScramSha1.Client = try .init(gpa, io, .{ ... });
A hash with no registered mechanism name is a compile error rather than a guess, since the name is what the two ends use to agree on what they are running. SHA-1, SHA-224, SHA-256, SHA-384, SHA-512 and SHA3-512 are accepted; References cited says where each of those names comes from.
Errors
handleServerFirst rejects a server whose nonce does not extend the client's
(error.NonceMismatch) and one asking for too few rounds
(error.IterationCountTooLow). handleServerFinal returns
error.ServerSignatureMismatch when the server cannot prove itself, and
error.AuthenticationFailed when it answered e= — client.serverError()
then says which of RFC 5802's error values it sent, for the log.
Message parsing follows the ABNF rather than accepting anything that could be
understood: attributes must arrive in the order the grammar lists them, and an
m= attribute fails the exchange wherever it appears, because it marks an
extension the receiver is required to understand.
As a SASL mechanism
SCRAM is a SASL mechanism, and a protocol library should be able to run it
without knowing what SCRAM is. src/sasl.zig is that adapter, exposed as a
separate module, scram-sasl, so that scram itself keeps no dependency on
zig-sasl and only something that
wants to hand SCRAM to an SMTP, POP3 or IMAP client pays for it.
var inner: scram.Client = try .init(gpa, io, .{ .username = "user", .password = "pencil" });
defer inner.deinit();
var adapter: scram_sasl.Sha256 = .init(&inner, scram.ScramSha256.name);
try client.authenticate(adapter.client()); // the protocol library's call
The adapter's whole job is a mapping. SCRAM is four messages —
client-first, server-first, client-final, server-final — and none of
SMTP, POP3 or IMAP can carry data alongside a successful outcome, which
RFC 4954 states outright and which IMAP's tagged OK does not change. So
server-final arrives as one more challenge and is answered with an empty
response before the protocol reports success.
That last exchange is where the server proves it knows ServerKey, so a
client that treats the protocol's success as the end of the story has
verified nothing. The adapter reports satisfied() == false until
handleServerFinal has run, and a caller that checks it cannot make that
mistake. The errors divide three ways, which is the distinction worth
keeping: a signature that does not verify is BadServerProof — an accusation
about the peer — where an e= is Rejected, a well-formed message from a
server doing nothing wrong, and anything else is BadChallenge.
SASLprep
SCRAM does not hash the password bytes directly. It hashes
SASLprep(password) — RFC 4013, a stringprep profile that maps some
characters away, folds some to a space, normalizes to NFKC, and rejects the
rest. Getting this wrong means the verifier silently disagrees with the server
for any password outside ASCII.
The implementation here follows PostgreSQL's src/common/saslprep.c rather
than the RFC wherever the two differ, because agreeing with the server is the
whole point. Three behaviours are worth knowing about:
- All-ASCII passwords short-circuit. SASLprep is the identity on ASCII, so the whole profile is skipped. This is also why an ASCII control character in a password never trips the prohibited-output check, even though the table lists it.
- The prohibit and bidi checks run before normalization, against the mapped string rather than the NFKC output, though RFC 3454 describes them as checks on the output. PostgreSQL has always done it this way.
- Failure falls back to the raw bytes. If a password is not valid UTF-8, or
contains a prohibited or Unicode-3.2-unassigned character, or breaks the
bidirectional-text rules, both the server and libpq hash the unprepared bytes
instead.
.saslprepdoes the same.
Normalization picks between that and doing nothing:
.saslprep(default) — full SASLprep with the PostgreSQL fallback. Reproduces the server's verifier for any password..raw— hash the bytes as given. Correct only if the caller has already prepared the password, or knows it is pure ASCII.
RFC 5802 also says a client should prepare the username, so Client runs the
same profile over username and authzid.
To find out that a password needed the fallback rather than silently taking
it, call scram.saslprep.prep directly; it returns error.InvalidUtf8 or
error.Prohibited instead. The CLI exposes this as --strict-prep.
Unicode versions
The stringprep range tables are transcribed from PostgreSQL's saslprep.c, so
the A.1 unassigned-code-point table stays frozen at Unicode 3.2 exactly as
RFC 3454 specifies. Normalization, on the other hand, uses whatever Unicode
version uucode ships — currently 17.0, against 15.1 in PostgreSQL 17 and 16.0
in PostgreSQL 18.
In practice this gap is unreachable: Unicode's normalization stability policy freezes a character's decomposition once assigned, so the versions can only disagree about characters that did not exist in the server's Unicode version.
The CLI
scram-sha-256 computes a PostgreSQL verifier and nothing else — it is named
for the one mechanism whose stored format it writes, not for the library. It
reads the password from stdin by default, since -p puts it in the process
table where other users can see it.
$ zig build
$ printf 'hunter2' | ./zig-out/bin/scram-sha-256
SCRAM-SHA-256$4096:zLU9phQvqg5BXTM1oxGFsQ==$NokuUG1vCRqy...:l/jd28uhQujg...
$ ./zig-out/bin/scram-sha-256 --help
-i/--iterations, -s/--salt-length, --raw, and --strict-prep map onto the
options above.
Shell completions
zig build install writes fish and bash completions under the prefix, in the
directories both shells already search:
$ zig build install --prefix ~/.local
$ ls ~/.local/share/fish/vendor_completions.d/scram-sha-256.fish
$ ls ~/.local/share/bash-completion/completions/scram-sha-256
Both shells search $XDG_DATA_HOME (usually ~/.local/share) and every prefix
on $XDG_DATA_DIRS, so a prefix already on those paths needs no further setup.
bash also needs the bash-completion package, which loads the file on demand
the first time scram-sha-256 is completed. Nothing searches zig-out, the
default prefix, so either install to a real prefix or source the files from
completions/ directly.
Nix
The flake builds the CLI and provides the development shell this project is worked on in:
nix build # the CLI, with its shell completions, into ./result
nix run . -- --help
nix develop # zig, reuse, zon2nix, kcov and perf
A Nix build has no network, and zig build wants one to fetch uucode. The
bridge is build.zig.zon.nix, generated from build.zig.zon by zon2nix: it
evaluates to a directory laid out like Zig's package cache, which package.nix
hands to zig build --system, so Nix fetches the dependency and the build
itself fetches nothing. Adding, removing or updating a dependency means
regenerating it — never editing it — so that every hash comes from the
manifest:
nix develop -c zon2nix --16 --nix=build.zig.zon.nix build.zig.zon
The dependency directory is also a flake output of its own, for running
zig build against something other than the package:
zig build --system "$(nix build --print-out-paths .#zig-deps)"
Testing
zig build test
The suite replays both published transcripts end to end — RFC 7677 §3 for SCRAM-SHA-256 and RFC 5802 §5 for SCRAM-SHA-1 — checking every message the client emits byte for byte, including the proof and the verification of the server's signature. Between them they pin the whole derivation chain and the whole wire format, under two different hashes.
The proof is also checked the way a server checks it, from a stored Secret
alone: recover ClientKey by undoing the XOR, hash it, and compare against
StoredKey. That runs against a verifier derived independently of the code
that built the proof.
The SASLprep half has been checked differentially against independent implementations. Those runs were one-off validations rather than part of the suite, since they need a Python interpreter and a copy of PostgreSQL's source:
- NFKC against Python's
unicodedata— 292,531 single code points and 155,704 random sequences, no mismatches. Code points unassigned in Python's Unicode 16 were skipped; the stability policy makes that sound. - The full SASLprep profile against a Python port of
pg_saslprep, reading its tables straight out ofsaslprep.crather than out of this repository — 331,229 random inputs, no mismatches.
Layout
| file | |
|---|---|
src/scram.zig |
SCRAM-SHA-256: the Secret and Client everything else aliases. |
src/mechanism.zig |
Mechanism(Hash) — the key schedule, the verifier, the client state machine. |
src/messages.zig |
The RFC 5802 message grammar and the GS2 header. |
src/saslprep.zig |
RFC 4013, following PostgreSQL's implementation. |
src/nfkc.zig |
NFKC (UAX #15) over uucode's character data. |
src/stringprep_tables.zig |
RFC 3454 range tables, transcribed from saslprep.c. |
src/sasl.zig |
SCRAM as a zig-sasl mechanism, in its own scram-sasl module. |
src/main.zig |
The CLI. |
completions/ |
fish and bash completions for the CLI. |
What is not here
The server half of the exchange. Secret holds exactly what a server needs
and src/messages.zig has the grammar, so it is mostly message-writing, but
nothing here accepts an exchange rather than initiating one.
References cited
Everything this implementation is answerable to. The note on each says what this project takes from it, not what the document is about.
The mechanism
| RFC 5802 | Salted Challenge Response Authentication Mechanism (SCRAM) SASL and GSS-API Mechanisms. The exchange, the message ABNF, the key schedule, the e= error values — and SCRAM-SHA-1, its own instantiation. |
| RFC 7677 | SCRAM-SHA-256 and SCRAM-SHA-256-PLUS. The default here, and where §4's floor of 4096 iterations comes from. |
| RFC 5801 | GSS-API Mechanisms in SASL: The GS2 Mechanism Family. The gs2-header — the channel-binding flag and authorization identity that open the client's first message and reappear inside c=. |
| RFC 4422 | Simple Authentication and Security Layer (SASL). The framework SCRAM is a mechanism of: what authentication and authorization identities are, and how a mechanism comes to be chosen. |
| draft-melnikov-scram-sha-512 | SCRAM-SHA-512. An expired Internet-Draft. |
| draft-melnikov-scram-sha3-512 | SCRAM-SHA3-512, likewise — and the one that has to exist, because SHA-3 has no entry in the hash-name registry below, so its mechanism name cannot be derived and must be specified outright. |
Preparing passwords and usernames
| RFC 4013 | SASLprep: Stringprep Profile for User Names and Passwords. What src/saslprep.zig implements. Formally obsolete — see RFC 8265 below. |
| RFC 3454 | Preparation of Internationalized Strings ("stringprep"). The framework SASLprep is a profile of, and the source of the range tables in src/stringprep_tables.zig. |
| UAX #15 | Unicode Normalization Forms. NFKC, the normalization step. |
| RFC 3629 | UTF-8. stringprep is defined over UTF-8, and a password that is not valid UTF-8 is one of the things that triggers the fallback to raw bytes. |
| RFC 8265 | PRECIS Profiles for Usernames and Passwords. Obsoletes RFC 7613, which obsoleted RFC 4013 — so SASLprep has been superseded twice over. Listed because it is deliberately not implemented: RFC 7677 still specifies SASLprep and PostgreSQL still uses it, and a verifier that disagrees with the server is worthless however current its string preparation is. |
Channel binding
| RFC 5056 | On the Use of Channel Bindings to Secure Channels. What a channel binding is and what attack it closes. |
| RFC 5929 | Channel Bindings for TLS. tls-unique and tls-server-end-point. |
| RFC 9266 | Channel Bindings for TLS 1.3. tls-exporter, which is the binding to use over TLS 1.3, where tls-unique is not defined. |
Primitives
| RFC 2104 | HMAC. |
| RFC 8018 | PKCS #5 v2.1 — PBKDF2, the salted iteration that turns a password into SaltedPassword. |
| FIPS 180-4 | SHA-1 and the SHA-2 family. |
| FIPS 202 | SHA-3. |
| RFC 4648 | Base64, which every binary field on the wire and in a verifier is encoded with. |
IANA registries
| SASL Mechanisms | Registered as the family SCRAM-*, so there is no individual SCRAM-SHA-256 row to look up. |
| Hash Function Textual Names | sha-1, sha-224, sha-256, sha-384, sha-512, and nothing else. RFC 5802 §4 builds a mechanism name by prefixing one of these with SCRAM-, which is exactly the set Mechanism(Hash) accepts without a draft to point at. |
| Channel-Binding Types | The cb-name values that may follow p=. |
License
MIT, with one exception: src/stringprep_tables.zig is MIT AND PostgreSQL,
because it is transcribed from PostgreSQL's src/common/saslprep.c — including
that file's merging of adjacent ranges — and the PostgreSQL License requires its
notice be retained. Both licenses are permissive and impose nothing beyond
attribution.
The underlying tables are published in RFC 3454, whose copyright statement allows derivative works that "assist in its implementation ... without restriction of any kind", so no further grant is needed for them.
Every file carries an SPDX header and the license texts are in LICENSES/, so
the project is REUSE compliant and reuse lint passes.
Note for anyone distributing a binary built from this: the Unicode character data reaches you through uucode, which ships the Unicode License alongside its own MIT license. Nothing to do when consuming this as source, but the Unicode License asks for its notice in distributions.