Toolkit API

Reference for the seven modules under toolkit/: signatures, defaults, gotchas, and copy-paste call patterns.

On this page
  1. Setup — getting the toolkit importable
  2. Self-test — ./run_selftests.sh
  3. qsim — circuit run/build helpers
  4. crypto_utils — classical finish (AES/XOR/bytes)
  5. bb84 — QKD simulator + transcript solver
  6. chsh — Bell-inequality game
  7. qasm_tools — QASM load/emit + golf linter
  8. algorithms — Grover + Shor-style factoring
  9. pqc — Kyber/ML-KEM, Dilithium/ML-DSA
  10. 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.

Tested two working patterns. Both require running with cwd = repo root (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.

If you skip 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:

ModuleSelf-test asserts
qsimBell pair measured 2000 shots yields only 00/11, and the 00 share is 0.4–0.6.
crypto_utilsbits_to_bytes round-trips a known bit string to b"Hi"; XOR is its own inverse; base64 round-trips.
qasm_toolsA 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.
chshThe scripted classical strategy hits exactly 0.75; the quantum strategy beats 0.80 over 8000 shots per input pair (Tsirelson bound is ~0.8536).
bb84Noiseless: 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.
algorithmsGrover 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.
pqcKEM: 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.

SignatureReturnsDescription
bell_pair() -> QuantumCircuitQuantumCircuit2-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) -> StatevectorQiskit StatevectorStrips final measurements first, then Statevector.from_instruction. Circuit must be otherwise unmeasured mid-circuit.
show(qc) -> strstrASCII circuit diagram (qc.draw(output="text")), safe to print in a terminal.
Bit-string ordering: Qiskit counts keys are little-endian by qubit index — the rightmost character is qubit 0. 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.

SignatureReturnsDescription
bits_to_bytes(bits: list[int] | str) -> bytesbytes[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) -> bytesbytesRepeating-key XOR — b is cycled to the length of a.
aes_decrypt(ciphertext, key, iv=None, mode="CBC") -> bytesbytesmode 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) -> strstrbase64.b64encode then decode to str.
unb64(s: str) -> bytesbytesbase64.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.

Basis convention: 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.
SignatureReturnsDescription
simulate_bb84(n=256, eve=False, seed=None) -> TranscriptTranscript dataclassRuns 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) -> floatfloatFraction 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.

Referee hands Alice bit 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.
SignatureReturnsDescription
quantum_winrate(shots=4000) -> floatfloatAverage 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)) -> floatfloatExact (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.

SignatureReturnsDescription
load_qasm(source: str) -> QuantumCircuitQuantumCircuitsource 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) -> strstrEmits 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) -> intintlen(operative_lines(...)) — this is what a "≤ N lines" budget counts.
gate_count(qc) -> intintNumber of instructions in the circuit, excluding barriers. Not the same number as line_count.
golf_report(qasm_str, budget=None) -> strstrHuman-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".

SignatureReturnsDescription
phase_oracle(targets: list[str], n: int) -> QuantumCircuitQuantumCircuitFlips 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) -> strstrRuns grover() and returns the most-frequent bitstring — the recovered answer.
multiplicative_order(a, N) -> intintSmallest 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 == NClassical 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's M/N ≈ 1/2 blind spot: when about half the search space is marked, amplitude amplification barely concentrates and the readout looks close to uniform — the module docstring calls this out explicitly. If 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).

Every function does a bare 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.
SignatureReturnsDescription
kem_alg() -> strstrFirst of ["ML-KEM-768", "Kyber768"] enabled in this liboqs build.
sig_alg() -> strstrFirst 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) -> bytesshared secretDecapsulate 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) -> bytessignature bytesSign a message.
dilithium_verify(public_key, message, signature, alg=None) -> boolboolTrue 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)