Organisations: trusting machines by name, not by pinned key

Until November 2026, an end-to-end encrypted TCP session in nfltr was as good as the fingerprint you copied. An agent printed its certificate's SHA-256, you pasted it into --pin or pin: on each client, and you listed each client's key on the agent. That works for two machines. For twenty it is a spreadsheet of fingerprints, and every new machine, reinstall or departure means editing files on the others. Worse, the first copy of each fingerprint was yours to protect: if it ever came through a page the relay showed, you were trusting the relay.

From v2.1.58, encrypted TCP runs between machines enrolled in your organisation instead. You make a root once, on your machine. Each machine joins once with a single-use token. After that a client names the machine it wants (nfltr dial web-1.db/pg: the agent db on the machine web-1), the session checks that the other end holds a certificate for that name signed under your root, and a service admits machine groups (allow_group: [db]) instead of a list of keys. Pins and client key lists are gone. The organisations guide is the step-by-step setup; this post is about the design, the threat model and the evidence.

What this means for you

  • What you do once. nfltr org init on your machine makes the root and the issuer and writes an offline recovery key. nfltr node there keeps the issuer running.
  • Per machine. nfltr org token --name web-1 --group web where the issuer is, then nfltr org join on web-1 with that token and the root's two fingerprints. About a tenth of a second. Nothing else to copy, and no agent-to-agent key file: the machine's key arrives sealed in its enrolment answer.
  • Day to day. Nothing. Machines renew their 24-hour certificates on their own; you renew the issuer once a month.
  • When something goes wrong. nfltr org revoke web-1 ends that machine's sessions within seconds everywhere. A lost or stolen root is replaced with the recovery key (nfltr org rotate), and running machines follow.
  • What the relay can do. Carry, delay or refuse. It cannot enrol a machine, impersonate one, hide a revocation for longer than the revocation list's hour, or read an organisation session.

The problem with pins

A pin answers "is this the key I expected?". It says nothing about which machine the key belongs to, whether that machine is still trusted, or who decided it should be. Everything around the pin was manual: you carried each fingerprint, kept each client key list current, and noticed yourself when a laptop was lost. Revoking a client meant editing every service that listed it. And the first use of each pin was outside every guarantee we could prove: the proofs against a hostile relay listed "the first use of every pin and key" as their first limit.

An organisation moves the first use to one place. A machine needs two fingerprints (the root's and the recovery key's) and one token, all from you, once. Every later decision (this machine is web-1, it is in web, it is still trusted) is a signature under that root that the machine checks itself.

The design

The trust chain of an organisationThe root signs the issuer's certificate monthly; the issuer signs 24-hour machine certificates and the hourly revocation list; the offline recovery key signs only a rotation to a new root.signs a rotation onlycertifies for 30 dayscertifies for 24 hourscertifies for 24 hourssigns every hourRecovery key, offlineRoot, on your machineIssuerweb-1, group webdb-1, group dbRevocation listRelay: carries, can forgenone of it
  • Root. A P-256 key, by default a file encrypted under your passphrase (argon2id, AES-256-GCM). It signs rarely, and asks you each time: the issuer's certificate once a month, a monthly checkpoint of the issuer's log, and a rotation. A Secure Enclave root is an option for owners who have the signing helper; this release does not ship it.
  • Recovery key. An Ed25519 key you keep offline. It does one thing: certify a new root after the old one is lost or stolen.
  • Issuer. Holds a certificate from the root for 30 days, and decides enrolments and renewals under a policy (the groups it may give out). Every decision goes into a hash-chained log that the root checkpoints.
  • Machine certificates. 24 hours, renewed at two thirds of their life over the relay. The name is the machine's name in your account; the groups are the certificate's organisational units.
  • Revocation list. Signed every hour and at once after a revoke, pushed to every machine and read again as a backstop before it expires. A machine whose list expired refuses every organisation session: it fails closed.
  • Tokens. Single use, short lived (an hour by default), bound to one name and its groups. A token redeemed once is spent, even if the answer is lost and the machine asks again.

