Audit round: fair OMA reference, dense grids, covariance-attack checks
Resource-match the OMA reference in the key-length sweep (oma_ser_keylen), which gives it the L/16 combining gain the longer frame allows. The proposal now passes a resource-matched OMA by 1.27x at L=64 rather than the 4.3x reported against a fixed-d reference. Densify the JSR, sensitivity, and brute-force grids so the curves are smooth, give the index cipher its channel floor instead of error-free reception, and add the permutation-key known-plaintext attack (exp_permkpa) so Fig. 7 carries a conventional linear scheme. Add check_cov_attack.py and check_cov_ceiling.py: a referee raised a ciphertext-only second-order attack; the exact-population test shows the received covariance leaks only a sparse rank-deficient subset of the key Gram and leaves the eavesdropper at the random-guess level. Dump verify_math.csv, move the superseded V=256 pilot CSVs to data/pilot.
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
"""Independent check of the ciphertext-only second-order attack (audit M1).
|
||||
|
||||
Claim under test: an eavesdropper who observes only received frames (no
|
||||
known indices) can estimate the key Gram matrix M^T M from the sample
|
||||
covariance, because per period
|
||||
E[y_k y_l] = (1/c^2) * E[h^2] * C_kl * (M^T M)_kl,
|
||||
where C_kl = (1/Vu) sum_i b_{i,k} b_{i,l} is the PUBLIC codebook column
|
||||
correlation and the noise touches only the diagonal.
|
||||
|
||||
Procedure, using nothing the threat model keeps secret:
|
||||
1. collect N received frames y_n = h_n * (1/c) sum_u e_{s_u} ⊙ m_u + noise
|
||||
2. form the per-period sample second moment S_kl = mean_n y_{n,k} y_{n,l}
|
||||
3. divide the off-diagonal by C_kl (public) to get G_hat ≈ M^T M
|
||||
4. set the diagonal of G_hat to U (unit-modulus keys)
|
||||
5. factor G_hat = M_hat^T M_hat (rank U), then for Walsh-Hadamard keys
|
||||
round to ±1 and search the 2^U U! signed permutations, keeping the
|
||||
M_hat that best decodes a handful of the collected frames
|
||||
6. report the recovered-entry fraction and the eavesdropper SER, both
|
||||
WITHOUT ever using a known index
|
||||
|
||||
Run under WSL. Prints a verdict; writes nothing to data/.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import itertools
|
||||
import math
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
from sse_lib import rayleigh_gain, DEVICE
|
||||
from exp_full import get_model, hadamard, eval_ser_eve
|
||||
|
||||
|
||||
def collect_frames(m, n, snr_db, seed):
|
||||
"""Received frames and the true indices (indices kept only for scoring)."""
|
||||
g = torch.Generator().manual_seed(seed)
|
||||
Bn = m.unit_codebook()
|
||||
true_m = m.masks()
|
||||
c = m.c
|
||||
sigma = math.sqrt(1.0 / (m.d * 10.0 ** (snr_db / 10.0)))
|
||||
digits = torch.randint(m.vu, (n, m.users, m.P), generator=g).to(DEVICE)
|
||||
e = Bn[digits] / math.sqrt(m.P) # (n,U,P,L)
|
||||
y = (e * true_m[None, :, None, :]).sum(dim=1) / c # (n,P,L)
|
||||
h = rayleigh_gain((n,), device=DEVICE)
|
||||
y = h[:, None, None] * y + sigma * torch.randn(n, m.P, m.L, device=DEVICE)
|
||||
return y, digits, Bn, true_m
|
||||
|
||||
|
||||
def codebook_corr(Bn):
|
||||
"""Public column correlation C_kl = (1/Vu) sum_i b_ik b_il."""
|
||||
return (Bn.T @ Bn) / Bn.shape[0] # (L,L)
|
||||
|
||||
|
||||
def attack(m, snr_db, n_frames, seed):
|
||||
y, digits, Bn, true_m = collect_frames(m, n_frames, snr_db, seed)
|
||||
U, L = m.users, m.L
|
||||
yf = y.reshape(-1, L) # pool all periods
|
||||
S = (yf.T @ yf) / yf.shape[0] # (L,L) 2nd moment
|
||||
C = codebook_corr(Bn) # public
|
||||
G = torch.zeros(L, L, device=DEVICE)
|
||||
mask = C.abs() > 1e-3
|
||||
G[mask] = S[mask] / C[mask] # ≈ (1/c^2) M^T M
|
||||
scale = float(torch.diagonal(G)[mask.diagonal()].mean()) / U
|
||||
G = G / max(scale, 1e-9) # normalize so diag≈U
|
||||
G.fill_diagonal_(float(U)) # unit-modulus keys
|
||||
# symmetric rank-U factor
|
||||
G = 0.5 * (G + G.T)
|
||||
evals, evecs = torch.linalg.eigh(G)
|
||||
idx = torch.argsort(evals, descending=True)[:U]
|
||||
root = evecs[:, idx] * evals[idx].clamp_min(0).sqrt()
|
||||
Mhat0 = root.T # (U,L), up to U×U orth
|
||||
# for WH keys, snap to ±1 and search signed row permutations
|
||||
cand = torch.sign(Mhat0)
|
||||
cand[cand == 0] = 1.0
|
||||
best = None
|
||||
best_ser = 1.0
|
||||
val = torch.arange(min(2000, n_frames))
|
||||
for perm in itertools.permutations(range(U)):
|
||||
for signs in itertools.product([1.0, -1.0], repeat=U):
|
||||
Mh = (cand[list(perm)] *
|
||||
torch.tensor(signs, device=DEVICE)[:, None])
|
||||
ser = eval_ser_eve(m, Mh.cpu(), [snr_db], frames=20_000,
|
||||
seed=13)[0]
|
||||
if ser < best_ser:
|
||||
best_ser, best = ser, Mh
|
||||
# recovered-entry fraction against the true keys (best sign-aligned)
|
||||
tm = torch.sign(true_m).to(DEVICE)
|
||||
frac = 0.0
|
||||
for perm in itertools.permutations(range(U)):
|
||||
for signs in itertools.product([1.0, -1.0], repeat=U):
|
||||
Mh = (best[list(perm)] *
|
||||
torch.tensor(signs, device=DEVICE)[:, None])
|
||||
frac = max(frac, float((Mh == tm).float().mean()))
|
||||
return frac, best_ser
|
||||
|
||||
|
||||
def main():
|
||||
U, L = 4, 16
|
||||
K0 = torch.tensor(hadamard(L)[1:U + 1], dtype=torch.float32)
|
||||
m = get_model(iters=4000, freeze_W=K0)
|
||||
m.eval()
|
||||
chance = 1.0 - (1.0 / m.vu) ** m.P
|
||||
print(f"chance SER = {chance:.5f}, legitimate reference ~0.276")
|
||||
print("ciphertext-only (NO known plaintext):")
|
||||
for snr in (10.0, 20.0):
|
||||
for nf in (300, 1000, 10000):
|
||||
frac, ser = attack(m, snr, nf, seed=1234 + nf)
|
||||
print(f" {snr:4.0f} dB N={nf:6d} "
|
||||
f"key-entry recovery={frac:.3f} eve SER={ser:.4f}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Decisive noiseless population test of the covariance attack (audit M1).
|
||||
|
||||
If the second-order statistics leak the key Gram, they leak it best in
|
||||
the noiseless infinite-sample limit. This computes the EXACT per-period
|
||||
second moment E[y_k y_l] over the uniform index distribution with no
|
||||
channel and no noise, then runs the same recovery, and asks whether the
|
||||
keys come out. If they do not come out even here, no finite noisy attack
|
||||
can do better and the leak is not exploitable against this codebook.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import itertools
|
||||
import math
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
from sse_lib import DEVICE
|
||||
from exp_full import get_model, hadamard, eval_ser_eve
|
||||
|
||||
|
||||
def main():
|
||||
U, L = 4, 16
|
||||
K0 = torch.tensor(hadamard(L)[1:U + 1], dtype=torch.float32)
|
||||
m = get_model(iters=4000, freeze_W=K0)
|
||||
m.eval()
|
||||
Bn = m.unit_codebook().to(DEVICE) # (Vu,L)
|
||||
true_m = m.masks().to(DEVICE) # (U,L)
|
||||
c = m.c
|
||||
|
||||
# exact population second moment of one period, indices uniform
|
||||
# y_k = (1/c) sum_u e_{s_u,k} m_{u,k}, s_u iid uniform over Vu
|
||||
mu = Bn.mean(0) # codebook column mean
|
||||
R = (Bn.T @ Bn) / Bn.shape[0] # E[e_k e_l], (L,L)
|
||||
G_true = true_m.T @ true_m # (L,L) key Gram, the target
|
||||
# E[y_k y_l] = (1/c^2)[ R_kl (M^TM)_kl + (mu_k mu_l)(rowsum_k rowsum_l
|
||||
# - diag correction) ]; assemble exactly
|
||||
rs = true_m.sum(0) # sum_u m_{u,k}
|
||||
cross = torch.outer(rs, rs) - G_true # sum_{u!=v} m_uk m_vl
|
||||
S = (R * G_true + torch.outer(mu, mu) * cross) / (c * c)
|
||||
|
||||
print(f"codebook column mean |mu|_max = {mu.abs().max():.4f}")
|
||||
offdiag = R - torch.diag(torch.diagonal(R))
|
||||
print(f"codebook R off-diagonal: max|R_kl| = {offdiag.abs().max():.4f}, "
|
||||
f"mean|R_kl| = {offdiag.abs().mean():.4f}")
|
||||
|
||||
# recover G from S using the public R (exactly the attack)
|
||||
C = R
|
||||
keep = C.abs() > 1e-2
|
||||
Ghat = torch.zeros(L, L, device=DEVICE)
|
||||
Ghat[keep] = S[keep] * (c * c) / C[keep]
|
||||
# how well does the off-diagonal of Ghat match the true key Gram?
|
||||
od = ~torch.eye(L, dtype=torch.bool, device=DEVICE)
|
||||
usable = keep & od
|
||||
if usable.any():
|
||||
err = (Ghat[usable] - G_true[usable]).abs().mean()
|
||||
rng = G_true[od].abs().mean()
|
||||
print(f"usable off-diagonal entries: {int(usable.sum())} of {L*(L-1)}")
|
||||
print(f"recovered-Gram error on usable entries: {err:.4f} "
|
||||
f"(true off-diag scale {rng:.4f})")
|
||||
else:
|
||||
print("no usable off-diagonal entries: R is diagonal, zero leak")
|
||||
|
||||
# try to factor and decode from the exact-population Ghat
|
||||
Ghat[~keep] = 0.0
|
||||
Ghat.fill_diagonal_(float(U))
|
||||
Ghat = 0.5 * (Ghat + Ghat.T)
|
||||
ev, evec = torch.linalg.eigh(Ghat)
|
||||
idx = torch.argsort(ev, descending=True)[:U]
|
||||
root = (evec[:, idx] * ev[idx].clamp_min(0).sqrt()).T
|
||||
cand = torch.sign(root)
|
||||
cand[cand == 0] = 1.0
|
||||
best = 1.0
|
||||
for perm in itertools.permutations(range(U)):
|
||||
for sg in itertools.product([1.0, -1.0], repeat=U):
|
||||
Mh = cand[list(perm)] * torch.tensor(sg, device=DEVICE)[:, None]
|
||||
best = min(best, eval_ser_eve(m, Mh.cpu(), [10.0],
|
||||
frames=20_000, seed=13)[0])
|
||||
print(f"best eavesdropper SER from EXACT population covariance: {best:.4f}")
|
||||
print("chance 0.99998, legitimate ~0.276")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+77
-13
@@ -152,7 +152,7 @@ def get_model(P=4, vu=16, d=64, U=4, iters=4000, seed=1, freeze_W=None, tag=""):
|
||||
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]
|
||||
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)
|
||||
@@ -209,18 +209,46 @@ def get_model_reg(P=4, vu=16, d=64, U=4, iters=4000, seed=1):
|
||||
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)
|
||||
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(6.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) ...")
|
||||
oma10 = oma_ser([10.0], bits=16)[0]
|
||||
rows = []
|
||||
for d in [16, 24, 32, 48, 64, 96, 128, 192, 256]:
|
||||
for d in [16, 24, 32, 40, 48, 56, 64, 80, 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}")
|
||||
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)
|
||||
|
||||
@@ -548,8 +576,10 @@ def stage_I():
|
||||
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, whose error rate is the closed form
|
||||
1 - 2^{-(1-f) log2 V} because the unknown bits are uniform.
|
||||
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 = get_model(iters=4000)
|
||||
@@ -563,8 +593,12 @@ def stage_I():
|
||||
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.94, 0.97, 1.0]
|
||||
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 = []
|
||||
@@ -585,7 +619,7 @@ def stage_I():
|
||||
perms, eve_perms=pperms,
|
||||
seed=777 + 13 * t))
|
||||
ser_perm = sum(acc) / len(acc)
|
||||
ser_pad = 1.0 - 2.0 ** (-(1.0 - f) * bits)
|
||||
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}")
|
||||
@@ -602,7 +636,8 @@ def stage_J():
|
||||
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 succeeds with probability K/V on each symbol.
|
||||
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
|
||||
@@ -612,7 +647,11 @@ def stage_J():
|
||||
perm_arr = np.array([float(r["ser_perm"]) for r in cmp_rows])
|
||||
|
||||
d, L, V = 64, 16, 65536
|
||||
ks = [1, 10, 100, 1_000, 10_000, 100_000, 1_000_000]
|
||||
# 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 = []
|
||||
@@ -629,7 +668,7 @@ def stage_J():
|
||||
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)
|
||||
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} "
|
||||
@@ -692,7 +731,7 @@ def stage_L():
|
||||
d = m.P * m.L
|
||||
gp = torch.Generator().manual_seed(11)
|
||||
perms = torch.randperm(d, generator=gp)[None].repeat(m.users, 1)
|
||||
jsr = [-10.0, -5.0, 0.0, 5.0, 10.0, 15.0, 20.0]
|
||||
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)
|
||||
rows = []
|
||||
for i, j in enumerate(jsr):
|
||||
@@ -705,6 +744,31 @@ def stage_L():
|
||||
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}")
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Known-plaintext attack on the global-permutation key (run under WSL).
|
||||
|
||||
The permutation scheme keeps the masks public and protects the frame
|
||||
with one secret permutation of the d entries shared by all users. Like
|
||||
the keyed masking, the protection is linear, so an attacker that knows
|
||||
the indices a few frames carried can recover the secret. This stage
|
||||
measures how many known frames the recovery needs, mirroring the grid
|
||||
of exp_kpa.py so the two curves share one figure.
|
||||
|
||||
Attack: with N known frames the attacker knows the pre-permutation
|
||||
signal x_n and observes y_n = h_n * perm(x_n) + noise at the collection
|
||||
SNR. The cross-correlation matrix C[i, j] = sum_n y_n[i] x_n[j] peaks at
|
||||
j = perm(i) because h_n > 0, so the permutation is the assignment that
|
||||
maximizes the total correlation, solved by the Hungarian method. The
|
||||
recovered permutation then decodes user 1 at 10 dB, the convention of
|
||||
exp_kpa.py.
|
||||
|
||||
Writes data/pkpa.csv. Fixed seeds: permutation 11 (the stage-I secret),
|
||||
collection 909.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import math
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
from sse_lib import write_csv, set_seed, DATA, DEVICE
|
||||
from exp_full import get_model, eval_scheme_permuted_eve, rayleigh_gain
|
||||
|
||||
try:
|
||||
from scipy.optimize import linear_sum_assignment
|
||||
except ImportError: # greedy fallback
|
||||
def linear_sum_assignment(cost):
|
||||
c = cost.copy()
|
||||
n = c.shape[0]
|
||||
rows = np.empty(n, dtype=int)
|
||||
cols = np.empty(n, dtype=int)
|
||||
for k in range(n):
|
||||
i, j = np.unravel_index(np.argmin(c), c.shape)
|
||||
rows[k], cols[k] = i, j
|
||||
c[i, :] = np.inf
|
||||
c[:, j] = np.inf
|
||||
order = np.argsort(rows)
|
||||
return rows[order], cols[order]
|
||||
|
||||
COLLECT_DB = 20.0
|
||||
DECODE_DB = 10.0
|
||||
TRIALS = 20
|
||||
EVAL_FRAMES = 100_000
|
||||
|
||||
|
||||
def main():
|
||||
m = get_model(iters=4000) # training needs grad
|
||||
m.eval()
|
||||
_run(m)
|
||||
|
||||
|
||||
@torch.no_grad()
|
||||
def _run(m):
|
||||
d = m.P * m.L
|
||||
Bn = m.unit_codebook()
|
||||
true_m = m.masks()
|
||||
c = m.c
|
||||
gp = torch.Generator().manual_seed(11)
|
||||
gperm = torch.randperm(d, generator=gp)
|
||||
perms = gperm[None].repeat(m.users, 1)
|
||||
sigma = math.sqrt(1.0 / (d * 10.0 ** (COLLECT_DB / 10.0)))
|
||||
|
||||
print(f"[P] permutation known-plaintext, collect {COLLECT_DB:.0f} dB, "
|
||||
f"decode {DECODE_DB:.0f} dB ...")
|
||||
rows = []
|
||||
for nf in [1, 2, 3, 4, 5, 6, 8, 10, 12, 16, 24, 32, 48, 64]:
|
||||
fr, sr = [], []
|
||||
for t in range(TRIALS):
|
||||
g = torch.Generator().manual_seed(909 + 1000 * t + nf)
|
||||
digits = torch.randint(m.vu, (nf, m.users, m.P), generator=g)
|
||||
e = Bn[digits.to(DEVICE)] / math.sqrt(m.P)
|
||||
x = (e * true_m[None, :, None, :]).sum(dim=1) / c # (nf,P,L)
|
||||
xf = x.reshape(nf, d)
|
||||
h = rayleigh_gain((nf,), device=DEVICE)
|
||||
noise = sigma * torch.randn(nf, d, device=DEVICE)
|
||||
yf = h[:, None] * xf[:, gperm.to(DEVICE)] + noise
|
||||
C = (yf.T @ xf).cpu().numpy() # (d,d)
|
||||
_, est = linear_sum_assignment(-C)
|
||||
est_t = torch.tensor(est, dtype=torch.long)
|
||||
fr.append(float((est_t == gperm).float().mean()))
|
||||
sr.append(eval_scheme_permuted_eve(
|
||||
m, DECODE_DB, EVAL_FRAMES, perms,
|
||||
eve_perms=est_t[None].repeat(m.users, 1),
|
||||
seed=777 + 31 * t))
|
||||
frac = sum(fr) / len(fr)
|
||||
ser = sum(sr) / len(sr)
|
||||
rows.append((nf, frac, ser))
|
||||
print(f" N={nf:3d} frac={frac:.4f} eve={ser:.4f}")
|
||||
write_csv(DATA / "pkpa.csv", ["n_frames", "perm_frac", "eve_ser"], rows)
|
||||
print("[done] pkpa.csv")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -166,7 +166,7 @@ def main():
|
||||
print("[Q1] Eve no-mask SER:", [f"{v:.3g}" for v in eve_n])
|
||||
print(f"[Q1] chance frame SER = {chance_frame:.4f}")
|
||||
|
||||
write_csv(DATA / "feas_q1_eavesdrop.csv",
|
||||
write_csv(DATA / "pilot" / "feas_q1_eavesdrop.csv",
|
||||
["snr_db", "legit", "eve_wrong", "eve_none", "eve_avg", "chance"],
|
||||
[(s, legit[i], eve_w[i], eve_n[i], eve_a[i], chance_frame)
|
||||
for i, s in enumerate(snr_eval)])
|
||||
@@ -187,7 +187,7 @@ def main():
|
||||
xc = mean_abs_cross_corr(mdl.masks().detach().cpu())
|
||||
q2_rows.append((mdl.L, d, lg, ev, xc))
|
||||
print(f" L={mdl.L:4d} legit={lg:.3g} eve={ev:.3g} |xcorr|={xc:.3f}")
|
||||
write_csv(DATA / "feas_q2_keyentropy.csv",
|
||||
write_csv(DATA / "pilot" / "feas_q2_keyentropy.csv",
|
||||
["L", "d", "legit_ser", "eve_ser", "mask_xcorr"], q2_rows)
|
||||
|
||||
# Q3: jamming robustness at SNR=10 dB
|
||||
@@ -197,7 +197,7 @@ def main():
|
||||
jam_rd = eval_ser_jam(model, 10.0, jsr, frames=300_000, mode="random")
|
||||
print("[Q3] aligned-jammer SER:", [f"{v:.3g}" for v in jam_al])
|
||||
print("[Q3] random-jammer SER:", [f"{v:.3g}" for v in jam_rd])
|
||||
write_csv(DATA / "feas_q3_jamming.csv",
|
||||
write_csv(DATA / "pilot" / "feas_q3_jamming.csv",
|
||||
["jsr_db", "ser_aligned", "ser_random"],
|
||||
[(j, jam_al[i], jam_rd[i]) for i, j in enumerate(jsr)])
|
||||
|
||||
|
||||
+70
-67
@@ -3,20 +3,22 @@ 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 and outsider SER vs SNR (Fig. 2)
|
||||
fig_sec_snr.pdf : legitimate and 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, four schemes (Fig. 4)
|
||||
fig_sec_sens.pdf : outsider SER vs fraction of key held (Fig. 5)
|
||||
fig_sec_brute.pdf : outsider SER vs number of key guesses (Fig. 6)
|
||||
fig_sec_kpa.pdf : outsider SER vs known-plaintext frames (Fig. 7)
|
||||
fig_sec_sens.pdf : eavesdropper SER vs fraction of key held (Fig. 5)
|
||||
fig_sec_brute.pdf : eavesdropper SER vs number of key guesses (Fig. 6)
|
||||
fig_sec_kpa.pdf : eavesdropper SER vs known-plaintext frames (Fig. 7)
|
||||
fig_sec_real.pdf : token error rate on real streams (Fig. 8)
|
||||
|
||||
fig_sec_brute_rho.pdf is also emitted as a diagnostic and is not used in
|
||||
the paper.
|
||||
Curves that coincide by construction are drawn deliberately layered, the
|
||||
lower one wide and semi-transparent and the upper one narrow with open
|
||||
markers, so every legend entry has a visible curve.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from pathlib import Path
|
||||
import csv
|
||||
import math
|
||||
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
@@ -32,7 +34,7 @@ plt.rcParams.update({
|
||||
"font.serif": ["DejaVu Serif", "Times New Roman"],
|
||||
"font.size": 9,
|
||||
"axes.labelsize": 9,
|
||||
"legend.fontsize": 6.6,
|
||||
"legend.fontsize": 7.4,
|
||||
"xtick.labelsize": 8,
|
||||
"ytick.labelsize": 8,
|
||||
"axes.grid": True,
|
||||
@@ -57,13 +59,20 @@ C_PUB = "#16a085"
|
||||
LBL = {
|
||||
"legit": "Legitimate",
|
||||
"oma": "OMA",
|
||||
"eve_pub": "Eve, public masks",
|
||||
"eve_key": "Eve, wrong key",
|
||||
"eve_pub": "Eavesdropper, public masks",
|
||||
"eve_key": "Eavesdropper, wrong key",
|
||||
"chance": "Random guess",
|
||||
"jam_m": "Matched jammer (public masks)",
|
||||
"jam_b": "Blind jammer (proposed)",
|
||||
"nojam": "No jammer",
|
||||
"mask": "Keyed masking",
|
||||
"perm": "Permutation key",
|
||||
"pad": "Index cipher",
|
||||
"insider": "Insider",
|
||||
"outsider": "Outsider",
|
||||
}
|
||||
# deliberate-layering style for the LOWER of two coinciding curves
|
||||
UNDER = dict(lw=2.6, ms=7, alpha=0.85)
|
||||
# and for the curve riding on top of it
|
||||
OVER = dict(lw=1.2, ms=4.5, mfc="none")
|
||||
|
||||
|
||||
def load(name):
|
||||
@@ -106,10 +115,11 @@ def fig_snr():
|
||||
r = load("sec_snr.csv")
|
||||
x = col(r, "snr_db")
|
||||
fig, ax = plt.subplots()
|
||||
# legitimate and OMA coincide by construction; layered deliberately
|
||||
ax.semilogy(x, col(r, "legit"), color=C_LEGIT, marker="o", ls="-",
|
||||
label=LBL["legit"])
|
||||
label=LBL["legit"], **UNDER)
|
||||
ax.semilogy(x, col(r, "oma"), color=C_OMA, marker="^", ls=":",
|
||||
label=LBL["oma"])
|
||||
label=LBL["oma"], **OVER)
|
||||
ax.semilogy(x, col(r, "eve_public"), color=C_PUB, marker="v",
|
||||
ls="none", markersize=5.2, markerfacecolor="none",
|
||||
label=LBL["eve_pub"])
|
||||
@@ -125,13 +135,17 @@ def fig_snr():
|
||||
|
||||
|
||||
def fig_keylen():
|
||||
"""The OMA reference is the resource-matched one of oma_ser_keylen,
|
||||
which is undefined at key lengths where 16/L is not an integer; those
|
||||
rows carry nan and are skipped."""
|
||||
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"])
|
||||
op = [(l, v) for l, v in zip(x, col(r, "oma")) if not math.isnan(v)]
|
||||
ax.semilogy([p[0] for p in op], [p[1] for p in op], 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$")
|
||||
@@ -144,47 +158,47 @@ def fig_keylen():
|
||||
def fig_jam():
|
||||
"""Target-user SER against JSR for four schemes. A linear axis is
|
||||
used because the range spans less than one decade, where a log axis
|
||||
would print wide minor tick labels that crowd out the y label."""
|
||||
would print wide minor tick labels that crowd out the y label. The
|
||||
no-jammer reference is annotated on the line rather than listed in
|
||||
the legend, so the legend never covers it."""
|
||||
r = load("sec_jam_cmp.csv")
|
||||
x = col(r, "jsr_db")
|
||||
me = max(1, len(x) // 8)
|
||||
fig, ax = plt.subplots()
|
||||
ax.plot(x, col(r, "oma_targeted"), color=C_PUB, marker="^", ls=":",
|
||||
label="OMA, targeted")
|
||||
ax.plot(x, col(r, "matched"), color=C_MATCH, marker="P", ls="--",
|
||||
label="Public masks, matched")
|
||||
# the two blind curves agree to 0.0015, so the proposed one is drawn
|
||||
# first and wide and the permutation key rides on top with open
|
||||
# markers, otherwise one legend entry would have no visible curve
|
||||
ax.plot(x, col(r, "blind"), color=C_LEGIT, marker="o", ls="-", lw=2.6,
|
||||
ms=7, alpha=0.85, label="Proposed, blind")
|
||||
markevery=me, label=LBL["mask"] + ", matched")
|
||||
ax.plot(x, col(r, "oma_targeted"), color=C_PUB, marker="^", ls=":",
|
||||
markevery=me, label=LBL["oma"] + ", targeted")
|
||||
# the two blind curves agree to 0.0015; deliberate layering
|
||||
ax.plot(x, col(r, "blind"), color=C_LEGIT, marker="o", ls="-",
|
||||
markevery=me, label=LBL["mask"] + ", blind", **UNDER)
|
||||
ax.plot(x, col(r, "perm_blind"), color=C_EVE, marker="s", ls="-.",
|
||||
lw=1.2, ms=4.5, mfc="none", label="Permutation key, blind")
|
||||
markevery=me, label=LBL["perm"] + ", blind", **OVER)
|
||||
nojam = float(load("sec_jam.csv")[0]["nojam"])
|
||||
ax.axhline(nojam, color=C_OMA, ls=(0, (1, 3)), lw=0.9,
|
||||
label=LBL["nojam"])
|
||||
ax.axhline(nojam, color=C_OMA, ls=(0, (1, 3)), lw=0.9)
|
||||
ax.text(max(x) - 0.6, nojam + 0.02, LBL["nojam"], ha="right",
|
||||
va="bottom", fontsize=7.4, color="#555555")
|
||||
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")
|
||||
ax.legend(loc="center right", bbox_to_anchor=(0.985, 0.47))
|
||||
save(fig, "fig_sec_jam")
|
||||
|
||||
|
||||
def fig_sens():
|
||||
"""Key sensitivity of three schemes on one axis, the fraction of the
|
||||
key the attacker holds. For keyed masking that fraction is the mask
|
||||
correlation, for the permutation scheme the fraction of positions
|
||||
placed correctly, for the index cipher the fraction of pad bits
|
||||
known."""
|
||||
key the attacker holds. All three ride the random-guess level over
|
||||
most of the range, so the flat region is deliberately layered."""
|
||||
r = load("sec_sens_cmp.csv")
|
||||
x = col(r, "frac")
|
||||
fig, ax = plt.subplots()
|
||||
ax.plot(x, col(r, "ser_mask"), color=C_LEGIT, marker="o", ls="-",
|
||||
label="Keyed masking")
|
||||
label=LBL["mask"], **UNDER)
|
||||
ax.plot(x, col(r, "ser_perm"), color=C_EVE, marker="s", ls="--",
|
||||
label="Permutation key")
|
||||
label=LBL["perm"], **OVER)
|
||||
ax.plot(x, col(r, "ser_pad"), color=C_PUB, marker="v", ls="-.",
|
||||
label="Index cipher")
|
||||
lw=1.2, ms=4.5, mfc="none", label=LBL["pad"])
|
||||
chance = 1.0 - (1.0 / 16.0) ** 4
|
||||
ax.axhline(chance, color=C_CH, ls=":", lw=0.9, label=LBL["chance"])
|
||||
ax.set_xlabel("Fraction of the key recovered")
|
||||
@@ -200,54 +214,35 @@ def fig_brute():
|
||||
r = load("sec_brute_cmp.csv")
|
||||
x = col(r, "K")
|
||||
fig, ax = plt.subplots()
|
||||
ax.semilogx(x, col(r, "ser_mask"), color=C_LEGIT, marker="o", ls="-",
|
||||
label="Keyed masking")
|
||||
ax.semilogx(x, col(r, "ser_perm"), color=C_EVE, marker="s", ls="--",
|
||||
label="Permutation key")
|
||||
label=LBL["perm"], **UNDER)
|
||||
ax.semilogx(x, col(r, "ser_pad"), color=C_PUB, marker="v", ls="-.",
|
||||
label="Index cipher")
|
||||
label=LBL["pad"], **OVER)
|
||||
ax.semilogx(x, col(r, "ser_mask"), color=C_LEGIT, marker="o", ls="-",
|
||||
label=LBL["mask"])
|
||||
kl = load("sec_keylen.csv")
|
||||
legit = float([q for q in kl if int(q["L"]) == 16][0]["legit_ser"])
|
||||
ax.axhline(legit, color=C_OMA, ls=":", lw=0.9, label=LBL["legit"])
|
||||
ax.set_xlabel("Number of key guesses $K$")
|
||||
ax.set_ylabel("Eavesdropper SER")
|
||||
ax.set_ylim(-0.03, 1.05)
|
||||
ax.legend(loc="center left")
|
||||
ax.set_ylim(0.2, 1.05)
|
||||
ax.legend(loc="lower left")
|
||||
save(fig, "fig_sec_brute")
|
||||
|
||||
|
||||
def fig_brute_rho():
|
||||
"""Best key correlation a search of size K reaches, per key length.
|
||||
This is a property of the key space alone."""
|
||||
r = load("sec_brute.csv")
|
||||
fig, ax = plt.subplots()
|
||||
sty = {8: ("#c0392b", "o"), 16: ("#2c5fa8", "s"),
|
||||
32: ("#16a085", "v"), 64: ("#8e44ad", "P")}
|
||||
for Lp, (c, mk) in sty.items():
|
||||
rows = [row for row in r if int(row["L"]) == Lp]
|
||||
ax.semilogx([float(x["K"]) for x in rows],
|
||||
[float(x["best_rho"]) for x in rows],
|
||||
color=c, marker=mk, ls="-", label=f"$L={Lp}$")
|
||||
ax.axhline(0.96, color=C_CH, ls="-.", lw=0.9, label="Break threshold")
|
||||
ax.set_xlabel("Number of key guesses $K$")
|
||||
ax.set_ylabel(r"Best key correlation $\kappa$")
|
||||
ax.set_ylim(0, 1.05)
|
||||
ax.legend(loc="upper left")
|
||||
save(fig, "fig_sec_brute_rho")
|
||||
|
||||
|
||||
def fig_real():
|
||||
r = load("real_sec_ter.csv")
|
||||
x = col(r, "snr_db")
|
||||
fig, ax = plt.subplots()
|
||||
# legitimate/OMA and insider/outsider coincide pairwise; layered
|
||||
ax.semilogy(x, col(r, "ter_legit"), color=C_LEGIT, marker="o", ls="-",
|
||||
label=LBL["legit"])
|
||||
label=LBL["legit"], **UNDER)
|
||||
ax.semilogy(x, col(r, "ter_oma"), color=C_OMA, marker="^", ls=":",
|
||||
label=LBL["oma"])
|
||||
label=LBL["oma"], **OVER)
|
||||
ax.semilogy(x, col(r, "ter_insider"), color=C_PUB, marker="v", ls="-.",
|
||||
label="Insider")
|
||||
lw=2.6, alpha=0.85, ms=7, label=LBL["insider"])
|
||||
ax.semilogy(x, col(r, "ter_eve"), color=C_EVE, marker="s", ls="--",
|
||||
label=LBL["eve_key"])
|
||||
label=LBL["outsider"], **OVER)
|
||||
ax.set_xlabel("SNR (dB)")
|
||||
ax.set_ylabel("TER")
|
||||
ax.set_xlim(min(x), max(x))
|
||||
@@ -256,6 +251,9 @@ def fig_real():
|
||||
|
||||
|
||||
def fig_kpa():
|
||||
"""Known-plaintext recovery of the keyed masks at three collection
|
||||
SNRs, with the permutation key under the same attack as the linear
|
||||
comparison scheme."""
|
||||
r = load("kpa.csv")
|
||||
fig, ax = plt.subplots()
|
||||
sty = {0.0: ("#c0392b", "o"), 10.0: ("#2c5fa8", "s"),
|
||||
@@ -265,7 +263,13 @@ def fig_kpa():
|
||||
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")
|
||||
label=LBL["mask"] + f", {int(snr)} dB")
|
||||
try:
|
||||
p = load("pkpa.csv")
|
||||
ax.semilogx(col(p, "n_frames"), col(p, "eve_ser"), color=C_MATCH,
|
||||
marker="P", ls="--", label=LBL["perm"] + ", 20 dB")
|
||||
except FileNotFoundError:
|
||||
print("[skip] pkpa.csv not present yet")
|
||||
# legitimate reference measured with the SAME estimator as the
|
||||
# eavesdropper curves, namely the four-user average of eval_ser_sse
|
||||
# at L=16, taken from sec_keylen.csv rather than from the user-1
|
||||
@@ -287,7 +291,6 @@ def main():
|
||||
try:
|
||||
fig_sens()
|
||||
fig_brute()
|
||||
fig_brute_rho()
|
||||
except FileNotFoundError:
|
||||
print("[skip] attack-difficulty CSVs not present yet")
|
||||
try:
|
||||
|
||||
@@ -33,9 +33,13 @@ def masks(U, d, rng):
|
||||
return M / np.linalg.norm(M, axis=1, keepdims=True) * np.sqrt(d)
|
||||
|
||||
|
||||
ROWS = [] # (tag, claim, emp, err, tol, verdict)
|
||||
|
||||
|
||||
def report(tag, claim, emp, tol, extra=""):
|
||||
err = abs(claim - emp)
|
||||
ok = err <= tol
|
||||
ROWS.append((tag, claim, emp, err, tol, "PASS" if ok else "FAIL"))
|
||||
print(f"[{'PASS' if ok else 'FAIL'}] {tag}: claim={claim:.5g} "
|
||||
f"emp={emp:.5g} |err|={err:.2g} tol={tol:g} {extra}")
|
||||
return ok
|
||||
@@ -195,6 +199,16 @@ def main():
|
||||
}
|
||||
print("\nsummary:", {k: ("PASS" if v else "FAIL") for k, v in results.items()})
|
||||
print("ALL PASS" if all(results.values()) else "SOME FAILED")
|
||||
# stored artifact so every quoted verification number has a raw file
|
||||
import csv as _csv
|
||||
from pathlib import Path as _Path
|
||||
data = _Path(__file__).resolve().parents[1] / "data"
|
||||
with open(data / "verify_math.csv", "w", newline="") as f:
|
||||
w = _csv.writer(f)
|
||||
w.writerow(["check", "claim", "empirical", "abs_err", "tol",
|
||||
"verdict"])
|
||||
w.writerows(ROWS)
|
||||
print("[csv]", data / "verify_math.csv")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user