Skip to content

← 17. The all-in-one reader · Contents · Next → 19. A DIY reader

18. The security agreement

Security here is owned by two parties.

wardn implements one half in code: who may act, what confines a workspace, how a controller proves it is itself, what a release must carry before it is allowed to run. That half travels with the product and holds wherever it is installed.

The other half cannot be implemented by an application at all. No process can grant itself TLS, decide where it sits on a network, or hold its own secrets. Those belong to whoever deploys it, on whatever platform they chose, and a deployment is secure only when both halves hold.

This chapter is the contract between the two:

  • Part 1 states what the product guarantees, with the file that implements each guarantee.
  • Part 2 states what the deployment owes, independent of technology.
  • Part 3 is the header table, normatively.
  • Part 4 says what neither side covers, because an agreement that hides its gaps is worse than none.

How to read the obligations. MUST means a deployment that skips it is not secure and should not carry real doors. SHOULD means the risk is real but bounded, and skipping it is a decision somebody takes knowingly rather than an oversight. Every obligation names how to check it, because an obligation nobody can verify is a wish.


Part 1 — What wardn implements

Nothing in this part needs configuration to hold. It is listed so a reviewer can see the boundary of the product's own responsibility, and so nobody re-implements a control that already exists.

Identity and authorisation

Property Where
Operators authenticate against an external OIDC provider; wardn stores no passwords 10. Security
Machine callers use API keys, stored hashed, never recoverable 10. Security
Every request is confined to its workspace; a confined caller cannot name another workspace's rows 10. Security
Unknown request properties are rejected, not dropped (whitelist + forbidNonWhitelisted) backend/src/main.ts:49
OpenAPI (/docs, /docs-json) is off unless ENABLE_API_DOCS=true, because Swagger mounts outside the auth pipeline backend/src/main.ts:64
Property Where
Controller ↔ broker is mutually authenticated TLS; the certificate decides which controller is talking 10. Security
Each controller is confined to its own topic subtree by broker ACL (EMQX rule, or the equivalent IoT policy) 14. Deploying
Controllers dial out. No inbound port is opened at a customer site, and no fixed address is negotiated 10. Security
The controller's local database is encrypted at rest (SQLCipher) and holds hashes, not plaintext badge numbers 10. Security

Firmware integrity

Four independent checks stand between a published file and a running binary. Three of them are enforced by the controller itself, so they hold even if the backend, the object store, or the transport is compromised.

Check Enforced by
Ed25519 signature over wardn-edge:{version}:{target}:{sha256}, verified against a public key compiled into the binary the controller, before download
Target triple must match the running binary's own the controller, before download
SHA-256 digest of the downloaded artifact the controller, before install
Staged install with the previous binary retained, and a release confirmed only once the new one boots and says so the controller, after install

The signing key lives with the release pipeline alone. It is never deployed to a controller and never to the backend. See 7. The fleet.

Browser-facing controls

The dashboard image ships its own headers. They are re-included in every location block, because nginx drops inherited add_header the moment a block sets one of its own — a subtlety that silently unprotects every JS and CSS asset when missed.

Header Value Where
Content-Security-Policy default-src 'self', frame-ancestors 'none', base-uri 'self', form-action 'self', script-src 'self' + the hashes of the dashboard's inline theme script and of the inline scripts in the bundled /help pages frontend/dashboard/docker/40-wardn-config.sh
X-Frame-Options DENY frontend/dashboard/security-headers.inc
X-Content-Type-Options nosniff idem
Referrer-Policy strict-origin-when-cross-origin idem
Permissions-Policy camera=(), microphone=(), geolocation=() idem

connect-src is templated at container start from WARDN_API_URL, WARDN_OIDC_AUTHORITY, WARDN_OIDC_ENDPOINT_ORIGIN and the Sentry DSN, because those origins are not known at build time. A deployment that points the dashboard at an API origin it did not declare will be blocked by its own CSP — that is the intended failure, not a bug to work around by loosening the policy.

⚠️ style-src carries 'unsafe-inline'. Angular injects component styles at runtime, so the alternative is nonce plumbing through the framework's own style pipeline. Stated rather than hidden: it is the weakest line in this policy, and it is why script-src is kept strict.

The API's own headers

The API is JSON. It serves no HTML, so the CSP helmet would default to is meaningless there and is deliberately disabled; what remains of helmet's defaults still applies, and still covers /docs while it is enabled (backend/src/main.ts:47).

