Automated timing side-channel scanner for NIST PQC standards ML-KEM and ML-DSA
Project description
PQC Side-Channel Scanner
An open-source automated timing side-channel scanner for NIST post-quantum cryptography standards. Detects constant-time violations in ML-KEM (FIPS 203) and ML-DSA (FIPS 204) implementations using TVLA statistical methodology.
Built by Disha P, Technical Director at Collective Qubits.
Why This Exists
NIST finalized the post-quantum cryptography standards in August 2024. Banks, cloud providers, and government agencies are actively deploying ML-KEM and ML-DSA right now to protect against future quantum attacks. But timing side-channel vulnerabilities in cryptographic implementations can leak secret key material to an attacker — even when the underlying algorithm is mathematically secure.
Existing tools like dudect require manual C-level integration, have no PQC-specific targets, and predate the NIST standards by years. No open-source automated scanner existed for FIPS 203 and FIPS 204 specifically.
This tool fills that gap.
What It Found
Python layer — ML-KEM-768 (kyber-py)
| Operation | t-statistic | Verdict |
|---|---|---|
| Decapsulation | -3868 | CRITICAL LEAK |
| Encapsulation | -3377 | CRITICAL LEAK |
| KeyGen | 3.1 | PASS |
Note: kyber-py is a documented non-constant-time reference implementation. These findings are expected and not novel.
Python layer — ML-DSA-65 (dilithium-py)
| Operation | t-statistic | Verdict |
|---|---|---|
| Sign | 0.4 | PASS |
| Verify | 15.4 | LEAK |
| KeyGen | 7.1 | LEAK |
FIPS 203 — ML-KEM (all parameter sets), liboqs 0.15.0, 100k traces
| Algorithm | Operation | t-statistic | Delta | Verdict |
|---|---|---|---|---|
| ML-KEM-512 | Encapsulation | -4.9 | 250ns | CRITICAL |
| ML-KEM-512 | Decapsulation | 0.5 | 39ns | PASS |
| ML-KEM-768 | Encapsulation | 75.5 | 264ns | CRITICAL |
| ML-KEM-768 | Decapsulation | -3.2 | 39ns | PASS |
| ML-KEM-1024 | Encapsulation | -12.6 | 1011ns | CRITICAL |
| ML-KEM-1024 | Decapsulation | -0.4 | 45ns | PASS |
Bare metal confirmed (GitHub Actions ubuntu-latest):
| Algorithm | Operation | t-statistic | Delta | Verdict |
|---|---|---|---|---|
| ML-KEM-512 | Encapsulation | -8.37 | 87ns | CRITICAL |
| ML-KEM-768 | Encapsulation | -13.68 | 298ns | CRITICAL |
| ML-KEM-1024 | Encapsulation | -51.99 | 517ns | CRITICAL |
Root cause — encaps_derand (RNG bypassed, same public key):
| Build | t-statistic | Delta | Verdict |
|---|---|---|---|
| Portable C | 0.66 | 7ns | PASS |
| AVX2 | 0.93 | 15ns | PASS |
Interpretation: Encapsulation leaks across all three ML-KEM parameter sets on both WSL2 and bare metal. Root cause confirmed as RNG-induced — OQS_randombytes() inside OQS_KEM_encaps() has variable timing. When bypassed via encaps_derand(), both builds pass. The cryptographic core is clean. Decapsulation passes on all parameter sets.
FIPS 204 — ML-DSA (all parameter sets), liboqs 0.15.0, 100k traces
| Algorithm | Operation | t-statistic | Verdict |
|---|---|---|---|
| ML-DSA-44 | Sign | 0.21 | PASS |
| ML-DSA-44 | Verify | -0.59 | PASS |
| ML-DSA-65 | Sign | 0.13 | PASS |
| ML-DSA-65 | Verify | -1.02 | PASS |
| ML-DSA-87 | Sign | -0.38 | PASS |
| ML-DSA-87 | Verify | -0.64 | PASS |
Interpretation: ML-DSA is clean across all three parameter sets and both sign and verify operations.
FIPS 206 — FN-DSA/FALCON (all variants), liboqs 0.15.0, 100k traces
| Algorithm | Operation | t-statistic | Delta | Verdict |
|---|---|---|---|---|
| FALCON-512 | Sign | 1.55 | 2186ns | PASS |
| FALCON-512 | Verify | -6.92 / -10.04 | ~6100ns | CRITICAL |
| FALCON-1024 | Sign | 1.03 | 2940ns | PASS |
| FALCON-1024 | Verify | -9.81 / -10.58 | ~9500ns | CRITICAL |
| FALCON-padded-512 | Verify | -9.95 | 7689ns | CRITICAL |
| FALCON-padded-1024 | Verify | -11.97 | 9637ns | CRITICAL |
Root cause investigation:
- Same message, different signatures → still CRITICAL (t=-53.86)
- Padded variants (fixed-length signatures) → still CRITICAL
- Verify has no RNG call — cannot be RNG-induced
- Conclusion: Signature-content dependent timing in FALCON verify. Algorithmic leak.
Interpretation: FALCON verify leaks across all four variants including padded (fixed-length) variants. The leak persists with the same message and different signatures, confirming it is signature-content dependent, not message or length dependent. Signing passes on all variants. No open issue exists for this finding on the liboqs GitHub.
FIPS 205 — SLH-DSA (all parameter sets), liboqs 0.15.0, 50k traces
SLH-DSA verify shows variable timing across parameter sets (some CRITICAL, some PASS). This is consistent with the inherently variable-time nature of Merkle tree traversal in hash-based signatures and is expected behavior by design, not an implementation vulnerability.
C-level noise floor (AES-128): 11.7ns. ML-KEM encapsulation finding is 22× above noise floor on WSL2, confirmed on bare metal GitHub Actions runners.
How It Works
The scanner implements Test Vector Leakage Assessment (TVLA) — the same methodology used by Riscure and documented in ISO/IEC 17825. For each cryptographic operation it:
- Collects timing measurements for a fixed input (same value every time)
- Collects timing measurements for random inputs (different every time)
- Runs Welch's t-test on the two distributions
- Flags as leaking if |t| > 4.5 — the ISO 17825 threshold
The scanner operates at two measurement layers:
- Python layer — targets kyber-py and dilithium-py via
time.perf_counter_ns(), detects millisecond-level leaks - C layer — targets liboqs directly with
CLOCK_MONOTONIC_RAW, detects nanosecond-level leaks
Installation
Quickstart
pip install pqc-scanner
Docker (no setup required)
docker build -t pqc-scanner .
docker run pqc-scanner python3 cli.py liboqs --traces 50000
docker run pqc-scanner python3 cli.py derand --traces 100000
No liboqs installation, no gcc, no Python setup required. Everything is bundled.
From source (recommended for full C harness support)
Requirements
- Ubuntu 20.04+ or WSL2 (Ubuntu 24.04 tested)
- Python 3.10+
- gcc and clang
- liboqs (instructions below)
1. Clone the repository
git clone https://github.com/Disha23112004/pqc-scanner
cd pqc-scanner
2. Set up Python environment
python3 -m venv venv
source venv/bin/activate
pip install kyber-py dilithium-py scipy numpy click rich
3. Install liboqs
sudo apt install -y cmake ninja-build libssl-dev clang
git clone --depth=1 https://github.com/open-quantum-safe/liboqs ~/liboqs
cmake -S ~/liboqs -B ~/liboqs/build -DBUILD_SHARED_LIBS=ON -GNinja
cmake --build ~/liboqs/build --parallel 4
sudo cmake --build ~/liboqs/build --target install
sudo ldconfig
4. Build the C harnesses
cd harness
# Production scanner (liboqs ML-KEM-768)
gcc -O2 -o ml_kem_harness ml_kem_harness.c -loqs -lssl -lcrypto
# Noise floor baseline (AES-128)
gcc -O2 -o baseline_harness baseline_harness.c -lssl -lcrypto
cd ..
Usage
Always activate the virtual environment first:
source venv/bin/activate
Production scan (liboqs C library) — primary scan
python cli.py liboqs --traces 100000
Uses CLOCK_MONOTONIC_RAW via C harness for nanosecond precision. 100,000 traces recommended for statistical confidence.
Scan ML-KEM and ML-DSA (Python targets)
python cli.py scan --algorithm both --traces 10000 --pin-cpu
Options:
--algorithm—ml-kem,ml-dsa, orboth--traces— number of measurements per operation (default 10000)--pin-cpu— pin process to CPU core 0 for lower noise--no-exit— do not exit with code 1 on findings (for scripting)--open-html— open HTML report in browser automatically
Algorithm comparison
python cli.py compare --traces 10000 --pin-cpu
Scans ML-KEM-768 and ML-DSA-65 side by side. Produces a comparison HTML report with bar chart.
Python noise floor characterization
python cli.py baseline --traces 10000 --pin-cpu
Runs SHA-256 as a control experiment. SHA-256 is provably constant-time so any signal here is measurement noise.
C-level noise floor characterization
python cli.py c-baseline --traces 100000
Runs AES-128 (AES-NI hardware instruction) through the same C harness. Reports the noise floor delta so you can assess signal-to-noise ratio of liboqs findings.
RNG root cause isolation (derand)
python cli.py derand --traces 100000 Bypasses the OS RNG using encaps_derand() with a fixed seed. PASS = leak is RNG-induced. LEAK = algorithmic. Run after liboqs scan to confirm root cause.
Compiler flag sweep
python cli.py compiler-sweep --traces 50000 --runs 3
Builds the C harness with six compiler flag combinations and scans each. Searches the same optimization space where KyberSlash was found.
Output
Every scan produces:
*.json— structured findings for CI/CD integration*.html— interactive report with timing distribution histograms
JSON format
{
"scanner": "pqc-scanner v1.1.3",
"target": "liboqs ML-KEM-768",
"summary": {
"total": 1,
"critical": 1,
"high": 0,
"medium": 0
},
"findings": [
{
"operation": "ML-KEM Encapsulation",
"t_statistic": 75.5185,
"p_value": 0.0,
"delta_ns": 264.0,
"severity": "CRITICAL",
"verdict": "LEAK DETECTED"
}
]
}
CI/CD Integration
The scanner exits with code 1 when findings are detected:
# GitHub Actions example
- name: PQC Timing Scan
run: |
source venv/bin/activate
python cli.py liboqs --traces 50000
# Pipeline fails automatically if leaks are found
Use --no-exit to suppress the exit code in reporting-only mode.
SARIF output (GitHub Advanced Security)
python cli.py liboqs --traces 100000 --format sarif --output results
- name: PQC Timing Scan
run: python cli.py liboqs --traces 50000 --no-exit --format sarif --output pqc
- name: Upload to GitHub Security
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: pqc.sarif
Findings appear under Security → Code scanning in your repository.
Methodology
TVLA (Test Vector Leakage Assessment)
Developed by Cryptography Research Inc. and documented in ISO/IEC 17825. The core principle: if an implementation is constant-time, its execution time should be statistically independent of the input.
Welch's t-test
Used instead of Student's t-test because it does not assume equal variance between distributions. Threshold: |t| > 4.5 indicates statistically significant timing leakage.
Derandomization (derand) technique
A novel root-cause isolation method introduced in this tool. By calling encaps_derand() directly with a fixed 32-byte seed, bypassing OQS_randombytes() entirely, RNG-induced timing variation is eliminated from the measurement window. Comparing standard TVLA results against derand results distinguishes algorithmic leakage from RNG-induced leakage — a distinction critical for correct remediation. This technique is not present in prior published tooling.
Measurement precision
- Python layer —
time.perf_counter_ns(), noise floor ~100–130ns on WSL2 - C layer —
CLOCK_MONOTONIC_RAWviaclock_gettime(), noise floor ~10–12ns on WSL2
Noise reduction
- 500-trace warmup — discards initial measurements to stabilize CPU cache and branch predictor
- 5% outlier trimming — removes top 5% of measurements to eliminate OS interrupt spikes
- CPU affinity —
sched_setaffinitypins process to core 0 to reduce scheduler noise - Pre-generated test vectors — random inputs generated before the timed window so
os.urandom()does not contaminate measurements
WSL2 limitations
WSL2 adds ~10–130ns of hypervisor noise depending on system load. Sub-50ns signals require bare metal Linux for confirmation. The decapsulation finding at t=-3.2 was not stable across runs and cannot be confirmed.
Project Structure
pqc-scanner/
├── cli.py ← all commands
├── requirements.txt
├── src/pqc_scanner/
│ ├── scanner/
│ │ ├── timing.py ← TVLA engine, warmup, outlier trim, CPU affinity
│ │ ├── static.py ← static analysis for crypto context
│ │ └── report.py ← JSON and HTML report generation
│ └── targets/
│ ├── ml_kem_target.py ← ML-KEM-768 Python harness (kyber-py)
│ ├── ml_dsa_target.py ← ML-DSA-65 Python harness (dilithium-py)
│ ├── baseline_target.py ← SHA-256 control experiment
│ └── liboqs_target.py ← liboqs subprocess interface
└── harness/
├── ml_kem_harness.c ← C timing harness, CLOCK_MONOTONIC_RAW
├── ml_kem_harness_derand.c ← derand harness for RNG root cause isolation
├── ml_dsa_harness.c ← ML-DSA-65 sign/verify harness
└── baseline_harness.c ← AES-128 noise floor harness
Comparison With Existing Tools
| Capability | dudect | Riscure | This tool |
|---|---|---|---|
| ML-KEM / ML-DSA targets | ✗ | ✗ | ✓ |
| liboqs integration | ✗ | ✗ | ✓ |
| Python + C dual layer | ✗ | ✗ | ✓ |
| Derand root-cause isolation | ✗ | ✗ | ✓ |
| Automated HTML reporting | ✗ | ✓ (hardware) | ✓ |
| CI/CD exit codes | ✗ | ✗ | ✓ |
| Noise floor validation | ✗ | ✓ (hardware) | ✓ |
| No hardware required | ✓ | ✗ | ✓ |
| Single command scan | ✗ | ✗ | ✓ |
| Open source | ✓ | ✗ | ✓ |
Related Work
- dudect — Reparaz et al. (2016). General-purpose constant-time testing library in C. Requires source-level integration. No PQC targets.
- KyberSlash — Bernstein et al. (2024). Manual timing analysis finding secret-dependent division leak in Kyber implementations.
- TVLA — Cryptography Research Inc. (2011). Original leakage assessment methodology, standardized in ISO/IEC 17825.
- ISO/IEC 17825:2024 — Updated standard for testing non-invasive attack mitigations in cryptographic modules.
- liboqs — Open Quantum Safe project. C library for post-quantum cryptographic algorithms.
Limitations
- WSL2 hypervisor adds noise. Sub-50ns findings require bare metal Linux for confirmation.
- kyber-py and dilithium-py are documented as non-constant-time reference implementations. Python layer findings for these targets are expected and not novel.
- liboqs findings are statistically confirmed. Exploitability in a real network attack scenario requires further research beyond timing measurement.
- Compiler sweep results below 50ns delta on WSL2 are marked inconclusive.
- TVLA detects first-order leakage. Higher-order leakage in masked implementations may require higher-order TVLA extensions not yet implemented.
- A PASS result does not prove constant-time behavior — it means no significant signal was detected at the given trace count and threshold.
License
MIT License. See LICENSE for details.
Citation
If you use this tool in research, please cite:
Disha P, "PQC Side-Channel Scanner: Automated TVLA-based timing analysis for
NIST post-quantum cryptography standards," 2026.
https://github.com/Disha23112004/pqc-scanner
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file pqc_scanner-1.3.0.tar.gz.
File metadata
- Download URL: pqc_scanner-1.3.0.tar.gz
- Upload date:
- Size: 41.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
250c1670a6531a47f7382bb0a831ee74b88ce1caa0ecd68c9a1f27230daa630d
|
|
| MD5 |
a8a218d6544638399f2c0b26eebf53d0
|
|
| BLAKE2b-256 |
d72ed1a7bd85304f4f3ba85792ff5fc0400929717216904f8f652ea4d6d7fb8c
|
File details
Details for the file pqc_scanner-1.3.0-py3-none-any.whl.
File metadata
- Download URL: pqc_scanner-1.3.0-py3-none-any.whl
- Upload date:
- Size: 39.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0d92a06cc101d81366b445b4a7c68f765991418cb94f7a4e31f680ae6f979157
|
|
| MD5 |
0d2ad3396f9a414febfe6451600f7252
|
|
| BLAKE2b-256 |
5e479e741d72989f5771fdb7a0b257a84f27999fb9d89b4fba12af94c06da18d
|