Skip to content

9. The API · Contents · Next → 11. Personal data

10. Security

An access control system is a security product twice over: it protects a site, and it is itself a target. This chapter gathers everything that protects the system, and ends with an honest list of what does not yet.

Who can be, and what they can do

Identity Proves itself with Sees
An operator A Keycloak access token Every tenant
A machine A tenant API key Exactly one tenant
A cabinet An X.509 client certificate Its own MQTT subtree, nothing else
The backend An X.509 client certificate The whole fleet tree

Operators

OpenID Connect through Keycloak, authorization code flow with PKCE. wardn holds no password and no client secret: the dashboard is a public client, and the code exchange is bound to a verifier the browser tab generated.

Signing keys are fetched from the realm's JWKS and cached, so a rotation is picked up without a restart and wardn never holds a signing secret.

The realm defines wardn-user (held by every operator) and wardn-admin. The backend enforces exactly one role check, wardn-admin on the OTA endpoint, and audits everything else.

Why so few roles? A fine-grained permission matrix is a document nobody maintains and everybody works around with an exception. Everything here is open to an operator and traced, except the one gesture whose mistake is most expensive to undo: pushing firmware to a fleet.

Machines

An API key is wapi_live_ plus 24 random bytes. Only a bcrypt hash is stored, computed inside PostgreSQL by pgcrypto, so the plaintext never travels further than the response that issued it.

The first sixteen characters are stored in the clear and indexed: enough to find the row in one lookup, never enough to be the key. The hash still decides. See 9. The API for why that matters.

A key can carry an expiry, and revoking it takes effect on the next request.

Bcrypt rather than a memory-hard hash. The seed is plain SQL and has to produce valid hashes without a runtime, and crypt()/gen_salt('bf', 10) is what PostgreSQL offers for that. The trade is deliberate and worth revisiting: a random 192-bit key is not a password, so the slow-hash property is defence in depth rather than the thing standing between an attacker and the key.

Confinement between customers

This is the guarantee customers care about most, and it is enforced in four places rather than one.

flowchart TD
    K["A tenant API key"] --> R["REST: every query carries the caller's scope"]
    K --> W["Live socket: each message filtered as it is sent"]
    K --> A["Alerts: only those whose cabinet resolves to this tenant"]
    K --> F["Fleet-wide actions: refused outright"]
    R --> N["A resource of another tenant answers 404, never 403"]
  • The filter is inside the query, not applied after it, so a caller can never be handed a row it should not have seen.
  • 404, never 403. A status that distinguishes "does not exist" from "not yours" lets a client map what it cannot read.
  • A message carrying no tenant reaches nobody who is confined. An unattributable payload is not a reason to widen what an API client can see.
  • The debug console is closed to API keys. Raw reader frames carry no tenant and cannot be confined to one; confining what cannot be confined is worse than saying no.
make api   # asserts that Acme's key sees nothing of Northwind's

A controller sits on someone else's network and publishes to a broker on the public internet. A shared password would be one leak away from a fleet; a certificate is per device, revocable on its own, and cannot be read off a screen over somebody's shoulder.

make mtls   # both ends on the mutually authenticated listener: asserted

The identities

tls-init issues, once, into a shared volume:

ca.crt / ca.key      the development authority
emqx.crt             CN=emqx,          what the broker presents
backend.crt          CN=wardn-backend, what the backend presents
edge-01.crt          CN=edge-01,       what that cabinet presents
flowchart LR
    CA["Development CA"] -->|"signs"| E["edge-01 certificate<br/>CN=edge-01"]
    CA -->|"signs"| BE["backend certificate<br/>CN=wardn-backend"]
    CA -->|"signs"| MQ["emqx certificate<br/>CN=emqx"]
    E -->|"connects, presents CN"| MQ
    MQ -->|"username = CN"| RULES["Access rules:<br/>wardn/devices/edge-01/# only"]

It is idempotent: restarting the stack does not hand every party a new identity. On a subsequent start it re-reads what is there and prints anything expired or inside the warning window. An expired authority reused in silence would make every handshake fail with no explanation of why.

The authority outlives what it signs: TLS_CA_DAYS (3650) against TLS_DAYS (825). Expiring together would mean that the day the cabinets' certificates die, the key that could have replaced them is dead too. Renewal would become a recall rather than a rotation.

The two listeners

Port What it asks for
1883 Nothing. Kept for development so a laptop needs no keys
8883 A certificate signed by the CA, from both sides, or the handshake fails

Moving a party across is a URL and three paths:

MQTT_URL=mqtts://emqx:8883
MQTT_TLS_CA=/tls/ca.crt
MQTT_TLS_CERT=/tls/edge-01.crt
MQTT_TLS_KEY=/tls/edge-01.key

Both ends refuse to start half-configured. A cabinet with two of the three variables set fails loudly rather than falling back to the plaintext port. That is precisely what whoever set them was trying to avoid.

The certificate decides which cabinet is talking

A valid certificate is not the same thing as this cabinet. On the mutually authenticated listener, EMQX replaces the MQTT username with the common name of the client certificate (peer_cert_as_username = cn) before consulting its access rules, so a client cannot announce somebody else's name.

The rules then confine each party to its own subtree:

%% The cloud sees the whole fleet, because it is the only party that has to.
{allow, {username, "wardn-backend"}, all, ["wardn/#"]}.

