diffie-hellman/experiments.py
2026-09-30 11:07:31 -06:00

191 lines
8.7 KiB
Python

"""Run the exchange, time the eavesdropper, and run the man in the middle. Given; do not change it.
python3 experiments.py all three parts
python3 experiments.py exchange Step 1: the exchange, by hand and at 2048 bits
python3 experiments.py timing Step 2: brute force at 16, 20, 24, and 28 bits
python3 experiments.py timing --bits 16 20 only some sizes (28 bits takes a while)
python3 experiments.py mitm Step 3: Mallory in the middle
The Canvas questions ask for what it prints. It also writes results.json,
which keeps the latest run of each part.
The timing part is the harness for your brute-force table. For each prime
size it picks private keys from a seeded random generator (change them with
--seed), times recover_secret() with each search style, and checks the answer
against the real secret. Because the search tries exponents in order, the
number of guesses is the private key plus one. The last column scales your
measured speed up to every possible key, which is the number to extrapolate
from.
"""
import argparse
import json
import random
import time
import dh
import eavesdrop
import mitm
import params
import wire
def short(n, digits=12):
"""A big number shown as its first hex digits and its size, so a table stays readable."""
h = format(n, "x")
return h if len(h) <= digits else f"{h[:digits]}... ({n.bit_length()} bits)"
def part_exchange():
print("## Step 1. The exchange\n")
g, p = params.TOY_G, params.TOY_P
a, b = 6, 15
A, B = dh.public_from(g, p, a), dh.public_from(g, p, b)
sa, sb = dh.shared_secret(B, p, a), dh.shared_secret(A, p, b)
print(f"Public: g = {g}, p = {p}")
print(f"Alice picks private a = {a} and sends A = g^a mod p = {A}")
print(f"Bob picks private b = {b} and sends B = g^b mod p = {B}")
print(f"Alice computes B^a mod p = {sa}")
print(f"Bob computes A^b mod p = {sb}")
print(f"The wire carried only {g}, {p}, {A}, and {B}.\n")
a2, b2 = dh.generate_private(p), dh.generate_private(p)
A2, B2 = dh.public_from(g, p, a2), dh.public_from(g, p, b2)
print(f"Again with random keys: a = {a2}, b = {b2}, A = {A2}, B = {B2}, "
f"secrets {dh.shared_secret(B2, p, a2)} and {dh.shared_secret(A2, p, b2)}\n")
g, p = params.GROUP14_G, params.GROUP14_P
start = time.perf_counter()
a3, b3 = dh.generate_private(p), dh.generate_private(p)
A3, B3 = dh.public_from(g, p, a3), dh.public_from(g, p, b3)
sa3, sb3 = dh.shared_secret(B3, p, a3), dh.shared_secret(A3, p, b3)
took = time.perf_counter() - start
print(f"RFC 3526 group 14: g = {g}, p = {short(p)}")
print(f"Alice sends A = {short(A3)}")
print(f"Bob sends B = {short(B3)}")
print(f"Alice's secret = {short(sa3)}")
print(f"Bob's secret = {short(sb3)}")
print(f"Both sides agree: {sa3 == sb3} (whole exchange: {took:.3f} s)\n")
return {"toy": {"g": 5, "p": 23, "a": a, "b": b, "A": A, "B": B, "alice_secret": sa, "bob_secret": sb},
"group14": {"agree": sa3 == sb3, "secret_bits": sa3.bit_length(), "seconds": round(took, 4)}}
def time_search(g, p, A, B, secret, search):
"""Seconds for one recover_secret() call, and whether its answer was right.
A search that finishes in under 0.2 s is repeated and averaged, because a
single run that short is mostly timer noise.
"""
runs, total = 0, 0.0
while True:
start = time.perf_counter()
got = eavesdrop.recover_secret(g, p, A, B, search=search)
total += time.perf_counter() - start
runs += 1
if total >= 0.2 or runs >= 1000 or got != secret:
return total / runs, got == secret
def part_timing(bits_list, seed, pow_bits):
print("## Step 2. The eavesdropper, timed\n")
print("Running product (brute_force_running):\n")
print("| bits | p | private key | guesses | seconds | guesses per second | seconds to try every key |")
print("| --: | --: | --: | --: | --: | --: | --: |")
rows, keys = [], {}
for bits in bits_list:
g, p = params.SIZES[bits]
rng = random.Random(seed * 1000 + bits)
a, b = rng.randint(2, p - 2), rng.randint(2, p - 2)
A, B = pow(g, a, p), pow(g, b, p)
keys[bits] = (g, p, a, A, B, pow(B, a, p))
took, right = time_search(g, p, A, B, pow(B, a, p), eavesdrop.brute_force_running)
rate = (a + 1) / took
rows.append({"bits": bits, "p": p, "private": a, "seconds": took, "per_second": round(rate),
"all_keys_seconds": round((p - 1) / rate, 2), "correct": right})
flag = "" if right else " WRONG SECRET"
print(f"| {bits} | {p} | {a} | {a + 1} | {took:.4f} | {rate:,.0f} | {(p - 1) / rate:,.2f} |{flag}")
print("\nOne pow() per guess (brute_force_pow), same keys:\n")
print("| bits | seconds | guesses per second | slower than the running product by |")
print("| --: | --: | --: | --: |")
slow = []
for bits in pow_bits:
if bits not in keys:
continue
g, p, a, A, B, secret = keys[bits]
took, right = time_search(g, p, A, B, secret, eavesdrop.brute_force_pow)
fast = next(r["seconds"] for r in rows if r["bits"] == bits)
ratio = took / fast
slow.append({"bits": bits, "seconds": took, "ratio": round(ratio, 1), "correct": right})
flag = "" if right else " WRONG SECRET"
print(f"| {bits} | {took:.4f} | {(a + 1) / took:,.0f} | {ratio:.1f}x |{flag}")
print("\nThe extrapolation to 2048 bits is yours: use the last column of the first table.\n")
return {"seed": seed, "running": rows, "pow": slow}
def part_mitm():
print("## Step 3. The man in the middle\n")
g, p = params.GROUP14_G, params.GROUP14_P
texts = [("Alice", "the key to the office is under the mat"), ("Bob", "thanks, I will be there at 9")]
alice, bob, line = wire.exchange(g, p)
for sender, text in texts:
(alice if sender == "Alice" else bob).send_text(bob if sender == "Alice" else alice, text)
print("Without Mallory, 2048-bit group. A passive eavesdropper sees only this:")
for m in line.transcript:
shown = short(m.value) if m.kind == "public" else m.value[:12].hex() + "..."
print(f" {m.sender} -> {m.receiver} {m.kind:6} {shown}")
print(f"Alice and Bob share one secret: {alice.secret == bob.secret}\n")
alice, bob, mallory, line = mitm.attack(g, p, texts)
print("With Mallory in the middle, same group, same messages:")
print(f" Alice's secret: {short(alice.secret)}")
print(f" Bob's secret: {short(bob.secret)}")
print(f" Alice and Bob share one secret: {alice.secret == bob.secret}")
print(f" Mallory holds Alice's secret: {mallory.secrets.get('Alice') == alice.secret}, "
f"Bob's secret: {mallory.secrets.get('Bob') == bob.secret}")
print(" What Mallory read:")
for sender, receiver, text in mallory.read:
print(f" {sender} -> {receiver}: {text!r}")
print(f" What Bob received: {bob.inbox}")
print(f" What Alice received: {alice.inbox}\n")
_, bob2, _, _ = mitm.attack(g, p, texts[:1], rewrite=lambda t: t.replace("under the mat", "with the front desk"))
print("Mallory rewrites Alice's message on the way:")
print(f" Alice sent: {texts[0][1]!r}")
print(f" Bob received: {bob2.inbox[0]!r}\n")
return {"share_one_secret": alice.secret == bob.secret, "mallory_read": mallory.read,
"bob_received": bob.inbox, "rewritten": bob2.inbox}
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("part", nargs="?", choices=["exchange", "timing", "mitm"], help="run one part (default: all)")
ap.add_argument("--bits", type=int, nargs="+", choices=sorted(params.SIZES), default=sorted(params.SIZES),
help="prime sizes for the timing table")
ap.add_argument("--pow-bits", type=int, nargs="+", default=[16, 20],
help="sizes to also time with brute_force_pow (default 16 20)")
ap.add_argument("--seed", type=int, default=7, help="seed for the timing table's private keys")
args = ap.parse_args()
try:
with open("results.json") as f:
out = json.load(f)
except (OSError, ValueError):
out = {}
parts = [("exchange", part_exchange), ("timing", lambda: part_timing(args.bits, args.seed, args.pow_bits)),
("mitm", part_mitm)]
for name, run in parts:
if args.part not in (None, name):
continue
try:
out[name] = run()
except NotImplementedError as e:
print(f"\n## {name}: skipped, {e} is not written yet (python3 selfcheck.py shows what is left)\n")
with open("results.json", "w") as f:
json.dump(out, f, indent=1)
print("wrote results.json")
if __name__ == "__main__":
main()