Authentication & access

Who may connect, and which topics each client may publish, subscribe to and receive.

Mercurio offers several ways to authenticate clients and decide their access. They combine as follows:

NeedUse
A fixed list of userspassword file and ACL file
Users and access managed while the broker runsdynamic security
Users kept in your own database or identity serviceHTTP authentication
Clients holding tokens from an identity providerJSON Web Tokens, with an ACL file or HTTP topic checks
Devices identified by certificatesclient certificates, with an ACL file
Custom logic in Rustthe server library's traits

Password files

[auth]
enabled = true
allow_anonymous = false
password_file = "/etc/mercurio/passwd"

With enabled = true, clients must send a user name and password found in password_file. With allow_anonymous = true, clients that send no user name are let in as anonymous; credentials that are sent are always checked.

The file holds one user:hash line per user. Manage it with mercuriod passwd, which writes Argon2 hashes:

# Add a user, or change their password (prompts for it)
mercuriod passwd -f /etc/mercurio/passwd -u alice

# Remove a user
mercuriod passwd -f /etc/mercurio/passwd -u alice -d

mercuriod passwd replaces the file in one step, so a reload never sees half of it. The file keeps its owner, group and mode, at most 0640; a new file gets 0600, so let the broker read it with chgrp mercurio and chmod 640. A file it cannot read in full, such as one with a malformed line, is left unchanged with an error.

Files written by mosquitto_passwd work unchanged: their $7$ (PBKDF2-SHA512, mosquitto 2.0's default) and $6$ (SHA-512, older versions) hashes are recognised. Any other hash field, such as a plain-text password or an empty one, stops the file from loading, with an error naming its line.

Next to the Argon2 hash, mercuriod passwd stores the user's SCRAM-SHA-256 keys, on a line of their own after the user's:

alice:$argon2id$v=19$m=19456,t=2,p=1$...
#scram-sha-256:alice:<digest of the hash>:SCRAM-SHA-256$600000:<salt>$<StoredKey>:<ServerKey>

The line starts with #, so mosquitto_passwd and earlier mercuriod releases read it as a comment, and the file stays readable by both. The digest ties the keys to the hash they were set with: when another tool, such as mosquitto_passwd, changes the password, the keys no longer match and are not used, so the old password does not keep working over SCRAM. A malformed #scram-sha-256: line stops the file from loading, as a malformed hash does.

Access control lists

[auth]
acl_file = "/etc/mercurio/acl"

The file follows mosquitto's acl_file format, so existing files can be reused:

# Rules before any `user` line apply to anonymous clients
topic read public/#

user alice
topic readwrite alice/#
topic deny alice/secret

# Patterns apply to every client; %u is the user name, %c the client id
pattern write devices/%c/status
pattern read users/%u/inbox

An ACL file works without passwords too, for example with client certificates.

$SYS topics only accept the broker's own statistics. $CONTROL topics, which administer the broker, are refused to every client unless an ACL file, dynamic security or an HTTP ACL endpoint grants them. Administration lists the grants its commands need; clients of the admin socket need none.

TLS and client certificates

[server.tls]
enabled = true
cert_path = "/etc/mercurio/server.crt"
key_path = "/etc/mercurio/server.key"

# Require client certificates signed by this CA
ca_path = "/etc/mercurio/ca.crt"
# Refuse revoked ones
crl_path = "/etc/mercurio/crl.pem"
# The certificate names the client
identity_as_username = true
# Refuse TLS 1.2
min_version = "1.3"

A device fleet authenticated by certificates alone:

[server]
host = "0.0.0.0"
port = 8883

[server.tls]
enabled = true
cert_path = "/etc/mercurio/server.crt"
key_path = "/etc/mercurio/server.key"
ca_path = "/etc/mercurio/devices-ca.crt"
identity_as_username = true

[auth]
enabled = true
allow_anonymous = false
acl_file = "/etc/mercurio/acl"
# /etc/mercurio/acl: each device writes its own telemetry
pattern write devices/%u/telemetry
pattern read devices/%u/commands

Each listener has its own TLS settings; see Listeners.

Dynamic security

Dynamic security manages clients, roles and access while the broker runs, with the JSON commands of mosquitto's dynamic security plugin. It replaces password_file and acl_file.

# Create the state file with an administrator (prompts for the password)
sudo mercuriod dynsec-init -f /var/lib/mercurio/dynamic-security.json -u admin

The file is created with mode 0600. Run as root in a directory owned by another user, as the service's state directory /var/lib/mercurio is, dynsec-init gives the file to that directory's owner and group, so the service can read it. systemd creates that directory when the service first starts: start it once before, with systemctl start mercuriod, if it has never run.

Coming from mosquitto, create it from mosquitto's file instead:

sudo mercuriod dynsec-import /var/lib/mosquitto/dynamic-security.json /var/lib/mercurio/dynamic-security.json

dynsec-import carries over clients, groups, roles, ACLs, priorities, the default access and the anonymous group, and gives the file to the directory's owner as dynsec-init does. It never overwrites an existing file. Clients keep their passwords: mosquitto's PBKDF2-SHA512 hashes are checked as they are, and a password set again with setClientPassword or modifyClient is stored as Argon2. A PBKDF2 check is faster than an Argon2 one, so until then the time a login takes can tell an imported client from an unknown user name; set those passwords again to close that. What can't be carried over is listed and left out: unsubscribeLiteral and unsubscribePattern ACLs and a default that denies unsubscribing (Mercurio lets every client unsubscribe), passwords in another form (those clients can't log in until they get one), and names a client, group or role refers to that the file does not define.

