Step 90. Project — XOR File Encryption/Decryption Tool

Step 90. Project — XOR File Encryption/Decryption Tool

Level 1 — Programming and the Inside of a Computer | Difficulty ★★★☆☆ | Estimated time: 3 hours

Prerequisites: Python basics from Steps 41–46 (lists, file I/O), bit operations from Step 54, and command-line arguments from Step 64.

  • What you need: Python, a text editor, and any one file (for the encryption experiment — absolutely not an important file).
  • Caution: the cipher you’re making today is educational. Proving to yourself that it can’t be used in the real world is today’s core objective. Every "breaking" experiment today applies only to files you encrypted yourself.

When people hear "cipher," they picture the black box from a spy movie, but at its heart sits an astonishingly simple operation. Today’s star is XOR (exclusive OR, symbol ⊕) — one single line of rule: "same gives 0, different gives 1." This operation has a magical property: if you XOR any value A with a key K and then XOR with K again, you get back the original A. This one property creates "a transformation only someone who knows the key can undo." Today’s goals are twofold — build a tool that encrypts and decrypts files with XOR yourself, and personally smash why this cipher is weak. Making it, breaking it, and explaining the reason — that triple beat is the most typical rhythm of security study.


1. Learning Objectives

By the end of this chapter, you will be able to:

  • Demonstrate XOR’s restoration property (A ⊕ K ⊕ K = A) with Python’s ^ operator
  • Read and write files in "rb"/"wb" binary mode
  • Complete an enc/dec CLI tool that takes command-line arguments
  • Break a one-byte-key cipher with 256 exhaustive attempts (brute force)
  • Prove by experiment why key reuse is dangerous, and explain this cipher’s limits

2. Background Knowledge — Today’s Tools and Concepts

Today’s Tools at a Glance

Category Details
Language/environment Python 3 (script + one-liner execution)
Today’s tools ^ (XOR operator), bytes(), open(..., "rb"/"wb"), sys.argv, range(256)
Concepts needed XOR truth table, symmetric key, binary files, key space, known-plaintext attack
Today’s artifacts xor_tool.py (encryption/decryption tool), crack.py (exhaustive-search cracker)

2-1. XOR’s Magic Property — Flipping a Switch Twice

Here’s XOR’s truth table.

A B A ⊕ B
0 0 0
0 1 1
1 0 1
1 1 0

You only need to memorize one property here. A ⊕ K ⊕ K = A. It’s like flipping a light switch the same way twice — you’re back to the original state. In Python, XOR is the ^ symbol.

2-2. Symmetric Key — A Cipher Locked and Opened with the Same Key

A cipher that uses the same key for encryption and decryption, like today’s method, is called a symmetric-key cipher. It’s like a front-door key — you unlock with the same key you locked with. The opposite concept is public-key cryptography (the locking key and the unlocking key differ), which you’ll meet in a later level.

2-3. Binary Files — Bytes, Not Characters

What you’ve read so far with open("file") was text. But photos, executables, and archives contain "bytes that can’t be interpreted as characters." To handle such files, you append b to the mode — "rb" (binary read), "wb" (binary write).

When you read in binary, Python gives you the file as a sequence of bytes (a bytes object). Each byte is a number from 0 to 255. And XOR is an operation between numbers — every file is a sequence of numbers, and numbers can be XORed. That’s the substance of the claim "any file can be encrypted."

2-4. The Roots of Stream Ciphers and Key Space

A cipher that stretches the key to the length of the data and XORs byte by byte is called a stream cipher. Today’s tool is the most primitive stream cipher, reusing a single key byte. Real-world stream ciphers (e.g., ChaCha20) share the same skeleton — generate a key stream that looks truly random, then XOR. The only difference is "how unpredictable the key stream is."

And today’s other star is the key space (the number of possible keys). A one-byte key has a key space of 256. The real-world cipher AES-128 has 2 to the 128th power. In section 3-6, this difference decides "broken in one second, or longer than the age of the universe."


3. Follow Along

3-1. Confirming the XOR Property — First Sighting of the Magic

Input:

python -c "print(65 ^ 42); print((65 ^ 42) ^ 42)"

Output (measured 2026-09-09):

107
65

How to read it: flipping 65 (the character ‘A’) with 42 gives 107 (‘k’), and flipping it again with the same 42 brought it back to 65. From the intermediate value 107 alone, you can’t tell what the original was without the key 42.

Why: everything you build today stands on these two lines. Only a property you’ve confirmed with your own eyes becomes a tool you can trust.

3-2. Reading a File in Binary

Input: make an experiment file and read it.

echo "hello secret" > test.txt
python -c "data = open('test.txt','rb').read(); print(data); print(list(data)[:5])"

Output (measured 2026-09-09):

