OPAQUE registration¶
The standard Register operation authorizes a component with the tenant
secret (pin): the component sends SHA256(CBOR([pin, cid])) and the
broker, which must be able to recompute it, stores the pin in clear text in
the tenant table.
OPAQUE registration (RFC 9807, an asymmetric password-authenticated key exchange) removes that weakness:
the broker stores neither the
pinnor a hash of it;the
pinis never sent, not even in a form that can be brute-forced offline from a captured exchange;a captured exchange cannot be replayed;
the component also verifies that it is talking to the broker that holds the tenant record.
It is opt-in: the standard Register keeps working and Rekey still uses
the standard pin proof.
Usage¶
Enable the tenant (broker host)¶
The administrator stores an OPAQUE record for the tenant with
rembus-tenant. The command must run where the broker directory is
readable, because it creates (if missing) and uses the broker server setup
file:
$ rembus-tenant --opaque -s 11223344 . # default tenant
$ rembus-tenant --opaque -s 11223344 acme.com # tenant "acme.com"
$ rembus-tenant --opaque -s 11223344 --broker mybroker acme.com
Running the command again for the same tenant replaces its record (pin
change). rembus-tenant -d TENANT removes both the plain secret and the
OPAQUE record. A running broker loads the records at startup, so restart it
after changing them.
The same tenant can have a plain secret and an OPAQUE record: the protocol used is chosen by the component.
Register a component¶
Pass opaque=True. The component name selects the tenant as described in
Multi-tenancy:
import rembus as rb
rb.register("ws://broker:8338/sensor1", "11223344", opaque=True)
rb.register("ws://broker:8338/sensor1.acme.com", "11223344", opaque=True)
# same options of the standard registration
rb.register(
"ws://broker:8338/sensor2", "11223344", rb.SIG_ECDSA, opaque=True
)
# authenticated connection as usual afterwards
cli = rb.node("ws://broker:8338/sensor1")
With an already connected node, node.register(rid, pin, scheme, opaque=True)
(sync API) and await twin.register(rid, pin, scheme, opaque=True) (async
API) behave the same.
Errors¶
RembusError is raised with the same reasons of the standard registration:
tenant [acme.com] not enabled: no OPAQUE record for the tenant;wrong tenant/pin: the pin does not match the tenant record (detected by the component, when the broker proof does not verify, or by the broker);[sensor1] not available(STS_NAME_ALREADY_TAKEN): the name is already registered;invalid public key (type mismatch, RSA smaller than 2048 bits, ECDSA curve other than P-256);
unexpected opaque message: stage 2 without a preceding stage 1 of the samecidon the same connection.
On any failure no key is stored and no private key is saved locally.
Operational notes¶
The server setup is the file
opaque_setup(mode0600) in the broker directory (<rembus dir>/<broker name>/), created on first use. It holds the OPRF seed and the server keypair. Back it up and keep it secret: if it is lost every OPAQUE record becomes unusable and the tenants must be enrolled again; if it leaks together with the database, pins become brute-forceable offline (still with a slow key derivation per guess).Do not copy the database to another broker without the matching
opaque_setup.The dependency is opaque-ke-py, Python bindings of the Rust
opaque-kecrate.
Design¶
Roles and data¶
Item |
Where |
Content |
|---|---|---|
Server setup ( |
broker, file mode 0600 |
OPRF seed and server long-term keypair; one per broker |
Password file (table |
broker, database |
OPAQUE record of the tenant: client public key and the encrypted envelope; contains no pin and no pin hash |
Pin |
component only |
never leaves the component |
The OPRF key of a tenant is derived from the seed and the OPAQUE credential identifier, so no per-tenant secret has to be stored. The OPAQUE username is the tenant name: one record serves all the components of the tenant.
Enrollment (administrator)¶
rembus.opaque.enroll runs the OPAQUE registration locally: the client
half blinds the pin, the server half evaluates it with the tenant OPRF key
and the client half finalizes the record, which is stored in
tenant_opaque. It is done in-process because the administrator already
holds the pin and the server setup; no OPAQUE registration traffic crosses the
network.
Registration of a component¶
The component runs an OPAQUE login against the tenant record and binds the public key to register to the resulting session with a MAC:
component broker
--------- ------
KE1 = client_login_start(pin)
Opaque(stage=1, cid, data=KE1) -----> tenant = tenant_of(cid)
record = tenant_opaque[tenant]
KE2, state = server_login_start(
setup, record, KE1, tenant)
<----- Response(OK, KE2)
(state kept on the connection)
KE3, session_key = client_login_finish(
pin, state, KE2) # fails on wrong pin or
# unauthentic broker
mac = HMAC(session_key, "rembus-register"
|| SHA256(cid || key_type || pubkey))
Opaque(stage=2, cid, data=KE3,
pubkey, key_type, mac) -----> session_key = server_login_finish(
state, KE3)
verify mac
not already registered?
validate and store pubkey
<----- Response(OK)
save private key
Properties and decisions:
Pin knowledge is proven by the login; the MAC under the session key makes the key to register inseparable from that proof: a man in the middle cannot swap the public key, and a captured KE3 is useless without the session key.
The server state is stored in
twin.handler["opaque"]together with thecidand is removed when stage 2 arrives, whatever its outcome, so each stage 1 allows exactly one finalization; stage 2 with a differentcidis rejected.The private key is generated before the exchange but saved locally only after the broker accepts the registration.
A wrong pin is reported as
wrong tenant/pin: the component cannot recover the envelope, and cannot tell a bad pin from a broker that does not hold the real record.The existence of a tenant record is revealed by the stage 1 error (
tenant [x] not enabled), as in the standard registration.The login state is per connection and not persisted: a broker restart between the two stages makes the component retry.
Wire format¶
Packet type TYPE_OPAQUE = 8. CBOR array:
[0x08 | flags, id, cid, stage, data, pubkey, key_type, mac]
Field |
Stage 1 |
Stage 2 |
|---|---|---|
|
|
|
|
credential request (KE1) |
credential finalization (KE3) |
|
|
PEM public key |
|
|
|
|
|
MAC described above |
The stage 1 response is a standard Response with status 0 and the
credential response (KE2) as data; the stage 2 response has status 0 and
no data, or an error status with the reason as data.
Over JSON-RPC the packet is a request whose method is the cid:
{"rembus": "1.0", "id": "123", "method": "sensor1",
"params": {"type": 8, "stage": 2, "data": "BASE64",
"key_val": "BASE64", "key_type": 1, "mac": "BASE64"}}
Byte values are base64 text; key_val, key_type and mac are present
only at stage 2. Elements after the eighth of the CBOR array are forwarded
unchanged, like in the other packets.
Implementation map¶
Piece |
Location |
|---|---|
|
|
server setup, enrollment, key MAC |
|
broker handler |
|
component side |
|
|
|
administration tool |
|