From 17d23fa76aad16e425c16bd9fbf06e65731aea4d Mon Sep 17 00:00:00 2001 From: KiHoLee Date: Fri, 28 Aug 2026 17:40:28 +0900 Subject: [PATCH] Ciphertext-only family enumeration, and checks that reproduce off a GPU check_family_enum.py measures the attack the manuscript now states in Section III-A: the winning correlation is an index-free verifier, so ranking the 63 non-constant Walsh rows by mean winning correlation recovers the user set from one frame in 0.905 of 200 trials at 10 dB and from four frames in 0.990, using nothing outside the stated threat model. Under the invariance refresh it recovers it in none, because the entry permutation relabels the codebook the adversary must align against. V8 and V9 read the trained codebook through main_model(), which retrains on every call, and a codebook trained on CUDA is not the one trained on CPU. The shipped verify_math.csv therefore read PASS here and FAIL for anyone running this package without a GPU. model_main.pt is 7 KB and fixes the codebook, which is what both checks are about; delete it to retrain. V1-V11 now pass on both. New checks: V10, the format-matched OMA reference Section VI-B quotes, and V11, the closed-form against Monte Carlo comparison the manuscript claimed and never stored. V3a's bias-linearity result was computed and printed but never written to the CSV, so the one linearity claim the paper quotes was the one this package could not show. check_consistency.py gains 21 assertions, covering five data files that no assertion read (users, csi, semantic, cov_attack, sec_jam) and the trend claims it structurally could not see, since it compared values and not shapes. README: the figure map named stages that do not write the artifacts they list, so following it did not reproduce Figs. 4 and 6; the reproduction block was five scripts short; and the refresh numbers were from a superseded run (nearly three, 15.0 to 64.8 bits) against the manuscript's 2.3 and 23.8 to 364.6. --- README.md | 16 ++++-- code/check_consistency.py | 86 +++++++++++++++++++++++++++++ code/check_family_enum.py | 113 ++++++++++++++++++++++++++++++++++++++ code/make_tables.py | 2 +- code/sse_lib.py | 38 +++++++++++++ code/verify_math.py | 69 +++++++++++++++++++++-- data/family_enum.csv | 11 ++++ data/model_main.pt | Bin 0 -> 7325 bytes data/verify_math.csv | 3 + 9 files changed, 328 insertions(+), 10 deletions(-) create mode 100644 code/check_family_enum.py create mode 100644 data/family_enum.csv create mode 100644 data/model_main.pt diff --git a/README.md b/README.md index ca7e379..7e07c13 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ code/ exp_permkpa.py permutation-key known-plaintext attack (Fig. 7) check_cov_*.py ciphertext-only covariance-attack checks (referee M1) exp_real_sec.py stage G: real BERT WordPiece token streams - verify_math.py closed-form checks V1-V5, PASS/FAIL and verify_math.csv + verify_math.py closed-form checks V1-V11, PASS/FAIL and verify_math.csv replot_security.py every result figure, from data/ to fig/ make_tables.py LaTeX rows of every result table, from data/ feasibility_security.py early CPU-sized study, kept for the record @@ -53,6 +53,12 @@ python exp_full.py # stages A-F and L python exp_kpa.py # known-plaintext attack python exp_refresh.py # the key-refresh layer python exp_real_sec.py # real token streams +python exp_permkpa.py # permutation-key known plaintext +python exp_infotheory.py # mutual information and equivocation +python exp_semantic.py # semantic-similarity leakage +python exp_users_csi.py # load and channel-estimate sweeps +python check_cov_attack.py # ciphertext-only covariance attack +python check_family_enum.py # ciphertext-only enumeration of the key family python replot_security.py # all figures from the CSVs python make_tables.py # LaTeX rows of the result tables ``` @@ -77,9 +83,9 @@ Logarithms in an entropy or an information rate are base two. |---|---|---| | Fig. 2 SER against SNR | `exp_full.stage_A` | `sec_snr.csv` | | Fig. 3 key length | `exp_full.stage_B` | `sec_keylen.csv` | -| Fig. 4 jamming (4 schemes) | `exp_full.stage_L` | `sec_jam_cmp.csv`, `sec_jam.csv` | +| Fig. 4 jamming (4 schemes) | `exp_full.stage_C`, `stage_L` | `sec_jam_cmp.csv`, `sec_jam.csv` | | Fig. 5 key sensitivity | `exp_full.stage_I` | `sec_sens_cmp.csv` | -| Fig. 6 brute-force search | `exp_full.stage_J` | `sec_brute_cmp.csv`, `sec_brute.csv` | +| Fig. 6 brute-force search | `exp_full.stage_I`, `stage_F`, `stage_J` | `sec_brute_cmp.csv`, `sec_brute.csv` | | Fig. 7 known-plaintext attack | `exp_kpa`, `exp_permkpa` | `kpa.csv`, `pkpa.csv` | | Fig. 8 real token streams | `exp_real_sec` | `real_sec_ter.csv` | | Scheme comparison table | `exp_full.stage_E` | `sec_compare.csv` | @@ -103,8 +109,8 @@ measures. The key must therefore be refreshed per coherence block from a shared seed. `exp_refresh.py` implements that layer and shows why it has to draw from the transformations that leave the decision statistic invariant: a refresh that installs fresh orthogonal keys instead costs -the legitimate users a factor of nearly three, while the invariant -refresh costs nothing and raises the per-block key from 15.0 to 64.8 +the legitimate users a factor of 2.3, while the invariant +refresh costs nothing and raises the per-block key from 23.8 to 364.6 bits. ## License diff --git a/code/check_consistency.py b/code/check_consistency.py index 64fc92c..20b6b2f 100644 --- a/code/check_consistency.py +++ b/code/check_consistency.py @@ -268,6 +268,92 @@ chk("secrecy rate 14.87 of 14.93", "%s of %s" % (it["secrecy_rate_refresh_bits"], it["mi_legit_bits"])) +_fe = {(float(r["snr_db"]), int(r["n_frames"]), r["keying"]): float(r["recovery"]) + for r in rows("family_enum.csv")} +chk("family enumeration recovers the user set at 10 dB", + abs(_fe[(10.0, 1, "fixed")] - 0.905) < 5e-3 + and abs(_fe[(10.0, 4, "fixed")] - 0.990) < 5e-3, + "N=1 %.3f, N=4 %.3f" % (_fe[(10.0, 1, "fixed")], + _fe[(10.0, 4, "fixed")])) +chk("the refresh defeats the family enumeration", + _fe[(10.0, 2, "refreshed")] == 0.0, + "%.3f over 200 blocks" % _fe[(10.0, 2, "refreshed")]) + +# --- trends, which the value assertions above cannot see --------------- +_snr = rows("sec_snr.csv") +_lg = [float(r["legit"]) for r in _snr] +chk("legitimate SER falls monotonically with SNR", + all(a > b for a, b in zip(_lg, _lg[1:])), "%d points" % len(_lg)) +chk("legitimate below the binary OMA reference at every SNR", + all(float(r["legit"]) < float(r["oma"]) for r in _snr), + "min margin %.3f" % min(1 - float(r["legit"]) / float(r["oma"]) + for r in _snr)) +_kl = rows("sec_keylen.csv") +chk("legitimate SER falls monotonically with key length", + all(float(a["legit_ser"]) > float(b["legit_ser"]) for a, b in zip(_kl, _kl[1:])), + "%d lengths" % len(_kl)) +chk("legitimate surpasses the reference from L=16 onward", + all(float(r["legit_ser"]) < float(r["oma"]) for r in _kl + if r["oma"] != "nan" and int(float(r["L"])) >= 16), + "checked L>=16") +_ter = rows("real_sec_ter.csv") +chk("legitimate TER below OMA over the whole range", + all(float(r["ter_legit"]) < float(r["ter_oma"]) for r in _ter), + "%d points" % len(_ter)) +chk("outsider TER stays above 0.9991", + min(float(r["ter_eve"]) for r in _ter) > 0.9991, + "min %.6f" % min(float(r["ter_eve"]) for r in _ter)) + +# --- files no assertion read ------------------------------------------ +_us = rows("users.csv") +chk("keys stay exactly orthogonal at every load", + all(float(r["mask_xcorr"]) == 0.0 for r in _us), + "U up to %s" % _us[-1]["users"]) +chk("eavesdropper never leaves chance across the load sweep", + all(float(r["eve_ser"]) > 0.999 for r in _us), + "min %.6f" % min(float(r["eve_ser"]) for r in _us)) +_u = {r["users"]: r for r in _us} +chk("load endpoints 0.027 and 0.946", + abs(float(_u["2"]["legit_ser"]) - 0.027) < 5e-4 + and abs(float(_u["32"]["legit_ser"]) - 0.946) < 5e-4, + "%.4f, %.4f" % (float(_u["2"]["legit_ser"]), + float(_u["32"]["legit_ser"]))) +chk("the OMA crossing lies between U=16 and U=32", + float(_u["16"]["legit_ser"]) < float(_u["16"]["oma"]) + and float(_u["32"]["legit_ser"]) > float(_u["32"]["oma"]), + "16: %.3f<%.3f, 32: %.3f>%.3f" + % (float(_u["16"]["legit_ser"]), float(_u["16"]["oma"]), + float(_u["32"]["legit_ser"]), float(_u["32"]["oma"]))) +_csi = rows("csi.csv") +chk("phase residual moves the rate to 0.057 at 0.2 rad", + any(abs(float(r["legit_ser"]) - 0.057) < 1e-3 for r in _csi), + "%d rows" % len(_csi)) +_sem = rows("semantic.csv") +chk("legitimate similarity at least 0.96 in both spaces", + all(float(r["legit"]) >= 0.96 for r in _sem if r["snr_db"] == "10.0"), + "%d rows" % len(_sem)) +_cov = rows("cov_attack.csv") +chk("covariance attack reaches 0.26 at 300 same-key frames", + any(r["n_frames"] == "300" and abs(float(r["eve_ser"]) - 0.26) < 0.01 + for r in _cov), + "%d rows" % len(_cov)) +chk("unjammed reference is 0.053", + abs(col("sec_jam.csv", "nojam")[0] - 0.053) < 0.05, "sec_jam.csv read") + +# --- the closed-form checks the manuscript quotes ---------------------- +_vm = {r["check"]: r for r in rows("verify_math.csv")} +for _k, _c in [("V8 cross-period remainder", 0.0005), + ("V9 score-variance ratio", 0.05), + ("V10 format-matched OMA at 10 dB", 0.001), + ("V3a bias slope in kappa", 0.03)]: + chk("stored check %s passes" % _k.split()[0], + _k in _vm and _vm[_k]["verdict"] == "PASS" + and float(_vm[_k]["abs_err"]) <= _c, + _vm[_k]["empirical"] if _k in _vm else "row missing") +chk("format-matched OMA quoted as 0.055", + "$0.055$ at $10$~dB against the proposed" in tex, "Section VI-B", + needs_tex=True) + # --- tables against their generator ----------------------------------- # Every printed table cell must be the one make_tables.py derives from # data/, so a rerun that moves a number cannot leave the manuscript behind. diff --git a/code/check_family_enum.py b/code/check_family_enum.py new file mode 100644 index 0000000..6cd73cf --- /dev/null +++ b/code/check_family_enum.py @@ -0,0 +1,113 @@ +# -*- coding: utf-8 -*- +"""Ciphertext-only enumeration of the structured key family. + +Section III-A states that the winning correlation is itself an +index-free verifier: with the right key the winning score is of order +1/c, with a wrong key of order 1/sqrt(L). That makes the finite +structured family exhaustible by an adversary that never sees a +transmitted index, which is why the refresh of Section V-C is required +rather than optional. This script is the measurement behind that +claim. + +The attack. The threat model grants the adversary the public codebook, +the key family and its distribution, the channel model and the +normalizer, and it uses exactly those. For each of the L-1 non-constant +Walsh-Hadamard rows the adversary de-masks the received frame with that +row and records the mean winning per-digit correlation over N frames, +then keeps the U highest-scoring rows. It reads only the size of the +peak, never which candidate won, so no transmitted index is touched. + +It also runs the same attack against a refreshed key. The per-block +sign draw and entry permutation relabel the codebook the adversary +would have to align against, and the attack fails there. + +Writes data/family_enum.csv. +""" +from __future__ import annotations + +import math +from pathlib import Path + +import torch + +from exp_full import base_keys, main_model +from sse_lib import DEVICE, rayleigh_gain, snr_to_sigma2, write_csv + +DATA = Path(__file__).resolve().parents[1] / "data" +TRIALS = 200 +SEED = 8131 + + +@torch.no_grad() +def _observe(m, keys, snr_db, n, g): + """n superposed frames under the given key set, seen by Eve. + + Eve has her own flat-fading gain and knows it, which is the + strongest reading of the threat model. + """ + Bn = m.unit_codebook() + idx = torch.randint(m.vu, (n, m.users, m.P), generator=g, device=DEVICE) + e = Bn[idx] / math.sqrt(m.P) # (n,U,P,L) + y = (e * keys[None, :, None, :]).sum(dim=1) / m.c # (n,P,L) + h = rayleigh_gain((n, 1, 1), device=DEVICE) + sig = float(snr_to_sigma2(torch.tensor(snr_db), m.d).sqrt()) + rx = h * y + sig * torch.randn(n, m.P, m.L, generator=g, device=DEVICE) + return rx / h + + +@torch.no_grad() +def _peak_scores(m, r, cand, Bn): + """Mean winning per-digit correlation for every candidate row.""" + out = torch.empty(cand.shape[0]) + for k in range(cand.shape[0]): + z = torch.einsum("npl,vl->npv", r * cand[k][None, None, :], Bn) + out[k] = z.max(dim=2).values.mean() + return out + + +def run(): + torch.manual_seed(SEED) + m = main_model() # trains, so not under no_grad + _attack(m) + + +@torch.no_grad() +def _attack(m): + keys = m.masks() # (U,L) the true rows + cand = base_keys(m.L - 1, m.L).to(DEVICE) # every non-constant row + Bn = m.unit_codebook() / math.sqrt(m.P) + rows = [] + + for snr in (0.0, 10.0, 20.0): + for n in (1, 2, 4): + hit = 0 + for t in range(TRIALS): + g = torch.Generator(device=DEVICE).manual_seed( + SEED + 1000 * int(snr) + 10 * n + t) + r = _observe(m, keys, snr, n, g) + top = _peak_scores(m, r, cand, Bn).topk(m.users).indices + hit += int(set(int(i) for i in top) == set(range(m.users))) + rows.append((snr, n, "fixed", hit / TRIALS)) + print(" %4.0f dB N=%d fixed recovery %.3f" + % (snr, n, hit / TRIALS)) + + hit = 0 + for t in range(TRIALS): + g = torch.Generator(device=DEVICE).manual_seed(SEED + 77 + t) + perm = torch.randperm(m.L, generator=g, device=DEVICE) + sign = torch.randint(2, (m.L,), generator=g, + device=DEVICE) * 2.0 - 1.0 + rk = (keys * sign[None, :])[:, perm] + r = _observe(m, rk, 10.0, 2, g) + top = _peak_scores(m, r, cand, Bn).topk(m.users).indices + hit += int(set(int(i) for i in top) == set(range(m.users))) + rows.append((10.0, 2, "refreshed", hit / TRIALS)) + print(" 10 dB N=2 refreshed recovery %.3f" % (hit / TRIALS)) + + write_csv(DATA / "family_enum.csv", + ["snr_db", "n_frames", "keying", "recovery"], rows) + print("[csv]", DATA / "family_enum.csv") + + +if __name__ == "__main__": + run() diff --git a/code/make_tables.py b/code/make_tables.py index 32c250a..975bf22 100644 --- a/code/make_tables.py +++ b/code/make_tables.py @@ -13,7 +13,7 @@ DATA = Path(__file__).resolve().parents[1] / "data" NAME = { "proposed": r"\textbf{Proposed keyed masking}", "public_mask": "Public masks", - "perm_key": r"Permutation key~\cite{chen2023shuffling}", + "perm_key": r"Permutation key~\cite{chen2025shufflingtifs}", "index_cipher": "Index cipher", "oma_plain": "OMA (no encryption)", "random": "Random", diff --git a/code/sse_lib.py b/code/sse_lib.py index f825e92..8e74780 100644 --- a/code/sse_lib.py +++ b/code/sse_lib.py @@ -309,6 +309,44 @@ def oma_ser(snr_db_list, bits: int = 16, n_grid: int = 200_000): return out +def oma_ser_orth(snr_db_list, P: int = 4, vu: int = 16, L: int = 64, + n_h: int = 20_000, n_z: int = 2001): + """Format-matched OMA reference. + + The binary reference of oma_ser_keylen spends 16 of its L exclusive + dimensions on antipodal bits, a one-bit-per-dimension format inside + a log2(V)/L = 0.25 bit-per-dimension budget. The better uncoded use + of the same allocation is the format the proposed scheme itself + uses: P orthogonal decisions among vu candidates, each over L/P + exclusive dimensions, which needs exactly vu = L/P of them and so + fits the allocation with nothing to spare. + + Energy accounting matches oma_ser, where one unit of energy on a + dimension gives 2Es/N0 = snr, so an L-dimension user spending its L + units on P symbols puts L/P units in each. Given the fading gain h + the correct matched-filter output is N(h sqrt(Es), N0/2) against + vu-1 outputs N(0, N0/2), so a digit is right with probability + E_z[Phi(z + h sqrt((L/P) snr))^(vu-1)] and the index is right when + all P digits are. + """ + from scipy.special import log_ndtr + x = (np.arange(n_h) + 0.5) / n_h + h = np.sqrt(-np.log(1.0 - x)) # h^2 ~ Exp(1) + z = np.linspace(-8.0, 8.0, n_z) + phi = np.exp(-0.5 * z * z) / math.sqrt(2.0 * math.pi) + out = [] + for s in snr_db_list: + a = h * math.sqrt((L / P) * 10.0 ** (s / 10.0)) + pc = np.empty_like(a) + for i in range(0, a.size, 2048): # bound the working set + blk = a[i:i + 2048][:, None] + pc[i:i + 2048] = np.trapezoid( + phi * np.exp((vu - 1) * log_ndtr(z[None, :] + blk)), + z, axis=1) + out.append(float(np.mean(1.0 - pc ** P))) + return out + + @torch.no_grad() def oma_ser_mc(snr_db_list, bits: int = 16, frames: int = 2_000_000, chunk: int = 200_000, seed: int = 777): diff --git a/code/verify_math.py b/code/verify_math.py index aec8737..345401d 100644 --- a/code/verify_math.py +++ b/code/verify_math.py @@ -18,9 +18,32 @@ Run on CPU (NumPy); no training involved, pure algebra checks. from __future__ import annotations import numpy as np +from pathlib import Path + RNG = np.random.default_rng(2026) D, U, V = 64, 4, 256 +CKPT = Path(__file__).resolve().parent.parent / "data" / "model_main.pt" + + +def cached_main_model(): + """The trained main-configuration model, from a checkpoint. + + V8 and V9 read the trained codebook. Retraining it reproduces only + on the device that trained it, so a CPU run of the released package + disagreed with the shipped numbers. The checkpoint fixes the + codebook, which is what both checks are about; delete it to retrain. + """ + import torch + from exp_full import main_model + m = main_model() + if CKPT.exists(): + m.load_state_dict(torch.load(CKPT, map_location="cpu")) + else: + torch.save({k: v.cpu() for k, v in m.state_dict().items()}, CKPT) + return m + + def unit_codebook(V, d, rng): E = rng.standard_normal((V, d)) @@ -121,6 +144,9 @@ def v3_leakage_vs_correlation(): lin_ok = all(abs(b - rho * b1) <= 3e-2 for rho, b in slopes) print(f"[{'PASS' if lin_ok else 'FAIL'}] V3a bias linear in rho: " + ", ".join(f"rho={r:.2f}->{b:.3f}" for r, b in slopes)) + ROWS.append(("V3a bias slope in kappa", "1.0", "%.4f" % b1, + "%.4f" % abs(1.0 - b1), "0.03", + "PASS" if lin_ok else "FAIL")) # (b) random independent mask correlation: E|corr| = sqrt(2/(pi d)) # (the folded-normal mean of a N(0, 1/d) variable) corrs = [] @@ -263,8 +289,7 @@ def v8_cross_period_terms(): the codebook, which is the claim the proof rests on.""" import math import torch - from exp_full import main_model - m = main_model() + m = cached_main_model() Bn = m.unit_codebook().detach().cpu() pat = m.masks().detach().cpu()[0] L, P, d = m.L, m.P, m.d @@ -294,8 +319,7 @@ def v9_score_variance_ratio(): of sum_j e_j^4 / sum_j e_j^2 e'_j^2 over ordered codeword pairs of the trained unit codebook, quoted as 2.8 in the manuscript.""" import torch - from exp_full import main_model - m = main_model() + m = cached_main_model() B = m.unit_codebook().detach().cpu().double() B = B / B.norm(dim=1, keepdim=True) n = B.shape[0] @@ -313,6 +337,41 @@ def v9_score_variance_ratio(): return ok + +def v10_format_matched_oma(): + """The format-matched OMA reference of Section VI-B. + + The binary reference spends 16 of its 64 exclusive dimensions on + antipodal bits. The same allocation spent the way the proposed + scheme spends it, P=4 sixteen-ary orthogonal decisions over 16 + dimensions each, is the comparison a reviewer will ask for.""" + from sse_lib import oma_ser_orth + from exp_full import oma_ser_keylen + val = oma_ser_orth([10.0])[0] + binary = oma_ser_keylen(64, 10.0) + ok = abs(val - 0.055) < 0.001 + print("V10 format-matched OMA at 10 dB: %.5f (binary %.5f)" + % (val, binary)) + ROWS.append(("V10 format-matched OMA at 10 dB", "0.055", "%.5f" % val, + "%.5f" % abs(val - 0.055), "0.001", "PASS" if ok else "FAIL")) + return ok + + +def v11_oma_closed_form_vs_mc(): + """The manuscript says the OMA closed form agrees with Monte Carlo + to within one percent. That check had no stored artifact.""" + from sse_lib import oma_ser, oma_ser_mc + cf = oma_ser([16.0])[0] + mc = oma_ser_mc([16.0], frames=2_000_000)[0] + rel = abs(cf - mc) / mc + ok = rel < 0.01 + print("V11 OMA closed form %.6f vs Monte Carlo %.6f (%.2f%%)" + % (cf, mc, 100 * rel)) + ROWS.append(("V11 OMA closed form vs Monte Carlo", "%.6f" % mc, + "%.6f" % cf, "%.4f" % rel, "0.01", "PASS" if ok else "FAIL")) + return ok + + def main(): print(f"config d={D} U={U} V={V}\n") results = { @@ -325,6 +384,8 @@ def main(): "V7": v7_symbolic_identities(), "V8": v8_cross_period_terms(), "V9": v9_score_variance_ratio(), + "V10": v10_format_matched_oma(), + "V11": v11_oma_closed_form_vs_mc(), } print("\nsummary:", {k: ("PASS" if v else "FAIL") for k, v in results.items()}) print("ALL PASS" if all(results.values()) else "SOME FAILED") diff --git a/data/family_enum.csv b/data/family_enum.csv new file mode 100644 index 0000000..14c0ede --- /dev/null +++ b/data/family_enum.csv @@ -0,0 +1,11 @@ +snr_db,n_frames,keying,recovery +0,1,fixed,0.425 +0,2,fixed,0.46 +0,4,fixed,0.645 +10,1,fixed,0.905 +10,2,fixed,0.95 +10,4,fixed,0.99 +20,1,fixed,0.985 +20,2,fixed,1 +20,4,fixed,1 +10,2,refreshed,0 diff --git a/data/model_main.pt b/data/model_main.pt new file mode 100644 index 0000000000000000000000000000000000000000..67dc0ca3203da6b597319532262ff3eb391d42ae GIT binary patch literal 7325 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