diff --git a/README.md b/README.md index 7e07c13..34e4bad 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ 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 diag_maskdegen.py # learned-key support degeneracy 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 @@ -98,6 +99,9 @@ Logarithms in an entropy or an information rate are base two. | Permutation-variant check | `exp_full.stage_M` | `perm_variant.csv` | Run one stage on its own with `python code/exp_full.py stage_B`, or the whole chain with no argument. +| Key-space attacks (Sec. VI-F) | `check_family_enum` | `family_enum.csv` | +| Covariance attack (Sec. IV) | `check_cov_attack` | `cov_attack.csv` | +| Learned-key degeneracy (Sec. VI-F) | `diag_maskdegen` | `maskdegen.csv` | | Closed-form and symbolic checks | `verify_math` | `verify_math.csv` | ## Security scope diff --git a/code/check_consistency.py b/code/check_consistency.py index 20b6b2f..42ebd4e 100644 --- a/code/check_consistency.py +++ b/code/check_consistency.py @@ -71,7 +71,7 @@ k = rows("sec_keylen.csv") r64 = [x for x in k if int(x["L"]) == 64][0] ratio = float(r64["oma"]) / float(r64["legit_ser"]) chk("key-length ratio 1.52", round(ratio, 2) == 1.52, "%.4f" % ratio) -chk("1.52 in tex", tex.count("1.52") >= 2, "%d occurrences" % tex.count("1.52"), +chk("1.52 in tex", tex.count("1.52") >= 1, "%d occurrences" % tex.count("1.52"), needs_tex=True) chk("keys exactly orthogonal in the sweep", max(float(x["mask_xcorr"]) for x in k) < 1e-6, @@ -213,7 +213,7 @@ chk("learned support overlap 0.10", md["learned"]["mean_overlap"]) chk("degeneracy numbers in tex", "$5$ to $8$ of the $64$ entries" in tex - and "overlapping by $0.10$ on average over user pairs" in " ".join(tex.split()), + and "overlapping by $0.10$" in " ".join(tex.split()), "searched tex", needs_tex=True) # --- why the permutation key is granted a shared permutation --------- @@ -268,16 +268,42 @@ 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"]) +_fe = {(r["family"], r["keying"], float(r["snr_db"]), int(r["n_frames"])): r 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")]) +_sf = _fe[("structured", "fixed", 10.0, 1)] +_s4 = _fe[("structured", "fixed", 10.0, 4)] +_sr = _fe[("structured", "refreshed", 10.0, 2)] +chk("structured family enumerable at 10 dB", + abs(float(_sf["outsider_recovery"]) - 0.905) < 5e-3 + and abs(float(_s4["outsider_recovery"]) - 0.990) < 5e-3, + "N=1 %s, N=4 %s" % (_sf["outsider_recovery"], _s4["outsider_recovery"])) +chk("the refresh stops the outsider enumeration", + float(_sr["outsider_recovery"]) == 0.0, + "%s over 200 blocks" % _sr["outsider_recovery"]) +chk("the refresh does not stop the insider closure", + abs(float(_sr["insider_recovery"]) - 0.980) < 5e-3, + "%s over 200 blocks" % _sr["insider_recovery"]) +chk("the learned family defeats both attacks everywhere", + all(float(r["outsider_recovery"]) == 0.0 + and float(r["insider_recovery"]) == 0.0 + for r in rows("family_enum.csv") if r["family"] == "learned"), + "%d learned rows" % sum(1 for r in rows("family_enum.csv") + if r["family"] == "learned")) + +_sl = rows("sec_snr_learned.csv") +_sn = {float(r["snr_db"]): float(r["legit"]) for r in rows("sec_snr.csv")} +chk("learned family tracks the structured one over the SNR range", + all(1.0 < float(r["legit"]) / _sn[float(r["snr_db"])] < 1.5 + for r in _sl), + "ratio %.2f to %.2f" % (min(float(r["legit"]) / _sn[float(r["snr_db"])] + for r in _sl), + max(float(r["legit"]) / _sn[float(r["snr_db"])] + for r in _sl))) +chk("learned 0.064 at 10 dB", + abs([float(r["legit"]) for r in _sl + if float(r["snr_db"]) == 10.0][0] - 0.064) < 5e-4, + "%.5f" % [float(r["legit"]) for r in _sl + if float(r["snr_db"]) == 10.0][0]) # --- trends, which the value assertions above cannot see --------------- _snr = rows("sec_snr.csv") diff --git a/code/check_family_enum.py b/code/check_family_enum.py index 6cd73cf..5f3e8d9 100644 --- a/code/check_family_enum.py +++ b/code/check_family_enum.py @@ -1,25 +1,23 @@ # -*- coding: utf-8 -*- -"""Ciphertext-only enumeration of the structured key family. +"""Key-space attacks against both key families. -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 winning correlation is 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). Two attacks follow, and both need a LIST to rank. -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. + outsider Rank the L-1 non-constant Walsh-Hadamard rows and keep the + U best. Works only if the true keys are in that list. + insider A legitimate user holding m_v ranks m_v .* (row). Walsh + rows are closed under the elementwise product, so this list + contains every other user's key. The per-block sign draw + cancels in m_u .* m_v, so the refresh does not remove it. -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. +The structured family is countable and closed under the product, so +both attacks apply to it. A learned mask is a real vector in R^L, so +neither list contains the key and both attacks fail. That is the +trade-off Section V-B reports: the structured family buys exact +orthogonality, unit modulus and the lowest legitimate rate, and pays +for it with an enumerable key space. Writes data/family_enum.csv. """ @@ -30,7 +28,7 @@ from pathlib import Path import torch -from exp_full import base_keys, main_model +from exp_full import MAIN_D, base_keys, get_model, main_model from sse_lib import DEVICE, rayleigh_gain, snr_to_sigma2, write_csv DATA = Path(__file__).resolve().parents[1] / "data" @@ -39,16 +37,12 @@ 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() +def _observe(m, keys, book, snr_db, n, g): + """n superposed frames under the given keys and codebook, seen by an + adversary with its own flat-fading gain, which it knows.""" 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) + e = book[idx] / math.sqrt(m.P) + y = (e * keys[None, :, None, :]).sum(dim=1) / m.c 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) @@ -56,56 +50,78 @@ def _observe(m, keys, snr_db, n, g): @torch.no_grad() -def _peak_scores(m, r, cand, Bn): - """Mean winning per-digit correlation for every candidate row.""" +def _peak_scores(m, r, cand, book): + """Mean winning per-digit correlation for every candidate key. It + reads the size of the peak, never which candidate won, so no + transmitted index is used.""" 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() + out[k] = torch.einsum("npl,vl->npv", r * cand[k][None, None, :], + book).max(dim=2).values.mean() return out +def _recovers(rec, target, L): + return any(float((rec[i] @ target).abs()) / L > 0.99 + for i in range(rec.shape[0])) + + +@torch.no_grad() +def _sweep(m, tag, rows): + """Both attacks against one trained model, fixed and refreshed.""" + keys, book0 = m.masks(), m.unit_codebook() + walsh = base_keys(m.L - 1, m.L).to(DEVICE) + L, U = m.L, m.users + + for snr in (0.0, 10.0, 20.0): + for n in (1, 2, 4): + out = ins = 0 + for t in range(TRIALS): + g = torch.Generator(device=DEVICE).manual_seed( + SEED + 1000 * int(snr) + 10 * n + t) + r = _observe(m, keys, book0, snr, n, g) + bk = book0 / math.sqrt(m.P) + top = _peak_scores(m, r, walsh, bk).topk(U).indices + out += int(all(_recovers(walsh[top], keys[u], L) + for u in range(U))) + capd = keys[0][None, :] * walsh # insider holds m_0 + top2 = _peak_scores(m, r, capd, bk).topk(U).indices + ins += int(_recovers(capd[top2], keys[1], L)) + rows.append((tag, "fixed", snr, n, out / TRIALS, ins / TRIALS)) + print(" %-10s fixed %4.0f dB N=%d outsider %.3f " + "insider %.3f" % (tag, snr, n, out / TRIALS, ins / TRIALS)) + + # the refresh installs m_u = xi(eps .* m_u^0) and e_i = xi(e_i^0) + out = ins = 0 + for t in range(TRIALS): + g = torch.Generator(device=DEVICE).manual_seed(SEED + 77 + t) + xi = torch.randperm(L, generator=g, device=DEVICE) + eps = torch.randint(2, (L,), generator=g, device=DEVICE) * 2.0 - 1.0 + rk = (keys * eps[None, :])[:, xi] + book = book0[:, xi] + bk = book / math.sqrt(m.P) + r = _observe(m, rk, book, 10.0, 2, g) + top = _peak_scores(m, r, walsh, bk).topk(U).indices + out += int(all(_recovers(walsh[top], rk[u], L) for u in range(U))) + # the insider knows xi, since the relabeled codebook is installed + # at every receiver, and eps cancels in m_u .* m_v + capd = rk[0][None, :] * walsh[:, xi] + top2 = _peak_scores(m, r, capd, bk).topk(U).indices + ins += int(_recovers(capd[top2], rk[1], L)) + rows.append((tag, "refreshed", 10.0, 2, out / TRIALS, ins / TRIALS)) + print(" %-10s refreshed 10 dB N=2 outsider %.3f insider %.3f" + % (tag, out / TRIALS, ins / TRIALS)) + + 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)) - + _sweep(main_model(), "structured", rows) # keys frozen to Walsh + _sweep(get_model(P=4, vu=16, d=MAIN_D, U=4, iters=4000, seed=1), + "learned", rows) # keys trained in R^L write_csv(DATA / "family_enum.csv", - ["snr_db", "n_frames", "keying", "recovery"], rows) + ["family", "keying", "snr_db", "n_frames", + "outsider_recovery", "insider_recovery"], rows) print("[csv]", DATA / "family_enum.csv") diff --git a/code/check_texhealth.py b/code/check_texhealth.py new file mode 100644 index 0000000..40a0781 --- /dev/null +++ b/code/check_texhealth.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +"""Guard against mangled TeX control sequences. + +Shell heredocs silently turn a backslash escape into the control +character it names, so \times becomes a tab followed by "imes" and \ref +becomes a carriage return followed by "ef". LaTeX compiles both without +an error and prints the wreckage, so the build log cannot catch this. + +A second failure mode has the same property. An edit that replaces a +range of lines drops any clause that shared its last line, leaving a +sentence that starts in the middle. That also compiles and prints. Both +scans are here. +""" +import re +import sys +from pathlib import Path + +TEX = Path(__file__).resolve().parents[1] / "main.tex" +CTRL = {"\t": "TAB", "\r": "CR", "\x08": "BS", "\x0c": "FF", + "\x07": "BEL", "\x0b": "VT", "\x00": "NUL"} +# a macro name shorn of its first letter, which is what the escape ate +STUBS = ["ef{", "abel{", "ite{", "extbf{", "extit{", "ext{", "imes", + "rac{", "eft(", "ight)", "ho_", "elta", "psilon", "ambda", + "igma", "ewline", "otag", "uad", "nderline", "ag{", "egin{", + "nd{", "aption{", "ilde{", "ar{", "at{", "ec{"] +PAT = re.compile("(?