Part 10 of 14
Protecting messages
With keys installed at both ends, every message can be encrypted, signed, or both. This is the part that runs on every message rather than once per peer, so it is written to allocate nothing and to copy the payload as few times as the cipher allows.
One ordering constraint governs all of it. Nothing derived from a message may be used, stored, or returned before the message has been authenticated. That sounds obvious and is violated easily: the header has to be read to know which key to use, so the reader is already acting on unverified bytes before it can verify anything.
Framing
Framing a protected message
a protected message is built or received
Once per protected message, in both directions.
A protected message carries a fixed header before its contents and a footer after them. The header names which key is in use, which algorithm, and where in the session this message sits; the footer carries the tag that proves the message has not been altered.
The header is exactly 20 bytes with every field at a fixed position: a three-byte key revision, one algorithm byte, an opaque key identifier, the session number, and the value that makes each message's encryption input unique. Its fields account for every one of those bytes exactly once, and the numeric ones are big-endian regardless of the machine.
Two refusals are worth pulling out, because both are about what must not happen before validation finishes.
Read-Protected-Header(b)
b the borrowed input bytes
- if |b| < 20
- return error // no provider chosen, no session touched
- h.revision = Read-24(b, 0)
- h.algorithm = b[3]
- if h.algorithm not in supported
- return error // an unknown algorithm selects nothing
- h.key-id = Read-Opaque(b, 4)
- h.session = Read-32(b, 8)
- h.suffix = Read-64(b, 12)
- return h
Line 1 refuses a header that is short. A partial header still has bytes at the positions the algorithm and session are read from, and using them would select a cipher and a replay session from whatever the truncation left behind.
Line 5 refuses an algorithm the implementation does not know. There is no default to fall back to: the algorithm decides how the rest of the message is interpreted, so guessing means interpreting an attacker's bytes under a scheme they chose.
Only once the header is complete and the footer's tag has verified does anything else happen — and the replay check described below runs after that, for the reason given there.
Framing
Framing the ciphertext
a protected message is built or read
Once per protected message.
Between the header and the tags sits the protected content, introduced by a four-byte length. The length is what lets the reader find where the content stops and the tags begin.
It is also, again, a number from the sender. Two things are checked before it is used: that it is within the configured capacity, and that adding the prefix width to it does not overflow. The second matters because a length near the maximum plus a four-byte prefix wraps to a small number, and the truncation check that follows would then compare against a value smaller than the content it is validating.
Read-Content(b, capacity, exact)
b the borrowed input · capacity the configured maximum · exact whether the content must consume the whole input
- if |b| < 4
- return error
- n = Read-32(b)
- if n > capacity
- return error
- extent = Checked-Add(4, n)
- if extent == overflow or extent > |b|
- return error
- if exact and extent ≠ |b|
- return error // trailing bytes are not ignored
- return (borrow of n bytes, extent)
Line 11 returns the extent as well as the content, so the caller can find the tags that follow without recomputing where they start from a number it would have to re-derive.
Cost
Reading is O(1) and borrows rather than copies. Writing copies the ciphertext only when the cipher did not already produce it in its final position; when it did, only the four-byte prefix is written.
Cipher
Calling the cipher
anything is encrypted, decrypted, or authenticated
Once per protected message.
The cipher itself is not implemented here. It is a standard library, used through a thin wrapper whose job is entirely about what surrounds the call rather than the cryptography.
When an association starts, the wrapper creates one context for sending and one for receiving, selects the AES algorithm, and retains the key. Each message resets the appropriate context with only that key and the message's nonce. It does not create a new context or select the algorithm again on the message path.
The wrapper has one rule that is easy to state and easy to get wrong: plaintext that has not been authenticated must never become visible.
Decrypt(ctx, nonce, aad, input, out, tag)
ctx the cipher context and its retained key · nonce this operation's unique value · aad data authenticated but not encrypted · input, out the ciphertext and caller-owned output · tag the tag to verify against
- if key width unsupported or |input| overflows or |out| too small
- return error // before the provider is touched
- Reinitialize(ctx, nonce)
- Feed-Aad(ctx, aad) // authenticated, not transformed
- Transform(ctx, input, out) // out now holds unverified plaintext
- Set-Expected-Tag(ctx, tag)
- if not Finalize(ctx) // this is where the tag is checked
- Erase(out) // the plaintext must not survive a failed check
- return error
- return ok
Line 5 produces plaintext and line 7 is where it is found out to be forged. There is no way round that ordering — the tag covers the whole ciphertext, so it cannot be checked until the whole ciphertext has been processed. What line 8 does is make the window harmless by erasing the buffer before returning.
Without it, the caller holds a buffer of attacker-influenced bytes and an error code. Every caller then has to remember not to look at the buffer, and one that logs it for diagnosis has built an oracle.
Large and fragmented samples
The cipher can process a long contiguous range in one pass, but a protected sample is not decrypted one network fragment at a time. Its authentication tag covers the complete ciphertext. The sender therefore protects the payload before RTPS splits it; the receiver reassembles the protected bytes and then authenticates and decrypts them. The final reassembly storage can be used directly, avoiding another payload-sized staging buffer without making partial plaintext visible.
Cost
O(a + n) for the authenticated and encrypted extents, into caller-owned output. Two contexts and their AES bindings are created when the association activates and reused per operation. When the data is already in its final position the in-place operation is used, so no copy is made merely to encrypt.
Transform
Protecting one payload
a sample's payload is protected or unprotected
Once per sample on a protected endpoint.
Putting the pieces together for one sample. There are two modes and the difference is whether the payload is hidden or merely proven: encryption replaces the payload with ciphertext, while authentication leaves it readable and attaches a tag.
The second is what you want for data that is not secret but must not be forged — a robot's position that any observer may see but nobody may fake.
Unprotect-Payload(a, b, out)
a the endpoint association, with its session and replay window · b the arriving protected bytes · out caller-owned output
- h = Read-Protected-Header(b)
- if h == error or h does not match a
- return error
- if h.algorithm is encrypting
- (body, extent) = Read-Content(b, a.capacity, false)
- footer = Read-Footer(b from extent, a.receivers)
- else
- body = the clear payload; footer = Read-Footer(b after body, 0)
- if not Verify(a.session, h, body, footer.common, out)
- return error // out already erased by the cipher wrapper
- if Admit-Fresh(a.window, h.session, h.suffix) ≠ accepted
- Erase(out)
- return error // authentic, but a replay
- return ok
Line 9 before line 11 is the ordering the last section of this part exists to explain. Line 12 erases again for the replay case: those bytes are authentic plaintext, which makes them more tempting to return than forged bytes, and returning them is exactly what accepting a replay means.
Cost
O(1) framing and admission around O(n) cipher work. Encrypting into the final position avoids a second copy of the payload. The authenticate-only mode instantiates no per-receiver storage at all, since it carries only the common tag.
Framing
The three-record envelope
a whole submessage rather than a payload is protected
Once per protected submessage.
Protecting a payload hides the sample. Protecting a whole submessage hides which endpoint it was for and what kind of record it was, and it is how the key-distribution traffic of Part 11 travels.
A protected submessage is three records: a prefix carrying the transform header, a body carrying the protected content, and a postfix carrying the tags. They must appear in that order and nothing may sit between them.
Decoding walks the three declared extents once and rejects wrong order, wrong flags, a zero or truncated length, or trailing bytes — before returning anything. Only after the triplet is structurally whole is it authenticated, and only after that is the record inside it classified.
That three-stage order — structure, then authentication, then meaning — is what keeps a malformed or forged message from reaching the code that interprets record kinds.
Cost
One preflight of the whole extent, then the cipher writes its output directly into the range the preflight identified, so the three headers are written around ciphertext that never moves.
Selection
Choosing which key to try
a protected submessage arrives
Once per protected submessage, before any authentication.
Here is the ordering problem stated at the top of this part, in its sharpest form. To authenticate a message you need the key. To know which key, you read the identifier in the header. The header is not authenticated yet.
There is no way to avoid acting on unverified bytes, so the question is how to act on them harmlessly. The answer is that the identifier may be used only to look something up, never to create anything.
Select-Association(p, msg)
p the participant and its registered associations · msg the arriving protected submessage
- t = Parse-Triplet(msg)
- if t == error
- return reject
- for each association a in p.writer-associations
- if a.key-id == t.header.key-id
- return a
- for each association a in p.reader-associations
- if a.key-id == t.header.key-id
- return a
- return reject // no association: nothing is created for an unknown id
Line 10 is the whole safety property. An unknown identifier finds nothing, and finding nothing is the end — no association is created, no storage reserved, no state changed. An attacker sending a million distinct identifiers gets a million scans of a bounded array and nothing else.
Both roles are searched because an identifier alone does not say whether the sender was acting as a writer or a reader. Searching only one would reject legitimate traffic whose direction happened to be the other one.
Sessions
A nonce that is never reused
anything is protected for sending
Once per outbound protected message.
The cipher requires that no two operations under one key use the same nonce. This is not a performance guideline. Reusing a nonce under AES-GCM lets an observer recover the relationship between the two plaintexts and, worse, recover the value used to forge tags — after which they can produce messages that authenticate.
The nonce here is the session number and a counter. Both are finite, so the session must roll over before either can repeat, and there is a limit on how much data one session key may protect.
Protect(s, payload, out)
s the outbound session — its key deriver, session number, next counter, and block budget · payload the bytes to protect · out caller-owned output
- if Encoded-Size(payload) > |out|
- return error // no identity consumed
- blocks = ⌈|payload| / 16⌉
- if blocks > s.limit
- return error // one message may not exceed a whole session
- if s.remaining < blocks
- Rotate(s) // next session number, counter back to zero
- if s.counter == maximum
- Rotate(s)
- if s.session == maximum
- s.terminal = true; return exhausted // permanently, from now on
- nonce = (s.session, s.counter) // captured
- s.counter = s.counter + 1 // advanced BEFORE the cipher runs
- s.remaining = s.remaining − blocks
- return Encrypt(s.cipher, nonce, payload, out)
Lines 12 to 15 are the heart of it. The counter advances before the cipher is called, not after, so a cipher failure has already consumed the nonce. That looks wasteful and is the only safe order: if the counter advanced only on success, a caller that retried after a failure would encrypt different bytes under the same nonce, which is precisely the catastrophe.
Lines 1 to 5 are the opposite case. Neither an output buffer that is too small nor an over-large payload has reached the cipher, so no identity has been spent and the session is untouched.
Line 11 makes exhaustion permanent. Once both counters are spent, every later operation returns the same failure and the cipher is never invoked again. Wrapping round to reuse the first nonce would be worse than refusing to send.
Both directions
The inbound side is the mirror and has no such counter, because it does not choose nonces — it reads them. What it owns instead is the replay window below, which decides whether a nonce it has been given is one it has already seen.
Cost
O(1) admission and rotation around O(n) cipher work. Rotation costs one key derivation and one context activation, and happens once per session rather than per message. Nothing on this path allocates.
Freshness
The replay window
a protected message has passed its cryptographic check
Once per protected message received. This is the security step that runs on every message.
A signature proves a message was written by someone holding the key. It does not prove the message is not one that was recorded an hour ago and sent again. An attacker who cannot read or forge traffic can still record it and repeat it — replaying "open the gripper" is as effective as composing it.
Refusing repeats needs a record of what has been seen. Keeping every number ever received is unbounded, so instead a window is kept: the highest number seen, and a bitmap of which of the positions just below it have already been used. Anything older than the window is refused for being too old; anything inside it is refused if its bit is already set.
Admit-Fresh(w, session, n)
w the replay window for one peer, endpoint, and path · session the key epoch · n the message's number within it · W the configured window size
- if w.session == nil or session > w.session
- w.session = session; w.max = n; w.bits = {0}
- return accepted // a new session starts its own history
- if session < w.session
- return obsolete-session
- if n > w.max
- d = n − w.max
- if d ≥ W
- w.bits = empty // the whole window has moved past
- else
- w.bits = w.bits shifted older by d
- w.max = n
- Set(w.bits, 0)
- return accepted
- offset = w.max − n
- if offset ≥ W
- return too-old
- if Is-Set(w.bits, offset)
- return duplicate
- Set(w.bits, offset)
- return accepted
Worked example
A window of 8 positions. Messages arrive in this order, each already past its cryptographic check.
| Arrives | Result | Window afterwards |
|---|---|---|
| 20 | accepted | highest 20; seen: 20 |
| 22 | accepted | highest 22; seen: 20, 22 |
| 21 | accepted | highest 22; seen: 20, 21, 22 — reordered, not replayed |
| 21 | duplicate | unchanged |
| 14 | too old | unchanged — 8 positions behind 22 |
| 40 | accepted | highest 40; seen: 40 only — the shift of 18 cleared the window |
| 22 | too old | unchanged, though it was accepted three rows earlier |
The last row is the cost of a fixed-size window, and it is deliberate. Once 40 has been seen, 22 is outside what can be remembered, and a message that cannot be proved fresh is refused rather than assumed fresh. A sender that reorders by more than the window will lose messages, which is why the window is configurable.
Messages that arrive out of order
Line 15 onwards accepts an older number as long as it is inside the window and has not been seen. Requiring strictly increasing numbers would reject perfectly good traffic that the network happened to reorder.
Why it runs after authentication, not before
The ordering constraint that matters most is not inside this procedure but around it. It runs only after the message's authentication has succeeded. If it ran first, the numbers in the header would be attacker-controlled, and sending one forged message with a very large number would slide the window far past where the real peer is — after which every genuine message is refused for being too old. That is a denial of service requiring no key at all, and the only thing preventing it is the order of the two steps.
Every rejection above happens before any assignment, so a message that is refused leaves the window exactly as it was.
Cost
Accepting or rejecting within the current session is O(1). Advancing shifts a fixed number of machine words. The whole structure is 128 bytes plus a few scalars and never allocates, so the memory cost does not depend on how much traffic arrives or how badly it is reordered.
Next: key distribution
A small reliable protocol inside an encrypted envelope, carrying secrets that must be erased on time.
These descriptions follow the implementations in Tin DDS and Retina. Evaluation access and the full algorithm notes are available from engineering@tinverse.com.