diff --git a/README.md b/README.md index ac40a08..1f80e20 100644 --- a/README.md +++ b/README.md @@ -24,14 +24,14 @@ hold the key, at no extra bandwidth, power, or rate. ``` code/ sse_lib.py transmit and receive core, channel, training, OMA reference - exp_full.py stages A-F: SNR sweep, key length, jamming, key families, - scheme comparison, attack difficulty + exp_full.py stages A-F and L: SNR sweep, key length, jamming across + schemes, key families, scheme comparison, attack difficulty exp_kpa.py stage H: known-plaintext attack on the key exp_refresh.py stage K: the key-refresh layer, invariance group exp_real_sec.py stage G: real BERT WordPiece token streams verify_math.py closed-form checks V1-V5 against Monte Carlo, PASS/FAIL replot_security.py every result figure, from data/ to fig/ - make_tables.py LaTeX rows of the two result tables, from data/ + make_tables.py LaTeX rows of every result table, from data/ feasibility_security.py early CPU-sized study, kept for the record data/ CSV results, one file per stage fig/ figure PDFs, regenerated by replot_security.py @@ -47,15 +47,18 @@ libraries. ```bash python verify_math.py # closed-form verification, prints PASS/FAIL -python exp_full.py # stages A-F +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 replot_security.py # all figures from the CSVs python make_tables.py # LaTeX rows of the result tables ``` Seeds are fixed: training 1, evaluation 777, attacker key guess -20260813. Re-running reproduces the released CSV files. +20260813, key recovery 4242, brute-force search 31, cross-scheme +comparison 11, key refresh 5150. Re-running reproduces the released CSV +files. ## Figure and table map @@ -63,15 +66,15 @@ Seeds are fixed: training 1, evaluation 777, attacker key guess |---|---|---| | 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 | `exp_full.stage_C` | `sec_jam.csv` | -| Fig. 5 key sensitivity | `exp_full.stage_F` | `sec_sens.csv` | -| Fig. 6 brute-force search | `exp_full.stage_F` | `sec_brute.csv` | +| Fig. 4 jamming | `exp_full.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. 7 known-plaintext attack | `exp_kpa` | `kpa.csv` | | Fig. 8 real token streams | `exp_real_sec` | `real_sec_ter.csv` | | Scheme comparison table | `exp_full.stage_E` | `sec_compare.csv` | | Key family table | `exp_full.stage_D` | `sec_maskfam.csv`, `sec_regjam.csv` | | Headline recovery table | `exp_real_sec` | `real_sec_stats.json` | -| Key refresh tables | `exp_refresh` | `refresh.csv`, `refresh_kpa.csv` | +| Key refresh tables | `exp_refresh` | `refresh_summary.csv`, `refresh_kpa.csv` | ## Security scope diff --git a/code/exp_full.py b/code/exp_full.py index f60a1c7..cd0eb16 100644 --- a/code/exp_full.py +++ b/code/exp_full.py @@ -645,6 +645,68 @@ def csv_rows(path): yield from _csv.DictReader(f) +def oma_ser_jammed(snr_db, jsr_db_list, bits=16, U=4, n_grid=4096): + """OMA under a jammer that concentrates on the victim's slots. + + An OMA user occupies d/U exclusive real dimensions that are public, + so a jammer needs no key to put all of its power there. With unit + energy per real dimension and a total jammer energy of rho times the + frame energy, concentrating on d/U of the d dimensions gives a + per-dimension jammer variance of U*rho. + + The jammer reaches the victim through its own Rayleigh channel, the + same convention eval_scheme uses for every simulated scheme, so the + victim sees an effective noise variance of 1/snr + U*rho*hJ**2 with + E[hJ**2]=1. Averaging over the independent signal and jammer gains + uses a product of exponential quantile grids. + """ + q = (torch.arange(n_grid, dtype=torch.float64) + 0.5) / n_grid + h2 = -torch.log1p(-q) # |h|^2 ~ Exp(1) + hj2 = h2.clone() # |hJ|^2 ~ Exp(1), independent + h = h2.sqrt()[:, None] # (n,1) signal amplitude + snr = 10.0 ** (snr_db / 10.0) + out = [] + for jsr_db in jsr_db_list: + rho = 10.0 ** (jsr_db / 10.0) + var = (1.0 / snr + U * rho * hj2)[None, :] # (1,n) + arg = (h / var.sqrt()).clamp(0, 38) + pe = 0.5 * torch.erfc(arg / math.sqrt(2.0)) # per-bit error + out.append(float((1.0 - (1.0 - pe) ** bits).mean())) + return out + + +def stage_L(): + """Jamming comparison across schemes at 10 dB. + + proposed blind : the strongest jammer the proposed scheme admits + while the key stays secret + public matched : the jammer a public-mask scheme always faces + permutation blind: the shuffling-style scheme, whose secret + permutation also denies the jammer a target + OMA targeted : the jammer an orthogonal scheme faces, since its + slot assignment is public and needs no key + """ + print("[L] jamming across schemes ...") + m = get_model(iters=4000) + F = 300_000 + d = m.P * m.L + gp = torch.Generator().manual_seed(11) + perms = torch.randperm(d, generator=gp)[None].repeat(m.users, 1) + jsr = [-10.0, -5.0, 0.0, 5.0, 10.0, 15.0, 20.0] + oma = oma_ser_jammed(10.0, jsr, bits=int(math.log2(m.V)), U=m.users) + rows = [] + for i, j in enumerate(jsr): + blind = eval_scheme(m, 10.0, F, jam_w="blind", jsr_db=j) + matched = eval_scheme(m, 10.0, F, jam_w="matched", jsr_db=j) + perm = eval_scheme(m, 10.0, F, perms=perms, jam_w="blind", jsr_db=j) + rows.append((j, blind, matched, perm, oma[i])) + print(f" JSR={j:6.1f} blind={blind:.4f} matched={matched:.4f} " + f"perm={perm:.4f} oma={oma[i]:.4f}") + write_csv(DATA / "sec_jam_cmp.csv", + ["jsr_db", "blind", "matched", "perm_blind", "oma_targeted"], + rows) + + def main(): print(f"device={DEVICE}") stage_A() @@ -655,6 +717,7 @@ def main(): stage_F() stage_I() stage_J() + stage_L() print("[done] full-scale security CSVs in", DATA) diff --git a/code/exp_refresh.py b/code/exp_refresh.py index cfeab72..c508e8e 100644 --- a/code/exp_refresh.py +++ b/code/exp_refresh.py @@ -109,6 +109,11 @@ def main(): B0 = m.B.detach().clone().cpu() ew = eve_wrong_mask(U, Lp, seed=20260813) + # the no-refresh reference: the trained keys, held for every block + install(m, K0, B0) + lg_fixed = eval_ser_sse(m, [10.0], frames=FRAMES)[0] + ev_fixed = eval_ser_eve(m, ew, [10.0], frames=FRAMES)[0] + rows = [] for t in range(BLOCKS): signs, colperm, userperm = kdf_invariant(SEED, t, U, Lp) @@ -117,18 +122,32 @@ def main(): ev = eval_ser_eve(m, ew, [10.0], frames=FRAMES)[0] install(m, kdf_naive(SEED, t, U, Lp), B0) lg_naive = eval_ser_sse(m, [10.0], frames=FRAMES)[0] - rows.append((t, lg, lg_naive, ev)) + ev_naive = eval_ser_eve(m, ew, [10.0], frames=FRAMES)[0] + rows.append((t, lg, lg_naive, ev, ev_naive)) if t < 3 or t == BLOCKS - 1: print(f" block {t:3d} invariant={lg:.4f} naive={lg_naive:.4f} " f"eve={ev:.4f}") write_csv(DATA / "refresh.csv", - ["block", "legit_invariant", "legit_naive", "eve_ser"], rows) + ["block", "legit_invariant", "legit_naive", "eve_invariant", + "eve_naive"], rows) inv = [r[1] for r in rows]; nai = [r[2] for r in rows] - ev = [r[3] for r in rows] + ev = [r[3] for r in rows]; evn = [r[4] for r in rows] print(f" invariant refresh: mean={np.mean(inv):.4f} " f"min={min(inv):.4f} max={max(inv):.4f}") print(f" naive refresh : mean={np.mean(nai):.4f}") - print(f" eavesdropper : mean={np.mean(ev):.5f}") + print(f" eavesdropper : mean={np.mean(ev):.5f} " + f"min={min(ev):.5f} max={max(ev):.5f}") + + # the three rows of the refresh table, so no cell is hand-typed. Both + # fixed and naive draw U of the L-1 non-constant Hadamard rows. + fam = math.lgamma(Lp) / math.log(2.0) - math.lgamma(Lp - U) / math.log(2.0) + write_csv(DATA / "refresh_summary.csv", + ["scheme", "legit", "eve", "entropy_bits"], + [("None (fixed key)", lg_fixed, ev_fixed, fam), + ("Fresh orthogonal keys", float(np.mean(nai)), + float(np.mean(evn)), fam), + ("Invariant", float(np.mean(inv)), float(np.mean(ev)), + entropy_bits(U, Lp))]) print("[K] known plaintext across a refresh ...") kpa_rows = [] diff --git a/code/make_tables.py b/code/make_tables.py index 26624aa..ab14892 100644 --- a/code/make_tables.py +++ b/code/make_tables.py @@ -23,7 +23,7 @@ NAME = { } RECEIVER = { "legit": "Legitimate", "oma": "OMA", - "insider": "Insider", "eve": "Outsider eavesdropper", + "insider": "Insider", "eve": "Outsider", } @@ -73,7 +73,30 @@ def real_table(): print(f"{RECEIVER[key]} & {cells}" + r" \\") +def refresh_tables(): + print("% Table: key refresh (from refresh_summary.csv)") + for r in csv.DictReader(open(DATA / "refresh_summary.csv")): + b = r["scheme"] == "Invariant" + name = r"\textbf{Invariant}" if b else r["scheme"] + f = (lambda t: r"\mathbf{" + t + "}") if b else (lambda t: t) + print(f"{name} & ${f(format(float(r['legit']), '.3f'))}$ & " + f"${f(format(float(r['eve']), '.4f'))}$ & " + f"${f(format(float(r['entropy_bits']), '.1f'))}$~bits" + r" \\") + print() + print("% Table: known plaintext across a refresh (from refresh_kpa.csv)") + rows = {r["n_frames"]: r for r in csv.DictReader(open(DATA / "refresh_kpa.csv"))} + keep = ["2", "8", "64"] + print("Frames used by the attacker & " + + " & ".join(f"${k}$" for k in keep) + r" \\") + for lbl, key in (("Same block", "ser_same_block"), + ("Next block", "ser_next_block")): + print(f"{lbl} & " + + " & ".join(f"${float(rows[k][key]):.3f}$" for k in keep) + + r" \\") + + if __name__ == "__main__": compare_table(); print() maskfam_table(); print() - real_table() + real_table(); print() + refresh_tables() diff --git a/code/replot_security.py b/code/replot_security.py index 9563d45..b7c589b 100644 --- a/code/replot_security.py +++ b/code/replot_security.py @@ -3,12 +3,16 @@ from ../data/*.csv and writes paper-ready PDFs to ../fig/. No experiment is rerun. All result plots share one canvas and axes rectangle (8:6 box). Label dictionary is fixed here and copied verbatim into tables and prose. - fig_sec_snr.pdf : legitimate vs eavesdropper SER vs SNR (Fig. 2) + fig_sec_snr.pdf : legitimate and outsider SER vs SNR (Fig. 2) fig_sec_keylen.pdf : SER vs key length L (Fig. 3) - fig_sec_jam.pdf : target-user SER vs JSR (Fig. 4) - fig_sec_sens.pdf : Eve SER vs key correlation (Fig. 5) - fig_sec_brute.pdf : Eve SER vs number of key guesses (Fig. 6) - fig_sec_brute_rho.pdf : best key correlation vs guesses (Fig. 7) + fig_sec_jam.pdf : target-user SER vs JSR, four schemes (Fig. 4) + fig_sec_sens.pdf : outsider SER vs fraction of key held (Fig. 5) + fig_sec_brute.pdf : outsider SER vs number of key guesses (Fig. 6) + fig_sec_kpa.pdf : outsider SER vs known-plaintext frames (Fig. 7) + fig_sec_real.pdf : token error rate on real streams (Fig. 8) + +fig_sec_brute_rho.pdf is also emitted as a diagnostic and is not used in +the paper. """ from __future__ import annotations from pathlib import Path @@ -138,19 +142,26 @@ def fig_keylen(): def fig_jam(): - # the target-user SER spans 0.3 to 1.0, less than one decade, so a - # linear axis is used: a log axis here produces wide minor tick - # labels (6x10^-1) that crowd out the y label under the fixed - # axes rectangle - r = load("sec_jam.csv") + """Target-user SER against JSR for four schemes. A linear axis is + used because the range spans less than one decade, where a log axis + would print wide minor tick labels that crowd out the y label.""" + r = load("sec_jam_cmp.csv") x = col(r, "jsr_db") fig, ax = plt.subplots() + ax.plot(x, col(r, "oma_targeted"), color=C_PUB, marker="^", ls=":", + label="OMA, targeted") ax.plot(x, col(r, "matched"), color=C_MATCH, marker="P", ls="--", - label=LBL["jam_m"]) - ax.plot(x, col(r, "blind"), color=C_LEGIT, marker="o", ls="-", - label=LBL["jam_b"]) - nojam = col(r, "nojam")[0] - ax.axhline(nojam, color=C_OMA, ls=":", lw=0.9, label=LBL["nojam"]) + label="Public masks, matched") + # the two blind curves agree to 0.0015, so the proposed one is drawn + # first and wide and the permutation key rides on top with open + # markers, otherwise one legend entry would have no visible curve + ax.plot(x, col(r, "blind"), color=C_LEGIT, marker="o", ls="-", lw=2.6, + ms=7, alpha=0.85, label="Proposed, blind") + ax.plot(x, col(r, "perm_blind"), color=C_EVE, marker="s", ls="-.", + lw=1.2, ms=4.5, mfc="none", label="Permutation key, blind") + nojam = float(load("sec_jam.csv")[0]["nojam"]) + ax.axhline(nojam, color=C_OMA, ls=(0, (1, 3)), lw=0.9, + label=LBL["nojam"]) ax.set_xlabel("JSR (dB)") ax.set_ylabel("SER") ax.set_xlim(min(x), max(x)) diff --git a/data/refresh.csv b/data/refresh.csv index 0ac2e45..fdc7381 100644 --- a/data/refresh.csv +++ b/data/refresh.csv @@ -1,25 +1,25 @@ -block,legit_invariant,legit_naive,eve_ser -0,0.2575,0.982775,0.9998575 -1,0.256835,0.7427116667,0.9998133333 -2,0.2566516667,0.8483041667,0.9999883333 -3,0.2572983333,0.778605,0.9996666667 -4,0.2569683333,0.6199566667,0.9979908333 -5,0.2571308333,0.6580941667,0.9997825 -6,0.2572883333,0.7788558333,0.9997916667 -7,0.2577975,0.6935075,0.9999575 -8,0.2573458333,0.8400325,0.99839 -9,0.25766,0.6547933333,0.9999866667 -10,0.2565575,0.4783583333,0.9995791667 -11,0.2578183333,0.65481,0.99993 -12,0.2573583333,0.8477883333,0.9997283333 -13,0.2571408333,0.6548475,0.9999741667 -14,0.2571491667,0.6579591667,0.9999975 -15,0.2574591667,0.4778091667,0.9994458333 -16,0.2568716667,0.76187,0.9999083333 -17,0.2572141667,0.8045925,0.9994866667 -18,0.2573291667,0.8065691667,0.9997483333 -19,0.2571316667,0.6892025,0.9992841667 -20,0.2576716667,0.5278108333,0.9980375 -21,0.25724,0.742345,0.9999641667 -22,0.2570333333,0.6545633333,0.9997633333 -23,0.25745,0.68938,0.99989 +block,legit_invariant,legit_naive,eve_invariant,eve_naive +0,0.25722,0.9825916667,0.9998433333,0.999975 +1,0.257695,0.7424366667,0.9997983333,0.998495 +2,0.2575741667,0.8482983333,0.9999891667,0.9999966667 +3,0.2574108333,0.7789558333,0.9997116667,0.9998625 +4,0.2578316667,0.619975,0.9978516667,0.9999391667 +5,0.2575941667,0.6579858333,0.99976,0.9997716667 +6,0.2568508333,0.7788175,0.9997783333,0.9999341667 +7,0.2574466667,0.6938383333,0.9999633333,0.9989225 +8,0.2568958333,0.8399391667,0.9983208333,0.99991 +9,0.2572158333,0.6549775,0.9999891667,0.9999816667 +10,0.2575733333,0.4789683333,0.9995841667,0.9995091667 +11,0.2569025,0.6551183333,0.999925,0.9999741667 +12,0.2582966667,0.8479616667,0.9997208333,0.9999233333 +13,0.2579675,0.6551841667,0.9999758333,0.9996183333 +14,0.2572158333,0.6582558333,1,0.999915 +15,0.2578983333,0.4791275,0.9994441667,0.9999233333 +16,0.2570508333,0.7614066667,0.9998991667,0.9993675 +17,0.2576233333,0.8045108333,0.9994666667,0.9999475 +18,0.2571108333,0.8073525,0.9997158333,0.999985 +19,0.257955,0.6890983333,0.9992433333,0.9994441667 +20,0.25777,0.5282241667,0.9981,0.9992808333 +21,0.2568141667,0.7426725,0.9999566667,0.999965 +22,0.2578066667,0.6547841667,0.9997633333,0.9990433333 +23,0.2575241667,0.68849,0.9998791667,0.99982 diff --git a/data/refresh_kpa.csv b/data/refresh_kpa.csv index 39ca6dc..9a8367e 100644 --- a/data/refresh_kpa.csv +++ b/data/refresh_kpa.csv @@ -1,7 +1,7 @@ n_frames,ser_same_block,ser_next_block -2,0.2777190625,0.998930625 -4,0.2615640625,0.998644375 -8,0.2594196875,0.998631875 -16,0.2582471875,0.99867 -32,0.2579625,0.99871875 -64,0.257455,0.9987190625 +2,0.27116875,0.9988428125 +4,0.260861875,0.998726875 +8,0.2588515625,0.99869375 +16,0.2586403125,0.998735625 +32,0.2571871875,0.9986965625 +64,0.25772125,0.99869375 diff --git a/data/refresh_summary.csv b/data/refresh_summary.csv new file mode 100644 index 0000000..3418eab --- /dev/null +++ b/data/refresh_summary.csv @@ -0,0 +1,4 @@ +scheme,legit,eve,entropy_bits +None (fixed key),0.2573025,0.9999908333,14.99964774 +Fresh orthogonal keys,0.7103737847,0.9996877083,14.99964774 +Invariant,0.2574685069,0.99957,64.83510297 diff --git a/data/sec_jam_cmp.csv b/data/sec_jam_cmp.csv new file mode 100644 index 0000000..8b91d4c --- /dev/null +++ b/data/sec_jam_cmp.csv @@ -0,0 +1,8 @@ +jsr_db,blind,matched,perm_blind,oma_targeted +-10,0.4691233333,0.7203966667,0.4694833333,0.6401244609 +-5,0.6347966667,0.87413,0.6333466667,0.8146859285 +0,0.80602,0.95331,0.8066566667,0.9237966241 +5,0.9194566667,0.98428,0.9189733333,0.9727474174 +10,0.9703833333,0.99481,0.97034,0.9908905586 +15,0.9903833333,0.9983633333,0.9900733333,0.9970319887 +20,0.9967733333,0.9995166667,0.9968766667,0.9990359654 diff --git a/fig/fig_sec_brute.pdf b/fig/fig_sec_brute.pdf index 2b271d4..af49438 100644 Binary files a/fig/fig_sec_brute.pdf and b/fig/fig_sec_brute.pdf differ diff --git a/fig/fig_sec_brute_rho.pdf b/fig/fig_sec_brute_rho.pdf new file mode 100644 index 0000000..4b5381c Binary files /dev/null and b/fig/fig_sec_brute_rho.pdf differ diff --git a/fig/fig_sec_jam.pdf b/fig/fig_sec_jam.pdf index 3e074b6..d57835d 100644 Binary files a/fig/fig_sec_jam.pdf and b/fig/fig_sec_jam.pdf differ diff --git a/fig/fig_sec_keylen.pdf b/fig/fig_sec_keylen.pdf index cda24da..82dbb84 100644 Binary files a/fig/fig_sec_keylen.pdf and b/fig/fig_sec_keylen.pdf differ diff --git a/fig/fig_sec_kpa.pdf b/fig/fig_sec_kpa.pdf index 437b575..0bc771d 100644 Binary files a/fig/fig_sec_kpa.pdf and b/fig/fig_sec_kpa.pdf differ diff --git a/fig/fig_sec_real.pdf b/fig/fig_sec_real.pdf index 203b0ad..6640565 100644 Binary files a/fig/fig_sec_real.pdf and b/fig/fig_sec_real.pdf differ diff --git a/fig/fig_sec_sens.pdf b/fig/fig_sec_sens.pdf index fdf3155..099e1cc 100644 Binary files a/fig/fig_sec_sens.pdf and b/fig/fig_sec_sens.pdf differ diff --git a/fig/fig_sec_snr.pdf b/fig/fig_sec_snr.pdf index 93a01bc..dafaf6a 100644 Binary files a/fig/fig_sec_snr.pdf and b/fig/fig_sec_snr.pdf differ