[RFC PATCH v2 13/13] tests: Add DecoyAuth cache and password identifier coverage
Jeff Hansen
x at jeffhansen.com
Thu Oct 22 15:59:47 PDT 2026
Verify cold and warm cache results against direct interpolation. Report
the measured cache preparation and reuse cost for configurable password
counts.
Add hwsim regression coverage for named and unnamed candidate sets,
wrong-set rejection, failure recovery, fragmentation, identifier limits,
and optional-mode compatibility.
Signed-off-by: Jeff Hansen <x at jeffhansen.com>
---
tests/Makefile | 10 +
tests/README | 23 ++
tests/decoyauth.mk | 15 ++
tests/hwsim/test_sae_decoyauth_pid.py | 298 ++++++++++++++++++++++++++
tests/test-decoyauth-cache.c | 179 ++++++++++++++++
5 files changed, 525 insertions(+)
create mode 100644 tests/decoyauth.mk
create mode 100644 tests/hwsim/test_sae_decoyauth_pid.py
create mode 100644 tests/test-decoyauth-cache.c
diff --git a/tests/Makefile b/tests/Makefile
index 63fc52161..c148a6124 100644
--- a/tests/Makefile
+++ b/tests/Makefile
@@ -5,6 +5,10 @@ ALL=test-base64 test-md4 test-milenage \
test-sha256 test-aes test-x509v3 test-list test-rc4 \
test-bss test-mle
+ifdef CONFIG_SAE_DECOYAUTH_CACHE
+ALL += test-decoyauth-cache
+endif
+
include ../src/build.rules
ifdef LIBFUZZER
@@ -52,6 +56,8 @@ include test.mk
TEST=test-base64
include test.mk
+include decoyauth.mk
+
TEST=test-https
include test.mk
@@ -156,10 +162,14 @@ run-tests: $(ALL)
./test-sha256
./test-bss
./test-mle
+ifdef CONFIG_SAE_DECOYAUTH_CACHE
+ ./test-decoyauth-cache
+endif
@echo
@echo All tests completed successfully.
clean: common-clean
+ rm -f test-decoyauth-cache
rm -f *~
rm -f test_x509v3_nist.out.*
rm -f test_x509v3_nist2.out.*
diff --git a/tests/README b/tests/README
index 0e2dcffcf..cf8a1a4f3 100644
--- a/tests/README
+++ b/tests/README
@@ -9,6 +9,29 @@ wpa_supplicant and hostapd with a simulated radio (mac80211_hwsim). See
hwsim/READM and hwsim/vm/README for more details.
+DecoyAuth cache testing
+----------------------
+
+The DecoyAuth interpolation cache test requires Linux/POSIX facilities and
+OpenSSL development headers and libcrypto. It uses a separate OpenSSL-backed
+target; the other tests continue to use the internal crypto library.
+
+From this directory, build and run the serial implementation with:
+
+make test-decoyauth-cache
+./test-decoyauth-cache
+
+To exercise the worker-thread implementation, use a clean build:
+
+make clean
+make test-decoyauth-cache CONFIG_SAE_DECOYAUTH_THREADS=y
+./test-decoyauth-cache
+
+Set CONFIG_SAE_DECOYAUTH_CACHE=y to include the cache test in the default
+build and run-tests targets. DECOYAUTH_CACHE_PASSWORDS selects the number
+of candidates at runtime (2..512, default 512).
+
+
Build testing
-------------
diff --git a/tests/decoyauth.mk b/tests/decoyauth.mk
new file mode 100644
index 000000000..864a35e07
--- /dev/null
+++ b/tests/decoyauth.mk
@@ -0,0 +1,15 @@
+# Keep the OpenSSL-backed test separate from the internal-crypto test library.
+DECOYAUTH_OBJS = test-decoyauth-cache.o ../src/crypto/crypto_openssl.o
+_OBJS_VAR := DECOYAUTH_OBJS
+include ../src/objs.mk
+
+$(DECOYAUTH_OBJS): CFLAGS += -DCONFIG_ECC -DCONFIG_SHA256 \
+ -DCONFIG_SAE_DECOYAUTH -DCONFIG_SAE_DECOYAUTH_CACHE
+DECOYAUTH_LIBS = -lcrypto -lrt
+ifdef CONFIG_SAE_DECOYAUTH_THREADS
+$(DECOYAUTH_OBJS): CFLAGS += -DCONFIG_SAE_DECOYAUTH_THREADS -pthread
+DECOYAUTH_LIBS += -pthread
+endif
+
+test-decoyauth-cache: $(DECOYAUTH_OBJS) $(SLIBS)
+ $(LDO) $(LDFLAGS) -o $@ $^ $(DECOYAUTH_LIBS)
diff --git a/tests/hwsim/test_sae_decoyauth_pid.py b/tests/hwsim/test_sae_decoyauth_pid.py
new file mode 100644
index 000000000..0e1b4a475
--- /dev/null
+++ b/tests/hwsim/test_sae_decoyauth_pid.py
@@ -0,0 +1,298 @@
+# DecoyAuth password-identifier regression tests
+# This software may be distributed under the terms of the BSD license.
+# See README for more details.
+
+import hostapd
+import hwsim_utils
+from test_sae import check_sae_capab
+
+
+SSID = "decoyauth-pid"
+
+
+def _ap(apdev, passwords, mode="2"):
+ params = hostapd.wpa2_params(ssid=SSID)
+ params.update({"wpa_key_mgmt": "SAE", "sae_pwe": "1",
+ "sae_groups": "19", "decoyauth": mode,
+ "sae_password": passwords})
+ return hostapd.add_ap(apdev, params)
+
+
+def _network(sta, password, pid=None, mode="2"):
+ check_sae_capab(sta)
+ sta.request("PMKSA_FLUSH")
+ sta.set("sae_groups", "19")
+ sta.set("sae_pwe", "1")
+ net = sta.connect(SSID, sae_password=password, key_mgmt="SAE",
+ scan_freq="2412", only_add_network=True)
+ sta.set_network(net, "decoyauth", mode)
+ if pid is not None:
+ sta.set_network_quoted(net, "sae_password_id", pid)
+ return net
+
+
+def _connect(sta, hapd, password, pid=None, mode="2"):
+ net = _network(sta, password, pid, mode)
+ sta.connect_network(net, timeout=30)
+ hapd.wait_sta()
+ hwsim_utils.test_connectivity(sta, hapd)
+ return net
+
+
+def _reject(sta, password, pid=None, mode="2", status=None):
+ net = _network(sta, password, pid, mode)
+ sta.dump_monitor()
+ sta.select_network(net)
+ try:
+ ev = sta.wait_event(["CTRL-EVENT-CONNECTED",
+ "CTRL-EVENT-AUTH-REJECT",
+ "CTRL-EVENT-SAE-UNKNOWN-PASSWORD-IDENTIFIER",
+ "CTRL-EVENT-SSID-TEMP-DISABLED"], timeout=20)
+ if ev is None:
+ raise Exception("No authentication failure reported")
+ if "CTRL-EVENT-CONNECTED" in ev:
+ raise Exception("Authenticated with a password from the wrong set")
+ if status is not None and "status_code=%d" % status not in ev:
+ raise Exception("Unexpected authentication failure: " + ev)
+ if sta.wait_event(["CTRL-EVENT-CONNECTED"], timeout=2):
+ raise Exception("Authentication failure fell back to another set")
+ finally:
+ sta.remove_network(net)
+
+
+def _cleanup(dev):
+ for sta in dev:
+ sta.request("REMOVE_NETWORK all")
+ sta.set("sae_pwe", "0")
+ sta.set("sae_groups", "")
+
+
+def test_sae_decoyauth_pid_sets(dev, apdev):
+ """Independent identified and unidentified DecoyAuth sets"""
+ hapd = _ap(apdev[0], ["blank-owner", "blank-guest",
+ "owner-a|id=tenant a", "guest-a|id=tenant a",
+ "owner-b|id=tenant b", "guest-b|id=tenant b"])
+ try:
+ _connect(dev[0], hapd, "blank-guest")
+ _connect(dev[1], hapd, "guest-a", "tenant a")
+ _connect(dev[2], hapd, "guest-b", "tenant b")
+ # Exercise the other credential in the same set without a cached PMKSA.
+ dev[1].request("REMOVE_NETWORK all")
+ dev[1].wait_disconnected()
+ hapd.request("DEAUTHENTICATE " + dev[1].own_addr())
+ _connect(dev[1], hapd, "owner-a", "tenant a")
+ finally:
+ _cleanup(dev)
+
+
+def _wrong_set(dev, apdev, mode):
+ hapd = _ap(apdev[0], ["blank-password", "a-password|id=tenant a",
+ "b-password|id=tenant b"], mode=mode)
+ try:
+ for password, pid in [("blank-password", "unknown"),
+ ("blank-password", "tenant a"),
+ ("a-password", None),
+ ("a-password", "tenant b"),
+ ("wrong-password", "tenant a")]:
+ # Reset AP protocol state between deliberately different identities.
+ hapd.request("DEAUTHENTICATE " + dev[0].own_addr())
+ _reject(dev[0], password, pid, mode=mode)
+ finally:
+ _cleanup(dev)
+
+
+def test_sae_decoyauth_pid_wrong_set(dev, apdev):
+ """Required mode rejects credentials from other identifier sets"""
+ _wrong_set(dev, apdev, "2")
+
+
+def test_sae_decoyauth_pid_wrong_set_optional(dev, apdev):
+ """Optional mode must not fall back to a different identifier set"""
+ _wrong_set(dev, apdev, "1")
+
+
+def _failure_recovery(dev, apdev, mode):
+ hapd = _ap(apdev[0], ["blank-owner", "blank-guest",
+ "owner-a|id=tenant a", "guest-a|id=tenant a",
+ "owner-b|id=tenant b", "guest-b|id=tenant b"],
+ mode=mode)
+ try:
+ cases = [("tenant a", "guest-b", "tenant b"),
+ ("tenant b", "blank-guest", None),
+ (None, "guest-a", "tenant a")]
+ for sta, (failed_pid, password, pid) in zip(dev, cases):
+ _reject(sta, "wrong-password", failed_pid, status=15)
+ # Keep the AP's station entry: resetting it hides stale SAE state.
+ _connect(sta, hapd, password, pid)
+ finally:
+ _cleanup(dev)
+
+
+def test_sae_decoyauth_pid_failure_recovery(dev, apdev):
+ """Failed Confirm must not pin a new attempt to the old PID"""
+ _failure_recovery(dev, apdev, "2")
+
+
+def test_sae_decoyauth_pid_failure_recovery_optional(dev, apdev):
+ """Optional AP also clears rejected exchanges before a new PID"""
+ _failure_recovery(dev, apdev, "1")
+
+
+def test_sae_decoyauth_pid_unknown_recovery(dev, apdev):
+ """Unknown PID rejection must allow subsequent named and blank PIDs"""
+ hapd = _ap(apdev[0], ["blank-owner", "blank-guest",
+ "owner-a|id=tenant a", "guest-a|id=tenant a"])
+ try:
+ for sta, password, pid in [(dev[0], "guest-a", "tenant a"),
+ (dev[1], "blank-guest", None),
+ (dev[2], "blank-guest", "")]:
+ _reject(sta, "wrong-password", "unknown")
+ _connect(sta, hapd, password, pid)
+ finally:
+ _cleanup(dev)
+
+
+def test_sae_decoyauth_pid_fragmented_recovery(dev, apdev):
+ """Discard a failed fragmented candidate set before changing PID"""
+ passwords = ["candidate-%02d|id=large tenant" % i for i in range(40)]
+ hapd = _ap(apdev[0], passwords + ["blank-owner", "blank-guest"])
+ try:
+ _reject(dev[0], "wrong-password", "large tenant", status=15)
+ _connect(dev[0], hapd, "blank-guest", "")
+ _reject(dev[1], "wrong-password", status=15)
+ _connect(dev[1], hapd, "candidate-39", "large tenant")
+ finally:
+ _cleanup(dev)
+
+
+def test_sae_decoyauth_pid_fragmented(dev, apdev):
+ """An identified set large enough to require the fragment exchange"""
+ passwords = ["candidate-%02d|id=large tenant" % i for i in range(40)]
+ hapd = _ap(apdev[0], passwords + ["blank-password"])
+ try:
+ _connect(dev[0], hapd, "candidate-00", "large tenant")
+ _connect(dev[1], hapd, "candidate-39", "large tenant")
+ _connect(dev[2], hapd, "blank-password")
+ finally:
+ _cleanup(dev)
+
+
+def test_sae_decoyauth_pid_legacy(dev, apdev):
+ """Optional AP retains ordinary SAE with and without identifiers"""
+ hapd = _ap(apdev[0], ["blank-password", "id-password|id=tenant"], mode="1")
+ try:
+ _connect(dev[0], hapd, "blank-password", mode="0")
+ _connect(dev[1], hapd, "id-password", "tenant", mode="0")
+ _connect(dev[2], hapd, "id-password", "tenant")
+ finally:
+ _cleanup(dev)
+
+
+def test_sae_decoyauth_pid_old_ap(dev, apdev):
+ """New optional client falls back to ordinary SAE on an old-style AP"""
+ hapd = _ap(apdev[0], ["id-password|id=tenant"], mode="0")
+ try:
+ _connect(dev[0], hapd, "id-password", "tenant", mode="1")
+ _reject(dev[1], "id-password", "tenant", mode="2")
+ finally:
+ _cleanup(dev)
+
+
+def test_sae_decoyauth_pid_empty(dev, apdev):
+ """Empty configured identifiers are equivalent to absent identifiers"""
+ hapd = _ap(apdev[0], ["blank-owner", "blank-guest|id=",
+ "identified|id=tenant"])
+ try:
+ _connect(dev[0], hapd, "blank-guest", "")
+ _connect(dev[1], hapd, "blank-guest")
+ _connect(dev[2], hapd, "identified", "tenant")
+ finally:
+ _cleanup(dev)
+
+
+def test_sae_decoyauth_pid_same_password(dev, apdev):
+ """The same password can occur in separate identifier sets"""
+ hapd = _ap(apdev[0], ["shared-password",
+ "shared-password|id=tenant a",
+ "shared-password|id=tenant b"])
+ try:
+ _connect(dev[0], hapd, "shared-password")
+ _connect(dev[1], hapd, "shared-password", "tenant a")
+ _connect(dev[2], hapd, "shared-password", "tenant b")
+ finally:
+ _cleanup(dev)
+
+
+def test_sae_decoyauth_pid_length(dev, apdev):
+ """Maximum-length PID and rejection of overlong or NUL identifiers"""
+ pid = "x" * 254
+ hapd = _ap(apdev[0], ["id-password|id=" + pid])
+ try:
+ net = _connect(dev[0], hapd, "id-password", pid)
+ for value in ['"' + "x" * 255 + '"', "74656e616e740061"]:
+ res = dev[0].request("SET_NETWORK %d sae_password_id %s" %
+ (net, value))
+ if "FAIL" not in res:
+ raise Exception("Invalid PID accepted")
+ finally:
+ _cleanup(dev)
+
+
+def _reflection_recovery(dev, apdev, mode):
+ hapd = _ap(apdev[0], ["reflection-password"], mode=mode)
+ sta = dev[0]
+
+ def receive_auth(transaction):
+ for _ in range(10):
+ req = hapd.mgmt_rx()
+ if req is None:
+ raise Exception("No SAE Authentication frame received")
+ if req['subtype'] != 11:
+ continue
+ if req['payload'][:4] != bytes([3, 0, transaction, 0]):
+ raise Exception("Unexpected SAE transaction or authentication retry")
+ return req
+ raise Exception("No SAE Authentication frame received")
+
+ def process(req):
+ res = hapd.request("MGMT_RX_PROCESS freq=2412 datarate=0 "
+ "ssi_signal=-30 frame=" + req['frame'].hex())
+ if "OK" not in res:
+ raise Exception("Could not process SAE Authentication frame")
+
+ try:
+ sta.scan_for_bss(apdev[0]['bssid'], freq=2412)
+ hapd.set("ext_mgmt_frame_handling", "1")
+ net = _network(sta, "reflection-password", mode=mode)
+ sta.select_network(net)
+ commit = receive_auth(1)
+ hapd.mgmt_tx({'fc': commit['fc'], 'da': commit['sa'],
+ 'sa': commit['da'], 'bssid': commit['bssid'],
+ 'payload': commit['payload']})
+
+ # A reflected Commit must not elicit a Confirm or restart SAE.
+ req = hapd.mgmt_rx(timeout=0.5)
+ if req is not None and req['subtype'] == 11:
+ raise Exception("STA responded to a reflected Commit")
+
+ # Complete this same exchange, without allowing a retry to hide a failure.
+ process(commit)
+ confirm = receive_auth(2)
+ hapd.set("ext_mgmt_frame_handling", "0")
+ process(confirm)
+ sta.wait_connected()
+ hapd.wait_sta()
+ hwsim_utils.test_connectivity(sta, hapd)
+ finally:
+ hapd.set("ext_mgmt_frame_handling", "0")
+ _cleanup(dev)
+
+
+def test_sae_decoyauth_reflection_recovery(dev, apdev):
+ """A reflected Commit must not consume the pending DecoyAuth password"""
+ _reflection_recovery(dev, apdev, "2")
+
+
+def test_sae_decoyauth_reflection_recovery_disabled(dev, apdev):
+ """Ordinary SAE must also survive a reflection followed by a valid Commit"""
+ _reflection_recovery(dev, apdev, "0")
diff --git a/tests/test-decoyauth-cache.c b/tests/test-decoyauth-cache.c
new file mode 100644
index 000000000..42ecfd9d1
--- /dev/null
+++ b/tests/test-decoyauth-cache.c
@@ -0,0 +1,179 @@
+/*
+ * DecoyAuth interpolation cache correctness and performance test
+ * Copyright (c) 2026, Jeff Hansen <x at jeffhansen.com>
+ *
+ * This software may be distributed under the terms of the BSD license.
+ * See README for more details.
+ */
+
+#include "includes.h"
+#include <dirent.h>
+
+#include "common.h"
+#include "crypto/crypto.h"
+#include "crypto/sha256.h"
+
+
+static long elapsed_ms(const struct os_reltime *start,
+ const struct os_reltime *end)
+{
+ struct os_reltime elapsed;
+
+ os_reltime_sub(end, start, &elapsed);
+ return elapsed.sec * 1000L + elapsed.usec / 1000;
+}
+
+
+static void free_values(struct crypto_bignum **values, int count)
+{
+ if (!values)
+ return;
+ for (int i = 0; i < count; i++)
+ crypto_bignum_deinit(values[i], 1);
+ os_free(values);
+}
+
+
+static struct crypto_bignum ** make_values(int count, const char *label)
+{
+ struct crypto_bignum **values;
+ u8 digest[SHA256_MAC_LEN];
+ char input[80];
+
+ values = os_calloc(count, sizeof(*values));
+ if (!values)
+ return NULL;
+ for (int i = 0; i < count; i++) {
+ const u8 *addr[1];
+ size_t len[1];
+
+ os_snprintf(input, sizeof(input), "%s-%d", label, i);
+ addr[0] = (const u8 *) input;
+ len[0] = os_strlen(input);
+ if (sha256_vector(1, addr, len, digest) < 0)
+ goto fail;
+ values[i] = crypto_bignum_init_set(digest, sizeof(digest));
+ if (!values[i])
+ goto fail;
+ }
+ forced_memzero(digest, sizeof(digest));
+ return values;
+fail:
+ forced_memzero(digest, sizeof(digest));
+ free_values(values, count);
+ return NULL;
+}
+
+
+static int coefficients_equal(struct crypto_bignum **a,
+ struct crypto_bignum **b, int count)
+{
+ for (int i = 0; i < count; i++) {
+ if (!a[i] || !b[i] || crypto_bignum_cmp(a[i], b[i]) != 0)
+ return 0;
+ }
+ return 1;
+}
+
+
+static void remove_cache_dir(const char *path)
+{
+ DIR *dir = opendir(path);
+ struct dirent *entry;
+ char file[512];
+
+ if (!dir)
+ return;
+ while ((entry = readdir(dir))) {
+ if (os_strcmp(entry->d_name, ".") == 0 ||
+ os_strcmp(entry->d_name, "..") == 0)
+ continue;
+ os_snprintf(file, sizeof(file), "%s/%s", path, entry->d_name);
+ unlink(file);
+ }
+ closedir(dir);
+ rmdir(path);
+}
+
+
+int main(int argc, char *argv[])
+{
+ struct crypto_ec *ec = NULL;
+ struct crypto_bignum **x = NULL, **first = NULL, **second = NULL;
+ struct crypto_bignum **direct_first = NULL, **direct_second = NULL;
+ struct crypto_bignum **cold_first = NULL, **cold_second = NULL;
+ struct crypto_bignum **warm_first = NULL, **warm_second = NULL;
+ struct crypto_interpolation_cache_stats cold = { 0 }, warm = { 0 };
+ struct os_reltime start, end;
+ char directory[] = "/tmp/decoyauth-cache-test-XXXXXX";
+ const char *configured = getenv("DECOYAUTH_CACHE_PASSWORDS");
+ long direct_ms = -1, cold_ms = -1, warm_ms = -1;
+ int count = configured ? atoi(configured) : 512;
+ int ret = 1;
+
+ if (count < 2 || count > 512) {
+ printf("DECOYAUTH_CACHE_PASSWORDS must be between 2 and 512\n");
+ return 1;
+ }
+ ec = crypto_ec_init(19);
+ x = make_values(count, "password");
+ first = make_values(count, "first");
+ second = make_values(count, "second");
+ if (!ec || !x || !first || !second || !mkdtemp(directory))
+ goto out;
+
+ os_get_reltime(&start);
+ if (crypto_interpolate_pair(x, first, second, count, ec,
+ &direct_first, &direct_second) < 0)
+ goto out;
+ os_get_reltime(&end);
+ direct_ms = elapsed_ms(&start, &end);
+
+ os_get_reltime(&start);
+ if (crypto_interpolate_pair_cached(
+ x, first, second, count, ec, directory, 10,
+ &cold_first, &cold_second, &cold) < 0)
+ goto out;
+ os_get_reltime(&end);
+ cold_ms = elapsed_ms(&start, &end);
+
+ os_get_reltime(&start);
+ if (crypto_interpolate_pair_cached(
+ x, first, second, count, ec, directory, 10,
+ &warm_first, &warm_second, &warm) < 0)
+ goto out;
+ os_get_reltime(&end);
+ warm_ms = elapsed_ms(&start, &end);
+
+ if (cold.hit || !warm.hit ||
+ cold.bytes != (size_t) count * count * 32 ||
+ warm.bytes != cold.bytes ||
+ !coefficients_equal(direct_first, cold_first, count) ||
+ !coefficients_equal(direct_second, cold_second, count) ||
+ !coefficients_equal(direct_first, warm_first, count) ||
+ !coefficients_equal(direct_second, warm_second, count)) {
+ printf("DecoyAuth cache result mismatch\n");
+ goto out;
+ }
+
+ printf("DecoyAuth cache benchmark: passwords=%d matrix_bytes=%zu "
+ "direct_ms=%ld cold_ms=%ld warm_ms=%ld saved_ms=%ld "
+ "cold_prepare_ms=%ld cold_weave_ms=%ld warm_weave_ms=%ld\n",
+ count, warm.bytes, direct_ms, cold_ms, warm_ms,
+ direct_ms - warm_ms, cold.prepare_ms, cold.weave_ms,
+ warm.weave_ms);
+ ret = 0;
+out:
+ free_values(x, count);
+ free_values(first, count);
+ free_values(second, count);
+ free_values(direct_first, count);
+ free_values(direct_second, count);
+ free_values(cold_first, count);
+ free_values(cold_second, count);
+ free_values(warm_first, count);
+ free_values(warm_second, count);
+ crypto_ec_deinit(ec);
+ remove_cache_dir(directory);
+ return ret;
+}
--
2.56.0
More information about the Hostap
mailing list