Ouroboros Tutorial 07

From Ouroboros
Jump to navigation Jump to search
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 OAP request and response of the enrollment of uni-3, with their sizes on the wire.

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.

The keys that come out of the key exchange. Both sides compute them; the response carries what the client needs to compute them too.

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.

The tree of keys for the packets of a flow, and the key selector that tells the receiver which key encrypted a packet.

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 0

The 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.

Two re-keys during a ping of 25 seconds. The epoch in the key selector goes up and the counter starts over; the previous keys stay for receiving until the next re-key.

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.

Notes