Part 8 of 14
Identity and permissions
Everything up to here assumes the participant on the other side is who it claims to be. On a shared network that assumption is unavailable. Discovery accepts announcements from anyone, and an announcement is just bytes — a name, some addresses, a list of topics — all of which are as easy to invent as to report honestly.
The work divides into establishing who a peer is and what it may do — a small number of expensive exchanges, covered here — and protecting every message after that, which is the three parts that follow.
A theme runs through both. An unauthenticated sender must not be able to make this process spend anything — not memory, not a slot in a table, not a state change that a later legitimate message depends on.
Discovery
Admitting a security advertisement
any participant announcement is parsed
Once per announcement, from every participant on the network.
A participant that requires security advertises two tokens in its ordinary announcement: one describing its identity and one describing its permissions. A participant that does not carries neither.
This is the first message from a completely unknown sender, so it is where an attacker's cheapest attempt lands. The tokens are variable-length, and both their sizes are numbers the sender chose.
Three outcomes have to be distinguished, and only the first two are legitimate: an announcement with neither token, one with both, and one with only one of them. The third is not an unsecured participant — it is a malformed advertisement, and treating it as unsecured would let a sender drop the permissions token to bypass authorization.
Admit-Advertisement(params, limits)
params the announcement's parameter list · limits the configured maximum extents for each token
- i = Find(params, identity-token)
- a = Find(params, permissions-token)
- if i == nil and a == nil
- return unsecured // no security storage is reserved
- if i == nil or a == nil
- return malformed // not the same as unsecured
- if |i| > limits.identity or |a| > limits.permissions
- return resource-limit
- return secured(Own(i), Own(a)) // copied once, borrowed thereafter
Line 4 reserves nothing. An unsecured participant on a network that also carries secured ones is normal, and it must not cost security storage to observe one.
Line 9 takes ownership of the bytes exactly once. Authentication later borrows them rather than copying again, which matters because the handshake may retry and each retry would otherwise duplicate a credential that can be several kilobytes.
Cost
O(P) over the announcement's parameters plus the two admitted extents. An unsecured announcement adds two lookups that find nothing.
Compatibility
Agreeing on mechanisms
an advertisement has been admitted
Once per secured peer.
The tokens name the mechanisms the peer uses — which authentication scheme, which access-control scheme. Two participants using different schemes cannot authenticate each other, and finding that out through a failed handshake wastes an expensive exchange.
The check is a string comparison on the class identifiers, and the thing it must not do is treat the presence of properties inside a token as meaning anything. A token may carry optional properties; those describe configuration, not identity. Reading one and concluding the peer is authenticated would accept an unauthenticated participant on the strength of a field it wrote itself.
What comes back from this check is a yes or no, carrying nothing. No token contents are retained, because nothing here has been verified — the announcement is still just bytes from a stranger.
Cost
O(C) in the class-identifier length, constant storage, no allocation. Building the local participant's own token allocates its class identifier once on the control plane.
Authentication
The authentication handshake
a peer requiring security is discovered
Three messages per peer, once. Nothing on the publishing path repeats this.
Two participants that both require security exchange three messages: a request, a reply, and a final proof. Each side holds a certificate signed by an authority both trust, and the exchange proves each holds the private key for the certificate it presented, while also agreeing on a secret that only the two of them know.
Until that finishes, the peer does not exist as far as the rest of the system is concerned. It is not in the matching graph, its endpoints are not matched, and nothing is sent to it.
Deciding who starts
If both sides begin, there are two overlapping exchanges and each has to work out which to abandon. Instead the two participant identifiers are compared and the lower one initiates. Both sides compute that from values they already have, so they agree without exchanging anything.
On-Token(s, t, now)
s the handshake session for this peer — phase, correlation, accepted sequence, retries, deadline · t the received token · now the current time
- if not Valid-Class(t) or t.destination ≠ local or t.peer ≠ s.peer
- return reject
- if t.related ≠ s.correlation or t.sequence ≤ s.accepted
- return reject // unrelated, or an old message replayed
- if |t| > limit or now > s.deadline
- return reject
- previous = s.accepted
- s.accepted = t.sequence
- if not Verify-Proof(s, t) // the expensive step, and the last one
- s.accepted = previous
- s.phase = preceding phase
- return reject
- s.phase = Next(s.phase)
- if s.phase == authenticated
- Publish-Binding(s.peer)
- return ok
Lines 1 to 6 are all cheap and all come before line 9, which is the only expensive one. Anyone can send bytes to this port, and verifying a signature costs far more than comparing an identifier, so the checks that cost nothing run first. Anything that fails them is discarded having cost a few comparisons.
When a proof fails
Lines 7 and 10 are the detail that is easy to get wrong. The sequence number has to advance so that a genuine replay of an older message is refused at line 3. But if it advances and the proof then fails, an honest peer whose reply was corrupted in transit could resend exactly the same message and have it rejected at line 3 as stale — the handshake would be dead with both sides behaving correctly. Restoring the previous value keeps that retransmission admissible while still refusing anything older.
A peer that starts endless handshakes
Enrolling a peer is bounded the same way as discovery. The registry has a fixed number of slots, a peer already present cannot take a second, and a full registry rejects a new one without disturbing any session in progress. Handshakes are expensive, so being able to start unlimited numbers of them would otherwise be a way to make a participant do unlimited work.
Authentication
Splitting a handshake message
a handshake message is larger than one datagram
Once per handshake message that needs splitting, plus one full retry per failed attempt.
The first handshake message carries a certificate chain, a public key, a signed permissions document, and a proof. That is frequently larger than a single datagram, so it has to be split — and this is the one place where the machinery from Part 3 is unavailable.
Why there is nothing to repair with
Reliable delivery works by the reader telling the writer what is missing and the writer resending it. Both sides have to be matched endpoints with state about each other for that to happen, and here they are not: these messages are what establishes the relationship in the first place. The endpoints carrying them are best-effort, so there is no acknowledgement and nothing to repair with.
What replaces it is retrying the whole message. If a send is refused partway through, the message stays retained and the next attempt starts again at the first piece — with the same sequence number, the same bytes, and the same piece boundaries.
That last requirement is what makes the receiver's job possible. A receiver assembling pieces holds one description of what it is assembling: the source, the sequence number, the total size, and the piece size. If a retry used different boundaries, its pieces would contradict that description while claiming to belong to the same message, and the receiver could not tell that from an attack. Because retries are byte-identical, a piece either fits what is being assembled or is rejected.
Send-Credential(w, m, N, Z)
w the stateless writer and its reusable datagram · m the retained message · N its size in bytes · Z the bytes carried per piece
- if N + headers ≤ datagram limit
- return Send-Whole(w, m)
- F = ⌈N / Z⌉ // checked arithmetic
- for i = 1 to F
- Encode-Piece(w.datagram, m, i, Z) // borrowed from the retained message
- if not Send(w.datagram)
- return pending // m stays retained; the next attempt restarts at piece 1
- return sent
Line 5 encodes into one datagram buffer that is reused for every piece, and takes the bytes by borrowing a range of the retained message rather than copying it. No list of pieces is built.
On the receiving side the rules are those of Part 3 with the same ordering discipline: a piece from the wrong route, with impossible geometry, claiming an oversized message, or contradicting the assembly already in progress is rejected before any storage is reserved. A duplicate is ignored. When the last piece lands, the assembled bytes are handed to authentication.
A message that assembles but fails its proof
Completing the assembly is not the same as the message being accepted — the proof inside it may be invalid. The high-water mark that stops old pieces from occupying storage advances only once authentication has accepted the message, so a peer whose proof was rejected can send it again and be heard.
Cost
One buffer and one receipt bitmap, both sized at startup. A send attempt is O(N) in encoded bytes with no temporary collection; receiving a piece is O(B) for its new bytes and O(1) to check receipt, and allocates nothing before the completed message is decoded.
Authorization
Authorizing an endpoint
a participant joins, or an endpoint tries to publish, subscribe, or relay
Once per join and once per endpoint, at match time — not per sample.
Authentication establishes who a peer is. It says nothing about what that peer is allowed to do. That comes from a separate signed document listing grants: for a given subject, over a given period, on given domains, which topics it may publish, subscribe to, or relay.
The document is XML and arrives signed. Once its signature and schema have been checked it is converted, once, into an ordered list of rules, because matching a request against XML at decision time would be both slow and a place for the parse to disagree with itself between calls.
Order is preserved exactly as written, because the decision is the first applicable grant, not the best or most specific one. Reordering rules during conversion would change what the document means.
Authorize(policy, subject, now, request)
policy the grants in document order · subject the name from the authenticated certificate · now the decision time · request what is being attempted — join, publish, subscribe, or relay
- for each grant g in policy // document order
- if not Subject-Matches(g.subject, subject)
- continue
- if now < g.valid-from or now > g.valid-until
- continue
- for each rule r in g.rules // document order
- if Rule-Matches(r, request)
- return r.effect // allow or deny, whichever came first
- return g.default-effect
- return deny
The subject comes from the authenticated certificate and from nowhere else. A peer announces plenty about itself during discovery, and none of it may be used here — a name a peer chose for itself is exactly the input an attacker controls.
What an absent condition means
A rule may name topics, and optionally partitions and tags. When one of those optional conditions is simply absent from the document, it is not treated as "matches anything". It means different things depending on whether the rule allows or denies, and both readings are the narrow one for allow and the wide one for deny.
Worked example
| Condition | In an allow rule | In a deny rule |
|---|---|---|
| No partitions named | matches only the default partition | matches every partition |
| No tags named | matches only an endpoint with no tags | matches every endpoint |
Read the table as one rule: an omission always resolves toward refusing. An allow rule that forgot to mention partitions grants the least it could have meant; a deny rule that forgot to mention them denies the most. Collapsing the absent case into an empty list would make both of these grant more than the document says.
Matching a name
Topic and partition names may be patterns. The matching is the standard
filename-style kind, case sensitive, with one detail that matters here:
* does not cross a /. A grant for
robot/* covers robot/arm but not
robot/arm/joint, so a topic hierarchy cannot be widened by
accident. Matching runs over the bytes of the request and the pattern
directly, building no intermediate string.
Cost
Conversion is done once per document, checking each capacity before it appends so an oversized document fails closed rather than partly. A decision walks grants and rules in order until one matches, which is bounded by the document's declared limits and happens when an endpoint is matched rather than when a sample is published.
Authorization
Endpoint data tags
a secure endpoint announcement is built or read
Once per secure endpoint announcement.
The permissions rules in the previous section can condition on tags attached to an endpoint — name and value pairs saying what this endpoint is for. Those tags travel with the endpoint's announcement so the other side can evaluate its own rules against them.
Two constraints make this more than another codec.
Mandatory, including when empty
The tag parameter is required on the secure announcement topics even when an endpoint has no tags. An absent parameter and an empty list are different: absent means this announcement did not come from the secure path at all, and the rules of Part 8's authorization section then apply an absent condition rather than an empty one.
Forbidden on the ordinary path
The same tags must never appear on ordinary, unprotected discovery. Tags feed authorization decisions, and tags that arrive unauthenticated are tags an attacker chose. So the secure announcement codec and the ordinary one are separate compositions rather than one codec with a flag — a flag can be set wrongly, and the ordinary path here simply has no code that can encode or read them.
Cost
O(B + T) for the encoded extent and tag count, with the count checked against its configured limit before any storage is reserved. The ordinary announcement path carries no tag storage and no security dispatch at all.
Next: key material
Turning one shared secret into keys, without any key ever travelling as a key.
These descriptions follow the implementations in Tin DDS and Retina. Evaluation access and the full algorithm notes are available from engineering@tinverse.com.