Setting a password with dynsec-init, createClient, setClientPassword or modifyClient also stores the client's SCRAM-SHA-256 keys, in the client's scram field, tied to its hash as in the password file. Earlier releases ignore the field. getClient never shows it. A client that is disabled, or bound to another client id, cannot log in with SCRAM either.

[auth]
enabled = true
allow_anonymous = false
dynamic_security = "/var/lib/mercurio/dynamic-security.json"

Manage it with mercurioctl, on the broker's host through the admin socket, or from anywhere as the administrator:

# On the broker's host, as root or a member of the mercurio group
mercurioctl dynsec create-role sensor
mercurioctl dynsec add-role-acl sensor publishClientSend 'sensors/#' allow
mercurioctl dynsec create-group sensors --role sensor
mercurioctl dynsec create-client s1 --group sensors   # asks for the password

# From another machine
mercurioctl -H broker.example.com -u admin dynsec list-clients

Underneath, these are JSON commands published to $CONTROL/dynamic-security/v1, with the names and arguments of mosquitto's plugin. They use the same envelope and replies as the broker's own commands. Failed commands carry an error, without a code.

Only clients of the admin socket and of listeners that use the main [auth] can send commands: a listener with its own [listeners.auth] doesn't include dynamic security.

Model

ACL typeDecides
publishClientSendPublishing
publishClientReceiveReceiving messages
subscribePatternSubscribing to filters covered by the topic
subscribeLiteralSubscribing to exactly the topic

Without a matching ACL, only receiving is allowed, as in mosquitto. setDefaultACLAccess changes that, with publishClientSend, publishClientReceive and subscribe.

Commands

AreaCommands
ClientscreateClient, modifyClient, deleteClient, setClientPassword, enableClient, disableClient, addClientRole, removeClientRole, getClient, listClients
RolescreateRole, modifyRole, deleteRole, addRoleACL, removeRoleACL, getRole, listRoles
GroupscreateGroup, modifyGroup, deleteGroup, addGroupRole, removeGroupRole, addGroupClient, removeGroupClient, getGroup, listGroups, setAnonymousGroup, getAnonymousGroup
DefaultssetDefaultACLAccess, getDefaultACLAccess

modifyClient takes password, clientid, textname, textdescription, roles and groups; modifyGroup takes textname, textdescription, roles and clients; modifyRole takes textname, textdescription and acls. As in mosquitto, an argument left out leaves that part as it is, and a list replaces the whole list: "roles": [] takes every role away. An empty clientid lets the client connect with any client id, and an empty password leaves the password as it is. Every argument is checked before anything changes, so a command that fails changes nothing.

Changes take effect at once and are saved to the file after each message. As in mosquitto, a change that can take access away disconnects the connected clients it concerns, with reason code Administrative Action (0x98) and without their wills, so they log in again under their new access: deleting or disabling a client or setting its password, adding or removing a client's roles or groups, adding or removing a group's roles, deleting a group or a role, adding or removing a role's ACLs, and the modify commands, unless they change only text. A modifyGroup that changes its clients disconnects those it loses and those it gains. Changes to the anonymous group disconnect anonymous clients that came over the network; in-process clients and those of Unix sockets stay connected. The file is replaced in one step, so a crash never leaves it half written.

HTTP authentication

Delegates checks to an HTTP service, with the requests of mosquitto-go-auth's HTTP backend, so services written for it answer Mercurio too.

[auth]
enabled = true
allow_anonymous = false