%% A cabinet gets the subtree its certificate names.
{allow, all, all, ["wardn/devices/${username}/#"]}.

%% Everything else under the fleet tree belongs to somebody else.
{deny, all, all, ["wardn/#"]}.

The placeholder is not rendered when there is no username, so a client that announces nothing matches nothing.

make mtls asserts all four claims: both ends connect and carry a real passage; a client with no certificate is turned away at the handshake; one with a valid certificate is let through; and a cabinet cannot reach another cabinet's topics even when it announces that cabinet's name.

On the plain 1883 listener the username is self-asserted. That port is a convenience for development, not a control, and that is the whole extent of it.

Certificates fail on a date, not for a reason

Every certificate in a fleet is minted in the same pass, so they lapse on the same day. A lapsed certificate does not fail immediately: an established session survives its own credential, so a cabinet keeps working until something makes it reconnect, and then never comes back.

Three things stand between here and that morning:

  1. The authority is issued for longer than what it signs.
  2. Each cabinet reports the instant its certificate expires, not the days left, because counting days needs a clock and that is the one thing a cabinet cannot vouch for. The cloud does the subtraction with its own clock and raises a certificate_expiring alert TLS_EXPIRY_WARNING_DAYS (30) ahead.
  3. For the broker's and the backend's own certificates, which nobody reports:
make tls-check   # exits non-zero inside the warning window, so it can be a cron job

What is stored, and how

On the cabinet

The local SQLite database is encrypted with SQLCipher, because the box is physically reachable: someone can open the enclosure and take the SD card.

Even decrypted, it holds no credential values, only sha256(normalise(type, value)). A stolen cabinet does not hand over a list of everyone's plates. The raw value exists in exactly one place: the outbound event.

It also holds no history, no names and no emails. It is a cache, rebuildable from the cloud, except for the queue of undelivered passages.

The encryption key comes from SQLITE_ENCRYPTION_KEY_SOURCE:

Value Behaviour
env Read from SQLITE_ENCRYPTION_KEY. What the local stack uses
none Unencrypted. Explicit, never a silent fallback
secure_element ⚠️ Not implemented: the controller refuses to start and says so

There is deliberately no default key. A silent fallback would produce a database that looks encrypted and is not.

In the cloud

Three database roles, and the separation is the point:

Role Log tables Business tables
wardn_owner owns them owns them
wardn_app SELECT, INSERT; UPDATE and DELETE revoked full
wardn_maintenance full, for retention and erasure only full

The application connects as wardn_app. An application bug cannot rewrite history, because the permission does not exist. It is not because a code path avoids it. make verify asserts the grants.

Retention drops whole monthly partitions as the owner, so the append-only guarantee costs nothing operationally. See 11. Personal data.

Secrets that pass through

Firmware links. The backend signs a GET valid for OTA_LINK_VALIDITY_S (900 s) and sends it to one cabinet. The artifact never travels through the API, and the link dies on its own rather than being a credential somebody has to remember to revoke. The audit trail records the version and the digest, not the link.

The audit trail. Any request field whose name contains password, secret, token, key or apiKey is stored as [redacted].

Error reporting. Sentry is wired into the backend, the dashboard and the cabinet, and is off unless a DSN is set. What does leave is scrubbed first, on the whole event rather than in the places trouble is expected:

  • keys named like credentials: authorization, cookie, x-api-key, password, token, apikey, …
  • keys naming a person: email, fullName, value, rawValue, scannedValue, plate, credentials, …
  • any email address appearing inside a string;
  • any token=, access_token=, api_key= in a URL, which is how the live socket's credential travels, and the one a key-name filter would have missed.

It is the one copy of our data we do not administer.

Ceilings and origins

Rate limiting applies before authentication: RATE_LIMIT_MAX (600) per RATE_LIMIT_WINDOW_MS, because verifying a key or a token costs real work and an unauthenticated caller should not be able to spend it freely. The live socket carries its own lower ceiling, WS_HANDSHAKE_LIMIT (60), applied before the credential is even looked at.

CORS origins are listed, never reflected. A wildcard would let any page an operator visits call this API with their token.

Nothing connects into a site. The cabinet dials out and keeps the connection open. There is no inbound port to open on a customer's router, and no fixed address to negotiate with their IT department.

What is not there yet

Every known limit, in one place.

⚠️ OSDP Secure Channel is not implemented. Frames on the RS-485 bus are in the clear. The secure block is parsed and skipped so a secure peripheral does not confuse the decoder, but nothing is encrypted or authenticated: that needs an SCBK provisioned per reader and can only be trusted once it has been proved against real hardware.

⚠️ SQLITE_ENCRYPTION_KEY_SOURCE=secure_element is not implemented. The controller refuses to start rather than pretending. Use env with a key from your secret store, or none to run unencrypted on purpose.

⚠️ In the local stack, private keys are files on a shared volume. That is fine for a laptop and nowhere else. In production the authority is the manufacturing one and a cabinet's private key never leaves its secure element.

⚠️ Keycloak accounts created at runtime are not backed up. The realm is imported from this repository, so what a backup would add is whatever was created afterwards. See 12. Operating.

⚠️ There is no write API for hardware. Cabinets, doors and readers are created through the seed or by SQL.


The system is defended. Now: what it holds about people.

Next → 11. Personal data