All of it travels through mailboxes on the relay: enrolment requests, answers, the revocation list and the current root. The relay forges none of it: requests carry the token's binding or the current key's signature, answers and lists are the issuer's signatures, and the root is pinned or certified by the recovery key.

The threat model

We assume the relay is hostile: it reads, drops, delays, replays, reorders and invents any message, holds an enrolled machine of another group, a revoked machine, an expired certificate and a leaked token. Against that we claim:

  • No name without the root: a certificate for web-1 exists only if your issuer signed it under your root.
  • A token is spent once and only for its own name and groups.
  • A revoked machine is refused within the revocation list's life (one hour), and within seconds when the push arrives.
  • No session survives its certificate's expiry.
  • A rotation happens only with the recovery key, and after it machines trust only the new root's issuers.
  • A machine's agent-to-agent key comes from its issuer, not from the relay.

What we do not claim: hiding who talks to whom and when, protecting a root you copied somewhere with its passphrase, or surviving a relay that simply refuses to carry anything (it can stop you, not fool you).

The evidence

The design was a model before it was code. A ProVerif model checks who can learn or forge what, with the attacker owning the relay; a TLA+ spec checks the state machine (renewals, revocation timing, rotation) over every interleaving; and the code's own runs are recorded and checked against the TLA+ spec. Every failure a model is meant to prevent is planted as a mutant, and the gate fails if the model does not catch it.

Evidence: the models of enrolment

Setup
ProVerif 2.05: the enrolment model (the attacker owns the relay and holds a compromised enrolled machine, a revoked one, an expired certificate, a leaked token and the old root) and the agent-to-agent key transfer model's enrolment path. TLA+ with TLC: the enrolment spec with one machine, one verifier, two root generations, a forged certificate under a stolen root, and a configuration in which the relay loses a request or an answer. Both run on every push of our repository.
Numbers
ProVerif enrolment model: 9 queries hold, 5 witnesses reached, 13 mutants each caught, about 1 s. Key transfer: 3 enrolment mutants each caught. TLA+: 8 invariants and 2 liveness properties hold, 280,785 states (162,030 with losses), 17 mutants each caught, every action taken. 26 recorded traces of the code (170 steps) checked against the spec.
Reproduce
the models are in our private repository (make spec-proverif, make spec-enrol-ci). With the released binaries you can check the behaviour they describe: a replayed token, a revoked client, an expired revocation list, as in the guide's troubleshooting table.
Limits
Symbolic models assume perfect cryptography and no timing; TLC checks small constants (one machine, one verifier, four time steps). The code is tied to the models by tests, mutants and traces, not by a verified implementation.

What the models caught

  • The issuer's signature on the machine's key. A machine's agent-to-agent key arrives sealed to its certified key with HPKE. HPKE's base mode says nothing about who sealed it, so in the model the relay sealed a leaked machine's key to the enrolling machine, and the machine took it. The fix: the issuer signs the token id, the certificate and the sealed key together, and the machine checks that signature first. Mutant unsigned-key removes the check; the model finds the attack.
  • The token id in that signature. With the signature over the certificate and the sealed key only, a leaked token redeemed for the honest machine's request would hand it a key from the leaked token's redemption: the request does not name the token. The answer now names the request's token id. Single use alone covers this in the code, but not in the model's abstraction, and the binding costs nothing. Mutant any-token.
  • The answer spool. The TLA+ configuration in which the relay loses a request or an answer showed that a machine asking again must get the first answer, not a second decision: otherwise a token is spent twice, or a machine is told "redeemed already" for its own enrolment. The issuer keeps each decision until the request is acknowledged, so a retry after a lost answer or an issuer restart is answered as it was. TLA+ mutant no-retry, Go mutant no-spool.

What the 20-machine test found

A model checks the design; the fleet test checks the processes. It runs a private relay and 20 machines in containers, enrols them all from tokens, replays a token, revokes a machine with and without the revocation push (a test tool withholds it, so only the backstop read can catch the revoke), rotates the root, and scans every container for keys on the command line, in the environment or in a readable file.