[auth.http]
user_url = "https://auth.internal/mqtt/user"
acl_url = "https://auth.internal/mqtt/acl"
timeout_seconds = 5
cache_seconds = 30
EndpointRequest body
user_url, at each login{"username": "alice", "password": "...", "clientid": ""}
acl_url, at each topic check{"username": "alice", "clientid": "c1", "topic": "a/b", "acc": 2}
superuser_url, before a topic check{"username": "alice"}

acc is 1 to receive, 2 to publish and 4 to subscribe, and 8 to publish to a $CONTROL/... topic, which administers the broker; answer 8 with 2xx only for administrators. A 2xx status allows and a 4xx status denies. Topic answers are reused for cache_seconds, since every message is checked. Any other status, an error or a timeout also denies, but is not cached, so the check is asked again once the service is back; the broker logs these failures at warn, at most once every 10 seconds. At most 32 requests run at once, and identical checks made while one is out wait for its answer. A user_url that is plain http:// to another host is logged at startup, as passwords then cross the network unencrypted. user_url replaces password_file, and acl_url replaces acl_file; either can be used alone.

With superuser_url, the broker first asks whether the client's user name is a superuser's. A 2xx answer makes it one: every topic check is allowed without asking acl_url, publishing to $CONTROL included, so a superuser may administer the broker. A 4xx answer, or any failure of the endpoint, means not a superuser, and acl_url decides as usual; without acl_url, the next check, such as acl_file, does. Superuser answers are cached for cache_seconds like topic answers, failures are not. Clients without a user name are never asked about. superuser_url needs enabled = true, as a user name nobody checked would otherwise make anyone a superuser.

Over https://, endpoint certificates are checked against the bundled Mozilla roots. For a service with a certificate of a private CA, give that CA's certificates with ca_path, which then replaces the bundled roots; for a service that authenticates the broker with a client certificate, give it with cert_path and its key with key_path. All three are PEM files, read at startup and by mercuriod --check; a file that can't be read or holds no certificate or key stops the broker from starting. A reload reads them again for later requests, and keeps the previous ones if any fails to load.

[auth.http]
acl_url = "https://auth.internal/mqtt/acl"
ca_path = "/etc/mercurio/auth-ca.pem"
cert_path = "/etc/mercurio/auth-client.pem"
key_path = "/etc/mercurio/auth-client-key.pem"

A minimal service, in Python:

from http.server import BaseHTTPRequestHandler, HTTPServer
import json

class Auth(BaseHTTPRequestHandler):
    def do_POST(self):
        body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
        if self.path == "/mqtt/user":
            ok = body["username"] == "alice" and body["password"] == "secret"
        else:
            ok = body["topic"].startswith(f"{body['username']}/")
        self.send_response(200 if ok else 403)
        self.end_headers()

HTTPServer(("127.0.0.1", 8000), Auth).serve_forever()

JSON Web Tokens

Clients can log in with a JSON Web Token (JWT) issued by your identity provider. The client sends the token as its CONNECT password, and the CONNECT user name must equal the token's user claim, sub unless username_claim names another. Access control is keyed by user name, so ACL files, acl_url and the rest work for these clients as for any other.

[auth]
enabled = true
acl_file = "/etc/mercurio/acl"

[auth.jwt]
public_key_file = "/etc/mercurio/jwt.pem"
algorithms = ["RS256"]
issuer = "https://issuer.example"
audience = "mercurio"

A password that does not look like a JWT, three base64url parts separated by dots whose first part is a JSON object with an alg, is left to the next check, so a password_file or user_url can keep serving other clients. With nothing else configured, such a client is refused as for a wrong password. A password that looks like a JWT and fails any check is refused with Bad User Name or Password (return code 4 in MQTT 3.1.1), counts as a failed login, and is logged at warn level with the client id, its address and the reason, never the token.

The broker checks:

Keys come from exactly one of:

All algorithms listed must take the same kind of key. A reload rereads secret_file and public_key_file, so keys can be rotated without a restart.

By default a client is disconnected once its token expires, with Not Authorized (MQTT 3.x clients are just disconnected); without that, a token valid for five minutes would buy a connection that lasts for ever. The broker checks expiries every [server] plugin_tick_interval, one second by default, so a client may stay up to that long after exp plus leeway_seconds, or up to 30 seconds after logging in when the broker is too busy to report the connection to the check. A client renews its token by reconnecting with a new one, which replaces the old expiry; re-authenticating a live connection with a token is not supported. The client's will is published, as for a lost connection. disconnect_on_expiry = false lets clients stay until they leave.

Tokens carry no access rules: give topic access with an ACL file or acl_url. JWT authentication needs enabled = true, and can't be combined with dynamic security, which owns its users.