b'hello secretn'
[104, 101, 108, 108, 111]

How to read it: b'...' marks a sequence of bytes. The list after it is each byte’s numeric value — h is 104, e is 101. The claim that a file is numbers has been proven. Encryption will be "flipping all these numbers with XOR."

3-3. The Encryption Function — One Line of Core

Input: start making xor_tool.py.

def xor_bytes(data, key):
    return bytes([b ^ key for b in data])

How to read it: XOR each byte b of the list with key to make a new byte sequence. bytes([...]) wraps the number list into bytes. This one line is both encryption and decryption — since XOR is symmetric, one function is enough. The heart of a seemingly complex "encryption program" is really this one line, and the rest of the code is all wrapping paper — file reading/writing and argument handling.

3-4. Completing the CLI and Round-Tripping

Input: complete all of xor_tool.py.

import sys

def xor_bytes(data, key):
    return bytes([b ^ key for b in data])

def main():
    if len(sys.argv) != 4:
        print("Usage: python xor_tool.py enc|dec input_file key(0-255)")
        return
    mode, path, key = sys.argv[1], sys.argv[2], int(sys.argv[3])
    out = path + ('.enc' if mode == 'enc' else '.dec')
    data = open(path, 'rb').read()
    open(out, 'wb').write(xor_bytes(data, key))
    print(f"{mode} complete: {out}")

main()

Input (execution):

python xor_tool.py enc test.txt 42
python xor_tool.py dec test.txt.enc 42
cat test.txt.enc.dec
cmp test.txt test.txt.enc.dec

Output (measured 2026-09-09):

enc complete: test.txt.enc
dec complete: test.txt.enc.dec
hello secret

cmp finished with no output — meaning the two files are completely identical at the byte level (Windows has fc /b).

How to read it: sys.argv is the list of words typed on the command line (Step 64). Whether the mode is enc or dec, the work is the same — because XOR is symmetric. The completion condition of a cipher tool is "does it come back," and the round trip has been proven.

Interesting discovery (measured 2026-09-09): if you peer into the ciphertext test.txt.enc with od -c, you get characters that happen to be readable like B O F F E ... — because ‘h’ (104) XOR key 42 is ‘B’. A ciphertext doesn’t have to be "strange symbols" — a ciphertext is just "bytes whose meaning you can’t know without the key."

3-5. Make a Prediction — What If the Key Is Wrong?

You encrypted with key 42 and decrypt with key 43. What’s the result?

  • (a) It restores roughly similarly
  • (b) Completely wrong bytes come out
  • (c) An error occurs

Check for yourself:

python -c "data=open('test.txt.enc','rb').read(); print(bytes([b ^ 43 for b in data]))"

Output (measured 2026-09-09):

b'idmmn!rdbsdux0b'

The closest answer is (b) — restoration failed, and there’s no error either. But the measurement shows an interesting detail: if the key is off by just 1 (43 instead of 42), each byte is off by exactly 1 — like "hello" becoming "idmmn." That’s because 42 XOR 43 is 1. If the key is off by more, the result becomes complete garbage. XOR has no "close enough" — a wrong key quietly gives a wrong answer.

3-6. Breaking It — 256 Exhaustive Attempts

Let’s attack my own tool. Assume you don’t know the key.

Input: crack.py:

data = open('test.txt.enc', 'rb').read()
for key in range(256):
    plain = bytes([b ^ key for b in data])
    if b'hello' in plain:
        print(f"key candidate: {key} → {plain[:20]}")

Output (measured 2026-09-09):

key candidate: 42 → b'hello secretn'

How to read it: we ran all 256 key candidates and looked for "a readable result." It takes less than a second. This is the proof of why today’s tool is "educational" — a cipher with a key space of 256 is defenseless in front of a computer.

Why: you’ve just confirmed with your hands the reason behind the textbook sentence "keys must be long." No further explanation should be needed for why AES-128’s key space (2 to the 128th power) is necessary.

3-7. The Danger of Key Reuse — Encrypting Twice with the Same Key

XOR ciphers have a subtler weakness beyond exhaustive search. If you encrypt two messages with the same key, the moment you XOR the two ciphertexts, the key cancels out: C1 ⊕ C2 = (M1 ⊕ K) ⊕ (M2 ⊕ K) = M1 ⊕ M2.

Input:

key = 77
m1 = b'attack at dawn'
m2 = b'retreat at noon'
c1 = bytes([b ^ key for b in m1])
c2 = bytes([b ^ key for b in m2])
print('c1 XOR c2 == m1 XOR m2:', bytes(a ^ b for a, b in zip(c1, c2)) == bytes(a ^ b for a, b in zip(m1, m2)))
print('recovered key:', c1[0] ^ ord('a'))   # if you know the first letter is 'a'

