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
+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: