Files
TOIFAS/code/exp_full.py
T
KiHoLee 3a9a5eebf4 Key-space attacks against both key families, and the learned SNR sweep
check_family_enum.py now runs both attacks against both families. The
outsider ranks the L-1 Walsh rows; the insider, holding m_v, ranks the
L-1 products m_v .* m_r, which works because Walsh rows are closed
under the elementwise product and the per-block sign cancels in
m_u .* m_v. Both need a list to rank, and only the structured family
supplies one: the structured family falls at 0.905 from one frame at
10 dB and 0.990 from four, the refresh takes the outsider to 0.000 and
leaves the insider at 0.980, and the learned family gives 0.000
throughout.

exp_full.stage_N sweeps the learned family over the same SNR grid at
the same frame count as stage_A, so Fig. 2 can carry both families and
a reader can see what the key space costs at every SNR rather than at
one point.

check_consistency.py gains four assertions for the key-space
measurements and two for the learned sweep, 82 in all.

README: the assertion count was two rounds stale, and the figure map
omitted family_enum, cov_attack and maskdegen, whose CSVs back quoted
manuscript numbers.
2026-08-28 19:30:58 +09:00

904 lines
40 KiB
Python

"""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=256, 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 sys
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)
MAIN_D = 256 # embedding dimension of the main configuration
def get_model(P=4, vu=16, d=MAIN_D, 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 base_keys(U: int, Lp: int) -> torch.Tensor:
"""The structured key family: U non-constant rows of a Walsh-Hadamard
matrix, truncated to Lp entries.
Row 0 of the Sylvester construction is the all-ones vector, which any
adversary can write down without searching, so the users take rows
1..U. The construction exists at power-of-two orders, so for other
key lengths the next power-of-two order is truncated to Lp entries.
That truncation keeps the entries unit modulus and, at every length
the evaluation uses, keeps the rows exactly orthogonal as well; the
measured cross-correlation is reported alongside every sweep point.
Requires U <= Lp - 1 non-constant rows to exist."""
n = 1 << max(math.ceil(math.log2(max(Lp, U + 1))), 1)
H = hadamard(n)
if H.shape[0] - 1 < U:
raise ValueError(f"key length {Lp} admits only {H.shape[0]-1} "
f"non-constant rows, fewer than U={U}")
return torch.tensor(H[1:U + 1, :Lp].copy(), dtype=torch.float32)
def main_model(iters=4000, P=4, vu=16, d=MAIN_D, U=4):
"""The main configuration used by every stage below.
The keys are frozen to the structured Walsh-Hadamard family rather
than learned. Unconstrained mask training converges to disjoint
sparse supports, that is, to an orthogonal slot allocation, which
collapses the superposition into OMA and leaves the key space far
smaller than a dense direction in R^L. The structured family is
dense, exactly orthogonal, and unit modulus, which is also the
condition the key-refresh invariance argument requires."""
return get_model(P=P, vu=vu, d=d, U=U, iters=iters,
freeze_W=base_keys(U, d // P))
def stage_N():
"""Fig. 2's learned-family curves.
The structured family is enumerable and closed under the elementwise
product, the learned one is neither, so the paper reports both. This
stage runs the same SNR sweep as stage_A with the keys trained in
R^L instead of frozen to Walsh-Hadamard rows, at the same frame
count, so the two are directly comparable.
"""
print("[N] security vs SNR, learned key family ...")
m = get_model(P=4, vu=16, d=MAIN_D, U=4, iters=4000, seed=1)
snr = [float(v) for v in range(0, 21, 2)]
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)
write_csv(DATA / "sec_snr_learned.csv",
["snr_db", "legit", "eve_wrong"],
[(s, legit[i], eve_w[i]) for i, s in enumerate(snr)])
print(" legit:", [f"{v:.2e}" for v in legit])
def stage_A():
print("[A] security vs SNR (V=65536) ...")
m = main_model()
snr = [float(v) for v in range(0, 21, 2)]
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_keylen(m.L, s, bits=int(math.log2(m.V))) for s in snr]
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=MAIN_D, 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 oma_ser_keylen(L, snr_db, bits=16, n_grid=200_000):
"""Resource-matched OMA reference for the key-length sweep.
The OMA user owns d/U = L exclusive real dimensions for its 16 index
bits at the same per-dimension SNR. For L >= 16 the best use of the
allocation is antipodal signaling on 16 dimensions with the frame
energy concentrated on them, an energy gain of L/16 per bit. For
L < 16 the user must pack 16/L bits per dimension, a 2^(16/L)-ary
pulse-amplitude constellation, defined when 16/L is an integer and
reported as nan otherwise.
"""
import numpy as np
if L >= bits:
return oma_ser([snr_db + 10.0 * math.log10(L / bits)], bits=bits)[0]
if bits % L:
return float("nan")
M = 2 ** (bits // L)
# M-PAM levels +-A, +-3A, ..., +-(M-1)A with unit AVERAGE symbol energy
# give A^2 = 3/(M^2-1), so the distance to the decision boundary is A
# and the Q-function argument is h*sqrt(3*g/(M^2-1)). Using 6 instead
# of 3 would assume an average energy of two per dimension.
x = (np.arange(n_grid) + 0.5) / n_grid
h = np.sqrt(-np.log(1.0 - x))
g = 10.0 ** (snr_db / 10.0)
arg = np.clip(h * math.sqrt(3.0 * g / (M * M - 1.0)), 0, 38)
q = (1.0 - 1.0 / M) * np.array([math.erfc(v / math.sqrt(2.0))
for v in arg])
q = np.clip(q, 0.0, 1.0)
return float(np.mean(1.0 - (1.0 - q) ** L))
def stage_B():
print("[B] key length (dense grid so the curve is smooth) ...")
rows = []
# L = d/P. Lengths 6, 10 and 14 are dropped because the
# truncated Walsh-Hadamard rows are not exactly orthogonal
# there, and L=4 admits only three non-constant rows for
# U=4 users.
for d in [32, 48, 64, 80, 96, 128, 192, 256]:
m = main_model(d=d) # same structured family as Fig. 2
lg = eval_ser_sse(m, [10.0], frames=500_000)[0]
# Proposition 1 is a statement about the substitute-key ENSEMBLE, so
# the eavesdropper is averaged over eight draws. A single draw makes
# the curve jump wherever one key happens to land luckily, which is
# a property of that draw and not of the key length.
ev = sum(eval_ser_eve(
m, eve_wrong_mask(m.users, m.L,
seed=20260813 + 101 * k).to(DEVICE),
[10.0], frames=500_000 // 8)[0]
for k in range(8)) / 8.0
xc = mean_abs_xcorr(m.masks().detach())
oma = oma_ser_keylen(m.L, 10.0)
rows.append((m.L, d, lg, ev, xc, oma))
print(f" L={m.L:4d} legit={lg:.2e} eve={ev:.3f} xcorr={xc:.4f} "
f"oma={oma:.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 = main_model()
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, MAIN_D, 4
Lp = d // P
fams = {}
# random fixed masks
set_seed(7); fams["random"] = random_mask(U, Lp)
# Walsh-Hadamard rows (orthogonal). Row 0 of the Sylvester
# construction is the all-ones vector, which any adversary can write
# down, so it is excluded and the users take rows 1 to U.
Hd = base_keys(U, Lp) # the main configuration's key family
fams["hadamard"] = Hd
ones = torch.ones(U, Lp) # the cheapest possible guess
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]
ev1 = eval_ser_eve(m, ones, [10.0], frames=500_000)[0]
xc = mean_abs_xcorr(m.masks().detach())
rows.append((name, lg, ev, ev1, xc))
print(f" {name:9s} legit={lg:.2e} eve={ev:.3f} ones={ev1:.3f} "
f"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]
ev1 = eval_ser_eve(m, ones, [10.0], frames=500_000)[0]
xc = mean_abs_xcorr(m.masks().detach())
rows.append(("learned", lg, ev, ev1, xc))
print(f" {'learned':9s} legit={lg:.2e} eve={ev:.3f} ones={ev1:.3f} "
f"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]
evr1 = eval_ser_eve(mr, ones, [10.0], frames=500_000)[0]
xcr = mean_abs_xcorr(mr.masks().detach())
rows.append(("learned_reg", lgr, evr, evr1, xcr))
print(f" {'learn_reg':9s} legit={lgr:.2e} eve={evr:.3f} ones={evr1:.3f} "
f"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", "eve_ones_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 = main_model()
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
lg5 = oma_ser_keylen(m.L, 10.0, bits=int(math.log2(m.V)))
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_perms=None):
"""Eve for S3: sees the per-user permuted tx, holds the PUBLIC masks
but not the permutation, decodes user 0 raw. When eve_perms is given,
Eve first undoes the permutation she believes was used, which models
an attacker holding a partially recovered permutation key."""
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)
if eve_perms is not None:
inv = torch.argsort(eve_perms[0]).to(r.device)
r = r.reshape(n, d)[:, inv].reshape(n, model.P, model.L)
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 = main_model()
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 partial_perm(true_perm: torch.Tensor, frac: float, gen: torch.Generator):
"""A permutation that agrees with true_perm on a fraction frac of the
positions and is scrambled on the rest, which is what an attacker
holding part of a permutation key would have."""
d = true_perm.numel()
k = int(round(frac * d))
idx = torch.randperm(d, generator=gen)
keep, rest = idx[:k], idx[k:]
out = true_perm.clone()
if rest.numel() > 1:
out[rest] = true_perm[rest][torch.randperm(rest.numel(), generator=gen)]
return out
TRIALS_PERM = 60
def stage_I():
"""Key sensitivity of three schemes on one axis.
The axis is the fraction of the key the attacker has recovered. For
the proposed scheme that fraction is the normalized correlation
between the guessed and the true mask. For the permutation scheme it
is the fraction of positions the guessed permutation places
correctly. For the index cipher it is the fraction of pad bits the
attacker knows. Its error rate is the closed form
1 - (1-p_ch) 2^{-(1-f) log2 V}, the probability of decoding the
ciphered index over the channel times the probability that the
unknown pad bits, which stay uniform, are all guessed right.
"""
print("[I] key sensitivity across schemes ...")
m = main_model()
F = 600_000 # more frames per point for a smooth curve
TRIALS_MASK = 12 # independent substitute keys per point
d = m.P * m.L
true_m = m.masks().detach().cpu()
gen = torch.Generator().manual_seed(31)
gp = torch.Generator().manual_seed(11)
gperm = torch.randperm(d, generator=gp)
perms = gperm[None].repeat(m.users, 1)
# a marker grid comparable to the other result figures, with the
# spacing tightened only where the curves fall
fracs = [0.0, 0.2, 0.4, 0.6, 0.75, 0.85, 0.9, 0.92, 0.94, 0.955,
0.97, 0.985, 1.0]
bits = math.log2(m.V)
# the channel success of a public-mask receiver, which the cipher
# cannot exceed even with the full pad
lg1 = eval_scheme(m, 10.0, 200_000)
rows = []
for f in fracs:
acc_m = []
for t in range(TRIALS_MASK):
mt = correlated_masks(true_m, f, gen)
acc_m.append(eval_ser_eve(m, mt, [10.0],
frames=F // TRIALS_MASK,
seed=777 + 17 * t)[0])
ser_mask = sum(acc_m) / len(acc_m)
# a partial permutation is combinatorially lumpy, so the point
# is averaged over independent draws of which positions the
# attacker holds
acc = []
for t in range(TRIALS_PERM):
pp = partial_perm(gperm, f, gen)
pperms = pp[None].repeat(m.users, 1)
acc.append(eval_scheme_permuted_eve(m, 10.0, F // TRIALS_PERM,
perms, eve_perms=pperms,
seed=777 + 13 * t))
ser_perm = sum(acc) / len(acc)
ser_pad = 1.0 - (1.0 - lg1) * 2.0 ** (-(1.0 - f) * bits)
rows.append((f, ser_mask, ser_perm, ser_pad))
print(f" f={f:.3f} mask={ser_mask:.4f} perm={ser_perm:.4f} "
f"pad={ser_pad:.4f}")
write_csv(DATA / "sec_sens_cmp.csv",
["frac", "ser_mask", "ser_perm", "ser_pad"], rows)
def stage_J():
"""Brute-force search against three schemes at the same key length.
Keyed masking: K random unit keys, keep the best correlation, map it
through the measured sensitivity curve of stage I.
Permutation key: K random permutations of the d positions, keep the
one that places the most positions correctly, map the resulting
fraction through the same sensitivity curve.
Index cipher: K random pads out of the 2^{log2 V} possible pads, so
the attacker holds the right pad with probability K/V and still has
to decode the ciphered index over the channel.
"""
print("[J] brute-force search across schemes ...")
import numpy as np
cmp_rows = list(csv_rows(DATA / "sec_sens_cmp.csv"))
f_arr = np.array([float(r["frac"]) for r in cmp_rows])
mask_arr = np.array([float(r["ser_mask"]) for r in cmp_rows])
perm_arr = np.array([float(r["ser_perm"]) for r in cmp_rows])
d, L, V = MAIN_D, MAIN_D // 4, 65536
# channel floor of the cipher receiver, read from the stage-I curve
# at a fully known pad so both figures share one source
lg1 = 1.0 - (1.0 - float(cmp_rows[-1]["ser_pad"]))
ks = [1, 3, 10, 30, 100, 300, 1_000, 3_000, 10_000, 30_000, 65_536,
100_000, 300_000, 1_000_000]
rng = np.random.default_rng(2026)
trials = 400
rows = []
for K in ks:
# keyed masking: best |first coordinate| of K random unit vectors
best_kappa = np.empty(trials)
best_frac = np.empty(trials)
for t in range(trials):
# |first coordinate| of a uniform random unit vector in R^L:
# its square is Beta(1/2, (L-1)/2), so the best of K draws
# needs K scalars rather than K*L Gaussians
best_kappa[t] = np.sqrt(rng.beta(0.5, (L - 1) / 2.0,
size=K).max())
# permutation: fraction of fixed points, Binomial(d, 1/d) per
# draw, so the best of K draws is the max of K such counts
best_frac[t] = rng.binomial(d, 1.0 / d, size=K).max() / d
ser_mask = float(np.mean(np.interp(best_kappa, f_arr, mask_arr)))
ser_perm = float(np.mean(np.interp(best_frac, f_arr, perm_arr)))
ser_pad = 1.0 - min(1.0, K / V) * (1.0 - lg1)
rows.append((K, ser_mask, ser_perm, ser_pad,
float(best_kappa.mean()), float(best_frac.mean())))
print(f" K={K:8d} mask={ser_mask:.4f} perm={ser_perm:.4f} "
f"pad={ser_pad:.4f}")
write_csv(DATA / "sec_brute_cmp.csv",
["K", "ser_mask", "ser_perm", "ser_pad",
"best_kappa", "best_frac"], rows)
def csv_rows(path):
import csv as _csv
with open(path) as f:
yield from _csv.DictReader(f)
def oma_ser_jammed(snr_db, jsr_db_list, bits=16, U=4, d=256, n_grid=4096):
"""OMA under a jammer that concentrates on the victim's slots.
An OMA user occupies L = d/U exclusive real dimensions that are
public, and repeats each of its 16 index bits over L/bits of them,
combining coherently for an amplitude gain of sqrt(L/bits) per bit.
This spread allocation is the configuration that serves the OMA user
best under a jammer, so it is the one the comparison grants it. A
jammer needs no key to find those public dimensions, but it must
cover all L of them. With unit energy per real dimension and a total
jammer energy of rho times the frame energy, spreading over L of the
d dimensions gives a per-dimension jammer variance of (d/L)*rho,
which is U*rho.
The jammer reaches the victim through its own Rayleigh channel, the
same convention eval_scheme uses for every simulated scheme, so the
victim sees an effective noise variance of 1/snr + U*rho*hJ**2 with
E[hJ**2]=1. Averaging over the independent signal and jammer gains
uses a product of exponential quantile grids.
"""
q = (torch.arange(n_grid, dtype=torch.float64) + 0.5) / n_grid
h2 = -torch.log1p(-q) # |h|^2 ~ Exp(1)
hj2 = h2.clone() # |hJ|^2 ~ Exp(1), independent
h = h2.sqrt()[:, None] # (n,1) signal amplitude
snr = 10.0 ** (snr_db / 10.0)
gain = math.sqrt((d / U) / bits) # antipodal amplitude
out = []
for jsr_db in jsr_db_list:
rho = 10.0 ** (jsr_db / 10.0)
var = (1.0 / snr + U * rho * hj2)[None, :] # (1,n)
arg = (h * gain / var.sqrt()).clamp(0, 38)
pe = 0.5 * torch.erfc(arg / math.sqrt(2.0)) # per-bit error
out.append(float((1.0 - (1.0 - pe) ** bits).mean()))
return out
def stage_M():
"""Why the permutation-key scheme shares one permutation.
The manuscript asserts that a per-user permutation breaks the trained
separation, which is the reason the compared scheme is granted a
shared one. That assertion needs a measurement of its own.
"""
print("[M] shared against per-user permutation ...")
m = main_model()
d = m.P * m.L
F = 200_000
g = torch.Generator().manual_seed(11)
shared = torch.randperm(d, generator=g)[None].repeat(m.users, 1)
peruser = torch.stack([torch.randperm(d, generator=g)
for _ in range(m.users)])
rows = [("shared", eval_scheme(m, 10.0, F, perms=shared)),
("per_user", eval_scheme(m, 10.0, F, perms=peruser))]
for k, v in rows:
print(" %-9s legit=%.4f" % (k, v))
write_csv(DATA / "perm_variant.csv", ["variant", "legit_ser"], rows)
def stage_L():
"""Jamming comparison across schemes at 10 dB.
proposed blind : the strongest jammer the proposed scheme admits
while the key stays secret
public matched : the jammer a public-mask scheme always faces
permutation blind: the shuffling-style scheme, whose secret
permutation also denies the jammer a target
OMA targeted : the jammer an orthogonal scheme faces, since its
slot assignment is public and needs no key
"""
print("[L] jamming across schemes ...")
m = main_model()
F = 300_000
d = m.P * m.L
gp = torch.Generator().manual_seed(11)
perms = torch.randperm(d, generator=gp)[None].repeat(m.users, 1)
jsr = [float(v) for v in range(-10, 21, 2)]
oma = oma_ser_jammed(10.0, jsr, bits=int(math.log2(m.V)),
U=m.users, d=m.d)
rows = []
for i, j in enumerate(jsr):
blind = eval_scheme(m, 10.0, F, jam_w="blind", jsr_db=j)
matched = eval_scheme(m, 10.0, F, jam_w="matched", jsr_db=j)
perm = eval_scheme(m, 10.0, F, perms=perms, jam_w="blind", jsr_db=j)
rows.append((j, blind, matched, perm, oma[i]))
print(f" JSR={j:6.1f} blind={blind:.4f} matched={matched:.4f} "
f"perm={perm:.4f} oma={oma[i]:.4f}")
write_csv(DATA / "sec_jam_cmp.csv",
["jsr_db", "blind", "matched", "perm_blind", "oma_targeted"],
rows)
stage_L_gap(rows)
def stage_L_gap(rows):
"""Store the blind-vs-matched power gap as a raw artifact.
For every error level both curves reach, the gap is the extra JSR the
blind jammer needs to inflict it. Both curves are interpolated on the
dense grid, so the quoted range comes from a stored file rather than
from a hand interpolation.
"""
import numpy as np
j = np.array([r[0] for r in rows])
blind = np.array([r[1] for r in rows])
matched = np.array([r[2] for r in rows])
lo = max(blind.min(), matched.min())
hi = min(blind.max(), matched.max())
ser = np.linspace(lo, hi, 200)
jb = np.interp(ser, blind, j)
jm = np.interp(ser, matched, j)
gap = jb - jm
write_csv(DATA / "sec_jam_gap.csv", ["ser", "gap_db"],
list(zip(ser.tolist(), gap.tolist())))
print(f" gap: {gap.min():.2f} to {gap.max():.2f} dB "
f"over SER {lo:.3f} to {hi:.3f}")
CHAIN = ["stage_A", "stage_B", "stage_C", "stage_D", "stage_E", "stage_F",
"stage_I", "stage_J", "stage_L", "stage_M", "stage_N"]
def main(names=None):
"""Run the named stages, or the whole chain when none are named.
The README maps every figure and table to its stage, so a reader
reproducing one figure runs that stage alone:
python code/exp_full.py stage_B
"""
print(f"device={DEVICE}")
todo = names or CHAIN
unknown = [n for n in todo if n not in CHAIN]
if unknown:
raise SystemExit("unknown stage(s): %s\nknown: %s"
% (", ".join(unknown), ", ".join(CHAIN)))
for n in todo:
globals()[n]()
print("[done] security CSVs in", DATA)
if __name__ == "__main__":
main(sys.argv[1:])