CORS origins are listed, never reflected (backend/src/main.ts:31). A wildcard would let any page a signed-in operator visits call the API with their token.

Secrets in transit and in logs

Property Where
Firmware is fetched by a pre-signed GET valid for OTA_LINK_VALIDITY_S (900 s); the artifact never passes through the API 10. Security
The live socket uses a single-use, few-second ticket rather than the operator's bearer token, because a browser cannot set headers on a WebSocket handshake idem
The audit trail and error reporting share one redaction function: credential-shaped keys, person-shaped keys, any email inside a string, and any token= / ticket= in a URL idem
Sentry is off unless a DSN is set, and a controller has none: its journal entries reach the backend over MQTT idem

Ceilings

Limit Default
RATE_LIMIT_MAX per RATE_LIMIT_WINDOW_MS 3000 / 60 s
WS_HANDSHAKE_LIMIT, applied before the credential is read 60
API_KEY_INVALID_ATTEMPT_LIMIT, failed lookups per address 20

⚠️ All three live in process memory. Across N backend instances the effective ceiling for one attacker is N times the number above. This is a deployment-shaped limit, not a code defect, and Part 2 says what to do about it.


Part 2 — What the deployment must add

None of this depends on the platform. A Kubernetes ingress, an ALB, a Caddy in front of two containers, or a single VM with nginx all satisfy these the same way — only the syntax changes.

Transport

MUST — terminate TLS in front of every public component. The dashboard container listens on plain 8080 by design, so that the container needs no certificate and no privileged port. Something in front of it must speak TLS to the browser. The same holds for the API.

MUST — send HSTS. wardn does not set it and cannot: only whoever terminates TLS knows whether every subdomain is ready for it.

Strict-Transport-Security: max-age=31536000; includeSubDomains

Add preload only once you are certain every host under the domain is HTTPS-only, because the preload list is slow to leave.

MUST — redirect HTTP to HTTPS, and never serve the dashboard or the API over plain HTTP on a routable network. Verify: curl -sI http://your-host/ returns a 301/308 to https://.

SHOULD — use TLS 1.2 as the floor, 1.3 where available, and disable renegotiation and compression. Verify: any TLS scanner, or openssl s_client -connect host:443 -tls1_1 failing to negotiate.

Preserving the caller's identity

MUST — set TRUST_PROXY whenever anything sits in front of the API.

Two ceilings and every audit row are keyed on the caller's address, and TRUST_PROXY is what decides whose word that is.

It defaults to false, which reads the address off the socket — correct when the backend is reached directly, and the only safe default: X-Forwarded-For is written by whoever sends the request, so a directly reachable backend that believed it would let any caller claim any address and walk past those ceilings.

Behind a proxy, that same default makes every caller look like the proxy. API_KEY_INVALID_ATTEMPT_LIMIT stops being per-attacker and becomes one shared bucket, and system_audit_logs records the hop instead of the origin.

Deployment Value
Nothing in front false
A proxy that is the only route in true
N proxies, all yours the hop count, e.g. 2
Reachable both directly and through a proxy the proxy's addresses or CIDR ranges

⚠️ true is wrong whenever the backend is also reachable directly. It is the one setting here where the permissive value is more dangerous than the restrictive one, because it converts a rate limit into a formality.

Verify: call the API from two different client addresses and compare the ip recorded in system_audit_logs. Two distinct addresses means the setting is right; one repeated address means it is not.

Ceilings at the edge

MUST — enforce a rate limit in front of the API whenever more than one backend instance runs, for the reason Part 1 states. A WAF rule, an ingress annotation, or a proxy module all work; what matters is that the counting happens somewhere shared.

SHOULD — add a bot/abuse control on the authentication routes of your identity provider, which is a separate product with its own edge.

Configuration that must not ship as it is

Setting Production value Why
TRUST_PROXY false direct, otherwise the proxy See above; the default is safe only when nothing is in front
ENABLE_API_DOCS false (or unset) Swagger mounts outside the auth pipeline: enabled means the full API surface is readable by anyone who can reach the port
CORS_ORIGINS the exact dashboard origins Never *, never reflected
OTA_PUBLIC_KEY / OTA_SIGNING_KEY a freshly generated production pair The dev pair in .env.example is public knowledge. A fleet provisioned with it will accept a release anyone can sign
SQLITE_ENCRYPTION_KEY_SOURCE env, keyed from a secret store secure_element is not implemented and the controller refuses to start; none is a deliberate choice to run unencrypted
MQTT credentials / certificates per-controller, from the manufacturing authority See 10. Security

