Part 9 of 14
Key material
Two participants that have authenticated each other share one secret and nothing else. Turning that into keys for protecting traffic is where most of the security machinery lives, and the governing rule is that no key ever travels as a key. What travels is material, derived at both ends, or material wrapped under a key that was itself derived.
The second rule is that installing new material is transactional. A peer can send a key at any moment, and material that is rejected must leave the previous material working — a participant that drops its keys on receiving a malformed one has been disconnected by anybody who can send it a packet.
Derivation
Material from the handshake
authentication completes
Once per authenticated peer.
The handshake leaves both sides holding a shared secret and the two challenges that were exchanged. The first channel — the one used to carry every later key — is keyed from those.
Keeping only the shared secret is not enough. The two directions are not symmetric: one side initiated and one responded, and each must derive the key the other will use to send. So the two challenges are combined in a fixed, role-specific order.
Derive-Bootstrap(peer)
peer the authenticated peer, holding the shared secret and both verified challenges
- d1 = Hash(challenge1 · challenge2) // fixed 80-octet input, this order
- d2 = Hash(challenge2 · challenge1) // the other order, for the other direction
- m1 = Hmac(peer.secret, d1)
- m2 = Hmac(peer.secret, d2)
- return material(m1, m2) // into erasing storage, never returned as bytes
Lines 1 and 2 differ only in the order of two values. That is what makes the two derived keys different, and it is why the specification is exact about which challenge goes first for which role: two implementations that disagree here derive keys that do not match, and every message fails to authenticate for reasons neither can see.
Cost
Two fixed-size hashes and two fixed-size keyed hashes, copying 64 secret bytes into storage that erases itself. Once per authenticated peer, and nothing on the message path.
Generation
Generating fresh material
an association is created or its keys are rotated
Once per association, plus once per rotation.
Beyond the bootstrap channel, each association uses freshly generated material rather than more derivation from the handshake. Generating it is short, and the interesting part is the identifier that comes with it.
Each generation carries a revision number so that a peer can tell a newer generation from an older one — that is what makes the replacement rule in the section on admitting remote material work. A revision that wrapped around would make two generations indistinguishable, so the counter does not wrap: when it reaches its maximum the factory becomes terminal and generates nothing further.
Generate-Material(f, width)
f the factory, holding the next revision and whether it is terminal · width 16 or 32 octets, from the transform
- if f.terminal
- return exhausted
- Fill-Random(salt, sender-key, sender-id, width)
- if receiver-specific enabled
- Fill-Random(receiver-key, receiver-id, width)
- if any identifier == 0
- return error // zero is reserved for "no key"
- m = Construct(f.revision, salt, keys, ids)
- if m == error
- return error // revision not spent
- if f.revision == maximum
- f.terminal = true
- else
- f.revision = f.revision + 1
- return m
Lines 8 to 14 spend the revision only after the material is validly constructed, which is the same discipline as the identifier in Part 1's table admission. A failed construction that had already advanced the revision would leave a gap, and a peer applying the strictly-newer rule would then reject the next legitimate generation for being older than a revision that never existed.
Encoding
The key material record
material is wrapped for sending, or unwrapped on receipt
Once per key exchange.
Material is a transform identity, up to three key identities, and two or three secret sequences. Those fields have to travel together and be validated together — a transform saying 32-octet keys alongside a 16-octet secret is not something to reconcile at the point of use.
So there is one type that owns all of it, one constructor that validates the combination, and no way to build a partial one. Both the local generator and the wire decoder go through that same constructor, so a remote peer cannot produce a combination the local side could not.
The encoded extent is one of four exact sizes, decided by the transform and whether receiver-specific keys are present. Knowing the extent before writing is what allows a short output buffer to be refused before anything is written.
The type releases metadata, its canonical encoding, or a derivation object — never a slice of a secret. That is the same rule as Part 9's session keys, applied one level up: there is no accessor that returns key bytes, so no caller can log or copy them.
Cost
O(k) for at most 96 secret octets, in inline storage. Decoding is one linear pass that caps each sequence before slicing it, requires the input to be entirely consumed, and copies each secret exactly once into its final owner.
Derivation
Deriving a session key
a handshake completes, and again whenever the session advances
Once per session per direction — not per message.
The handshake leaves both sides holding the same secret material. That material is not used to encrypt anything directly. Keys are derived from it for each session, so that the key protecting messages changes over time while the material behind it does not have to.
Derivation is a keyed hash over three things joined in order: a fixed label, a salt, and the session number. The label is what separates the two directions — one for messages a participant sends, another for messages it receives — so the same shared material produces different keys each way. Without that separation, a message a participant sent could be replayed back at it and would decrypt.
Derive-Session-Key(role, salt, master, session, width)
role which direction the key is for, contributing its fixed label · salt, master the material the handshake agreed · session the session number · width the cipher's key size
- if |salt| ≠ width or |master| ≠ width
- return error // widths must match the chosen cipher
- h = Hmac-Sha256(master, role.label · salt · session)
- k = first width octets of h
- cipher = Activate(k)
- Erase(h)
- return cipher // a cipher, never the key bytes
Why the key bytes are never returned
Line 7 returns a cipher rather than a key. There is no operation anywhere that hands back the derived bytes, so no caller can log them, copy them into a structure that outlives them, or pass them somewhere they are not erased. The storage holding the intermediate result erases itself when it goes out of scope, and line 6 does it explicitly as well.
Why the whole session number is used
Line 3 uses the complete session number, not a truncation of it. Two different sessions have to produce two different keys; if part of the number were dropped, two sessions differing only in the dropped part would share a key, which is the failure the session number exists to prevent.
Cost
Setting up copies at most 32 bytes of salt. Rotating to a new session is one hash over a fixed-length input and one cipher setup. No sample storage is allocated and no payload is copied, so key rotation does not interrupt publishing.
Installation
Admitting remote material
a peer sends key material
Once per key exchange and per rotation.
A peer can send material at any time. Deciding whether to install it has two tests, and then a third problem that neither test solves.
The tests are freshness: the message must be newer than the last one accepted for this association, and the material's revision must be strictly newer than the installed one. Together they stop a recorded exchange from being replayed to reinstate a key an attacker has had time to work on.
The remaining problem is that passing both tests does not mean the material will work. Building a session from it can still fail, and by then the old material has been replaced — leaving an association with no working key, caused by input that was well-formed enough to pass every check.
So admission returns a permit rather than installing. The permit holds the candidate and can be discarded; nothing changes until it is committed.
Admit-Material(a, seq, m)
a the association, with its accepted message sequence and installed revision · seq the message sequence · m the candidate material
- if seq ≤ 0 or seq ≤ a.accepted
- return stale
- if m.revision == 0 or m.revision ≤ a.revision
- return stale
- return permit(m, seq) // a owns nothing new yet
Install(a, permit)
a the association · permit the result of the admission above
- session = Build-Session(permit.material) // the step that can still fail
- if session == error
- Erase(permit) // a keeps its working material
- return error
- a.material = permit.material
- a.accepted = permit.seq
- a.revision = permit.material.revision
- a.session = session // all four, or none
Line 3 erases the candidate rather than dropping it. A rejected key is still a key, and leaving it in memory to be reclaimed at some later point is the kind of residue that key material is specifically supposed not to leave.
Lines 5 to 8 move together. Installing the material but not advancing the accepted sequence would let the same message be applied twice; advancing the sequence without installing would make the peer's next legitimate message look stale.
Association
Activating a participant
authentication completes for a peer
Once per peer.
Authentication produces identity and a shared secret. It does not produce the protected channel, and it does not produce the key that secures ordinary application traffic. Both are built here, in one step that either completes or leaves the peer unassociated.
Activate-Participant(peer)
peer the authenticated peer
- boot = Derive-Bootstrap(peer)
- local = Generate-Material(factory, width)
- out = Activate-Outbound(boot); in = Activate-Inbound(boot)
- registry = New-Endpoint-Registry(capacity)
- channel = New-Volatile-Pair(peer) // the reliable protected channel
- token = Wrap(local)
- slot = Reserve-First-History-Slot(channel)
- Move(token, slot)
- return Release-Association(peer, out, in, registry, channel)
Line 9 is the only line that makes any of this visible. Everything before it can fail, and a failure at any point leaves the peer exactly as authentication left it: known, but with no association and therefore no traffic.
Line 7 reserves the history slot before line 8 moves the token into it, which is the same ordering as everywhere else. The token is this participant's own fresh key material, and having generated it but having nowhere to put it would mean a key exists that the peer will never receive — and the revision has already been spent.
Sending the token, repeatedly
The token sits in the channel's history and is announced on a timer, like the discovery announcements of Part 1 and for the same reason: the peer may not be listening yet. Each announcement protects the retained token and its accompanying reliability record into one reusable buffer, so repeating costs no allocation.
On receipt, the peer is selected from the message header, the protected record is authenticated once, and only then is its content classified as data or reliability control. Authenticating before classifying is what keeps an attacker from reaching the reliability state machine at all.
Association
Activating a matched endpoint
an endpoint key token arrives for a registered match
Once per matched endpoint pair, plus rotations.
Participant-level material protects the channel. Application traffic between a particular writer and a particular reader uses material for that pair, and a token carrying it names the two endpoints.
Those names come from the network. The token says which writer and which reader it is for, and an attacker writes whatever it likes there.
Activate-Endpoint(reg, token)
reg the registry of authenticated endpoint matches · token the arriving endpoint key token
- if token.participant ≠ the authenticated peer's prefix
- return reject // a peer may only speak for its own endpoints
- route = Derive-Route(token.writer, token.reader) // derived, not supplied
- h = Find-Exact(reg, route)
- if h == nil
- return reject // not a match this participant registered
- permit = Admit-Material(h, token.seq, token.material)
- if permit == stale
- return reject
- return Install(h, permit)
Line 1 is the check that makes the rest safe. An authenticated peer is allowed to distribute keys for endpoints it owns, and for nothing else. Without this, any authenticated participant could install material for any other participant's endpoint pair.
Line 3 derives the route rather than accepting one. Letting the token carry both a pair of endpoint names and a route would allow the two to disagree, and whichever the implementation trusted would be the one an attacker sets.
Line 4 requires an exact existing match. A token for a pair that was never matched is not a reason to create the match — matching is discovery's decision, made under the rules of Part 1, and key exchange does not get to add to it.
Bounds
The endpoint registry
an endpoint match is registered, looked up, or removed
Once per match; a lookup per arriving endpoint token.
The registry holds those matches. It is a fixed array of slots, allocated when the association is built, with a compiled ceiling of 64 and a configured active capacity at or below it.
A fixed array with a linear scan looks like the wrong structure until the threat is named. The keys are endpoint identifiers chosen by the remote peer. A hash table would let that peer choose them to collide, turning every lookup into a scan anyway — while also having allowed the table to grow to get there. A fixed array is a scan in every case, and the number scanned is a number the deployment chose.
Register-Match(reg, pair)
reg the slot array and its active capacity · pair the writer and reader being registered
- for i = 1 to reg.capacity
- if reg.slot[i] is occupied and reg.slot[i].pair == pair
- return duplicate
- if reg.occupied == reg.capacity
- return full
- h = Build-Handle(pair) // before taking the slot
- if h == error
- return error
- reg.slot[First-Empty(reg)] = h
- reg.occupied = reg.occupied + 1
- return ok
Removal clears the slot and decrements the count, and a later registration reuses it without allocating. So a system that matches and unmatches endpoints for hours holds exactly the storage it held at startup.
Cost
Registration, lookup and removal are each O(c) over the configured capacity, at most 64. One construction-time allocation, and none afterwards.
Association
A message addressed to nobody
a protected message from a remote writer names no particular reader
Once per such message.
A writer may send one message meant for every matched reader rather than addressing each individually. The record then names the writer and leaves the reader unspecified, and each receiving participant delivers it to all of its readers matched to that writer.
That collides with per-pair keys. The material is per writer-and-reader pair, and a message that does not name a reader does not say which material protects it.
The answer is that the writer's material is shared across the readers matched to it, so a single association covers the fanout, and one authentication serves every delivery. The alternative — each exact pair owning separate material for a message the writer sent once — would require the receiver to try each in turn, which means attempting authentication repeatedly with keys that are expected to fail. That is both wasteful and exactly the shape of operation whose timing tends to leak information.
Cost
One authentication per message rather than one per matched reader, followed by delivery to each. The association is found once, from the writer named in the record.
Next: protecting messages
What a protected message looks like, and what must be true before its bytes are believed.
These descriptions follow the implementations in Tin DDS and Retina. Evaluation access and the full algorithm notes are available from engineering@tinverse.com.