summaryrefslogtreecommitdiff
path: root/src/irmd/oap
diff options
context:
space:
mode:
Diffstat (limited to 'src/irmd/oap')
-rw-r--r--src/irmd/oap/auth.c558
-rw-r--r--src/irmd/oap/auth.h68
-rw-r--r--src/irmd/oap/cli.c682
-rw-r--r--src/irmd/oap/hdr.c761
-rw-r--r--src/irmd/oap/hdr.h199
-rw-r--r--src/irmd/oap/internal.h119
-rw-r--r--src/irmd/oap/io.c158
-rw-r--r--src/irmd/oap/io.h40
-rw-r--r--src/irmd/oap/srv.c592
-rw-r--r--src/irmd/oap/tests/CMakeLists.txt64
-rw-r--r--src/irmd/oap/tests/common.c717
-rw-r--r--src/irmd/oap/tests/common.h122
-rw-r--r--src/irmd/oap/tests/oap_test.c2109
-rw-r--r--src/irmd/oap/tests/oap_test_ml_dsa.c777
14 files changed, 6966 insertions, 0 deletions
diff --git a/src/irmd/oap/auth.c b/src/irmd/oap/auth.c
new file mode 100644
index 00000000..f70f9df1
--- /dev/null
+++ b/src/irmd/oap/auth.c
@@ -0,0 +1,558 @@
+/*
+ * Ouroboros - Copyright (C) 2016 - 2026
+ *
+ * OAP - Authentication, replay detection, and validation
+ *
+ * Dimitri Staessens <dimitri@ouroboros.rocks>
+ * Sander Vrijders <sander@ouroboros.rocks>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., http://www.fsf.org/about/contact/.
+ */
+
+#if defined(__linux__) || defined(__CYGWIN__)
+ #define _DEFAULT_SOURCE
+#else
+ #define _POSIX_C_SOURCE 200809L
+#endif
+
+#define OUROBOROS_PREFIX "irmd/oap"
+
+#include <ouroboros/crypt.h>
+#include <ouroboros/endian.h>
+#include <ouroboros/errno.h>
+#include <ouroboros/logs.h>
+#include <ouroboros/pthread.h>
+#include <ouroboros/time.h>
+
+#include "config.h"
+
+#include "auth.h"
+#include "hdr.h"
+
+#include <assert.h>
+#include <stdlib.h>
+#include <string.h>
+
+/*
+ * Replay cache: three timestamp-generation hash buckets. A header's bucket
+ * is gen(T) = T / OAP_REPLAY_TIMER, taken mod 3. Staleness bounds a valid T
+ * to generations {G-1, G, G+1} (G is now's generation; a within-slack future
+ * stamp can reach G+1), which are distinct mod 3; the aliasing generation
+ * G-3 is always rejected as too old first. Each bucket is an open-addressed
+ * hash set whose slots are live iff slot.gen == bucket.gen, so a stale bucket
+ * clears in O(1) by bumping its gen. Overflow fails closed (reject), never
+ * evicts, so a flood cannot displace a genuine entry into a replayable state.
+ */
+#define OAP_REPLAY_GENS 3
+
+struct oap_replay_slot {
+ uint64_t gen; /* live iff == bucket gen; 0 = never used */
+ uint64_t ts;
+ uint8_t id[OAP_ID_SIZE];
+};
+
+struct oap_replay_bucket {
+ uint64_t gen;
+ size_t count;
+ struct oap_replay_slot * slots;
+};
+
+static struct {
+ struct auth_ctx * ca_ctx;
+ struct {
+ size_t mask; /* slots per bucket - 1 */
+ size_t cap; /* fail-closed threshold */
+ struct oap_replay_bucket bucket[OAP_REPLAY_GENS];
+ pthread_mutex_t mtx;
+ } replay;
+} oap_auth;
+
+/* FNV-1a over id || ts; the table mask reduces it to a slot index. */
+static size_t replay_hash(const uint8_t * id,
+ uint64_t ts)
+{
+ uint64_t hh = 14695981039346656037ULL;
+ size_t i;
+
+ for (i = 0; i < OAP_ID_SIZE; i++) {
+ hh ^= id[i];
+ hh *= 1099511628211ULL;
+ }
+
+ for (i = 0; i < sizeof(ts); i++) {
+ hh ^= (uint8_t) (ts >> (i * 8));
+ hh *= 1099511628211ULL;
+ }
+
+ return (size_t) hh;
+}
+
+int oap_auth_init(void)
+{
+ size_t m = 1;
+ int i;
+
+ oap_auth.ca_ctx = auth_create_ctx();
+ if (oap_auth.ca_ctx == NULL) {
+ log_err("Failed to create OAP auth context.");
+ goto fail_ctx;
+ }
+
+ while (m < (size_t) OAP_REPLAY_MAX * 2)
+ m <<= 1;
+
+ oap_auth.replay.mask = m - 1;
+ oap_auth.replay.cap = OAP_REPLAY_MAX;
+
+ for (i = 0; i < OAP_REPLAY_GENS; i++) {
+ struct oap_replay_bucket * b = &oap_auth.replay.bucket[i];
+ b->gen = 0;
+ b->count = 0;
+ b->slots = calloc(m, sizeof(*b->slots));
+ if (b->slots == NULL) {
+ log_err("Failed to alloc OAP replay bucket.");
+ goto fail_bucket;
+ }
+ }
+
+ if (pthread_mutex_init(&oap_auth.replay.mtx, NULL)) {
+ log_err("Failed to init OAP replay mutex.");
+ goto fail_bucket;
+ }
+
+ return 0;
+
+ fail_bucket:
+ for (i = 0; i < OAP_REPLAY_GENS; i++)
+ free(oap_auth.replay.bucket[i].slots);
+
+ auth_destroy_ctx(oap_auth.ca_ctx);
+ fail_ctx:
+ return -1;
+}
+
+void oap_auth_fini(void)
+{
+ int i;
+
+ pthread_mutex_lock(&oap_auth.replay.mtx);
+
+ for (i = 0; i < OAP_REPLAY_GENS; i++) {
+ free(oap_auth.replay.bucket[i].slots);
+ oap_auth.replay.bucket[i].slots = NULL;
+ }
+
+ pthread_mutex_unlock(&oap_auth.replay.mtx);
+ pthread_mutex_destroy(&oap_auth.replay.mtx);
+
+ auth_destroy_ctx(oap_auth.ca_ctx);
+}
+
+int oap_auth_add_ca_crt(void * crt)
+{
+ return auth_add_crt_to_store(oap_auth.ca_ctx, crt);
+}
+
+int oap_auth_add_chain_crt(void * crt)
+{
+ return auth_add_crt_to_chain(oap_auth.ca_ctx, crt);
+}
+
+/* HKDF info = LABEL (incl. NUL separator) || request-hash [|| response-hash] */
+#define OAP_BIND_LABEL "o7s-oap-bind"
+#define OAP_KC_LABEL "o7s-oap-kc"
+#define OAP_HS_LABEL "o7s-oap-hs"
+
+int oap_resp_hash(int md_nid,
+ buffer_t kex,
+ buffer_t data,
+ buffer_t crt,
+ buffer_t * out)
+{
+ buffer_t cat = BUF_INIT;
+ uint8_t * p;
+ ssize_t len;
+
+ assert(out != NULL);
+ assert(out->data != NULL);
+
+ cat.len = kex.len + data.len + crt.len;
+ if (cat.len == 0)
+ return -EINVAL;
+
+ cat.data = malloc(cat.len);
+ if (cat.data == NULL)
+ return -ENOMEM;
+
+ p = cat.data;
+ if (kex.len > 0) {
+ memcpy(p, kex.data, kex.len);
+ p += kex.len;
+ }
+
+ if (data.len > 0) {
+ memcpy(p, data.data, data.len);
+ p += data.len;
+ }
+
+ if (crt.len > 0)
+ memcpy(p, crt.data, crt.len);
+
+ len = md_digest(md_nid, cat, out->data);
+
+ freebuf(cat);
+
+ if (len < 0)
+ return -ECRYPT;
+
+ out->len = (size_t) len;
+
+ return 0;
+}
+
+/* HKDF-expand sk->key with info into out; -ECRYPT on failure. */
+static int oap_hkdf_expand(const struct crypt_sk * sk,
+ buffer_t info,
+ uint8_t * out,
+ size_t outlen)
+{
+ buffer_t prk;
+ buffer_t okm;
+
+ prk.len = SYMMKEYSZ;
+ prk.data = sk->key;
+ okm.len = outlen;
+ okm.data = out;
+
+ if (crypt_hkdf_expand(prk, info, okm) < 0)
+ return -ECRYPT;
+
+ return 0;
+}
+
+/* info = label || H(req) */
+#define OAP_HS_INFO_SZ (sizeof(OAP_HS_LABEL) + MAX_HASH_SIZE)
+int oap_derive_hs_key(const struct crypt_sk * sk,
+ buffer_t req_hash,
+ uint8_t * out)
+{
+ uint8_t info_buf[OAP_HS_INFO_SZ];
+ buffer_t info;
+ size_t len;
+
+ assert(sk != NULL);
+ assert(req_hash.data != NULL);
+ assert(out != NULL);
+
+ if (req_hash.len == 0 || req_hash.len > MAX_HASH_SIZE)
+ return -EINVAL;
+
+ len = sizeof(OAP_HS_LABEL);
+ memcpy(info_buf, OAP_HS_LABEL, len);
+ memcpy(info_buf + len, req_hash.data, req_hash.len);
+ len += req_hash.len;
+
+ info.len = len;
+ info.data = info_buf;
+
+ return oap_hkdf_expand(sk, info, out, SYMMKEYSZ);
+}
+
+/* info = label || H(req) || H(resp) || cipher_nid || kdf_nid */
+#define OAP_BIND_INFO_SZ \
+ (sizeof(OAP_BIND_LABEL) + 2 * MAX_HASH_SIZE + 2 * sizeof(uint16_t))
+int oap_bind_session_key(struct crypt_sk * sk,
+ buffer_t req_hash,
+ buffer_t resp_hash,
+ int kdf_nid)
+{
+ uint8_t info_buf[OAP_BIND_INFO_SZ];
+ uint8_t tmp[SYMMKEYSZ];
+ uint16_t suite[2];
+ buffer_t info;
+ size_t len;
+
+ assert(sk != NULL);
+ assert(req_hash.data != NULL);
+ assert(resp_hash.data != NULL);
+
+ if (req_hash.len == 0 || req_hash.len > MAX_HASH_SIZE)
+ return -EINVAL;
+
+ if (resp_hash.len == 0 || resp_hash.len > MAX_HASH_SIZE)
+ return -EINVAL;
+
+ len = sizeof(OAP_BIND_LABEL);
+ memcpy(info_buf, OAP_BIND_LABEL, len);
+ memcpy(info_buf + len, req_hash.data, req_hash.len);
+ len += req_hash.len;
+
+ memcpy(info_buf + len, resp_hash.data, resp_hash.len);
+ len += resp_hash.len;
+
+ suite[0] = hton16((uint16_t) sk->nid);
+ suite[1] = hton16((uint16_t) kdf_nid);
+ memcpy(info_buf + len, suite, sizeof(suite));
+ len += sizeof(suite);
+
+ info.len = len;
+ info.data = info_buf;
+
+ if (oap_hkdf_expand(sk, info, tmp, SYMMKEYSZ) < 0)
+ return -ECRYPT;
+
+ memcpy(sk->key, tmp, SYMMKEYSZ);
+ crypt_secure_clear(tmp, SYMMKEYSZ);
+
+ return 0;
+}
+
+/* info = label || H(req) || H(resp) */
+#define OAP_KC_INFO_SZ (sizeof(OAP_KC_LABEL) + 2 * MAX_HASH_SIZE)
+int oap_key_confirm_tag(const struct crypt_sk * sk,
+ buffer_t req_hash,
+ buffer_t resp_hash,
+ uint8_t * out,
+ size_t outlen)
+{
+ uint8_t info_buf[OAP_KC_INFO_SZ];
+ buffer_t info;
+ size_t len;
+
+ assert(sk != NULL);
+ assert(req_hash.data != NULL);
+ assert(resp_hash.data != NULL);
+ assert(out != NULL);
+
+ if (req_hash.len == 0 || req_hash.len > MAX_HASH_SIZE)
+ return -EINVAL;
+
+ if (resp_hash.len == 0 || resp_hash.len > MAX_HASH_SIZE)
+ return -EINVAL;
+
+ if (outlen > MAX_HASH_SIZE)
+ return -EINVAL;
+
+ len = sizeof(OAP_KC_LABEL);
+ memcpy(info_buf, OAP_KC_LABEL, len);
+ memcpy(info_buf + len, req_hash.data, req_hash.len);
+ len += req_hash.len;
+
+ memcpy(info_buf + len, resp_hash.data, resp_hash.len);
+ len += resp_hash.len;
+
+ info.len = len;
+ info.data = info_buf;
+
+ return oap_hkdf_expand(sk, info, out, outlen);
+}
+
+#define TIMESYNC_SLACK 100 /* ms */
+#define ID_IS_EQUAL(id1, id2) (memcmp(id1, id2, OAP_ID_SIZE) == 0)
+int oap_check_hdr(const struct oap_hdr * hdr)
+{
+ struct oap_replay_bucket * b;
+ struct oap_replay_slot * slots;
+ struct timespec now;
+ uint64_t stamp;
+ uint64_t cur;
+ uint64_t gen;
+ uint8_t * id;
+ size_t h;
+ ssize_t delta;
+ int ret = 0;
+
+ assert(hdr != NULL);
+
+ stamp = hdr->timestamp;
+ id = hdr->id.data;
+
+ clock_gettime(CLOCK_REALTIME, &now);
+
+ cur = TS_TO_UINT64(now);
+
+ delta = (ssize_t)(cur - stamp) / MILLION;
+ if (delta < -TIMESYNC_SLACK) {
+ log_err_id(id, "OAP header from %zd ms into future.", -delta);
+ return -EAUTH;
+ }
+
+ if (delta > OAP_REPLAY_TIMER * 1000) {
+ log_err_id(id, "OAP header too old (%zd ms).", delta);
+ return -EAUTH;
+ }
+
+ gen = stamp / ((uint64_t) OAP_REPLAY_TIMER * BILLION);
+
+ pthread_mutex_lock(&oap_auth.replay.mtx);
+
+ b = &oap_auth.replay.bucket[gen % OAP_REPLAY_GENS];
+
+ /* Rotate a stale bucket in O(1): its old-gen slots become free. */
+ if (b->gen != gen) {
+ b->gen = gen;
+ b->count = 0;
+ }
+
+ slots = b->slots;
+
+ h = replay_hash(id, stamp) & oap_auth.replay.mask;
+ while (slots[h].gen == gen) {
+ if (slots[h].ts == stamp && ID_IS_EQUAL(slots[h].id, id)) {
+ log_warn_id(id, "OAP header already known.");
+ ret = -EREPLAY;
+ goto out;
+ }
+
+ h = (h + 1) & oap_auth.replay.mask;
+ }
+
+ /* Empty slot found; fail closed when the window is at capacity. */
+ if (b->count >= oap_auth.replay.cap) {
+ log_warn_id(id, "OAP replay cache full; rejecting.");
+ ret = -EAUTH;
+ goto out;
+ }
+
+ slots[h].gen = gen;
+ slots[h].ts = stamp;
+ memcpy(slots[h].id, id, OAP_ID_SIZE);
+ b->count++;
+ out:
+ pthread_mutex_unlock(&oap_auth.replay.mtx);
+
+ return ret;
+}
+
+int oap_auth_peer(char * name,
+ const struct sec_config * cfg,
+ const struct oap_hdr * local_hdr,
+ const struct oap_hdr * peer_hdr,
+ const buffer_t * cached_crt)
+{
+ void * crt;
+ void * pk = NULL;
+ void * pin = NULL;
+ buffer_t crt_der; /* cert source: wire, else cached (re-key) */
+ buffer_t sign; /* Signed region */
+ uint8_t * id = peer_hdr->id.data;
+ int ret;
+
+ assert(name != NULL);
+ assert(cfg != NULL);
+ assert(local_hdr != NULL);
+ assert(peer_hdr != NULL);
+
+ if (memcmp(peer_hdr->id.data, local_hdr->id.data, OAP_ID_SIZE) != 0) {
+ log_err_id(id, "OAP ID mismatch in flow allocation.");
+ goto fail_check;
+ }
+
+ /* Re-key drops the wire cert; fall back to the cached peer cert. */
+ crt_der = peer_hdr->crt;
+ if (crt_der.len == 0 && cached_crt != NULL)
+ crt_der = *cached_crt;
+
+ if (crt_der.len == 0) {
+ if (cfg->a.req) {
+ log_err_id(id, "Peer did not provide a certificate.");
+ goto fail_check;
+ }
+ log_dbg_id(id, "No crt provided.");
+ name[0] = '\0';
+ return 0;
+ }
+
+ if (crypt_load_crt_der(crt_der, &crt) < 0) {
+ log_err_id(id, "Failed to load crt.");
+ goto fail_check;
+ }
+
+ log_dbg_id(id, "Loaded peer crt.");
+
+ if (crypt_get_pubkey_crt(crt, &pk) < 0) {
+ log_err_id(id, "Failed to get pubkey from crt.");
+ goto fail_crt;
+ }
+
+ log_dbg_id(id, "Got public key from crt.");
+
+ if (cfg->a.cacert[0] != '\0') {
+ if (crypt_load_crt_file(cfg->a.cacert, &pin) < 0) {
+ log_err_id(id, "Failed to load pinned CA %s.",
+ cfg->a.cacert);
+ goto fail_crt;
+ }
+ }
+
+ ret = auth_verify_crt_pin(oap_auth.ca_ctx, crt, pin);
+ if (ret == -ENOENT) {
+ log_err_id(id, "Peer crt not issued by pinned CA %s.",
+ cfg->a.cacert);
+ goto fail_pin;
+ }
+
+ if (ret < 0) {
+ log_err_id(id, "Failed to verify peer with CA store.");
+ goto fail_pin;
+ }
+
+ log_dbg_id(id, "Successfully verified peer crt.");
+
+ /* Digest pin: peer must sign with the configured digest */
+ if (crypt_pk_requires_md(pk) &&
+ cfg->d.nid != NID_undef && peer_hdr->md_nid != cfg->d.nid) {
+ log_err_id(id, "Peer did not sign with %s.",
+ md_nid_to_str(cfg->d.nid));
+ goto fail_pin;
+ }
+
+ /* Sealed responses verify over the reconstructed plaintext. */
+ sign = peer_hdr->sealed_pt.data != NULL ?
+ peer_hdr->sealed_pt : peer_hdr->hdr;
+ sign.len -= peer_hdr->sig.len;
+
+ if (auth_verify_sig(pk, peer_hdr->md_nid, sign, peer_hdr->sig) < 0) {
+ log_err_id(id, "Failed to verify signature.");
+ goto fail_pin;
+ }
+
+ ret = crypt_get_crt_name(crt, name);
+ if (ret < 0) {
+ if (ret == -ENAME)
+ log_err_id(id, "Certificate CN too long.");
+ else
+ log_err_id(id, "No name in certificate.");
+ goto fail_pin;
+ }
+
+ if (pin != NULL)
+ crypt_free_crt(pin);
+
+ crypt_free_key(pk);
+ crypt_free_crt(crt);
+
+ log_dbg_id(id, "Successfully authenticated peer.");
+
+ return 0;
+
+ fail_pin:
+ if (pin != NULL)
+ crypt_free_crt(pin);
+ fail_crt:
+ crypt_free_key(pk);
+ crypt_free_crt(crt);
+ fail_check:
+ return -EAUTH;
+}
diff --git a/src/irmd/oap/auth.h b/src/irmd/oap/auth.h
new file mode 100644
index 00000000..72938b53
--- /dev/null
+++ b/src/irmd/oap/auth.h
@@ -0,0 +1,68 @@
+/*
+ * Ouroboros - Copyright (C) 2016 - 2026
+ *
+ * OAP - Authentication functions
+ *
+ * Dimitri Staessens <dimitri@ouroboros.rocks>
+ * Sander Vrijders <sander@ouroboros.rocks>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., http://www.fsf.org/about/contact/.
+ */
+
+#ifndef OUROBOROS_IRMD_OAP_AUTH_H
+#define OUROBOROS_IRMD_OAP_AUTH_H
+
+#include <ouroboros/crypt.h>
+
+#include "hdr.h"
+
+int oap_check_hdr(const struct oap_hdr * hdr);
+
+/*
+ * name is set to the peer crt CN, "" if no crt was presented.
+ * cached_crt (or NULL) is the peer cert from the initial handshake, used
+ * to verify a cert-less re-key.
+ */
+int oap_auth_peer(char * name,
+ const struct sec_config * cfg,
+ const struct oap_hdr * local_hdr,
+ const struct oap_hdr * peer_hdr,
+ const buffer_t * cached_crt);
+
+/* Derive the handshake key that seals the response identity block. */
+int oap_derive_hs_key(const struct crypt_sk * sk,
+ buffer_t req_hash,
+ uint8_t * out);
+
+/* resp_hash = H(kex || data || crt): binds the server response transcript. */
+int oap_resp_hash(int md_nid,
+ buffer_t kex,
+ buffer_t data,
+ buffer_t crt,
+ buffer_t * out);
+
+/* Fold request + response transcript + negotiated suite into the key. */
+int oap_bind_session_key(struct crypt_sk * sk,
+ buffer_t req_hash,
+ buffer_t resp_hash,
+ int kdf_nid);
+
+/* Server->client key-confirmation tag derived from the bound key. */
+int oap_key_confirm_tag(const struct crypt_sk * sk,
+ buffer_t req_hash,
+ buffer_t resp_hash,
+ uint8_t * out,
+ size_t outlen);
+
+#endif /* OUROBOROS_IRMD_OAP_AUTH_H */
diff --git a/src/irmd/oap/cli.c b/src/irmd/oap/cli.c
new file mode 100644
index 00000000..ebfcd71f
--- /dev/null
+++ b/src/irmd/oap/cli.c
@@ -0,0 +1,682 @@
+/*
+ * Ouroboros - Copyright (C) 2016 - 2026
+ *
+ * OAP - Client-side processing
+ *
+ * Dimitri Staessens <dimitri@ouroboros.rocks>
+ * Sander Vrijders <sander@ouroboros.rocks>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., http://www.fsf.org/about/contact/.
+ */
+
+#if defined(__linux__) || defined(__CYGWIN__)
+ #define _DEFAULT_SOURCE
+#else
+ #define _POSIX_C_SOURCE 200809L
+#endif
+
+#define OUROBOROS_PREFIX "irmd/oap"
+
+#include <ouroboros/crypt.h>
+#include <ouroboros/errno.h>
+#include <ouroboros/logs.h>
+#include <ouroboros/random.h>
+
+#include "config.h"
+
+#include "auth.h"
+#include "hdr.h"
+#include "io.h"
+#include "../oap.h"
+
+#include <assert.h>
+#include <limits.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+/* Client context between oap_cli_prepare and oap_cli_complete */
+struct oap_cli_ctx {
+ uint8_t __id[OAP_ID_SIZE];
+ buffer_t id;
+ uint8_t kex_buf[CRYPT_KEY_BUFSZ];
+ uint8_t req_hash[MAX_HASH_SIZE];
+ size_t req_hash_len;
+ int req_md_nid;
+ struct sec_config scfg;
+ struct oap_hdr local_hdr;
+ void * pkp; /* Ephemeral keypair */
+ uint8_t * key; /* For client-encap KEM */
+};
+
+#define OAP_CLI_CTX_INIT(s) \
+ do { s->id.len = OAP_ID_SIZE; s->id.data = s->__id; } while (0)
+
+/* Client-side credential loading, mocked in tests */
+
+#ifdef OAP_TEST_MODE
+extern int load_cli_credentials(const struct name_info * info,
+ void ** pkp,
+ void ** crt);
+extern int load_cli_sec_config(const struct name_info * info,
+ struct sec_config * cfg);
+extern int load_server_kem_pk(const char * name,
+ struct sec_config * cfg,
+ buffer_t * buf);
+#else
+
+int load_cli_credentials(const struct name_info * info,
+ void ** pkp,
+ void ** crt)
+{
+ assert(info != NULL);
+ assert(pkp != NULL);
+ assert(crt != NULL);
+
+ return load_credentials(info->name, &info->c, pkp, crt);
+}
+
+int load_cli_sec_config(const struct name_info * info,
+ struct sec_config * cfg)
+{
+ assert(info != NULL);
+ assert(cfg != NULL);
+
+ memset(cfg, 0, sizeof(*cfg));
+
+ /* A client authenticates the server by default, like an https client */
+ cfg->a.req = OAP_CLIENT_AUTH_DEFAULT;
+
+ return load_sec_config(info->name, info->c.sec, cfg);
+}
+
+int load_server_kem_pk(const char * name,
+ struct sec_config * cfg,
+ buffer_t * pk)
+{
+ char path[PATH_MAX];
+ const char * ext;
+
+ assert(name != NULL);
+ assert(cfg != NULL);
+ assert(pk != NULL);
+
+ ext = IS_HYBRID_KEM(cfg->x.str) ? "raw" : "pem";
+
+ snprintf(path, sizeof(path),
+ OUROBOROS_CLI_CRT_DIR "/%s/kex.srv.pub.%s", name, ext);
+
+ if (IS_HYBRID_KEM(cfg->x.str)) {
+ if (crypt_load_pubkey_raw_file(path, pk) < 0) {
+ log_err("Failed to load %s pubkey from %s.", ext, path);
+ return -1;
+ }
+ } else {
+ if (crypt_load_pubkey_file_to_der(path, pk) < 0) {
+ log_err("Failed to load %s pubkey from %s.", ext, path);
+ return -1;
+ }
+ }
+
+ log_dbg("Loaded %s pubkey from %s (%zu bytes).", ext, path, pk->len);
+
+ return 0;
+}
+
+#endif /* OAP_TEST_MODE */
+
+static int do_client_kex_prepare_dhe(struct oap_cli_ctx * s)
+{
+ struct sec_config * scfg = &s->scfg;
+ buffer_t * kex = &s->local_hdr.kex;
+ uint8_t * id = s->id.data;
+ ssize_t len;
+
+ /* Generate ephemeral keypair, send PK */
+ len = kex_pkp_create(scfg, &s->pkp, kex->data);
+ if (len < 0) {
+ log_err_id(id, "Failed to generate DHE keypair.");
+ return -ECRYPT;
+ }
+
+ kex->len = (size_t) len;
+ log_dbg_id(id, "Generated ephemeral %s keys (%zd bytes).",
+ scfg->x.str, len);
+
+ return 0;
+}
+
+static int do_client_kex_prepare_kem_encap(const char * server_name,
+ struct oap_cli_ctx * s)
+{
+ struct sec_config * scfg = &s->scfg;
+ buffer_t * kex = &s->local_hdr.kex;
+ uint8_t * id = s->id.data;
+ buffer_t server_pk = BUF_INIT;
+ uint8_t key_buf[SYMMKEYSZ];
+ ssize_t len;
+
+ if (load_server_kem_pk(server_name, scfg, &server_pk) < 0) {
+ log_err_id(id, "Failed to load server KEM pk.");
+ return -ECRYPT;
+ }
+
+ if (IS_HYBRID_KEM(scfg->x.str))
+ len = kex_kem_encap_raw(server_pk, kex->data,
+ scfg->k.nid, key_buf);
+ else
+ len = kex_kem_encap(server_pk, kex->data,
+ scfg->k.nid, key_buf);
+
+ freebuf(server_pk);
+
+ if (len < 0) {
+ log_err_id(id, "Failed to encapsulate KEM.");
+ return -ECRYPT;
+ }
+
+ kex->len = (size_t) len;
+ log_dbg_id(id, "Client encaps: CT len=%zd.", len);
+
+ /* Store derived key */
+ s->key = crypt_secure_malloc(SYMMKEYSZ);
+ if (s->key == NULL) {
+ log_err_id(id, "Failed to allocate secure key.");
+ return -ENOMEM;
+ }
+ memcpy(s->key, key_buf, SYMMKEYSZ);
+ crypt_secure_clear(key_buf, SYMMKEYSZ);
+
+ return 0;
+}
+
+static int do_client_kex_prepare_kem_decap(struct oap_cli_ctx * s)
+{
+ struct sec_config * scfg = &s->scfg;
+ buffer_t * kex = &s->local_hdr.kex;
+ uint8_t * id = s->id.data;
+ ssize_t len;
+
+ /* Server encaps: generate keypair, send PK */
+ len = kex_pkp_create(scfg, &s->pkp, kex->data);
+ if (len < 0) {
+ log_err_id(id, "Failed to generate KEM keypair.");
+ return -ECRYPT;
+ }
+
+ kex->len = (size_t) len;
+ log_dbg_id(id, "Client PK for server encaps (%zd bytes).", len);
+
+ return 0;
+}
+
+static int do_client_kex_prepare(const char * server_name,
+ struct oap_cli_ctx * s)
+{
+ struct sec_config * scfg = &s->scfg;
+
+ if (!IS_KEX_ALGO_SET(scfg))
+ return 0;
+
+ if (IS_KEM_ALGORITHM(scfg->x.str)) {
+ if (scfg->x.mode == KEM_MODE_CLIENT_ENCAP)
+ return do_client_kex_prepare_kem_encap(server_name, s);
+ else
+ return do_client_kex_prepare_kem_decap(s);
+ }
+
+ return do_client_kex_prepare_dhe(s);
+}
+
+int oap_cli_prepare(void ** ctx,
+ const struct name_info * info,
+ buffer_t * req_buf,
+ buffer_t data,
+ bool rekey)
+{
+ struct oap_cli_ctx * s;
+ void * pkp = NULL;
+ void * crt = NULL;
+ buffer_t no_tag = BUF_INIT;
+ ssize_t ret;
+ int enc_flags = 0;
+
+ assert(ctx != NULL);
+ assert(info != NULL);
+ assert(req_buf != NULL);
+
+ clrbuf(*req_buf);
+ *ctx = NULL;
+
+ /* Allocate ctx to carry between prepare and complete */
+ s = malloc(sizeof(*s));
+ if (s == NULL) {
+ log_err("Failed to allocate OAP client ctx.");
+ return -ENOMEM;
+ }
+
+ memset(s, 0, sizeof(*s));
+ OAP_CLI_CTX_INIT(s);
+
+ /* Generate session ID */
+ if (random_buffer(s->__id, OAP_ID_SIZE) < 0) {
+ log_err("Failed to generate OAP session ID.");
+ goto fail_id;
+ }
+
+ log_dbg_id(s->id.data, "Preparing OAP request for %s.", info->name);
+
+ /* Load client credentials */
+ if (load_cli_credentials(info, &pkp, &crt) < 0) {
+ log_err_id(s->id.data, "Failed to load credentials for %s.",
+ info->name);
+ goto fail_id;
+ }
+
+ /* Load security config */
+ if (load_cli_sec_config(info, &s->scfg) < 0) {
+ log_err_id(s->id.data, "Failed to load security config for %s.",
+ info->name);
+ goto fail_kex;
+ }
+
+ /* A re-keyed flow is encrypted; absent config must fail closed. */
+ if (rekey && !IS_KEX_ALGO_SET(&s->scfg)) {
+ log_err_id(s->id.data, "Refusing re-key without KEX for %s.",
+ info->name);
+ goto fail_kex;
+ }
+
+ /* Re-key forces server-encap: client-encap forfeits FS/PCS. */
+ if (rekey && s->scfg.x.mode == KEM_MODE_CLIENT_ENCAP) {
+ s->scfg.x.mode = KEM_MODE_SERVER_ENCAP;
+ log_dbg_id(s->id.data, "Re-key forcing ephemeral server KEX.");
+ }
+
+ /* Re-key omits the cert; the server verifies against its cache. */
+ if (rekey && crt != NULL) {
+ crypt_free_crt(crt);
+ crt = NULL;
+ }
+
+ if (rekey)
+ enc_flags = OAP_ENC_REKEY;
+
+ oap_hdr_init(&s->local_hdr, s->id, s->kex_buf, data, s->scfg.c.nid);
+
+ if (do_client_kex_prepare(info->name, s) < 0) {
+ log_err_id(s->id.data, "Failed to prepare client KEX.");
+ goto fail_kex;
+ }
+
+ if (oap_hdr_encode(&s->local_hdr, pkp, crt, &s->scfg,
+ no_tag, NID_undef, NULL, enc_flags)) {
+ log_err_id(s->id.data, "Failed to create OAP request header.");
+ goto fail_hdr;
+ }
+
+ debug_oap_hdr_snd(&s->local_hdr);
+
+ /* Compute and store hash of request for verification in complete */
+ s->req_md_nid = s->scfg.d.nid != NID_undef ? s->scfg.d.nid : NID_sha384;
+ ret = md_digest(s->req_md_nid, s->local_hdr.hdr, s->req_hash);
+ if (ret < 0) {
+ log_err_id(s->id.data, "Failed to hash request.");
+ goto fail_hash;
+ }
+ s->req_hash_len = (size_t) ret;
+
+ /* Transfer ownership of request buffer */
+ *req_buf = s->local_hdr.hdr;
+ clrbuf(s->local_hdr.hdr);
+
+ /* oap_hdr_encode repoints id into hdr; restore to __id */
+ s->local_hdr.id = s->id;
+
+ crypt_free_crt(crt);
+ crypt_free_key(pkp);
+
+ *ctx = s;
+
+ log_dbg_id(s->id.data, "OAP request prepared for %s.", info->name);
+
+ return 0;
+
+ fail_hash:
+ oap_hdr_fini(&s->local_hdr);
+ fail_hdr:
+ crypt_secure_free(s->key, SYMMKEYSZ);
+ crypt_free_key(s->pkp);
+ fail_kex:
+ crypt_free_crt(crt);
+ crypt_free_key(pkp);
+ fail_id:
+ free(s);
+ return -ECRYPT;
+}
+
+void oap_ctx_free(void * ctx)
+{
+ struct oap_cli_ctx * s = ctx;
+
+ if (s == NULL)
+ return;
+
+ oap_hdr_fini(&s->local_hdr);
+
+ if (s->pkp != NULL)
+ crypt_free_key(s->pkp);
+
+ if (s->key != NULL)
+ crypt_secure_free(s->key, SYMMKEYSZ);
+
+ memset(s, 0, sizeof(*s));
+ free(s);
+}
+
+static int do_client_kex_complete_kem(struct oap_cli_ctx * s,
+ const struct oap_hdr * peer_hdr,
+ struct crypt_sk * sk)
+{
+ struct sec_config * scfg = &s->scfg;
+ uint8_t * id = s->id.data;
+ uint8_t key_buf[SYMMKEYSZ];
+ buffer_t ct;
+
+ if (scfg->x.mode == KEM_MODE_CLIENT_ENCAP) {
+ /* Key already derived during prepare */
+ memcpy(sk->key, s->key, SYMMKEYSZ);
+ sk->nid = scfg->c.nid;
+ log_info_id(id, "Negotiated %s + %s.", scfg->x.str,
+ scfg->c.str);
+ return 0;
+ }
+
+ /* KEM_MODE_SERVER_ENCAP */
+ if (peer_hdr->kex.len == 0) {
+ log_err_id(id, "Server did not send KEM CT.");
+ return -ECRYPT;
+ }
+
+ ct.data = peer_hdr->kex.data;
+ ct.len = peer_hdr->kex.len;
+
+ if (kex_kem_decap(s->pkp, ct, scfg->k.nid, key_buf) < 0) {
+ log_err_id(id, "Failed to decapsulate KEM.");
+ return -ECRYPT;
+ }
+
+ log_dbg_id(id, "Client decapsulated server CT.");
+
+ memcpy(sk->key, key_buf, SYMMKEYSZ);
+ sk->nid = scfg->c.nid;
+ crypt_secure_clear(key_buf, SYMMKEYSZ);
+
+ log_info_id(id, "Negotiated %s + %s.", scfg->x.str, scfg->c.str);
+
+ return 0;
+}
+
+static int do_client_kex_complete_dhe(struct oap_cli_ctx * s,
+ const struct oap_hdr * peer_hdr,
+ struct crypt_sk * sk)
+{
+ struct sec_config * scfg = &s->scfg;
+ uint8_t * id = s->id.data;
+ uint8_t key_buf[SYMMKEYSZ];
+
+ /* DHE: derive from server's public key */
+ if (peer_hdr->kex.len == 0) {
+ log_err_id(id, "Server did not send DHE public key.");
+ return -ECRYPT;
+ }
+
+ if (kex_dhe_derive(scfg, s->pkp, peer_hdr->kex, key_buf) < 0) {
+ log_err_id(id, "Failed to derive DHE secret.");
+ return -ECRYPT;
+ }
+
+ log_dbg_id(id, "DHE: derived shared secret.");
+
+ memcpy(sk->key, key_buf, SYMMKEYSZ);
+ sk->nid = scfg->c.nid;
+ crypt_secure_clear(key_buf, SYMMKEYSZ);
+
+ log_info_id(id, "Negotiated %s + %s.", scfg->x.str, scfg->c.str);
+
+ return 0;
+}
+
+
+static int do_client_kex_complete(struct oap_cli_ctx * s,
+ const struct oap_hdr * peer_hdr,
+ struct crypt_sk * sk)
+{
+ struct sec_config * scfg = &s->scfg;
+ uint8_t * id = s->id.data;
+ int cipher_nid;
+ int kdf_nid;
+
+ if (!IS_KEX_ALGO_SET(scfg))
+ return 0;
+
+ /* Save client's configured minimums */
+ cipher_nid = scfg->c.nid;
+ kdf_nid = scfg->k.nid;
+
+ /* Accept server's cipher choice */
+ if (peer_hdr->cipher_str == NULL) {
+ log_err_id(id, "Server did not provide cipher.");
+ return -ECRYPT;
+ }
+
+ SET_KEX_CIPHER(scfg, peer_hdr->cipher_str);
+ if (crypt_validate_nid(scfg->c.nid) < 0) {
+ log_err_id(id, "Server cipher '%s' not supported.",
+ peer_hdr->cipher_str);
+ return -ENOTSUP;
+ }
+
+ /* Verify server cipher >= client's minimum */
+ if (crypt_cipher_rank(scfg->c.nid) < crypt_cipher_rank(cipher_nid)) {
+ log_err_id(id, "Server cipher %s too weak.",
+ peer_hdr->cipher_str);
+ return -ECRYPT;
+ }
+
+ log_dbg_id(id, "Accepted server cipher %s.",
+ peer_hdr->cipher_str);
+
+ /* Accept server's KDF for non-client-encap modes */
+ if (scfg->x.mode != KEM_MODE_CLIENT_ENCAP
+ && peer_hdr->kdf_nid != NID_undef) {
+ if (crypt_kdf_rank(peer_hdr->kdf_nid)
+ < crypt_kdf_rank(kdf_nid)) {
+ log_err_id(id, "Server KDF too weak.");
+ return -ECRYPT;
+ }
+ SET_KEX_KDF_NID(scfg, peer_hdr->kdf_nid);
+ log_dbg_id(id, "Accepted server KDF %s.",
+ md_nid_to_str(scfg->k.nid));
+ }
+
+ /* Derive shared secret */
+ if (IS_KEM_ALGORITHM(scfg->x.str))
+ return do_client_kex_complete_kem(s, peer_hdr, sk);
+
+ return do_client_kex_complete_dhe(s, peer_hdr, sk);
+}
+
+int oap_cli_complete(void * ctx,
+ const struct name_info * info,
+ buffer_t rsp_buf,
+ buffer_t * data,
+ struct crypt_sk * sk,
+ const buffer_t * cached_crt,
+ buffer_t * peer_crt)
+{
+ struct oap_cli_ctx * s = ctx;
+ struct oap_hdr peer_hdr;
+ char peer[NAME_SIZE + 1];
+ uint8_t kc_buf[MAX_HASH_SIZE];
+ uint8_t resp_hash_buf[MAX_HASH_SIZE];
+ uint8_t hs_key[SYMMKEYSZ];
+ buffer_t req_hash = BUF_INIT;
+ buffer_t resp_hash = BUF_INIT;
+ uint8_t * id;
+ int rc;
+
+ assert(ctx != NULL);
+ assert(info != NULL);
+ assert(data != NULL);
+ assert(sk != NULL);
+
+ sk->nid = NID_undef;
+
+ clrbuf(*data);
+
+ memset(&peer_hdr, 0, sizeof(peer_hdr));
+
+ id = s->id.data;
+
+ log_dbg_id(id, "Completing OAP for %s.", info->name);
+
+ /* Decode response header using client's md_nid for hash length */
+ if (oap_hdr_decode(&peer_hdr, rsp_buf, s->req_md_nid, false) < 0) {
+ log_err_id(id, "Failed to decode OAP response header.");
+ goto fail_oap;
+ }
+
+ debug_oap_hdr_rcv(&peer_hdr);
+
+ /* Verify response ID matches request */
+ if (memcmp(peer_hdr.id.data, id, OAP_ID_SIZE) != 0) {
+ log_err_id(id, "OAP response ID mismatch.");
+ goto fail_oap;
+ }
+
+ /* Complete key exchange first; the sealed identity needs the secret */
+ if (do_client_kex_complete(s, &peer_hdr, sk) < 0) {
+ log_err_id(id, "Failed to complete key exchange.");
+ goto fail_oap;
+ }
+
+ req_hash.data = s->req_hash;
+ req_hash.len = s->req_hash_len;
+
+ /* Decrypt the sealed server identity (data+cert+sig) before auth */
+ if (sk->nid != NID_undef && peer_hdr.sealed.data != NULL) {
+ if (oap_derive_hs_key(sk, req_hash, hs_key) < 0) {
+ log_err_id(id, "Failed to derive handshake key.");
+ goto fail_oap;
+ }
+
+ rc = oap_hdr_unseal(&peer_hdr, hs_key);
+
+ crypt_secure_clear(hs_key, SYMMKEYSZ);
+
+ if (rc < 0) {
+ log_err_id(id, "Failed to unseal server identity.");
+ goto fail_oap;
+ }
+ }
+
+ /* Authenticate server (cert + signature now in cleartext) */
+ if (oap_auth_peer(peer, &s->scfg, &s->local_hdr, &peer_hdr,
+ cached_crt) < 0) {
+ log_err_id(id, "Failed to authenticate server.");
+ goto fail_oap;
+ }
+
+ /* Surface the peer cert so the caller can cache it for re-key. */
+ if (peer_crt != NULL && peer_hdr.crt.len > 0) {
+ peer_crt->data = malloc(peer_hdr.crt.len);
+ if (peer_crt->data == NULL)
+ goto fail_oap;
+
+ memcpy(peer_crt->data, peer_hdr.crt.data, peer_hdr.crt.len);
+ peer_crt->len = peer_hdr.crt.len;
+ }
+
+ /* Response must carry a transcript tag of the expected length */
+ if (peer_hdr.rsp_tag.len != s->req_hash_len) {
+ log_err_id(id, "Response transcript tag mismatch.");
+ goto fail_oap;
+ }
+
+ /* Verify peer certificate name matches expected destination */
+ if (peer_hdr.crt.len > 0 && strcmp(peer, info->name) != 0) {
+ log_err_id(id, "Peer crt for '%s' does not match '%s'.",
+ peer, info->name);
+ goto fail_oap;
+ }
+
+ if (sk->nid != NID_undef) {
+ /* Encrypted: bind the key and verify key confirmation */
+ resp_hash.data = resp_hash_buf;
+
+ if (oap_resp_hash(s->req_md_nid, peer_hdr.kex,
+ peer_hdr.data, peer_hdr.crt,
+ &resp_hash) < 0) {
+ log_err_id(id, "Failed to hash response.");
+ goto fail_oap;
+ }
+
+ if (oap_bind_session_key(sk, req_hash, resp_hash,
+ s->scfg.k.nid) < 0) {
+ log_err_id(id, "Failed to bind session key.");
+ goto fail_oap;
+ }
+
+ if (oap_key_confirm_tag(sk, req_hash, resp_hash, kc_buf,
+ s->req_hash_len) < 0) {
+ log_err_id(id, "Failed to confirm session key.");
+ goto fail_oap;
+ }
+
+ if (crypt_ct_cmp(peer_hdr.rsp_tag.data, kc_buf,
+ s->req_hash_len) != 0) {
+ log_err_id(id, "Key confirmation mismatch.");
+ goto fail_oap;
+ }
+ } else {
+ /* Cleartext path is config-driven, never a wire downgrade */
+ assert(!IS_KEX_ALGO_SET(&s->scfg));
+ /* Unencrypted: verify request-echo integrity */
+ if (crypt_ct_cmp(peer_hdr.rsp_tag.data, s->req_hash,
+ s->req_hash_len) != 0) {
+ log_err_id(id, "Response tag mismatch.");
+ goto fail_oap;
+ }
+ }
+
+ /* Copy piggybacked data from server response */
+ if (oap_hdr_copy_data(&peer_hdr, data) < 0) {
+ log_err_id(id, "Failed to copy server data.");
+ goto fail_oap;
+ }
+
+ log_info_id(id, "OAP completed for %s.", info->name);
+
+ freebuf(peer_hdr.sealed_pt);
+
+ oap_ctx_free(s);
+
+ return 0;
+
+ fail_oap:
+ freebuf(peer_hdr.sealed_pt);
+ oap_ctx_free(s);
+ return -ECRYPT;
+}
diff --git a/src/irmd/oap/hdr.c b/src/irmd/oap/hdr.c
new file mode 100644
index 00000000..0cff345c
--- /dev/null
+++ b/src/irmd/oap/hdr.c
@@ -0,0 +1,761 @@
+/*
+ * Ouroboros - Copyright (C) 2016 - 2026
+ *
+ * OAP - Header encoding, decoding, and debugging
+ *
+ * Dimitri Staessens <dimitri@ouroboros.rocks>
+ * Sander Vrijders <sander@ouroboros.rocks>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., http://www.fsf.org/about/contact/.
+ */
+
+#if defined(__linux__) || defined(__CYGWIN__)
+ #define _DEFAULT_SOURCE
+#else
+ #define _POSIX_C_SOURCE 200809L
+#endif
+
+#define OUROBOROS_PREFIX "irmd/oap"
+
+#include <ouroboros/crypt.h>
+#include <ouroboros/endian.h>
+#include <ouroboros/errno.h>
+#include <ouroboros/hash.h>
+#include <ouroboros/logs.h>
+#include <ouroboros/rib.h>
+#include <ouroboros/time.h>
+
+#include "config.h"
+
+#include "hdr.h"
+
+#include <assert.h>
+#include <errno.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+
+#define OAP_SEAL_TAGSZ 16 /* AEAD tag on the sealed identity block */
+/* Sealed length prefix: data_len ‖ crt_len. */
+#define OAP_SEAL_LENSZ (sizeof(uint16_t) + sizeof(uint16_t))
+
+/* hs_key is single-use per handshake, so a fixed nonce is reuse-safe. */
+static const uint8_t oap_seal_nonce[12];
+
+int oap_hdr_decode(struct oap_hdr * oap_hdr,
+ buffer_t hdr,
+ int req_md_nid,
+ bool rekey)
+{
+ off_t offset;
+ uint16_t kex_len;
+ uint16_t ciph_nid;
+ size_t crt_len;
+ size_t data_len;
+ size_t hash_len;
+ size_t sig_len;
+
+ assert(oap_hdr != NULL);
+ memset(oap_hdr, 0, sizeof(*oap_hdr));
+
+ if (hdr.len < OAP_HDR_MIN_SIZE)
+ goto fail_decode;
+
+ /* Parse fixed header (36 bytes) */
+ oap_hdr->id.data = hdr.data;
+ oap_hdr->id.len = OAP_ID_SIZE;
+
+ offset = OAP_ID_SIZE;
+
+ oap_hdr->timestamp = ntoh64(*(uint64_t *)(hdr.data + offset));
+ offset += sizeof(uint64_t);
+
+ /* cipher NID */
+ ciph_nid = ntoh16(*(uint16_t *)(hdr.data + offset));
+ oap_hdr->nid = ciph_nid;
+ oap_hdr->cipher_str = crypt_nid_to_str(ciph_nid);
+ offset += sizeof(uint16_t);
+
+ /* kdf NID */
+ oap_hdr->kdf_nid = ntoh16(*(uint16_t *)(hdr.data + offset));
+ oap_hdr->kdf_str = md_nid_to_str(oap_hdr->kdf_nid);
+ offset += sizeof(uint16_t);
+
+ /* md NID (signature hash) */
+ oap_hdr->md_nid = ntoh16(*(uint16_t *)(hdr.data + offset));
+ oap_hdr->md_str = md_nid_to_str(oap_hdr->md_nid);
+ offset += sizeof(uint16_t);
+
+ /*
+ * Validate NIDs: NID_undef is valid at parse time, else must be known.
+ * Note: md_nid=NID_undef only valid for PQC; enforced at sign/verify.
+ */
+ if (ciph_nid != NID_undef && crypt_validate_nid(ciph_nid) < 0)
+ goto fail_decode;
+
+ if (oap_hdr->kdf_nid != NID_undef &&
+ md_validate_nid(oap_hdr->kdf_nid) < 0)
+ goto fail_decode;
+ if (oap_hdr->md_nid != NID_undef &&
+ md_validate_nid(oap_hdr->md_nid) < 0)
+ goto fail_decode;
+
+ /* crt_len */
+ crt_len = (size_t) ntoh16(*(uint16_t *)(hdr.data + offset));
+ offset += sizeof(uint16_t);
+
+ /* kex_len + flags */
+ kex_len = ntoh16(*(uint16_t *)(hdr.data + offset));
+ oap_hdr->kex.len = (size_t) (kex_len & OAP_KEX_LEN_MASK);
+ oap_hdr->kex_flags.fmt = (kex_len & OAP_KEX_FMT_BIT) ? 1 : 0;
+ oap_hdr->kex_flags.role = (kex_len & OAP_KEX_ROLE_BIT) ? 1 : 0;
+ offset += sizeof(uint16_t);
+
+ /* data_len */
+ data_len = (size_t) ntoh16(*(uint16_t *)(hdr.data + offset));
+ offset += sizeof(uint16_t);
+
+ assert((size_t) offset == OAP_HDR_MIN_SIZE);
+
+ /* Response includes rsp_tag when md_nid is set */
+ hash_len = (req_md_nid != NID_undef) ?
+ (size_t) md_len(req_md_nid) : 0;
+
+ /* Encrypted response: sealed block is data_len‖crt_len‖data‖crt‖sig. */
+ if (req_md_nid != NID_undef && ciph_nid != NID_undef) {
+ if (hdr.len < (size_t) offset + oap_hdr->kex.len + hash_len +
+ OAP_SEAL_TAGSZ + OAP_SEAL_LENSZ)
+ goto fail_decode;
+
+ oap_hdr->kex.data = hdr.data + offset;
+ offset += oap_hdr->kex.len;
+
+ oap_hdr->rsp_tag.data = hdr.data + offset;
+ oap_hdr->rsp_tag.len = hash_len;
+ offset += hash_len;
+
+ oap_hdr->sealed.data = hdr.data + offset;
+ oap_hdr->sealed.len = hdr.len - offset;
+
+ /* crt/data/sig lengths are sealed; set by oap_hdr_unseal. */
+ oap_hdr->crt.len = crt_len;
+ oap_hdr->data.len = data_len;
+
+ oap_hdr->hdr = hdr;
+
+ return 0;
+ }
+
+ /* Validate total length */
+ if (hdr.len < (size_t) offset + crt_len + oap_hdr->kex.len +
+ data_len + hash_len)
+ goto fail_decode;
+
+ /* Derive sig_len from remaining bytes */
+ sig_len = hdr.len - offset - crt_len - oap_hdr->kex.len -
+ data_len - hash_len;
+
+ /*
+ * Unsigned packets must not have trailing bytes. A re-key request
+ * is signed but cert-less (verified against the cached peer cert),
+ * so the rekey caller permits crt_len==0 with a signature.
+ */
+ if (crt_len == 0 && sig_len != 0 && !rekey)
+ goto fail_decode;
+
+ /* Parse variable fields */
+ oap_hdr->crt.data = hdr.data + offset;
+ oap_hdr->crt.len = crt_len;
+ offset += crt_len;
+
+ oap_hdr->kex.data = hdr.data + offset;
+ offset += oap_hdr->kex.len;
+
+ oap_hdr->data.data = hdr.data + offset;
+ oap_hdr->data.len = data_len;
+ offset += data_len;
+
+ oap_hdr->rsp_tag.data = hdr.data + offset;
+ oap_hdr->rsp_tag.len = hash_len;
+ offset += hash_len;
+
+ oap_hdr->sig.data = hdr.data + offset;
+ oap_hdr->sig.len = sig_len;
+
+ oap_hdr->hdr = hdr;
+
+ return 0;
+
+ fail_decode:
+ memset(oap_hdr, 0, sizeof(*oap_hdr));
+ return -1;
+}
+
+void oap_hdr_fini(struct oap_hdr * oap_hdr)
+{
+ assert(oap_hdr != NULL);
+
+ freebuf(oap_hdr->sealed_pt);
+ freebuf(oap_hdr->hdr);
+ memset(oap_hdr, 0, sizeof(*oap_hdr));
+}
+
+int oap_hdr_copy_data(const struct oap_hdr * hdr,
+ buffer_t * out)
+{
+ assert(hdr != NULL);
+ assert(out != NULL);
+
+ if (hdr->data.len == 0) {
+ clrbuf(*out);
+ return 0;
+ }
+
+ out->data = malloc(hdr->data.len);
+ if (out->data == NULL)
+ return -ENOMEM;
+
+ memcpy(out->data, hdr->data.data, hdr->data.len);
+ out->len = hdr->data.len;
+
+ return 0;
+}
+
+void oap_hdr_init(struct oap_hdr * hdr,
+ buffer_t id,
+ uint8_t * kex_buf,
+ buffer_t data,
+ uint16_t nid)
+{
+ assert(hdr != NULL);
+ assert(id.data != NULL && id.len == OAP_ID_SIZE);
+
+ memset(hdr, 0, sizeof(*hdr));
+
+ hdr->id = id;
+ hdr->kex.data = kex_buf;
+ hdr->kex.len = 0;
+ hdr->data = data;
+ hdr->nid = nid;
+}
+
+/* Write the 36-byte fixed header; stamp is already in network order. */
+static void write_oap_fixed(uint8_t * buf,
+ const struct oap_hdr * hdr,
+ const struct sec_config * scfg,
+ size_t crt_len,
+ size_t data_len,
+ uint64_t stamp)
+{
+ uint16_t v;
+ uint16_t kex_len;
+ off_t offset = 0;
+
+ memcpy(buf + offset, hdr->id.data, hdr->id.len);
+ offset += hdr->id.len;
+
+ memcpy(buf + offset, &stamp, sizeof(stamp));
+ offset += sizeof(stamp);
+
+ v = hton16(hdr->nid);
+ memcpy(buf + offset, &v, sizeof(v));
+ offset += sizeof(v);
+
+ v = hton16(scfg->k.nid);
+ memcpy(buf + offset, &v, sizeof(v));
+ offset += sizeof(v);
+
+ v = hton16(scfg->d.nid);
+ memcpy(buf + offset, &v, sizeof(v));
+ offset += sizeof(v);
+
+ v = hton16((uint16_t) crt_len);
+ memcpy(buf + offset, &v, sizeof(v));
+ offset += sizeof(v);
+
+ kex_len = (uint16_t) hdr->kex.len;
+ if (hdr->kex.len > 0 && IS_KEM_ALGORITHM(scfg->x.str)) {
+ if (IS_HYBRID_KEM(scfg->x.str))
+ kex_len |= OAP_KEX_FMT_BIT;
+ if (scfg->x.mode == KEM_MODE_CLIENT_ENCAP)
+ kex_len |= OAP_KEX_ROLE_BIT;
+ }
+
+ v = hton16(kex_len);
+ memcpy(buf + offset, &v, sizeof(v));
+ offset += sizeof(v);
+
+ v = hton16((uint16_t) data_len);
+ memcpy(buf + offset, &v, sizeof(v));
+}
+
+/*
+ * Pack lens ‖ data ‖ crt, sign prefix ‖ body, append the signature, then
+ * AEAD-seal lens ‖ data ‖ crt ‖ sig under prefix as AAD. The cert, app data
+ * and their sizes stay confidential; *out is the opaque sealed block. The
+ * signature rides inside the seal so it can't deanonymise the server.
+ */
+static int oap_seal_body(int nid,
+ const uint8_t * seal_key,
+ void * pkp,
+ int md_nid,
+ buffer_t prefix,
+ buffer_t data,
+ buffer_t crt,
+ buffer_t * out)
+{
+ buffer_t sig = BUF_INIT;
+ buffer_t sign;
+ buffer_t aad;
+ buffer_t plain;
+ uint8_t * buf;
+ uint8_t * tmp;
+ uint16_t datalen;
+ uint16_t crtlen;
+ size_t body_len;
+ off_t offset;
+
+ datalen = hton16((uint16_t) data.len);
+ crtlen = hton16((uint16_t) crt.len);
+
+ body_len = OAP_SEAL_LENSZ + data.len + crt.len;
+
+ buf = malloc(prefix.len + body_len);
+ if (buf == NULL)
+ return -1;
+
+ memcpy(buf, prefix.data, prefix.len);
+ offset = (off_t) prefix.len;
+
+ memcpy(buf + offset, &datalen, sizeof(datalen));
+ offset += sizeof(datalen);
+
+ memcpy(buf + offset, &crtlen, sizeof(crtlen));
+ offset += sizeof(crtlen);
+
+ if (data.len != 0)
+ memcpy(buf + offset, data.data, data.len);
+
+ offset += data.len;
+
+ if (crt.len != 0)
+ memcpy(buf + offset, crt.data, crt.len);
+
+ /* Sign prefix ‖ lens ‖ data ‖ crt (plaintext, before sealing). */
+ sign.data = buf;
+ sign.len = prefix.len + body_len;
+
+ if (pkp != NULL && auth_sign(pkp, md_nid, sign, &sig) < 0)
+ goto fail_buf;
+
+ /* Append the signature so the seal covers lens ‖ data ‖ crt ‖ sig. */
+ if (sig.len != 0) {
+ tmp = realloc(buf, prefix.len + body_len + sig.len);
+ if (tmp == NULL)
+ goto fail_sig;
+
+ buf = tmp;
+ memcpy(buf + prefix.len + body_len, sig.data, sig.len);
+ }
+
+ aad.data = buf;
+ aad.len = prefix.len;
+ plain.data = buf + prefix.len;
+ plain.len = body_len + sig.len;
+
+ if (crypt_oneshot_seal(nid, seal_key, oap_seal_nonce,
+ aad, plain, out) < 0)
+ goto fail_sig;
+
+ free(buf);
+ freebuf(sig);
+
+ return 0;
+
+ fail_sig:
+ freebuf(sig);
+ fail_buf:
+ free(buf);
+ return -1;
+}
+
+/* Encode an identity-hidden response: wire = prefix ‖ oap_seal_body(...). */
+static int oap_hdr_encode_sealed(struct oap_hdr * hdr,
+ void * pkp,
+ void * crt,
+ struct sec_config * scfg,
+ buffer_t rsp_tag,
+ int req_md_nid,
+ const uint8_t * seal_key,
+ int flags)
+{
+ struct timespec now;
+ uint64_t stamp;
+ buffer_t der = BUF_INIT;
+ buffer_t sealed = BUF_INIT;
+ buffer_t prefix;
+ off_t offset;
+
+ clock_gettime(CLOCK_REALTIME, &now);
+ stamp = hton64(TS_TO_UINT64(now));
+
+ if (crt != NULL && crypt_crt_der(crt, &der) < 0)
+ goto fail_der;
+
+ prefix.len = OAP_HDR_MIN_SIZE + hdr->kex.len + rsp_tag.len;
+ prefix.data = malloc(prefix.len);
+ if (prefix.data == NULL)
+ goto fail_der;
+
+ /* Cleartext crt_len/data_len are 0; real lengths prefix the seal. */
+ write_oap_fixed(prefix.data, hdr, scfg, 0, 0, stamp);
+ offset = OAP_HDR_MIN_SIZE;
+
+ if (hdr->kex.len != 0)
+ memcpy(prefix.data + offset, hdr->kex.data, hdr->kex.len);
+
+ offset += hdr->kex.len;
+
+ if (rsp_tag.len != 0)
+ memcpy(prefix.data + offset, rsp_tag.data, rsp_tag.len);
+
+ offset += rsp_tag.len;
+
+ assert((size_t) offset == prefix.len);
+
+ if (oap_seal_body(hdr->nid, seal_key, pkp, scfg->d.nid,
+ prefix, hdr->data, der, &sealed) < 0)
+ goto fail_prefix;
+
+ hdr->hdr.len = prefix.len + sealed.len;
+ hdr->hdr.data = malloc(hdr->hdr.len);
+ if (hdr->hdr.data == NULL)
+ goto fail_sealed;
+
+ memcpy(hdr->hdr.data, prefix.data, prefix.len);
+ memcpy(hdr->hdr.data + prefix.len, sealed.data, sealed.len);
+
+ freebuf(sealed);
+ free(prefix.data);
+ freebuf(der);
+
+ if (oap_hdr_decode(hdr, hdr->hdr, req_md_nid,
+ flags & OAP_ENC_REKEY) < 0)
+ goto fail_decode;
+
+ return 0;
+
+ fail_decode:
+ oap_hdr_fini(hdr);
+ return -1;
+ fail_sealed:
+ freebuf(sealed);
+ fail_prefix:
+ free(prefix.data);
+ fail_der:
+ freebuf(der);
+ return -1;
+}
+
+int oap_hdr_encode(struct oap_hdr * hdr,
+ void * pkp,
+ void * crt,
+ struct sec_config * scfg,
+ buffer_t rsp_tag,
+ int req_md_nid,
+ const uint8_t * seal_key,
+ int flags)
+{
+ struct timespec now;
+ uint64_t stamp;
+ buffer_t out;
+ buffer_t der = BUF_INIT;
+ buffer_t sig = BUF_INIT;
+ buffer_t sign;
+ off_t offset;
+
+ assert(hdr != NULL);
+ assert(hdr->id.data != NULL && hdr->id.len == OAP_ID_SIZE);
+ assert(scfg != NULL);
+
+ if (seal_key != NULL)
+ return oap_hdr_encode_sealed(hdr, pkp, crt, scfg, rsp_tag,
+ req_md_nid, seal_key, flags);
+
+ clock_gettime(CLOCK_REALTIME, &now);
+ stamp = hton64(TS_TO_UINT64(now));
+
+ if (crt != NULL && crypt_crt_der(crt, &der) < 0)
+ goto fail_der;
+
+ /* Fixed header (36 bytes) + variable fields + rsp_tag (rsp only) */
+ out.len = OAP_HDR_MIN_SIZE + der.len + hdr->kex.len + hdr->data.len +
+ rsp_tag.len;
+
+ out.data = malloc(out.len);
+ if (out.data == NULL)
+ goto fail_out;
+
+ write_oap_fixed(out.data, hdr, scfg, der.len, hdr->data.len, stamp);
+ offset = OAP_HDR_MIN_SIZE;
+
+ /* certificate (variable) */
+ if (der.len != 0)
+ memcpy(out.data + offset, der.data, der.len);
+
+ offset += der.len;
+
+ /* kex data (variable) */
+ if (hdr->kex.len != 0)
+ memcpy(out.data + offset, hdr->kex.data, hdr->kex.len);
+
+ offset += hdr->kex.len;
+
+ /* data (variable) */
+ if (hdr->data.len != 0)
+ memcpy(out.data + offset, hdr->data.data, hdr->data.len);
+
+ offset += hdr->data.len;
+
+ /* rsp_tag (variable, response only) */
+ if (rsp_tag.len != 0)
+ memcpy(out.data + offset, rsp_tag.data, rsp_tag.len);
+
+ offset += rsp_tag.len;
+
+ assert((size_t) offset == out.len);
+
+ /* Sign the entire header (fixed + variable, excluding signature) */
+ sign.data = out.data;
+ sign.len = out.len;
+
+ if (pkp != NULL && auth_sign(pkp, scfg->d.nid, sign, &sig) < 0)
+ goto fail_sig;
+
+ hdr->hdr = out;
+
+ /* Append signature */
+ if (sig.len > 0) {
+ hdr->hdr.len += sig.len;
+ hdr->hdr.data = realloc(out.data, hdr->hdr.len);
+ if (hdr->hdr.data == NULL)
+ goto fail_realloc;
+
+ memcpy(hdr->hdr.data + offset, sig.data, sig.len);
+ }
+
+ /* Ownership moved to hdr->hdr; drop the alias to avoid double-free. */
+ clrbuf(out);
+
+ if (oap_hdr_decode(hdr, hdr->hdr, req_md_nid,
+ flags & OAP_ENC_REKEY) < 0)
+ goto fail_decode;
+
+ freebuf(der);
+ freebuf(sig);
+
+ return 0;
+
+ fail_decode:
+ oap_hdr_fini(hdr);
+ fail_realloc:
+ freebuf(sig);
+ fail_sig:
+ freebuf(out);
+ fail_out:
+ freebuf(der);
+ fail_der:
+ return -1;
+}
+
+int oap_hdr_unseal(struct oap_hdr * hdr,
+ const uint8_t * key)
+{
+ buffer_t pt = BUF_INIT;
+ buffer_t prefix;
+ uint8_t * recon;
+ size_t body_len;
+ size_t pt_len;
+ size_t data_len;
+ size_t crt_len;
+
+ assert(hdr != NULL);
+ assert(key != NULL);
+
+ if (hdr->sealed.data == NULL || hdr->sealed.len == 0)
+ return -EINVAL;
+
+ /* AAD prefix is fixed‖kex‖rsp_tag; sealed starts right after. */
+ prefix.data = hdr->hdr.data;
+ prefix.len = (size_t) (hdr->sealed.data - hdr->hdr.data);
+
+ if (crypt_oneshot_open(hdr->nid, key, oap_seal_nonce, prefix,
+ hdr->sealed, &pt) < 0)
+ return -ECRYPT;
+
+ pt_len = pt.len;
+
+ /* Plaintext = data_len ‖ crt_len ‖ data ‖ crt ‖ sig. */
+ if (pt_len < OAP_SEAL_LENSZ)
+ goto fail_auth;
+
+ data_len = (size_t) ntoh16(*(uint16_t *) pt.data);
+ crt_len = (size_t) ntoh16(*(uint16_t *)(pt.data + sizeof(uint16_t)));
+
+ body_len = OAP_SEAL_LENSZ + data_len + crt_len;
+ if (pt_len < body_len)
+ goto fail_auth;
+
+ /* Rebuild prefix ‖ lens ‖ data ‖ crt ‖ sig (whole signed region). */
+ recon = malloc(prefix.len + pt_len);
+ if (recon == NULL)
+ goto fail_mem;
+
+ memcpy(recon, prefix.data, prefix.len);
+ memcpy(recon + prefix.len, pt.data, pt_len);
+
+ freebuf(pt);
+
+ hdr->sealed_pt.data = recon;
+ hdr->sealed_pt.len = prefix.len + pt_len;
+
+ hdr->data.data = recon + prefix.len + OAP_SEAL_LENSZ;
+ hdr->data.len = data_len;
+ hdr->crt.data = recon + prefix.len + OAP_SEAL_LENSZ + data_len;
+ hdr->crt.len = crt_len;
+ hdr->sig.data = recon + prefix.len + body_len;
+ hdr->sig.len = pt_len - body_len;
+
+ return 0;
+
+ fail_mem:
+ freebuf(pt);
+ return -ENOMEM;
+ fail_auth:
+ freebuf(pt);
+ return -EAUTH;
+}
+
+#ifdef DEBUG_PROTO_OAP
+#define OAP_KEX_IS_KEM(hdr) ((hdr)->kex_flags.role | (hdr)->kex_flags.fmt)
+static void debug_oap_hdr(const struct oap_hdr * hdr)
+{
+ assert(hdr);
+
+ if (hdr->sealed.len > 0)
+ log_proto(" Sealed block: [%zu bytes] on wire",
+ hdr->sealed.len);
+
+ if (hdr->crt.len > 0)
+ log_proto(" crt: [%zu bytes]", hdr->crt.len);
+ else if (hdr->sealed.len > 0)
+ log_proto(" crt: <sealed>");
+ else
+ log_proto(" crt: <none>");
+
+ if (hdr->kex.len > 0) {
+ if (OAP_KEX_IS_KEM(hdr))
+ log_proto(" Key Exchange Data: [%zu bytes] [%s]",
+ hdr->kex.len,
+ hdr->kex_flags.role ?
+ "Client encaps" : "Server encaps");
+ else
+ log_proto(" Key Exchange Data: [%zu bytes]",
+ hdr->kex.len);
+ } else
+ log_proto(" Key Exchange Data: <none>");
+
+ if (hdr->cipher_str != NULL)
+ log_proto(" Cipher: %s", hdr->cipher_str);
+ else
+ log_proto(" Cipher: <none>");
+
+ if (hdr->kdf_str != NULL)
+ log_proto(" KDF: HKDF-%s", hdr->kdf_str);
+ else
+ log_proto(" KDF: <none>");
+
+ if (hdr->md_str != NULL)
+ log_proto(" Digest: %s", hdr->md_str);
+ else
+ log_proto(" Digest: <none>");
+
+ if (hdr->data.len > 0)
+ log_proto(" Data: [%zu bytes]", hdr->data.len);
+ else if (hdr->sealed.len > 0)
+ log_proto(" Data: <sealed>");
+ else
+ log_proto(" Data: <none>");
+
+ if (hdr->rsp_tag.len > 0)
+ log_proto(" Rsp Tag: [%zu bytes]", hdr->rsp_tag.len);
+ else
+ log_proto(" Rsp Tag: <none>");
+
+ if (hdr->sig.len > 0)
+ log_proto(" Signature: [%zu bytes]", hdr->sig.len);
+ else if (hdr->sealed.len > 0)
+ log_proto(" Signature: <sealed>");
+ else
+ log_proto(" Signature: <none>");
+}
+#endif
+
+void debug_oap_hdr_rcv(const struct oap_hdr * hdr)
+{
+#ifdef DEBUG_PROTO_OAP
+ struct tm * tm;
+ char tmstr[RIB_TM_STRLEN];
+ time_t stamp;
+
+ assert(hdr);
+
+ stamp = (time_t) hdr->timestamp / BILLION;
+
+ tm = gmtime(&stamp);
+ strftime(tmstr, sizeof(tmstr), RIB_TM_FORMAT, tm);
+
+ log_proto("OAP_HDR [" HASH_FMT64 " @ %s ]%s <--",
+ HASH_VAL64(hdr->id.data), tmstr,
+ hdr->sealed.len > 0 ? " [sealed]" : "");
+
+ debug_oap_hdr(hdr);
+#else
+ (void) hdr;
+#endif
+}
+
+void debug_oap_hdr_snd(const struct oap_hdr * hdr)
+{
+#ifdef DEBUG_PROTO_OAP
+ struct tm * tm;
+ char tmstr[RIB_TM_STRLEN];
+ time_t stamp;
+
+ assert(hdr);
+
+ stamp = (time_t) hdr->timestamp / BILLION;
+
+ tm = gmtime(&stamp);
+ strftime(tmstr, sizeof(tmstr), RIB_TM_FORMAT, tm);
+
+ log_proto("OAP_HDR [" HASH_FMT64 " @ %s ]%s -->",
+ HASH_VAL64(hdr->id.data), tmstr,
+ hdr->sealed.len > 0 ? " [sealed]" : "");
+
+ debug_oap_hdr(hdr);
+#else
+ (void) hdr;
+#endif
+}
diff --git a/src/irmd/oap/hdr.h b/src/irmd/oap/hdr.h
new file mode 100644
index 00000000..66fbac9a
--- /dev/null
+++ b/src/irmd/oap/hdr.h
@@ -0,0 +1,199 @@
+/*
+ * Ouroboros - Copyright (C) 2016 - 2026
+ *
+ * OAP - Header definitions and functions
+ *
+ * Dimitri Staessens <dimitri@ouroboros.rocks>
+ * Sander Vrijders <sander@ouroboros.rocks>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., http://www.fsf.org/about/contact/.
+ */
+
+#ifndef OUROBOROS_IRMD_OAP_HDR_H
+#define OUROBOROS_IRMD_OAP_HDR_H
+
+#include <ouroboros/crypt.h>
+#include <ouroboros/utils.h>
+
+#include <stdbool.h>
+#include <stdint.h>
+
+#define OAP_ID_SIZE (16)
+#define OAP_HDR_MIN_SIZE (OAP_ID_SIZE + sizeof(uint64_t) + 6 * sizeof(uint16_t))
+
+#define OAP_KEX_FMT_BIT 0x8000 /* bit 15: 0=X.509 DER, 1=Raw */
+#define OAP_KEX_ROLE_BIT 0x4000 /* bit 14: 0=Server encaps, 1=Client encaps */
+#define OAP_KEX_LEN_MASK 0x3FFF /* bits 0-13: Length (0-16383 bytes) */
+
+#define OAP_KEX_ROLE(hdr) (hdr->kex_flags.role)
+#define OAP_KEX_FMT(hdr) (hdr->kex_flags.fmt)
+
+#define OAP_KEX_IS_X509_FMT(hdr) (((hdr)->kex_flags.fmt) == 0)
+#define OAP_KEX_IS_RAW_FMT(hdr) (((hdr)->kex_flags.fmt) == 1)
+
+/*
+ * Plaintext layout (request, and unencrypted/signed response). The
+ * signature covers the whole packet except itself.
+ *
+ * 0 1 2 3
+ * 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+ * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ ---+
+ * | | |
+ * + + |
+ * | | |
+ * + id (128 bits) + |
+ * | Unique flow allocation ID | |
+ * + + |
+ * | | |
+ * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ |
+ * | | |
+ * + timestamp (64 bits) + |
+ * | UTC nanoseconds since epoch | |
+ * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ |
+ * | cipher_nid (16 bits) | kdf_nid (16 bits) | |
+ * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ |
+ * | md_nid (16 bits) | crt_len (16 bits) | |
+ * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ |
+ * |F|R| kex_len (14 bits) | data_len (16 bits) | | Signed
+ * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Region
+ * | | |
+ * + certificate (variable) + |
+ * | X.509 certificate, DER encoded | |
+ * + + |
+ * | | |
+ * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ |
+ * | | |
+ * + kex_data (variable) + |
+ * | public key (DER/raw) or ciphertext (KEM) | |
+ * + + |
+ * | | |
+ * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ |
+ * | | |
+ * + data (variable) + |
+ * | Piggybacked application data | |
+ * + + |
+ * | | |
+ * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ |
+ * | | |
+ * + rsp_tag (variable, response only) + |
+ * | key-confirm tag (enc), else H(request) | |
+ * | | |
+ * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ ---+
+ * | |
+ * + signature (variable) +
+ * | DSA signature over signed region |
+ * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ *
+ * Encrypted response - wire layout. The certificate, application data and
+ * signature are AEAD-sealed - hiding the server identity and the cert/data
+ * sizes; kex and rsp_tag move ahead of the sealed block as cleartext AAD.
+ *
+ * 0 1 2 3
+ * 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+ * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ ---+
+ * | fixed header (36 bytes, see above) | |
+ * + id, timestamp, NIDs, crt_len=0, kex_len, data_len=0 + | AAD
+ * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ |
+ * | kex_data (variable) | |
+ * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ |
+ * | rsp_tag (variable, response only) | |
+ * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ ---+
+ * | SEAL( data_len ‖ crt_len ‖ data ‖ crt ‖ sig ) | |
+ * + encrypted cert, app data and signature + | Sealed
+ * | + AEAD tag (128 bits) | | area
+ * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ ---+
+ *
+ * cipher_nid: NID value for symmetric cipher (0 = none)
+ * kdf_nid: NID value for KDF function (0 = none)
+ * md_nid: NID value for signature hash (0 = PQC/no signature)
+ *
+ * kex_len field bit layout:
+ * F (bit 15): Format - 0 = X.509 DER, 1 = Raw/Hybrid
+ * R (bit 14): Role - 0 = Server encaps, 1 = Client encaps
+ * (R is ignored for non-KEM algorithms)
+ * Bits 0-13: Length (0-16383 bytes)
+ *
+ * Request: sig_len = total - 36 - crt_len - kex_len - data_len
+ * Response: sig_len = total - 36 - crt_len - kex_len - data_len - hash_len
+ * where hash_len = md_len(req_md_nid / sha384)
+ *
+ * The signed plaintext inside the seal is prefix ‖ data_len ‖ crt_len ‖
+ * data ‖ crt ‖ sig; the cleartext prefix (fixed ‖ kex ‖ rsp_tag) is the
+ * AEAD AAD. Cleartext crt_len/data_len are 0 - the real lengths are sealed,
+ * hiding the cert and data sizes; oap_hdr_unseal reads them to split.
+ */
+
+/* Parsed OAP header - buffers pointing to a single memory region */
+struct oap_hdr {
+ const char * cipher_str;
+ const char * kdf_str;
+ const char * md_str;
+ uint64_t timestamp;
+ uint16_t nid;
+ uint16_t kdf_nid;
+ uint16_t md_nid;
+ struct {
+ bool fmt; /* Format */
+ bool role; /* Role */
+ } kex_flags;
+
+ buffer_t id;
+ buffer_t crt;
+ buffer_t kex;
+ buffer_t data;
+ buffer_t rsp_tag; /* key-confirm tag / H(req), rsp only */
+ buffer_t sig;
+ buffer_t sealed; /* wire ciphertext ‖ tag (sealed rsp) */
+ buffer_t sealed_pt; /* prefix‖lens‖data‖crt‖sig, owned */
+ buffer_t hdr;
+};
+
+
+void oap_hdr_init(struct oap_hdr * hdr,
+ buffer_t id,
+ uint8_t * kex_buf,
+ buffer_t data,
+ uint16_t nid);
+
+void oap_hdr_fini(struct oap_hdr * oap_hdr);
+
+/* oap_hdr_encode option flags */
+#define OAP_ENC_REKEY (1U << 0) /* signed, cert-less re-key packet */
+
+int oap_hdr_encode(struct oap_hdr * hdr,
+ void * pkp,
+ void * crt,
+ struct sec_config * scfg,
+ buffer_t rsp_tag,
+ int req_md_nid,
+ const uint8_t * seal_key,
+ int flags);
+
+int oap_hdr_decode(struct oap_hdr * hdr,
+ buffer_t buf,
+ int req_md_nid,
+ bool rekey);
+
+/* Decrypt a sealed response identity block; fills data, crt and sig. */
+int oap_hdr_unseal(struct oap_hdr * hdr,
+ const uint8_t * key);
+
+void debug_oap_hdr_rcv(const struct oap_hdr * hdr);
+
+void debug_oap_hdr_snd(const struct oap_hdr * hdr);
+
+int oap_hdr_copy_data(const struct oap_hdr * hdr,
+ buffer_t * out);
+
+#endif /* OUROBOROS_IRMD_OAP_HDR_H */
diff --git a/src/irmd/oap/internal.h b/src/irmd/oap/internal.h
new file mode 100644
index 00000000..4a156723
--- /dev/null
+++ b/src/irmd/oap/internal.h
@@ -0,0 +1,119 @@
+/*
+ * Ouroboros - Copyright (C) 2016 - 2026
+ *
+ * OAP internal definitions
+ *
+ * Dimitri Staessens <dimitri@ouroboros.rocks>
+ * Sander Vrijders <sander@ouroboros.rocks>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., http://www.fsf.org/about/contact/.
+ */
+
+#ifndef OUROBOROS_IRMD_OAP_INTERNAL_H
+#define OUROBOROS_IRMD_OAP_INTERNAL_H
+
+#include <ouroboros/crypt.h>
+#include <ouroboros/list.h>
+#include <ouroboros/name.h>
+#include <ouroboros/pthread.h>
+#include <ouroboros/utils.h>
+
+#include "hdr.h"
+
+#include <stdbool.h>
+#include <stdint.h>
+
+int oap_check_hdr(const struct oap_hdr * hdr);
+
+int oap_auth_peer(char * name,
+ const struct sec_config * cfg,
+ const struct oap_hdr * local_hdr,
+ const struct oap_hdr * peer_hdr);
+
+int oap_negotiate_cipher(const struct oap_hdr * peer_hdr,
+ struct sec_config * scfg);
+
+#ifndef OAP_TEST_MODE
+int load_credentials(const char * name,
+ const struct name_sec_paths * paths,
+ void ** pkp,
+ void ** crt);
+
+int load_sec_config(const char * name,
+ const char * path,
+ struct sec_config * cfg);
+#endif
+
+#ifndef OAP_TEST_MODE
+int load_srv_credentials(const struct name_info * info,
+ void ** pkp,
+ void ** crt);
+
+int load_srv_sec_config(const struct name_info * info,
+ struct sec_config * cfg);
+
+int load_server_kem_keypair(const char * name,
+ struct sec_config * cfg,
+ void ** pkp);
+#else
+extern int load_srv_credentials(const struct name_info * info,
+ void ** pkp,
+ void ** crt);
+extern int load_srv_sec_config(const struct name_info * info,
+ struct sec_config * cfg);
+extern int load_server_kem_keypair(const char * name,
+ struct sec_config * cfg,
+ void ** pkp);
+#endif
+
+int do_server_kex(const struct name_info * info,
+ struct oap_hdr * peer_hdr,
+ struct sec_config * scfg,
+ buffer_t * kex,
+ struct crypt_sk * sk);
+
+#ifndef OAP_TEST_MODE
+int load_cli_credentials(const struct name_info * info,
+ void ** pkp,
+ void ** crt);
+
+int load_cli_sec_config(const struct name_info * info,
+ struct sec_config * cfg);
+
+int load_server_kem_pk(const char * name,
+ struct sec_config * cfg,
+ buffer_t * pk);
+#else
+extern int load_cli_credentials(const struct name_info * info,
+ void ** pkp,
+ void ** crt);
+extern int load_cli_sec_config(const struct name_info * info,
+ struct sec_config * cfg);
+extern int load_server_kem_pk(const char * name,
+ struct sec_config * cfg,
+ buffer_t * pk);
+#endif
+
+int oap_client_kex_prepare(struct sec_config * scfg,
+ buffer_t server_pk,
+ buffer_t * kex,
+ uint8_t * key,
+ void ** ephemeral_pkp);
+
+int oap_client_kex_complete(const struct oap_hdr * peer_hdr,
+ struct sec_config * scfg,
+ void * pkp,
+ uint8_t * key);
+
+#endif /* OUROBOROS_IRMD_OAP_INTERNAL_H */
diff --git a/src/irmd/oap/io.c b/src/irmd/oap/io.c
new file mode 100644
index 00000000..845723fa
--- /dev/null
+++ b/src/irmd/oap/io.c
@@ -0,0 +1,158 @@
+/*
+ * Ouroboros - Copyright (C) 2016 - 2026
+ *
+ * OAP - File I/O for credentials and configuration
+ *
+ * Dimitri Staessens <dimitri@ouroboros.rocks>
+ * Sander Vrijders <sander@ouroboros.rocks>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., http://www.fsf.org/about/contact/.
+ */
+
+#if defined(__linux__) || defined(__CYGWIN__)
+ #define _DEFAULT_SOURCE
+#else
+ #define _POSIX_C_SOURCE 200809L
+#endif
+
+#define OUROBOROS_PREFIX "irmd/oap"
+
+#include <ouroboros/crypt.h>
+#include <ouroboros/errno.h>
+#include <ouroboros/logs.h>
+
+#include "config.h"
+
+#include "io.h"
+
+#include <assert.h>
+#include <string.h>
+#include <sys/stat.h>
+
+/*
+ * Shared credential and configuration loading helpers
+ */
+
+#ifndef OAP_TEST_MODE
+
+static bool file_exists(const char * path)
+{
+ struct stat s;
+
+ if (stat(path, &s) == 0)
+ return true;
+
+ if (errno == ENOENT) {
+ log_dbg("File %s does not exist.", path);
+ return false;
+ }
+
+ /* Can't stat for another reason; assume present, fail on load */
+ log_warn("Failed to stat %s: %s.", path, strerror(errno));
+
+ return true;
+}
+
+int load_credentials(const char * name,
+ const struct name_sec_paths * paths,
+ void ** pkp,
+ void ** crt)
+{
+ assert(paths != NULL);
+ assert(pkp != NULL);
+ assert(crt != NULL);
+
+ *pkp = NULL;
+ *crt = NULL;
+
+ if (!file_exists(paths->crt) || !file_exists(paths->key)) {
+ log_info("No authentication certificates for %s.", name);
+ return 0;
+ }
+
+ if (crypt_load_crt_file(paths->crt, crt) < 0) {
+ log_err("Failed to load %s for %s.", paths->crt, name);
+ goto fail_crt;
+ }
+
+ if (crypt_load_privkey_file(paths->key, pkp) < 0) {
+ log_err("Failed to load %s for %s.", paths->key, name);
+ goto fail_key;
+ }
+
+ log_info("Loaded authentication certificates for %s.", name);
+
+ return 0;
+
+ fail_key:
+ crypt_free_crt(*crt);
+ *crt = NULL;
+ fail_crt:
+ return -EAUTH;
+}
+
+int load_sec_config(const char * name,
+ const char * path,
+ struct sec_config * cfg)
+{
+ void * pin;
+
+ assert(name != NULL);
+ assert(cfg != NULL);
+
+ /* Load security config */
+ if (!file_exists(path))
+ log_dbg("No encryption %s for %s.", path, name);
+
+ if (load_sec_config_file(cfg, path) < 0) {
+ log_warn("Failed to load %s for %s.", path, name);
+ return -1;
+ }
+
+ if (cfg->a.cacert[0] != '\0') {
+ if (crypt_load_crt_file(cfg->a.cacert, &pin) < 0) {
+ log_err("Failed to load pinned CA %s for %s.",
+ cfg->a.cacert, name);
+ return -EAUTH;
+ }
+ crypt_free_crt(pin);
+ }
+
+ if (!IS_KEX_ALGO_SET(cfg)) {
+ log_info("Key exchange not configured for %s.", name);
+ return 0;
+ }
+#ifndef HAVE_ML
+ if (IS_KEM_ALGORITHM(cfg->x.str)) {
+ log_err("PQC not available, can't use %s for %s.",
+ cfg->x.str, name);
+ return -ENOTSUP;
+ }
+#endif
+ if (crypt_kex_rank(cfg->x.nid) < 1) {
+ log_err("Key exchange not supported for %s.", name);
+ return -ENOTSUP;
+ }
+
+ if (crypt_cipher_rank(cfg->c.nid) < 1) {
+ log_err("Cipher not supported for %s.", name);
+ return -ECRYPT;
+ }
+
+ log_info("Encryption enabled for %s.", name);
+
+ return 0;
+}
+
+#endif /* OAP_TEST_MODE */
diff --git a/src/irmd/oap/io.h b/src/irmd/oap/io.h
new file mode 100644
index 00000000..953e3898
--- /dev/null
+++ b/src/irmd/oap/io.h
@@ -0,0 +1,40 @@
+/*
+ * Ouroboros - Copyright (C) 2016 - 2026
+ *
+ * OAP - Credential and configuration file I/O
+ *
+ * Dimitri Staessens <dimitri@ouroboros.rocks>
+ * Sander Vrijders <sander@ouroboros.rocks>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., http://www.fsf.org/about/contact/.
+ */
+
+#ifndef OUROBOROS_IRMD_OAP_IO_H
+#define OUROBOROS_IRMD_OAP_IO_H
+
+#include <ouroboros/crypt.h>
+#include <ouroboros/name.h>
+
+#ifndef OAP_TEST_MODE
+int load_credentials(const char * name,
+ const struct name_sec_paths * paths,
+ void ** pkp,
+ void ** crt);
+
+int load_sec_config(const char * name,
+ const char * path,
+ struct sec_config * cfg);
+#endif
+
+#endif /* OUROBOROS_IRMD_OAP_IO_H */
diff --git a/src/irmd/oap/srv.c b/src/irmd/oap/srv.c
new file mode 100644
index 00000000..d78fc8d4
--- /dev/null
+++ b/src/irmd/oap/srv.c
@@ -0,0 +1,592 @@
+/*
+ * Ouroboros - Copyright (C) 2016 - 2026
+ *
+ * OAP - Server-side processing
+ *
+ * Dimitri Staessens <dimitri@ouroboros.rocks>
+ * Sander Vrijders <sander@ouroboros.rocks>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., http://www.fsf.org/about/contact/.
+ */
+
+#if defined(__linux__) || defined(__CYGWIN__)
+ #define _DEFAULT_SOURCE
+#else
+ #define _POSIX_C_SOURCE 200809L
+#endif
+
+#define OUROBOROS_PREFIX "irmd/oap"
+
+#include <ouroboros/crypt.h>
+#include <ouroboros/errno.h>
+#include <ouroboros/logs.h>
+
+#include "config.h"
+
+#include "auth.h"
+#include "hdr.h"
+#include "io.h"
+#include "oap.h"
+
+#include <assert.h>
+#include <limits.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#ifdef OAP_TEST_MODE
+extern int load_srv_credentials(const struct name_info * info,
+ void ** pkp,
+ void ** crt);
+extern int load_srv_sec_config(const struct name_info * info,
+ struct sec_config * cfg);
+extern int load_server_kem_keypair(const char * name,
+ bool raw_fmt,
+ void ** pkp);
+#else
+
+int load_srv_credentials(const struct name_info * info,
+ void ** pkp,
+ void ** crt)
+{
+ assert(info != NULL);
+ assert(pkp != NULL);
+ assert(crt != NULL);
+
+ return load_credentials(info->name, &info->s, pkp, crt);
+}
+
+int load_srv_sec_config(const struct name_info * info,
+ struct sec_config * cfg)
+{
+ assert(info != NULL);
+ assert(cfg != NULL);
+
+ memset(cfg, 0, sizeof(*cfg));
+
+ /* Client auth stays opt-in (mTLS); enable with auth=required */
+ return load_sec_config(info->name, info->s.sec, cfg);
+}
+
+int load_server_kem_keypair(const char * name,
+ bool raw_fmt,
+ void ** pkp)
+{
+ char path[PATH_MAX];
+ const char * ext;
+
+ assert(name != NULL);
+ assert(pkp != NULL);
+
+ ext = raw_fmt ? "raw" : "pem";
+
+ snprintf(path, sizeof(path),
+ OUROBOROS_SRV_CRT_DIR "/%s/kex.key.%s", name, ext);
+
+ if (raw_fmt) {
+ if (crypt_load_privkey_raw_file(path, pkp) < 0) {
+ log_err("Failed to load %s keypair from %s.",
+ ext, path);
+ return -ECRYPT;
+ }
+ } else {
+ if (crypt_load_privkey_file(path, pkp) < 0) {
+ log_err("Failed to load %s keypair from %s.",
+ ext, path);
+ return -ECRYPT;
+ }
+ }
+
+ log_dbg("Loaded server KEM keypair from %s.", path);
+ return 0;
+}
+
+#endif /* OAP_TEST_MODE */
+
+static int get_algo_from_peer_key(const struct oap_hdr * peer_hdr,
+ char * algo_buf)
+{
+ uint8_t * id = peer_hdr->id.data;
+ int ret;
+
+ if (OAP_KEX_IS_RAW_FMT(peer_hdr)) {
+ ret = kex_get_algo_from_pk_raw(peer_hdr->kex, algo_buf);
+ if (ret < 0) {
+ log_err_id(id, "Failed to get algo from raw key.");
+ return -ECRYPT;
+ }
+ } else {
+ ret = kex_get_algo_from_pk_der(peer_hdr->kex, algo_buf);
+ if (ret < 0) {
+ log_err_id(id, "Failed to get algo from DER key.");
+ return -ECRYPT;
+ }
+ }
+
+ return 0;
+}
+
+static int negotiate_cipher(const struct oap_hdr * peer_hdr,
+ struct sec_config * scfg)
+{
+ uint8_t * id = peer_hdr->id.data;
+ int cli_nid;
+ int cli_rank;
+ int srv_rank;
+
+ /* Cipher: select the strongest of client and server */
+ if (peer_hdr->cipher_str != NULL)
+ cli_nid = (int) crypt_str_to_nid(peer_hdr->cipher_str);
+ else
+ cli_nid = NID_undef;
+
+ if (cli_nid != NID_undef && crypt_cipher_rank(cli_nid) < 0) {
+ log_err_id(id, "Unsupported cipher '%s'.",
+ peer_hdr->cipher_str);
+ return -ENOTSUP;
+ }
+
+ cli_rank = crypt_cipher_rank(cli_nid);
+ srv_rank = crypt_cipher_rank(scfg->c.nid);
+
+ if (cli_rank > srv_rank) {
+ SET_KEX_CIPHER_NID(scfg, cli_nid);
+ log_dbg_id(id, "Selected client cipher %s.", scfg->c.str);
+ } else if (srv_rank > 0) {
+ log_dbg_id(id, "Selected server cipher %s.", scfg->c.str);
+ } else {
+ log_err_id(id, "Encryption requested, no cipher.");
+ return -ECRYPT;
+ }
+
+ /* KDF: select the strongest of client and server */
+ if (peer_hdr->kdf_nid != NID_undef
+ && crypt_kdf_rank(peer_hdr->kdf_nid) < 0) {
+ log_err_id(id, "Unsupported KDF NID %d.",
+ peer_hdr->kdf_nid);
+ return -ENOTSUP;
+ }
+
+ cli_rank = crypt_kdf_rank(peer_hdr->kdf_nid);
+ srv_rank = crypt_kdf_rank(scfg->k.nid);
+
+ /* Client-encap KEM bakes KDF into ciphertext; verify min. */
+ if (OAP_KEX_ROLE(peer_hdr) == KEM_MODE_CLIENT_ENCAP) {
+ if (srv_rank > cli_rank) {
+ log_err_id(id, "Client KDF too weak.");
+ return -ECRYPT;
+ }
+ SET_KEX_KDF_NID(scfg, peer_hdr->kdf_nid);
+ } else if (cli_rank > srv_rank) {
+ SET_KEX_KDF_NID(scfg, peer_hdr->kdf_nid);
+ log_dbg_id(id, "Selected client KDF %s.",
+ md_nid_to_str(scfg->k.nid));
+ } else if (srv_rank > 0) {
+ log_dbg_id(id, "Selected server KDF %s.",
+ md_nid_to_str(scfg->k.nid));
+ }
+
+ if (IS_KEX_ALGO_SET(scfg))
+ log_info_id(id, "Negotiated %s + %s.",
+ scfg->x.str, scfg->c.str);
+ else
+ log_info_id(id, "No key exchange.");
+
+ return 0;
+}
+
+static int do_server_kem_decap(const struct name_info * info,
+ const struct oap_hdr * peer_hdr,
+ struct sec_config * scfg,
+ struct crypt_sk * sk)
+{
+ buffer_t ct;
+ void * server_pkp = NULL;
+ int ret;
+ uint8_t * id = peer_hdr->id.data;
+
+ ret = load_server_kem_keypair(info->name,
+ peer_hdr->kex_flags.fmt,
+ &server_pkp);
+ if (ret < 0)
+ return ret;
+
+ ct.data = peer_hdr->kex.data;
+ ct.len = peer_hdr->kex.len;
+
+ ret = kex_kem_decap(server_pkp, ct, scfg->k.nid, sk->key);
+
+ crypt_free_key(server_pkp);
+
+ if (ret < 0) {
+ log_err_id(id, "Failed to decapsulate KEM.");
+ return -ECRYPT;
+ }
+
+ log_dbg_id(id, "Client encaps: decapsulated CT.");
+
+ return 0;
+}
+
+static int do_server_kem_encap(const struct oap_hdr * peer_hdr,
+ struct sec_config * scfg,
+ buffer_t * kex,
+ struct crypt_sk * sk)
+{
+ buffer_t client_pk;
+ ssize_t ct_len;
+ uint8_t * id = peer_hdr->id.data;
+
+ client_pk.data = peer_hdr->kex.data;
+ client_pk.len = peer_hdr->kex.len;
+
+ if (IS_HYBRID_KEM(scfg->x.str))
+ ct_len = kex_kem_encap_raw(client_pk, kex->data,
+ scfg->k.nid, sk->key);
+ else
+ ct_len = kex_kem_encap(client_pk, kex->data,
+ scfg->k.nid, sk->key);
+
+ if (ct_len < 0) {
+ log_err_id(id, "Failed to encapsulate KEM.");
+ return -ECRYPT;
+ }
+
+ kex->len = (size_t) ct_len;
+
+ log_dbg_id(id, "Server encaps: generated CT, len=%zd.", ct_len);
+
+ return 0;
+}
+
+static int do_server_kex_kem(const struct name_info * info,
+ struct oap_hdr * peer_hdr,
+ struct sec_config * scfg,
+ buffer_t * kex,
+ struct crypt_sk * sk)
+{
+ int ret;
+
+ scfg->x.mode = peer_hdr->kex_flags.role;
+
+ if (scfg->x.mode == KEM_MODE_CLIENT_ENCAP) {
+ ret = do_server_kem_decap(info, peer_hdr, scfg, sk);
+ kex->len = 0;
+ } else {
+ ret = do_server_kem_encap(peer_hdr, scfg, kex, sk);
+ }
+
+ return ret;
+}
+
+static int do_server_kex_dhe(const struct oap_hdr * peer_hdr,
+ struct sec_config * scfg,
+ buffer_t * kex,
+ struct crypt_sk * sk)
+{
+ ssize_t key_len;
+ void * epkp;
+ int ret;
+ uint8_t * id = peer_hdr->id.data;
+
+ key_len = kex_pkp_create(scfg, &epkp, kex->data);
+ if (key_len < 0) {
+ log_err_id(id, "Failed to generate key pair.");
+ return -ECRYPT;
+ }
+
+ kex->len = (size_t) key_len;
+
+ log_dbg_id(id, "Generated %s ephemeral keys.", scfg->x.str);
+
+ ret = kex_dhe_derive(scfg, epkp, peer_hdr->kex, sk->key);
+ if (ret < 0) {
+ log_err_id(id, "Failed to derive secret.");
+ kex_pkp_destroy(epkp);
+ return -ECRYPT;
+ }
+
+ kex_pkp_destroy(epkp);
+
+ return 0;
+}
+
+int do_server_kex(const struct name_info * info,
+ struct oap_hdr * peer_hdr,
+ struct sec_config * scfg,
+ buffer_t * kex,
+ struct crypt_sk * sk)
+{
+ char algo_buf[KEX_ALGO_BUFSZ];
+ int srv_kex_nid;
+ uint8_t * id;
+
+ id = peer_hdr->id.data;
+
+ /* No KEX data from client */
+ if (peer_hdr->kex.len == 0) {
+ if (IS_KEX_ALGO_SET(scfg)) {
+ log_warn_id(id, "KEX requested without info.");
+ return -ECRYPT;
+ }
+ return 0;
+ }
+
+ if (negotiate_cipher(peer_hdr, scfg) < 0)
+ return -ECRYPT;
+
+ /* Save server's configured KEX before overwriting */
+ srv_kex_nid = scfg->x.nid;
+
+ if (OAP_KEX_ROLE(peer_hdr) != KEM_MODE_CLIENT_ENCAP) {
+ /* Server encapsulation or DHE: extract algo from DER PK */
+ if (get_algo_from_peer_key(peer_hdr, algo_buf) < 0)
+ return -ECRYPT;
+
+ SET_KEX_ALGO(scfg, algo_buf);
+
+ /* Reject if client KEX is weaker than server's */
+ if (crypt_kex_rank(scfg->x.nid)
+ < crypt_kex_rank(srv_kex_nid)) {
+ log_err_id(id, "Client KEX %s too weak.",
+ scfg->x.str);
+ return -ECRYPT;
+ }
+ }
+
+ /* Dispatch based on algorithm type */
+ if (IS_KEM_ALGORITHM(scfg->x.str))
+ return do_server_kex_kem(info, peer_hdr, scfg, kex, sk);
+ else
+ return do_server_kex_dhe(peer_hdr, scfg, kex, sk);
+}
+
+int oap_srv_process(const struct name_info * info,
+ buffer_t req_buf,
+ buffer_t * rsp_buf,
+ buffer_t * data,
+ struct crypt_sk * sk,
+ bool rekey,
+ const buffer_t * cached_crt,
+ buffer_t * peer_crt)
+{
+ struct oap_hdr peer_hdr;
+ struct oap_hdr local_hdr;
+ struct sec_config scfg;
+ uint8_t kex_buf[CRYPT_KEY_BUFSZ];
+ uint8_t hash_buf[MAX_HASH_SIZE];
+ uint8_t kc_buf[MAX_HASH_SIZE];
+ uint8_t resp_hash_buf[MAX_HASH_SIZE];
+ uint8_t hs_key[SYMMKEYSZ];
+ const uint8_t * seal_key = NULL;
+ buffer_t req_hash = BUF_INIT;
+ buffer_t resp_hash = BUF_INIT;
+ buffer_t crt_der = BUF_INIT;
+ buffer_t rsp_tag = BUF_INIT;
+ ssize_t hash_ret;
+ char cli_name[NAME_SIZE + 1];
+ uint8_t * id;
+ void * pkp = NULL;
+ void * crt = NULL;
+ int req_md_nid;
+ int enc_flags = 0;
+ int ret;
+
+ assert(info != NULL);
+ assert(rsp_buf != NULL);
+ assert(data != NULL);
+ assert(sk != NULL);
+
+ sk->nid = NID_undef;
+
+ memset(&peer_hdr, 0, sizeof(peer_hdr));
+ memset(&local_hdr, 0, sizeof(local_hdr));
+ clrbuf(*rsp_buf);
+
+ log_dbg("Processing OAP request for %s.", info->name);
+
+ if (load_srv_credentials(info, &pkp, &crt) < 0) {
+ log_err("Failed to load security keys for %s.", info->name);
+ goto fail_cred;
+ }
+
+ /* Re-key omits the cert; the peer verifies against its cache. */
+ if (rekey && crt != NULL) {
+ crypt_free_crt(crt);
+ crt = NULL;
+ }
+
+ if (rekey)
+ enc_flags = OAP_ENC_REKEY;
+
+ if (load_srv_sec_config(info, &scfg) < 0) {
+ log_err("Failed to load security config for %s.", info->name);
+ goto fail_kex;
+ }
+
+ /* Decode incoming header (NID_undef = request, no hash) */
+ if (oap_hdr_decode(&peer_hdr, req_buf, NID_undef, rekey) < 0) {
+ log_err("Failed to decode OAP header.");
+ goto fail_auth;
+ }
+
+ debug_oap_hdr_rcv(&peer_hdr);
+
+ id = peer_hdr.id.data; /* Logging */
+
+ ret = oap_check_hdr(&peer_hdr);
+ if (ret == -EREPLAY) {
+ log_warn_id(id, "OAP header failed replay check.");
+ goto fail_replay;
+ }
+ if (ret < 0) {
+ log_err_id(id, "OAP header check failed.");
+ goto fail_auth;
+ }
+
+ oap_hdr_init(&local_hdr, peer_hdr.id, kex_buf, *data, NID_undef);
+
+ if (oap_auth_peer(cli_name, &scfg, &local_hdr, &peer_hdr,
+ cached_crt) < 0) {
+ log_err_id(id, "Failed to authenticate client.");
+ goto fail_auth;
+ }
+
+ /* Surface the peer cert so the caller can cache it for re-key. */
+ if (peer_crt != NULL && peer_hdr.crt.len > 0) {
+ peer_crt->data = malloc(peer_hdr.crt.len);
+ if (peer_crt->data == NULL)
+ goto fail_auth;
+
+ memcpy(peer_crt->data, peer_hdr.crt.data, peer_hdr.crt.len);
+ peer_crt->len = peer_hdr.crt.len;
+ }
+
+ /* A re-keyed flow is encrypted; refuse a plaintext re-key. */
+ if (rekey && peer_hdr.kex.len == 0) {
+ log_err_id(id, "Re-key request without KEX.");
+ goto fail_kex;
+ }
+
+ if (do_server_kex(info, &peer_hdr, &scfg, &local_hdr.kex, sk) < 0)
+ goto fail_kex;
+
+ sk->nid = scfg.c.nid;
+
+ /* Build response header with hash of client request */
+ local_hdr.nid = sk->nid;
+
+ /* Use client's md_nid, defaulting to SHA-384 for PQC */
+ req_md_nid = peer_hdr.md_nid != NID_undef ?
+ peer_hdr.md_nid : NID_sha384;
+
+ /* Compute request hash using client's md_nid */
+ hash_ret = md_digest(req_md_nid, req_buf, hash_buf);
+ if (hash_ret < 0) {
+ log_err_id(id, "Failed to hash request.");
+ goto fail_auth;
+ }
+ req_hash.data = hash_buf;
+ req_hash.len = (size_t) hash_ret;
+
+ rsp_tag = req_hash;
+
+ /* Bind the key to the transcript and confirm it to the client */
+ if (sk->nid != NID_undef) {
+ if (crt != NULL && crypt_crt_der(crt, &crt_der) < 0) {
+ log_err_id(id, "Failed to serialize cert.");
+ goto fail_auth;
+ }
+
+ resp_hash.data = resp_hash_buf;
+
+ ret = oap_resp_hash(req_md_nid, local_hdr.kex, *data,
+ crt_der, &resp_hash);
+
+ freebuf(crt_der);
+
+ if (ret < 0) {
+ log_err_id(id, "Failed to hash response.");
+ goto fail_auth;
+ }
+
+ /* Derive the identity-seal key before bind mutates sk->key */
+ if (oap_derive_hs_key(sk, req_hash, hs_key) < 0) {
+ log_err_id(id, "Failed to derive handshake key.");
+ goto fail_auth;
+ }
+
+ seal_key = hs_key;
+
+ if (oap_bind_session_key(sk, req_hash, resp_hash,
+ scfg.k.nid) < 0) {
+ log_err_id(id, "Failed to bind session key.");
+ goto fail_auth;
+ }
+
+ if (oap_key_confirm_tag(sk, req_hash, resp_hash, kc_buf,
+ (size_t) hash_ret) < 0) {
+ log_err_id(id, "Failed to confirm session key.");
+ goto fail_auth;
+ }
+
+ rsp_tag.data = kc_buf;
+ }
+
+ ret = oap_hdr_encode(&local_hdr, pkp, crt, &scfg,
+ rsp_tag, req_md_nid, seal_key, enc_flags);
+
+ crypt_secure_clear(hs_key, SYMMKEYSZ);
+
+ if (ret < 0) {
+ log_err_id(id, "Failed to create OAP response header.");
+ goto fail_auth;
+ }
+
+ debug_oap_hdr_snd(&local_hdr);
+
+ if (oap_hdr_copy_data(&peer_hdr, data) < 0) {
+ log_err_id(id, "Failed to copy client data.");
+ goto fail_data;
+ }
+
+ /* Transfer ownership of response buffer */
+ *rsp_buf = local_hdr.hdr;
+
+ log_info_id(id, "OAP request processed for %s.", info->name);
+
+ crypt_free_crt(crt);
+ crypt_free_key(pkp);
+
+ return 0;
+
+ fail_data:
+ oap_hdr_fini(&local_hdr);
+ fail_auth:
+ crypt_secure_clear(hs_key, SYMMKEYSZ);
+ crypt_free_crt(crt);
+ crypt_free_key(pkp);
+ fail_cred:
+ return -EAUTH;
+
+ fail_replay:
+ crypt_free_crt(crt);
+ crypt_free_key(pkp);
+ return -EREPLAY;
+
+ fail_kex:
+ crypt_free_crt(crt);
+ crypt_free_key(pkp);
+ return -ECRYPT;
+}
diff --git a/src/irmd/oap/tests/CMakeLists.txt b/src/irmd/oap/tests/CMakeLists.txt
new file mode 100644
index 00000000..b534cb72
--- /dev/null
+++ b/src/irmd/oap/tests/CMakeLists.txt
@@ -0,0 +1,64 @@
+get_filename_component(PARENT_PATH ${CMAKE_CURRENT_SOURCE_DIR} DIRECTORY)
+get_filename_component(PARENT_DIR ${PARENT_PATH} NAME)
+
+get_filename_component(OAP_SOURCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}" DIRECTORY)
+get_filename_component(OAP_BINARY_DIR "${CMAKE_CURRENT_BINARY_DIR}" DIRECTORY)
+get_filename_component(IRMD_SOURCE_DIR "${OAP_SOURCE_DIR}" DIRECTORY)
+get_filename_component(IRMD_BINARY_DIR "${OAP_BINARY_DIR}" DIRECTORY)
+
+compute_test_prefix()
+
+create_test_sourcelist(${PARENT_DIR}_tests test_suite.c
+ # Add new tests here
+ oap_test.c
+)
+
+create_test_sourcelist(${PARENT_DIR}_ml_dsa_tests test_suite_ml_dsa.c
+ # ML-DSA-specific tests
+ oap_test_ml_dsa.c
+)
+
+# OAP test needs io.c compiled with OAP_TEST_MODE
+set(OAP_TEST_SOURCES
+ ${OAP_SOURCE_DIR}/io.c
+ ${OAP_SOURCE_DIR}/hdr.c
+ ${OAP_SOURCE_DIR}/auth.c
+ ${OAP_SOURCE_DIR}/srv.c
+ ${OAP_SOURCE_DIR}/cli.c
+ ${CMAKE_CURRENT_SOURCE_DIR}/common.c
+)
+
+# Regular test executable (ECDSA)
+add_executable(${PARENT_DIR}_test ${${PARENT_DIR}_tests} ${OAP_TEST_SOURCES})
+set_source_files_properties(${OAP_TEST_SOURCES}
+ PROPERTIES COMPILE_DEFINITIONS "OAP_TEST_MODE"
+)
+
+disable_test_logging_for_target(${PARENT_DIR}_test)
+target_link_libraries(${PARENT_DIR}_test ouroboros-irm)
+target_include_directories(${PARENT_DIR}_test PRIVATE
+ ${IRMD_SOURCE_DIR}
+ ${IRMD_BINARY_DIR}
+)
+
+# ML-DSA test executable
+add_executable(${PARENT_DIR}_ml_dsa_test ${${PARENT_DIR}_ml_dsa_tests} ${OAP_TEST_SOURCES})
+set_source_files_properties(${OAP_TEST_SOURCES}
+ TARGET_DIRECTORY ${PARENT_DIR}_ml_dsa_test
+ PROPERTIES COMPILE_DEFINITIONS "OAP_TEST_MODE"
+)
+
+disable_test_logging_for_target(${PARENT_DIR}_ml_dsa_test)
+target_link_libraries(${PARENT_DIR}_ml_dsa_test ouroboros-irm)
+target_include_directories(${PARENT_DIR}_ml_dsa_test PRIVATE
+ ${IRMD_SOURCE_DIR}
+ ${IRMD_BINARY_DIR}
+)
+
+add_dependencies(build_tests ${PARENT_DIR}_test ${PARENT_DIR}_ml_dsa_test)
+
+# Regular tests
+ouroboros_register_tests(TARGET ${PARENT_DIR}_test TESTS ${${PARENT_DIR}_tests})
+
+# ML-DSA tests
+ouroboros_register_tests(TARGET ${PARENT_DIR}_ml_dsa_test TESTS ${${PARENT_DIR}_ml_dsa_tests})
diff --git a/src/irmd/oap/tests/common.c b/src/irmd/oap/tests/common.c
new file mode 100644
index 00000000..16d52c63
--- /dev/null
+++ b/src/irmd/oap/tests/common.c
@@ -0,0 +1,717 @@
+/*
+ * Ouroboros - Copyright (C) 2016 - 2026
+ *
+ * Common test helper functions for OAP tests
+ *
+ * Dimitri Staessens <dimitri@ouroboros.rocks>
+ * Sander Vrijders <sander@ouroboros.rocks>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., http://www.fsf.org/about/contact/.
+ */
+
+#include "common.h"
+
+#include <ouroboros/crypt.h>
+
+#include "oap.h"
+
+#include <string.h>
+#include <stdio.h>
+
+int load_srv_sec_config(const struct name_info * info,
+ struct sec_config * cfg)
+{
+ (void) info;
+
+ memset(cfg, 0, sizeof(*cfg));
+
+ cfg->a.req = test_cfg.srv.req_auth;
+
+ /* Digest is kept without kex, as in parse_sec_config */
+ SET_KEX_DIGEST_NID(cfg, test_cfg.srv.md);
+
+ if (test_cfg.srv.kex == NID_undef)
+ return 0;
+
+ SET_KEX_ALGO_NID(cfg, test_cfg.srv.kex);
+ SET_KEX_CIPHER_NID(cfg, test_cfg.srv.cipher);
+ SET_KEX_KDF_NID(cfg, test_cfg.srv.kdf);
+ SET_KEX_KEM_MODE(cfg, test_cfg.srv.kem_mode);
+
+ return 0;
+}
+
+int load_cli_sec_config(const struct name_info * info,
+ struct sec_config * cfg)
+{
+ (void) info;
+
+ memset(cfg, 0, sizeof(*cfg));
+
+ cfg->a.req = test_cfg.cli.req_auth;
+
+ /* Digest is kept without kex, as in parse_sec_config */
+ SET_KEX_DIGEST_NID(cfg, test_cfg.cli.md);
+
+ if (test_cfg.cli.kex == NID_undef)
+ return 0;
+
+ SET_KEX_ALGO_NID(cfg, test_cfg.cli.kex);
+ SET_KEX_CIPHER_NID(cfg, test_cfg.cli.cipher);
+ SET_KEX_KDF_NID(cfg, test_cfg.cli.kdf);
+ SET_KEX_KEM_MODE(cfg, test_cfg.cli.kem_mode);
+
+ return 0;
+}
+
+int load_srv_credentials(const struct name_info * info,
+ void ** pkp,
+ void ** crt)
+{
+ (void) info;
+
+ *pkp = NULL;
+ *crt = NULL;
+
+ if (!test_cfg.srv.auth)
+ return 0;
+
+ return mock_load_credentials(pkp, crt);
+}
+
+int load_cli_credentials(const struct name_info * info,
+ void ** pkp,
+ void ** crt)
+{
+ (void) info;
+
+ *pkp = NULL;
+ *crt = NULL;
+
+ if (!test_cfg.cli.auth)
+ return 0;
+
+ return mock_load_credentials(pkp, crt);
+}
+
+int oap_test_setup(struct oap_test_ctx * ctx,
+ const char * root_ca_str,
+ const char * im_ca_str)
+{
+ memset(ctx, 0, sizeof(*ctx));
+
+ strcpy(ctx->srv.info.name, "test-1.unittest.o7s");
+ strcpy(ctx->cli.info.name, "test-1.unittest.o7s");
+
+ if (oap_auth_init() < 0) {
+ printf("Failed to init OAP.\n");
+ goto fail_init;
+ }
+
+ if (crypt_load_crt_str(root_ca_str, &ctx->root_ca) < 0) {
+ printf("Failed to load root CA cert.\n");
+ goto fail_root_ca;
+ }
+
+ if (crypt_load_crt_str(im_ca_str, &ctx->im_ca) < 0) {
+ printf("Failed to load intermediate CA cert.\n");
+ goto fail_im_ca;
+ }
+
+ if (oap_auth_add_ca_crt(ctx->root_ca) < 0) {
+ printf("Failed to add root CA cert to store.\n");
+ goto fail_add_ca;
+ }
+
+ if (oap_auth_add_ca_crt(ctx->im_ca) < 0) {
+ printf("Failed to add intermediate CA cert to store.\n");
+ goto fail_add_ca;
+ }
+
+ return 0;
+
+ fail_add_ca:
+ crypt_free_crt(ctx->im_ca);
+ fail_im_ca:
+ crypt_free_crt(ctx->root_ca);
+ fail_root_ca:
+ oap_auth_fini();
+ fail_init:
+ memset(ctx, 0, sizeof(*ctx));
+ return -1;
+}
+
+void oap_test_teardown(struct oap_test_ctx * ctx)
+{
+ struct crypt_sk res;
+ buffer_t dummy = BUF_INIT;
+
+ if (ctx->cli.state != NULL) {
+ res.key = ctx->cli.key;
+ oap_cli_complete(ctx->cli.state, &ctx->cli.info, dummy,
+ &ctx->data, &res, NULL, NULL);
+ ctx->cli.state = NULL;
+ }
+
+ freebuf(ctx->data);
+ freebuf(ctx->resp_hdr);
+ freebuf(ctx->req_hdr);
+ freebuf(ctx->srv_crt);
+ freebuf(ctx->cli_crt);
+
+ crypt_free_crt(ctx->im_ca);
+ crypt_free_crt(ctx->root_ca);
+
+ oap_auth_fini();
+ memset(ctx, 0, sizeof(*ctx));
+}
+
+int oap_cli_prepare_ctx(struct oap_test_ctx * ctx)
+{
+ return oap_cli_prepare(&ctx->cli.state, &ctx->cli.info, &ctx->req_hdr,
+ ctx->data, ctx->rekey);
+}
+
+int oap_srv_process_ctx(struct oap_test_ctx * ctx)
+{
+ struct crypt_sk res = { .nid = NID_undef, .key = ctx->srv.key };
+ int ret;
+
+ ret = oap_srv_process(&ctx->srv.info, ctx->req_hdr,
+ &ctx->resp_hdr, &ctx->data, &res, ctx->rekey,
+ ctx->rekey ? &ctx->srv_crt : NULL,
+ ctx->rekey ? NULL : &ctx->srv_crt);
+ if (ret == 0)
+ ctx->srv.nid = res.nid;
+
+ return ret;
+}
+
+int oap_cli_complete_ctx(struct oap_test_ctx * ctx)
+{
+ struct crypt_sk res = { .nid = NID_undef, .key = ctx->cli.key };
+ int ret;
+
+ ret = oap_cli_complete(ctx->cli.state, &ctx->cli.info, ctx->resp_hdr,
+ &ctx->data, &res,
+ ctx->rekey ? &ctx->cli_crt : NULL,
+ ctx->rekey ? NULL : &ctx->cli_crt);
+ ctx->cli.state = NULL;
+
+ if (ret == 0)
+ ctx->cli.nid = res.nid;
+
+ return ret;
+}
+
+int roundtrip_auth_only(const char * root_ca,
+ const char * im_ca_str)
+{
+ struct oap_test_ctx ctx;
+
+ TEST_START();
+
+ if (oap_test_setup(&ctx, root_ca, im_ca_str) < 0)
+ goto fail;
+
+ if (oap_cli_prepare_ctx(&ctx) < 0) {
+ printf("Client prepare failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (oap_srv_process_ctx(&ctx) < 0) {
+ printf("Server process failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (oap_cli_complete_ctx(&ctx) < 0) {
+ printf("Client complete failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (ctx.cli.nid != NID_undef || ctx.srv.nid != NID_undef) {
+ printf("Cipher should not be set for auth-only.\n");
+ goto fail_cleanup;
+ }
+
+ oap_test_teardown(&ctx);
+
+ TEST_SUCCESS();
+ return TEST_RC_SUCCESS;
+
+ fail_cleanup:
+ oap_test_teardown(&ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+static const char * rekey_mode(bool srv_auth,
+ bool cli_auth)
+{
+ if (srv_auth && cli_auth)
+ return "mutual";
+
+ if (srv_auth)
+ return "srv-only";
+
+ if (cli_auth)
+ return "cli-only";
+
+ return "none";
+}
+
+int roundtrip_rekey(const char * root_ca,
+ const char * im_ca_str,
+ bool srv_auth,
+ bool cli_auth)
+{
+ struct oap_test_ctx ctx;
+ uint8_t key0[SYMMKEYSZ];
+ const char * mode = rekey_mode(srv_auth, cli_auth);
+
+ TEST_START("(%s)", mode);
+
+ if (oap_test_setup(&ctx, root_ca, im_ca_str) < 0)
+ goto fail;
+
+ if (oap_cli_prepare_ctx(&ctx) < 0) {
+ printf("Initial client prepare failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (oap_srv_process_ctx(&ctx) < 0) {
+ printf("Initial server process failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (oap_cli_complete_ctx(&ctx) < 0) {
+ printf("Initial client complete failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (memcmp(ctx.cli.key, ctx.srv.key, SYMMKEYSZ) != 0) {
+ printf("Initial keys do not match.\n");
+ goto fail_cleanup;
+ }
+
+ /* The client caches the server cert only if the server authed. */
+ if (srv_auth && ctx.cli_crt.len == 0) {
+ printf("Server cert was not cached for re-key.\n");
+ goto fail_cleanup;
+ }
+
+ /* The server caches the client cert only if the client authed. */
+ if (cli_auth && ctx.srv_crt.len == 0) {
+ printf("Client cert was not cached by the server.\n");
+ goto fail_cleanup;
+ }
+
+ memcpy(key0, ctx.cli.key, SYMMKEYSZ);
+
+ /* Re-key: cert dropped on the wire, verified against the cache. */
+ freebuf(ctx.req_hdr);
+ freebuf(ctx.resp_hdr);
+ freebuf(ctx.data);
+
+ ctx.rekey = true;
+
+ if (oap_cli_prepare_ctx(&ctx) < 0) {
+ printf("Re-key client prepare failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (oap_srv_process_ctx(&ctx) < 0) {
+ printf("Re-key server process failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (oap_cli_complete_ctx(&ctx) < 0) {
+ printf("Re-key client complete failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (memcmp(ctx.cli.key, ctx.srv.key, SYMMKEYSZ) != 0) {
+ printf("Re-key keys do not match.\n");
+ goto fail_cleanup;
+ }
+
+ if (memcmp(ctx.cli.key, key0, SYMMKEYSZ) == 0) {
+ printf("Re-key did not produce a fresh key.\n");
+ goto fail_cleanup;
+ }
+
+ if (ctx.cli.nid == NID_undef || ctx.srv.nid == NID_undef) {
+ printf("Cipher not set after re-key.\n");
+ goto fail_cleanup;
+ }
+
+ oap_test_teardown(&ctx);
+
+ TEST_SUCCESS("(%s)", mode);
+
+ return TEST_RC_SUCCESS;
+ fail_cleanup:
+ oap_test_teardown(&ctx);
+ fail:
+ TEST_FAIL("(%s)", mode);
+ return TEST_RC_FAIL;
+}
+
+int roundtrip_rekey_badcache(const char * root_ca,
+ const char * im_ca_str,
+ bool cli_auth)
+{
+ struct oap_test_ctx ctx;
+ const char * mode = rekey_mode(true, cli_auth);
+
+ TEST_START("(%s)", mode);
+
+ if (oap_test_setup(&ctx, root_ca, im_ca_str) < 0)
+ goto fail;
+
+ if (oap_cli_prepare_ctx(&ctx) < 0) {
+ printf("Initial client prepare failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (oap_srv_process_ctx(&ctx) < 0) {
+ printf("Initial server process failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (oap_cli_complete_ctx(&ctx) < 0) {
+ printf("Initial client complete failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (ctx.cli_crt.len == 0) {
+ printf("Server cert was not cached.\n");
+ goto fail_cleanup;
+ }
+
+ /* Corrupt the client's cached server cert: re-key must fail closed. */
+ ctx.cli_crt.data[ctx.cli_crt.len / 2] ^= 0xFF;
+
+ freebuf(ctx.req_hdr);
+ freebuf(ctx.resp_hdr);
+ freebuf(ctx.data);
+
+ ctx.rekey = true;
+
+ if (oap_cli_prepare_ctx(&ctx) < 0) {
+ printf("Re-key client prepare failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (oap_srv_process_ctx(&ctx) < 0) {
+ printf("Re-key server process failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (oap_cli_complete_ctx(&ctx) == 0) {
+ printf("Re-key accepted a corrupted cached cert.\n");
+ goto fail_cleanup;
+ }
+
+ oap_test_teardown(&ctx);
+
+ TEST_SUCCESS("(%s)", mode);
+
+ return TEST_RC_SUCCESS;
+ fail_cleanup:
+ oap_test_teardown(&ctx);
+ fail:
+ TEST_FAIL("(%s)", mode);
+ return TEST_RC_FAIL;
+}
+
+int roundtrip_rekey_srv_badcache(const char * root_ca,
+ const char * im_ca_str,
+ bool srv_auth)
+{
+ struct oap_test_ctx ctx;
+ const char * mode = rekey_mode(srv_auth, true);
+
+ TEST_START("(%s)", mode);
+
+ if (oap_test_setup(&ctx, root_ca, im_ca_str) < 0)
+ goto fail;
+
+ if (oap_cli_prepare_ctx(&ctx) < 0) {
+ printf("Initial client prepare failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (oap_srv_process_ctx(&ctx) < 0) {
+ printf("Initial server process failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (oap_cli_complete_ctx(&ctx) < 0) {
+ printf("Initial client complete failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (ctx.srv_crt.len == 0) {
+ printf("Client cert was not cached by the server.\n");
+ goto fail_cleanup;
+ }
+
+ /* Corrupt the server's cached client cert: re-key must fail closed. */
+ ctx.srv_crt.data[ctx.srv_crt.len / 2] ^= 0xFF;
+
+ freebuf(ctx.req_hdr);
+ freebuf(ctx.resp_hdr);
+ freebuf(ctx.data);
+
+ ctx.rekey = true;
+
+ if (oap_cli_prepare_ctx(&ctx) < 0) {
+ printf("Re-key client prepare failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (oap_srv_process_ctx(&ctx) == 0) {
+ printf("Server accepted a corrupted cached client cert.\n");
+ goto fail_cleanup;
+ }
+
+ oap_test_teardown(&ctx);
+
+ TEST_SUCCESS("(%s)", mode);
+
+ return TEST_RC_SUCCESS;
+ fail_cleanup:
+ oap_test_teardown(&ctx);
+ fail:
+ TEST_FAIL("(%s)", mode);
+ return TEST_RC_FAIL;
+}
+
+int roundtrip_kex_only(void)
+{
+ struct name_info cli_info;
+ struct name_info srv_info;
+ struct crypt_sk res;
+ uint8_t cli_key[SYMMKEYSZ];
+ uint8_t srv_key[SYMMKEYSZ];
+ int cli_nid;
+ int srv_nid;
+ buffer_t req_hdr = BUF_INIT;
+ buffer_t resp_hdr = BUF_INIT;
+ buffer_t data = BUF_INIT;
+ void * cli_state = NULL;
+
+ TEST_START();
+
+ memset(&cli_info, 0, sizeof(cli_info));
+ memset(&srv_info, 0, sizeof(srv_info));
+
+ strcpy(cli_info.name, "test-1.unittest.o7s");
+ strcpy(srv_info.name, "test-1.unittest.o7s");
+
+ if (oap_auth_init() < 0) {
+ printf("Failed to init OAP.\n");
+ goto fail;
+ }
+
+ if (oap_cli_prepare(&cli_state, &cli_info, &req_hdr,
+ data, false) < 0) {
+ printf("Client prepare failed.\n");
+ goto fail_cleanup;
+ }
+
+ res.key = srv_key;
+
+ if (oap_srv_process(&srv_info, req_hdr, &resp_hdr, &data, &res,
+ false, NULL, NULL) < 0) {
+ printf("Server process failed.\n");
+ goto fail_cleanup;
+ }
+
+ srv_nid = res.nid;
+
+ res.key = cli_key;
+
+ if (oap_cli_complete(cli_state, &cli_info, resp_hdr, &data, &res,
+ NULL, NULL) < 0) {
+ printf("Client complete failed.\n");
+ cli_state = NULL;
+ goto fail_cleanup;
+ }
+
+ cli_nid = res.nid;
+ cli_state = NULL;
+
+ if (memcmp(cli_key, srv_key, SYMMKEYSZ) != 0) {
+ printf("Client and server keys do not match!\n");
+ goto fail_cleanup;
+ }
+
+ if (cli_nid == NID_undef || srv_nid == NID_undef) {
+ printf("Cipher should be set for kex-only.\n");
+ goto fail_cleanup;
+ }
+
+ freebuf(resp_hdr);
+ freebuf(req_hdr);
+ oap_auth_fini();
+
+ TEST_SUCCESS();
+ return TEST_RC_SUCCESS;
+
+ fail_cleanup:
+ if (cli_state != NULL) {
+ res.key = cli_key;
+ oap_cli_complete(cli_state, &cli_info, resp_hdr, &data,
+ &res, NULL, NULL);
+ }
+ freebuf(resp_hdr);
+ freebuf(req_hdr);
+ oap_auth_fini();
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+int corrupted_request(const char * root_ca,
+ const char * im_ca_str)
+{
+ struct oap_test_ctx ctx;
+
+ TEST_START();
+
+ if (oap_test_setup(&ctx, root_ca, im_ca_str) < 0)
+ goto fail;
+
+ if (oap_cli_prepare_ctx(&ctx) < 0) {
+ printf("Client prepare failed.\n");
+ goto fail_cleanup;
+ }
+
+ /* Corrupt the request */
+ if (ctx.req_hdr.len > 100) {
+ ctx.req_hdr.data[50] ^= 0xFF;
+ ctx.req_hdr.data[51] ^= 0xAA;
+ ctx.req_hdr.data[52] ^= 0x55;
+ }
+
+ if (oap_srv_process_ctx(&ctx) == 0) {
+ printf("Server should reject corrupted request.\n");
+ goto fail_cleanup;
+ }
+
+ oap_test_teardown(&ctx);
+
+ TEST_SUCCESS();
+ return TEST_RC_SUCCESS;
+
+ fail_cleanup:
+ oap_test_teardown(&ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+int corrupted_response(const char * root_ca,
+ const char * im_ca_str)
+{
+ struct oap_test_ctx ctx;
+ struct crypt_sk res;
+
+ TEST_START();
+
+ if (oap_test_setup(&ctx, root_ca, im_ca_str) < 0)
+ goto fail;
+
+ if (oap_cli_prepare_ctx(&ctx) < 0) {
+ printf("Client prepare failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (oap_srv_process_ctx(&ctx) < 0) {
+ printf("Server process failed.\n");
+ goto fail_cleanup;
+ }
+
+ /* Corrupt the response */
+ if (ctx.resp_hdr.len > 100) {
+ ctx.resp_hdr.data[50] ^= 0xFF;
+ ctx.resp_hdr.data[51] ^= 0xAA;
+ ctx.resp_hdr.data[52] ^= 0x55;
+ }
+
+ res.key = ctx.cli.key;
+
+ if (oap_cli_complete(ctx.cli.state, &ctx.cli.info, ctx.resp_hdr,
+ &ctx.data, &res, NULL, NULL) == 0) {
+ printf("Client should reject corrupted response.\n");
+ ctx.cli.state = NULL;
+ goto fail_cleanup;
+ }
+
+ ctx.cli.state = NULL;
+
+ oap_test_teardown(&ctx);
+
+ TEST_SUCCESS();
+ return TEST_RC_SUCCESS;
+
+ fail_cleanup:
+ oap_test_teardown(&ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+int truncated_request(const char * root_ca,
+ const char * im_ca_str)
+{
+ struct oap_test_ctx ctx;
+ size_t orig_len;
+
+ TEST_START();
+
+ if (oap_test_setup(&ctx, root_ca, im_ca_str) < 0)
+ goto fail;
+
+ if (oap_cli_prepare_ctx(&ctx) < 0) {
+ printf("Client prepare failed.\n");
+ goto fail_cleanup;
+ }
+
+ /* Truncate the request buffer */
+ orig_len = ctx.req_hdr.len;
+ ctx.req_hdr.len = orig_len / 2;
+
+ if (oap_srv_process_ctx(&ctx) == 0) {
+ printf("Server should reject truncated request.\n");
+ ctx.req_hdr.len = orig_len;
+ goto fail_cleanup;
+ }
+
+ ctx.req_hdr.len = orig_len;
+
+ oap_test_teardown(&ctx);
+
+ TEST_SUCCESS();
+ return TEST_RC_SUCCESS;
+
+ fail_cleanup:
+ oap_test_teardown(&ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
diff --git a/src/irmd/oap/tests/common.h b/src/irmd/oap/tests/common.h
new file mode 100644
index 00000000..7aead07a
--- /dev/null
+++ b/src/irmd/oap/tests/common.h
@@ -0,0 +1,122 @@
+/*
+ * Ouroboros - Copyright (C) 2016 - 2026
+ *
+ * Common test helper functions for OAP tests
+ *
+ * Dimitri Staessens <dimitri@ouroboros.rocks>
+ * Sander Vrijders <sander@ouroboros.rocks>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., http://www.fsf.org/about/contact/.
+ */
+
+#ifndef IRMD_TESTS_COMMON_H
+#define IRMD_TESTS_COMMON_H
+
+#include <ouroboros/utils.h>
+#include <ouroboros/flow.h>
+#include <ouroboros/name.h>
+#include <test/test.h>
+
+#include <stdbool.h>
+
+#define AUTH true
+#define NO_AUTH false
+
+/* Per-side security configuration for tests */
+struct test_sec_cfg {
+ int kex; /* KEX algorithm NID */
+ int cipher; /* Cipher NID for encryption */
+ int kdf; /* KDF NID for key derivation */
+ int md; /* Digest NID for signatures */
+ int kem_mode; /* KEM encapsulation mode (0 for ECDH) */
+ bool auth; /* Use authentication (certificates) */
+ bool req_auth; /* Require peer authentication */
+};
+
+/* Test configuration - set by each test before running roundtrip */
+extern struct test_cfg {
+ struct test_sec_cfg srv;
+ struct test_sec_cfg cli;
+} test_cfg;
+
+/* Each test file defines this with its own certificates */
+extern int mock_load_credentials(void ** pkp,
+ void ** crt);
+
+/* Per-side test context */
+struct oap_test_side {
+ struct name_info info;
+ struct flow_info flow;
+ uint8_t key[SYMMKEYSZ];
+ int nid;
+ void * state;
+};
+
+/* Test context - holds all common state for OAP tests */
+struct oap_test_ctx {
+ struct oap_test_side srv;
+ struct oap_test_side cli;
+
+ buffer_t req_hdr;
+ buffer_t resp_hdr;
+ buffer_t data;
+ void * root_ca;
+ void * im_ca;
+
+ /* Re-key (tier iii): drop the cert, verify against the cache. */
+ bool rekey;
+ buffer_t srv_crt; /* client cert cached by server */
+ buffer_t cli_crt; /* server cert cached by client */
+};
+
+int oap_test_setup(struct oap_test_ctx * ctx,
+ const char * root_ca_str,
+ const char * im_ca_str);
+
+void oap_test_teardown(struct oap_test_ctx * ctx);
+
+int oap_cli_prepare_ctx(struct oap_test_ctx * ctx);
+
+int oap_srv_process_ctx(struct oap_test_ctx * ctx);
+
+int oap_cli_complete_ctx(struct oap_test_ctx * ctx);
+
+int roundtrip_auth_only(const char * root_ca,
+ const char * im_ca_str);
+
+int roundtrip_rekey(const char * root_ca,
+ const char * im_ca_str,
+ bool srv_auth,
+ bool cli_auth);
+
+int roundtrip_rekey_badcache(const char * root_ca,
+ const char * im_ca_str,
+ bool cli_auth);
+
+int roundtrip_rekey_srv_badcache(const char * root_ca,
+ const char * im_ca_str,
+ bool srv_auth);
+
+int roundtrip_kex_only(void);
+
+int corrupted_request(const char * root_ca,
+ const char * im_ca_str);
+
+int corrupted_response(const char * root_ca,
+ const char * im_ca_str);
+
+int truncated_request(const char * root_ca,
+ const char * im_ca_str);
+
+#endif /* IRMD_TESTS_COMMON_H */
diff --git a/src/irmd/oap/tests/oap_test.c b/src/irmd/oap/tests/oap_test.c
new file mode 100644
index 00000000..b24bb786
--- /dev/null
+++ b/src/irmd/oap/tests/oap_test.c
@@ -0,0 +1,2109 @@
+/*
+ * Ouroboros - Copyright (C) 2016 - 2026
+ *
+ * Unit tests of Ouroboros Allocation Protocol (OAP)
+ *
+ * Dimitri Staessens <dimitri@ouroboros.rocks>
+ * Sander Vrijders <sander@ouroboros.rocks>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., http://www.fsf.org/about/contact/.
+ */
+
+#if defined(__linux__) || defined(__CYGWIN__)
+ #ifndef _DEFAULT_SOURCE
+ #define _DEFAULT_SOURCE
+ #endif
+#else
+#define _POSIX_C_SOURCE 200809L
+#endif
+
+#include "config.h"
+
+#include <ouroboros/crypt.h>
+#include <ouroboros/endian.h>
+#include <ouroboros/errno.h>
+#include <ouroboros/flow.h>
+#include <ouroboros/name.h>
+#include <ouroboros/random.h>
+#include <ouroboros/time.h>
+
+#include <test/test.h>
+#include <test/certs/ecdsa.h>
+
+#include "oap.h"
+#include "oap/auth.h"
+#include "common.h"
+
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#ifdef HAVE_OPENSSL
+#include <openssl/evp.h>
+#endif
+
+extern const uint16_t kex_supported_nids[];
+extern const uint16_t md_supported_nids[];
+
+struct test_cfg test_cfg;
+
+/* Mock load - called by load_*_credentials in common.c */
+int mock_load_credentials(void ** pkp,
+ void ** crt)
+{
+ *crt = NULL;
+
+ if (crypt_load_privkey_str(server_pkp_ec, pkp) < 0)
+ goto fail_privkey;
+
+ if (crypt_load_crt_str(signed_server_crt_ec, crt) < 0)
+ goto fail_crt;
+
+ return 0;
+
+ fail_crt:
+ crypt_free_key(*pkp);
+ fail_privkey:
+ *pkp = NULL;
+ return -1;
+}
+
+/* Stub KEM functions - ECDSA tests don't use KEM */
+int load_server_kem_keypair(__attribute__((unused)) const char * name,
+ __attribute__((unused)) bool raw_fmt,
+ __attribute__((unused)) void ** pkp)
+{
+ return -1;
+}
+
+int load_server_kem_pk(__attribute__((unused)) const char * name,
+ __attribute__((unused)) struct sec_config * cfg,
+ __attribute__((unused)) buffer_t * pk)
+{
+ return -1;
+}
+
+static void test_default_cfg(void)
+{
+ memset(&test_cfg, 0, sizeof(test_cfg));
+
+ /* Server: X25519, AES-256-GCM, SHA-256, with auth */
+ test_cfg.srv.kex = NID_X25519;
+ test_cfg.srv.cipher = NID_aes_256_gcm;
+ test_cfg.srv.kdf = NID_sha256;
+ test_cfg.srv.md = NID_sha256;
+ test_cfg.srv.auth = AUTH;
+
+ /* Client: same KEX/cipher/kdf/md, no auth */
+ test_cfg.cli.kex = NID_X25519;
+ test_cfg.cli.cipher = NID_aes_256_gcm;
+ test_cfg.cli.kdf = NID_sha256;
+ test_cfg.cli.md = NID_sha256;
+ test_cfg.cli.auth = NO_AUTH;
+}
+
+/* Encrypted, unauthenticated on both sides. */
+static void test_enc_noauth_cfg(void)
+{
+ test_default_cfg();
+ test_cfg.srv.auth = NO_AUTH;
+}
+
+static int test_oap_auth_init_fini(void)
+{
+ TEST_START();
+
+ if (oap_auth_init() < 0) {
+ printf("Failed to init OAP.\n");
+ goto fail;
+ }
+
+ oap_auth_fini();
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+static int test_oap_roundtrip(int kex)
+{
+ struct oap_test_ctx ctx;
+ const char * kex_str = kex_nid_to_str(kex);
+
+ TEST_START("(%s)", kex_str);
+
+ test_default_cfg();
+ test_cfg.srv.kex = kex;
+ test_cfg.cli.kex = kex;
+
+ if (oap_test_setup(&ctx, root_ca_crt_ec, im_ca_crt_ec) < 0)
+ goto fail;
+
+ if (oap_cli_prepare_ctx(&ctx) < 0) {
+ printf("Client prepare failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (oap_srv_process_ctx(&ctx) < 0) {
+ printf("Server process failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (oap_cli_complete_ctx(&ctx) < 0) {
+ printf("Client complete failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (memcmp(ctx.cli.key, ctx.srv.key, SYMMKEYSZ) != 0) {
+ printf("Client and server keys do not match!\n");
+ goto fail_cleanup;
+ }
+
+ if (ctx.cli.nid == NID_undef || ctx.srv.nid == NID_undef) {
+ printf("Cipher not set in flow.\n");
+ goto fail_cleanup;
+ }
+
+ oap_test_teardown(&ctx);
+
+ TEST_SUCCESS("(%s)", kex_str);
+
+ return TEST_RC_SUCCESS;
+
+ fail_cleanup:
+ oap_test_teardown(&ctx);
+ fail:
+ TEST_FAIL("(%s)", kex_str);
+ return TEST_RC_FAIL;
+}
+
+static int test_oap_roundtrip_auth_only(void)
+{
+ memset(&test_cfg, 0, sizeof(test_cfg));
+
+ /* Server: auth only, no encryption */
+ test_cfg.srv.md = NID_sha256;
+ test_cfg.srv.auth = AUTH;
+
+ /* Client: no auth, no encryption */
+ test_cfg.cli.md = NID_sha256;
+ test_cfg.cli.auth = NO_AUTH;
+
+ return roundtrip_auth_only(root_ca_crt_ec, im_ca_crt_ec);
+}
+
+static int test_oap_rekey(bool srv_auth,
+ bool cli_auth)
+{
+ test_default_cfg();
+ test_cfg.srv.auth = srv_auth;
+ test_cfg.cli.auth = cli_auth;
+
+ return roundtrip_rekey(root_ca_crt_ec, im_ca_crt_ec,
+ srv_auth, cli_auth);
+}
+
+static int test_oap_rekey_all(void)
+{
+ int ret = 0;
+
+ ret |= test_oap_rekey(AUTH, NO_AUTH);
+ ret |= test_oap_rekey(AUTH, AUTH);
+ ret |= test_oap_rekey(NO_AUTH, AUTH);
+ ret |= test_oap_rekey(NO_AUTH, NO_AUTH);
+
+ return ret;
+}
+
+static int test_oap_rekey_srv_badcache(bool srv_auth)
+{
+ test_default_cfg();
+ test_cfg.srv.auth = srv_auth;
+ test_cfg.cli.auth = AUTH;
+
+ return roundtrip_rekey_srv_badcache(root_ca_crt_ec, im_ca_crt_ec,
+ srv_auth);
+}
+
+static int test_oap_rekey_srv_badcache_all(void)
+{
+ int ret = 0;
+
+ ret |= test_oap_rekey_srv_badcache(AUTH);
+ ret |= test_oap_rekey_srv_badcache(NO_AUTH);
+
+ return ret;
+}
+
+static int test_oap_rekey_badcache(bool cli_auth)
+{
+ test_default_cfg();
+ test_cfg.cli.auth = cli_auth;
+
+ return roundtrip_rekey_badcache(root_ca_crt_ec, im_ca_crt_ec,
+ cli_auth);
+}
+
+static int test_oap_rekey_badcache_all(void)
+{
+ int ret = 0;
+
+ ret |= test_oap_rekey_badcache(NO_AUTH);
+ ret |= test_oap_rekey_badcache(AUTH);
+
+ return ret;
+}
+
+/* Absent sec config (ENOENT) clears the KEX; a re-key must fail closed. */
+static int test_oap_cli_rejects_rekey_no_kex(void)
+{
+ struct oap_test_ctx ctx;
+
+ TEST_START();
+
+ test_enc_noauth_cfg();
+ test_cfg.cli.kex = NID_undef;
+
+ if (oap_test_setup(&ctx, root_ca_crt_ec, im_ca_crt_ec) < 0)
+ goto fail;
+
+ ctx.rekey = true;
+
+ if (oap_cli_prepare_ctx(&ctx) == 0) {
+ printf("Client prepared a re-key without KEX.\n");
+ goto fail_cleanup;
+ }
+
+ oap_test_teardown(&ctx);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+ fail_cleanup:
+ oap_test_teardown(&ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+static int test_oap_srv_rejects_rekey_no_kex(void)
+{
+ struct oap_test_ctx ctx;
+
+ TEST_START();
+
+ test_enc_noauth_cfg();
+ test_cfg.cli.kex = NID_undef;
+ test_cfg.srv.kex = NID_undef;
+
+ if (oap_test_setup(&ctx, root_ca_crt_ec, im_ca_crt_ec) < 0)
+ goto fail;
+
+ /* First-contact plaintext request, replayed as a re-key. */
+ if (oap_cli_prepare_ctx(&ctx) < 0) {
+ printf("Client prepare failed.\n");
+ goto fail_cleanup;
+ }
+
+ ctx.rekey = true;
+
+ if (oap_srv_process_ctx(&ctx) == 0) {
+ printf("Server accepted a re-key without KEX.\n");
+ goto fail_cleanup;
+ }
+
+ oap_test_teardown(&ctx);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+ fail_cleanup:
+ oap_test_teardown(&ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+static int test_oap_roundtrip_kex_only(void)
+{
+ test_enc_noauth_cfg();
+
+ return roundtrip_kex_only();
+}
+
+static int test_oap_piggyback_data(void)
+{
+ struct oap_test_ctx ctx;
+ const char * cli_data_str = "client_data";
+ const char * srv_data_str = "server_data";
+ buffer_t srv_data = BUF_INIT;
+
+ TEST_START();
+
+ test_default_cfg();
+
+ if (oap_test_setup(&ctx, root_ca_crt_ec, im_ca_crt_ec) < 0)
+ goto fail;
+
+ /* Client prepares request with piggybacked data */
+ ctx.data.len = strlen(cli_data_str);
+ ctx.data.data = malloc(ctx.data.len);
+ if (ctx.data.data == NULL)
+ goto fail_cleanup;
+
+ memcpy(ctx.data.data, cli_data_str, ctx.data.len);
+
+ if (oap_cli_prepare_ctx(&ctx) < 0)
+ goto fail_cleanup;
+
+ /* Set server's response data (ctx.data will take cli data) */
+ srv_data.len = strlen(srv_data_str);
+ srv_data.data = (uint8_t *) srv_data_str;
+
+ freebuf(ctx.data);
+ ctx.data.data = srv_data.data;
+ ctx.data.len = srv_data.len;
+ srv_data.data = NULL;
+ srv_data.len = 0;
+
+ if (oap_srv_process_ctx(&ctx) < 0)
+ goto fail_cleanup;
+
+ /* Verify server received client's piggybacked data */
+ if (ctx.data.len != strlen(cli_data_str) ||
+ memcmp(ctx.data.data, cli_data_str, ctx.data.len) != 0) {
+ printf("Server did not receive correct client data.\n");
+ goto fail_cleanup;
+ }
+
+ freebuf(ctx.data);
+
+ if (oap_cli_complete_ctx(&ctx) < 0)
+ goto fail_cleanup;
+
+ /* Verify client received server's piggybacked data */
+ if (ctx.data.len != strlen(srv_data_str) ||
+ memcmp(ctx.data.data, srv_data_str, ctx.data.len) != 0) {
+ printf("Client did not receive correct server data.\n");
+ goto fail_cleanup;
+ }
+
+ /* Free the copied data */
+ free(ctx.data.data);
+ ctx.data.data = NULL;
+ ctx.data.len = 0;
+
+ if (memcmp(ctx.cli.key, ctx.srv.key, SYMMKEYSZ) != 0) {
+ printf("Client and server keys do not match!\n");
+ goto fail_cleanup;
+ }
+
+ oap_test_teardown(&ctx);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+
+ fail_cleanup:
+ freebuf(srv_data);
+ oap_test_teardown(&ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+static int test_oap_corrupted_request(void)
+{
+ test_default_cfg();
+ test_cfg.cli.auth = AUTH;
+
+ return corrupted_request(root_ca_crt_ec, im_ca_crt_ec);
+}
+
+static int test_oap_corrupted_response(void)
+{
+ test_default_cfg();
+
+ return corrupted_response(root_ca_crt_ec, im_ca_crt_ec);
+}
+
+static int test_oap_truncated_request(void)
+{
+ test_default_cfg();
+
+ return truncated_request(root_ca_crt_ec, im_ca_crt_ec);
+}
+
+/* After ID (16), timestamp (8), cipher_nid (2), kdf_nid (2), md (2) */
+#define OAP_CERT_LEN_OFFSET 30
+static int test_oap_inflated_length_field(void)
+{
+ struct oap_test_ctx ctx;
+ uint16_t fake;
+
+ test_default_cfg();
+
+ TEST_START();
+
+ if (oap_test_setup(&ctx, root_ca_crt_ec, im_ca_crt_ec) < 0)
+ goto fail;
+
+ if (oap_cli_prepare_ctx(&ctx) < 0) {
+ printf("Client prepare failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (ctx.req_hdr.len < OAP_CERT_LEN_OFFSET + 2) {
+ printf("Request too short for test.\n");
+ goto fail_cleanup;
+ }
+
+ /* Set cert length to claim more bytes than packet contains */
+ fake = hton16(60000);
+ memcpy(ctx.req_hdr.data + OAP_CERT_LEN_OFFSET, &fake, sizeof(fake));
+
+ if (oap_srv_process_ctx(&ctx) == 0) {
+ printf("Server should reject inflated length field.\n");
+ goto fail_cleanup;
+ }
+
+ oap_test_teardown(&ctx);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+
+ fail_cleanup:
+ oap_test_teardown(&ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/* Attacker claims cert is smaller - causes misparse of subsequent fields */
+static int test_oap_deflated_length_field(void)
+{
+ struct oap_test_ctx ctx;
+ uint16_t fake;
+
+ test_default_cfg();
+
+ TEST_START();
+
+ if (oap_test_setup(&ctx, root_ca_crt_ec, im_ca_crt_ec) < 0)
+ goto fail;
+
+ if (oap_cli_prepare_ctx(&ctx) < 0) {
+ printf("Client prepare failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (ctx.req_hdr.len < OAP_CERT_LEN_OFFSET + 2) {
+ printf("Request too short for test.\n");
+ goto fail_cleanup;
+ }
+
+ /* Set cert length to claim fewer bytes - will misparse rest */
+ fake = hton16(1);
+ memcpy(ctx.req_hdr.data + OAP_CERT_LEN_OFFSET, &fake, sizeof(fake));
+
+ if (oap_srv_process_ctx(&ctx) == 0) {
+ printf("Server should reject deflated length field.\n");
+ goto fail_cleanup;
+ }
+
+ oap_test_teardown(&ctx);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+
+ fail_cleanup:
+ oap_test_teardown(&ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/* Header field offsets for byte manipulation */
+#define OAP_CIPHER_NID_OFFSET 24
+#define OAP_KDF_NID_OFFSET 26
+#define OAP_MD_NID_OFFSET 28
+#define OAP_KEX_LEN_OFFSET 32
+
+/* A NID the crypto backend does not recognise; guarded by a test below. */
+#define UNSUPPORTED_NID 9999
+
+/* Server rejects request when cipher NID set but no KEX data provided */
+static int test_oap_nid_without_kex(void)
+{
+ struct oap_test_ctx ctx;
+ uint16_t cipher_nid;
+ uint16_t zero = 0;
+
+ test_enc_noauth_cfg();
+
+ TEST_START();
+
+ if (oap_test_setup(&ctx, root_ca_crt_ec, im_ca_crt_ec) < 0)
+ goto fail;
+
+ if (oap_cli_prepare_ctx(&ctx) < 0) {
+ printf("Client prepare failed.\n");
+ goto fail_cleanup;
+ }
+
+ /* Tamper: keep cipher_nid but set kex_len=0, truncate KEX data */
+ cipher_nid = hton16(NID_aes_256_gcm);
+ memcpy(ctx.req_hdr.data + OAP_CIPHER_NID_OFFSET, &cipher_nid,
+ sizeof(cipher_nid));
+ memcpy(ctx.req_hdr.data + OAP_KEX_LEN_OFFSET, &zero, sizeof(zero));
+ ctx.req_hdr.len = 36; /* Fixed header only, no KEX data */
+
+ if (oap_srv_process_ctx(&ctx) == 0) {
+ printf("Server should reject cipher NID without KEX data.\n");
+ goto fail_cleanup;
+ }
+
+ oap_test_teardown(&ctx);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+
+ fail_cleanup:
+ oap_test_teardown(&ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/* Guard: the tamper tests below rely on UNSUPPORTED_NID being invalid. */
+static int test_oap_unsupported_nid_undefined(void)
+{
+ TEST_START();
+
+ if (crypt_cipher_rank(UNSUPPORTED_NID) >= 0) {
+ printf("UNSUPPORTED_NID is a valid cipher NID.\n");
+ goto fail;
+ }
+
+ if (crypt_kdf_rank(UNSUPPORTED_NID) >= 0) {
+ printf("UNSUPPORTED_NID is a valid KDF NID.\n");
+ goto fail;
+ }
+
+ if (md_validate_nid(UNSUPPORTED_NID) >= 0) {
+ printf("UNSUPPORTED_NID is a valid digest NID.\n");
+ goto fail;
+ }
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/* Server rejects a request whose cipher/kdf/digest NID is unsupported */
+static int test_oap_unsupported_nid(size_t offset,
+ const char * label)
+{
+ struct oap_test_ctx ctx;
+ uint16_t bad_nid;
+
+ test_enc_noauth_cfg();
+
+ TEST_START("(%s)", label);
+
+ if (oap_test_setup(&ctx, root_ca_crt_ec, im_ca_crt_ec) < 0)
+ goto fail;
+
+ if (oap_cli_prepare_ctx(&ctx) < 0) {
+ printf("Client prepare failed.\n");
+ goto fail_cleanup;
+ }
+
+ bad_nid = hton16(UNSUPPORTED_NID);
+ memcpy(ctx.req_hdr.data + offset, &bad_nid, sizeof(bad_nid));
+
+ if (oap_srv_process_ctx(&ctx) == 0) {
+ printf("Server should reject unsupported %s NID.\n", label);
+ goto fail_cleanup;
+ }
+
+ oap_test_teardown(&ctx);
+
+ TEST_SUCCESS("(%s)", label);
+
+ return TEST_RC_SUCCESS;
+
+ fail_cleanup:
+ oap_test_teardown(&ctx);
+ fail:
+ TEST_FAIL("(%s)", label);
+ return TEST_RC_FAIL;
+}
+
+static int test_oap_unsupported_nid_all(void)
+{
+ int ret = 0;
+
+ ret |= test_oap_unsupported_nid(OAP_CIPHER_NID_OFFSET, "cipher");
+ ret |= test_oap_unsupported_nid(OAP_KDF_NID_OFFSET, "kdf");
+ ret |= test_oap_unsupported_nid(OAP_MD_NID_OFFSET, "digest");
+
+ return ret;
+}
+
+/* Client rejects a response whose key-confirmation tag is tampered */
+static int test_oap_key_confirm_mismatch(void)
+{
+ struct oap_test_ctx ctx;
+
+ /* Unauthenticated + encrypted: response unsigned, KC is the gate */
+ test_enc_noauth_cfg();
+
+ TEST_START();
+
+ if (oap_test_setup(&ctx, root_ca_crt_ec, im_ca_crt_ec) < 0)
+ goto fail;
+
+ if (oap_cli_prepare_ctx(&ctx) < 0) {
+ printf("Client prepare failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (oap_srv_process_ctx(&ctx) < 0) {
+ printf("Server process failed.\n");
+ goto fail_cleanup;
+ }
+
+ /* The key-confirm tag is the last field of an unsigned response */
+ ctx.resp_hdr.data[ctx.resp_hdr.len - 1] ^= 0xFF;
+
+ if (oap_cli_complete_ctx(&ctx) == 0) {
+ printf("Client accepted a bad key-confirmation tag.\n");
+ goto fail_cleanup;
+ }
+
+ oap_test_teardown(&ctx);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+
+ fail_cleanup:
+ oap_test_teardown(&ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+static int test_oap_roundtrip_all(void)
+{
+ int ret = 0;
+ int i;
+
+ for (i = 0; kex_supported_nids[i] != NID_undef; i++) {
+ const char * algo = kex_nid_to_str(kex_supported_nids[i]);
+
+ /* Skip KEM algorithms - tested in oap_test_ml_dsa */
+ if (IS_KEM_ALGORITHM(algo))
+ continue;
+
+ ret |= test_oap_roundtrip(kex_supported_nids[i]);
+ }
+
+ return ret;
+}
+
+/* Cipher negotiation - strongest cipher and KDF are selected */
+static int test_oap_cipher_mismatch(void)
+{
+ struct oap_test_ctx ctx;
+
+ TEST_START();
+
+ memset(&test_cfg, 0, sizeof(test_cfg));
+
+ /* Server: AES-128-GCM, SHA-256 */
+ test_cfg.srv.kex = NID_X25519;
+ test_cfg.srv.cipher = NID_aes_128_gcm;
+ test_cfg.srv.kdf = NID_sha256;
+ test_cfg.srv.md = NID_sha256;
+ test_cfg.srv.auth = AUTH;
+
+ /* Client: AES-256-GCM, SHA-512 */
+ test_cfg.cli.kex = NID_X25519;
+ test_cfg.cli.cipher = NID_aes_256_gcm;
+ test_cfg.cli.kdf = NID_sha512;
+ test_cfg.cli.md = NID_sha256;
+ test_cfg.cli.auth = NO_AUTH;
+
+ if (oap_test_setup(&ctx, root_ca_crt_ec, im_ca_crt_ec) < 0)
+ goto fail;
+
+ if (oap_cli_prepare_ctx(&ctx) < 0) {
+ printf("Client prepare failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (oap_srv_process_ctx(&ctx) < 0) {
+ printf("Server process failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (oap_cli_complete_ctx(&ctx) < 0) {
+ printf("Client complete failed.\n");
+ goto fail_cleanup;
+ }
+
+ /* Verify: both should have the strongest cipher */
+ if (ctx.srv.nid != NID_aes_256_gcm) {
+ printf("Server cipher mismatch: expected %s, got %s\n",
+ crypt_nid_to_str(NID_aes_256_gcm),
+ crypt_nid_to_str(ctx.srv.nid));
+ goto fail_cleanup;
+ }
+
+ if (ctx.cli.nid != NID_aes_256_gcm) {
+ printf("Client cipher mismatch: expected %s, got %s\n",
+ crypt_nid_to_str(NID_aes_256_gcm),
+ crypt_nid_to_str(ctx.cli.nid));
+ goto fail_cleanup;
+ }
+
+ /* Parse response header to check negotiated KDF */
+ if (ctx.resp_hdr.len > 26) {
+ uint16_t resp_kdf_nid;
+ /* KDF NID at offset 26: ID(16) + ts(8) + cipher(2) */
+ resp_kdf_nid = ntoh16(*(uint16_t *)(ctx.resp_hdr.data + 26));
+
+ if (resp_kdf_nid != NID_sha512) {
+ printf("Response KDF mismatch: expected %s, got %s\n",
+ md_nid_to_str(NID_sha512),
+ md_nid_to_str(resp_kdf_nid));
+ goto fail_cleanup;
+ }
+ }
+
+ oap_test_teardown(&ctx);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+
+ fail_cleanup:
+ oap_test_teardown(&ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/* Server encryption, client none: server rejects (no KEX data) */
+static int test_oap_srv_enc_cli_none(void)
+{
+ struct oap_test_ctx ctx;
+
+ TEST_START();
+
+ memset(&test_cfg, 0, sizeof(test_cfg));
+
+ /* Server: encryption configured */
+ test_cfg.srv.kex = NID_X25519;
+ test_cfg.srv.cipher = NID_aes_256_gcm;
+ test_cfg.srv.kdf = NID_sha256;
+ test_cfg.srv.md = NID_sha256;
+ test_cfg.srv.auth = AUTH;
+
+ /* Client: no encryption */
+ test_cfg.cli.md = NID_sha256;
+ test_cfg.cli.auth = NO_AUTH;
+
+ if (oap_test_setup(&ctx, root_ca_crt_ec, im_ca_crt_ec) < 0)
+ goto fail;
+
+ if (oap_cli_prepare_ctx(&ctx) < 0) {
+ printf("Client prepare failed.\n");
+ goto fail_cleanup;
+ }
+
+ /* Server should reject: KEX required but client sent none */
+ if (oap_srv_process_ctx(&ctx) == 0) {
+ printf("Server should have rejected.\n");
+ goto fail_cleanup;
+ }
+
+ oap_test_teardown(&ctx);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+
+ fail_cleanup:
+ oap_test_teardown(&ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/* Client encryption, server none: use client settings */
+static int test_oap_cli_enc_srv_none(void)
+{
+ struct oap_test_ctx ctx;
+
+ TEST_START();
+
+ memset(&test_cfg, 0, sizeof(test_cfg));
+
+ /* Server: no encryption configured */
+ test_cfg.srv.md = NID_sha256;
+ test_cfg.srv.auth = AUTH;
+
+ /* Client: encryption configured */
+ test_cfg.cli.kex = NID_X25519;
+ test_cfg.cli.cipher = NID_aes_256_gcm;
+ test_cfg.cli.kdf = NID_sha256;
+ test_cfg.cli.md = NID_sha256;
+ test_cfg.cli.auth = NO_AUTH;
+
+ if (oap_test_setup(&ctx, root_ca_crt_ec, im_ca_crt_ec) < 0)
+ goto fail;
+
+ if (oap_cli_prepare_ctx(&ctx) < 0) {
+ printf("Client prepare failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (oap_srv_process_ctx(&ctx) < 0) {
+ printf("Server process failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (oap_cli_complete_ctx(&ctx) < 0) {
+ printf("Client complete failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (memcmp(ctx.cli.key, ctx.srv.key, SYMMKEYSZ) != 0) {
+ printf("Key mismatch.\n");
+ goto fail_cleanup;
+ }
+
+ if (ctx.cli.nid != NID_aes_256_gcm) {
+ printf("Expected %s, got %s.\n",
+ crypt_nid_to_str(NID_aes_256_gcm),
+ crypt_nid_to_str(ctx.cli.nid));
+ goto fail_cleanup;
+ }
+
+ if (ctx.srv.nid != NID_aes_256_gcm) {
+ printf("Expected %s, got %s.\n",
+ crypt_nid_to_str(NID_aes_256_gcm),
+ crypt_nid_to_str(ctx.srv.nid));
+ goto fail_cleanup;
+ }
+
+ oap_test_teardown(&ctx);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+
+ fail_cleanup:
+ oap_test_teardown(&ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/* Unauthenticated server: client floor-rejects a downgraded cipher */
+static int test_oap_cli_rejects_downgrade(void)
+{
+ struct oap_test_ctx ctx;
+ uint16_t weak;
+
+ test_enc_noauth_cfg();
+
+ TEST_START();
+
+ if (oap_test_setup(&ctx, root_ca_crt_ec, im_ca_crt_ec) < 0)
+ goto fail;
+
+ if (oap_cli_prepare_ctx(&ctx) < 0) {
+ printf("Client prepare failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (oap_srv_process_ctx(&ctx) < 0) {
+ printf("Server process failed.\n");
+ goto fail_cleanup;
+ }
+
+ /* Tamper: replace cipher NID with weaker one */
+ weak = hton16(NID_aes_128_gcm);
+ memcpy(ctx.resp_hdr.data + OAP_CIPHER_NID_OFFSET,
+ &weak, sizeof(weak));
+
+ /* Client should reject the downgraded cipher */
+ if (oap_cli_complete_ctx(&ctx) == 0) {
+ printf("Client accepted downgrade.\n");
+ goto fail_cleanup;
+ }
+
+ oap_test_teardown(&ctx);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+
+ fail_cleanup:
+ oap_test_teardown(&ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/*
+ * Suite binding: a cipher swapped to a higher rank clears the client floor
+ * check, but the bound key commits to the negotiated suite, so the swap must
+ * still fail key confirmation.
+ */
+static int test_oap_cli_rejects_suite_swap(void)
+{
+ struct oap_test_ctx ctx;
+ uint16_t swap;
+
+ /* Both AES-128-GCM: a swap to AES-256 outranks the client floor */
+ test_enc_noauth_cfg();
+ test_cfg.srv.cipher = NID_aes_128_gcm;
+ test_cfg.cli.cipher = NID_aes_128_gcm;
+
+ TEST_START();
+
+ if (oap_test_setup(&ctx, root_ca_crt_ec, im_ca_crt_ec) < 0)
+ goto fail;
+
+ if (oap_cli_prepare_ctx(&ctx) < 0) {
+ printf("Client prepare failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (oap_srv_process_ctx(&ctx) < 0) {
+ printf("Server process failed.\n");
+ goto fail_cleanup;
+ }
+
+ /* Swap the response cipher to a higher-ranked one */
+ swap = hton16(NID_aes_256_gcm);
+ memcpy(ctx.resp_hdr.data + OAP_CIPHER_NID_OFFSET,
+ &swap, sizeof(swap));
+
+ if (oap_cli_complete_ctx(&ctx) == 0) {
+ printf("Client accepted a swapped cipher suite.\n");
+ goto fail_cleanup;
+ }
+
+ oap_test_teardown(&ctx);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+
+ fail_cleanup:
+ oap_test_teardown(&ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/* Server rejects client with weaker KEX algorithm */
+static int test_oap_srv_rejects_weak_kex(void)
+{
+ struct oap_test_ctx ctx;
+
+ TEST_START();
+
+ memset(&test_cfg, 0, sizeof(test_cfg));
+
+ /* Server: secp521r1 (strong) */
+ test_cfg.srv.kex = NID_secp521r1;
+ test_cfg.srv.cipher = NID_aes_256_gcm;
+ test_cfg.srv.kdf = NID_sha256;
+ test_cfg.srv.md = NID_sha256;
+ test_cfg.srv.auth = AUTH;
+
+ /* Client: ffdhe2048 (weakest) */
+ test_cfg.cli.kex = NID_ffdhe2048;
+ test_cfg.cli.cipher = NID_aes_256_gcm;
+ test_cfg.cli.kdf = NID_sha256;
+ test_cfg.cli.md = NID_sha256;
+ test_cfg.cli.auth = NO_AUTH;
+
+ if (oap_test_setup(&ctx, root_ca_crt_ec, im_ca_crt_ec) < 0)
+ goto fail;
+
+ if (oap_cli_prepare_ctx(&ctx) < 0) {
+ printf("Client prepare failed.\n");
+ goto fail_cleanup;
+ }
+
+ /* Server should reject: client KEX too weak */
+ if (oap_srv_process_ctx(&ctx) == 0) {
+ printf("Server should reject weak KEX.\n");
+ goto fail_cleanup;
+ }
+
+ oap_test_teardown(&ctx);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+
+ fail_cleanup:
+ oap_test_teardown(&ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/* Test roundtrip with different signature digest algorithms */
+static int test_oap_roundtrip_md(int md)
+{
+ struct oap_test_ctx ctx;
+ const char * md_str = md_nid_to_str(md);
+
+ TEST_START("(%s)", md_str ? md_str : "default");
+
+ memset(&test_cfg, 0, sizeof(test_cfg));
+
+ /* Server: auth + KEX with specified md */
+ test_cfg.srv.kex = NID_X25519;
+ test_cfg.srv.cipher = NID_aes_256_gcm;
+ test_cfg.srv.kdf = NID_sha256;
+ test_cfg.srv.md = md;
+ test_cfg.srv.auth = AUTH;
+
+ /* Client: no auth */
+ test_cfg.cli.kex = NID_X25519;
+ test_cfg.cli.cipher = NID_aes_256_gcm;
+ test_cfg.cli.kdf = NID_sha256;
+ test_cfg.cli.md = md;
+ test_cfg.cli.auth = NO_AUTH;
+
+ if (oap_test_setup(&ctx, root_ca_crt_ec, im_ca_crt_ec) < 0)
+ goto fail;
+
+ if (oap_cli_prepare_ctx(&ctx) < 0) {
+ printf("Client prepare failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (oap_srv_process_ctx(&ctx) < 0) {
+ printf("Server process failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (oap_cli_complete_ctx(&ctx) < 0) {
+ printf("Client complete failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (memcmp(ctx.cli.key, ctx.srv.key, SYMMKEYSZ) != 0) {
+ printf("Client and server keys do not match!\n");
+ goto fail_cleanup;
+ }
+
+ oap_test_teardown(&ctx);
+
+ TEST_SUCCESS("(%s)", md_str ? md_str : "default");
+
+ return TEST_RC_SUCCESS;
+
+ fail_cleanup:
+ oap_test_teardown(&ctx);
+ fail:
+ TEST_FAIL("(%s)", md_str ? md_str : "default");
+ return TEST_RC_FAIL;
+}
+
+static int test_oap_roundtrip_md_all(void)
+{
+ int ret = 0;
+ int i;
+
+ /* Test with default */
+ ret |= test_oap_roundtrip_md(NID_undef);
+
+ /* Test with all supported digest NIDs */
+ for (i = 0; md_supported_nids[i] != NID_undef; i++)
+ ret |= test_oap_roundtrip_md(md_supported_nids[i]);
+
+ return ret;
+}
+
+/* Timestamp is at offset 16 (after the 16-byte ID) */
+#define OAP_TIMESTAMP_OFFSET 16
+/* Test that packets with outdated timestamps are rejected */
+/* Server rejects a request whose timestamp is outside the replay window */
+static int test_oap_ts_reject(int delta_sec,
+ const char * label)
+{
+ struct oap_test_ctx ctx;
+ struct timespec ts;
+ uint64_t stamp;
+
+ test_default_cfg();
+
+ TEST_START("(%s)", label);
+
+ if (oap_test_setup(&ctx, root_ca_crt_ec, im_ca_crt_ec) < 0)
+ goto fail;
+
+ if (oap_cli_prepare_ctx(&ctx) < 0) {
+ printf("Client prepare failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (ctx.req_hdr.len < OAP_TIMESTAMP_OFFSET + sizeof(uint64_t)) {
+ printf("Request too short for test.\n");
+ goto fail_cleanup;
+ }
+
+ clock_gettime(CLOCK_REALTIME, &ts);
+ ts.tv_sec += delta_sec;
+ stamp = hton64(TS_TO_UINT64(ts));
+ memcpy(ctx.req_hdr.data + OAP_TIMESTAMP_OFFSET, &stamp,
+ sizeof(stamp));
+
+ if (oap_srv_process_ctx(&ctx) == 0) {
+ printf("Server should reject %s packet.\n", label);
+ goto fail_cleanup;
+ }
+
+ oap_test_teardown(&ctx);
+
+ TEST_SUCCESS("(%s)", label);
+
+ return TEST_RC_SUCCESS;
+
+ fail_cleanup:
+ oap_test_teardown(&ctx);
+ fail:
+ TEST_FAIL("(%s)", label);
+ return TEST_RC_FAIL;
+}
+
+static int test_oap_ts_reject_all(void)
+{
+ int ret = 0;
+
+ /* Past the 20s replay window, and past the 100ms future slack. */
+ ret |= test_oap_ts_reject(-(OAP_REPLAY_TIMER + 10), "outdated");
+ ret |= test_oap_ts_reject(1, "future");
+
+ return ret;
+}
+
+/* Test that replayed packets (same ID + timestamp) are rejected */
+static int test_oap_replay_packet(void)
+{
+ struct oap_test_ctx ctx;
+ buffer_t saved_req;
+
+ test_default_cfg();
+
+ TEST_START();
+
+ if (oap_test_setup(&ctx, root_ca_crt_ec, im_ca_crt_ec) < 0)
+ goto fail;
+
+ if (oap_cli_prepare_ctx(&ctx) < 0) {
+ printf("Client prepare failed.\n");
+ goto fail_cleanup;
+ }
+
+ /* Save the request for replay */
+ saved_req.len = ctx.req_hdr.len;
+ saved_req.data = malloc(saved_req.len);
+ if (saved_req.data == NULL) {
+ printf("Failed to allocate saved request.\n");
+ goto fail_cleanup;
+ }
+ memcpy(saved_req.data, ctx.req_hdr.data, saved_req.len);
+
+ /* First request should succeed */
+ if (oap_srv_process_ctx(&ctx) < 0) {
+ printf("First request should succeed.\n");
+ free(saved_req.data);
+ goto fail_cleanup;
+ }
+
+ /* Free response from first request before replay */
+ freebuf(ctx.resp_hdr);
+
+ /* Restore the saved request for replay */
+ freebuf(ctx.req_hdr);
+ ctx.req_hdr = saved_req;
+
+ /* Replay must return -EREPLAY so callers can drop silently. */
+ if (oap_srv_process_ctx(&ctx) != -EREPLAY) {
+ printf("Replayed packet rejection != -EREPLAY.\n");
+ goto fail_cleanup;
+ }
+
+ oap_test_teardown(&ctx);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+
+ fail_cleanup:
+ oap_test_teardown(&ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/* Encode a distinct OAP session ID from an index */
+static void make_id(uint8_t * id,
+ size_t idx)
+{
+ memset(id, 0, OAP_ID_SIZE);
+ memcpy(id, &idx, sizeof(idx));
+}
+
+/*
+ * Replay cache fails closed at capacity: a flood is rejected and no genuine
+ * entry is evicted (so it cannot be replayed).
+ */
+static int test_oap_replay_cap(void)
+{
+ struct oap_hdr h;
+ struct timespec now;
+ uint8_t id[OAP_ID_SIZE];
+ uint64_t stamp;
+ size_t i;
+
+ TEST_START();
+
+ if (oap_auth_init() < 0) {
+ printf("Failed to init OAP.\n");
+ goto fail;
+ }
+
+ clock_gettime(CLOCK_REALTIME, &now);
+ stamp = TS_TO_UINT64(now);
+
+ memset(&h, 0, sizeof(h));
+ h.id.data = id;
+ h.id.len = OAP_ID_SIZE;
+ h.timestamp = stamp;
+
+ /* Fill one generation bucket to capacity with distinct IDs */
+ for (i = 0; i < OAP_REPLAY_MAX; i++) {
+ make_id(id, i);
+ if (oap_check_hdr(&h) != 0) {
+ printf("Distinct header %zu rejected.\n", i);
+ goto fail_fini;
+ }
+ }
+
+ /* One past capacity fails closed (rejected, not evict-oldest) */
+ make_id(id, OAP_REPLAY_MAX);
+ if (oap_check_hdr(&h) != -EAUTH) {
+ printf("Header past capacity not fail-closed.\n");
+ goto fail_fini;
+ }
+
+ /* No genuine entry was evicted: the oldest still reads as a replay */
+ make_id(id, 0);
+ if (oap_check_hdr(&h) != -EREPLAY) {
+ printf("Genuine entry evicted under flood.\n");
+ goto fail_fini;
+ }
+
+ oap_auth_fini();
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+
+ fail_fini:
+ oap_auth_fini();
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/*
+ * Distinct timestamp generations use separate buckets and are detected
+ * independently (covers the multi-generation / rotation path).
+ */
+static int test_oap_replay_generations(void)
+{
+ struct oap_hdr h;
+ struct timespec now;
+ struct timespec wait;
+ uint8_t id[OAP_ID_SIZE];
+ uint64_t cur;
+ uint64_t gen_ns;
+ uint64_t off;
+ uint64_t wait_ns;
+ uint64_t stamp_a;
+ uint64_t stamp_b;
+
+ TEST_START();
+
+ if (oap_auth_init() < 0) {
+ printf("Failed to init OAP.\n");
+ goto fail;
+ }
+
+ gen_ns = (uint64_t) OAP_REPLAY_TIMER * BILLION;
+
+ /* Prev-gen stamp flakes on staleness near a generation top. */
+ clock_gettime(CLOCK_REALTIME, &now);
+ cur = TS_TO_UINT64(now);
+ off = cur % gen_ns;
+ if (gen_ns - off < BILLION) {
+ wait_ns = gen_ns - off + MILLION;
+ wait.tv_sec = (time_t) (wait_ns / BILLION);
+ wait.tv_nsec = (long) (wait_ns % BILLION);
+ nanosleep(&wait, NULL);
+ clock_gettime(CLOCK_REALTIME, &now);
+ cur = TS_TO_UINT64(now);
+ }
+
+ /* stamp_a in the current generation, stamp_b one generation older */
+ stamp_a = cur;
+ stamp_b = (cur / gen_ns) * gen_ns - 1;
+
+ memset(&h, 0, sizeof(h));
+ h.id.data = id;
+ h.id.len = OAP_ID_SIZE;
+ make_id(id, 1);
+
+ /* First sighting in each generation is accepted */
+ h.timestamp = stamp_a;
+ if (oap_check_hdr(&h) != 0) {
+ printf("Gen-A header rejected.\n");
+ goto fail_fini;
+ }
+
+ h.timestamp = stamp_b;
+ if (oap_check_hdr(&h) != 0) {
+ printf("Gen-B header rejected.\n");
+ goto fail_fini;
+ }
+
+ /* Each generation independently detects its own replay */
+ h.timestamp = stamp_a;
+ if (oap_check_hdr(&h) != -EREPLAY) {
+ printf("Gen-A replay not detected.\n");
+ goto fail_fini;
+ }
+
+ h.timestamp = stamp_b;
+ if (oap_check_hdr(&h) != -EREPLAY) {
+ printf("Gen-B replay not detected.\n");
+ goto fail_fini;
+ }
+
+ oap_auth_fini();
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+
+ fail_fini:
+ oap_auth_fini();
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/* Server rejects client certificate when root CA is missing from store */
+static int test_oap_missing_root_ca(void)
+{
+ struct oap_test_ctx ctx;
+ void * im_ca = NULL;
+
+ test_default_cfg();
+
+ TEST_START();
+
+ memset(&ctx, 0, sizeof(ctx));
+
+ strcpy(ctx.srv.info.name, "test-1.unittest.o7s");
+ strcpy(ctx.cli.info.name, "test-1.unittest.o7s");
+
+ if (oap_auth_init() < 0) {
+ printf("Failed to init OAP.\n");
+ goto fail;
+ }
+
+ /* Load intermediate CA but intentionally omit the root CA */
+ if (crypt_load_crt_str(im_ca_crt_ec, &im_ca) < 0) {
+ printf("Failed to load intermediate CA cert.\n");
+ goto fail_fini;
+ }
+
+ ctx.im_ca = im_ca;
+
+ if (oap_auth_add_ca_crt(im_ca) < 0) {
+ printf("Failed to add intermediate CA cert to store.\n");
+ goto fail_fini;
+ }
+
+ if (oap_cli_prepare_ctx(&ctx) < 0) {
+ printf("Client prepare failed.\n");
+ goto fail_fini;
+ }
+
+ /* Server processes and signs response - succeeds without root CA */
+ if (oap_srv_process_ctx(&ctx) < 0) {
+ printf("Server process failed.\n");
+ goto fail_teardown;
+ }
+
+ /* Client verifies server certificate against trust store:
+ * should reject because root CA is not in the store */
+ if (oap_cli_complete_ctx(&ctx) == 0) {
+ printf("Client should reject without root CA.\n");
+ goto fail_teardown;
+ }
+
+ oap_test_teardown(&ctx);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+
+ fail_teardown:
+ oap_test_teardown(&ctx);
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+ fail_fini:
+ crypt_free_crt(im_ca);
+ oap_auth_fini();
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/* Test that client rejects server with wrong certificate name */
+static int test_oap_server_name_mismatch(void)
+{
+ struct oap_test_ctx ctx;
+
+ test_default_cfg();
+
+ TEST_START();
+
+ if (oap_test_setup(&ctx, root_ca_crt_ec, im_ca_crt_ec) < 0)
+ goto fail;
+
+ /* Set client's expected name to something different from cert name */
+ strcpy(ctx.cli.info.name, "wrong.server.name");
+
+ if (oap_cli_prepare_ctx(&ctx) < 0) {
+ printf("Client prepare failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (oap_srv_process_ctx(&ctx) < 0) {
+ printf("Server process failed.\n");
+ goto fail_cleanup;
+ }
+
+ /* Client should reject due to name mismatch */
+ if (oap_cli_complete_ctx(&ctx) == 0) {
+ printf("Client should reject server with wrong cert name.\n");
+ goto fail_cleanup;
+ }
+
+ oap_test_teardown(&ctx);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+
+ fail_cleanup:
+ oap_test_teardown(&ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/* Client requiring auth rejects a response without certificate */
+static int test_oap_cli_requires_srv_auth(void)
+{
+ struct oap_test_ctx ctx;
+
+ test_default_cfg();
+ test_cfg.srv.auth = NO_AUTH;
+ test_cfg.cli.req_auth = true;
+
+ TEST_START();
+
+ if (oap_test_setup(&ctx, root_ca_crt_ec, im_ca_crt_ec) < 0)
+ goto fail;
+
+ if (oap_cli_prepare_ctx(&ctx) < 0) {
+ printf("Client prepare failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (oap_srv_process_ctx(&ctx) < 0) {
+ printf("Server process failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (oap_cli_complete_ctx(&ctx) == 0) {
+ printf("Client should reject unauthenticated server.\n");
+ goto fail_cleanup;
+ }
+
+ oap_test_teardown(&ctx);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+
+ fail_cleanup:
+ oap_test_teardown(&ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/* Server requiring auth rejects a request without certificate */
+static int test_oap_srv_requires_cli_auth(void)
+{
+ struct oap_test_ctx ctx;
+
+ test_default_cfg();
+ test_cfg.srv.req_auth = true;
+
+ TEST_START();
+
+ if (oap_test_setup(&ctx, root_ca_crt_ec, im_ca_crt_ec) < 0)
+ goto fail;
+
+ if (oap_cli_prepare_ctx(&ctx) < 0) {
+ printf("Client prepare failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (oap_srv_process_ctx(&ctx) == 0) {
+ printf("Server should reject unauthenticated client.\n");
+ goto fail_cleanup;
+ }
+
+ oap_test_teardown(&ctx);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+
+ fail_cleanup:
+ oap_test_teardown(&ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/* Roundtrip succeeds when both sides require and provide auth */
+static int test_oap_mutual_req_auth(void)
+{
+ struct oap_test_ctx ctx;
+
+ test_default_cfg();
+ test_cfg.srv.req_auth = true;
+ test_cfg.cli.auth = AUTH;
+ test_cfg.cli.req_auth = true;
+
+ TEST_START();
+
+ if (oap_test_setup(&ctx, root_ca_crt_ec, im_ca_crt_ec) < 0)
+ goto fail;
+
+ if (oap_cli_prepare_ctx(&ctx) < 0) {
+ printf("Client prepare failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (oap_srv_process_ctx(&ctx) < 0) {
+ printf("Server process failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (oap_cli_complete_ctx(&ctx) < 0) {
+ printf("Client complete failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (memcmp(ctx.cli.key, ctx.srv.key, SYMMKEYSZ) != 0) {
+ printf("Client and server keys do not match!\n");
+ goto fail_cleanup;
+ }
+
+ oap_test_teardown(&ctx);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+
+ fail_cleanup:
+ oap_test_teardown(&ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/* Client rejects a server signature with a different digest */
+static int test_oap_cli_rejects_md_mismatch(void)
+{
+ struct oap_test_ctx ctx;
+
+ test_default_cfg();
+ test_cfg.srv.md = NID_sha384;
+
+ TEST_START();
+
+ if (oap_test_setup(&ctx, root_ca_crt_ec, im_ca_crt_ec) < 0)
+ goto fail;
+
+ if (oap_cli_prepare_ctx(&ctx) < 0) {
+ printf("Client prepare failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (oap_srv_process_ctx(&ctx) < 0) {
+ printf("Server process failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (oap_cli_complete_ctx(&ctx) == 0) {
+ printf("Client should reject digest mismatch.\n");
+ goto fail_cleanup;
+ }
+
+ oap_test_teardown(&ctx);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+
+ fail_cleanup:
+ oap_test_teardown(&ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/* Server rejects a client signature with a different digest */
+static int test_oap_srv_rejects_md_mismatch(void)
+{
+ struct oap_test_ctx ctx;
+
+ test_default_cfg();
+ test_cfg.cli.auth = AUTH;
+ test_cfg.cli.md = NID_sha384;
+
+ TEST_START();
+
+ if (oap_test_setup(&ctx, root_ca_crt_ec, im_ca_crt_ec) < 0)
+ goto fail;
+
+ if (oap_cli_prepare_ctx(&ctx) < 0) {
+ printf("Client prepare failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (oap_srv_process_ctx(&ctx) == 0) {
+ printf("Server should reject digest mismatch.\n");
+ goto fail_cleanup;
+ }
+
+ oap_test_teardown(&ctx);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+
+ fail_cleanup:
+ oap_test_teardown(&ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/* Naive substring search over raw bytes (memmem is not portable here). */
+static bool buf_contains(const uint8_t * hay,
+ size_t hlen,
+ const uint8_t * needle,
+ size_t nlen)
+{
+ size_t i;
+
+ if (nlen == 0 || nlen > hlen)
+ return false;
+
+ for (i = 0; i + nlen <= hlen; i++) {
+ if (memcmp(hay + i, needle, nlen) == 0)
+ return true;
+ }
+
+ return false;
+}
+
+/* The server certificate must not appear in cleartext on the wire */
+static int test_oap_server_cert_hidden(void)
+{
+ struct oap_test_ctx ctx;
+ void * crt = NULL;
+ buffer_t der = BUF_INIT;
+
+ test_default_cfg();
+
+ TEST_START();
+
+ if (oap_test_setup(&ctx, root_ca_crt_ec, im_ca_crt_ec) < 0)
+ goto fail;
+
+ if (oap_cli_prepare_ctx(&ctx) < 0) {
+ printf("Client prepare failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (oap_srv_process_ctx(&ctx) < 0) {
+ printf("Server process failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (crypt_load_crt_str(signed_server_crt_ec, &crt) < 0) {
+ printf("Failed to load server crt.\n");
+ goto fail_cleanup;
+ }
+
+ if (crypt_crt_der(crt, &der) < 0) {
+ printf("Failed to DER-encode server crt.\n");
+ goto fail_crt;
+ }
+
+ if (der.len == 0 || der.len > ctx.resp_hdr.len) {
+ printf("Unexpected cert/response sizes.\n");
+ goto fail_der;
+ }
+
+ if (buf_contains(ctx.resp_hdr.data, ctx.resp_hdr.len,
+ der.data, der.len)) {
+ printf("Server certificate found in cleartext.\n");
+ goto fail_der;
+ }
+
+ /* The handshake must still complete and agree on a key */
+ if (oap_cli_complete_ctx(&ctx) < 0) {
+ printf("Client complete failed.\n");
+ goto fail_der;
+ }
+
+ if (memcmp(ctx.cli.key, ctx.srv.key, SYMMKEYSZ) != 0) {
+ printf("Client and server keys do not match!\n");
+ goto fail_der;
+ }
+
+ freebuf(der);
+ crypt_free_crt(crt);
+ oap_test_teardown(&ctx);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+
+ fail_der:
+ freebuf(der);
+ fail_crt:
+ crypt_free_crt(crt);
+ fail_cleanup:
+ oap_test_teardown(&ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/* Tampering the sealed identity block fails the handshake */
+static int test_oap_sealed_tamper(void)
+{
+ struct oap_test_ctx ctx;
+ size_t pos;
+
+ test_default_cfg();
+
+ TEST_START();
+
+ if (oap_test_setup(&ctx, root_ca_crt_ec, im_ca_crt_ec) < 0)
+ goto fail;
+
+ if (oap_cli_prepare_ctx(&ctx) < 0) {
+ printf("Client prepare failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (oap_srv_process_ctx(&ctx) < 0) {
+ printf("Server process failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (ctx.resp_hdr.len < 64) {
+ printf("Response too short for test.\n");
+ goto fail_cleanup;
+ }
+
+ /* Flip a byte inside the sealed ciphertext, before the AEAD tag */
+ pos = ctx.resp_hdr.len - 32;
+ ctx.resp_hdr.data[pos] ^= 0xFF;
+
+ if (oap_cli_complete_ctx(&ctx) == 0) {
+ printf("Client accepted a tampered identity block.\n");
+ goto fail_cleanup;
+ }
+
+ oap_test_teardown(&ctx);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+
+ fail_cleanup:
+ oap_test_teardown(&ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/* Cleartext md-only: rsp_tag echoes H(request) and is the sole gate */
+static int test_oap_cleartext_echo_tamper(void)
+{
+ struct oap_test_ctx ctx;
+
+ memset(&test_cfg, 0, sizeof(test_cfg));
+ test_cfg.srv.md = NID_sha256;
+ test_cfg.srv.auth = NO_AUTH;
+ test_cfg.cli.md = NID_sha256;
+ test_cfg.cli.auth = NO_AUTH;
+
+ TEST_START();
+
+ if (oap_test_setup(&ctx, root_ca_crt_ec, im_ca_crt_ec) < 0)
+ goto fail;
+
+ if (oap_cli_prepare_ctx(&ctx) < 0) {
+ printf("Client prepare failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (oap_srv_process_ctx(&ctx) < 0) {
+ printf("Server process failed.\n");
+ goto fail_cleanup;
+ }
+
+ /* rsp_tag is the trailing field of an unsealed, unsigned response */
+ ctx.resp_hdr.data[ctx.resp_hdr.len - 1] ^= 0xFF;
+
+ if (oap_cli_complete_ctx(&ctx) == 0) {
+ printf("Client accepted a tampered request echo.\n");
+ goto fail_cleanup;
+ }
+
+ oap_test_teardown(&ctx);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+
+ fail_cleanup:
+ oap_test_teardown(&ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/* Client rejects a response whose session ID does not match the request */
+static int test_oap_response_id_tamper(void)
+{
+ struct oap_test_ctx ctx;
+
+ /* Cleartext md-only: no seal, so the ID check itself must reject. */
+ memset(&test_cfg, 0, sizeof(test_cfg));
+ test_cfg.srv.md = NID_sha256;
+ test_cfg.srv.auth = NO_AUTH;
+ test_cfg.cli.md = NID_sha256;
+ test_cfg.cli.auth = NO_AUTH;
+
+ TEST_START();
+
+ if (oap_test_setup(&ctx, root_ca_crt_ec, im_ca_crt_ec) < 0)
+ goto fail;
+
+ if (oap_cli_prepare_ctx(&ctx) < 0) {
+ printf("Client prepare failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (oap_srv_process_ctx(&ctx) < 0) {
+ printf("Server process failed.\n");
+ goto fail_cleanup;
+ }
+
+ /* The session ID is the first field of the fixed header. */
+ ctx.resp_hdr.data[0] ^= 0xFF;
+
+ if (oap_cli_complete_ctx(&ctx) == 0) {
+ printf("Client accepted a mismatched response ID.\n");
+ goto fail_cleanup;
+ }
+
+ oap_test_teardown(&ctx);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+
+ fail_cleanup:
+ oap_test_teardown(&ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+int oap_test(int argc,
+ char **argv)
+{
+ int ret = 0;
+
+ (void) argc;
+ (void) argv;
+
+ ret |= test_oap_auth_init_fini();
+ ret |= test_oap_replay_cap();
+ ret |= test_oap_replay_generations();
+
+#ifdef HAVE_OPENSSL
+ ret |= test_oap_roundtrip_auth_only();
+ ret |= test_oap_roundtrip_kex_only();
+ ret |= test_oap_piggyback_data();
+ ret |= test_oap_rekey_all();
+ ret |= test_oap_rekey_badcache_all();
+ ret |= test_oap_rekey_srv_badcache_all();
+ ret |= test_oap_cli_rejects_rekey_no_kex();
+ ret |= test_oap_srv_rejects_rekey_no_kex();
+
+ ret |= test_oap_roundtrip_all();
+ ret |= test_oap_roundtrip_md_all();
+
+ ret |= test_oap_corrupted_request();
+ ret |= test_oap_corrupted_response();
+ ret |= test_oap_key_confirm_mismatch();
+ ret |= test_oap_truncated_request();
+ ret |= test_oap_inflated_length_field();
+ ret |= test_oap_deflated_length_field();
+ ret |= test_oap_nid_without_kex();
+ ret |= test_oap_unsupported_nid_undefined();
+ ret |= test_oap_unsupported_nid_all();
+
+ ret |= test_oap_cipher_mismatch();
+ ret |= test_oap_srv_enc_cli_none();
+ ret |= test_oap_cli_enc_srv_none();
+ ret |= test_oap_cli_rejects_downgrade();
+ ret |= test_oap_cli_rejects_suite_swap();
+ ret |= test_oap_srv_rejects_weak_kex();
+
+ ret |= test_oap_ts_reject_all();
+ ret |= test_oap_replay_packet();
+ ret |= test_oap_missing_root_ca();
+ ret |= test_oap_server_name_mismatch();
+
+ ret |= test_oap_cli_requires_srv_auth();
+ ret |= test_oap_srv_requires_cli_auth();
+ ret |= test_oap_mutual_req_auth();
+
+
+ ret |= test_oap_cli_rejects_md_mismatch();
+ ret |= test_oap_srv_rejects_md_mismatch();
+
+ ret |= test_oap_server_cert_hidden();
+ ret |= test_oap_sealed_tamper();
+ ret |= test_oap_cleartext_echo_tamper();
+ ret |= test_oap_response_id_tamper();
+#else
+ (void) test_oap_roundtrip_auth_only;
+ (void) test_oap_roundtrip_kex_only;
+ (void) test_oap_piggyback_data;
+ (void) test_oap_rekey_all;
+ (void) test_oap_rekey_badcache_all;
+ (void) test_oap_rekey_srv_badcache_all;
+ (void) test_oap_cli_rejects_rekey_no_kex;
+ (void) test_oap_srv_rejects_rekey_no_kex;
+ (void) test_oap_roundtrip;
+ (void) test_oap_roundtrip_all;
+ (void) test_oap_roundtrip_md;
+ (void) test_oap_roundtrip_md_all;
+ (void) test_oap_corrupted_request;
+ (void) test_oap_corrupted_response;
+ (void) test_oap_key_confirm_mismatch;
+ (void) test_oap_truncated_request;
+ (void) test_oap_inflated_length_field;
+ (void) test_oap_deflated_length_field;
+ (void) test_oap_nid_without_kex;
+ (void) test_oap_unsupported_nid;
+ (void) test_oap_unsupported_nid_undefined;
+ (void) test_oap_unsupported_nid_all;
+ (void) test_oap_cipher_mismatch;
+ (void) test_oap_srv_enc_cli_none;
+ (void) test_oap_cli_enc_srv_none;
+ (void) test_oap_cli_rejects_downgrade;
+ (void) test_oap_cli_rejects_suite_swap;
+ (void) test_oap_srv_rejects_weak_kex;
+ (void) test_oap_ts_reject_all;
+ (void) test_oap_replay_packet;
+ (void) test_oap_replay_generations;
+ (void) test_oap_missing_root_ca;
+ (void) test_oap_server_name_mismatch;
+ (void) test_oap_cli_requires_srv_auth;
+ (void) test_oap_srv_requires_cli_auth;
+ (void) test_oap_mutual_req_auth;
+ (void) test_oap_cli_rejects_md_mismatch;
+ (void) test_oap_srv_rejects_md_mismatch;
+ (void) test_oap_server_cert_hidden;
+ (void) test_oap_sealed_tamper;
+ (void) test_oap_cleartext_echo_tamper;
+ (void) test_oap_response_id_tamper;
+ (void) test_oap_rekey;
+ (void) test_oap_rekey_badcache;
+
+ ret = TEST_RC_SKIP;
+#endif
+ crypt_cleanup();
+
+ return ret;
+}
diff --git a/src/irmd/oap/tests/oap_test_ml_dsa.c b/src/irmd/oap/tests/oap_test_ml_dsa.c
new file mode 100644
index 00000000..36712830
--- /dev/null
+++ b/src/irmd/oap/tests/oap_test_ml_dsa.c
@@ -0,0 +1,777 @@
+/*
+ * Ouroboros - Copyright (C) 2016 - 2026
+ *
+ * Unit tests of OAP ML-KEM/ML-DSA key exchange
+ *
+ * Dimitri Staessens <dimitri@ouroboros.rocks>
+ * Sander Vrijders <sander@ouroboros.rocks>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., http://www.fsf.org/about/contact/.
+ */
+
+#if defined(__linux__) || defined(__CYGWIN__)
+#define _DEFAULT_SOURCE
+#else
+#define _POSIX_C_SOURCE 200809L
+#endif
+
+#include "config.h"
+
+#include <ouroboros/crypt.h>
+#include <ouroboros/endian.h>
+#include <ouroboros/flow.h>
+#include <ouroboros/name.h>
+#include <ouroboros/random.h>
+#include <test/test.h>
+
+#include <test/certs/ml_dsa.h>
+
+#include "oap.h"
+#include "common.h"
+
+#include <stdbool.h>
+#include <string.h>
+
+#ifdef HAVE_OPENSSL
+#include <openssl/evp.h>
+#endif
+
+#define CLI_ENCAP KEM_MODE_CLIENT_ENCAP
+#define SRV_ENCAP KEM_MODE_SERVER_ENCAP
+
+/* Wire constants for inspecting the encoded kex_len field. */
+#define OAP_KEX_LEN_OFFSET 32
+#define OAP_KEX_ROLE_BIT 0x4000 /* bit 14: 1 = client encaps */
+
+extern const uint16_t kex_supported_nids[];
+extern const uint16_t md_supported_nids[];
+
+static int get_random_kdf(void)
+{
+ static int idx = 0;
+ int count;
+
+ if (md_supported_nids[0] == NID_undef)
+ return NID_undef;
+
+ for (count = 0; md_supported_nids[count] != NID_undef; count++)
+ ;
+
+ return md_supported_nids[(idx++) % count];
+}
+
+struct test_cfg test_cfg;
+
+/* KEM keypair storage for tests (server-side keypair for KEM modes) */
+static void * test_kem_pkp = NULL; /* Private key pair */
+static uint8_t test_kem_pk[4096]; /* Public key buffer */
+static size_t test_kem_pk_len = 0;
+
+/* Mock load - called by load_*_credentials in common.c */
+int mock_load_credentials(void ** pkp,
+ void ** crt)
+{
+ *pkp = NULL;
+ *crt = NULL;
+
+ if (crypt_load_privkey_str(server_pkp_ml, pkp) < 0)
+ return -1;
+
+ if (crypt_load_crt_str(signed_server_crt_ml, crt) < 0) {
+ crypt_free_key(*pkp);
+ *pkp = NULL;
+ return -1;
+ }
+
+ return 0;
+}
+
+int load_server_kem_keypair(const char * name,
+ bool raw_fmt,
+ void ** pkp)
+{
+#ifdef HAVE_OPENSSL
+ struct sec_config local_cfg;
+ ssize_t pk_len;
+
+ (void) name;
+ (void) raw_fmt;
+
+ /*
+ * Uses reference counting. The caller will call
+ * EVP_PKEY_free which decrements the count.
+ */
+ if (test_kem_pkp != NULL) {
+ if (EVP_PKEY_up_ref((EVP_PKEY *)test_kem_pkp) != 1)
+ return -1;
+
+ *pkp = test_kem_pkp;
+ return 0;
+ }
+
+ /*
+ * Generate a new KEM keypair from test_cfg.srv.kex.
+ */
+ memset(&local_cfg, 0, sizeof(local_cfg));
+ if (test_cfg.srv.kex == NID_undef)
+ goto fail;
+
+ SET_KEX_ALGO_NID(&local_cfg, test_cfg.srv.kex);
+
+ pk_len = kex_pkp_create(&local_cfg, &test_kem_pkp, test_kem_pk);
+ if (pk_len < 0)
+ goto fail;
+
+ test_kem_pk_len = (size_t) pk_len;
+
+ if (EVP_PKEY_up_ref((EVP_PKEY *)test_kem_pkp) != 1)
+ goto fail_ref;
+
+ *pkp = test_kem_pkp;
+
+ return 0;
+ fail_ref:
+ kex_pkp_destroy(test_kem_pkp);
+ test_kem_pkp = NULL;
+ test_kem_pk_len = 0;
+ fail:
+ return -1;
+
+#else
+ (void) name;
+ (void) raw_fmt;
+ (void) pkp;
+ return -1;
+#endif
+}
+
+int load_server_kem_pk(const char * name,
+ struct sec_config * cfg,
+ buffer_t * pk)
+{
+ ssize_t len;
+
+ (void) name;
+
+ if (test_kem_pk_len > 0) {
+ pk->data = malloc(test_kem_pk_len);
+ if (pk->data == NULL)
+ return -1;
+ memcpy(pk->data, test_kem_pk, test_kem_pk_len);
+ pk->len = test_kem_pk_len;
+ return 0;
+ }
+
+ /* Generate keypair on demand if not already done */
+ len = kex_pkp_create(cfg, &test_kem_pkp, test_kem_pk);
+ if (len < 0)
+ return -1;
+
+ test_kem_pk_len = (size_t) len;
+ pk->data = malloc(test_kem_pk_len);
+ if (pk->data == NULL)
+ return -1;
+
+ memcpy(pk->data, test_kem_pk, test_kem_pk_len);
+ pk->len = test_kem_pk_len;
+
+ return 0;
+}
+
+static void reset_kem_state(void)
+{
+ if (test_kem_pkp != NULL) {
+ kex_pkp_destroy(test_kem_pkp);
+ test_kem_pkp = NULL;
+ }
+ test_kem_pk_len = 0;
+}
+
+static void test_cfg_init(int kex,
+ int cipher,
+ int kdf,
+ int kem_mode,
+ bool cli_auth)
+{
+ memset(&test_cfg, 0, sizeof(test_cfg));
+
+ /* Server config */
+ test_cfg.srv.kex = kex;
+ test_cfg.srv.cipher = cipher;
+ test_cfg.srv.kdf = kdf;
+ test_cfg.srv.kem_mode = kem_mode;
+ test_cfg.srv.auth = true;
+
+ /* Client config */
+ test_cfg.cli.kex = kex;
+ test_cfg.cli.cipher = cipher;
+ test_cfg.cli.kdf = kdf;
+ test_cfg.cli.kem_mode = kem_mode;
+ test_cfg.cli.auth = cli_auth;
+}
+
+static int oap_test_setup_kem(struct oap_test_ctx * ctx,
+ const char * root_ca,
+ const char * im_ca)
+{
+ reset_kem_state();
+ return oap_test_setup(ctx, root_ca, im_ca);
+}
+
+static void oap_test_teardown_kem(struct oap_test_ctx * ctx)
+{
+ oap_test_teardown(ctx);
+}
+
+static int test_oap_roundtrip_auth_only(void)
+{
+ test_cfg_init(NID_undef, NID_undef, NID_undef, 0, false);
+
+ return roundtrip_auth_only(root_ca_crt_ml, im_ca_crt_ml);
+}
+
+/* Digest pin does not apply to PQC: the digest is intrinsic */
+static int test_oap_cli_md_pin_exempts_pqc(void)
+{
+ test_cfg_init(NID_undef, NID_undef, NID_undef, 0, NO_AUTH);
+ test_cfg.cli.md = NID_sha256;
+
+ return roundtrip_auth_only(root_ca_crt_ml, im_ca_crt_ml);
+}
+
+static int test_oap_srv_md_pin_exempts_pqc(void)
+{
+ test_cfg_init(NID_undef, NID_undef, NID_undef, 0, AUTH);
+ test_cfg.srv.md = NID_sha256;
+
+ return roundtrip_auth_only(root_ca_crt_ml, im_ca_crt_ml);
+}
+
+static int test_oap_rekey(bool srv_auth,
+ bool cli_auth)
+{
+ test_cfg_init(NID_X25519, NID_aes_256_gcm, NID_sha256,
+ 0, cli_auth);
+ test_cfg.srv.auth = srv_auth;
+
+ return roundtrip_rekey(root_ca_crt_ml, im_ca_crt_ml,
+ srv_auth, cli_auth);
+}
+
+static int test_oap_rekey_all(void)
+{
+ int ret = 0;
+
+ ret |= test_oap_rekey(AUTH, NO_AUTH);
+ ret |= test_oap_rekey(AUTH, AUTH);
+ ret |= test_oap_rekey(NO_AUTH, AUTH);
+ ret |= test_oap_rekey(NO_AUTH, NO_AUTH);
+
+ return ret;
+}
+
+static int test_oap_rekey_badcache(bool cli_auth)
+{
+ test_cfg_init(NID_X25519, NID_aes_256_gcm, NID_sha256,
+ 0, cli_auth);
+
+ return roundtrip_rekey_badcache(root_ca_crt_ml, im_ca_crt_ml,
+ cli_auth);
+}
+
+static int test_oap_rekey_badcache_all(void)
+{
+ int ret = 0;
+
+ ret |= test_oap_rekey_badcache(NO_AUTH);
+ ret |= test_oap_rekey_badcache(AUTH);
+
+ return ret;
+}
+
+static int test_oap_rekey_srv_badcache(bool srv_auth)
+{
+ test_cfg_init(NID_X25519, NID_aes_256_gcm, NID_sha256,
+ 0, AUTH);
+ test_cfg.srv.auth = srv_auth;
+
+ return roundtrip_rekey_srv_badcache(root_ca_crt_ml, im_ca_crt_ml,
+ srv_auth);
+}
+
+static int test_oap_rekey_srv_badcache_all(void)
+{
+ int ret = 0;
+
+ ret |= test_oap_rekey_srv_badcache(AUTH);
+ ret |= test_oap_rekey_srv_badcache(NO_AUTH);
+
+ return ret;
+}
+
+static int test_oap_corrupted_request(void)
+{
+ test_cfg_init(NID_MLKEM768, NID_aes_256_gcm, get_random_kdf(),
+ SRV_ENCAP, AUTH);
+
+ return corrupted_request(root_ca_crt_ml, im_ca_crt_ml);
+}
+
+static int test_oap_corrupted_response(void)
+{
+ test_cfg_init(NID_MLKEM768, NID_aes_256_gcm, get_random_kdf(),
+ SRV_ENCAP, NO_AUTH);
+
+ return corrupted_response(root_ca_crt_ml, im_ca_crt_ml);
+}
+
+static int test_oap_truncated_request(void)
+{
+ test_cfg_init(NID_MLKEM768, NID_aes_256_gcm, get_random_kdf(),
+ SRV_ENCAP, NO_AUTH);
+
+ return truncated_request(root_ca_crt_ml, im_ca_crt_ml);
+}
+
+static int test_oap_roundtrip_kem(int kex,
+ int kem_mode)
+{
+ struct oap_test_ctx ctx;
+ const char * kex_str = kex_nid_to_str(kex);
+ const char * mode_str = kem_mode == CLI_ENCAP ? "cli" : "srv";
+
+ test_cfg_init(kex, NID_aes_256_gcm, get_random_kdf(),
+ kem_mode, NO_AUTH);
+
+ TEST_START("(%s, %s encaps)", kex_str, mode_str);
+
+ if (oap_test_setup_kem(&ctx, root_ca_crt_ml, im_ca_crt_ml) < 0)
+ goto fail;
+
+ if (oap_cli_prepare_ctx(&ctx) < 0) {
+ printf("Client prepare failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (oap_srv_process_ctx(&ctx) < 0) {
+ printf("Server process failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (oap_cli_complete_ctx(&ctx) < 0) {
+ printf("Client complete failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (memcmp(ctx.cli.key, ctx.srv.key, SYMMKEYSZ) != 0) {
+ printf("Client and server keys do not match!\n");
+ goto fail_cleanup;
+ }
+
+ if (ctx.cli.nid == NID_undef ||
+ ctx.srv.nid == NID_undef) {
+ printf("Cipher not set in flow.\n");
+ goto fail_cleanup;
+ }
+
+ oap_test_teardown_kem(&ctx);
+
+ TEST_SUCCESS("(%s, %s encaps)", kex_str, mode_str);
+ return TEST_RC_SUCCESS;
+
+ fail_cleanup:
+ oap_test_teardown_kem(&ctx);
+ fail:
+ TEST_FAIL("(%s, %s encaps)", kex_str, mode_str);
+ return TEST_RC_FAIL;
+}
+
+static int test_oap_roundtrip_kem_all(void)
+{
+ int ret = 0;
+ int i;
+
+ for (i = 0; kex_supported_nids[i] != NID_undef; i++) {
+ const char * algo = kex_nid_to_str(kex_supported_nids[i]);
+
+ if (!IS_KEM_ALGORITHM(algo))
+ continue;
+
+ ret |= test_oap_roundtrip_kem(kex_supported_nids[i], SRV_ENCAP);
+ ret |= test_oap_roundtrip_kem(kex_supported_nids[i], CLI_ENCAP);
+ }
+
+ return ret;
+}
+
+/* Re-key over a KEM KEX: forced ephemeral server-encap + cert-drop cache. */
+static int test_oap_rekey_kem(int kex,
+ int kem_mode)
+{
+ struct oap_test_ctx ctx;
+ const char * kex_str = kex_nid_to_str(kex);
+ const char * mode_str = "srv";
+ uint8_t key0[SYMMKEYSZ];
+
+ if (kem_mode == CLI_ENCAP)
+ mode_str = "cli";
+
+ test_cfg_init(kex, NID_aes_256_gcm, get_random_kdf(),
+ kem_mode, NO_AUTH);
+
+ TEST_START("(%s, %s encaps)", kex_str, mode_str);
+
+ if (oap_test_setup_kem(&ctx, root_ca_crt_ml, im_ca_crt_ml) < 0)
+ goto fail;
+
+ if (oap_cli_prepare_ctx(&ctx) < 0) {
+ printf("Initial client prepare failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (oap_srv_process_ctx(&ctx) < 0) {
+ printf("Initial server process failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (oap_cli_complete_ctx(&ctx) < 0) {
+ printf("Initial client complete failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (memcmp(ctx.cli.key, ctx.srv.key, SYMMKEYSZ) != 0) {
+ printf("Initial keys do not match.\n");
+ goto fail_cleanup;
+ }
+
+ if (ctx.cli_crt.len == 0) {
+ printf("Server cert was not cached for re-key.\n");
+ goto fail_cleanup;
+ }
+
+ memcpy(key0, ctx.cli.key, SYMMKEYSZ);
+
+ freebuf(ctx.req_hdr);
+ freebuf(ctx.resp_hdr);
+ freebuf(ctx.data);
+
+ ctx.rekey = true;
+
+ if (oap_cli_prepare_ctx(&ctx) < 0) {
+ printf("Re-key client prepare failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (oap_srv_process_ctx(&ctx) < 0) {
+ printf("Re-key server process failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (oap_cli_complete_ctx(&ctx) < 0) {
+ printf("Re-key client complete failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (memcmp(ctx.cli.key, ctx.srv.key, SYMMKEYSZ) != 0) {
+ printf("Re-key keys do not match.\n");
+ goto fail_cleanup;
+ }
+
+ if (memcmp(ctx.cli.key, key0, SYMMKEYSZ) == 0) {
+ printf("Re-key did not produce a fresh key.\n");
+ goto fail_cleanup;
+ }
+
+ oap_test_teardown_kem(&ctx);
+
+ TEST_SUCCESS("(%s, %s encaps)", kex_str, mode_str);
+ return TEST_RC_SUCCESS;
+
+ fail_cleanup:
+ oap_test_teardown_kem(&ctx);
+ fail:
+ TEST_FAIL("(%s, %s encaps)", kex_str, mode_str);
+ return TEST_RC_FAIL;
+}
+
+static int test_oap_rekey_kem_all(void)
+{
+ int ret = 0;
+ int i;
+
+ for (i = 0; kex_supported_nids[i] != NID_undef; i++) {
+ const char * algo = kex_nid_to_str(kex_supported_nids[i]);
+
+ if (!IS_KEM_ALGORITHM(algo))
+ continue;
+
+ ret |= test_oap_rekey_kem(kex_supported_nids[i], SRV_ENCAP);
+ ret |= test_oap_rekey_kem(kex_supported_nids[i], CLI_ENCAP);
+ }
+
+ return ret;
+}
+
+/*
+ * Client-encap bakes the KDF into the ciphertext, so the server cannot
+ * upgrade it: a client KDF weaker than the server floor must be rejected.
+ */
+static int test_oap_kem_kdf_floor(int kex)
+{
+ struct oap_test_ctx ctx;
+ const char * kex_str = kex_nid_to_str(kex);
+
+ test_cfg_init(kex, NID_aes_256_gcm, NID_sha256,
+ CLI_ENCAP, NO_AUTH);
+ test_cfg.srv.kdf = NID_sha512;
+ test_cfg.cli.kdf = NID_sha256;
+
+ TEST_START("(%s)", kex_str);
+
+ if (oap_test_setup_kem(&ctx, root_ca_crt_ml, im_ca_crt_ml) < 0)
+ goto fail;
+
+ if (oap_cli_prepare_ctx(&ctx) < 0) {
+ printf("Client prepare failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (oap_srv_process_ctx(&ctx) == 0) {
+ printf("Server accepted a client KDF below its floor.\n");
+ goto fail_cleanup;
+ }
+
+ oap_test_teardown_kem(&ctx);
+
+ TEST_SUCCESS("(%s)", kex_str);
+ return TEST_RC_SUCCESS;
+
+ fail_cleanup:
+ oap_test_teardown_kem(&ctx);
+ fail:
+ TEST_FAIL("(%s)", kex_str);
+ return TEST_RC_FAIL;
+}
+
+/*
+ * A client-encap flow re-keys to ephemeral server-encap, so it keeps
+ * forward secrecy. The re-key request must advertise server-encap
+ * (kex_len Role bit clear) rather than re-using client encapsulation.
+ */
+static int test_oap_rekey_kem_forcing(int kex)
+{
+ struct oap_test_ctx ctx;
+ const char * kex_str = kex_nid_to_str(kex);
+ uint16_t kex_len;
+
+ test_cfg_init(kex, NID_aes_256_gcm, NID_sha256,
+ CLI_ENCAP, NO_AUTH);
+
+ TEST_START("(%s)", kex_str);
+
+ if (oap_test_setup_kem(&ctx, root_ca_crt_ml, im_ca_crt_ml) < 0)
+ goto fail;
+
+ if (oap_cli_prepare_ctx(&ctx) < 0) {
+ printf("Initial client prepare failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (oap_srv_process_ctx(&ctx) < 0) {
+ printf("Initial server process failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (oap_cli_complete_ctx(&ctx) < 0) {
+ printf("Initial client complete failed.\n");
+ goto fail_cleanup;
+ }
+
+ freebuf(ctx.req_hdr);
+ freebuf(ctx.resp_hdr);
+ freebuf(ctx.data);
+
+ ctx.rekey = true;
+
+ if (oap_cli_prepare_ctx(&ctx) < 0) {
+ printf("Re-key client prepare failed.\n");
+ goto fail_cleanup;
+ }
+
+ memcpy(&kex_len, ctx.req_hdr.data + OAP_KEX_LEN_OFFSET,
+ sizeof(kex_len));
+ kex_len = ntoh16(kex_len);
+
+ if (kex_len & OAP_KEX_ROLE_BIT) {
+ printf("Re-key did not force server-encap KEX.\n");
+ goto fail_cleanup;
+ }
+
+ if (oap_srv_process_ctx(&ctx) < 0) {
+ printf("Re-key server process failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (oap_cli_complete_ctx(&ctx) < 0) {
+ printf("Re-key client complete failed.\n");
+ goto fail_cleanup;
+ }
+
+ oap_test_teardown_kem(&ctx);
+
+ TEST_SUCCESS("(%s)", kex_str);
+ return TEST_RC_SUCCESS;
+
+ fail_cleanup:
+ oap_test_teardown_kem(&ctx);
+ fail:
+ TEST_FAIL("(%s)", kex_str);
+ return TEST_RC_FAIL;
+}
+
+static int test_oap_kem_srv_uncfg(int kex)
+{
+ struct oap_test_ctx ctx;
+ const char * kex_str = kex_nid_to_str(kex);
+
+ memset(&test_cfg, 0, sizeof(test_cfg));
+
+ /* Server: auth only, no KEX configured */
+ test_cfg.srv.auth = true;
+
+ /* Client: requests KEM with server-side encapsulation */
+ test_cfg.cli.kex = kex;
+ test_cfg.cli.cipher = NID_aes_256_gcm;
+ test_cfg.cli.kdf = get_random_kdf();
+ test_cfg.cli.kem_mode = SRV_ENCAP;
+ test_cfg.cli.auth = false;
+
+ TEST_START("(%s)", kex_str);
+
+ if (oap_test_setup_kem(&ctx, root_ca_crt_ml,
+ im_ca_crt_ml) < 0)
+ goto fail;
+
+ if (oap_cli_prepare_ctx(&ctx) < 0) {
+ printf("Client prepare failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (oap_srv_process_ctx(&ctx) < 0) {
+ printf("Server process failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (oap_cli_complete_ctx(&ctx) < 0) {
+ printf("Client complete failed.\n");
+ goto fail_cleanup;
+ }
+
+ if (memcmp(ctx.cli.key, ctx.srv.key, SYMMKEYSZ) != 0) {
+ printf("Client and server keys do not match!\n");
+ goto fail_cleanup;
+ }
+
+ if (ctx.cli.nid == NID_undef ||
+ ctx.srv.nid == NID_undef) {
+ printf("Cipher not set in flow.\n");
+ goto fail_cleanup;
+ }
+
+ oap_test_teardown_kem(&ctx);
+
+ TEST_SUCCESS("(%s)", kex_str);
+ return TEST_RC_SUCCESS;
+
+ fail_cleanup:
+ oap_test_teardown_kem(&ctx);
+ fail:
+ TEST_FAIL("(%s)", kex_str);
+ return TEST_RC_FAIL;
+}
+
+static int test_oap_kem_srv_uncfg_all(void)
+{
+ int ret = 0;
+ int i;
+
+ for (i = 0; kex_supported_nids[i] != NID_undef; i++) {
+ const char * algo;
+
+ algo = kex_nid_to_str(kex_supported_nids[i]);
+
+ if (!IS_KEM_ALGORITHM(algo))
+ continue;
+
+ ret |= test_oap_kem_srv_uncfg(kex_supported_nids[i]);
+ }
+
+ return ret;
+}
+
+int oap_test_ml_dsa(int argc,
+ char **argv)
+{
+ int ret = 0;
+
+ (void) argc;
+ (void) argv;
+
+#ifdef HAVE_ML
+ ret |= test_oap_roundtrip_auth_only();
+ ret |= test_oap_cli_md_pin_exempts_pqc();
+ ret |= test_oap_srv_md_pin_exempts_pqc();
+
+ ret |= test_oap_roundtrip_kem_all();
+
+ ret |= test_oap_kem_srv_uncfg_all();
+
+ ret |= test_oap_corrupted_request();
+ ret |= test_oap_corrupted_response();
+ ret |= test_oap_truncated_request();
+
+ ret |= test_oap_rekey_all();
+ ret |= test_oap_rekey_badcache_all();
+ ret |= test_oap_rekey_srv_badcache_all();
+ ret |= test_oap_rekey_kem_all();
+ ret |= test_oap_kem_kdf_floor(NID_MLKEM768);
+ ret |= test_oap_rekey_kem_forcing(NID_MLKEM768);
+#else
+ (void) test_oap_roundtrip_auth_only;
+ (void) test_oap_cli_md_pin_exempts_pqc;
+ (void) test_oap_srv_md_pin_exempts_pqc;
+ (void) test_oap_rekey;
+ (void) test_oap_rekey_all;
+ (void) test_oap_rekey_badcache;
+ (void) test_oap_rekey_badcache_all;
+ (void) test_oap_rekey_srv_badcache;
+ (void) test_oap_rekey_srv_badcache_all;
+ (void) test_oap_roundtrip_kem;
+ (void) test_oap_roundtrip_kem_all;
+ (void) test_oap_rekey_kem;
+ (void) test_oap_rekey_kem_all;
+ (void) test_oap_kem_kdf_floor;
+ (void) test_oap_rekey_kem_forcing;
+ (void) test_oap_kem_srv_uncfg;
+ (void) test_oap_kem_srv_uncfg_all;
+ (void) test_oap_corrupted_request;
+ (void) test_oap_corrupted_response;
+ (void) test_oap_truncated_request;
+
+ ret = TEST_RC_SKIP;
+#endif
+ crypt_cleanup();
+
+ return ret;
+}