To try it, make a token for alice, valid for five minutes, signed with the secret in /etc/mercurio/jwt.secret, and connect with it:

b64() { openssl base64 -A | tr '+/' '-_' | tr -d '='; }
header=$(printf '{"alg":"HS256","typ":"JWT"}' | b64)
payload=$(printf '{"sub":"alice","exp":%d}' $(($(date +%s) + 300)) | b64)
signature=$(printf '%s.%s' "$header" "$payload" \
  | openssl dgst -sha256 -hmac "$(cat /etc/mercurio/jwt.secret)" -binary | b64)
RECADO_PASSWORD="$header.$payload.$signature" recado sub -u alice -t '#'

MQTT 5 enhanced authentication

MQTT 5 clients can authenticate with a method through the AUTH packet, and re-authenticate during a connection. The daemon offers SCRAM-SHA-256 with a password file or dynamic security. Applications embedding the broker register other methods through the server library. See Embedding.

SCRAM-SHA-256

With SCRAM-SHA-256 (RFC 5802 and RFC 7677, method name SCRAM-SHA-256), the client proves it knows the password without sending it, and the broker proves it knows the user's keys: its signature goes in the CONNACK, or in the AUTH that ends a re-authentication, and the Mercurio client refuses a broker without it. Nothing needs configuring: with password_file or dynamic_security set, the broker offers the method, and every user whose password was set by this release or later can use it. Plain user name and password logins keep working for the same users.

SCRAM-SHA-256-PLUS

SCRAM-SHA-256-PLUS ties the login to the TLS connection it runs on, so a machine that relays the exchange between a client and the broker cannot reuse it. The broker offers it only on TLS 1.3 connections that it terminates itself, and binds with tls-exporter (RFC 9266) only; tls-unique and tls-server-end-point are not supported. TLS 1.2, plain TCP, Unix sockets and connections behind a TLS-terminating proxy have no exporter, so there the method is not available and clients use SCRAM-SHA-256. A client that uses SCRAM-SHA-256-PLUS with a binding that does not match the connection is refused with Malformed Packet and the reason channel bindings don't match.

Set scram_require_channel_binding = true in [auth] to refuse SCRAM-SHA-256 without binding on TLS 1.3 connections, so clients there must use SCRAM-SHA-256-PLUS. It does not restrict the other connections: plain TCP, Unix sockets, TLS 1.2 and connections behind a TLS-terminating proxy keep using SCRAM-SHA-256, and it does not affect plain user name and password logins. The refusal is Bad Authentication Method with the reason channel binding is required.

Connect with SCRAM from the command line with --auth-method scram-sha-256, see recado, or --auth-method scram-sha-256-plus for SCRAM-SHA-256-PLUS, or from Rust with ScramSha256, see the client library.

Client id ownership

Each mount point has its own client ids: the same id on listeners with different mount points names two independent sessions, with their own subscriptions, queues, in-flight messages and wills, and neither takes the other over, closes it or ends it. Listeners without a mount point share one set of ids, with in-process clients such as bridges and plugin connections.

Within a mount point, a client id belongs to the owner of its session for as long as that session exists, connected or not. The owner is the user the client authenticated as, or anonymous. A client of the same owner takes over the id as usual: the connection that held it closes with Session Taken Over. A client of another owner is refused with Not Authorized (return code 5 in MQTT 3.1.1), Clean Start or not, and the connection, session, queued messages and will of the owner stay as they were. The broker logs each refusal at warn level with the client id, its mount point and both owners. With use_username_as_clientid, a user has one session on each mount point it connects to.

A session left by a previous owner blocks the id until it expires. To give the id to someone else sooner, delete the session with mercurioctl client delete-session <clientid>, adding --mount-point when the id is on more than one. A ban on a client id holds on every mount point.

Reloading

Send SIGHUP, or run systemctl reload mercuriod, to reread every password file, ACL file and dynamic security file, the TLS certificate, key, CA and CRL files, those of listeners included, the CA and client certificate files of [auth.http], and the key files of [auth.jwt], without dropping connections. mercurioctl broker reload does the same and reports how each file went.

Plugin sandbox

WebAssembly plugins run with no files, no environment and no network, except the HTTP hosts and socket addresses listed in allow_http and allow_sockets. Their standard output and error are discarded; they log through the broker, and a Rust plugin's panic messages are logged at error level. Each call has a time limit and each copy a memory limit. A plugin that fails or runs out of time denies, for authentication and access control, and drops, for messages. See WebAssembly plugins.

Per-listener authentication

A listener can have its own [listeners.auth], replacing [auth] for its clients: for example, open access on a localhost listener and passwords on the public one. See Listeners.