Ouroboros Tutorial 07

In Tutorial 06, we used certificates to authenticate flows. In this tutorial, we look at the messages that carry them: the requests and responses of the Ouroboros Allocation Protocol (OAP). We use the capture of Tutorial 06, and then look at how the keys of an encrypted flow are renewed while it runs. At the end, there is an overview of the options of the security configuration.
We use the same setup and debug build as in Tutorial 06, including -DDEBUG_PROTO_OAP=ON. For the part about renewing keys, we also add -DOAP_REKEY_TIMER=10, so that it happens every 10 seconds instead of every 2 minutes.
The exchange
Every flow allocation, of an application or of an IPCP, carries one OAP request from the client IRMd to the server IRMd, and one OAP response back. In Tutorial 06, uni-3 enrolled with unicast-layer, and both sides authenticated each other. With DEBUG_PROTO_OAP, the IRMd prints each OAP message it sends (-->) and receives (<--). This is the request, as uni-3's side of the IRMd sends it:
==110158== irmd/oap(PP): OAP_HDR [53e933c8d7b1197c @ 2026-09-27 09:28:05 (UTC) ] -->
==110158== irmd/oap(PP): crt: [394 bytes]
==110158== irmd/oap(PP): Key Exchange Data: [91 bytes]
==110158== irmd/oap(PP): Cipher: aes-256-gcm
==110158== irmd/oap(PP): KDF: HKDF-sha256
==110158== irmd/oap(PP): Digest: sha256
==110158== irmd/oap(PP): Data: <none>
==110158== irmd/oap(PP): Rsp Tag: <none>
==110158== irmd/oap(PP): Signature: [71 bytes]and this is the response of the server:
==110158== irmd/oap(PP): OAP_HDR [53e933c8d7b1197c @ 2026-09-27 09:28:05 (UTC) ] [sealed] -->
==110158== irmd/oap(PP): Sealed block: [493 bytes] on wire
==110158== irmd/oap(PP): crt: <sealed>
==110158== irmd/oap(PP): Key Exchange Data: [91 bytes]
==110158== irmd/oap(PP): Cipher: aes-256-gcm
==110158== irmd/oap(PP): KDF: HKDF-sha256
==110158== irmd/oap(PP): Digest: sha256
==110158== irmd/oap(PP): Data: <sealed>
==110158== irmd/oap(PP): Rsp Tag: [32 bytes]
==110158== irmd/oap(PP): Signature: <sealed>The request carries the certificate of uni-3, and a public key for the key exchange: the client proposes an ephemeral key exchange (ECDHE on the P-256 curve), the cipher and the key derivation function (KDF) for the flow, and signs the request with the key of its certificate (the Digest is the hash for that signature). The response carries the server's own public key for the key exchange, and a response tag. The server's certificate and signature are sealed: encrypted with a key that only the client can compute. Anyone watching the wire can see who the client is, but not who the server is.

