commit 37392bc38f226b8f76215c2c9fc3ae98168c8e4f Author: KiHoLee Date: Thu Aug 13 21:01:32 2026 +0900 Keyed masking for secure multi-user semantic communication Reproducibility package for the TIFS submission: transmit and receive core, security stages (eavesdropper, jamming, key families, attack difficulty, known-plaintext), real BERT token streams, closed-form verification, and the scripts that regenerate every figure and table from the released CSVs. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6797f5f --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.pyc +out/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7e839e2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ki-Ho Lee, Hyun-Ho Choi, Jung-Ryun Lee + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e56d1ab --- /dev/null +++ b/README.md @@ -0,0 +1,86 @@ +# Keyed Masking for Secure Multi-User Semantic Communication + +Reproducibility package for the manuscript *Mask-as-Key Secure Multiple +Access for Semantic Communications: Physical-Layer Encryption and +Jamming Robustness*, submitted to the IEEE Transactions on Information +Forensics and Security. + +Every figure and table in the paper is regenerated from this +repository. Experiment scripts write CSV files to `data/` and never +draw; `replot_security.py` reads only `data/` and writes the figure PDFs +to `fig/`; `make_tables.py` prints the LaTeX rows of the result tables. + +## Idea + +Multi-user semantic communication superposes several users on one frame +and multiplies each user embedding by a distinct pattern so that the +receivers can separate them. This code treats that pattern as a secret +key. One keyed operation then encrypts each user against a receiver +without the key, separates the users, and spreads a jammer that does not +hold the key, at no extra bandwidth, power, or rate. + +## Layout + +``` +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_kpa.py stage H: known-plaintext attack on the key + 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/ + 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 +``` + +## Reproducing + +Requires Python 3, PyTorch, NumPy, and Matplotlib. The real-token stage +additionally needs `datasets` and `transformers`. A CUDA device is +recommended; the code falls back to CPU. Under Windows install PyTorch +in WSL, because the native Windows build does not load the CUDA +libraries. + +```bash +python verify_math.py # closed-form verification, prints PASS/FAIL +python exp_full.py # stages A-F +python exp_kpa.py # known-plaintext attack +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. + +## Figure and table map + +| Artifact | Script | Data | +|---|---|---| +| 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. 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` | + +## Security scope + +The analysis covers an adversary that observes transmitted frames. The +masking is linear, so an adversary that also learns the indices some +frames carried recovers the key from a few frames, which `exp_kpa.py` +measures. The key must therefore be refreshed per coherence block from +a shared seed, as the paper states. This repository implements the +measurement of that limit, not a key-refresh layer. + +## License + +MIT for the code. The AG News data and the language-model tokenizer are +obtained from their own sources under their own terms. diff --git a/code/exp_full.py b/code/exp_full.py new file mode 100644 index 0000000..1060414 --- /dev/null +++ b/code/exp_full.py @@ -0,0 +1,523 @@ +"""Full-scale security evaluation for paper 11 (run under WSL CUDA). + +Reuses the SSE transmit/receive core from sse_lib.py and adds an +eavesdropper receiver, a jammer channel, and structured mask families. +Main configuration d=64, P=4, Vu=16 (V=Vu^P=65,536), U=4 users, matching +the language-model token vocabulary scale. + +Stages (each writes a CSV to ../data; figures come from replot_security.py +and the two result tables from make_tables.py): + A security vs SNR -> sec_snr.csv (Fig. 2) + B key length -> sec_keylen.csv (Fig. 3) + C jamming vs JSR -> sec_jam.csv (Fig. 4) + D mask families -> sec_maskfam.csv (key-family table) + E scheme comparison -> sec_compare.csv (comparison table) + F attack difficulty -> sec_sens.csv, sec_brute.csv (Figs. 5-6) + +Experiment scripts write CSV only, never draw. Fixed seeds. +""" +from __future__ import annotations +import math +import numpy as np +import torch + +import sse_lib as L +from sse_lib import (SSE, rayleigh_gain, snr_to_sigma2, write_csv, set_seed, + eval_ser_sse, oma_ser, DATA, DEVICE) + + +# ---------------------------------------------------------------------- +# eavesdropper: correlate the transmitted (true-mask) frame with a +# substitute mask the eavesdropper does not truly hold. +# ---------------------------------------------------------------------- +@torch.no_grad() +def eval_ser_eve(model: SSE, eve_masks: torch.Tensor, snr_list, + frames: int, chunk: int = 100_000, seed: int = 777): + model.eval().to(DEVICE) + Bn = model.unit_codebook() + true_m = model.masks() + eve_masks = eve_masks.to(DEVICE) + c = model.c + out = [] + for snr_db in snr_list: + g = torch.Generator(device="cpu").manual_seed(seed + int(10 * snr_db)) + err = tot = 0 + for n0 in range(0, frames, chunk): + n = min(chunk, frames - n0) + digits = torch.randint(model.vu, (n, model.users, model.P), + generator=g).to(DEVICE) + e = Bn[digits] / math.sqrt(model.P) + y = (e * true_m[None, :, None, :]).sum(dim=1) / c + h = rayleigh_gain((n, model.users), device=DEVICE) + sigma = snr_to_sigma2(snr_db, model.d).to(DEVICE).sqrt() + noise = torch.randn(n, model.users, model.P, model.L, device=DEVICE) + y_rx = h[:, :, None, None] * y[:, None] + sigma * noise + r = y_rx / h[:, :, None, None].clamp_min(1e-6) + cand = Bn[None, :, :] * eve_masks[:, None, :] + scores = torch.einsum("nupl,uvl->nupv", r, cand) + wrong = (scores.argmax(-1) != digits).any(dim=2) + err += int(wrong.sum()); tot += n * model.users + out.append(err / tot) + return out + + +@torch.no_grad() +def eval_ser_jam(model: SSE, snr_db, jsr_db_list, frames: int, + chunk: int = 100_000, seed: int = 777, mode: str = "blind", + target: int = 0): + """Returns the target-user SER (user `target`, the user a mask-matched + jammer aims at). The mask-matched jammer aligns with the target key, + which a mask-blind jammer cannot do. Reporting the target-user SER, + rather than the user average, isolates how efficiently each jammer can + degrade a chosen victim (Proposition 2).""" + model.eval().to(DEVICE) + Bn = model.unit_codebook() + true_m = model.masks() + c = model.c + sigma = snr_to_sigma2(snr_db, model.d).to(DEVICE).sqrt() + # matched jammer aligns with the target user's masked codeword mean + # direction (needs that user's secret key) + w_fixed = (Bn[target][None, :] * true_m[target][None, :]).repeat(model.P, 1) + w_fixed = w_fixed / w_fixed.norm() + out = [] + for jsr_db in jsr_db_list: + jsr = 10.0 ** (jsr_db / 10.0) + g = torch.Generator(device="cpu").manual_seed(seed + int(10 * jsr_db)) + err = tot = 0 + for n0 in range(0, frames, chunk): + n = min(chunk, frames - n0) + digits = torch.randint(model.vu, (n, model.users, model.P), + generator=g).to(DEVICE) + e = Bn[digits] / math.sqrt(model.P) + y = (e * true_m[None, :, None, :]).sum(dim=1) / c + h = rayleigh_gain((n, model.users), device=DEVICE) + hJ = rayleigh_gain((n,), device=DEVICE) + if mode == "matched": + w = w_fixed[None].expand(n, model.P, model.L) + else: + w = torch.randn(n, model.P, model.L, device=DEVICE) + w = w / w.reshape(n, -1).norm(dim=1)[:, None, None].clamp_min(1e-8) + jam = (hJ * math.sqrt(jsr))[:, None, None] * w + noise = torch.randn(n, model.users, model.P, model.L, device=DEVICE) + y_rx = h[:, :, None, None] * y[:, None] + jam[:, None] + sigma * noise + r = y_rx / h[:, :, None, None].clamp_min(1e-6) + cand = Bn[None, :, :] * true_m[:, None, :] + scores = torch.einsum("nupl,uvl->nupv", r, cand) + wrong = (scores.argmax(-1) != digits).any(dim=2) # (n,U) + err += int(wrong[:, target].sum()); tot += n + out.append(err / tot) + return out + + +def hadamard(n: int) -> np.ndarray: + """Sylvester construction, n a power of two.""" + H = np.array([[1.0]]) + while H.shape[0] < n: + H = np.block([[H, H], [H, -H]]) + return H + + +def mean_abs_xcorr(masks: torch.Tensor) -> float: + m = masks / masks.norm(dim=1, keepdim=True).clamp_min(1e-8) + G = (m @ m.T).abs() + U = m.shape[0] + off = G[~torch.eye(U, dtype=torch.bool, device=G.device)] + return float(off.mean()) + + +def random_mask(U, Lp): + W = torch.randn(U, Lp) + return W / W.norm(dim=1, keepdim=True) * math.sqrt(Lp) + + +def eve_wrong_mask(U, Lp, seed): + g = torch.Generator().manual_seed(seed) + W = torch.randn(U, Lp, generator=g) + return W / W.norm(dim=1, keepdim=True) * math.sqrt(Lp) + + +def get_model(P=4, vu=16, d=64, U=4, iters=4000, seed=1, freeze_W=None, tag=""): + """Train an SSE model, optionally with fixed (frozen) masks.""" + set_seed(seed) + m = SSE(P=P, vu=vu, d=d, users=U).to(DEVICE) + if freeze_W is not None: + with torch.no_grad(): + m.W.copy_(freeze_W.to(DEVICE)) + m.W.requires_grad_(False) + L.train_sse(m, iters=iters, batch=256, lr=3e-3, seed=seed) + m.calibrate_power() + return m + + +def stage_A(): + print("[A] security vs SNR (V=65536) ...") + m = get_model(iters=4000) + snr = [0.0, 4.0, 8.0, 12.0, 16.0, 20.0] + frames = 800_000 + legit = eval_ser_sse(m, snr, frames=frames) + ew = eve_wrong_mask(m.users, m.L, seed=20260813).to(DEVICE) + eve_w = eval_ser_eve(m, ew, snr, frames=frames) + eve_n = eval_ser_eve(m, torch.ones(m.users, m.L), snr, frames=frames) + # conventional public-mask scheme: the eavesdropper holds the same + # (public) masks and decodes exactly like a legitimate user + eve_p = eval_ser_eve(m, m.masks().detach().cpu(), snr, frames=frames) + oma = oma_ser(snr, bits=int(math.log2(m.V))) + chance = 1.0 - (1.0 / m.vu) ** m.P + write_csv(DATA / "sec_snr.csv", + ["snr_db", "legit", "eve_wrong", "eve_none", "eve_public", + "oma", "chance"], + [(s, legit[i], eve_w[i], eve_n[i], eve_p[i], oma[i], chance) + for i, s in enumerate(snr)]) + print(" legit:", [f"{v:.2e}" for v in legit]) + print(" eve :", [f"{v:.3f}" for v in eve_w]) + + +def train_sse_reg(m: SSE, iters=4000, batch=256, lr=3e-3, seed=1, + lam_orth=1.0, lam_flat=0.1): + """Regularized key learning for improved spreading and de-spreading. + Adds to the digit-wise cross entropy (i) an orthogonality penalty on + the off-diagonal key Gram entries, which reduces cross-user + interference and residual leakage, and (ii) a constant-modulus + penalty that flattens the key spectrum, which maximizes the spreading + of a mask-blind jammer (Proposition 2: the jammer concentration on + candidate i is sum_k w_k^2 e_{i,k}^2 weighted through the key, and a + flat key removes any low-energy entries a jammer could exploit).""" + set_seed(seed) + m.to(DEVICE) + opt = torch.optim.Adam(m.parameters(), lr=lr) + ce = torch.nn.CrossEntropyLoss() + for it in range(1, iters + 1): + digits = torch.randint(m.vu, (batch, m.users, m.P), device=DEVICE) + snr = torch.empty(batch).uniform_(0.0, 20.0) + m.calibrate_power(8192) + scores = m(digits, snr) * m.logit_scale.exp() + loss = ce(scores.reshape(-1, m.vu), digits.reshape(-1)) + mk = m.masks() + G = (mk @ mk.T) / m.L + off = G - torch.eye(m.users, device=G.device) + loss = loss + lam_orth * off.pow(2).sum() + loss = loss + lam_flat * (mk.pow(2) - 1.0).pow(2).mean() + opt.zero_grad(); loss.backward(); opt.step() + m.calibrate_power() + return m + + +def get_model_reg(P=4, vu=16, d=64, U=4, iters=4000, seed=1): + set_seed(seed) + m = SSE(P=P, vu=vu, d=d, users=U).to(DEVICE) + train_sse_reg(m, iters=iters, seed=seed) + return m + + +def stage_B(): + print("[B] key length (dense grid so the curve is smooth) ...") + oma10 = oma_ser([10.0], bits=16)[0] + rows = [] + for d in [16, 24, 32, 48, 64, 96, 128, 192, 256]: + m = get_model(d=d, iters=4000) + lg = eval_ser_sse(m, [10.0], frames=500_000)[0] + ew = eve_wrong_mask(m.users, m.L, seed=20260813).to(DEVICE) + ev = eval_ser_eve(m, ew, [10.0], frames=500_000)[0] + xc = mean_abs_xcorr(m.masks().detach()) + rows.append((m.L, d, lg, ev, xc, oma10)) + print(f" L={m.L:4d} legit={lg:.2e} eve={ev:.3f} xcorr={xc:.4f}") + write_csv(DATA / "sec_keylen.csv", + ["L", "d", "legit_ser", "eve_ser", "mask_xcorr", "oma"], rows) + + +def stage_C(): + print("[C] jamming vs JSR ...") + m = get_model(iters=4000) + jsr = [-10.0, -5.0, 0.0, 5.0, 10.0, 15.0, 20.0] + blind = eval_ser_jam(m, 10.0, jsr, frames=500_000, mode="blind", target=0) + matched = eval_ser_jam(m, 10.0, jsr, frames=500_000, mode="matched", target=0) + # target-user SER with no jammer, for the reference line + nojam = eval_ser_jam(m, 10.0, [-40.0], frames=500_000, mode="blind", + target=0)[0] + write_csv(DATA / "sec_jam.csv", + ["jsr_db", "blind", "matched", "nojam"], + [(j, blind[i], matched[i], nojam) for i, j in enumerate(jsr)]) + print(f" target no-jam={nojam:.2e}") + print(" blind :", [f"{v:.3f}" for v in blind]) + print(" matched:", [f"{v:.3f}" for v in matched]) + + +def stage_D(): + print("[D] mask families ...") + P, vu, d, U = 4, 16, 64, 4 + Lp = d // P + fams = {} + # random fixed masks + set_seed(7); fams["random"] = random_mask(U, Lp) + # Walsh-Hadamard rows (orthogonal) + Hd = torch.tensor(hadamard(Lp)[:U], dtype=torch.float32) # ||row||=sqrt(Lp) + fams["hadamard"] = Hd + rows = [] + for name, W in fams.items(): + m = get_model(P=P, vu=vu, d=d, U=U, iters=4000, freeze_W=W) + lg = eval_ser_sse(m, [10.0], frames=500_000)[0] + ew = eve_wrong_mask(U, Lp, seed=20260813).to(DEVICE) + ev = eval_ser_eve(m, ew, [10.0], frames=500_000)[0] + xc = mean_abs_xcorr(m.masks().detach()) + rows.append((name, lg, ev, xc)) + print(f" {name:9s} legit={lg:.2e} eve={ev:.3f} xcorr={xc:.4f}") + # learned masks (plain cross entropy) + m = get_model(P=P, vu=vu, d=d, U=U, iters=4000) + lg = eval_ser_sse(m, [10.0], frames=500_000)[0] + ew = eve_wrong_mask(U, Lp, seed=20260813).to(DEVICE) + ev = eval_ser_eve(m, ew, [10.0], frames=500_000)[0] + xc = mean_abs_xcorr(m.masks().detach()) + rows.append(("learned", lg, ev, xc)) + print(f" {'learned':9s} legit={lg:.2e} eve={ev:.3f} xcorr={xc:.4f}") + # regularized key learning (orthogonality + constant modulus) + mr = get_model_reg(P=P, vu=vu, d=d, U=U, iters=4000) + lgr = eval_ser_sse(mr, [10.0], frames=500_000)[0] + evr = eval_ser_eve(mr, ew, [10.0], frames=500_000)[0] + xcr = mean_abs_xcorr(mr.masks().detach()) + rows.append(("learned_reg", lgr, evr, xcr)) + print(f" {'learn_reg':9s} legit={lgr:.2e} eve={evr:.3f} xcorr={xcr:.4f}") + # jamming robustness of plain vs regularized keys (blind jammer) + jsr = [-10.0, -5.0, 0.0, 5.0, 10.0, 15.0, 20.0] + jb_plain = eval_ser_jam(m, 10.0, jsr, frames=300_000, mode="blind") + jb_reg = eval_ser_jam(mr, 10.0, jsr, frames=300_000, mode="blind") + write_csv(DATA / "sec_regjam.csv", + ["jsr_db", "plain", "regularized"], + [(j, jb_plain[i], jb_reg[i]) for i, j in enumerate(jsr)]) + write_csv(DATA / "sec_maskfam.csv", + ["family", "legit_ser", "eve_ser", "mask_xcorr"], rows) + + +@torch.no_grad() +def eval_scheme(model: SSE, snr_db, frames, *, rx_masks=None, perms=None, + jam_w=None, jsr_db=None, target=0, chunk=100_000, seed=777, + decode_user=0): + """Generic evaluator for the comparison schemes. + rx_masks: masks used at the decoding receiver (None = true masks). + perms: (U,d-index) per-user secret permutations applied at tx to + x_u; the decoder for `decode_user` inverse-permutes first. + rx side without the permutation just decodes raw. + jam_w: None or 'matched'/'blind' jammer aimed at `target`. + Returns SER of `decode_user` (frame error over its P digits).""" + model.eval().to(DEVICE) + Bn = model.unit_codebook() + true_m = model.masks() + c = model.c + sigma = snr_to_sigma2(snr_db, model.d).to(DEVICE).sqrt() + d = model.P * model.L + if perms is not None: + inv = torch.argsort(perms, dim=1) + if jam_w == "matched": + wf = (Bn[target][None, :] * true_m[target][None, :]).repeat(model.P, 1) + if perms is not None: + wfl = wf.reshape(-1)[perms[target]] + wf = wfl.reshape(model.P, model.L) + wf = wf / wf.norm() + jsr = 10.0 ** (jsr_db / 10.0) if jsr_db is not None else 0.0 + g = torch.Generator(device="cpu").manual_seed(seed + int(10 * snr_db)) + err = tot = 0 + for n0 in range(0, frames, chunk): + n = min(chunk, frames - n0) + digits = torch.randint(model.vu, (n, model.users, model.P), + generator=g).to(DEVICE) + e = Bn[digits] / math.sqrt(model.P) + x = e * true_m[None, :, None, :] # (n,U,P,L) + if perms is not None: + xf = x.reshape(n, model.users, d) + xf = torch.stack([xf[:, u][:, perms[u]] for u in range(model.users)], 1) + x = xf.reshape(n, model.users, model.P, model.L) + y = x.sum(dim=1) / c # (n,P,L) + h = rayleigh_gain((n,), device=DEVICE) # decode_user channel + y_rx = h[:, None, None] * y # (n,P,L) + if jam_w is not None: + hJ = rayleigh_gain((n,), device=DEVICE) + if jam_w == "matched": + w = wf[None].expand(n, model.P, model.L) + else: + w = torch.randn(n, model.P, model.L, device=DEVICE) + w = w / w.reshape(n, -1).norm(dim=1)[:, None, None].clamp_min(1e-8) + y_rx = y_rx + (hJ * math.sqrt(jsr))[:, None, None] * w + y_rx = y_rx + sigma * torch.randn(n, model.P, model.L, device=DEVICE) + r = y_rx / h[:, None, None].clamp_min(1e-6) + if perms is not None: + rf = r.reshape(n, d)[:, inv[decode_user]] + r = rf.reshape(n, model.P, model.L) + m_rx = true_m if rx_masks is None else rx_masks.to(DEVICE) + cand = Bn * m_rx[decode_user][None, :] # (Vu,L) + scores = torch.einsum("npl,vl->npv", r, cand) + wrong = (scores.argmax(-1) != digits[:, decode_user]).any(dim=1) + err += int(wrong.sum()); tot += n + return err / tot + + +def stage_E(): + """Comparison across five schemes at 10 dB, V=65,536, user-0 metrics. + Columns: legitimate SER; outsider-eavesdropper SER; insider SER (a + curious legitimate user of the SAME system decoding user 0 with its + own credentials); target-user SER under the strongest jammer the + attacker can BUILD from public knowledge at JSR 0 dB (matched if the + masks are public, blind if the PHY structure is secret).""" + print("[E] scheme comparison ...") + m = get_model(iters=4000) + F = 400_000 + d = m.P * m.L + set_seed(20260813) + ew = eve_wrong_mask(m.users, m.L, seed=20260813) + # shuffling-style multi-user adaptation: one GLOBAL secret permutation + # shared by all users (per-user permutations break the trained + # multi-user separation, so the shared key is the fair extension) + gp = torch.Generator().manual_seed(11) + gperm = torch.randperm(d, generator=gp) + perms = gperm[None].repeat(m.users, 1) + + chance = 1.0 - (1.0 / m.vu) ** m.P + insider_masks = torch.roll(m.masks().detach().cpu(), 1, 0) # user 1's key + + rows = [] + # S1 proposed keyed masking: per-user secret masks + lg = eval_scheme(m, 10.0, F) + ev = eval_scheme(m, 10.0, F, rx_masks=ew) + ins = eval_scheme(m, 10.0, F, rx_masks=insider_masks) + jm = eval_scheme(m, 10.0, F, jam_w="blind", jsr_db=0.0) + rows.append(("proposed", lg, ev, ins, jm)) + # S2 public-mask superposition (no key): everyone decodes, attacker + # builds the matched jammer + jm2 = eval_scheme(m, 10.0, F, jam_w="matched", jsr_db=0.0) + rows.append(("public_mask", lg, lg, lg, jm2)) + # S3 global permutation key over public masks (shuffling-style): the + # outsider lacks the permutation, but every insider holds it and the + # masks are public, so insiders decode each other + lg3 = eval_scheme(m, 10.0, F, perms=perms) + ev3 = eval_scheme_permuted_eve(m, 10.0, F, perms) + jm3 = eval_scheme(m, 10.0, F, perms=perms, jam_w="blind", jsr_db=0.0) + rows.append(("perm_key", lg3, ev3, lg3, jm3)) + # S4 per-user index cipher (one-time pad on the digits) over public + # masks: content protected from outsiders and insiders, but the PHY + # is public so the matched jammer remains buildable + rows.append(("index_cipher", lg, chance, chance, jm2)) + # S5 OMA digital, no encryption: open to everyone + from sse_lib import oma_ser + lg5 = oma_ser([10.0], bits=int(math.log2(m.V)))[0] + rows.append(("oma_plain", lg5, lg5, lg5, float("nan"))) + + write_csv(DATA / "sec_compare.csv", + ["scheme", "legit_ser", "eve_out", "eve_in", "jam0_ser"], rows) + for r in rows: + print(" ", r) + + +@torch.no_grad() +def eval_scheme_permuted_eve(model: SSE, snr_db, frames, perms, + chunk=100_000, seed=777): + """Eve for S3: sees the per-user permuted tx, holds the PUBLIC masks + but not the permutation, decodes user 0 raw.""" + model.eval().to(DEVICE) + Bn = model.unit_codebook() + true_m = model.masks() + c = model.c + d = model.P * model.L + sigma = snr_to_sigma2(snr_db, model.d).to(DEVICE).sqrt() + g = torch.Generator(device="cpu").manual_seed(seed + int(10 * snr_db)) + err = tot = 0 + for n0 in range(0, frames, chunk): + n = min(chunk, frames - n0) + digits = torch.randint(model.vu, (n, model.users, model.P), + generator=g).to(DEVICE) + e = Bn[digits] / math.sqrt(model.P) + x = e * true_m[None, :, None, :] + xf = x.reshape(n, model.users, d) + xf = torch.stack([xf[:, u][:, perms[u]] for u in range(model.users)], 1) + y = xf.reshape(n, model.users, model.P, model.L).sum(dim=1) / c + h = rayleigh_gain((n,), device=DEVICE) + y_rx = h[:, None, None] * y + sigma * torch.randn( + n, model.P, model.L, device=DEVICE) + r = y_rx / h[:, None, None].clamp_min(1e-6) + cand = Bn * true_m[0][None, :] + scores = torch.einsum("npl,vl->npv", r, cand) + wrong = (scores.argmax(-1) != digits[:, 0]).any(dim=1) + err += int(wrong.sum()); tot += n + return err / tot + + +def correlated_masks(true_m: torch.Tensor, rho: float, gen: torch.Generator): + """Substitute masks with prescribed normalized correlation rho to the + true keys: mtil = rho*m + sqrt(1-rho^2)*m_perp, ||mtil|| = ||m||.""" + U, Lp = true_m.shape + out = torch.empty_like(true_m) + for u in range(U): + m = true_m[u] + p = torch.randn(Lp, generator=gen) + p = p - (p @ m) / (m @ m) * m + p = p / p.norm() * m.norm() + out[u] = rho * m + math.sqrt(max(0.0, 1 - rho * rho)) * p + return out + + +def stage_F(): + """Attack difficulty in the style of standard security evaluations. + (i) Key sensitivity: Eve SER against the correlation rho between her + guess and the true key (avalanche-style curve). + (ii) Brute-force key search: expected Eve SER against the number of + random key guesses K, where for each trial the attacker keeps the + guess with the LARGEST correlation to the true key (a genie-aided + upper bound on any selection rule). The best-guess correlation + rho_max(K, L) is sampled by Monte Carlo and mapped through the + measured sensitivity curve of (i).""" + print("[F] attack difficulty ...") + m = get_model(iters=4000) + F = 200_000 + true_m = m.masks().detach().cpu() + gen = torch.Generator().manual_seed(31) + + # (i) sensitivity curve, densest where the curve falls steeply + rhos = [0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.65, 0.7, 0.75, + 0.8, 0.84, 0.88, 0.90, 0.92, 0.94, 0.96, 0.97, 0.98, + 0.99, 0.995, 1.0] + sens = [] + for rho in rhos: + mt = correlated_masks(true_m, rho, gen) + ser = eval_ser_eve(m, mt, [10.0], frames=F)[0] + sens.append((rho, ser)) + print(f" rho={rho:.2f} eve_ser={ser:.4f}") + write_csv(DATA / "sec_sens.csv", ["rho", "eve_ser"], sens) + + # (ii) brute-force: sample rho_max(K, L) and interpolate SER(rho) + import numpy as np + r_arr = np.array([r for r, _ in sens]) + s_arr = np.array([s for _, s in sens]) + + def ser_of_rho(r): + return float(np.interp(abs(r), r_arr, s_arr)) + + ks = [1, 10, 100, 1_000, 10_000, 100_000, 1_000_000] + rows = [] + rng = np.random.default_rng(2026) + for Lp in [8, 16, 32, 64]: + for K in ks: + trials = 400 + # rho of a random unit guess vs a fixed key in R^L is the + # first coordinate of a random unit vector; sample K per trial + best = np.empty(trials) + for t in range(trials): + g = rng.standard_normal((K, Lp)) + g /= np.linalg.norm(g, axis=1, keepdims=True) + best[t] = np.abs(g[:, 0]).max() + ser_est = float(np.mean([ser_of_rho(b) for b in best])) + rows.append((Lp, K, float(best.mean()), ser_est)) + print(f" L={Lp} done") + write_csv(DATA / "sec_brute.csv", + ["L", "K", "best_rho", "eve_ser"], rows) + + +def main(): + print(f"device={DEVICE}") + stage_A() + stage_B() + stage_C() + stage_D() + stage_E() + stage_F() + print("[done] full-scale security CSVs in", DATA) + + +if __name__ == "__main__": + main() diff --git a/code/exp_kpa.py b/code/exp_kpa.py new file mode 100644 index 0000000..dd538c3 --- /dev/null +++ b/code/exp_kpa.py @@ -0,0 +1,155 @@ +"""Stage H: known-plaintext attack on the keyed masking. + +The masking is linear in the keys, so an attacker who knows the indices +carried by some frames can write one linear equation per dimension per +frame and solve for the keys by least squares. This script measures how +much known plaintext the attacker needs before the recovered key is good +enough to decode, and how receiver noise slows that recovery down. + +Per dimension k the observation of frame n is + y_k(n) = (1/c) sum_u e_{s_u(n),k} m_{u,k} + noise, +so stacking N frames gives A m_k = y_k with A(n,u) = e_{s_u(n),k}/c, an +N-by-U system that is solvable once N >= U in the noiseless case. The +attacker solves it per dimension, then correlates the estimate with the +true key and runs the correlation receiver with the estimated key. + +Outputs: + kpa.csv : key correlation and eavesdropper SER against the number of + known-plaintext frames, at several SNRs +""" +from __future__ import annotations +import math + +import numpy as np +import torch + +from sse_lib import (DATA, DEVICE, SSE, rayleigh_gain, snr_to_sigma2, + set_seed, write_csv, eval_ser_sse) +from exp_full import get_model, eval_ser_eve + +SNRS = [0.0, 10.0, 20.0] +NFRAMES = [1, 2, 3, 4, 5, 6, 8, 10, 12, 16, 24, 32, 48, 64] +SEED = 4242 +EVAL_FRAMES = 50_000 +# The spread across independent key-recovery attempts dominates the +# spread across channel realizations within one attempt, so the curve is +# smoothed by drawing many attempts rather than by lengthening each one. +TRIALS = 40 + + +@torch.no_grad() +def collect_known_plaintext(model: SSE, n_frames: int, snr_db: float, + gen: torch.Generator): + """Return (digits, raw observations, channel gains) for an attacker + that knows the transmitted indices. + + The raw observation is returned rather than an equalized one. A + maximum-likelihood attacker keeps the channel gain in the design + matrix instead of dividing by it, which weights every frame by its + own quality and is the strongest use of the collected material. It + also avoids the numerical blow-up that equalizing a deep fade would + cause. + """ + digits = torch.randint(model.vu, (n_frames, model.users, model.P), + generator=gen).to(DEVICE) + Bn = model.unit_codebook() + m = model.masks() + e = Bn[digits] / math.sqrt(model.P) + y = (e * m[None, :, None, :]).sum(dim=1) / model.c # (N,P,L) + h = rayleigh_gain((n_frames,), device=DEVICE) + sigma = snr_to_sigma2(snr_db, model.d).to(DEVICE).sqrt() + noise = torch.randn(n_frames, model.P, model.L, device=DEVICE) + obs = h[:, None, None] * y + sigma * noise + return digits, obs, h + + +@torch.no_grad() +def solve_keys(model: SSE, digits, obs, h): + """Maximum-likelihood key estimate from known plaintext. + + Each period of each frame is an independent observation of the same + per-period key, so the P periods multiply the effective number of + equations. For entry l the system is A x = b with + A[(n,p), u] = h(n) e_{digit(n,u,p), l} / (c sqrt(P)) and b the raw + observation, so a frame in a deep fade contributes a small row on + both sides and is downweighted rather than amplified. + """ + Bn = model.unit_codebook() # (Vu, L) + N, U, P = digits.shape + L = model.L + c = float(model.c) + est = torch.zeros(U, L, device=DEVICE) + for l in range(L): + # design matrix over all (frame, period) pairs + A = Bn[digits, l] / (c * math.sqrt(P)) # (N,U,P) + A = A * h[:, None, None] + A = A.permute(0, 2, 1).reshape(N * P, U) # (N*P, U) + b = obs[:, :, l].reshape(N * P, 1) # (N*P, 1) + # A pseudo-inverse with an absolute tolerance is used instead of + # a least-squares driver. Training can leave a codebook entry + # numerically dead, with every codeword value below the smallest + # normal float. That entry carries no information about the + # digit, and inverting its system would amplify noise without + # bound, so the absolute tolerance discards it and the estimate + # for that entry stays at zero, which is what a careful attacker + # would do. + sol = torch.linalg.pinv(A.double(), atol=1e-12, rtol=0.0) @ b.double() + est[:, l] = sol[:, 0].float() + est = torch.nan_to_num(est) + # normalize to the key norm convention + est = est / est.norm(dim=1, keepdim=True).clamp_min(1e-9) * math.sqrt(L) + return est + + +def key_correlation(est: torch.Tensor, true: torch.Tensor) -> float: + """Mean absolute normalized correlation over the users.""" + a = est / est.norm(dim=1, keepdim=True).clamp_min(1e-9) + b = true / true.norm(dim=1, keepdim=True).clamp_min(1e-9) + return float((a * b).sum(dim=1).abs().mean()) + + +def main(): + set_seed(SEED) + model = get_model(iters=4000) + model.eval() + true_m = model.masks().detach() + legit = eval_ser_sse(model, [10.0], frames=200_000)[0] + print(f"[kpa] legitimate SER at 10 dB = {legit:.4g}, U={model.users}, " + f"L={model.L}") + + # Nested known-plaintext sets with common random numbers. Within a + # trial the attacker collects one pool of frames and the N-frame + # estimate uses the first N of them, so more material can only help, + # exactly as an attacker accumulating traffic would experience. The + # evaluation noise is also shared across N within a trial. Both + # choices remove the between-point variance that would otherwise make + # the averaged curve jagged, without changing what is being measured. + nmax = max(NFRAMES) + rows = [] + for snr in SNRS: + acc = {n: [[], []] for n in NFRAMES} + for t in range(TRIALS): + gen = torch.Generator(device="cpu").manual_seed( + SEED + int(snr) + 1000 * t) + digits, obs, h = collect_known_plaintext(model, nmax, snr, gen) + eval_seed = 777 + 31 * t + int(snr) + for n in NFRAMES: + est = solve_keys(model, digits[:n], obs[:n], h[:n]) + acc[n][0].append(key_correlation(est, true_m)) + acc[n][1].append(eval_ser_eve(model, est.cpu(), [10.0], + frames=EVAL_FRAMES, + seed=eval_seed)[0]) + for n in NFRAMES: + ks, ss = acc[n] + kappa = sum(ks) / len(ks) + ser = sum(ss) / len(ss) + rows.append((snr, n, kappa, ser)) + print(f" snr={snr:4.1f} N={n:5d} kappa={kappa:.4f} " + f"eve_ser={ser:.4f}") + write_csv(DATA / "kpa.csv", + ["snr_db", "n_frames", "kappa", "eve_ser"], rows) + print("[done] kpa.csv") + + +if __name__ == "__main__": + main() diff --git a/code/exp_real_sec.py b/code/exp_real_sec.py new file mode 100644 index 0000000..be33794 --- /dev/null +++ b/code/exp_real_sec.py @@ -0,0 +1,195 @@ +"""Stage G: security on real language-model token streams. + +AG News test headlines are tokenized with the bert-base-uncased +WordPiece tokenizer (vocabulary 30,522). Four users carry four disjoint +headline streams, each frame transmits one token per user, and the token +identifier is carried by its base-16 digits, so the digit space +16^4 = 65,536 covers the vocabulary. The keys and codebook trained on +uniform indices are reused unchanged, so this stage tests the design on +a real, highly non-uniform source without retraining. + +Two metrics are reported. The token error rate is the symbol-level +measure used in the rest of the paper. The headline recovery rate is a +meaning-level measure: the fraction of complete headlines a receiver +reconstructs without a single token error, which is what an +eavesdropper actually needs to read the message. + +Outputs: + real_sec_ter.csv : token error rate vs SNR for legitimate, outsider + eavesdropper, insider, and OMA + real_sec_stats.json: stream statistics and headline recovery rates +""" +from __future__ import annotations + +import json +import math + +import torch + +import sse_lib as L +from sse_lib import (DATA, DEVICE, SSE, rayleigh_gain, snr_to_sigma2, + set_seed, write_csv) +from exp_full import get_model, eve_wrong_mask + +SNR_GRID = [0, 4, 8, 12, 16, 20, 24, 28] +# headline recovery is meaningful only where the legitimate user clears +# most tokens, since a headline averages tens of tokens and needs every +# one of them correct +REC_SNR = (20, 24, 28) +N_TEXTS = 2000 +REPEATS = 8 +REC_RUNS = 4 +SEED_EVAL = 777 +P_MAX, VU, U = 4, 16, 4 + + +def load_streams(): + from datasets import load_dataset + from transformers import AutoTokenizer + tok = AutoTokenizer.from_pretrained("bert-base-uncased") + ds = load_dataset("fancyzhx/ag_news", split="test") + texts = [ds[i]["text"] for i in range(N_TEXTS)] + streams = [[] for _ in range(U)] + bounds = [[] for _ in range(U)] # (start, end) per headline + for i, t in enumerate(texts): + ids = tok(t, add_special_tokens=False)["input_ids"] + u = i % U + s = len(streams[u]) + streams[u].extend(ids) + bounds[u].append((s, s + len(ids))) + n = min(len(s) for s in streams) + streams = [s[:n] for s in streams] + bounds = [[(a, b) for (a, b) in bu if b <= n] for bu in bounds] + return streams, bounds, tok.vocab_size + + +def ids_to_digits(ids: torch.Tensor) -> torch.Tensor: + """(N,U) token ids -> (N,U,P) base-16 digits, most significant first.""" + d, x = [], ids.clone() + for _ in range(P_MAX): + d.append(x % VU) + x = x // VU + return torch.stack(d[::-1], dim=-1) + + +@torch.no_grad() +def wrong_keyed(model: SSE, digits_all, snr_db, seed, rx_masks=None, + chunk=50_000): + """Per-frame per-user error indicator (N,U) for a receiver that + correlates with rx_masks. rx_masks=None means the legitimate keys.""" + torch.manual_seed(seed) + Bn = model.unit_codebook() + true_m = model.masks() + rx = true_m if rx_masks is None else rx_masks.to(DEVICE) + c = model.c + N = digits_all.shape[0] + wrong = torch.zeros(N, U, dtype=torch.bool) + sigma = snr_to_sigma2(snr_db, model.d).to(DEVICE).sqrt() + for n0 in range(0, N, chunk): + dg = digits_all[n0:n0 + chunk].to(DEVICE) + n = dg.shape[0] + e = Bn[dg] / math.sqrt(model.P) + y = (e * true_m[None, :, None, :]).sum(dim=1) / c + h = rayleigh_gain((n, U), device=DEVICE) + noise = torch.randn(n, U, model.P, model.L, device=DEVICE) + y_rx = h[:, :, None, None] * y[:, None] + sigma * noise + r = y_rx / h[:, :, None, None].clamp_min(1e-6) + cand = Bn[None, :, :] * rx[:, None, :] + sc = torch.einsum("nupl,uvl->nupv", r, cand) + wrong[n0:n0 + chunk] = (sc.argmax(-1) != dg).any(dim=2).cpu() + return wrong + + +@torch.no_grad() +def wrong_oma(ids_all, snr_db, seed, bits=16): + """Antipodal signaling on the actual token bits, same frame energy.""" + torch.manual_seed(seed) + N, Uu = ids_all.shape + b = ((ids_all[..., None] >> torch.arange(bits)) & 1).float() * 2 - 1 + b = b.to(DEVICE) + sigma = math.sqrt(1.0 / (10.0 ** (snr_db / 10.0))) + h = rayleigh_gain((N, Uu, 1)) + y = h * b + sigma * torch.randn(N, Uu, bits, device=DEVICE) + return ((y * b) < 0).any(dim=2).cpu() + + +def headline_recovery(wrong: torch.Tensor, bounds) -> tuple[int, int]: + """A headline counts as recovered only if every token is correct.""" + ok = tot = 0 + for u in range(U): + wu = wrong[:, u] + for (a, b) in bounds[u]: + tot += 1 + ok += int(not bool(wu[a:b].any())) + return ok, tot + + +def main(): + set_seed(SEED_EVAL) + streams, bounds, vocab = load_streams() + ids_all = torch.tensor(list(zip(*streams)), dtype=torch.long) # (N,U) + digits_all = ids_to_digits(ids_all) + N = ids_all.shape[0] + assert int(ids_all.max()) < VU ** P_MAX + + print(f"[real] {N} frames, {int(torch.unique(ids_all).numel())} " + f"distinct tokens, max id {int(ids_all.max())}") + + # keys and codebook trained on uniform indices, reused unchanged + model = get_model(P=P_MAX, vu=VU, d=64, U=U, iters=4000) + model.eval() + + eve_m = eve_wrong_mask(U, model.L, seed=20260813) # outsider + ins_m = model.masks().detach().roll(1, 0).cpu() # insider + + schemes = { + "legit": lambda s, k: wrong_keyed(model, digits_all, s, k), + "eve": lambda s, k: wrong_keyed(model, digits_all, s, k, eve_m), + "insider": lambda s, k: wrong_keyed(model, digits_all, s, k, ins_m), + "oma": lambda s, k: wrong_oma(ids_all, s, k), + } + + rows = [] + for s in SNR_GRID: + ter = {} + for name, fn in schemes.items(): + e = 0 + for r in range(REPEATS): + e += int(fn(s, SEED_EVAL + 1000 * r + int(10 * s)).sum()) + ter[name] = e / (N * U * REPEATS) + rows.append((s, ter["legit"], ter["eve"], ter["insider"], ter["oma"])) + print("[real]", [f"{v:.4g}" for v in rows[-1]]) + write_csv(DATA / "real_sec_ter.csv", + ["snr_db", "ter_legit", "ter_eve", "ter_insider", "ter_oma"], + rows) + + rec = {} + for s in REC_SNR: + rec[str(s)] = {} + for name, fn in schemes.items(): + ok = tot = 0 + for r in range(REC_RUNS): + w = fn(s, SEED_EVAL + 5000 * r + int(10 * s)) + o, t = headline_recovery(w, bounds) + ok += o; tot += t + rec[str(s)][name] = ok / tot + print(f"[rec] {s} dB {name}: {ok}/{tot} = {ok/tot:.4g}") + + stats = { + "vocab_size": vocab, + "n_texts": N_TEXTS, + "frames": N, + "repeats": REPEATS, + "decisions_per_point": N * U * REPEATS, + "distinct_tokens": int(torch.unique(ids_all).numel()), + "max_token_id": int(ids_all.max()), + "headlines_scored": sum(len(b) for b in bounds), + "headline_runs": REC_RUNS, + "recovery": rec, + } + (DATA / "real_sec_stats.json").write_text(json.dumps(stats, indent=1)) + print(json.dumps(stats, indent=1)) + + +if __name__ == "__main__": + main() diff --git a/code/feasibility_security.py b/code/feasibility_security.py new file mode 100644 index 0000000..2ced3eb --- /dev/null +++ b/code/feasibility_security.py @@ -0,0 +1,208 @@ +"""Feasibility study for paper 11 (TIFS): the per-user mask as a +physical-layer key. + +Three questions, all under the shared-embedding multiple-access model of +sse_lib.py (real-vector convention, flat Rayleigh fading): + + Q1 (encryption): a legitimate receiver knows its mask mu_u; an + eavesdropper (Eve) does not. How far above chance can Eve decode? + We measure the legitimate symbol error rate (SER) against Eve's SER + when Eve applies (a) a wrong mask drawn from the same distribution, + (b) no mask (mu = 1), (c) the average mask. Chance level is + (Vu-1)/Vu per digit, 1-(1/Vu)^P per frame. + + Q2 (key entropy vs dimension): as the per-period length L grows, two + independently drawn unit-norm masks become more nearly orthogonal, + so Eve's residual after de-masking with a wrong key grows. We sweep + L and report Eve's SER and the mean absolute mask cross-correlation. + + Q3 (jamming robustness): a jammer adds h_J * w to the frame, where w is + an arbitrary unit waveform (worst case: aligned with the victim's + masked codeword direction; and random). We sweep the + jammer-to-signal ratio (JSR) and report the legitimate SER, to show + the mask spreads a mismatched jammer and bounds its effect. + +This is a CPU-sized feasibility run (small V), not the final experiment. +Seeds fixed; results written to ../data as CSV. +""" +from __future__ import annotations + +import math +import numpy as np +import torch + +import sse_lib as L +from sse_lib import SSE, rayleigh_gain, snr_to_sigma2, write_csv, set_seed, DATA, DEVICE + + +# ---------------------------------------------------------------------- +# Eve: apply a chosen (wrong) set of masks to the SAME received frame the +# legitimate users see, then run the correlation receiver. +# ---------------------------------------------------------------------- +@torch.no_grad() +def eval_ser_eve(model: SSE, eve_masks: torch.Tensor, snr_list, + frames: int = 400_000, chunk: int = 50_000, seed: int = 777): + """eve_masks: (U, L) the masks Eve uses in place of the true ones. + Eve observes the same physically transmitted frame (true masks used at + the transmitter) but correlates with eve_masks.""" + model.eval().to(DEVICE) + Bn = model.unit_codebook() + true_m = model.masks() + eve_masks = eve_masks.to(DEVICE) + c = model.c + out = [] + for snr_db in snr_list: + g = torch.Generator(device="cpu").manual_seed(seed + int(10 * snr_db)) + err = tot = 0 + for n0 in range(0, frames, chunk): + n = min(chunk, frames - n0) + digits = torch.randint(model.vu, (n, model.users, model.P), + generator=g).to(DEVICE) + # transmit with the TRUE masks + e = Bn[digits] / math.sqrt(model.P) + y = (e * true_m[None, :, None, :]).sum(dim=1) / c # (n,P,L) + h = rayleigh_gain((n, model.users), device=DEVICE) + sigma = snr_to_sigma2(snr_db).to(DEVICE).sqrt() + noise = torch.randn(n, model.users, model.P, model.L, device=DEVICE) + y_rx = h[:, :, None, None] * y[:, None] + sigma * noise + r = y_rx / h[:, :, None, None].clamp_min(1e-6) # (n,U,P,L) + # Eve correlates with her (wrong) masks + cand = Bn[None, :, :] * eve_masks[:, None, :] # (U,Vu,L) + scores = torch.einsum("nupl,uvl->nupv", r, cand) + wrong = (scores.argmax(-1) != digits).any(dim=2) + err += int(wrong.sum()); tot += n * model.users + out.append(err / tot) + return out + + +@torch.no_grad() +def eval_ser_jam(model: SSE, snr_db, jsr_db_list, frames: int = 400_000, + chunk: int = 50_000, seed: int = 777, mode: str = "aligned"): + """Legitimate SER with an added jammer h_J * sqrt(JSR) * w. + mode='aligned': w points along user 0's masked mean codeword direction + (a structured, mask-matched worst case for user 0). + mode='random': w is an isotropic random unit frame each transmission.""" + model.eval().to(DEVICE) + Bn = model.unit_codebook() + true_m = model.masks() + c = model.c + sigma = snr_to_sigma2(snr_db).to(DEVICE).sqrt() + # aligned jammer direction: mask-0 applied to a fixed unit codeword, + # i.e. what an attacker would build if it copied the public codebook + # but guessed the (secret) mask wrong -> here we give it mask 0 exactly + # as the strongest realistic structured jammer. + w_fixed = (Bn[0][None, :] * true_m[0][None, :]).repeat(model.P, 1) # (P,L) + w_fixed = w_fixed / w_fixed.norm() + out = [] + for jsr_db in jsr_db_list: + jsr = 10.0 ** (jsr_db / 10.0) + g = torch.Generator(device="cpu").manual_seed(seed + int(10 * jsr_db)) + err = tot = 0 + for n0 in range(0, frames, chunk): + n = min(chunk, frames - n0) + digits = torch.randint(model.vu, (n, model.users, model.P), + generator=g).to(DEVICE) + e = Bn[digits] / math.sqrt(model.P) + y = (e * true_m[None, :, None, :]).sum(dim=1) / c # (n,P,L) + h = rayleigh_gain((n, model.users), device=DEVICE) + hJ = rayleigh_gain((n,), device=DEVICE) + if mode == "aligned": + w = w_fixed[None].expand(n, model.P, model.L) + else: + w = torch.randn(n, model.P, model.L, device=DEVICE) + w = w / w.reshape(n, -1).norm(dim=1)[:, None, None].clamp_min(1e-8) + jam = (hJ * math.sqrt(jsr))[:, None, None] * w # (n,P,L) + noise = torch.randn(n, model.users, model.P, model.L, device=DEVICE) + y_rx = (h[:, :, None, None] * y[:, None] + + h[:, :, None, None] * 0 # keep shape clarity + + jam[:, None] + sigma * noise) + r = y_rx / h[:, :, None, None].clamp_min(1e-6) + cand = Bn[None, :, :] * true_m[:, None, :] + scores = torch.einsum("nupl,uvl->nupv", r, cand) + wrong = (scores.argmax(-1) != digits).any(dim=2) + err += int(wrong.sum()); tot += n * model.users + out.append(err / tot) + return out + + +def mean_abs_cross_corr(masks: torch.Tensor) -> float: + """Mean || / (||mu_i|| ||mu_j||) over i str: + try: + return f"{float(x):.3f}" + except ValueError: + return "--" + + +def compare_table(): + print("% Table: scheme comparison (from sec_compare.csv)") + rows = list(csv.DictReader(open(DATA / "sec_compare.csv"))) + order = ["public_mask", "perm_key", "index_cipher", "oma_plain", "proposed"] + rows = sorted(rows, key=lambda r: order.index(r["scheme"])) + for r in rows: + cells = [f3(r["legit_ser"]), f3(r["eve_out"]), f3(r["eve_in"]), + f3(r["jam0_ser"])] + if r["scheme"] == "proposed": + cells = [rf"$\mathbf{{{c}}}$" for c in cells] + else: + cells = [f"${c}$" if c != "--" else "--" for c in cells] + print(f"{NAME[r['scheme']]} & " + " & ".join(cells) + r" \\") + + +def maskfam_table(): + print("% Table: key families (from sec_maskfam.csv)") + for r in csv.DictReader(open(DATA / "sec_maskfam.csv")): + print(f"{NAME[r['family']]} & ${f3(r['legit_ser'])}$ & " + f"${f3(r['eve_ser'])}$ & ${f3(r['mask_xcorr'])}$" + r" \\") + + +if __name__ == "__main__": + compare_table() + print() + maskfam_table() diff --git a/code/replot_security.py b/code/replot_security.py new file mode 100644 index 0000000..d5191b5 --- /dev/null +++ b/code/replot_security.py @@ -0,0 +1,253 @@ +"""Canonical replot script for paper 11: regenerates every result figure +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_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) +""" +from __future__ import annotations +from pathlib import Path +import csv + +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt + +ROOT = Path(__file__).resolve().parents[1] +DATA = ROOT / "data" +FIG = ROOT / "fig" +FIG.mkdir(exist_ok=True) + +plt.rcParams.update({ + "font.family": "serif", + "font.serif": ["DejaVu Serif", "Times New Roman"], + "font.size": 9, + "axes.labelsize": 9, + "legend.fontsize": 6.6, + "xtick.labelsize": 8, + "ytick.labelsize": 8, + "axes.grid": True, + "grid.linestyle": "--", + "grid.linewidth": 0.4, + "grid.alpha": 0.6, + "lines.linewidth": 1.3, + "lines.markersize": 3.4, + "figure.figsize": (3.15, 2.36), + "pdf.fonttype": 42, +}) +AXES_RECT = dict(left=0.185, right=0.965, top=0.955, bottom=0.195) + +C_LEGIT = "#c0392b" +C_EVE = "#2c5fa8" +C_OMA = "#7f8c8d" +C_CH = "#95a5a6" +C_MATCH = "#8e44ad" +C_PUB = "#16a085" + +# fixed label dictionary: tables and prose copy these strings verbatim +LBL = { + "legit": "Legitimate", + "oma": "OMA", + "eve_pub": "Eve, public masks", + "eve_key": "Eve, wrong key", + "chance": "Random guess", + "jam_m": "Matched jammer (public masks)", + "jam_b": "Blind jammer (proposed)", + "nojam": "No jammer", +} + + +def load(name): + with open(DATA / name) as f: + return list(csv.DictReader(f)) + + +def col(rows, k, f=float): + return [f(r[k]) for r in rows] + + +def save(fig, name): + """Write the figure and assert that no axis label is clipped. + + A long y label, or wide minor tick labels such as 6x10^-1 on a log + axis that spans less than a decade, silently pushes the label off + the canvas under the fixed axes rectangle. Reading the plotting code + cannot reveal this, so the check is made on the rendered geometry. + """ + fig.subplots_adjust(**AXES_RECT) + fig.canvas.draw() + fbox = fig.get_window_extent() + for ax in fig.axes: + for lbl in (ax.yaxis.label, ax.xaxis.label): + if not lbl.get_text(): + continue + b = lbl.get_window_extent() + if (b.x0 < fbox.x0 or b.y0 < fbox.y0 + or b.x1 > fbox.x1 or b.y1 > fbox.y1): + raise RuntimeError( + f"{name}: axis label '{lbl.get_text()}' is clipped " + f"(label {b} outside figure {fbox}); shorten the " + f"label or widen the margin") + fig.savefig(FIG / f"{name}.pdf") + plt.close(fig) + print("[OK]", name) + + +def fig_snr(): + r = load("sec_snr.csv") + x = col(r, "snr_db") + fig, ax = plt.subplots() + ax.semilogy(x, col(r, "legit"), color=C_LEGIT, marker="o", ls="-", + label=LBL["legit"]) + ax.semilogy(x, col(r, "oma"), color=C_OMA, marker="^", ls=":", + label=LBL["oma"]) + ax.semilogy(x, col(r, "eve_public"), color=C_PUB, marker="v", + ls="none", markersize=5.2, markerfacecolor="none", + label=LBL["eve_pub"]) + ax.semilogy(x, col(r, "eve_wrong"), color=C_EVE, marker="s", ls="--", + label=LBL["eve_key"]) + ax.plot(x, col(r, "chance"), color=C_CH, ls="-.", lw=0.9, + label=LBL["chance"]) + ax.set_xlabel("SNR (dB)") + ax.set_ylabel("SER") + ax.set_xlim(min(x), max(x)) + ax.legend(loc="lower left") + save(fig, "fig_sec_snr") + + +def fig_keylen(): + r = load("sec_keylen.csv") + x = col(r, "L", int) + fig, ax = plt.subplots() + ax.semilogy(x, col(r, "legit_ser"), color=C_LEGIT, marker="o", ls="-", + label=LBL["legit"]) + ax.semilogy(x, col(r, "oma"), color=C_OMA, marker="^", ls=":", + label=LBL["oma"]) + ax.semilogy(x, col(r, "eve_ser"), color=C_EVE, marker="s", ls="--", + label=LBL["eve_key"]) + ax.set_xlabel("Key length $L$") + ax.set_ylabel("SER") + ax.set_xscale("log", base=2) + ax.legend(loc="center right", bbox_to_anchor=(0.98, 0.72)) + save(fig, "fig_sec_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") + x = col(r, "jsr_db") + fig, ax = plt.subplots() + 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"]) + ax.set_xlabel("JSR (dB)") + ax.set_ylabel("SER") + ax.set_xlim(min(x), max(x)) + ax.set_ylim(0.2, 1.02) + ax.legend(loc="lower right") + save(fig, "fig_sec_jam") + + +def fig_sens(): + r = load("sec_sens.csv") + x = col(r, "rho") + fig, ax = plt.subplots() + ax.plot(x, col(r, "eve_ser"), color=C_EVE, marker="s", ls="-", + label=LBL["eve_key"]) + chance = 1.0 - (1.0 / 16.0) ** 4 + ax.axhline(chance, color=C_CH, ls="-.", lw=0.9, label=LBL["chance"]) + ax.set_xlabel(r"Key correlation $\kappa$") + ax.set_ylabel("Eavesdropper SER") + ax.set_xlim(0, 1) + ax.legend(loc="lower left") + save(fig, "fig_sec_sens") + + +def fig_brute(): + r = load("sec_brute.csv") + fig, ax = plt.subplots() + sty = {8: ("#c0392b", "o"), 16: ("#2c5fa8", "s"), + 32: ("#16a085", "v"), 64: ("#8e44ad", "P")} + for Lp in [8, 16, 32, 64]: + rows = [row for row in r if int(row["L"]) == Lp] + ks = [float(row["K"]) for row in rows] + ser = [float(row["eve_ser"]) for row in rows] + c, mk = sty[Lp] + ax.semilogx(ks, ser, color=c, marker=mk, ls="-", + label=f"$L={Lp}$") + ax.set_xlabel("Number of key guesses $K$") + ax.set_ylabel("Eavesdropper SER") + ax.legend(loc="lower left") + save(fig, "fig_sec_brute") + + +def fig_real(): + r = load("real_sec_ter.csv") + x = col(r, "snr_db") + fig, ax = plt.subplots() + ax.semilogy(x, col(r, "ter_legit"), color=C_LEGIT, marker="o", ls="-", + label=LBL["legit"]) + ax.semilogy(x, col(r, "ter_oma"), color=C_OMA, marker="^", ls=":", + label=LBL["oma"]) + ax.semilogy(x, col(r, "ter_insider"), color=C_PUB, marker="v", ls="-.", + label="Insider") + ax.semilogy(x, col(r, "ter_eve"), color=C_EVE, marker="s", ls="--", + label=LBL["eve_key"]) + ax.set_xlabel("SNR (dB)") + ax.set_ylabel("TER") + ax.set_xlim(min(x), max(x)) + ax.legend(loc="lower left") + save(fig, "fig_sec_real") + + +def fig_kpa(): + r = load("kpa.csv") + fig, ax = plt.subplots() + sty = {0.0: ("#c0392b", "o"), 10.0: ("#2c5fa8", "s"), + 20.0: ("#16a085", "v")} + for snr, (c, mk) in sty.items(): + rows = [row for row in r if float(row["snr_db"]) == snr] + n = [float(row["n_frames"]) for row in rows] + ser = [float(row["eve_ser"]) for row in rows] + ax.semilogx(n, ser, color=c, marker=mk, ls="-", + label=f"{int(snr)} dB") + ax.axhline(0.304, color=C_OMA, ls=":", lw=0.9, label=LBL["legit"]) + ax.set_xlabel("Known-plaintext frames $N$") + ax.set_ylabel("Eavesdropper SER") + ax.set_xscale("log", base=2) + ax.legend(loc="upper right") + save(fig, "fig_sec_kpa") + + +def main(): + fig_snr() + fig_keylen() + fig_jam() + try: + fig_sens() + fig_brute() + except FileNotFoundError: + print("[skip] attack-difficulty CSVs not present yet") + try: + fig_real() + except FileNotFoundError: + print("[skip] real-token CSV not present yet") + try: + fig_kpa() + except FileNotFoundError: + print("[skip] known-plaintext CSV not present yet") + print("[done] figures in", FIG) + + +if __name__ == "__main__": + main() diff --git a/code/sse_lib.py b/code/sse_lib.py new file mode 100644 index 0000000..ee2a3bf --- /dev/null +++ b/code/sse_lib.py @@ -0,0 +1,388 @@ +"""Shared library for the scalable shared embedding (SSE) letter. + +System model (real-vector convention, declared in the paper): + d-dimensional real embedding frame, U users, single transmitter. + Proposed SSE: the frame is split into P periods of length L = d/P and + one unit codebook B in R^{Vu x L} is reused in every period, so the + vocabulary size is V = Vu^P while the codebook stores only Vu*L + numbers. Index v maps to base-Vu digits (i_1,...,i_P). + Per-user periodic masks mu_u in R^L are repeated over the P periods. + Tx: y = (1/c) * sum_u e(s_u) .* m_u, c fixes unit average frame power. + Channel: user u sees h_u * y + n, h_u^2 ~ Exp(1) (Rayleigh magnitude, + known at the receiver), n ~ N(0, sigma^2 I), sigma^2 = 1/(d*snr). + Rx u: equalize by h_u, per period correlate with the masked unit + codewords b_i .* mu_u and take the argmax digit. + +Device: CUDA when available (run under WSL), CPU fallback. +""" +from __future__ import annotations + +import math +import time +from pathlib import Path + +import numpy as np +import torch + +ROOT = Path(__file__).resolve().parents[1] +DATA = ROOT / "data" +FIG = ROOT / "fig" +DATA.mkdir(exist_ok=True) +FIG.mkdir(exist_ok=True) + +DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu") + +# ---------------------------------------------------------------------- +# global configuration +# ---------------------------------------------------------------------- +D = 64 # embedding dimension (real) +U = 4 # users +VU = 16 # unit codebook size +P_MAX = 4 # periods for the main configuration, V = 16^4 = 65536 +SEED = 1 + +TRAIN_ITERS = 4000 +TRAIN_BATCH = 256 +TRAIN_SNR_DB = (0.0, 20.0) +LR = 3e-3 + + +def set_seed(seed: int = SEED) -> None: + torch.manual_seed(seed) + np.random.seed(seed) + if torch.cuda.is_available(): + torch.cuda.manual_seed_all(seed) + + +def snr_to_sigma2(snr_db: torch.Tensor | float, + d: int = D) -> torch.Tensor | float: + """Per-dimension noise variance for unit frame power and E[h^2]=1. + Pass the model's actual frame dimension d when it differs from the + module default, otherwise the effective SNR shifts with d.""" + snr = 10.0 ** (torch.as_tensor(snr_db, dtype=torch.float64) / 10.0) + return (1.0 / (d * snr)).float() + + +def rayleigh_gain(shape, device=DEVICE) -> torch.Tensor: + """|g| with g ~ CN(0,1): h^2 ~ Exp(1), E[h^2] = 1.""" + u = torch.rand(shape, device=device).clamp_min(1e-12) + return torch.sqrt(-torch.log(u)) + + +# ---------------------------------------------------------------------- +# proposed scalable shared embedding +# ---------------------------------------------------------------------- +class SSE(torch.nn.Module): + """Periodic unit-codebook shared embedding with per-user periodic masks.""" + + def __init__(self, P: int = P_MAX, vu: int = VU, d: int = D, users: int = U): + super().__init__() + assert d % P == 0, "embedding dimension must split into P periods" + self.P, self.vu, self.d, self.users = P, vu, d, users + self.L = d // P + self.B = torch.nn.Parameter(torch.randn(vu, self.L) / math.sqrt(self.L)) + self.W = torch.nn.Parameter(torch.randn(users, self.L) / math.sqrt(self.L)) + self.logit_scale = torch.nn.Parameter(torch.tensor(2.0)) + # transmit power normalizer, calibrated after training (buffer) + self.register_buffer("c", torch.tensor(1.0)) + + @property + def V(self) -> int: + return self.vu ** self.P + + def unit_codebook(self) -> torch.Tensor: + return self.B / self.B.norm(dim=1, keepdim=True).clamp_min(1e-8) + + def masks(self) -> torch.Tensor: + return self.W / self.W.norm(dim=1, keepdim=True).clamp_min(1e-8) * math.sqrt(self.L) + + def tx_frame(self, digits: torch.Tensor) -> torch.Tensor: + """digits: (N, U, P) ints -> unnormalized tx frame (N, P, L).""" + Bn = self.unit_codebook() + e = Bn[digits] / math.sqrt(self.P) # (N,U,P,L) + m = self.masks() # (U,L) + x = e * m[None, :, None, :] + return x.sum(dim=1) # (N,P,L) + + def calibrate_power(self, n: int = 65536) -> None: + with torch.no_grad(): + digits = torch.randint(self.vu, (n, self.users, self.P), device=self.B.device) + y = self.tx_frame(digits) + self.c.fill_(float(y.pow(2).sum(dim=(1, 2)).mean().sqrt())) + + def scores(self, r: torch.Tensor) -> torch.Tensor: + """r: equalized rx frame (N,P,L) -> scores (N,U,P,Vu).""" + Bn = self.unit_codebook() # (Vu,L) + m = self.masks() # (U,L) + cand = Bn[None, :, :] * m[:, None, :] # (U,Vu,L) + return torch.einsum("npl,uvl->nupv", r, cand) + + def forward(self, digits: torch.Tensor, snr_db: torch.Tensor, + h: torch.Tensor | None = None): + """digits (N,U,P), snr_db (N,) -> per-user scores and rx frames.""" + N = digits.shape[0] + y = self.tx_frame(digits) / self.c # (N,P,L) + if h is None: + h = rayleigh_gain((N, self.users), device=y.device) + sigma = snr_to_sigma2(snr_db, self.d).to(y.device).sqrt() # (N,) + n = torch.randn(N, self.users, self.P, self.L, device=y.device) + y_rx = h[:, :, None, None] * y[:, None] + sigma[:, None, None, None] * n + r = y_rx / h[:, :, None, None].clamp_min(1e-6) # equalized (N,U,P,L) + Bn = self.unit_codebook() + m = self.masks() + cand = Bn[None, :, :] * m[:, None, :] # (U,Vu,L) + scores = torch.einsum("nupl,uvl->nupv", r, cand) + return scores + + def n_params(self) -> int: + return self.B.numel() + self.W.numel() + + +# ---------------------------------------------------------------------- +# conventional scheme: full unstructured codebook (prior shared embedding) +# ---------------------------------------------------------------------- +class FullCodebook(torch.nn.Module): + """Directly trained V x d codebook with full-d per-user masks.""" + + def __init__(self, V: int, d: int = D, users: int = U): + super().__init__() + self.Vc, self.d, self.users = V, d, users + self.E = torch.nn.Parameter(torch.randn(V, d) / math.sqrt(d)) + self.W = torch.nn.Parameter(torch.randn(users, d) / math.sqrt(d)) + self.logit_scale = torch.nn.Parameter(torch.tensor(2.0)) + self.register_buffer("c", torch.tensor(1.0)) + + @property + def V(self) -> int: + return self.Vc + + def codebook(self) -> torch.Tensor: + return self.E / self.E.norm(dim=1, keepdim=True).clamp_min(1e-8) + + def masks(self) -> torch.Tensor: + return self.W / self.W.norm(dim=1, keepdim=True).clamp_min(1e-8) * math.sqrt(self.d) + + def tx_frame(self, idx: torch.Tensor) -> torch.Tensor: + En = self.codebook() + e = En[idx] # (N,U,d) + m = self.masks() + return (e * m[None]).sum(dim=1) # (N,d) + + def calibrate_power(self, n: int = 65536) -> None: + with torch.no_grad(): + idx = torch.randint(self.Vc, (n, self.users), device=self.E.device) + y = self.tx_frame(idx) + self.c.fill_(float(y.pow(2).sum(dim=1).mean().sqrt())) + + def forward(self, idx: torch.Tensor, snr_db: torch.Tensor, + h: torch.Tensor | None = None): + N = idx.shape[0] + y = self.tx_frame(idx) / self.c # (N,d) + if h is None: + h = rayleigh_gain((N, self.users), device=y.device) + sigma = snr_to_sigma2(snr_db).to(y.device).sqrt() + n = torch.randn(N, self.users, self.d, device=y.device) + y_rx = h[:, :, None] * y[:, None] + sigma[:, None, None] * n + r = y_rx / h[:, :, None].clamp_min(1e-6) # (N,U,d) + En = self.codebook() + m = self.masks() + cand = En[None, :, :] * m[:, None, :] # (U,V,d) + scores = torch.einsum("nud,uvd->nuv", r, cand) + return scores + + def n_params(self) -> int: + return self.E.numel() + self.W.numel() + + +# ---------------------------------------------------------------------- +# training +# ---------------------------------------------------------------------- +def train_sse(model: SSE, iters: int = TRAIN_ITERS, batch: int = TRAIN_BATCH, + lr: float = LR, log_every: int = 0, eval_snr: float = 10.0, + eval_frames: int = 20000, seed: int = SEED): + """Digit-wise cross entropy under superposition; cost independent of V.""" + set_seed(seed) + model.to(DEVICE) + opt = torch.optim.Adam(model.parameters(), lr=lr) + ce = torch.nn.CrossEntropyLoss() + curve = [] + t0 = time.time() + for it in range(1, iters + 1): + digits = torch.randint(model.vu, (batch, model.users, model.P), device=DEVICE) + snr = torch.empty(batch).uniform_(*TRAIN_SNR_DB) + model.calibrate_power(8192) + scores = model(digits, snr) * model.logit_scale.exp() + loss = ce(scores.reshape(-1, model.vu), digits.reshape(-1)) + opt.zero_grad(); loss.backward(); opt.step() + if log_every and (it % log_every == 0 or it == 1): + ser = eval_ser_sse(model, [eval_snr], frames=eval_frames)[0] + curve.append((it, time.time() - t0, float(loss.detach()), ser)) + model.calibrate_power() + return curve + + +def train_full(model: FullCodebook, iters: int = TRAIN_ITERS, + batch: int = TRAIN_BATCH, lr: float = LR, log_every: int = 0, + eval_snr: float = 10.0, eval_frames: int = 20000, + seed: int = SEED): + set_seed(seed) + model.to(DEVICE) + opt = torch.optim.Adam(model.parameters(), lr=lr) + ce = torch.nn.CrossEntropyLoss() + curve = [] + t0 = time.time() + for it in range(1, iters + 1): + idx = torch.randint(model.Vc, (batch, model.users), device=DEVICE) + snr = torch.empty(batch).uniform_(*TRAIN_SNR_DB) + model.calibrate_power(8192) + scores = model(idx, snr) * model.logit_scale.exp() + loss = ce(scores.reshape(-1, model.Vc), idx.reshape(-1)) + opt.zero_grad(); loss.backward(); opt.step() + if log_every and (it % log_every == 0 or it == 1): + ser = eval_ser_full(model, [eval_snr], frames=eval_frames)[0] + curve.append((it, time.time() - t0, float(loss.detach()), ser)) + model.calibrate_power() + return curve + + +# ---------------------------------------------------------------------- +# evaluation (Monte Carlo) +# ---------------------------------------------------------------------- +@torch.no_grad() +def eval_ser_sse(model: SSE, snr_list, frames: int = 2_000_000, + chunk: int = 100_000, seed: int = 777): + """Frame (vocabulary-symbol) error rate: any wrong digit is an error.""" + model.eval().to(DEVICE) + out = [] + for snr_db in snr_list: + g = torch.Generator(device="cpu").manual_seed(seed + int(10 * snr_db)) + err = tot = 0 + for n0 in range(0, frames, chunk): + n = min(chunk, frames - n0) + digits = torch.randint(model.vu, (n, model.users, model.P), + generator=g).to(DEVICE) + snr = torch.full((n,), float(snr_db)) + scores = model(digits, snr) + wrong = (scores.argmax(-1) != digits).any(dim=2) # (n,U) + err += int(wrong.sum()); tot += n * model.users + out.append(err / tot) + return out + + +@torch.no_grad() +def eval_ser_full(model: FullCodebook, snr_list, frames: int = 2_000_000, + chunk: int = 50_000, seed: int = 777): + model.eval().to(DEVICE) + if model.Vc >= 4096: + chunk = max(512, (1 << 23) // model.Vc) + out = [] + for snr_db in snr_list: + g = torch.Generator(device="cpu").manual_seed(seed + int(10 * snr_db)) + err = tot = 0 + for n0 in range(0, frames, chunk): + n = min(chunk, frames - n0) + idx = torch.randint(model.Vc, (n, model.users), generator=g).to(DEVICE) + snr = torch.full((n,), float(snr_db)) + scores = model(idx, snr) + err += int((scores.argmax(-1) != idx).sum()); tot += n * model.users + out.append(err / tot) + return out + + +# ---------------------------------------------------------------------- +# conventional digital scheme: OMA with BPSK per real dimension (QPSK +# per complex subcarrier under the declared real-imaginary stacking) +# ---------------------------------------------------------------------- +def oma_ser(snr_db_list, bits: int = 16, n_grid: int = 200_000): + """Semi-analytic expression under the real-dimension convention. + All bits of a vocabulary symbol share the same flat-fading gain, so + SER = E_h[1 - (1 - Q(h sqrt(snr)))^bits] with h^2 ~ Exp(1); the + expectation is evaluated by numerical integration on a dense grid.""" + x = (np.arange(n_grid) + 0.5) / n_grid # uniform quantiles + h = np.sqrt(-np.log(1.0 - x)) # inverse-CDF transform + out = [] + for s in snr_db_list: + g = 10.0 ** (s / 10.0) + q = 0.5 * np.array([math.erfc(v / math.sqrt(2.0)) for v in + np.clip(h * math.sqrt(g), 0, 38)]) + out.append(float(np.mean(1.0 - (1.0 - q) ** bits))) + 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): + """Monte Carlo check of the closed form (same channel conventions).""" + out = [] + for s in snr_db_list: + sigma = math.sqrt(1.0 / (10.0 ** (s / 10.0))) # per-dim, unit Es + err = tot = 0 + torch.manual_seed(seed + int(10 * s)) + for n0 in range(0, frames, chunk): + n = min(chunk, frames - n0) + h = rayleigh_gain((n, 1)) + b = torch.randint(0, 2, (n, bits), device=DEVICE) * 2.0 - 1.0 + y = h * b + sigma * torch.randn(n, bits, device=DEVICE) + wrong = ((y * b) < 0).any(dim=1) + err += int(wrong.sum()); tot += n + out.append(err / tot) + return out + + +# ---------------------------------------------------------------------- +# analysis: union bound with residual-interference Gaussian approximation +# ---------------------------------------------------------------------- +@torch.no_grad() +def sse_union_bound(model: SSE, snr_db_list, n_mc_h: int = 200_000): + """P_e <= 1 - (1 - min(1, sum_pairs)) ... digit union bound averaged + over the Rayleigh gain, with measured residual interference treated + as additional Gaussian noise (approximation stated in the paper).""" + model.eval().to(DEVICE) + Bn = model.unit_codebook() + m = model.masks() + c = float(model.c) + # residual interference power per dimension at user u (measured) + digits = torch.randint(model.vu, (65536, model.users, model.P), device=DEVICE) + e = Bn[digits] / math.sqrt(model.P) + x = e * m[None, :, None, :] # (N,U,P,L) + y = x.sum(dim=1) / c # (N,P,L) + # per-user: signal = x_u/c, interference = (y - x_u/c) + interf_pw = [] + dmin2 = [] + for u in range(model.users): + su = x[:, u] / c # (N,P,L) + iu = y - su + # project interference onto the normalized candidate directions + cand = Bn * m[u][None, :] # (Vu,L) + cn = cand / cand.norm(dim=1, keepdim=True).clamp_min(1e-8) + proj = torch.einsum("npl,vl->npv", iu, cn) + interf_pw.append(float(proj.pow(2).mean())) + # pairwise distances of the scaled candidate set (tx side scaling) + cs = cand / (c * math.sqrt(model.P)) + dd = torch.cdist(cs, cs) + dmin2.append(float((dd + torch.eye(model.vu, device=DEVICE) * 1e9).min() ** 2)) + res = [] + g = rayleigh_gain(n_mc_h) + for s in snr_db_list: + sig2 = float(snr_to_sigma2(torch.tensor(s))) + pe_users = [] + for u in range(model.users): + # effective noise per dim after equalization: sig2/h^2 + interf + sig_eff2 = sig2 / g.pow(2) + interf_pw[u] + arg = torch.sqrt(torch.clamp(torch.tensor(dmin2[u], device=DEVICE) + / (4.0 * sig_eff2), min=0.0)) + q = 0.5 * torch.erfc(arg / math.sqrt(2.0)) + p_digit = torch.clamp((model.vu - 1) * q, max=1.0) + p_frame = 1.0 - (1.0 - p_digit) ** model.P + pe_users.append(float(p_frame.mean())) + res.append(sum(pe_users) / len(pe_users)) + return res + + +def write_csv(path: Path, header: list[str], rows) -> None: + with open(path, "w") as f: + f.write(",".join(header) + "\n") + for row in rows: + f.write(",".join(f"{v:.10g}" if isinstance(v, float) else str(v) + for v in row) + "\n") + print("[csv]", path) diff --git a/code/verify_math.py b/code/verify_math.py new file mode 100644 index 0000000..05bb7eb --- /dev/null +++ b/code/verify_math.py @@ -0,0 +1,201 @@ +"""Numerical verification of the closed forms in Sections IV and V of +paper 11 (mask-as-key encryption and jamming robustness). + +Every claim that enters the manuscript is checked here against Monte +Carlo, with a PASS/FAIL verdict and the achieved agreement level printed. +Real-vector convention, dimension d, U users, codebook of V unit-norm +codewords, per-user masks with zero-mean entries normalized to +||m||^2 = d (so E[m_k^2] = 1). + +Notation matches the tex: + y = (1/c) sum_u e_{s_u} .* m_u transmit frame + legit score z_{u,i} = r_u^T (e_i .* m_u), r_u = y + n_u/h_u + eve score zE_{u,i} = (y_E/h_E)^T (e_i .* mtil_u) + jammer adds h_J sqrt(rho) w to the victim observation + +Run on CPU (NumPy); no training involved, pure algebra checks. +""" +from __future__ import annotations +import numpy as np + +RNG = np.random.default_rng(2026) +D, U, V = 64, 4, 256 + + +def unit_codebook(V, d, rng): + E = rng.standard_normal((V, d)) + return E / np.linalg.norm(E, axis=1, keepdims=True) + + +def masks(U, d, rng): + """Zero-mean entries normalized so ||m_u||^2 = d.""" + M = rng.standard_normal((U, d)) + return M / np.linalg.norm(M, axis=1, keepdims=True) * np.sqrt(d) + + +def report(tag, claim, emp, tol, extra=""): + err = abs(claim - emp) + ok = err <= tol + print(f"[{'PASS' if ok else 'FAIL'}] {tag}: claim={claim:.5g} " + f"emp={emp:.5g} |err|={err:.2g} tol={tol:g} {extra}") + return ok + + +def v1_legit_self_alignment(): + """Claim: E[e_s^T diag(m^2) e_s] = 1 (signal self-correlation).""" + E = unit_codebook(V, D, RNG) + vals = [] + for _ in range(4000): + m = masks(1, D, RNG)[0] + s = RNG.integers(V) + vals.append(float((E[s] ** 2) @ (m ** 2))) + return report("V1 legit self-alignment", 1.0, float(np.mean(vals)), 2e-2) + + +def v2_eve_uninformed(): + """Claim: with an independent substitute mask, the eavesdropper's + correct-index correlation has the same mean as any wrong index, so + the mean advantage is zero and the eavesdropper SER = (V-1)/V, + independent of SNR.""" + E = unit_codebook(V, D, RNG) + # mean advantage of the true index over the wrong indices, noiseless + adv = [] + ser_by_snr = {} + for snr_db in [0.0, 10.0, 20.0, 80.0]: # 80 dB stands in for noiseless + sigma = np.sqrt(1.0 / (D * 10 ** (snr_db / 10.0))) + err = 0 + trials = 6000 + for _ in range(trials): + s = RNG.integers(V, size=U) + M = masks(U, D, RNG) + c = 1.0 # scale-invariant for argmax + y = np.zeros(D) + for u in range(U): + y += E[s[u]] * M[u] + y /= np.sqrt(U) # any fixed scale + # eavesdropper targets user 0 with an independent wrong mask + mtil = masks(1, D, RNG)[0] + hE = np.sqrt(-np.log(RNG.random())) + rE = y + (sigma / hE) * RNG.standard_normal(D) + scores = (E * mtil) @ rE # (V,) + if snr_db == 80.0: + adv.append(scores[s[0]] - scores.mean()) + if scores.argmax() != s[0]: + err += 1 + ser_by_snr[snr_db] = err / trials + chance = (V - 1) / V + ok1 = report("V2a eve mean advantage", 0.0, float(np.mean(adv)), 3e-3) + ok2 = True + for snr_db, ser in ser_by_snr.items(): + tag = f"V2b eve SER @ {int(snr_db)}dB" + ok2 &= report(tag, chance, ser, 1.5e-2) + return ok1 and ok2 + + +def v3_leakage_vs_correlation(): + """Claim: if the substitute mask has normalized correlation + rho = /d with the true mask, the eavesdropper's true-index + bias grows linearly in rho; independent random masks give + E|rho| = O(1/sqrt(d)); orthogonal masks give rho = 0.""" + E = unit_codebook(V, D, RNG) + # (a) bias vs prescribed rho + slopes = [] + for rho in [0.0, 0.25, 0.5, 0.75, 1.0]: + bias = [] + for _ in range(3000): + m = masks(1, D, RNG)[0] + mp = masks(1, D, RNG)[0] + mp = mp - (mp @ m) / (m @ m) * m # orthogonalize + mp = mp / np.linalg.norm(mp) * np.sqrt(D) + mtil = rho * m + np.sqrt(1 - rho ** 2) * mp + s = RNG.integers(V) + # noiseless single-user useful alignment for the true index + bias.append(float((E[s] ** 2) @ (m * mtil))) + slopes.append((rho, float(np.mean(bias)))) + # claim: bias(rho) = rho * bias(1); check linearity + b1 = slopes[-1][1] + 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)) + # (b) random independent mask correlation: E|corr| = sqrt(2/(pi d)) + # (the folded-normal mean of a N(0, 1/d) variable) + corrs = [] + for _ in range(5000): + m = masks(1, D, RNG)[0] + mt = masks(1, D, RNG)[0] + corrs.append(abs((m @ mt) / D)) + emp = float(np.mean(corrs)) + claim = float(np.sqrt(2.0 / (np.pi * D))) + ok_b = report("V3b random mask E|corr|", claim, emp, 0.1 * claim) + return lin_ok and ok_b + + +def v4_blind_jammer_spread(): + """Claim: a mask-blind jammer (w independent of m_u) contributes a + zero-mean term to every candidate score with variance + (hJ^2/hu^2) rho * sum_k w_k^2 e_{i,k}^2, i.e. it is spread with no + systematic bias toward any index.""" + E = unit_codebook(V, D, RNG) + rho = 1.0 + # (a) structural claim: the mask projection g_i = (w .* m)^T e_i is + # zero-mean, decoupled from the positive gain factor hJ/hu. + g, var_emp, var_cl = [], [], [] + for _ in range(20000): + m = masks(1, D, RNG)[0] + w = RNG.standard_normal(D); w /= np.linalg.norm(w) + i = RNG.integers(V) + gi = (w * m) @ E[i] + g.append(gi) + var_emp.append(gi ** 2) + var_cl.append(np.sum(w ** 2 * E[i] ** 2)) + ok1 = report("V4a blind jammer projection mean", 0.0, + float(np.mean(g)), 3e-3) + # (b) variance of the projection matches sum_k w_k^2 e_{i,k}^2; the + # full contribution scales this by (hJ^2/hu^2) rho. + ok2 = report("V4b blind jammer projection variance", + float(np.mean(var_cl)), float(np.mean(var_emp)), + 0.05 * float(np.mean(var_cl))) + return ok1 and ok2 + + +def v5_matched_jammer_concentrates(): + """Claim: a mask-matched jammer aligned with the victim key for a + target index t creates a bias of order one on index t, while the + blind-jammer projection has zero mean and RMS of order 1/sqrt(d). + The physically meaningful separation is matched bias over blind RMS, + which is sqrt(d) (the sample mean of the blind bias estimates zero + and is pure Monte Carlo noise, so it is NOT a valid denominator).""" + E = unit_codebook(V, D, RNG) + bias_matched, blind_sq = [], [] + for _ in range(3000): + m = masks(1, D, RNG)[0] + t = RNG.integers(V) + wm = E[t] * m; wm /= np.linalg.norm(wm) # matched (needs m) + wb = RNG.standard_normal(D); wb /= np.linalg.norm(wb) # blind + bias_matched.append(float((wm * m) @ E[t])) + blind_sq.append(float(((wb * m) @ E[t]) ** 2)) + bm = float(np.mean(bias_matched)) + brms = float(np.sqrt(np.mean(blind_sq))) + ratio = bm / brms + ok = abs(ratio - np.sqrt(D)) <= 0.25 * np.sqrt(D) and bm > 0.9 + print(f"[{'PASS' if ok else 'FAIL'}] V5 matched bias / blind RMS: " + f"matched={bm:.3f} blind_rms={brms:.4f} ratio={ratio:.1f} " + f"(claim sqrt(d)={np.sqrt(D):.1f})") + return ok + + +def main(): + print(f"config d={D} U={U} V={V}\n") + results = { + "V1": v1_legit_self_alignment(), + "V2": v2_eve_uninformed(), + "V3": v3_leakage_vs_correlation(), + "V4": v4_blind_jammer_spread(), + "V5": v5_matched_jammer_concentrates(), + } + print("\nsummary:", {k: ("PASS" if v else "FAIL") for k, v in results.items()}) + print("ALL PASS" if all(results.values()) else "SOME FAILED") + + +if __name__ == "__main__": + main() diff --git a/data/feas_q1_eavesdrop.csv b/data/feas_q1_eavesdrop.csv new file mode 100644 index 0000000..2cee08e --- /dev/null +++ b/data/feas_q1_eavesdrop.csv @@ -0,0 +1,6 @@ +snr_db,legit,eve_wrong,eve_none,eve_avg,chance +0,0.55692125,0.995261875,0.992021875,0.95962875,0.99609375 +5,0.26456,0.9946975,0.990336875,0.94351625,0.99609375 +10,0.099379375,0.994283125,0.989521875,0.93188,0.99609375 +15,0.0334,0.994011875,0.989031875,0.925765625,0.99609375 +20,0.010784375,0.9939975,0.988625625,0.922928125,0.99609375 diff --git a/data/feas_q2_keyentropy.csv b/data/feas_q2_keyentropy.csv new file mode 100644 index 0000000..ef57c24 --- /dev/null +++ b/data/feas_q2_keyentropy.csv @@ -0,0 +1,6 @@ +L,d,legit_ser,eve_ser,mask_xcorr +8,16,0.5546,0.9975366667,0.07264538854 +16,32,0.1181416667,0.99724,0.006702715065 +32,64,0.09873416667,0.9943241667,0.01185981557 +64,128,0.08845333333,0.9922383333,0.009370678104 +128,256,0.08771083333,0.9851966667,0.008488235064 diff --git a/data/feas_q3_jamming.csv b/data/feas_q3_jamming.csv new file mode 100644 index 0000000..fb76b31 --- /dev/null +++ b/data/feas_q3_jamming.csv @@ -0,0 +1,8 @@ +jsr_db,ser_aligned,ser_random +-10,0.1924166667,0.1780558333 +-5,0.2540891667,0.2948208333 +0,0.3063416667,0.4912158333 +5,0.3530991667,0.7022516667 +10,0.4284433333,0.8538791667 +15,0.5548433333,0.935385 +20,0.71272,0.9718341667 diff --git a/data/kpa.csv b/data/kpa.csv new file mode 100644 index 0000000..9a4bdf1 --- /dev/null +++ b/data/kpa.csv @@ -0,0 +1,43 @@ +snr_db,n_frames,kappa,eve_ser +0,1,0.2374871574,0.99874975 +0,2,0.6126027606,0.96312825 +0,3,0.7285515711,0.921628875 +0,4,0.8244251639,0.85221925 +0,5,0.868628718,0.797125 +0,6,0.8920436099,0.745713875 +0,8,0.9208301157,0.67688775 +0,10,0.9429872826,0.592138625 +0,12,0.9557282105,0.534421875 +0,16,0.9700051412,0.44833975 +0,24,0.9802580416,0.38197025 +0,32,0.9852379695,0.351050875 +0,48,0.9904022858,0.32166525 +0,64,0.993118532,0.307348 +10,1,0.3489475794,0.989224875 +10,2,0.8794977516,0.704872 +10,3,0.9473693997,0.547376875 +10,4,0.9682661489,0.455776875 +10,5,0.9782963678,0.3962285 +10,6,0.9843041778,0.359232 +10,8,0.9900029436,0.326613375 +10,10,0.9934537426,0.306601375 +10,12,0.9949941516,0.2992645 +10,16,0.9965374678,0.291205125 +10,24,0.9978871465,0.285018125 +10,32,0.9984912023,0.28255425 +10,48,0.9990138412,0.280105125 +10,64,0.9992718786,0.279036875 +20,1,0.596763967,0.861350125 +20,2,0.984469898,0.34732075 +20,3,0.9948319912,0.300176375 +20,4,0.9975389287,0.286598125 +20,5,0.9984581739,0.282405125 +20,6,0.9988854468,0.2803655 +20,8,0.9992000297,0.279362875 +20,10,0.9994319767,0.278179625 +20,12,0.9995499209,0.277694875 +20,16,0.9996712342,0.277181875 +20,24,0.9997934118,0.276410875 +20,32,0.999845539,0.2763615 +20,48,0.9999018267,0.276363875 +20,64,0.9999270439,0.276194125 diff --git a/data/real_sec_stats.json b/data/real_sec_stats.json new file mode 100644 index 0000000..68db78e --- /dev/null +++ b/data/real_sec_stats.json @@ -0,0 +1,31 @@ +{ + "vocab_size": 30522, + "n_texts": 2000, + "frames": 24998, + "repeats": 8, + "decisions_per_point": 799936, + "distinct_tokens": 10486, + "max_token_id": 29599, + "headlines_scored": 1948, + "headline_runs": 4, + "recovery": { + "20": { + "legit": 0.1985369609856263, + "eve": 0.0, + "insider": 0.0, + "oma": 0.19815195071868583 + }, + "24": { + "legit": 0.5103952772073922, + "eve": 0.0, + "insider": 0.0, + "oma": 0.5160420944558521 + }, + "28": { + "legit": 0.7630903490759754, + "eve": 0.0, + "insider": 0.0, + "oma": 0.7583418891170431 + } + } +} \ No newline at end of file diff --git a/data/real_sec_ter.csv b/data/real_sec_ter.csv new file mode 100644 index 0000000..d40b202 --- /dev/null +++ b/data/real_sec_ter.csv @@ -0,0 +1,9 @@ +snr_db,ter_legit,ter_eve,ter_insider,ter_oma +0,0.8937765021,0.9999699976,0.999212437,0.8927576706 +4,0.6685672354,0.9999337447,0.9977085667,0.6680284423 +8,0.3903512281,0.9999224938,0.9958434175,0.3898399372 +12,0.1875025002,0.9999362449,0.9946258201,0.1874349948 +16,0.08110023802,0.9999349948,0.9938970118,0.08109648772 +20,0.03404147332,0.9999337447,0.9936357409,0.03380395432 +24,0.01362108969,0.9999274942,0.9934882291,0.01364484159 +28,0.005464187135,0.9999312445,0.993449476,0.005405432435 diff --git a/data/sec_brute.csv b/data/sec_brute.csv new file mode 100644 index 0000000..9925c1e --- /dev/null +++ b/data/sec_brute.csv @@ -0,0 +1,29 @@ +L,K,best_rho,eve_ser +8,1,0.2918601623,0.9961626487 +8,10,0.6307837307,0.9703819549 +8,100,0.8190062809,0.8850038404 +8,1000,0.9077793813,0.7200369393 +8,10000,0.9535904264,0.5318095675 +8,100000,0.9757162716,0.4169265965 +8,1000000,0.9872786315,0.339091505 +16,1,0.2228791779,0.9991641146 +16,10,0.4570522499,0.9957471368 +16,100,0.6321070191,0.9833336645 +16,1000,0.7417848118,0.9546712761 +16,10000,0.8164950053,0.9000406219 +16,100000,0.8673534005,0.8378011935 +16,1000000,0.9048131336,0.7494679145 +32,1,0.1399813941,0.9997996279 +32,10,0.3205750013,0.9991500426 +32,100,0.4660256564,0.9971309056 +32,1000,0.5620279439,0.9934165308 +32,10000,0.6393936736,0.9852959659 +32,100000,0.6993164916,0.9746142481 +32,1000000,0.7479539255,0.9565757219 +64,1,0.0987436915,0.9998965347 +64,10,0.2263401688,0.9996584104 +64,100,0.3389944824,0.9991447783 +64,1000,0.4155608514,0.9984268536 +64,10000,0.4757575011,0.9974241507 +64,100000,0.5299797378,0.9957096073 +64,1000000,0.5741312045,0.9933056882 diff --git a/data/sec_compare.csv b/data/sec_compare.csv new file mode 100644 index 0000000..d762ce3 --- /dev/null +++ b/data/sec_compare.csv @@ -0,0 +1,6 @@ +scheme,legit_ser,eve_out,eve_in,jam0_ser +proposed,0.303585,0.99983,0.99998,0.80612 +public_mask,0.303585,0.303585,0.303585,0.95359 +perm_key,0.303435,0.9999775,0.303435,0.8065225 +index_cipher,0.303585,0.9999847412,0.9999847412,0.95359 +oma_plain,0.2747696909,0.2747696909,0.2747696909,nan diff --git a/data/sec_jam.csv b/data/sec_jam.csv new file mode 100644 index 0000000..8ab7cfa --- /dev/null +++ b/data/sec_jam.csv @@ -0,0 +1,8 @@ +jsr_db,blind,matched,nojam +-10,0.468186,0.720358,0.302716 +-5,0.633112,0.874476,0.302716 +0,0.806548,0.9534,0.302716 +5,0.918778,0.984432,0.302716 +10,0.970874,0.994914,0.302716 +15,0.989928,0.9984,0.302716 +20,0.996858,0.999468,0.302716 diff --git a/data/sec_keylen.csv b/data/sec_keylen.csv new file mode 100644 index 0000000..6c2664e --- /dev/null +++ b/data/sec_keylen.csv @@ -0,0 +1,10 @@ +L,d,legit_ser,eve_ser,mask_xcorr,oma +4,16,0.9997925,0.999963,0.01188752614,0.2747696909 +6,24,0.9921175,0.9996935,0.09415384382,0.2747696909 +8,32,0.9297855,0.999972,0.007307400461,0.2747696909 +12,48,0.416604,0.999781,0.005153660662,0.2747696909 +16,64,0.2762895,0.9999285,0.007116591092,0.2747696909 +24,96,0.1829615,0.999975,0.002973971656,0.2747696909 +32,128,0.131901,0.9998895,0.005575809628,0.2747696909 +48,192,0.090206,0.999845,0.005743456539,0.2747696909 +64,256,0.0635265,0.9997915,0.006678360514,0.2747696909 diff --git a/data/sec_maskfam.csv b/data/sec_maskfam.csv new file mode 100644 index 0000000..c3c5f69 --- /dev/null +++ b/data/sec_maskfam.csv @@ -0,0 +1,5 @@ +family,legit_ser,eve_ser,mask_xcorr +random,0.64962,0.998488,0.2709003091 +hadamard,0.2568,0.9995605,0 +learned,0.2762895,0.9999285,0.007116591092 +learned_reg,0.315952,0.9999555,0.01121100038 diff --git a/data/sec_regjam.csv b/data/sec_regjam.csv new file mode 100644 index 0000000..3ad7086 --- /dev/null +++ b/data/sec_regjam.csv @@ -0,0 +1,8 @@ +jsr_db,plain,regularized +-10,0.46879,0.4691633333 +-5,0.6335366667,0.6307333333 +0,0.8056466667,0.8018166667 +5,0.9187366667,0.91602 +10,0.9708533333,0.9693866667 +15,0.9902133333,0.9896433333 +20,0.9967333333,0.9965933333 diff --git a/data/sec_sens.csv b/data/sec_sens.csv new file mode 100644 index 0000000..38a01d2 --- /dev/null +++ b/data/sec_sens.csv @@ -0,0 +1,23 @@ +rho,eve_ser +0,0.99997875 +0.1,0.999955 +0.2,0.99976375 +0.3,0.99947625 +0.4,0.99873 +0.5,0.99717375 +0.6,0.992395 +0.65,0.983195 +0.7,0.9776825 +0.75,0.9594925 +0.8,0.91810625 +0.84,0.88158125 +0.88,0.8247575 +0.9,0.792455 +0.92,0.67340125 +0.94,0.602535 +0.96,0.50181875 +0.97,0.47287375 +0.98,0.37558375 +0.99,0.32493625 +0.995,0.30185625 +1,0.27494375 diff --git a/data/sec_snr.csv b/data/sec_snr.csv new file mode 100644 index 0000000..80c5a7e --- /dev/null +++ b/data/sec_snr.csv @@ -0,0 +1,7 @@ +snr_db,legit,eve_wrong,eve_none,eve_public,oma,chance +0,0.8944596875,0.9999515625,0.999990625,0.8943865625,0.8933480658,0.9999847412 +4,0.66980875,0.9999575,0.99998625,0.670624375,0.6686275787,0.9999847412 +8,0.39054875,0.9999425,0.9999865625,0.3902384375,0.3892153151,0.9999847412 +12,0.1880821875,0.9999284375,0.9999890625,0.1877565625,0.1870712987,0.9999847412 +16,0.0811665625,0.9999203125,0.9999875,0.081488125,0.08092517452,0.9999847412 +20,0.033519375,0.999921875,0.999988125,0.0334946875,0.03334949917,0.9999847412 diff --git a/fig/fig1_system.pdf b/fig/fig1_system.pdf new file mode 100644 index 0000000..dadb1ca Binary files /dev/null and b/fig/fig1_system.pdf differ diff --git a/fig/fig_sec_brute.pdf b/fig/fig_sec_brute.pdf new file mode 100644 index 0000000..05b8453 Binary files /dev/null and b/fig/fig_sec_brute.pdf differ diff --git a/fig/fig_sec_jam.pdf b/fig/fig_sec_jam.pdf new file mode 100644 index 0000000..a1dc8e2 Binary files /dev/null and b/fig/fig_sec_jam.pdf differ diff --git a/fig/fig_sec_keylen.pdf b/fig/fig_sec_keylen.pdf new file mode 100644 index 0000000..cf2f2ea Binary files /dev/null and b/fig/fig_sec_keylen.pdf differ diff --git a/fig/fig_sec_kpa.pdf b/fig/fig_sec_kpa.pdf new file mode 100644 index 0000000..f674e0e Binary files /dev/null and b/fig/fig_sec_kpa.pdf differ diff --git a/fig/fig_sec_real.pdf b/fig/fig_sec_real.pdf new file mode 100644 index 0000000..fe58e08 Binary files /dev/null and b/fig/fig_sec_real.pdf differ diff --git a/fig/fig_sec_sens.pdf b/fig/fig_sec_sens.pdf new file mode 100644 index 0000000..fc4ebe5 Binary files /dev/null and b/fig/fig_sec_sens.pdf differ diff --git a/fig/fig_sec_snr.pdf b/fig/fig_sec_snr.pdf new file mode 100644 index 0000000..2beb803 Binary files /dev/null and b/fig/fig_sec_snr.pdf differ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..61b140b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +torch>=2.0 +numpy>=1.24 +matplotlib>=3.7 +# real-token stage only +datasets>=2.14 +transformers>=4.30