Output (measured 2026-09-09):

c1 XOR c2 == m1 XOR m2: True
recovered key: 77

How to read it: even knowing nothing about the key, the XOR of the two ciphertexts exactly matched the XOR of the two plaintexts — the key was erased. Worse, if you know even one character of the plaintext (e.g., a guess that the message starts with "attack"), the key is recovered outright. This is called a known-plaintext attack.

Why it matters: this is why "a key, only once, for only one message" is the iron rule of stream ciphers. This principle is also the condition of the OTP (one-time pad) coming up next.

3-8. Encryption You Can See — Round-Tripping a Binary File

Experimenting with a binary file instead of text makes the generality visible. Let’s make a test image (PPM format) directly with Python.

Input:

python -c "
w,h=64,64
with open('photo.ppm','wb') as f:
    f.write(b'P6n64 64n255n')
    for y in range(h):
        for x in range(w):
            f.write(bytes([x*4 % 256, y*4 % 256, 128]))
print('photo.ppm created')"
python xor_tool.py enc photo.ppm 200
python xor_tool.py dec photo.ppm.enc 200
cmp photo.ppm photo.ppm.enc.dec

Output (measured 2026-09-09):

photo.ppm created
enc complete: photo.ppm.enc
dec complete: photo.ppm.enc.dec

cmp went silent again — the image file also round-tripped perfectly at the byte level. The encrypted photo.ppm.enc has even its header (P6) flipped (measured: the leading bytes changed to 230 376 302 ...), so no image viewer can open it.

How to read it: whether text or picture, a file is a sequence of bytes, and bytes get XORed. This is an experiment in feeling firsthand what the state "once encrypted, even programs can’t recognize it" looks like.


4. Missions & Exercises

Mission — A Finished Tool and Self-Analysis

  1. Prove that xor_tool.py works in both enc and dec modes, and that the round-trip result matches the original, with cmp (or fc /b)
  2. Succeed in round-tripping a non-text file (image, archive, etc.)
  3. Break your own ciphertext with crack.py and find the key
  4. Write XOR-cipher.md in your Step 89 wiki — one-line summary / usage commands / stuck points / a 3-line "why is it weak" explanation
  5. Bonus challenge (optional): modify it to take a multi-character key instead of one byte and XOR cycling through the key bytes, then organize why it’s still weak

Exercises

Exercise 1. Explain which rows of the truth table let you read why A ⊕ K ⊕ K = A holds.

Exercise 2. Explain, together with the word "symmetric key," why one single function is enough for both encryption and decryption.

Exercise 3. Compare, via "key space," why a one-byte-key XOR cipher breaks in one second while AES-128 doesn’t break by exhaustive search.

Exercise 4. Explain what happens when you XOR two ciphertexts encrypted with the same key, and why it’s dangerous.


5. Model Answers & Completion Criteria

Mission Model Answer

The code from 3-4 is the finished tool. Collecting just the mission’s verification commands:

python xor_tool.py enc original_file 42
python xor_tool.py dec original_file.enc 42
cmp original_file original_file.enc.dec   # no output = round-trip success
python crack.py                            # should print key candidate: 42

Skeleton of the optional task (multi-character key):

def xor_bytes(data, key):
    return bytes([b ^ key[i % len(key)] for i, b in enumerate(data)])

i % len(key) cycles through the key’s bytes. Why it’s still weak: the key space does grow, but as long as the key repeats, the same key byte gets reused periodically (the key-reuse problem from 3-7 repeats every cycle), and an attack that estimates the key length, splits, and brute-forces each position is possible.

How to verify: ① is cmp silent? ② does crack.py find the correct key? ③ is "why is it weak" written in the wiki document? All "yes" means complete.

Exercise Answers

Answer 1. In the truth table, if K=0 then A ⊕ 0 = A (rows 1 and 3: unchanged); if K=1 then A ⊕ 1 = the inverse of A (rows 2 and 4). In other words, XOR is an operation that "flips only the positions where K=1," so flipping twice with the same K flips the flipped positions back to the original.

Answer 2. Since XOR is a symmetric operation that returns to the original when applied twice with the same key, encryption and decryption are the same calculation. Because it’s a symmetric-key cipher that uses the same key to lock and unlock, one function covers both directions.

Answer 3. A one-byte key has only 256 candidates, so you try 0–255 all the way through and pick the "readable" one — done (measured in 3-6: under a second). AES-128 has 2-to-the-128th candidates, so even trying trillions per second takes longer than the age of the universe. A cipher’s strength comes from the size of its key space.

Answer 4. The key cancels out and the XOR of the two plaintexts (M1 ⊕ M2) is exposed as-is (measured in 3-7: True). The difference pattern of the two messages leaks, and if you know even part of one side’s content, the other side and the key are recovered — which is why key reuse is absolutely forbidden in stream ciphers.

