Compute PostgreSQL SCRAM-SHA-256 password verifiers in Zig, without sending the plaintext to the server.
  • Zig 98.2%
  • Shell 1.8%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jeffrey C. Ollie 5a29398fdf
Rename the binary to scram-sha-256
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
2026-08-27 20:19:49 -05:00
.forgejo/workflows Pin the workflow's actions to commit hashes 2026-08-27 20:13:14 -05:00
completions Rename the binary to scram-sha-256 2026-08-27 20:19:49 -05:00
LICENSES License stringprep tables as MIT AND PostgreSQL 2026-08-25 15:53:18 -05:00
src Rename the binary to scram-sha-256 2026-08-27 20:19:49 -05:00
.gitignore Add SPDX headers and MIT license 2026-08-25 15:45:58 -05:00
build.zig Rename the binary to scram-sha-256 2026-08-27 20:19:49 -05:00
build.zig.zon Add fish and bash shell completions for the CLI 2026-08-27 18:54:28 -05:00
README.md Rename the binary to scram-sha-256 2026-08-27 20:19:49 -05:00

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. .saslprep does 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 of saslprep.c rather 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.