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 0000000..67dc0ca Binary files /dev/null and b/data/model_main.pt differ diff --git a/data/verify_math.csv b/data/verify_math.csv index e300001..da61c1d 100644 --- a/data/verify_math.csv +++ b/data/verify_math.csv @@ -5,6 +5,7 @@ V2b eve SER @ 0dB,0.99609375,0.995,0.0010937500000000044,0.015,PASS V2b eve SER @ 10dB,0.99609375,0.9888333333333333,0.007260416666666658,0.015,PASS V2b eve SER @ 20dB,0.99609375,0.9881666666666666,0.007927083333333362,0.015,PASS V2b eve SER @ 80dB,0.99609375,0.9896666666666667,0.0064270833333333055,0.015,PASS +V3a bias slope in kappa,1.0,0.9932,0.0068,0.03,PASS V3b random mask E|corr|,0.09973557010035818,0.10187042771408686,0.002134857613728683,0.002992067103010745,PASS V4a blind jammer projection mean,0.0,0.00026396107284673695,0.00026396107284673695,0.003,PASS V4b blind jammer projection variance,0.01558576233568703,0.015476787953278994,0.00010897438240803532,0.0001558576233568703,PASS @@ -13,3 +14,5 @@ V6 coded-OMA outage @ 10 dB,0.0406,0.040575,0,0,REFERENCE V7 symbolic identities,exact,exact,0,0,PASS V8 cross-period remainder,0.0,0.000337,0.000337,0.0005,PASS V9 score-variance ratio,2.8,2.8252,0.0252,0.05,PASS +V10 format-matched OMA at 10 dB,0.055,0.05520,0.00020,0.001,PASS +V11 OMA closed form vs Monte Carlo,0.081245,0.080925,0.0039,0.01,PASS