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:
KiHoLee
2026-08-16 23:48:39 +09:00
parent fbbad154b2
commit 3529ab1918
28 changed files with 755 additions and 123 deletions
+112
View File
@@ -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()
+82
View File
@@ -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
View File
@@ -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():
+99
View File
@@ -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()
+3 -3
View File
@@ -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
View File
@@ -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:
+14
View File
@@ -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__":