The request on the wire
On lo, the request travels in a PoA FLOW_REQ, as in Tutorial 04. After the 48-byte management header and the 32-byte hash of unicast-layer comes the OAP request:
09:28:05.543825 76:0f:92:78:e2:8a (oui Unknown) > 32:30:3c:bd:27:38 (oui Unknown), ethertype Unknown (0xa000), length 691:
0x0000: 0000 02a0 7f01 0000 0000 0000 0100 0000 ................
0x0010: 0000 0000 0000 0000 0000 0000 0000 0003 ................
0x0020: e800 0000 0000 0000 0000 0007 d000 01d4 ................
0x0030: c000 0102 7023 958d a531 0c98 d039 8d50 ....p#...1...9.P
0x0040: acfc 3fac 986f 44e7 bb8c 73e4 31a6 157f ..?..oD...s.1...
0x0050: 252d e4dd 7b53 e933 c8d7 b119 7c02 a5c2 %-..{S.3....|...
0x0060: dc23 7662 9d18 d923 6ec6 aea3 de03 8502 .#vb...#n.......
...
0x0280: 544a 0002 2077 2ef8 6919 f2d2 5223 11d0 TJ...w..i...R#..
0x0290: 797f 903c 3ef7 1019 932d 226c 8bb2 a067 y..<>....-"l...g
0x02a0: 756c 3e2b 9f ul>+.The OAP request starts with a fixed header of 36 bytes, followed by the variable parts. The offsets are those of the dump:
offset size field bytes value 0x0055 16 id 53 e9 33 c8 d7 b1 19 7c ... 0x0065 8 timestamp 18 d9 23 6e c6 ae a3 de 2026-09-27 09:28:05.543191 UTC 0x006d 2 cipher 03 85 AES-256-GCM 0x006f 2 kdf 02 a0 SHA-256 0x0071 2 digest 02 a0 SHA-256 0x0073 2 crt_len 01 8a 394 0x0075 2 kex_len 00 5b 91 0x0077 2 data_len 00 00 0 0x0079 394 crt 30 82 01 86 ... certificate of the client (DER) 0x0203 91 kex 30 59 30 13 ... ephemeral P-256 key of the client (DER) 0x025e 71 signature 30 45 02 21 ... ECDSA (DER)
The cipher, KDF and digest are identified by their number in OpenSSL (the NID): 0x0385 is AES-256-GCM, and 0x02a0 is SHA-256. The key exchange length has two flag bits, both zero here: the top bit is set when the key is in a raw format instead of X.509 DER, the next bit when the client, rather than the server, does the encapsulation of a key encapsulation mechanism (KEM), such as ML-KEM. The certificate is the uni-3 certificate of Tutorial 06, in DER (the binary form of the PEM file). The public key is also DER, and the signature is an ECDSA signature over everything before it, including the certificate and the public key. The server checks the signature with the key in the certificate, which shows that the client holds the private key.
The timestamp is the time of the request, in nanoseconds since 1970 (UTC). Together with the ID, it protects the server against replays: it refuses a request with an ID it has seen before, or with a timestamp that is too far off.
The response on the wire
The response comes back in a FLOW_REPLY:
09:28:05.551841 32:30:3c:bd:27:38 (oui Unknown) > 76:0f:92:78:e2:8a (oui Unknown), ethertype Unknown (0xa000), length 719:
0x0000: 0000 02bc 3a02 0000 0000 0000 0300 0000 ....:...........
0x0010: 0100 0000 0000 0000 0000 0000 0000 0000 ................
0x0020: 0000 0000 0000 0000 0000 0000 0000 0000 ................
0x0030: 0000 0002 8c53 e933 c8d7 b119 7c02 a5c2 .....S.3....|...
0x0040: dc23 7662 9d18 d923 6ec6 ef7a ba03 8502 .#vb...#n..z....
0x0050: a002 a000 0000 5b00 0030 5930 1306 072a ......[..0Y0...*
0x0060: 8648 ce3d 0201 0608 2a86 48ce 3d03 0107 .H.=....*.H.=...
0x0070: 0342 0004 703a 8025 974e 602a dd3c 6083 .B..p:.%.N`*.<`.
0x0080: 3575 a41c 9a11 e746 1393 15b6 5012 6ced 5u.....F....P.l.
0x0090: 1efe 9e88 210b 7320 c6bc 68f6 d5a1 c053 ....!.s...h....S
0x00a0: 66f5 153d 2d21 916c b354 d561 e716 1f23 f..=-!.l.T.a...#
0x00b0: 9559 f738 dc44 7ae8 a6b3 f829 a460 b05c .Y.8.Dz....).`.\
0x00c0: 9f04 ca3c 626a 0fb3 1d8e 4527 98d5 3ec7 ...<bj....E'..>.
0x00d0: bec0 0b8e 9b0a 8bf8 ca00 e84d f41f 75dd ...........M..u.
...
0x02b0: 96a9 4f28 6042 345c 9af9 3e62 d957 a4c7 ..O(`B4\..>b.W..
0x02c0: 8c .offset size field bytes value 0x0035 16 id 53 e9 33 c8 d7 b1 19 7c ... 0x0045 8 timestamp 18 d9 23 6e c6 ef 7a ba 2026-09-27 09:28:05.547441 UTC 0x004d 2 cipher 03 85 AES-256-GCM 0x004f 2 kdf 02 a0 SHA-256 0x0051 2 digest 02 a0 SHA-256 0x0053 2 crt_len 00 00 0 0x0055 2 kex_len 00 5b 91 0x0057 2 data_len 00 00 0 0x0059 91 kex 30 59 30 13 ... ephemeral P-256 key of the server (DER) 0x00b4 32 rsp_tag dc 44 7a e8 ... key confirmation 0x00d4 493 sealed 9b 0a 8b f8 ... lengths, certificate, signature, 16-byte tag
The fixed header is the same, but the certificate and data lengths are zero: the real lengths are in the sealed block. The public key is the server's ephemeral key. From the two public keys, each side computes the same secret (ECDHE), and derives the key for the flow from it with HKDF, using the negotiated KDF (SHA-256). That key is bound to a hash of the request and the response, so that neither message can be changed on the way.
The response tag is a key confirmation: it is computed from the new key and the hashes of both messages. When it matches on the client side, the client knows the server has the same key. The sealed block holds the lengths, the data, the server's certificate and its signature, encrypted with AES-256-GCM under a key derived from the key exchange. It ends with a 16-byte tag, which the client checks when it opens the block.

Without encryption, there is no key exchange and nothing to seal: the certificate and signature of the server travel in the clear, and the response tag is a plain hash of the request, so the client knows which request the response is for.
The refused enrollment of Tutorial 06 shows the other end: a request without certificate and without signature (uni-3 had no key to sign with), and a FLOW_REPLY without any OAP data, with the error in its response field (0xfffffc0f = -1009):
09:28:03.507818 76:0f:92:78:e2:8a (oui Unknown) > ba:3f:21:b5:f1:e4 (oui Unknown), ethertype Unknown (0xa000), length 226:
0x0000: 0000 00cf 8d01 0000 0000 0000 0100 0000 ................
0x0010: 0000 0000 0000 0000 0000 0000 0000 0003 ................
0x0020: e800 0000 0000 0000 0000 0007 d000 01d4 ................
0x0030: c000 0100 9f23 958d a531 0c98 d039 8d50 .....#...1...9.P
...
09:28:03.509593 ba:3f:21:b5:f1:e4 (oui Unknown) > 76:0f:92:78:e2:8a (oui Unknown), ethertype Unknown (0xa000), length 67:
0x0000: 0000 0030 cf02 0000 0000 0000 0300 0000 ...0............
0x0010: 01ff fffc 0f00 0000 0000 0000 0000 0000 ................
0x0020: 0000 0000 0000 0000 0000 0000 0000 0000 ................
0x0030: 0000 0000 00 .....Keys for the data
The key from the OAP exchange is not used directly to encrypt the packets of the flow. Each side derives a tree of keys from it: 128 node keys, and from each node key, a series of leaf keys, separately for each direction. A leaf key encrypts at most 220 packets, a node key covers 64 leaf keys, and the whole tree covers 233 packets in each direction.
Each encrypted packet starts with a 6-byte key selector, which tells the receiver which key to use:
4 bits epoch which tree (generation of keys) 12 bits node which node key 32 bits counter packet number within the node; the top bits pick the leaf key
In Tutorial 04, the encrypted ping had selector 0000 0000 0000: epoch 0, node 0, the first packet. Together with the node, the counter also makes the nonce for AES-GCM, so a nonce is never used twice with the same key.

Renewing the keys
The IRMd renews the keys of an encrypted flow regularly: every OAP_REKEY_TIMER seconds (120 by default), and before a tree runs out of node keys. It runs a new OAP exchange over the flow allocator of the Layer, between the same two IRMds. The re-key request ends with a tag computed from a secret that both IRMds derived from the previous exchange, so only the two ends of a flow can renew its keys, and the new key is chained with that secret. The request has no certificate, but it is signed, and the other side checks the signature with the certificate it received when the flow was allocated. A side can also present a new certificate on a re-key, issued for the same name, and the other side keeps that one from then on: a certificate can be renewed without dropping the flow. With our timer of 10 seconds, a ping of 25 seconds shows two re-keys:
$ oping -n oping-server -c 25==112316== irmd(DB): Re-key request sent for flow 8.
==112316== irmd(DB): Delivered re-key seed for flow 7 (gen 1).
==112316== irmd(DB): Re-key completed for flow 8 (gen 1).
==112316== irmd(DB): Delivered re-key seed for flow 8 (gen 1).
==112316== irmd(DB): Re-key request sent for flow 8.
==112316== irmd(DB): Delivered re-key seed for flow 7 (gen 2).
==112316== irmd(DB): Re-key completed for flow 8 (gen 2).
==112316== irmd(DB): Delivered re-key seed for flow 8 (gen 2).The IRMd hands the new keys to the oping processes, which start a new tree. On the wire, the epoch in the key selector goes up, and the counter starts over. These are the last ping before and the first ping after each re-key, with the selector that follows the 16-byte DTP header:
09:51:08.582 request 00 00 00 00 00 09 epoch 0 node 0 counter 9
09:51:08.583 reply 00 00 00 00 00 09 epoch 0 node 0 counter 9
09:51:09.583 request 10 00 00 00 00 00 epoch 1 node 0 counter 0
09:51:09.583 reply 10 00 00 00 00 00 epoch 1 node 0 counter 0
09:51:18.583 request 10 00 00 00 00 09 epoch 1 node 0 counter 9
09:51:18.584 reply 10 00 00 00 00 09 epoch 1 node 0 counter 9
09:51:19.584 request 20 00 00 00 00 00 epoch 2 node 0 counter 0
09:51:19.584 reply 20 00 00 00 00 00 epoch 2 node 0 counter 0The side that started the re-key switches first; the other side keeps sending with the old keys until it receives a packet with the new ones. The receiver keeps the previous keys until the next re-key, so packets that are still underway can be decrypted. With a 4-bit epoch, a flow can renew its keys 16 times before the epoch number is reused, and by then the old keys are long gone.

The security configuration
The sec.conf files of Tutorial 06 accept these options, one per line (# starts a comment):
| Option | Values | Default |
|---|---|---|
| auth | required, optional | client: required, server: optional |
| encryption | none: no key exchange, no cipher, no KDF | encryption on |
| kex | key exchange: prime256v1, secp384r1, secp521r1, X25519, X448, ffdhe2048, ffdhe3072, ffdhe4096; with OpenSSL 3.5: ML-KEM-512, ML-KEM-768, ML-KEM-1024 and the hybrids X25519MLKEM768, X448MLKEM1024, SecP256r1MLKEM768, SecP384r1MLKEM1024 | prime256v1 |
| kem_mode | server, client: which side encapsulates with a KEM | server |
| cipher | aes-128-gcm, aes-192-gcm, aes-256-gcm, chacha20-poly1305 | aes-256-gcm |
| kdf | sha256, sha384, sha512, sha3-256, sha3-384, sha3-512, blake2s256, blake2b512 | sha256 |
| digest | same list as kdf: the hash for the signature | sha256 |
| cacert | path to a CA certificate: the peer's certificate must be issued under this CA | none |
Without a sec.conf, there is no encryption, and the defaults for auth and digest apply. The server takes the stronger cipher and KDF of the two sides, and refuses a client that proposes a weaker key exchange than its own; the client refuses a server that answers with a weaker cipher or KDF than it asked for. A peer that signs with ECDSA must use the same digest as the other side. EdDSA (Ed25519, Ed448) and post-quantum signatures (ML-DSA, SLH-DSA) have their own hash, and do not use the digest option.
With kem_mode=client, the client encapsulates the key with the server's long-term KEM public key, which it finds in client/<name>/kex.srv.pub.pem (or .raw for the hybrids); the server keeps the private key in server/<name>/kex.key.pem. With the default, kem_mode=server, the client sends an ephemeral public key, as with ECDHE, and the server encapsulates.