Toolkit API
Reference for the seven modules under toolkit/: signatures,
defaults, gotchas, and copy-paste call patterns.
- Setup — getting the toolkit importable
- Self-test — ./run_selftests.sh
- qsim — circuit run/build helpers
- crypto_utils — classical finish (AES/XOR/bytes)
- bb84 — QKD simulator + transcript solver
- chsh — Bell-inequality game
- qasm_tools — QASM load/emit + golf linter
- algorithms — Grover + Shor-style factoring
- pqc — Kyber/ML-KEM, Dilithium/ML-DSA
- Challenge shapes — end-to-end chains
Setup
All seven modules live at toolkit/*.py with no __init__.py — Python 3's
implicit namespace packages make toolkit importable as a package as long as the repo
root is on sys.path. There is no installed package; nothing is pip install -e'd.
cd ~/LUME/qctf first) — a script's own directory is what Python puts on
sys.path by default, not your shell's cwd, so a solver script saved anywhere still
needs an explicit path insert.# 1. venv
cd ~/LUME/qctf
source .venv/bin/activate # or call .venv/bin/python directly
# 2. at the top of your solver script:
import sys
sys.path.insert(0, ".")
from toolkit import bb84, crypto_utils # package style — matches module docstrings
# — or, bare-name style (matches how the modules import each other internally,
# e.g. bb84.py does `from crypto_utils import bits_to_bytes, xor`) —
sys.path.insert(0, "toolkit")
import bb84, crypto_utils
Both forms were run from a script file outside the repo (cwd forced to the repo root) and both
import and execute cleanly. A quick one-off in the interpreter or python -c also works
without any path insert, because -c/stdin mode puts cwd on sys.path
automatically — scripts saved to a .py file do not get that for free.
cd ~/LUME/qctf and run from another directory,
PYTHONPATH=~/LUME/qctf .venv/bin/python your_solver.py is the fallback —
same effect as the sys.path.insert line, done from the shell.Self-test
./run_selftests.sh (from the repo root) runs every module's _selftest()
via .venv/bin/python toolkit/<module>.py and prints the last line of each — green
across the board means the toolkit is CTF-ready:
qsim: qsim self-test OK
crypto_utils: crypto_utils self-test OK
qasm_tools: qasm_tools self-test OK (sample = 3 operative lines)
chsh: chsh self-test OK (quantum 0.858 vs classical 0.750, Tsirelson 0.854)
bb84: bb84 self-test OK (sifted 182/400 clean, Eve QBER 0.233)
algorithms: algorithms self-test OK (grover -> 101, shor 3233 -> 61*53)
pqc: pqc self-test OK (KEM ML-KEM-768 shared-secret match; SIG ML-DSA-65 verify+reject)
What each assert actually checks:
| Module | Self-test asserts |
|---|---|
qsim | Bell pair measured 2000 shots yields only 00/11, and the 00 share is 0.4–0.6. |
crypto_utils | bits_to_bytes round-trips a known bit string to b"Hi"; XOR is its own inverse; base64 round-trips. |
qasm_tools | A 7-line sample QASM program parses to 2 qubits, has exactly 3 operative lines, gate_count is 4 (h + cx + 2 measures), and golf_report reports OK/OVER correctly against budgets of 7 and 2. |
chsh | The scripted classical strategy hits exactly 0.75; the quantum strategy beats 0.80 over 8000 shots per input pair (Tsirelson bound is ~0.8536). |
bb84 | Noiseless: reconstructed key equals Alice's sifted bits, QBER 0. With eve=True over 4000 pulses: QBER lands in (0.18, 0.32), i.e. near the 0.25 intercept-resend signature. Also runs the sifted-key → bytes → XOR chain end to end. |
algorithms | Grover on a single 3-qubit target recovers it exactly; two non-degenerate targets (M/N=2/8) both dominate the readout; shor_factor correctly factors 15 and the toy RSA modulus 3233 = 53·61. |
pqc | KEM: encap/decap shared secrets match and are ≥16 bytes. Signatures: a valid signature verifies, and verification fails once the message is tampered with. Skips cleanly (prints SKIPPED, exit 0) if import oqs fails — no liboqs on the machine is not a toolkit failure. |
qsim
Thin Qiskit helpers used across every category that touches a circuit: build, run, inspect. Nothing category-specific — this is the base every other quantum module sits on.
| Signature | Returns | Description |
|---|---|---|
bell_pair() -> QuantumCircuit | QuantumCircuit | 2-qubit, 2-clbit circuit: H(0), CX(0,1), measure both. |00>+|11>. |
run_counts(qc, shots=1024) -> dict[str, int] | {bitstring: count} | Runs a measured circuit on AerSimulator(). |
statevector(qc) -> Statevector | Qiskit Statevector | Strips final measurements first, then Statevector.from_instruction. Circuit must be otherwise unmeasured mid-circuit. |
show(qc) -> str | str | ASCII circuit diagram (qc.draw(output="text")), safe to print in a terminal. |
run_counts passes that convention straight through
uninterpreted; don't re-reverse it unless a challenge's own convention says otherwise.from toolkit import qsim
qc = qsim.bell_pair()
print(qsim.show(qc))
counts = qsim.run_counts(qc, shots=2000)
print(counts) # {'11': 1024, '00': 976}
sv = qsim.statevector(qc)
print(sv) # Statevector([0.707+0j, 0, 0, 0.707+0j], dims=(2, 2))
crypto_utils
Classical glue for the "now decrypt the flag" finish that most quantum challenges end with — once you've recovered key bits from a BB84 transcript or a CHSH-style side channel, this turns them into bytes and runs the AES/XOR step so the quantum solver stays focused on the quantum part.
| Signature | Returns | Description |
|---|---|---|
bits_to_bytes(bits: list[int] | str) -> bytes | bytes | [1,0,1,1,...] or "1011..." → bytes, MSB-first, left-padded to a whole byte (non-0/1 chars in a string are dropped). |
xor(a: bytes, b: bytes) -> bytes | bytes | Repeating-key XOR — b is cycled to the length of a. |
aes_decrypt(ciphertext, key, iv=None, mode="CBC") -> bytes | bytes | mode in {"ECB","CBC","CTR"}. iv required for CBC/CTR (CTR treats it as a big-endian initial counter value). No padding is stripped — do that yourself (e.g. PKCS7 unpad). |
b64(data: bytes) -> str | str | base64.b64encode then decode to str. |
unb64(s: str) -> bytes | bytes | base64.b64decode. |
from toolkit import crypto_utils as cu
key = cu.bits_to_bytes("0100100001101001") # b'Hi'
msg = b"flag{demo}"
ct = cu.xor(msg, key)
print(cu.b64(ct)) # LgUpDjMNLQQnFA==
pt = cu.xor(cu.unb64(cu.b64(ct)), key)
print(pt) # b'flag{demo}'
bb84
[Day 7] BB84 quantum key distribution — simulator and transcript solver. QKD category: given a captured transcript (Alice's bases, Bob's bases, Bob's measured bits) reconstruct the sifted shared key, and use QBER to detect an eavesdropper.
0 = rectilinear/Z (|0>,|1>), 1 = diagonal/X (|+>,|->).
Encoding: (Z,0)→|0>, (Z,1)→|1>, (X,0)→|+>, (X,1)→|->.
When Bob's basis matches Alice's he recovers the bit deterministically; on a mismatch his result is
uniformly random. Intercept-resend Eve injects ~25% error into the sifted bits — that's the
detection signal.| Signature | Returns | Description |
|---|---|---|
simulate_bb84(n=256, eve=False, seed=None) -> Transcript | Transcript dataclass | Runs an n-pulse exchange. Fields: alice_bits, alice_bases, bob_bases, bob_bits, eve_bases, sifted_positions, sifted_key. eve=True adds an intercept-resend eavesdropper. |
sift(alice_bases, bob_bases, bits) -> list[int] | list[int] | Keeps bits at positions where the two basis lists agree. bits is whichever side's bits you have at those positions. |
reconstruct_key(alice_bases, bob_bases, bob_bits) -> list[int] | list[int] | Alias of sift() — the core transcript → key step. |
qber(key_a, key_b) -> float | float | Fraction of mismatched bits between two sifted keys. ~0 = clean channel; ~0.25 = intercept-resend Eve. Returns 0.0 for an empty key_a. |
Transcript dataclass fields, for reference: alice_bits, alice_bases,
bob_bases, bob_bits, eve_bases=None, sifted_positions=[], sifted_key=[].
CLI, for sifting a transcript straight from the shell without writing a script:
python toolkit/bb84.py --sift 0101 0011 1010
# prints the key from positions where alice_bases[i] == bob_bases[i]
from toolkit import bb84, crypto_utils as cu
t = bb84.simulate_bb84(n=512, seed=42)
key_bits = bb84.reconstruct_key(t.alice_bases, t.bob_bases, t.bob_bits)
print(len(key_bits)) # 261
key_bytes = cu.bits_to_bytes(key_bits)
alice_sifted = [t.alice_bits[i] for i in t.sifted_positions]
print(bb84.qber(alice_sifted, key_bits)) # 0.0 (no Eve here)
chsh
[Day 6] CHSH game — Bell-pair strategy and win-rate checker. "Proof of entanglement" category: demonstrate or reproduce a win rate above the 0.75 classical ceiling.
x, Bob bit y (independent, uniform);
no communication allowed; they win when a XOR b == x AND y. Best classical strategy
caps at 0.75. Sharing a Bell pair and measuring at the angles below wins
cos²(π/8) ≈ 0.854 (Tsirelson bound). Optimal angles in the X-Z plane on
|Φ+> = (|00>+|11>)/√2: Alice x=0→0, x=1→π/4; Bob
y=0→π/8, y=1→-π/8. To measure at angle θ in the X-Z plane: Ry(-2θ) then
measure in Z — that's exactly what _round_circuit does internally.| Signature | Returns | Description |
|---|---|---|
quantum_winrate(shots=4000) -> float | float | Average win probability of the optimal quantum strategy over all 4 (x,y) pairs, shots per pair. ~0.854. |
classical_winrate(strategy=lambda x,y: (0,0)) -> float | float | Exact (not sampled) win rate of a deterministic strategy(x, y) -> (a, b) over all 4 inputs. Default "always output 0" hits the 0.75 ceiling. |
Module-level constants worth knowing: ALICE_ANGLE = {0: 0.0, 1: pi/4},
BOB_ANGLE = {0: pi/8, 1: -pi/8}.
from toolkit import chsh
q = chsh.quantum_winrate(shots=4000)
c = chsh.classical_winrate()
print(f"quantum={q:.3f} classical={c:.3f}") # quantum=0.849 classical=0.750
qasm_tools
[Day 5] OpenQASM load/emit plus a golf linter for "≤ N lines" budget challenges (e.g. an "XOR-bit-al"-style circuit-golf task). Loads QASM 2.0/3.0, counts the lines a golf challenge actually counts, and round-trips through the simulator so you can verify a golfed circuit still behaves the same.
| Signature | Returns | Description |
|---|---|---|
load_qasm(source: str) -> QuantumCircuit | QuantumCircuit | source is a QASM string, or (if it's a single line ending in .qasm) a file path. Auto-detects 2.0 vs 3.0 by checking for "openqasm 3" (case-insensitive) in the text. |
to_qasm(qc, version=2) -> str | str | Emits OpenQASM; version=2 (default) uses qasm2.dumps, version=3 uses qasm3.dumps. |
operative_lines(qasm_str) -> list[str] | list[str] | Non-blank, non-comment lines that aren't a declaration/header (skips lines starting with openqasm, include, qreg, creg, qubit, bit, gate case-insensitively). |
line_count(qasm_str) -> int | int | len(operative_lines(...)) — this is what a "≤ N lines" budget counts. |
gate_count(qc) -> int | int | Number of instructions in the circuit, excluding barriers. Not the same number as line_count. |
golf_report(qasm_str, budget=None) -> str | str | Human-readable numbered listing of operative lines; if budget is given, appends OK/OVER. |
gate_count and line_count measure different things and will
diverge. A single QASM 2.0 line measure q -> c; expands to one measure
instruction per qubit, so for a 2-qubit circuit with H, CX and that one measure line,
line_count == 3 but gate_count == 4 (h + cx + 2 measures). Golf challenges
count lines — use line_count/golf_report for the budget check, not
gate_count.from toolkit import qasm_tools as qt
sample = """OPENQASM 2.0;
include "qelib1.inc";
qreg q[2];
creg c[2];
h q[0];
cx q[0],q[1];
measure q -> c;
"""
qc = qt.load_qasm(sample)
print(qt.golf_report(sample, budget=3))
# 3 operative line(s) / budget 3 -> OK
# 1: h q[0];
# 2: cx q[0],q[1];
# 3: measure q -> c;
print(qt.gate_count(qc), qt.line_count(sample)) # 4 3
algorithms
[Days 9–10] Grover search and Shor-style factoring. Grover: "find the input that makes this predicate true" over a small search space. Shor: "factor this modulus" / "break this small RSA".
| Signature | Returns | Description |
|---|---|---|
phase_oracle(targets: list[str], n: int) -> QuantumCircuit | QuantumCircuit | Flips the phase of each target bitstring. Bitstrings are big-endian (leftmost char = most significant qubit) — the opposite convention from Qiskit's own little-endian counts keys. |
grover(targets, n, iterations=None, shots=2000) -> dict[str, int] | {bitstring: count} | Full amplitude-amplification run. iterations defaults to floor((π/4)·√(2ⁿ/m)) where m = max(1, len(targets)). |
grover_search(targets, n, shots=2000) -> str | str | Runs grover() and returns the most-frequent bitstring — the recovered answer. |
multiplicative_order(a, N) -> int | int | Smallest r > 0 with a^r ≡ 1 (mod N). Raises ValueError if gcd(a, N) != 1. This is the step Shor's algorithm does quantumly; here it's brute-forced classically. |
shor_factor(N) -> tuple[int, int] | (p, q) with p*q == N | Classical order-finding walk over bases a = 2..N-1, deterministic and reproducible. Handles even N directly; raises ValueError if it exhausts all bases without finding a nontrivial factor. |
grover_search returns something
that doesn't look dominant, check how many targets you're marking relative to 2**n;
add a qubit (widen n) or narrow the target set.from toolkit import algorithms as alg
# targets are big-endian strings of width n
answer = alg.grover_search(["1011"], n=4)
print(answer) # '1011'
p, q = alg.shor_factor(3233)
print(p, q) # 61 53
pqc
Post-quantum crypto: Kyber/ML-KEM encapsulation and Dilithium/ML-DSA signatures, wrapping
liboqs-python (import oqs). For the "recover the shared secret" or
"forge/verify a signature" mechanics, so you can focus on the actual bug (nonce reuse, malformed
ciphertext, key confusion, etc).
import oqs — if liboqs-python
isn't installed, every call raises ImportError immediately. The module's own
self-test catches this and skips cleanly (prints
pqc self-test SKIPPED (liboqs-python not installed — see env/setup.md), exit 0)
rather than failing — worth knowing before you assume a red run_selftests.sh line
means a real bug. On this machine oqs is installed, so the self-test actually runs the
full KEM + signature round trip.| Signature | Returns | Description |
|---|---|---|
kem_alg() -> str | str | First of ["ML-KEM-768", "Kyber768"] enabled in this liboqs build. |
sig_alg() -> str | str | First of ["ML-DSA-65", "Dilithium3"] enabled in this liboqs build. |
kyber_keypair(alg=None) -> tuple[bytes, bytes] | (public_key, secret_key) | alg defaults to kem_alg(). |
kyber_encaps(public_key, alg=None) -> tuple[bytes, bytes] | (ciphertext, shared_secret) | Encapsulate to a public key. |
kyber_decaps(secret_key, ciphertext, alg=None) -> bytes | shared secret | Decapsulate with the secret key. |
dilithium_keypair(alg=None) -> tuple[bytes, bytes] | (public_key, secret_key) | alg defaults to sig_alg(). |
dilithium_sign(secret_key, message, alg=None) -> bytes | signature bytes | Sign a message. |
dilithium_verify(public_key, message, signature, alg=None) -> bool | bool | True iff the signature verifies against the message and key. |
from toolkit import pqc
pk, sk = pqc.kyber_keypair()
ct, secret_enc = pqc.kyber_encaps(pk)
secret_dec = pqc.kyber_decaps(sk, ct)
print(secret_enc == secret_dec) # True
vpk, vsk = pqc.dilithium_keypair()
msg = b"flag{pqc_demo}"
sig = pqc.dilithium_sign(vsk, msg)
print(pqc.dilithium_verify(vpk, msg, sig)) # True
print(pqc.dilithium_verify(vpk, msg + b"!", sig)) # False
Challenge shapes — end-to-end chains
Three complete, runnable scripts for the shapes these challenges actually take. Each was executed
against the repo venv; output is shown as a comment where it matters. Run with
cd ~/LUME/qctf && .venv/bin/python your_script.py.
1. BB84 transcript → sift → bytes → AES-CBC → flag
The captured transcript gives you alice_bases, bob_bases,
bob_bits; the challenge ships an AES-CBC ciphertext + IV encrypted under the sifted
key. This script fabricates a matching ciphertext with the simulator (a stand-in for what a
challenge would hand you) and then does the actual solve: transcript → key → decrypt.
import sys
sys.path.insert(0, ".")
from toolkit import bb84, crypto_utils as cu
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
# --- stand-in for "the challenge hands you a transcript + ciphertext" ---
FLAG = b"flag{bb84_then_aes_cbc}"
t = bb84.simulate_bb84(n=1024, seed=99)
alice_bases, bob_bases, bob_bits = t.alice_bases, t.bob_bases, t.bob_bits
key_bits = bb84.reconstruct_key(alice_bases, bob_bases, bob_bits)
key = cu.bits_to_bytes(key_bits)[:16]
iv = bytes(16)
ciphertext = AES.new(key, AES.MODE_CBC, iv).encrypt(pad(FLAG, 16))
# --- the actual solve: recover the key from the transcript, decrypt ---
recovered_bits = bb84.reconstruct_key(alice_bases, bob_bases, bob_bits)
recovered_key = cu.bits_to_bytes(recovered_bits)[:16]
plaintext = unpad(cu.aes_decrypt(ciphertext, recovered_key, iv, mode="CBC"), 16)
print("recovered flag:", plaintext)
# recovered flag: b'flag{bb84_then_aes_cbc}'
2. QASM given → load_qasm → golf_report → shortened
A challenge gives you a QASM program and a line budget it's over. Load it, see which lines
count, spot the redundancy (here: h q[0]; h q[0]; cancels to identity), golf it, and
confirm the shortened version still measures the same.
import sys
sys.path.insert(0, ".")
from toolkit import qasm_tools as qt, qsim
given = """OPENQASM 2.0;
include "qelib1.inc";
qreg q[2];
creg c[2];
h q[0];
h q[0];
cx q[0],q[1];
measure q -> c;
"""
print(qt.golf_report(given, budget=3))
# 4 operative line(s) / budget 3 -> OVER
# 1: h q[0];
# 2: h q[0];
# 3: cx q[0],q[1];
# 4: measure q -> c;
golfed = """OPENQASM 2.0;
include "qelib1.inc";
qreg q[2];
creg c[2];
cx q[0],q[1];
measure q -> c;
"""
print(qt.golf_report(golfed, budget=3))
# 2 operative line(s) / budget 3 -> OK
# confirm the golfed circuit still behaves the same
print(qsim.run_counts(qt.load_qasm(given), shots=1000)) # {'00': 1000}
print(qsim.run_counts(qt.load_qasm(golfed), shots=1000)) # {'00': 1000}
3. Unknown predicate → phase_oracle (via grover_search) → answer
A challenge exposes a black-box predicate over an n-bit input and asks for the input that makes it true. Evaluate the predicate classically (or however the challenge lets you probe it) to build the target list, then let Grover pick it out of the search space.
import sys
sys.path.insert(0, ".")
from toolkit import algorithms as alg
def predicate(x: str) -> bool:
return x == "1101" # stand-in for a real black box
n = 4
targets = [format(v, f"0{n}b") for v in range(2 ** n) if predicate(format(v, f"0{n}b"))]
print("targets:", targets) # targets: ['1101']
answer = alg.grover_search(targets, n=n, shots=2000)
print("recovered input:", answer) # recovered input: 1101
assert predicate(answer)