SCRAM-SHA-256 authentication

Server-side SCRAM-SHA-256 (RFC 5802 / RFC 7677): authenticate a client over a password without the server ever storing — or seeing on the wire — a password-equivalent. The home for a BMC / web console that wants salted, challenge/response browser login instead of sending the password (even over TLS).

At enrollment the server runs axl_scram_sha256_derive and keeps only {salt, iterations, StoredKey, ServerKey} — never the password. At login the client proves knowledge of the password through a two-message challenge/response the server drives with axl_scram_server_first then axl_scram_server_final; the engine verifies the client proof with a constant-time compare (axl_consttime_equal) and returns a server signature the client checks in turn.

This is plain SCRAM-SHA-256 (gs2 header n,,, no channel binding): a browser client driving it from JavaScript cannot read the TLS channel-binding hash, so run it inside TLS for transport security. The two login messages arrive on two separate requests, so AxlScramState is a plain serializable value the caller parks between the steps.

A matching client engine (axl_scram_client_first / axl_scram_client_final / axl_scram_client_verify) is provided for an axl consumer that authenticates to a SCRAM server — a client tool, agt, or a test driver — including verifying the server’s signature for mutual authentication. It holds the password only for the exchange and never sends it.

Built on the dependency-free digest / HMAC / PBKDF2 / base64 / RNG primitives — available in every build (no AXL_TLS required).

API Reference

Server-side SCRAM-SHA-256 authentication (RFC 5802 / RFC 7677).

SCRAM lets a server authenticate a client over a password without the server ever storing — or seeing on the wire — a password-equivalent. At enrollment the server runs axl_scram_sha256_derive and keeps only {salt, iterations, StoredKey, ServerKey} (an AxlScramCredential); the password is never persisted. At login the client proves knowledge of the password through a two-message challenge/response that the server drives with axl_scram_server_first then axl_scram_server_final.

This is plain SCRAM-SHA-256 only — gs2 header n,,, no channel binding (SCRAM-PLUS). A browser client driving this from JavaScript cannot read the TLS channel-binding hash, so channel binding is out of scope; run it inside TLS for transport security.

// Enrollment (once, when a password is set):
uint8_t salt[16];
axl_rng_bytes(salt, sizeof salt);
uint8_t stored[32], server[32];
axl_scram_sha256_derive("pencil", salt, sizeof salt, 4096, stored, server);
// ... persist {salt, 4096, stored, server} as the user's credential ...

// Login, request 1 (client-first arrives):
AxlScramCredential cred = { salt, sizeof salt, 4096, ..., ... };
AxlScramState st;
char server_first[512];
axl_scram_server_first(&cred, client_first, client_first_len,
                       server_first, sizeof server_first, &st);
// ... send server_first to the client; park `st` keyed by a login id ...

// Login, request 2 (client-final arrives; reload `st`):
char server_final[256];
if (axl_scram_server_final(&st, client_final, client_final_len,
                           server_final, sizeof server_final) == AXL_OK) {
    // authenticated — send server_final (proves the server too)
}

The two login messages arrive on two separate connections/requests, so AxlScramState is a plain serializable value (no pointers, no heap): copy it into a small table between the two steps and back.

Verification uses axl_consttime_equal (axl-crypto.h) for the proof compare. Dependency-free except for the digest/HMAC/base64/RNG primitives — works in every build (no AXL_TLS required).

Defines

AXL_SCRAM_MAX_MESSAGE

Max bytes of a SCRAM message this engine parses/emits (client-first-bare and server-first). Longer inputs are rejected with AXL_INVALID; size an out_server_first buffer at this.

AXL_SCRAM_MAX_NONCE

Max length (bytes of printable, comma-free ASCII) of the combined client+server nonce. The client nonce must therefore be at most AXL_SCRAM_MAX_NONCE - AXL_SCRAM_SERVER_NONCE_LEN bytes, else AXL_INVALID.

AXL_SCRAM_SERVER_NONCE_LEN

Length of the server nonce the engine appends (base64 of 18 random bytes; base64’s alphabet excludes ‘,’).

AXL_SCRAM_SERVER_FINAL_MAX

Minimum size for an out_server_final buffer: “v=” + base64(32-byte ServerSignature) + NUL = 2 + 44 + 1.

Functions

int axl_scram_sha256_derive(const char *password, const uint8_t *salt, size_t salt_len, uint32_t iterations, uint8_t stored_key[32], uint8_t server_key[32])

Derive a SCRAM-SHA-256 credential from a password (enrollment).