MUST — hold the OTA signing key in the release pipeline only. Not on a controller, not in the backend's environment, not in the repository. Its public half is the only part that is deployed.

Network placement

MUST — keep the database unreachable from the internet. Private subnet or equivalent, TLS on the connection, and an application role that is not the schema owner. Verify: attempt a connection from outside the private network and confirm it is refused at the network layer, not by a password prompt.

MUST — keep the object store private. Firmware is fetched by pre-signed URL, which only works if anonymous reads are refused. A world-readable OTA bucket does not break OTA — it silently removes the link expiry the design depends on. Verify: curl -sI the artifact URL without a signature and confirm a 403.

MUST — require mutual TLS on the broker and refuse anonymous connections, with an ACL confining each controller to its own subtree.

SHOULD — expose only the dashboard and the API, and keep the broker's listener reachable only from where controllers actually dial in.

Identity provider

MUST — enforce MFA for operator accounts that can administer a workspace. wardn delegates authentication entirely; the strength of an operator login is whatever your provider enforces, and nothing in wardn can raise it.

SHOULD — keep access token lifetimes short and confirm the audience matches OIDC_AUDIENCE, so a token minted for another client is not accepted.

Supply chain and operations

MUST — pin container images by digest, not by a moving tag.

MUST — restore a backup before go-live. 12. Operating makes the argument; it belongs here because an unrestorable backup is a security control that does not exist.

SHOULD — scan images and dependencies on a schedule, and watch the certificate expiry the certificate_expiring alert reports: the fleet was minted in one pass and reaches its expiry date together.

SHOULD — decide where logs and error reports may live before setting a Sentry DSN. Redaction reduces what leaves; it does not change the jurisdiction it leaves for.


Part 3 — The header table, normatively

What a deployment ends up serving, and who is responsible for each line.

Header Dashboard API Set by
Content-Security-Policy required not applicable (JSON) product
X-Frame-Options: DENY required required product
X-Content-Type-Options: nosniff required required product
Referrer-Policy required required product
Permissions-Policy required — product
Strict-Transport-Security required required deployment
HTTP → HTTPS redirect required required deployment

MUST — do not let the reverse proxy strip or duplicate these. A proxy that adds a second Content-Security-Policy does not replace the first: browsers enforce the intersection, and the result is usually a dashboard that fails to load for reasons nobody can find. Verify: curl -sI https://your-dashboard/ and confirm each header appears exactly once, then repeat against a hashed asset such as /main-<hash>.js, where the add_header inheritance trap would show up.


Part 4 — What this agreement does not cover

10. Security holds the full list and stays the reference. The ones that change what a deployment may promise:

⚠️ OSDP Secure Channel is not implemented. RS-485 frames are in the clear. Physical protection of the bus between a controller and its readers is a deployment obligation — conduit, tamper-evident enclosures, or accepting the risk knowingly.

⚠️ wardn does not back up a self-hosted identity provider's accounts. A managed provider removes this gap entirely.


Sign-off

A deployment can be called secure when each line below is true and someone has put their name to it.

  • [ ] TLS terminates in front of the dashboard and the API; HTTP redirects
  • [ ] Strict-Transport-Security is served by both, verified with curl -sI
  • [ ] All five product headers appear exactly once, on the page and on a hashed asset
  • [ ] ENABLE_API_DOCS is false; /docs returns 404
  • [ ] CORS_ORIGINS lists exact origins, no wildcard
  • [ ] A production OTA keypair was generated; the dev pair appears nowhere
  • [ ] The signing key exists only in the release pipeline
  • [ ] Database and object store refuse connections from outside the private network
  • [ ] The object store refuses an unsigned read of a firmware artifact
  • [ ] The broker requires mutual TLS and confines each controller by ACL
  • [ ] MFA is enforced for administrative operators
  • [ ] A rate limit runs at the edge, or exactly one backend instance runs
  • [ ] TRUST_PROXY matches the topology, verified against system_audit_logs
  • [ ] Images are pinned by digest
  • [ ] A backup has been restored, not merely taken

wardn secures what an application can secure. The rest is a deployment decision, and this chapter is the list of decisions nobody may skip.