- Zig 98.2%
- Shell 1.8%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
Dashes read better than underscores in a command name, and match the SCRAM-
SHA-256 spelling the tool prints. The usage line, the README examples, and
both completion files follow.
The completion files are renamed along with it. bash-completion loads a
completion on demand from a file named exactly like the command, so that one
had to move rather than just change its `complete` line; the function inside
keeps its underscores, which a shell function name requires.
The Zig module and package names stay scram_sha_256: they are identifiers, so
they cannot take a dash, and `b.dependency("scram_sha_256", ...)` remains what
a consumer writes.
Verified from a clean zig-out: the binary and both completion files install
under their new names, `--help` reports the new usage line, and completing
`scram-sha-256` works in both shells. Tests pass and `reuse lint` is clean.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01XddM4nFLF8sdFN5mdK9AxJ
|
||
| .forgejo/workflows | ||
| completions | ||
| LICENSES | ||
| src | ||
| .gitignore | ||
| build.zig | ||
| build.zig.zon | ||
| README.md | ||
zig-scram-sha-256
Compute PostgreSQL SCRAM-SHA-256 password verifiers in Zig, without sending the plaintext to the server.
SCRAM-SHA-256$<iterations>:<base64 salt>$<base64 StoredKey>:<base64 ServerKey>
This is the string PostgreSQL stores in pg_authid.rolpassword. Producing 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://codeberg.org/jcollie/zig-scram-sha-256.git
// build.zig
const scram = b.dependency("scram_sha_256", .{
.target = target,
.optimize = optimize,
});
exe.root_module.addImport("scram", scram.module("scram_sha_256"));
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.
Cloning with Radicle
The repository 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 Codeberg. 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.
Use
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 below. |
The allocator is only touched when SASLprep has real work to do, which means
never for an all-ASCII password and never for .raw.
CLI
The package also builds a small tool. 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.
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.
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.
Testing
zig build test
The suite includes the RFC 7677 §3 test vector. The client proof and server signature published in that RFC are derived from this StoredKey and ServerKey, so matching it pins the entire derivation chain.
Both halves have also 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 |
Key derivation, the Secret type, parsing and rendering. |
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/main.zig |
The CLI. |
completions/ |
fish and bash completions for the CLI. |
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.