minato_auth (minato v0.18.6)

View Source

The authentication exchange, as a fold over the messages the server sends.

minato_protocol decodes an Authentication* message; this module decides what to answer with. It does no I/O and starts no process, so the whole cycle, including the ones that need several round trips, can be driven in a test without a socket.

State = minato_auth:init(#{user => ~"minato", password => ~"pencil"}),
{send, Reply, Next} = minato_auth:step(Message, State),
%% write minato_protocol:encode(Reply), read the next message, step again

step/2 answers one of three things:

  • {send, Reply, State} to write Reply and wait for the next message
  • {continue, State} when the server's message needed no answer, which is what AuthenticationSASLFinal is
  • authenticated on AuthenticationOk, after which the caller reads ParameterStatus, BackendKeyData and ReadyForQuery as usual

An ErrorResponse from the server is not handled here. It is an ordinary decoded message and the caller branches on it: a wrong password arrives that way, not as an exception.

A started SASL exchange has to finish

AuthenticationOk is refused while a SCRAM exchange is outstanding, and that refusal is load bearing rather than tidiness. The check that authenticates the server to the client lives in AuthenticationSASLFinal, so a server that cannot produce a valid ServerSignature has an obvious move available: send AuthenticationOk instead and never send the final message at all. A client that accepted it would have proved itself to an impostor and gone on to send it queries, which is exactly the outcome verifying the signature is meant to prevent. Only an exchange that reached verified, or one that never started because the server asked for no password, ends in authenticated.

Changing method part way through is refused for the same reason. A server that opened with AuthenticationSASL and then asks for a cleartext password, or for MD5, is asking the client to hand over something weaker than the exchange it already began, which is what a man in the middle who cannot finish SCRAM would do next.

That guard is worth having but it is not a policy. A server that asks for cleartext in its very first message is answered, because that is what supporting cleartext means, and minato has no way yet to be told which methods are acceptable. A require_auth style option belongs with the connection layer, along with TLS, since both are decisions about the transport rather than about the exchange.

Methods

Server asks forminato answers with
AuthenticationOknothing, the cycle is over
AuthenticationCleartextPasswordthe password, unmodified
AuthenticationMD5Passwordthe salted MD5 digest
AuthenticationSASLSCRAM-SHA-256, through minato_scram

SCRAM-SHA-256 is the one that matters: it is what password_encryption has defaulted to since PostgreSQL 14, and CloudNativePG issues nothing else. The other two are here because a PostgreSQL is not always a recent PostgreSQL, and they cost a handful of lines each.

MD5 is broken as a hash and PostgreSQL has deprecated it, so a server asking for it is one to reconfigure rather than one to trust. minato answers anyway, because refusing would only mean a connection that cannot be made at all, whereas the server has already decided what it will accept.

GSSAPI, SSPI and the rest of the Authentication codes never reach this module: minato_protocol models the four above and raises on any other code as it decodes, because there is no useful reply to build and a message silently passed on would look to a caller like a hung connection. A message that is not an authentication message at all raises here, for the same reason.

Channel binding

SCRAM-SHA-256-PLUS is chosen when the server offers it and the caller supplied channel_binding_data, which is the hash of the server's certificate and comes from the connection layer, since only it has the TLS session.

channel_binding => require refuses to authenticate at all without it. prefer takes it when both sides have it, and falls back to plain SCRAM with a y,, header, which tells a server that does support binding that its mechanism list was stripped on the way. disable never binds. See minato_scram for what each header means.

Summary

Types

What to do about channel binding.

Who to authenticate as.

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

The exchange so far. Never holds anything derived from the password.

What to do with the message that was just decoded.

Functions

Begin an authentication exchange.

Answer one Authentication* message.

Types

channel_binding()

-type channel_binding() :: prefer | require | disable.

What to do about channel binding.

prefer uses SCRAM-SHA-256-PLUS when the server offers it and there is binding data to use, and plain SCRAM otherwise. require refuses to authenticate without it. disable never binds.

prefer still tells the truth in the gs2 header: a client with binding data whose server did not offer -PLUS sends y,,, which is how a server that did offer it detects the mechanism list being stripped on the way.

credentials()

-type credentials() ::
          #{user := binary(),
            password => binary(),
            scram => minato_scram:opts(),
            channel_binding => channel_binding(),
            channel_binding_data => binary()}.

Who to authenticate as.

#{user := binary(),
  password => binary(),
  scram => minato_scram:opts()}

password is absent for a server that asks for none, and a server that then asks for one raises rather than sending an empty string. scram is passed to minato_scram and is only worth setting for a server deliberately configured away from its iteration count defaults.

report()

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

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

state()

-opaque state()

The exchange so far. Never holds anything derived from the password.

step()

-type step() :: {send, minato_protocol:frontend(), state()} | {continue, state()} | authenticated.

What to do with the message that was just decoded.

Functions

init/1

-spec init(credentials()) -> state().

Begin an authentication exchange.

Nothing is sent and nothing is derived yet: the server has not said what it wants, and every method but one needs something from it first.

step/2

-spec step(minato_protocol:backend(), state()) -> step().

Answer one Authentication* message.

Raises error({minato_auth, t:report/0}) when the server asks for a method minato does not implement, when it asks for a password that was not configured, when it offers no SASL mechanism minato can speak, or when a SASL message arrives outside an exchange. Raises error({minato_scram, _}) for a server that breaks SCRAM or fails to prove itself; see minato_scram.