minato_scram (minato v0.18.6)

View Source

SCRAM-SHA-256 (RFC 5802, RFC 7677) as a pure function of messages.

Bytes in, bytes out. No socket, no process, no state outside the value the caller threads through the three calls, so the whole exchange can be tested against the published vectors and against a hostile server without either one.

The exchange

Four messages, three calls:

{ClientFirst, Client} = minato_scram:client_first(~"minato", #{}),
%% send ClientFirst, receive ServerFirst
{ClientFinal, Check} = minato_scram:client_final(ServerFirst, ~"pencil", Client),
%% send ClientFinal, receive ServerFinal
ok = minato_scram:server_final(ServerFinal, Check).

minato_auth drives those calls from the AuthenticationSASL messages the server sends, and is what a connection uses. This module is the mechanism on its own.

What the client checks

Authentication is mutual, and both directions are enforced here.

  • The server nonce must extend the client nonce. The server answers with the client's nonce and its own appended. A server that returns something else has not seen the client's first message, which is what a replay or a man in the middle looks like, so client_final/3 raises rather than continuing.
  • The server signature must verify. server_final/2 recomputes ServerSignature and compares it, in constant time, with the value the server sent. Only a party that holds ServerKey can produce it, so this is what proves the server is the one that stored the verifier. Skipping the check leaves a client that will complete a handshake with anybody, which is a downgrade to no server authentication at all, so a mismatch raises and there is no option to turn it off.
  • The iteration count must be in range. See below.

The iteration count

i decides how much work an offline attack on the stored verifier costs, and the server chooses it, so a hostile or compromised server can choose badly in either direction.

The floor is 4096, which is what RFC 7677 says a server SHOULD announce and what PostgreSQL's scram_iterations defaults to. Below it the derivation is cheaper to brute force than the specification allows for, and since PostgreSQL will not put a lower value there by default, a lower value is a signal rather than a configuration.

The ceiling is 1,000,000, roughly 180ms of PBKDF2 on a current machine and some 240 times PostgreSQL's default. It exists because i is an unbounded number chosen by the far end and fed straight into a loop: without a ceiling a server that answers i=2000000000 costs the client minutes of CPU per connection attempt for free.

Both move with minimum_iterations and maximum_iterations in opts/0, for a server that is deliberately configured away from the defaults.

The digits are counted before they are parsed, and more than 20 of them are rejected without being read. Turning a decimal string into an integer costs more than linear time in its length, so a server that answers with a megabyte of digits would otherwise be paid for out of the client's CPU before the ceiling above ever got a number to compare.

Channel binding

channel_binding in opts/0 decides what the gs2 header says, and binding/0 documents the three forms. {tls_server_end_point, Hash} is the one that does something: the hash of the server's certificate goes into the c= field of client-final-message, which is covered by the ClientProof, so the exchange is tied to the TLS session it happened on.

Without it, SCRAM proves a shared password over whatever channel the client happens to be on, and a man in the middle holding a certificate the client accepts can relay the whole exchange and keep the session. With it, the relay fails: the certificate hash the client bound to is not the one the server sees, and the server rejects the proof.

y,, is the other half. A client that can bind and is talking to a server that did not offer -PLUS says so in the header, and a server that did offer it and receives y knows the mechanism list was tampered with on the way. That is the downgrade the flag exists to catch, and it only works if the client sets it honestly.

The hash is the connection layer's to compute, because it comes from the TLS session; see minato_conn.

What is not an error here

A wrong password is not. The server answers a bad ClientProof with an ordinary ErrorResponse, which minato_protocol decodes as data for the caller to branch on. Everything this module raises on is a server that broke the protocol or failed to prove itself.

Secrets in the state

client/0 never holds the password, which is why client_final/3 takes it rather than client_first/2. Nothing derived from it survives that call either: what is carried forward to server_final/2 is the expected ServerSignature alone, which the server is about to send in the clear anyway. The value is therefore safe to log, and no scrubbing is needed for one that is not.

Summary

Types

What to tell the server about channel binding, in the gs2 header.

A SCRAM exchange in progress.

Bounds on the iteration count a server may ask for.

The map carried by an error({minato_scram, Report}) exception.

Functions

Read the server-first-message and answer it with the client-final-message.

Build the client-first-message and the state the rest of the exchange needs.

Internal. client_first/2 with the client nonce given rather than generated.

Verify the server-final-message, which is what authenticates the server.

Types

binding()

-type binding() :: none | unoffered | {tls_server_end_point, binary()}.

What to tell the server about channel binding, in the gs2 header.

  • none is n,,: this exchange is not bound to anything.
  • unoffered is y,,: the client can bind and the server did not offer it. A server that did offer it and receives this has found a man in the middle stripping the -PLUS mechanism, and fails the exchange, which is the whole point of the flag.
  • {tls_server_end_point, Hash} is p=tls-server-end-point,, followed by the hash of the server's certificate, which ties this exchange to this TLS session. See minato_conn for where the hash comes from.

client()

-opaque client()

A SCRAM exchange in progress.

Holds no password and nothing derived from one. See the module documentation.

opts()

-type opts() ::
          #{minimum_iterations => pos_integer(),
            maximum_iterations => pos_integer(),
            channel_binding => binding()}.

Bounds on the iteration count a server may ask for.

#{minimum_iterations => pos_integer(),
  maximum_iterations => pos_integer()}

Defaulting to 4096 and 1,000,000. See the module documentation for why both ends are checked.

report()

-type report() :: #{message := term(), reason := term(), data := term()}.

The map carried by an error({minato_scram, Report}) exception.

Functions

client_final/3

-spec client_final(binary(), binary(), client()) -> {iodata(), client()}.

Read the server-first-message and answer it with the client-final-message.

1> {Message, _Check} = minato_scram:client_final(ServerFirst, ~"pencil", Client),
1> iolist_to_binary(Message).
<<"c=biws,r=...,p=...">>

Raises error({minato_scram, t:report/0}) when the server nonce does not extend the client nonce, when the iteration count is outside opts/0, when the salt is not base64, when the server asks for a mandatory extension, or when the message does not parse.

The password is prepared with minato_saslprep before it is salted.

client_first(Username, Opts)

-spec client_first(binary(), opts()) -> {iodata(), client()}.

Build the client-first-message and the state the rest of the exchange needs.

1> {Message, _Client} = minato_scram:client_first(~"minato", #{}),
1> iolist_to_binary(Message).
<<"n,,n=minato,r=ZU1VevH/T4oYMaHeqfcBbLlQCqZ3APDo">>

The nonce is 24 bytes of crypto:strong_rand_bytes/1, base64 encoded, which is 192 bits of entropy in 32 of the printable characters RFC 5802 allows. A nonce is what stops an answer to an earlier exchange being replayed into this one, so it comes from the strong generator and never from rand.

The user name goes in n= with = and , escaped as RFC 5802 requires, so that the message matches the published vectors. PostgreSQL then ignores it and uses the name from the startup packet instead, which it documents, because SCRAM requires UTF-8 there and a PostgreSQL user name need not be representable in it. Nothing therefore depends on this field, and it is passed through rather than prepared with minato_saslprep: preparing it could only change bytes the server does not read.

client_first(Username, Nonce, Opts)

-spec client_first(binary(), binary(), opts()) -> {iodata(), client()}.

Internal. client_first/2 with the client nonce given rather than generated.

Exported so that the RFC 7677 test vectors, which fix the client nonce, can be reproduced exactly. A nonce that is not freshly random defeats the entire point of the exchange, so nothing outside a test may call this.

server_final/2

-spec server_final(binary(), client()) -> ok.

Verify the server-final-message, which is what authenticates the server.

Answers ok, and raises error({minato_scram, t:report/0}) otherwise: on a ServerSignature that does not match, on the e= form in which a server reports its own failure, and on a message that does not parse.

The comparison runs in constant time through crypto:hash_equals/2. A byte at a time comparison would leak how much of a forged signature was right, which is enough to build the rest of it one byte per attempt.