mlsca-bench 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mlsca_bench/__init__.py +73 -0
- mlsca_bench/__main__.py +9 -0
- mlsca_bench/benchmark/__init__.py +68 -0
- mlsca_bench/benchmark/classical.py +350 -0
- mlsca_bench/benchmark/leakage.py +319 -0
- mlsca_bench/benchmark/metrics.py +129 -0
- mlsca_bench/benchmark/plots.py +99 -0
- mlsca_bench/benchmark/splits.json +9 -0
- mlsca_bench/benchmark/splits.py +374 -0
- mlsca_bench/cli.py +332 -0
- mlsca_bench/datasets/__init__.py +70 -0
- mlsca_bench/datasets/adapters/__init__.py +43 -0
- mlsca_bench/datasets/adapters/_concat.py +229 -0
- mlsca_bench/datasets/adapters/_hdf5.py +56 -0
- mlsca_bench/datasets/adapters/aes_hd_csv.py +275 -0
- mlsca_bench/datasets/adapters/agilent.py +231 -0
- mlsca_bench/datasets/adapters/ascad.py +52 -0
- mlsca_bench/datasets/adapters/asciiwave.py +247 -0
- mlsca_bench/datasets/adapters/ascon.py +57 -0
- mlsca_bench/datasets/adapters/chameleon.py +380 -0
- mlsca_bench/datasets/adapters/hdf5.py +527 -0
- mlsca_bench/datasets/adapters/lecroy.py +299 -0
- mlsca_bench/datasets/adapters/manifest.py +250 -0
- mlsca_bench/datasets/adapters/matlab.py +236 -0
- mlsca_bench/datasets/adapters/npy.py +400 -0
- mlsca_bench/datasets/adapters/pickled.py +131 -0
- mlsca_bench/datasets/adapters/rawbinary.py +188 -0
- mlsca_bench/datasets/adapters/scaaml.py +391 -0
- mlsca_bench/datasets/adapters/trs.py +233 -0
- mlsca_bench/datasets/adapters/two_class.py +110 -0
- mlsca_bench/datasets/base.py +390 -0
- mlsca_bench/datasets/cache.py +90 -0
- mlsca_bench/datasets/download.py +1463 -0
- mlsca_bench/datasets/errors.py +118 -0
- mlsca_bench/datasets/loading.py +731 -0
- mlsca_bench/datasets/registry.json +4056 -0
- mlsca_bench/datasets/registry.py +1204 -0
- mlsca_bench/models/__init__.py +45 -0
- mlsca_bench/models/attack.py +312 -0
- mlsca_bench/models/cnn.py +126 -0
- mlsca_bench/models/registry.py +182 -0
- mlsca_bench/py.typed +0 -0
- mlsca_bench-0.1.0.dist-info/METADATA +271 -0
- mlsca_bench-0.1.0.dist-info/RECORD +48 -0
- mlsca_bench-0.1.0.dist-info/WHEEL +5 -0
- mlsca_bench-0.1.0.dist-info/entry_points.txt +2 -0
- mlsca_bench-0.1.0.dist-info/licenses/LICENSE +201 -0
- mlsca_bench-0.1.0.dist-info/top_level.txt +1 -0
mlsca_bench/__init__.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Copyright (c) 2026 Universität der Bundeswehr München / FI CODE - ESSEC Lab.
|
|
2
|
+
# Licensed under the Apache License, Version 2.0, see LICENSE for details.
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
"""Public side-channel datasets, baselines and metrics for machine-learning side-channel analysis."""
|
|
6
|
+
|
|
7
|
+
from importlib.metadata import PackageNotFoundError, version as _version
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
__version__ = _version("mlsca-bench")
|
|
11
|
+
except PackageNotFoundError: # running from a source tree without installing
|
|
12
|
+
__version__ = "0+unknown"
|
|
13
|
+
|
|
14
|
+
from .datasets import (
|
|
15
|
+
ArraySideChannelDataset,
|
|
16
|
+
CustomDownloaderRequired,
|
|
17
|
+
DatasetDownloadError,
|
|
18
|
+
DatasetFile,
|
|
19
|
+
DatasetMetadata,
|
|
20
|
+
DatasetSpec,
|
|
21
|
+
DatasetUnavailable,
|
|
22
|
+
DatasetArray,
|
|
23
|
+
ManualDownloadRequired,
|
|
24
|
+
MissingDependencyError,
|
|
25
|
+
SideChannelDataset,
|
|
26
|
+
TraceSample,
|
|
27
|
+
broadcast_field,
|
|
28
|
+
cache_root,
|
|
29
|
+
cache_usage,
|
|
30
|
+
cached_datasets,
|
|
31
|
+
validate_dataset,
|
|
32
|
+
download_dataset,
|
|
33
|
+
get_dataset,
|
|
34
|
+
list_datasets,
|
|
35
|
+
load_dataset,
|
|
36
|
+
load_registry_file,
|
|
37
|
+
register_dataset,
|
|
38
|
+
remove_cached_dataset,
|
|
39
|
+
saved_datasets,
|
|
40
|
+
unregister_dataset,
|
|
41
|
+
user_registry_path,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
"__version__",
|
|
46
|
+
"ArraySideChannelDataset",
|
|
47
|
+
"CustomDownloaderRequired",
|
|
48
|
+
"DatasetDownloadError",
|
|
49
|
+
"DatasetFile",
|
|
50
|
+
"DatasetMetadata",
|
|
51
|
+
"DatasetSpec",
|
|
52
|
+
"DatasetUnavailable",
|
|
53
|
+
"DatasetArray",
|
|
54
|
+
"ManualDownloadRequired",
|
|
55
|
+
"MissingDependencyError",
|
|
56
|
+
"SideChannelDataset",
|
|
57
|
+
"TraceSample",
|
|
58
|
+
"broadcast_field",
|
|
59
|
+
"cache_root",
|
|
60
|
+
"cache_usage",
|
|
61
|
+
"cached_datasets",
|
|
62
|
+
"download_dataset",
|
|
63
|
+
"get_dataset",
|
|
64
|
+
"list_datasets",
|
|
65
|
+
"load_dataset",
|
|
66
|
+
"load_registry_file",
|
|
67
|
+
"register_dataset",
|
|
68
|
+
"remove_cached_dataset",
|
|
69
|
+
"saved_datasets",
|
|
70
|
+
"unregister_dataset",
|
|
71
|
+
"user_registry_path",
|
|
72
|
+
"validate_dataset",
|
|
73
|
+
]
|
mlsca_bench/__main__.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Copyright (c) 2026 Universität der Bundeswehr München / FI CODE - ESSEC Lab.
|
|
2
|
+
# Licensed under the Apache License, Version 2.0, see LICENSE for details.
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
"""Benchmark layer: leakage models, reproducible splits, classical attacks and metrics.
|
|
6
|
+
|
|
7
|
+
The metrics, splits and classical attacks are pure NumPy. The deep-learning
|
|
8
|
+
baselines live in :mod:`mlsca_bench.models` behind the optional ``eval`` extra,
|
|
9
|
+
and the plots in :mod:`mlsca_bench.benchmark.plots` behind the ``plot`` extra.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from .classical import (
|
|
13
|
+
CLASSICAL_ATTACKS,
|
|
14
|
+
ClassicalAttackResult,
|
|
15
|
+
run_classical,
|
|
16
|
+
run_cpa,
|
|
17
|
+
run_dpa,
|
|
18
|
+
run_template_attack,
|
|
19
|
+
)
|
|
20
|
+
from .leakage import (
|
|
21
|
+
HAMMING_WEIGHT,
|
|
22
|
+
SBOX,
|
|
23
|
+
SBOX_INV,
|
|
24
|
+
LeakageModel,
|
|
25
|
+
aes128_round_keys,
|
|
26
|
+
aes_round1_output,
|
|
27
|
+
)
|
|
28
|
+
from .metrics import (
|
|
29
|
+
RankResult,
|
|
30
|
+
evaluate_key_rank,
|
|
31
|
+
guessing_entropy,
|
|
32
|
+
success_rate,
|
|
33
|
+
)
|
|
34
|
+
from .splits import (
|
|
35
|
+
DatasetSplits,
|
|
36
|
+
SplitPolicy,
|
|
37
|
+
Subset,
|
|
38
|
+
describe_splits,
|
|
39
|
+
make_splits,
|
|
40
|
+
resolve_policy,
|
|
41
|
+
verify_splits,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
"CLASSICAL_ATTACKS",
|
|
46
|
+
"ClassicalAttackResult",
|
|
47
|
+
"DatasetSplits",
|
|
48
|
+
"HAMMING_WEIGHT",
|
|
49
|
+
"LeakageModel",
|
|
50
|
+
"RankResult",
|
|
51
|
+
"SBOX",
|
|
52
|
+
"SBOX_INV",
|
|
53
|
+
"SplitPolicy",
|
|
54
|
+
"Subset",
|
|
55
|
+
"aes128_round_keys",
|
|
56
|
+
"aes_round1_output",
|
|
57
|
+
"describe_splits",
|
|
58
|
+
"evaluate_key_rank",
|
|
59
|
+
"guessing_entropy",
|
|
60
|
+
"make_splits",
|
|
61
|
+
"resolve_policy",
|
|
62
|
+
"run_classical",
|
|
63
|
+
"run_cpa",
|
|
64
|
+
"run_dpa",
|
|
65
|
+
"run_template_attack",
|
|
66
|
+
"success_rate",
|
|
67
|
+
"verify_splits",
|
|
68
|
+
]
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
# Copyright (c) 2026 Universität der Bundeswehr München / FI CODE - ESSEC Lab.
|
|
2
|
+
# Licensed under the Apache License, Version 2.0, see LICENSE for details.
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
"""Classical (non-deep-learning) SCA baselines: CPA, DPA, Gaussian templates.
|
|
6
|
+
|
|
7
|
+
These give the "classical" half of the paper's Objective 5 and slot into the
|
|
8
|
+
same evaluation as the DL models — they return an attack result exposing the
|
|
9
|
+
Guessing-Entropy / Success-Rate curves, so the benchmark runner treats them as
|
|
10
|
+
just more models (``"cpa"``, ``"dpa"``, ``"template"``).
|
|
11
|
+
|
|
12
|
+
* **CPA** (Brier et al., 2004) — *non-profiled* Correlation Power Analysis:
|
|
13
|
+
rank key guesses by the peak Pearson correlation between a Hamming-weight
|
|
14
|
+
power model of the S-box output and the traces.
|
|
15
|
+
* **DPA** (Kocher et al., 1999) — *non-profiled* single-bit Differential Power
|
|
16
|
+
Analysis: rank key guesses by the peak difference-of-means between the two
|
|
17
|
+
groups a selection bit induces.
|
|
18
|
+
* **Template attack** (Chari et al., 2002) — *profiled* Gaussian templates:
|
|
19
|
+
fit a per-class Gaussian on points of interest from the profiling set, then
|
|
20
|
+
score attack traces by likelihood (reuses the log-likelihood key ranking).
|
|
21
|
+
|
|
22
|
+
Everything here is pure NumPy — no PyTorch needed.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from dataclasses import dataclass, fields, replace
|
|
28
|
+
|
|
29
|
+
import numpy as np
|
|
30
|
+
|
|
31
|
+
from ..datasets.base import SideChannelDataset
|
|
32
|
+
from .leakage import LeakageModel
|
|
33
|
+
from .metrics import RankResult, evaluate_key_rank
|
|
34
|
+
from .splits import SplitPolicy, make_splits
|
|
35
|
+
|
|
36
|
+
CLASSICAL_ATTACKS = ("cpa", "dpa", "template")
|
|
37
|
+
|
|
38
|
+
_EPS = 1e-12
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class ClassicalAttackResult:
|
|
43
|
+
"""Outcome of a classical attack (mirrors models.AttackResult, no net)."""
|
|
44
|
+
|
|
45
|
+
ranks: RankResult
|
|
46
|
+
n_train: int
|
|
47
|
+
n_test: int
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def guessing_entropy(self) -> np.ndarray:
|
|
51
|
+
return self.ranks.guessing_entropy
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def success_rate(self) -> np.ndarray:
|
|
55
|
+
return self.ranks.success_rate
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def traces_to_disclosure(self) -> int | None:
|
|
59
|
+
return self.ranks.traces_to_disclosure()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _materialize(dataset: SideChannelDataset) -> np.ndarray:
|
|
63
|
+
return np.asarray(dataset.traces[:], dtype=np.float32)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _checkpoints(limit: int, n_points: int) -> np.ndarray:
|
|
67
|
+
"""Trace-count checkpoints: denser early, log-spaced up to ``limit``."""
|
|
68
|
+
|
|
69
|
+
if limit <= n_points:
|
|
70
|
+
return np.arange(1, limit + 1)
|
|
71
|
+
pts = np.unique(np.geomspace(1, limit, n_points).round().astype(int))
|
|
72
|
+
if pts[-1] != limit:
|
|
73
|
+
pts = np.append(pts, limit)
|
|
74
|
+
return pts
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
# Non-profiled distinguishers (CPA / DPA)
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
def _nonprofiled_curves(
|
|
81
|
+
traces: np.ndarray,
|
|
82
|
+
hypotheses: np.ndarray, # (N, 256): HW values for CPA, S-box out for DPA
|
|
83
|
+
correct_key: int,
|
|
84
|
+
*,
|
|
85
|
+
kind: str, # "cpa" | "dpa"
|
|
86
|
+
dpa_bit: int = 7,
|
|
87
|
+
n_experiments: int = 100,
|
|
88
|
+
order: int = 1,
|
|
89
|
+
max_traces: int | None = None,
|
|
90
|
+
n_points: int = 40,
|
|
91
|
+
seed: int = 0,
|
|
92
|
+
) -> RankResult:
|
|
93
|
+
"""GE / SR curves for a non-profiled distinguisher.
|
|
94
|
+
|
|
95
|
+
For each of ``n_experiments`` random trace orderings, the distinguisher is
|
|
96
|
+
accumulated block-by-block up to each checkpoint (so the total matmul cost
|
|
97
|
+
per experiment is one pass over the traces), the correct key is ranked, and
|
|
98
|
+
the ranks are averaged.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
x = np.asarray(traces, dtype=np.float32)
|
|
102
|
+
n_traces, n_samples = x.shape
|
|
103
|
+
n_keys = hypotheses.shape[1]
|
|
104
|
+
limit = n_traces if max_traces is None else min(max_traces, n_traces)
|
|
105
|
+
checkpoints = _checkpoints(limit, n_points)
|
|
106
|
+
rng = np.random.default_rng(seed)
|
|
107
|
+
key = int(correct_key)
|
|
108
|
+
|
|
109
|
+
if kind == "cpa":
|
|
110
|
+
model = np.asarray(hypotheses, dtype=np.float32) # HW values
|
|
111
|
+
elif kind == "dpa":
|
|
112
|
+
model = ((np.asarray(hypotheses).astype(np.int64) >> dpa_bit) & 1).astype(np.float32)
|
|
113
|
+
else: # pragma: no cover - guarded by caller
|
|
114
|
+
raise ValueError(f"unknown non-profiled kind {kind!r}")
|
|
115
|
+
|
|
116
|
+
ge = np.zeros(len(checkpoints))
|
|
117
|
+
sr = np.zeros(len(checkpoints))
|
|
118
|
+
for _ in range(n_experiments):
|
|
119
|
+
idx = rng.permutation(n_traces)[:limit]
|
|
120
|
+
xo = x[idx]
|
|
121
|
+
mo = model[idx]
|
|
122
|
+
|
|
123
|
+
sx = np.zeros(n_samples, dtype=np.float64)
|
|
124
|
+
if kind == "cpa":
|
|
125
|
+
sxx = np.zeros(n_samples)
|
|
126
|
+
sh = np.zeros(n_keys)
|
|
127
|
+
shh = np.zeros(n_keys)
|
|
128
|
+
sxh = np.zeros((n_keys, n_samples))
|
|
129
|
+
else:
|
|
130
|
+
s1 = np.zeros((n_keys, n_samples))
|
|
131
|
+
c1 = np.zeros(n_keys)
|
|
132
|
+
|
|
133
|
+
prev = 0
|
|
134
|
+
for ci, cp in enumerate(checkpoints):
|
|
135
|
+
xb = xo[prev:cp].astype(np.float64)
|
|
136
|
+
mb = mo[prev:cp].astype(np.float64)
|
|
137
|
+
sx += xb.sum(axis=0)
|
|
138
|
+
n = cp
|
|
139
|
+
if kind == "cpa":
|
|
140
|
+
sxx += (xb * xb).sum(axis=0)
|
|
141
|
+
sh += mb.sum(axis=0)
|
|
142
|
+
shh += (mb * mb).sum(axis=0)
|
|
143
|
+
sxh += mb.T @ xb
|
|
144
|
+
num = n * sxh - sh[:, None] * sx[None, :]
|
|
145
|
+
den = np.sqrt(
|
|
146
|
+
np.maximum(n * shh - sh * sh, _EPS)[:, None]
|
|
147
|
+
* np.maximum(n * sxx - sx * sx, _EPS)[None, :]
|
|
148
|
+
)
|
|
149
|
+
score = np.abs(num / den).max(axis=1)
|
|
150
|
+
else: # dpa
|
|
151
|
+
s1 += mb.T @ xb
|
|
152
|
+
c1 += mb.sum(axis=0)
|
|
153
|
+
c0 = n - c1
|
|
154
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
155
|
+
mean1 = s1 / np.where(c1[:, None] > 0, c1[:, None], np.nan)
|
|
156
|
+
mean0 = (sx[None, :] - s1) / np.where(c0[:, None] > 0, c0[:, None], np.nan)
|
|
157
|
+
dom = np.abs(np.nan_to_num(mean1 - mean0, nan=0.0))
|
|
158
|
+
score = dom.max(axis=1)
|
|
159
|
+
prev = cp
|
|
160
|
+
|
|
161
|
+
score = score + rng.standard_normal(n_keys) * 1e-12 # break ties
|
|
162
|
+
rank = int((score > score[key]).sum())
|
|
163
|
+
ge[ci] += rank
|
|
164
|
+
sr[ci] += rank < order
|
|
165
|
+
|
|
166
|
+
ge /= n_experiments
|
|
167
|
+
sr /= n_experiments
|
|
168
|
+
return RankResult(np.asarray(checkpoints), ge, sr)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _relabel(leakage, label: str):
|
|
172
|
+
"""The user's leakage model with only its label type changed (any cipher)."""
|
|
173
|
+
|
|
174
|
+
if not any(f.name == "leakage" for f in fields(leakage)):
|
|
175
|
+
raise TypeError(f"{type(leakage).__name__} has no label type and cannot be used here.")
|
|
176
|
+
return replace(leakage, leakage=label)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def run_cpa(
|
|
180
|
+
name: str, *, leakage: LeakageModel | None = None, path: str | None = None,
|
|
181
|
+
policy: SplitPolicy | None = None, n_experiments: int = 100, order: int = 1,
|
|
182
|
+
seed: int = 0, n_points: int = 40, max_traces: int | None = None,
|
|
183
|
+
**load_kwargs,
|
|
184
|
+
) -> ClassicalAttackResult:
|
|
185
|
+
"""Correlation Power Analysis (non-profiled, Hamming-weight power model)."""
|
|
186
|
+
|
|
187
|
+
leakage = leakage or LeakageModel()
|
|
188
|
+
hw = _relabel(leakage, "hw")
|
|
189
|
+
with make_splits(name, policy=policy, path=path, **load_kwargs) as splits:
|
|
190
|
+
x = _materialize(splits.test)
|
|
191
|
+
hyp = hw.dataset_hypotheses(splits.test)
|
|
192
|
+
key = hw.true_key_byte(splits.test)
|
|
193
|
+
n_train = len(splits.train)
|
|
194
|
+
ranks = _nonprofiled_curves(
|
|
195
|
+
x, hyp, key, kind="cpa", n_experiments=n_experiments, order=order,
|
|
196
|
+
max_traces=max_traces, n_points=n_points, seed=seed,
|
|
197
|
+
)
|
|
198
|
+
return ClassicalAttackResult(ranks, n_train, len(x))
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def run_dpa(
|
|
202
|
+
name: str, *, leakage: LeakageModel | None = None, path: str | None = None,
|
|
203
|
+
policy: SplitPolicy | None = None, dpa_bit: int | None = None, n_experiments: int = 100,
|
|
204
|
+
order: int = 1, seed: int = 0, n_points: int = 40, max_traces: int | None = None,
|
|
205
|
+
**load_kwargs,
|
|
206
|
+
) -> ClassicalAttackResult:
|
|
207
|
+
"""Single-bit Differential Power Analysis (non-profiled, difference of means).
|
|
208
|
+
|
|
209
|
+
``dpa_bit`` selects the bit of the attacked intermediate; by default its most
|
|
210
|
+
significant bit (bit 7 of the AES S-box output).
|
|
211
|
+
"""
|
|
212
|
+
|
|
213
|
+
leakage = leakage or LeakageModel()
|
|
214
|
+
idm = _relabel(leakage, "id")
|
|
215
|
+
width = int(idm.n_classes).bit_length() - 1 # bits of the intermediate
|
|
216
|
+
if dpa_bit is None:
|
|
217
|
+
dpa_bit = width - 1
|
|
218
|
+
if not 0 <= dpa_bit < width:
|
|
219
|
+
raise ValueError(f"dpa_bit must be between 0 and {width - 1} for {type(leakage).__name__}.")
|
|
220
|
+
with make_splits(name, policy=policy, path=path, **load_kwargs) as splits:
|
|
221
|
+
x = _materialize(splits.test)
|
|
222
|
+
hyp = idm.dataset_hypotheses(splits.test)
|
|
223
|
+
key = idm.true_key_byte(splits.test)
|
|
224
|
+
n_train = len(splits.train)
|
|
225
|
+
ranks = _nonprofiled_curves(
|
|
226
|
+
x, hyp, key, kind="dpa", dpa_bit=dpa_bit, n_experiments=n_experiments,
|
|
227
|
+
order=order, max_traces=max_traces, n_points=n_points, seed=seed,
|
|
228
|
+
)
|
|
229
|
+
return ClassicalAttackResult(ranks, n_train, len(x))
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# ---------------------------------------------------------------------------
|
|
233
|
+
# Profiled Gaussian template attack
|
|
234
|
+
# ---------------------------------------------------------------------------
|
|
235
|
+
def _select_poi(traces: np.ndarray, labels: np.ndarray, n_poi: int, n_classes: int) -> np.ndarray:
|
|
236
|
+
"""Top-``n_poi`` samples by SNR = var(class means) / mean(class variances)."""
|
|
237
|
+
|
|
238
|
+
n_samples = traces.shape[1]
|
|
239
|
+
class_means = np.zeros((n_classes, n_samples))
|
|
240
|
+
class_vars = np.zeros((n_classes, n_samples))
|
|
241
|
+
present = np.zeros(n_classes, dtype=bool)
|
|
242
|
+
for c in range(n_classes):
|
|
243
|
+
rows = traces[labels == c]
|
|
244
|
+
if len(rows) == 0:
|
|
245
|
+
continue
|
|
246
|
+
present[c] = True
|
|
247
|
+
class_means[c] = rows.mean(axis=0)
|
|
248
|
+
class_vars[c] = rows.var(axis=0)
|
|
249
|
+
signal = class_means[present].var(axis=0)
|
|
250
|
+
noise = class_vars[present].mean(axis=0) + _EPS
|
|
251
|
+
snr = signal / noise
|
|
252
|
+
return np.argsort(snr)[::-1][:n_poi]
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _fit_templates(traces: np.ndarray, labels: np.ndarray, n_classes: int):
|
|
256
|
+
"""Per-class means + a shared (pooled) covariance on the POI subspace."""
|
|
257
|
+
|
|
258
|
+
n_poi = traces.shape[1]
|
|
259
|
+
means = np.full((n_classes, n_poi), np.nan)
|
|
260
|
+
pooled = np.zeros((n_poi, n_poi))
|
|
261
|
+
total = 0
|
|
262
|
+
for c in range(n_classes):
|
|
263
|
+
rows = traces[labels == c]
|
|
264
|
+
if len(rows) == 0:
|
|
265
|
+
continue
|
|
266
|
+
means[c] = rows.mean(axis=0)
|
|
267
|
+
centered = rows - means[c]
|
|
268
|
+
pooled += centered.T @ centered
|
|
269
|
+
total += len(rows)
|
|
270
|
+
pooled /= max(total - n_classes, 1)
|
|
271
|
+
pooled += np.eye(n_poi) * (np.trace(pooled) / n_poi * 1e-3 + _EPS) # regularize
|
|
272
|
+
inv_cov = np.linalg.inv(pooled)
|
|
273
|
+
return means, inv_cov
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _template_logliks(traces: np.ndarray, means: np.ndarray, inv_cov: np.ndarray) -> np.ndarray:
|
|
277
|
+
"""Per-trace, per-class Gaussian log-likelihood (pooled cov ⇒ const drops)."""
|
|
278
|
+
|
|
279
|
+
n, _ = traces.shape
|
|
280
|
+
n_classes = means.shape[0]
|
|
281
|
+
ll = np.full((n, n_classes), -np.inf)
|
|
282
|
+
for c in range(n_classes):
|
|
283
|
+
if np.isnan(means[c]).any():
|
|
284
|
+
continue
|
|
285
|
+
d = traces - means[c]
|
|
286
|
+
ll[:, c] = -0.5 * np.einsum("ij,jk,ik->i", d, inv_cov, d)
|
|
287
|
+
return ll
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def run_template_attack(
|
|
291
|
+
name: str, *, leakage: LeakageModel | None = None, path: str | None = None,
|
|
292
|
+
policy: SplitPolicy | None = None, n_poi: int = 20, n_experiments: int = 100,
|
|
293
|
+
order: int = 1, seed: int = 0, max_traces: int | None = None,
|
|
294
|
+
**load_kwargs,
|
|
295
|
+
) -> ClassicalAttackResult:
|
|
296
|
+
"""Gaussian template attack (profiled): fit on the profiling set, attack the
|
|
297
|
+
test set via likelihood, and rank keys with the shared log-likelihood metric.
|
|
298
|
+
The ``n_poi`` points of interest are the samples with the highest SNR on the
|
|
299
|
+
profiling traces."""
|
|
300
|
+
|
|
301
|
+
leakage = leakage or LeakageModel(leakage="hw")
|
|
302
|
+
with make_splits(name, policy=policy, path=path, **load_kwargs) as splits:
|
|
303
|
+
x_train = _materialize(splits.train)
|
|
304
|
+
y_train = leakage.dataset_labels(splits.train)
|
|
305
|
+
x_test = _materialize(splits.test)
|
|
306
|
+
hyp = leakage.dataset_hypotheses(splits.test)
|
|
307
|
+
key = leakage.true_key_byte(splits.test)
|
|
308
|
+
|
|
309
|
+
n_poi = min(n_poi, x_train.shape[1])
|
|
310
|
+
poi = _select_poi(x_train, y_train, n_poi, leakage.n_classes)
|
|
311
|
+
means, inv_cov = _fit_templates(x_train[:, poi], y_train, leakage.n_classes)
|
|
312
|
+
ll = _template_logliks(x_test[:, poi], means, inv_cov)
|
|
313
|
+
|
|
314
|
+
# log-likelihoods -> probabilities (stable softmax over classes)
|
|
315
|
+
ll = ll - ll.max(axis=1, keepdims=True)
|
|
316
|
+
probs = np.exp(ll)
|
|
317
|
+
probs /= probs.sum(axis=1, keepdims=True) + _EPS
|
|
318
|
+
|
|
319
|
+
ranks = evaluate_key_rank(
|
|
320
|
+
probs, hyp, key, n_experiments=n_experiments, order=order,
|
|
321
|
+
max_traces=max_traces, seed=seed,
|
|
322
|
+
)
|
|
323
|
+
return ClassicalAttackResult(ranks, len(x_train), len(x_test))
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def run_classical(
|
|
327
|
+
name: str, dataset: str, *, leakage: LeakageModel | None = None,
|
|
328
|
+
path: str | None = None, seed: int = 0, n_experiments: int = 100,
|
|
329
|
+
**kwargs,
|
|
330
|
+
) -> ClassicalAttackResult:
|
|
331
|
+
"""Dispatch to a classical attack by name (``"cpa"``/``"dpa"``/``"template"``)."""
|
|
332
|
+
|
|
333
|
+
common = dict(leakage=leakage, path=path, seed=seed, n_experiments=n_experiments)
|
|
334
|
+
if name == "cpa":
|
|
335
|
+
return run_cpa(dataset, **common, **kwargs)
|
|
336
|
+
if name == "dpa":
|
|
337
|
+
return run_dpa(dataset, **common, **kwargs)
|
|
338
|
+
if name == "template":
|
|
339
|
+
return run_template_attack(dataset, **common, **kwargs)
|
|
340
|
+
raise ValueError(f"unknown classical attack {name!r}; choose from {CLASSICAL_ATTACKS}")
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
__all__ = [
|
|
344
|
+
"CLASSICAL_ATTACKS",
|
|
345
|
+
"ClassicalAttackResult",
|
|
346
|
+
"run_classical",
|
|
347
|
+
"run_cpa",
|
|
348
|
+
"run_dpa",
|
|
349
|
+
"run_template_attack",
|
|
350
|
+
]
|