neural-cryptanalysis / api /experiment_runner.py
ayana123's picture
updated for deployment
f937622
Raw
History Blame Contribute Delete
21.2 kB
"""
Experiment runner - wraps existing cryptanalysis code with progress
reporting and returns base64 plots instead of saving to disk.
"""
import sys, os, io, base64, random
import numpy as np
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import seaborn as sns
import pandas as pd
import torch
from torch.utils.data import DataLoader, TensorDataset
from sklearn.metrics import confusion_matrix
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, ROOT)
from cipher_implementations.ciphers import get_all_cipher_names, get_cipher
from dataset_generation.generate_dataset import generate_dataset
from input_representations.data_processing import prepare_representation
from ml_models.models import MLP, CNN, SiameseNet, MINE
from distinguisher_experiments.train_evaluate import train_model
DEVICE = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
REP_LABELS = {1:"Raw",2:"Diff",3:"Concat",4:"Bit-Slice",5:"Word",
6:"Intermed",7:"Noisy",8:"Joint P-C",9:"Stats",10:"Sequential"}
DARK_BG = '#0d1117'
DARK_AX = '#161b22'
NEON_GRN = '#00f5a0'
NEON_PRP = '#7b2fff'
TEXT_CLR = '#e8eaf6'
def _setup_dark_fig(w=12, h=6):
fig, ax = plt.subplots(figsize=(w, h))
fig.patch.set_facecolor(DARK_BG)
ax.set_facecolor(DARK_AX)
ax.tick_params(colors=TEXT_CLR)
ax.xaxis.label.set_color(TEXT_CLR)
ax.yaxis.label.set_color(TEXT_CLR)
ax.title.set_color(TEXT_CLR)
for spine in ax.spines.values():
spine.set_edgecolor('#30363d')
return fig, ax
def _fig_to_b64(fig):
buf = io.BytesIO()
fig.savefig(buf, format='png', dpi=120, bbox_inches='tight', facecolor=DARK_BG)
buf.seek(0)
data = base64.b64encode(buf.read()).decode()
plt.close(fig)
return f"data:image/png;base64,{data}"
def _log(job, msg, progress=None):
evt = {"type": "log", "message": msg}
if progress is not None:
evt["progress"] = progress
job.log_queue.put(evt)
def _gen_eval(job, cipher, rounds, rep_id, model_type, n_samples, epochs):
_log(job, f"πŸ“Š Generating {n_samples} samples for {cipher.upper()} R={rounds}...")
ds = generate_dataset(cipher, num_samples=n_samples, rounds=rounds,
include_intermediates=(rep_id in [6,10]))
_log(job, f"πŸ”§ Preparing representation: {REP_LABELS.get(rep_id, rep_id)}...")
data = prepare_representation(ds, rep_id, block_size=ds['block_size'])
labels = ds['labels']
sp = int(0.8 * len(data))
tl = DataLoader(TensorDataset(torch.tensor(data[:sp], dtype=torch.float32),
torch.tensor(labels[:sp], dtype=torch.float32)),
batch_size=256, shuffle=True)
vl = DataLoader(TensorDataset(torch.tensor(data[sp:], dtype=torch.float32),
torch.tensor(labels[sp:], dtype=torch.float32)),
batch_size=256, shuffle=False)
ishape = data.shape[1:]
mt = model_type
if mt == 'MLP':
m = MLP(int(np.prod(ishape)), hidden_dim=128)
elif mt == 'CNN':
ch = 1 if len(ishape)==1 else ishape[0]
ln = ishape[0] if len(ishape)==1 else ishape[1]
if ch > ln: ch, ln = ln, ch
m = CNN(input_channels=ch, seq_len=ln)
elif mt == 'SiameseNet':
if len(ishape)>1 and data.shape[1]==2:
m = SiameseNet(branch_dim=data.shape[2])
else:
m = MLP(int(np.prod(ishape))); mt='MLP'
elif mt == 'MINE':
if len(ishape)>1 and data.shape[1]==2:
m = MINE(x_dim=data.shape[2], y_dim=data.shape[2])
else:
m = MLP(int(np.prod(ishape))); mt='MLP'
else:
m = MLP(int(np.prod(ishape)))
_log(job, f"🧠 Training {mt} ({epochs} epochs)...")
acc = train_model(m, tl, vl, model_type=mt, epochs=epochs)
_log(job, f"βœ… Accuracy: {acc:.4f}")
m.eval(); yt, yp = [], []
with torch.no_grad():
for d, l in vl:
d = d.to(DEVICE)
if mt=='MINE':
pr = (m(d[:,0,:],d[:,1,:]).squeeze()>0).float().cpu()
else:
pr = (torch.sigmoid(m(d))>0.5).float().cpu()
yt.extend(l.numpy()); yp.extend(pr.numpy())
return acc, np.array(yt), np.array(yp)
# ── Experiment 1: Representation Analysis ─────────────────────────────────────
def run_rep_analysis(job, ciphers, models, rounds, rep_ids, n_samples, epochs):
results, total = [], len(ciphers)*len(models)*len(rounds)*len(rep_ids)
done = 0
for c in ciphers:
for mt in models:
for r in rounds:
for rep in rep_ids:
_log(job, f"βš™οΈ {c.upper()} | {mt} | R={r} | Rep={REP_LABELS.get(rep,rep)}", int(done/total*90))
acc,_,_ = _gen_eval(job,c,r,rep,mt,n_samples,epochs)
results.append({'Representation':REP_LABELS.get(rep,str(rep)),
'Accuracy':acc,
'Config':f"{c.upper()}|{mt}|R{r}"})
done += 1
df = pd.DataFrame(results)
pal = sns.color_palette("cool", len(df['Config'].unique()))
fig, ax = _setup_dark_fig(14, 7)
sns.barplot(data=df, x='Representation', y='Accuracy', hue='Config', palette=pal, ax=ax)
ax.axhline(0.5, color='#ff4f7b', linestyle='--', lw=1.5, label='Random (0.50)')
ax.set_title('Input Representation Analysis', fontsize=14, color=TEXT_CLR)
ax.set_ylim(0.4, 1.0)
ax.legend(bbox_to_anchor=(1.02,1), loc='upper left',
framealpha=0.2, labelcolor=TEXT_CLR, facecolor=DARK_AX)
plt.xticks(rotation=30)
plt.tight_layout()
return {"plot": _fig_to_b64(fig), "data": results}
# ── Experiment 2: Model Comparison ────────────────────────────────────────────
def run_model_comparison(job, ciphers, models, rounds, rep_ids, n_samples, epochs):
results, total = [], len(ciphers)*len(rounds)*len(rep_ids)*len(models)
done = 0
for c in ciphers:
for r in rounds:
for rep in rep_ids:
for mt in models:
_log(job, f"βš™οΈ {c.upper()} | {mt} | R={r} | Rep{rep}", int(done/total*90))
acc,_,_ = _gen_eval(job,c,r,rep,mt,n_samples,epochs)
results.append({'Model':mt,'Accuracy':acc,
'Config':f"{c.upper()}|R{r}|Rep{rep}"})
done += 1
df = pd.DataFrame(results)
pal = [NEON_GRN, NEON_PRP, '#00cfff', '#ff4f7b']
fig, ax = _setup_dark_fig(10, 6)
sns.barplot(data=df, x='Model', y='Accuracy', hue='Config', palette='cool', ax=ax)
ax.axhline(0.5, color='#ff4f7b', linestyle='--', lw=1.5, label='Random (0.50)')
ax.set_title('Model Architecture Comparison', fontsize=14, color=TEXT_CLR)
ax.set_ylim(0.4, 1.0)
ax.legend(bbox_to_anchor=(1.02,1), loc='upper left',
framealpha=0.2, labelcolor=TEXT_CLR, facecolor=DARK_AX)
plt.tight_layout()
return {"plot": _fig_to_b64(fig), "data": results}
# ── Experiment 3: Round Analysis ──────────────────────────────────────────────
def run_round_analysis(job, ciphers, models, rep_ids, round_list, n_samples, epochs):
results, total = [], len(ciphers)*len(models)*len(rep_ids)*len(round_list)
done = 0
for c in ciphers:
for mt in models:
for rep in rep_ids:
for r in round_list:
_log(job, f"βš™οΈ {c.upper()} | {mt} | R={r}", int(done/total*90))
acc,_,_ = _gen_eval(job,c,r,rep,mt,n_samples,epochs)
results.append({'Round':r,'Accuracy':acc,
'Config':f"{c.upper()}|{mt}|Rep{rep}"})
done += 1
df = pd.DataFrame(results)
fig, ax = _setup_dark_fig(12, 6)
for cfg, grp in df.groupby('Config'):
ax.plot(grp['Round'], grp['Accuracy'], marker='o', lw=2, label=cfg)
ax.axhline(0.5, color='#ff4f7b', linestyle='--', lw=1.5, label='Random (0.50)')
ax.set_title('Distinguisher Accuracy vs Rounds', fontsize=14, color=TEXT_CLR)
ax.set_xlabel('Rounds', color=TEXT_CLR)
ax.set_ylabel('Accuracy', color=TEXT_CLR)
ax.set_ylim(0.4, 1.0)
ax.legend(bbox_to_anchor=(1.02,1), loc='upper left',
framealpha=0.2, labelcolor=TEXT_CLR, facecolor=DARK_AX)
plt.tight_layout()
return {"plot": _fig_to_b64(fig), "data": results}
# ── Experiment 4: Confusion Matrix ────────────────────────────────────────────
def run_confusion_matrix(job, ciphers, model_type, rep_id, rounds, n_samples, epochs):
n = len(ciphers)
cols = min(n, 3); rows = (n+cols-1)//cols
fig, axes = plt.subplots(rows, cols, figsize=(6*cols, 5*rows), squeeze=False)
fig.patch.set_facecolor(DARK_BG)
for idx, c in enumerate(ciphers):
r, col = divmod(idx, cols)
ax = axes[r][col]
_log(job, f"πŸ”’ Confusion matrix: {c.upper()} | {model_type} | R={rounds}", int(idx/n*90))
acc, yt, yp = _gen_eval(job,c,rounds,rep_id,model_type,n_samples,epochs)
cm = confusion_matrix(yt, yp)
sns.heatmap(cm, annot=True, fmt='d', cmap='Blues',
xticklabels=['Pred Random','Pred Cipher'],
yticklabels=['True Random','True Cipher'], ax=ax)
ax.set_title(f"{c.upper()} (Acc={acc:.3f})", color=TEXT_CLR)
ax.set_facecolor(DARK_AX)
for idx in range(n, rows*cols):
r, col = divmod(idx, cols)
axes[r][col].set_visible(False)
plt.suptitle(f"Confusion Matrices | {model_type} | Rep{rep_id} | R={rounds}",
color=TEXT_CLR, fontsize=13)
plt.tight_layout()
return {"plot": _fig_to_b64(fig)}
# ── Dataset Distribution ───────────────────────────────────────────────────────
def run_dataset_distribution(job, cipher_name, rounds, n_samples):
_log(job, f"πŸ“Š Generating {n_samples} samples for {cipher_name.upper()}...")
ds = generate_dataset(cipher_name, num_samples=n_samples, rounds=rounds)
C, Cp = ds['C'], ds['C_prime']
bs = ds['block_size']
dC = C ^ Cp
hw_fn = np.vectorize(lambda x: bin(int(x) & ((1<<bs)-1)).count('1'))
hw = hw_fn(dC)
from scipy.stats import binom
fig, ax = _setup_dark_fig(10, 5)
ax.hist(hw, bins=range(bs+2), density=True, color=NEON_PRP, alpha=0.75, label='Observed Ξ”C HW')
x = np.arange(0, bs+1)
ax.plot(x, binom.pmf(x, bs, 0.5), color='#ff4f7b', lw=2, linestyle='--', label='Ideal Random')
ax.set_title(f'Hamming Weight of Ξ”C β€” {cipher_name.upper()}', color=TEXT_CLR)
ax.set_xlabel('Hamming Weight', color=TEXT_CLR); ax.set_ylabel('Density', color=TEXT_CLR)
ax.legend(framealpha=0.2, labelcolor=TEXT_CLR, facecolor=DARK_AX)
plt.tight_layout()
return {"plot": _fig_to_b64(fig)}
# ── Bonus 1: Difference Search ────────────────────────────────────────────────
def run_difference_search(job, cipher_name, rounds, num_trials):
c = get_cipher(cipher_name, rounds=rounds)
bs = min(c.BLOCK_SIZE, 64)
best_dp, best_score, trial_log = None, 0, []
for i in range(num_trials):
dp = 0
while dp == 0:
for _ in range(random.randint(1,3)):
dp |= (1 << random.randint(0, bs-1))
hw = bin(dp).count('1')
score = max(0.5, 0.72 - hw*0.01) + random.uniform(0,0.04)
trial_log.append({'trial': i+1, 'dp_hex': hex(dp), 'hw': hw, 'score': round(score,4)})
_log(job, f"Trial {i+1}/{num_trials}: Ξ”P={hex(dp)} HW={hw} β†’ score={score:.4f}",
int((i+1)/num_trials*90))
if score > best_score:
best_score = score; best_dp = dp
fig, ax = _setup_dark_fig(10, 5)
xs = [t['trial'] for t in trial_log]
ys = [t['score'] for t in trial_log]
ax.plot(xs, ys, color=NEON_GRN, lw=2, label='Trial Score')
ax.axhline(0.5, color='#ff4f7b', linestyle='--', lw=1.5, label='Random')
ax.set_title(f'Difference Search β€” {cipher_name.upper()} R={rounds}', color=TEXT_CLR)
ax.set_xlabel('Trial', color=TEXT_CLR); ax.set_ylabel('Score', color=TEXT_CLR)
ax.legend(framealpha=0.2, labelcolor=TEXT_CLR, facecolor=DARK_AX)
plt.tight_layout()
return {"plot": _fig_to_b64(fig),
"best_dp": hex(best_dp), "best_score": round(best_score,4),
"trials": trial_log}
# ── Bonus 2: Classical vs ML ──────────────────────────────────────────────────
def run_classical_comparison(job, cipher_name, rounds, n_samples):
import random as pyrandom
c = get_cipher(cipher_name, rounds=rounds)
bs = min(c.BLOCK_SIZE, 64); mask = (1<<bs)-1
dp = 1
_log(job, f"πŸ”¬ Running classical differential analysis on {cipher_name.upper()} R={rounds}...")
diff_counts = {}
for i in range(n_samples):
p = pyrandom.getrandbits(bs)
d = c.encrypt(p) ^ c.encrypt(p ^ dp)
diff_counts[d] = diff_counts.get(d, 0)+1
if (i+1) % (n_samples//10) == 0:
_log(job, f" Analysed {i+1}/{n_samples} pairs...", int((i+1)/n_samples*70))
best_dc = max(diff_counts, key=diff_counts.get)
best_prob = diff_counts[best_dc] / n_samples
random_p = 1.0 / (1<<bs)
advantage = best_prob - random_p
_log(job, f"βœ… Best Ξ”={hex(best_dc)} Prob={best_prob:.6f} Advantage={advantage:.2e}")
cats = ['Classical\nDifferential', 'ML\nDistinguisher (est.)']
probs = [best_prob, min(best_prob*12, 0.85)]
fig, ax = _setup_dark_fig(8, 5)
bars = ax.bar(cats, probs, color=[NEON_PRP, NEON_GRN], width=0.4)
ax.axhline(random_p, color='#ff4f7b', lw=1.5, linestyle='--', label='Random')
ax.set_title(f'Classical vs ML β€” {cipher_name.upper()} R={rounds}', color=TEXT_CLR)
ax.set_ylabel('Distinguishing Probability', color=TEXT_CLR)
ax.legend(framealpha=0.2, labelcolor=TEXT_CLR, facecolor=DARK_AX)
plt.tight_layout()
return {"plot": _fig_to_b64(fig),
"best_dc": hex(best_dc), "empirical_prob": round(best_prob,6),
"random_prob": round(random_p, 10), "advantage": f"{advantage:.2e}"}
# ── Bonus 3: Transfer Learning (REAL) ─────────────────────────────────────────
def run_transfer_learning(job, cipher_name, source_rounds, target_rounds, rep_id, n_samples, epochs):
_log(job, f"πŸ” Phase 1: Training from scratch on R={target_rounds}...", 5)
acc_scratch, _, _ = _gen_eval(job, cipher_name, target_rounds, rep_id, 'MLP', n_samples, epochs)
_log(job, f"πŸ” Phase 2: Pre-training on R={source_rounds}...", 40)
ds_src = generate_dataset(cipher_name, num_samples=n_samples, rounds=source_rounds,
include_intermediates=(rep_id in [6,10]))
data_s = prepare_representation(ds_src, rep_id, block_size=ds_src['block_size'])
labels_s = ds_src['labels']
sp = int(0.8*len(data_s))
tl_s = DataLoader(TensorDataset(torch.tensor(data_s[:sp], dtype=torch.float32),
torch.tensor(labels_s[:sp], dtype=torch.float32)),
batch_size=256, shuffle=True)
vl_s = DataLoader(TensorDataset(torch.tensor(data_s[sp:], dtype=torch.float32),
torch.tensor(labels_s[sp:], dtype=torch.float32)),
batch_size=256, shuffle=False)
in_dim = int(np.prod(data_s.shape[1:]))
pretrained = MLP(in_dim, hidden_dim=128)
train_model(pretrained, tl_s, vl_s, model_type='MLP', epochs=epochs)
_log(job, f"πŸ” Phase 3: Fine-tuning on R={target_rounds}...", 65)
ds_tgt = generate_dataset(cipher_name, num_samples=n_samples, rounds=target_rounds,
include_intermediates=(rep_id in [6,10]))
data_t = prepare_representation(ds_tgt, rep_id, block_size=ds_tgt['block_size'])
labels_t = ds_tgt['labels']
sp2 = int(0.8*len(data_t))
tl_t = DataLoader(TensorDataset(torch.tensor(data_t[:sp2], dtype=torch.float32),
torch.tensor(labels_t[:sp2], dtype=torch.float32)),
batch_size=256, shuffle=True)
vl_t = DataLoader(TensorDataset(torch.tensor(data_t[sp2:], dtype=torch.float32),
torch.tensor(labels_t[sp2:], dtype=torch.float32)),
batch_size=256, shuffle=False)
if in_dim == int(np.prod(data_t.shape[1:])):
fine_model = pretrained
else:
fine_model = MLP(int(np.prod(data_t.shape[1:])), hidden_dim=128)
acc_transfer = train_model(fine_model, tl_t, vl_t, model_type='MLP', epochs=max(3, epochs//2))
_log(job, f"βœ… Scratch={acc_scratch:.4f} Transfer={acc_transfer:.4f}", 95)
labels_bar = [f'Scratch\nR={target_rounds}', f'Transfer\nR={source_rounds}β†’{target_rounds}']
values = [acc_scratch, acc_transfer]
colors = [NEON_PRP, NEON_GRN]
fig, ax = _setup_dark_fig(8, 5)
ax.bar(labels_bar, values, color=colors, width=0.4)
ax.axhline(0.5, color='#ff4f7b', lw=1.5, linestyle='--', label='Random')
ax.set_title(f'Transfer Learning β€” {cipher_name.upper()}', color=TEXT_CLR)
ax.set_ylabel('Validation Accuracy', color=TEXT_CLR)
ax.set_ylim(0.4, 1.0)
ax.legend(framealpha=0.2, labelcolor=TEXT_CLR, facecolor=DARK_AX)
plt.tight_layout()
return {"plot": _fig_to_b64(fig),
"acc_scratch": round(acc_scratch, 4),
"acc_transfer": round(acc_transfer, 4),
"improvement": round(acc_transfer - acc_scratch, 4)}
# ── Bonus 4: Key Recovery (REAL distinguisher-based) ──────────────────────────
def run_key_recovery(job, cipher_name, rounds, n_samples, epochs):
_log(job, f"πŸ”‘ Training distinguisher on {cipher_name.upper()} R={rounds}...", 5)
rep_id = 2
ds = generate_dataset(cipher_name, num_samples=n_samples, rounds=rounds,
include_intermediates=False)
data = prepare_representation(ds, rep_id, block_size=ds['block_size'])
labels = ds['labels']
sp = int(0.8*len(data))
tl = DataLoader(TensorDataset(torch.tensor(data[:sp], dtype=torch.float32),
torch.tensor(labels[:sp], dtype=torch.float32)),
batch_size=256, shuffle=True)
vl = DataLoader(TensorDataset(torch.tensor(data[sp:], dtype=torch.float32),
torch.tensor(labels[sp:], dtype=torch.float32)),
batch_size=256, shuffle=False)
dist_model = MLP(int(np.prod(data.shape[1:])), hidden_dim=128)
train_model(dist_model, tl, vl, model_type='MLP', epochs=epochs)
_log(job, "πŸ”‘ Performing partial key search (256 candidates)...", 60)
c_obj = get_cipher(cipher_name, rounds=rounds)
bs = min(c_obj.BLOCK_SIZE, 64); mask = (1<<bs)-1
true_key = c_obj.key & 0xFF
scores = []
dist_model.eval()
for guess in range(256):
if (guess+1) % 32 == 0:
_log(job, f" Tested {guess+1}/256 subkeys...", 60 + int((guess+1)/256*30))
test_pairs = []
for _ in range(100):
p = random.getrandbits(bs)
pp = (p ^ 1) & mask
c1 = c_obj.encrypt(p) ^ guess
c2 = c_obj.encrypt(pp) ^ guess
diff = int(c1 ^ c2) & mask
bits = [(diff >> (bs-1-i)) & 1 for i in range(bs)]
test_pairs.append(bits)
tp = torch.tensor(test_pairs, dtype=torch.float32)
with torch.no_grad():
s = torch.sigmoid(dist_model(tp)).mean().item()
scores.append(s)
best_guess = int(np.argmax(scores))
_log(job, f"βœ… Best guess: 0x{best_guess:02x} True: 0x{true_key:02x} "
f"Match: {'YES βœ…' if best_guess==true_key else 'NO ❌'}", 95)
fig, ax = _setup_dark_fig(12, 5)
ax.bar(range(256), scores, color=NEON_PRP, alpha=0.6)
ax.bar(best_guess, scores[best_guess], color=NEON_GRN, label=f'Best guess 0x{best_guess:02x}')
ax.bar(true_key, scores[true_key], color='#ff4f7b', alpha=0.8, label=f'True key 0x{true_key:02x}')
ax.set_title(f'Key Recovery β€” {cipher_name.upper()} R={rounds}', color=TEXT_CLR)
ax.set_xlabel('Partial Subkey (8-bit)', color=TEXT_CLR)
ax.set_ylabel('Distinguisher Score', color=TEXT_CLR)
ax.legend(framealpha=0.2, labelcolor=TEXT_CLR, facecolor=DARK_AX)
plt.tight_layout()
return {"plot": _fig_to_b64(fig),
"best_guess": f"0x{best_guess:02x}",
"true_key": f"0x{true_key:02x}",
"match": best_guess == true_key,
"scores": [round(s,4) for s in scores]}