minato_scram (minato v0.18.6)
View SourceSCRAM-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/3raises rather than continuing. - The server signature must verify.
server_final/2recomputesServerSignatureand compares it, in constant time, with the value the server sent. Only a party that holdsServerKeycan 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
-type binding() :: none | unoffered | {tls_server_end_point, binary()}.
What to tell the server about channel binding, in the gs2 header.
noneisn,,: this exchange is not bound to anything.unofferedisy,,: 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-PLUSmechanism, and fails the exchange, which is the whole point of the flag.{tls_server_end_point, Hash}isp=tls-server-end-point,,followed by the hash of the server's certificate, which ties this exchange to this TLS session. Seeminato_connfor where the hash comes from.
-opaque client()
A SCRAM exchange in progress.
Holds no password and nothing derived from one. See the module documentation.
-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.
The map carried by an error({minato_scram, Report}) exception.
Functions
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.
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.
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.
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.