Simulating BB84 Quantum Key Distribution in Python
Implement the BB84 quantum key distribution protocol from scratch in Python, simulating qubit encoding, eavesdropping detection, and secure key sifting.
Quantum key distribution is one of the few areas where quantum mechanics provides a provably unconditional security advantage over classical methods. Classical key exchange (Diffie-Hellman, RSA) relies on computational hardness assumptions: no known efficient algorithm can break it, but a sufficiently powerful computer could in principle. QKD is different: its security follows from the laws of physics. An eavesdropper cannot intercept and re-send a qubit without disturbing it in a detectable way.
The BB84 protocol, published by Charles Bennett and Gilles Brassard in 1984, was the first QKD protocol. It remains the most widely deployed. This tutorial implements it from scratch in pure Python, no quantum library required, by simulating the qubit states and measurement outcomes directly.
Why QKD Works
The key insight is that quantum measurement is irreversible and basis-dependent. A qubit can be in one of two states in the Z basis (|0>, |1>) or one of two states in the X basis (|+>, |->). If you measure an X-basis state in the Z basis, you get a random result, and the original state is destroyed. An eavesdropper who intercepts a qubit does not know which basis Alice used, so she has to guess. Half the time she guesses wrong, measures in the wrong basis, and sends on a corrupted qubit. This introduces detectable errors.
The Four BB84 States
BB84 uses two conjugate bases:
| Bit | Z basis (rectilinear) | X basis (diagonal) |
|---|---|---|
| 0 | 0> = “no photon” | |
| 1 | 1> = “photon” |
Alice picks a random basis for each bit. Bob picks a random basis for each measurement. When they pick the same basis, the result is perfectly correlated. When they pick different bases, the result is uniformly random and discarded.
Python Implementation
We represent qubits as a pair (bit, basis): bit is 0 or 1, basis is ‘Z’ or ‘X’.
import random
import hashlib
def prepare_qubit(bit: int, basis: str) -> tuple:
"""
Simulate Alice preparing a qubit.
Returns (bit, basis) -- the qubit state.
bit: 0 or 1
basis: 'Z' (rectilinear) or 'X' (diagonal)
"""
return (bit, basis)
def measure_qubit(qubit: tuple, measurement_basis: str) -> int:
"""
Simulate Bob measuring a qubit in a given basis.
If the measurement basis matches the preparation basis, returns the exact bit.
If bases differ, returns a uniformly random bit (the qubit's state is destroyed).
"""
bit, prep_basis = qubit
if measurement_basis == prep_basis:
return bit
else:
return random.randint(0, 1)
def bb84_generate_keys(n_bits: int) -> dict:
"""
Simulate the BB84 protocol between Alice and Bob.
n_bits: number of qubits to send (before sifting)
Returns a dict with alice_bits, alice_bases, bob_bases, bob_results, sifted_key.
"""
# Step 1: Alice generates random bits and random bases
alice_bits = [random.randint(0, 1) for _ in range(n_bits)]
alice_bases = [random.choice(["Z", "X"]) for _ in range(n_bits)]
# Step 2: Alice prepares qubits
qubits = [prepare_qubit(b, basis) for b, basis in zip(alice_bits, alice_bases)]
# Step 3: Bob measures each qubit in a randomly chosen basis
bob_bases = [random.choice(["Z", "X"]) for _ in range(n_bits)]
bob_results = [measure_qubit(q, basis) for q, basis in zip(qubits, bob_bases)]
# Step 4: Basis reconciliation -- Alice and Bob compare bases over a public channel
# Keep only the positions where they used the same basis
matching_positions = [
i for i in range(n_bits) if alice_bases[i] == bob_bases[i]
]
# Step 5: Sifted key -- the shared bits at matching positions
alice_sifted = [alice_bits[i] for i in matching_positions]
bob_sifted = [bob_results[i] for i in matching_positions]
return {
"alice_bits": alice_bits,
"alice_bases": alice_bases,
"bob_bases": bob_bases,
"bob_results": bob_results,
"matching_pos": matching_positions,
"alice_sifted": alice_sifted,
"bob_sifted": bob_sifted,
}
# Run with 1000 qubits
random.seed(42)
result = bb84_generate_keys(1000)
print(f"Qubits sent: {len(result['alice_bits'])}")
print(f"Matching bases: {len(result['matching_pos'])}")
print(f"Sifted key length: {len(result['alice_sifted'])}")
# Verify no errors without eavesdropping
errors = sum(
a != b for a, b in zip(result["alice_sifted"], result["bob_sifted"])
)
print(f"Errors (no Eve): {errors}")
On average, about 50% of bits survive sifting (half the basis pairs match). With 1000 qubits, you get roughly 500 sifted key bits with zero errors if there is no eavesdropper.
Eavesdropping Simulation
Now add Eve. She intercepts every qubit, measures it in a randomly chosen basis (she does not know Alice’s basis), and re-prepares a new qubit to send to Bob. When Eve guesses the wrong basis, she disturbs the state:
def bb84_with_eavesdropper(n_bits: int) -> dict:
"""
BB84 with Eve intercepting every qubit.
"""
alice_bits = [random.randint(0, 1) for _ in range(n_bits)]
alice_bases = [random.choice(["Z", "X"]) for _ in range(n_bits)]
qubits = [prepare_qubit(b, basis) for b, basis in zip(alice_bits, alice_bases)]
# Eve intercepts: measures in a random basis, re-prepares based on her result
eve_bases = [random.choice(["Z", "X"]) for _ in range(n_bits)]
eve_results = [measure_qubit(q, basis) for q, basis in zip(qubits, eve_bases)]
# Eve sends Bob new qubits prepared with her measurement result and basis
eve_qubits = [prepare_qubit(b, basis) for b, basis in zip(eve_results, eve_bases)]
# Bob measures Eve's qubits (not Alice's originals)
bob_bases = [random.choice(["Z", "X"]) for _ in range(n_bits)]
bob_results = [measure_qubit(q, basis) for q, basis in zip(eve_qubits, bob_bases)]
matching_positions = [
i for i in range(n_bits) if alice_bases[i] == bob_bases[i]
]
alice_sifted = [alice_bits[i] for i in matching_positions]
bob_sifted = [bob_results[i] for i in matching_positions]
return {
"alice_sifted": alice_sifted,
"bob_sifted": bob_sifted,
"matching_pos": matching_positions,
}
random.seed(0)
eve_result = bb84_with_eavesdropper(4000)
errors_with_eve = sum(
a != b for a, b in zip(eve_result["alice_sifted"], eve_result["bob_sifted"])
)
total = len(eve_result["alice_sifted"])
error_rate = errors_with_eve / total
print(f"Sifted key length: {total}")
print(f"Errors with Eve: {errors_with_eve} ({error_rate:.1%})")
print("Expected error rate with full interception: ~25%")
The theoretical error rate when Eve intercepts every bit is exactly 25%. This is because:
- Eve picks the wrong basis with probability 1/2.
- When she picks the wrong basis, she disturbs the qubit.
- In the cases where Alice and Bob used the same basis but Eve used a different one, Bob still gets the right answer with probability 1/2.
- Overall: 1/2 (Eve wrong basis) * 1/2 (Bob wrong result) = 25% error rate on the sifted key.
Error Checking and Eavesdropping Detection
Alice and Bob sacrifice a random subset of their sifted key to estimate the error rate. If the error rate exceeds a threshold, they abort and assume eavesdropping:
def check_for_eavesdropping(
alice_sifted: list,
bob_sifted: list,
sample_fraction: float = 0.2,
threshold: float = 0.11,
) -> dict:
"""
Compare a random sample of the sifted key to estimate error rate.
sample_fraction: fraction of sifted key used for checking (discarded afterward)
threshold: abort if estimated QBER exceeds this value
Returns dict with error_rate, eavesdropping_detected, and remaining key.
"""
n = len(alice_sifted)
n_sample = max(1, int(n * sample_fraction))
sample_indices = random.sample(range(n), n_sample)
sample_set = set(sample_indices)
errors = sum(
alice_sifted[i] != bob_sifted[i] for i in sample_indices
)
qber = errors / n_sample # Quantum Bit Error Rate
# Remaining key bits (not used for checking)
remaining_alice = [alice_sifted[i] for i in range(n) if i not in sample_set]
remaining_bob = [bob_sifted[i] for i in range(n) if i not in sample_set]
return {
"qber": qber,
"eavesdropping_detected": qber > threshold,
"remaining_alice": remaining_alice,
"remaining_bob": remaining_bob,
}
# No eavesdropper
no_eve = bb84_generate_keys(2000)
check_no_eve = check_for_eavesdropping(no_eve["alice_sifted"], no_eve["bob_sifted"])
print(f"No Eve - QBER: {check_no_eve['qber']:.3f}, "
f"Eavesdropping detected: {check_no_eve['eavesdropping_detected']}")
# With eavesdropper
with_eve = bb84_with_eavesdropper(2000)
check_eve = check_for_eavesdropping(with_eve["alice_sifted"], with_eve["bob_sifted"])
print(f"With Eve - QBER: {check_eve['qber']:.3f}, "
f"Eavesdropping detected: {check_eve['eavesdropping_detected']}")
A QBER below about 11% is generally considered safe for BB84 with one-way classical post-processing. Above 11%, the information Eve might have accumulated cannot be fully removed by privacy amplification.
Privacy Amplification
Even when the QBER is below the threshold, Eve may have partial information about some bits (from the cases where she guessed the right basis). Privacy amplification uses a universal hash function to compress the remaining key, destroying Eve’s partial knowledge:
def privacy_amplification(key_bits: list, output_length: int) -> str:
"""
Apply a hash function to reduce the key and eliminate Eve's partial knowledge.
key_bits: list of 0/1 integers (the remaining sifted key)
output_length: desired output key length in bits
Returns the final secure key as a binary string.
"""
# Convert bit list to bytes
key_bytes = bytes(int("".join(str(b) for b in key_bits[i:i+8]), 2)
for i in range(0, len(key_bits) - len(key_bits) % 8, 8))
# SHA-256 as a randomness extractor (real PA uses Toeplitz hashing)
digest = hashlib.sha256(key_bytes).digest()
full_bits = bin(int.from_bytes(digest, "big"))[2:].zfill(256)
return full_bits[:output_length]
remaining = check_no_eve["remaining_alice"]
if len(remaining) >= 8:
final_key = privacy_amplification(remaining, output_length=128)
print(f"\nFinal secure key (128 bits): {final_key[:32]}...")
print(f"Key length: {len(final_key)} bits")
In production QKD implementations, Toeplitz-matrix hashing is used rather than SHA-256, because it has provable security properties. The hash function is chosen randomly and shared publicly, which does not help Eve.
Realistic Key Rates
This simulation assumes a perfect quantum channel. In practice:
- A 100 km fiber link attenuates single photons by roughly 20 dB, leaving 1% of photons transmitted.
- Single-photon detectors have detection efficiency of roughly 20-80% depending on technology.
- Dark counts and timing jitter add noise even without eavesdropping.
Typical commercial QKD systems (ID Quantique, Toshiba, QuantumCTek) achieve:
| Distance | Typical secret key rate |
|---|---|
| 10 km | 1-10 Mbps |
| 50 km | 10-100 kbps |
| 100 km | 1-10 kbps |
| 200 km | 0.01-1 kbps |
Beyond about 300-400 km, loss is too high for direct fiber QKD. Trusted relay nodes or quantum repeaters are needed for longer distances.
Connection to Real Hardware
This simulation captures the protocol logic faithfully: the security analysis, error rate calculations, and key sifting are identical to what real systems do. What it abstracts away:
- Physical qubits: real QKD uses photon polarization states or time-bin encoding, not abstract bits.
- Photon sources: weak coherent pulses (lasers attenuated to mean photon number < 1) require decoy states to close the photon-number-splitting attack.
- Detectors: superconducting nanowire single-photon detectors (SNSPDs) achieve the highest efficiency and lowest dark count rates.
- Timing: Alice and Bob must synchronize their clocks to sub-nanosecond precision.
The BB84 simulation you have built is conceptually complete. The security proof by Shor and Preskill (2000) and Mayers (2001) shows that it is information-theoretically secure against arbitrary quantum attacks, a level of security no classical protocol can achieve.
What to Try Next
- Implement the E91 protocol (Ekert 1991), which uses entangled photon pairs and Bell inequality violation to detect eavesdropping
- Simulate a partial eavesdropper who intercepts only a fraction of qubits and quantify how the QBER scales
- Use Qiskit or Cirq to simulate BB84 with actual quantum gates, where qubit states are represented as statevectors
- Read the NIST report on quantum key distribution for a technical overview of QKD standardization efforts
Was this tutorial helpful?