Computes, per RFC 5802:

  • SaltedPassword = PBKDF2-HMAC-SHA256(password, salt, iterations, 32)

  • ClientKey = HMAC-SHA256(SaltedPassword, “Client Key”)

  • StoredKey = SHA256(ClientKey) (written to stored_key)

  • ServerKey = HMAC-SHA256(SaltedPassword, “Server Key”) (to server_key)

The caller persists {salt, iterations, stored_key, server_key} and discards the password. Use a random per-user salt (>= 16 bytes) and a high iterations (>= 4096).

Parameters:
  • password – UTF-8 password (NUL-terminated)

  • salt – per-user salt (may be NULL iff salt_len==0)

  • salt_len – salt length in bytes

  • iterations – PBKDF2 iteration count (>= 1)

  • stored_key – [out] StoredKey

  • server_key – [out] ServerKey

Returns:

AXL_OK on success; AXL_INVALID if password is NULL, iterations is 0, or an output pointer is NULL; AXL_ERR on an internal failure.

int axl_scram_server_first(const AxlScramCredential *cred, const char *client_first, size_t client_first_len, char *out_server_first, size_t out_server_first_size, AxlScramState *out_state)

SCRAM step 1: consume client-first, emit server-first.

Parses the client-first message "n,,n=<user>,r=<client-nonce>", appends a fresh AXL_SCRAM_SERVER_NONCE_LEN-byte random server nonce to the client nonce, and writes the server-first message "r=<client-nonce‖server-nonce>,s=<base64(salt)>,i=<iterations>" to out_server_first (NUL-terminated). The client-first-message-bare stored for the AuthMessage is "n=<user>,r=<client-nonce>" (the gs2 header is stripped). All state the final step needs is in out_state.

Only the plain gs2 header n,, is accepted: a header requesting or asserting channel binding (y,,, p=...) or carrying an authzid (n,a=...), or a client-first mandatory-extension field (m=...), is rejected with AXL_INVALID — no silent downgrade.

Parameters:
  • cred – the user’s stored credential

  • client_first – client-first message

  • client_first_len – its length in bytes

  • out_server_first – [out] server-first message

  • out_server_first_size – capacity of out_server_first

  • out_state – [out] state for step 2

Returns:

AXL_OK on success; AXL_INVALID if the client-first message is malformed, the gs2 header is not exactly n,,, the client nonce exceeds AXL_SCRAM_MAX_NONCE - AXL_SCRAM_SERVER_NONCE_LEN, the assembled server-first would exceed AXL_SCRAM_MAX_MESSAGE, or out_server_first is too small; AXL_ERR on an RNG/internal failure.

int axl_scram_server_final(const AxlScramState *state, const char *client_final, size_t client_final_len, char *out_server_final, size_t out_server_final_size)

SCRAM step 2: verify client-final, emit server-final.

Parses the client-final message "c=biws,r=<combined-nonce>,p=<base64(ClientProof)>" (biws is base64 of the gs2 header n,,), requires its nonce to equal the combined nonce from step 1, and verifies the proof:

  • client-final-without-proof = the client-final message truncated at “,p=” — i.e. the exact bytes "c=biws,r=<combined-nonce>", INCLUDING the leading c=biws channel-binding token.

  • AuthMessage = client-first-bare ‖ “,” ‖ server-first ‖ “,” ‖ client-final-without-proof, where client-first-bare is "n=<user>,r=<cnonce>" (gs2 header excluded) as captured in step 1.

  • ClientKey’ = ClientProof XOR HMAC-SHA256(StoredKey, AuthMessage)

  • accept iff SHA256(ClientKey’) == StoredKey (constant-time compare)

On success writes the server-final message "v=<base64(ServerSignature)>" (ServerSignature = HMAC-SHA256(ServerKey, AuthMessage)) to out_server_final, which the client checks to authenticate the server in turn.

A wrong nonce and a wrong proof BOTH return AXL_DENIED — the engine never reveals which failed (distinguishing them would leak whether the login state was valid). out_server_final must be at least AXL_SCRAM_SERVER_FINAL_MAX bytes.

Parameters:
  • state – state from axl_scram_server_first

  • client_final – client-final message

  • client_final_len – its length in bytes

  • out_server_final – [out] server-final message (>= AXL_SCRAM_SERVER_FINAL_MAX)

  • out_server_final_size – capacity of out_server_final

Returns:

AXL_OK on authentication success; AXL_DENIED if the proof is wrong or the nonce does not match step 1; AXL_INVALID if the client-final message is malformed or out_server_final is too small; AXL_ERR on an internal failure.

int axl_scram_client_first(const char *username, char *out_client_first, size_t out_size, AxlScramClientState *out_state)

Client step 1: emit the client-first message.

