Skip to content

Security model

Colca has no certificate authority and no password file. Nodes and machines are identified by ed25519 keys, people by OIDC tokens, and local services by the fact that they can reach a door that is never published.

Doors

A node opens only the listeners its configuration names. Each one has a single way of deciding who is talking.

Door Default port Who uses it Credential
local HTTP 80 services inside the deployment network none; the door is not published
local MQTT 1883 the same none
API 443 machines, external services, administrators, people client certificate, Authorization: Bearer, or the admin token
machine MQTT 8883 machines and external services client certificate carrying an enrolled key
human MQTT 8884 people and applications OIDC token as the MQTT password
human WebSocket 8885 browsers OIDC token
replication 9443 child nodes client certificate carrying an enrolled node key

A presented credential that fails is refused. There is no fallback to a weaker door.

The local doors are meant for services that run next to the node, for example in the same Docker network. Publish them, and anyone who reaches them can write. Keep them unpublished.

Keys instead of a CA

Every node and every machine owns an ed25519 key pair, created with colca-keygen. The TLS certificate is a self-signed wrapper around that key and carries no authority of its own.

  • A parent accepts a child when the key in the child's client certificate belongs to a node enrolled at the parent.
  • A child accepts its parent when the parent presents the key in parent.pubkey. Anything else aborts the handshake.
  • A machine is accepted when its key is enrolled at the node it connects to.

TLS 1.3 is the minimum on every encrypted door. Revoking an entry closes the live session at once.

Identities

Kind How it gets in What it may do by default
node enrolled at its parent with its public key, bound to an element the parent holds replicate its subtree
external enrolled with its public key, bound to the element it belongs to read its element's subtree; writing needs explicit grants
local creates its own entry on first use of a local door, by name read and write its element's subtree, or the whole node when unplaced
human OIDC token; grants come from the groups the token names nothing without grants; never writes data, only commands

An identity is bound to a system element, not to a path. Renaming or moving the element moves everything bound to it.

Enrollment goes through POST /enroll with the admin token, or through a _CmdAdmin command sent down the tree to a node that is not directly reachable.

Grants

Grants use one grammar for machines and people:

Grant Meaning
read:<element>/# live bus, current state and history below the element
write:<element>/# publish data and entities below the element
cmd:<element>/#:<classes> send commands of the listed classes below the element
admin:# use the administrative routes; does not widen reads or commands

Command classes are acknowledge, param, operate, maintain, configure and admin. configure is separate on purpose: someone who may rename a signal must not thereby be able to send maintenance commands to a PLC. acknowledge is separate for the opposite reason: quitting an alarm moves nothing, so everyone who watches a line may hold it, and holding it must not let them start or stop the line. It covers _CmdAcknowledge and nothing else; silencing an alarm keeps notifications from other people and stays operate. No class implies another: someone who may both operate and acknowledge holds both.

_CmdEdit is a person's tool for the node's data model, and the door admits anyone holding configure on it. Two narrower classes are also admitted, each covering only the part of _CmdEdit that matches the hazard: operate covers creating an annotation, or editing one's own; param covers setting the value and metadata of an existing constant — an operator input such as a station's sandoff or grit — never creating or deleting a constant, never its other attributes, and never a signal's binding. Both are scoped by the grant's element exactly as configure is: cmd:<element>/#:param reaches only the constants under that element. The write itself is still made by the node — _CmdEdit never lets a person publish state directly — but it carries the operator's own verified identity as actor_id/actor_label/actor_kind, so /kv can show who set it (see http-api.md).

# in place of an element means the whole node. A grant on an element the node has never heard of covers nothing, and a node that has never reached its parent cannot resolve elements above itself, so scoped grants fail closed there.

People

People authenticate with tokens from OIDC issuers the node lists. The node validates them offline:

auth:
  issuers:
    - url: https://login.example.com/realms/plant
  audience: colca
  jwks_url: https://login.example.com/realms/plant/protocol/openid-connect/certs
mqtt_human: { tcp_addr: ":8884", ws_addr: ":8885" }

A token's iss must be one of issuers, and its aud must be audience. The issuer also decides which keys the signature is checked against: its own jwks_url, or the shared auth.jwks_url when it has none. A token that names an issuer but was signed with another issuer's keys is rejected.

Several issuers are normal when one identity provider is reached under more than one host name. Keycloak, for example, writes the host the browser used into iss, so a site with two networks gets two issuers with the same keys:

auth:
  issuers:
    - url: https://red.plant.example/realms/plant
    - url: https://green.plant.example/realms/plant
  audience: colca
  jwks_url: http://keycloak:8080/realms/plant/protocol/openid-connect/certs

Issuers from different identity providers each name their own keys:

auth:
  issuers:
    - url: https://login.example.com/realms/plant
      jwks_url: https://login.example.com/realms/plant/protocol/openid-connect/certs
    - url: https://idp.partner.example
      jwks_url: https://idp.partner.example/.well-known/jwks.json
  audience: colca

Signing keys are fetched in the background from each distinct JWKS URL, stored, and refreshed when a token names an unknown key. The issuer is never called while a request waits, and a node that restarts without network keeps validating until the tokens expire. A session ends when its token expires, unless the client renews it first.

An MQTT 5 client renews on the open connection: it sends the authentication method colca-token in its CONNECT (the token stays the password), and the node names the method in the CONNACK. Before the token runs out, the client sends an AUTH packet with reason 0x19, the same method, and the new token as authentication data. The node checks it as at CONNECT, requires the same sub, and moves the session's grants and expiry to it; the subscriptions stay. A refused token ends the connection with 0x87 (not authorized), a different method with 0x8C. A client without the method reconnects with a new token instead.

A logout at the identity provider reaches the node by OIDC back-channel logout. The node serves POST /auth/backchannel-logout on its API door and its local door; the identity provider posts a signed logout_token there. The node checks the signature against the issuer's keys, iss, aud (the audience above), iat, exp if present, the back-channel logout event in events, that there is no nonce, and that the jti was not used before, and answers 400 to a token that fails. A valid one is answered 200: every connection of that session (the token's sid) is closed with 0x98 (administrative action), and tokens of the session are refused from then on, on every door. A logout token with a sub and no sid ends everything issued to that person before it. In Keycloak, set the client's back-channel logout URL to the node's local door, for example http://colca/auth/backchannel-logout. Without back-channel logout, the token lifetime is the revocation delay.

A token names groups, not grants. The node resolves the groups against _Group definitions that its ancestors pushed down. Membership lives in the identity provider, grants live in the tree, and an edge cut off from its parent still knows what its people may do. colca-grantsync keeps a Keycloak realm and the tree's groups in step, if you use Keycloak.

Administration

The administrative routes (/enroll, /debug/state) require either the node's admin token (X-Colca-Token) or a token with admin:#. An empty api.token authenticates nobody; it does not switch the check off.

Secrets

With secrets_dir set, a local service can store sealed envelopes on its node (/secrets). The node only ever holds ciphertext; the service owns the private key and decrypts in its own process. The secret store is a separate database, never replicated and not part of data_dir. See the secrets Go package.

Reporting a vulnerability

See SECURITY.md.