Completion Criteria Checklist

  • [ ] I can demonstrate XOR’s restoration property with the ^ operator
  • [ ] I can read and write files in binary mode ("rb"/"wb")
  • [ ] I completed a CLI tool with working enc/dec
  • [ ] I proved with cmp that the round-trip result equals the original
  • [ ] I broke my own ciphertext with 256 exhaustive attempts
  • [ ] I can explain the key-reuse attack (M1 ⊕ M2 exposure)
  • [ ] I can state why this cipher is weak (key space, key repetition)

6. Common Pitfalls & Fixes

Wall 1. The restored file is corrupted / UnicodeDecodeError appears

Symptom (measured 2026-09-09, ciphertext read in text mode):

UnicodeDecodeError: 'cp949' codec can't decode byte 0xfe in position 6: illegal multibyte sequence

Cause: you dropped the b in open and read in text mode. Text mode dies or mangles things while the OS tries to interpret the encoding (cp949 on Korean Windows) against binary bytes.
Fix: check that both reading and writing use "rb"/"wb". This single b is this chapter’s most frequent accident.

Wall 2. "ValueError: invalid literal for int()" appears

Symptom (measured 2026-09-09, the word key entered in the key position; path fabricated):

  File "xor_tool.py", line 10, in main
    mode, path, key = sys.argv[1], sys.argv[2], int(sys.argv[3])
ValueError: invalid literal for int() with base 10: 'key'

Cause: a non-numeric string went into int(sys.argv[3]).
Fix: enter a number (0–255) as the usage says. Once you’re comfortable, wrapping it in try/except to show a friendly message is also good practice.

Wall 3. crack.py finds no candidates

Symptom: it ran 256 times and stayed quiet.
Cause: the word you’re looking for (hello) isn’t in the ciphertext, or that word wasn’t in the original.
Fix: search for a word actually present in the original. In the real world, you score "does it look like an English sentence" — since a short word can match by coincidence, visually confirming the printed candidates is part of cracking.

Wall 4. The key is wrong but no error appears, so you move on unaware

Symptom: decryption "succeeded" but the content is strange.
Cause: XOR gives a wrong answer without an error even for a wrong key (measured in 3-5: a key off by 1 gives results like "idmmn," each letter off by 1).
Fix: the tool can’t verify the result. Build the habit of visually confirming the content after restoring — if you have the original, comparing with cmp is the standard.

Wall 5. Filenames snowball (.enc.enc.dec…)

Symptom: the extensions pile up the more you run it.
Cause: a limitation of simply appending to the output name.
Fix: in dec mode, try polishing the naming rule, like stripping the trailing .enc. This finishing touch is part of "the tool’s completeness."


7. Summary

Today’s Concepts

Concept One-line explanation
XOR (⊕) Same gives 0, different gives 1. A ⊕ K ⊕ K = A — this property makes a cipher
Symmetric-key cipher A cipher that uses the same key to lock and unlock
Binary mode "rb"/"wb" — every file is a sequence of numbers from 0 to 255
Key space The number of possible keys. 256 means a 1-second exhaustive search; 2^128 means impossible
Brute force An attack that runs through all key candidates and picks the readable one
Known-plaintext attack An attack where knowing part of the plaintext recovers the key — fatal with key reuse
OTP The only cipher mathematically proven unbreakable, with a key as long as the data, truly random, used only once

Today’s Code

Code What it does
a ^ b XOR of two numbers
bytes([b ^ key for b in data]) Flip the entire byte sequence with the key (encryption = decryption)
open(p, "rb").read() Read a file as a byte sequence
open(p, "wb").write(b) Write a byte sequence to a file
sys.argv[1:] Pull out command-line arguments
for key in range(256) Exhaustive search of one-byte keys
cmp original restored Round-trip verification (no output = match; Windows uses fc /b)

An Instinct More Important Than Commands

Today you made it, reversed it, and broke it yourself. You confirmed with your own hands that a single line of XOR becomes a cipher, that a key space of 256 becomes that cipher’s grave, and that key reuse interlocks two ciphertexts and lets plaintext leak out. The difference between "using a weak cipher knowing it’s weak" and "using it without knowing" is the difference between a professional and an amateur.

And this one-line operation has a great descendant — the OTP (one-time pad), the only cipher mathematically proven unbreakable, when the key is as long as the data, completely random, and used exactly once. Today’s toy is that cipher’s ancestor. A large share of CTF crypto problems are variations on "something XORed," so keep crack.py well stored in your wiki — the day will come when you pull it out as-is at the competition venue.


Once every box is checked, Step 90 is complete.