Generates a fresh random client nonce and writes "n,,n=<username>,r=<client-nonce>" to out_client_first (NUL-terminated). The username must contain no , or = (SCRAM reserves them) and is rejected with AXL_INVALID otherwise.

Parameters:
  • username – login name

  • out_client_first – [out] client-first message

  • out_size – capacity of out_client_first

  • out_state – [out] state for the next steps

Returns:

AXL_OK on success; AXL_INVALID if username is NULL, empty, contains a reserved character, or out_client_first is too small; AXL_ERR on an RNG failure.

int axl_scram_client_final(AxlScramClientState *state, const char *password, const char *server_first, size_t server_first_len, char *out_client_final, size_t out_size)

Client step 2: consume server-first, emit client-final.

Parses the server-first message "r=<combined>,s=<base64(salt)>,i=<i>", checks that combined begins with the nonce from step 1 (else the server is not echoing our nonce), derives the proof from password, salt and iteration count, and writes the client-final message "c=biws,r=<combined>,p=<base64(ClientProof)>" to out_client_final. It also stores the expected ServerSignature in state for axl_scram_client_verify.

Parameters:
  • state – state from axl_scram_client_first

  • password – UTF-8 password (NUL-terminated)

  • server_first – server-first message

  • server_first_len – its length in bytes

  • out_client_final – [out] client-final message

  • out_size – capacity of out_client_final

Returns:

AXL_OK on success; AXL_INVALID if password is NULL, the server-first message is malformed, its nonce does not extend ours, or out_client_final is too small; AXL_ERR on an internal failure.

int axl_scram_client_verify(const AxlScramClientState *state, const char *server_final, size_t server_final_len)

Client step 3: verify the server-final message (mutual auth).

Parses "v=<base64(ServerSignature)>" and compares it, in constant time, against the signature computed in step 2 — confirming the server also knows the credential.

Parameters:
  • state – state from axl_scram_client_final

  • server_final – server-final message

  • server_final_len – its length in bytes

Returns:

AXL_OK if the server signature is correct; AXL_DENIED if it is wrong; AXL_INVALID if the server-final message is malformed.

struct AxlScramCredential
#include <axl-scram.h>

A stored SCRAM credential (no password-equivalent).

What the server persists per user. salt points to the per-user random salt the caller stores (not copied — must outlive any axl_scram_server_first call that reads it). stored_key and server_key come from axl_scram_sha256_derive. None of these reveal the password, and stored_key alone cannot forge a client proof (that needs ClientKey, which the server never holds).

Public Members

const uint8_t *salt

per-user salt bytes

size_t salt_len

salt length in bytes

uint32_t iterations

PBKDF2 iteration count used at enrollment.

uint8_t stored_key[32]

SHA256(HMAC(SaltedPassword,”Client Key”))

uint8_t server_key[32]

HMAC(SaltedPassword,”Server Key”)

struct AxlScramState
#include <axl-scram.h>

Engine state carried between the two server steps.

A plain serializable value (no pointers): axl_scram_server_first fills it, the caller parks it between the two HTTP requests, and axl_scram_server_final consumes it. Holds the keys, the combined nonce, and the two messages that form the AuthMessage. The salt is NOT retained here — it is already embedded in server_first (s=...), so the final step needs no salt and the no-pointers POD guarantee holds.

It is sensitive (contains the credential keys) but not a password-equivalent. Zero it with axl_memset(&st, 0, sizeof st) after server_final (and likewise any AxlScramCredential copy): a leaked stored_key enables an offline attack against captured login traffic.

Public Members

uint8_t stored_key[32]

copied from the credential

uint8_t server_key[32]

copied from the credential

uint16_t client_first_bare_len

length of client_first_bare

uint16_t server_first_len

length of server_first

uint16_t combined_nonce_len

length of combined_nonce

char client_first_bare[512]

“n=<user>,r=<cnonce>”

char server_first[512]

“r=…,s=…,i=…”

char combined_nonce[160]

client nonce ‖ server nonce

struct AxlScramClientState
#include <axl-scram.h>

Client state carried across the SCRAM exchange.

A plain serializable value (no pointers): axl_scram_client_first fills the nonce/message fields, axl_scram_client_final adds the expected server signature, and axl_scram_client_verify checks it. It holds no password-equivalent. Zero it with axl_memset(&st, 0, sizeof st) after use.

Public Members

uint16_t client_first_bare_len

length of client_first_bare

uint16_t client_nonce_len

length of client_nonce

char client_first_bare[512]

“n=<user>,r=<cnonce>”

char client_nonce[160]

the nonce we generated

uint8_t server_signature[32]

expected ServerSignature (set by _final)