Its first full runs found two stalls in rotation, both now fixed with guards:

  • A renewal that waited after a rotation. A machine took the new root in memory before writing it to disk, so a reload between the two read the old root as a rotation the recovery key never signed: one failed renewal and a 30-second retry. And a machine whose revocation push was withheld renewed into the new issuer only at its 10-minute backstop read, because the reload failed on the old root's list. The two steps are now serialised, and a list the machine refuses is only not taken (it still admits nothing under that issuer until it takes one).
  • Sessions refused for up to 30 seconds after a rotation. The new issuer's revocation list could arrive before the rotation itself, and was read again only after the retry wait. A machine now reads it again at once when it takes a rotation. Mutant crl-no-wake-on-rotation.

Evidence: a 20-machine organisation against a private relay

Setup
A private relay built from our tree and 20 machine containers (Linux, arm64) on one Mac, enrolment, revocation and rotation over the relay's mailboxes, 2026-11-10. One run, all steps passing, about 11 minutes.
Numbers
20 machines enrolled in 7.27 s (slowest join 0.63 s, each with its agent-to-agent key); a replayed token refused; a client outside the group refused; revoke with the push: 11.13 s to the held session's cut (2.31 s in an earlier run; the bound is 30 s, the issuer publishes within 10 s); revoke with the push withheld: 584.46 s, at the backstop read (bound 665 s); rotation: 18 machines under the new issuer 6.25 s after the rotation, the two revoked ones not, and a session under the new root served; no key on any command line, in any environment or in a readable file, in 24 containers.
Reproduce
make smoke-test-org-enrol in our repository. With the released binaries: enrol two machines from the guide, hold a session open, nfltr org revoke the client and time the cut.
Limits
One machine, one run, containers on one host: the times are mostly the issuer's publication interval and the backstop's schedule, not network latency.

What writing the guide found

We ran every command in the guide against a private relay before publishing it. Three things did not hold, and were fixed in the same release:

  • A token used on the wrong machine. A token names a machine of your account. Redeemed from another machine of the account, it used to enrol that machine under the token's name, so the certificate's name and the account's name disagreed and every client dial failed later with a confusing exit code. The issuer now refuses at enrolment and names both machines.
  • A replay from another machine said "not for this key", which reads like a bug. It now says the token was redeemed already.
  • nfltr ssh-config for a plain shell. nfltr shell is plain at the nfltr layer (SSH encrypts it end to end itself), but an enrolled client opens organisation sessions by default, so the generated ProxyCommand was refused. It now passes --plain.

Limits

  • SSH cannot check revocation. OpenSSH checks the host certificate, not the revocation list: a revoked machine's shell stays trusted by ssh until its host certificate ends, up to 24 hours.
  • The revocation list fails closed. If nothing serves the issuer for more than an hour, every organisation session is refused until it runs again. Run it on a machine that stays on.
  • The issuer lives with the root. A separate always-on issuer, certified monthly by a root kept elsewhere, is not available yet.
  • One relay account, tokens only. Every machine of an organisation uses one relay account. Cloud instance identity and Kubernetes service-account tokens are not supported.
  • Names follow the account. A client dials another machine as <machine>.<agent>, where the machine part is its name in your account.
  • No hardware root in the release. The Secure Enclave root needs a helper the release does not ship; the default root is a passphrase-encrypted file, which can be copied.
  • Metadata. The relay still sees which machine talks to which, when and how much.

Is it for you?

Fits if you run end-to-end encrypted services between more than a couple of machines and want to add, remove and replace machines without copying fingerprints or editing key lists, with revocation that takes effect in seconds and a recovery path for a lost root.

Not ready for machines that must join from cloud instance identity or Kubernetes tokens, a hardware-held root out of the box, an issuer separate from the root's machine, or hiding traffic patterns from the relay.

Try next: make an organisation and enrol two machines with the organisations guide, then publish a service with allow_group and dial it by name. For how the encrypted paths are proven against a hostile relay, see Proving nfltr's end-to-end encryption against a hostile relay; for the state machines, What TLA+ found in nfltr.

← All posts