Perfect encryption via XOR with a truly random key
XOR is such a low-level, almost boring operation that it’s easy to under-estimate it. Combined with a key that is uniformly random, exactly as long as the message, and never used twice, it is the only construction in all of cryptography with a formal perfect-secrecy guarantee: the ciphertext does not reduce the probability of any plaintext, even one, no matter how much computing power the attacker has. That guarantee isn’t “hard to break” — it’s “nothing to break.” It comes from Shannon’s 1949 proof that if every possible key is equally likely, then for any ciphertext C and any candidate plaintext P, the key that would have produced it (C XOR P) is a perfectly valid random draw, so seeing C changes nothing about P’s probability.
This post walks the whole arc: the two-line cipher and its round trip, the algebraic reason one key reuse kills the guarantee (plus a working crib-dragging attack that recovers a plaintext without ever knowing the key), and the practical math of why nobody actually uses one-time pads for normal traffic.
Perfect secrecy in one line of code
The entire cipher is one byte-wise operation. Encryption and decryption are the same function, because XOR is self-inverse: P XOR K XOR K == P. The interesting parts are all in what you demand of K — random, same length as the message, single-use. Let’s see the round trip with a real key drawn from os.urandom (Python’s system CSPRNG, per the os module docs), used twice on the same plaintext:
#!/usr/bin/env python3
"""One-time pad: encrypt with os.urandom, decrypt, and verify the round trip."""
import os
def hx(b: bytes, width=64) -> str:
out = []
for i in range(0, len(b), width):
out.append(b[i:i+width].hex())
return " ".join(out)
def otp_encrypt(plaintext: bytes, key: bytes) -> bytes:
return bytes(p ^ k for p, k in zip(plaintext, key))
def otp_decrypt(ciphertext: bytes, key: bytes) -> bytes:
return otp_encrypt(ciphertext, key)
plaintext = b"The one-time pad is the only cipher with a formal perfect-secrecy proof."
key1 = os.urandom(len(plaintext))
key2 = os.urandom(len(plaintext))
print("plaintext :", plaintext.decode())
print("len(key1) :", len(key1), " bytes")
print("key1 :", hx(key1))
print()
ct1 = otp_encrypt(plaintext, key1)
print("ciphertext 1 (plaintext XOR key1):")
print(" ", hx(ct1))
print()
ct2 = otp_encrypt(plaintext, key2)
print("ciphertext 2 (plaintext XOR key2):")
print(" ", hx(ct2))
print()
print("same plaintext, different random keys -> different ciphertexts:")
print(" ct1 == ct2 ?", ct1 == ct2)
print()
pt = otp_decrypt(ct1, key1)
print("decrypted :", pt.decode())
print("matches :", pt == plaintext)
plaintext : The one-time pad is the only cipher with a formal perfect-secrecy proof.
len(key1) : 72 bytes
key1 : eaf73549c9c334b79eaf8be891e200e00fa2d4774cd58dab05a0cbb06b4642abd4ae32e4fc934af713334aaa8c609ec4b7fbece62e185ee17a921be109ef6f39 ee4b71ff51d8c2d0
ciphertext 1 (plaintext XOR key1):
be9f5069a6ad519aeac6e68db19261842fcba75738bde88b6acea7c94b252bdbbccb40c48bfa3e9f33526acce312f3a5dbdb9c835c7e3b820ebf68846a9d0a5a 976b018d3eb7a4fe
ciphertext 2 (plaintext XOR key2):
d3875f45f0ef5c3c95cbe9a0372ab9a6cf586d4aea5ee0926c3572596f5c9bbf3816afd5cd8dae71e9b6164211a43ac685a9ef4c30ed7cfd826ffdd40ca22433 e55efd26ffeb68fb
same plaintext, different random keys -> different ciphertexts:
ct1 == ct2 ? False
decrypted : The one-time pad is the only cipher with a formal perfect-secrecy proof.
matches : True
(The hex differs every run — that’s the point. What’s stable is the shape: two random 72-byte keys produce two completely different ciphertexts for the identical 72-byte plaintext, and XORing the first ciphertext back with its key reproduces the plaintext exactly.)
That shape is the perfect-secrecy argument made visible. An attacker holding ciphertext 1 has nothing to attack: each byte is the XOR of a plaintext byte with a fair coin, so the ciphertext bytes are themselves fair coins. Frequency analysis, known-plaintext guessing, brute force — all of them run into the same wall, that for every plaintext the attacker imagines, exactly one key would have produced it, and that key was just as likely as any other.
Reuse is the death of the OTP
Now break the single rule. Encrypt two messages with the same key — a “multi-time pad”. The fatal algebra is one line. If c1 = m1 XOR k and c2 = m2 XOR k, then
c1 XOR c2 = (m1 XOR k) XOR (m2 XOR k) = m1 XOR m2
The key cancels. The attacker no longer needs to find k at all — they have m1 XOR m2 in hand, and plaintexts are anything but random. English has predictable phrases, and one guessed phrase (a “crib”) in one message hands over the other message at the same offset. Here is exactly that, with a fixed key reused for two messages and a crib-dragging scan:
#!/usr/bin/env python3
"""Multi-time pad: one key reused for two messages, broken by crib-dragging.
The key never appears in the attacker's computation at all.
"""
def xor(a: bytes, b: bytes) -> bytes:
return bytes(x ^ y for x, y in zip(a, b))
def encrypt(plaintext: bytes, key: bytes) -> bytes:
return xor(plaintext, key)
m1 = b"The secret is buried on site."
m2 = b"The backup key is 847-2 and the alarm is disabled."
base = bytes([0x3A, 0x7F, 0xC1, 0x0E, 0x55, 0xB2, 0x9D, 0x4A, 0x11, 0xE8])
key = (base * 5)[:len(m2)]
c1 = encrypt(m1, key)
c2 = encrypt(m2, key)
print("c1 =", c1.hex())
print("c2 =", c2.hex())
print()
xp = xor(c1, c2)
print("c1 XOR c2 (the shared key cancels; this is m1 XOR m2):")
print(" ", xp.hex())
print()
# The attacker never sees the key. They guess a common phrase ("crib")
# that is likely in one message, and slide it across c1 XOR c2: wherever
# it lines up, XORing the crib out leaves the *other* message's text.
crib = b"The secret is"
print(f"crib-dragging with {crib.decode()!r}:")
for i in range(len(xp) - len(crib) + 1):
cand = xor(xp[i:i+len(crib)], crib)
t = cand.decode("ascii", errors="replace")
if cand.isascii() and all(ch.isalpha() or ch == " " for ch in t):
print(f" offset {i:>2}: {t!r}")
recovered = bytes(m1[i+j] ^ xp[i+j] for j in range(len(crib)))
print(f" -> plaintext of the other message: {recovered.decode()!r}")
print()
print("m2 was:", m2.decode())
c1 = 6e17a42e26d7fe38749c1a16b22e37c7ef23748c1a10af2e26dbe92f3f
c2 = 6e17a42e37d3fe2164981a14a47775dbee6a29dc0d52f32e34dcf96a65805f5fa06234c0f06a789b1a1ba87d34d0f12f75c6
c1 XOR c2 (the shared key cancels; this is m1 XOR m2):
0000000011040019100400021659421c01495d5017425c00120710455a
crib-dragging with 'The secret is':
offset 0: 'The backup ke'
-> plaintext of the other message: 'The backup ke'
m2 was: The backup key is 847-2 and the alarm is disabled.
Note what the attacker did not do: guess at the key, crack a hash, or do any heavy computation. They XORed two ciphertexts, slid one plausible 13-letter phrase across the result, and read off 13 letters of the other message — 'The backup ke' — for free. The lead zeros in c1 XOR c2 even leak that the two messages shared the first four characters (“The”). In practice the scan produces more candidates than this, and each confirmed crib position hands over another window of the partner message; the attack compounds. This is why historical multi-time pad traffic — Soviet diplomatic traffic from the 1940s-60s, for example — was so breakable despite being “one-time pad” on paper: the pads were reused, and once m1 XOR m2 is on the table, the randomness the pad was supposed to provide has left the system.
The mental model to keep: the OTP’s security does not live in the XOR. It lives in the key’s randomness. One key, one message: the ciphertext is indistinguishable from noise. Two messages, one key: the ciphertext pair is less informative than the two plaintexts were, but no longer random — the shared key has become a bridge between them.
Why you don’t use OTPs
If the math is this clean, why isn’t this what we use? Two hard requirements make the scheme expensive in a way modern ciphers never are. First, the key must be at least as long as the message — there is no short-key trick. Second, every key is single-use, and the key has to reach the recipient over a channel at least as secure as the channel you’re protecting — hand-courier, one-time pad book, burned after use. You are spending a perfect, physically-protected transport to enable the encryption of the message itself. Look at the key side of the trade at a few realistic sizes, and watch what happens when the message becomes a gigabyte:
#!/usr/bin/env python3
"""The practical cost of a one-time pad: the key must be at least as
long as the plaintext, every key is single-use, and the key itself
must be transported securely to the recipient."""
import os
from datetime import datetime
def show(label: str, n: int) -> None:
t0 = datetime.now()
k = os.urandom(n)
dt = (datetime.now() - t0).total_seconds() * 1000
print(f"{label:>28} key = {n:>13,} bytes generated in {dt:7.2f} ms")
def human(n: int) -> str:
for unit, div in [("GiB", 1 << 30), ("MiB", 1 << 20), ("KiB", 1 << 10)]:
if n >= div:
return f"{n / div:.0f} {unit}"
return f"{n} B"
print("one-time pad key size must match plaintext size, per message:")
print()
show("1 KiB text note", 1 << 10)
show("1 MiB spreadsheet", 1 << 20)
show("1 GiB backup", 1 << 30)
print()
print("each of those keys is single-use.")
print()
print("a one-time pad gives you the encryption of the plaintext,")
print("but it does not solve the harder problem:")
print()
print(" plaintext to send: 1 GiB backup")
print(" key to transport: 1 GiB of random bytes,")
print(" via a channel at least as secure as the")
print(" channel you are protecting,")
print(" generated once, used once, then destroyed.")
print()
print("the XOR is the easy part.")
one-time pad key size must match plaintext size, per message:
1 KiB text note key = 1,024 bytes generated in 0.01 ms
1 MiB spreadsheet key = 1,048,576 bytes generated in 1.96 ms
1 GiB backup key = 1,073,741,824 bytes generated in 2011.57 ms
each of those keys is single-use.
a one-time pad gives you the encryption of the plaintext,
but it does not solve the harder problem:
plaintext to send: 1 GiB backup
key to transport: 1 GiB of random bytes,
via a channel at least as secure as the
channel you are protecting,
generated once, used once, then destroyed.
the XOR is the easy part.
In this run, generating the 1 GiB key took about two seconds; the generation cost is the least of it. The operational cost is the logistics: one gigabyte of genuinely random material, moved once, over a channel you already trust, for a message you could instead have encrypted with a 32-byte key and a well-understood cipher. At the scale of the first two rows — a text note, a spreadsheet — OTP-style one-shot keys are genuinely used (they’re the model for ephemeral keys in modern protocols, where each session gets a fresh derived key). It’s the third row that breaks the scheme, and it’s the row that matches real traffic.
Takeaway
The one-time pad is the reference point for what “secure” can mean: security from mathematics (a uniformly random, message-length key) rather than from an assumed-hard problem. The cost is that every bit of the message must be paid for by a bit of pre-shared randomness, and a single reuse hands an attacker the XOR of your plaintexts — less noise than noise, and plenty of English to drag a crib across. Modern cryptography is, in one sense, an engineering compromise that trades the OTP’s absolute guarantee for short, reusable keys — and knowing the exact line where that trade begins (key reuse, key length, key distribution) is what makes the compromise make sense.