Compression, encryption and hashing
Run length and dictionary coding, the Caesar and Vernam ciphers, symmetric and asymmetric encryption, and hashing.
Do this lesson in the simulatorAll three of these transform data with an algorithm. Compression makes data smaller, encryption makes it unreadable without a key, and hashing turns it into a short fixed-size value that cannot be turned back. At GCSE you met lossy and lossless compression, run length encoding and Huffman coding (F8.9), the Caesar cipher (F11.6) and hashed passwords (F11.5). A level adds dictionary coding, the one cipher that cannot be broken, the difference between perfect and computational security, and what hashing is used for.
Lossy and lossless
- Lossless compression reduces the size and lets the exact original be rebuilt. It is essential for text, program code, spreadsheets and the robot's commands: one changed character in
LEFT 90is a different instruction. - Lossy compression throws away data that people are unlikely to notice, such as detail the eye cannot see in a photo (JPEG) or sounds masked by louder ones (MP3). The original can never be rebuilt, but files become far smaller than lossless methods can manage.
Lossy suits images, sound and video meant for people. Lossless suits anything where every bit matters.
Run length encoding
Run length encoding (RLE) replaces a run of repeated values with one copy of the value and a count. It is lossless.
def rle(row):
runs = []
i = 0
while i < len(row):
j = i
while j < len(row) and row[j] == row[i]:
j = j + 1
runs.append(str(j - i) + row[i])
i = j
return "".join(runs)
for row in ["WWWWWWWWWWBBBWWWWWWWWWWWWB", "WBWBWB"]:
packed = rle(row)
print(row, "->", packed, len(row), "->", len(packed))
The first row of a 1-bit image shrinks from 26 characters to 10. The second grows from 6 to 12, because every run has length 1 and each still costs a count. RLE works only when data has long runs: simple graphics, scanned documents, the black background of a robot camera frame. Photos rarely have runs, because neighbouring pixels differ slightly.
Dictionary coding
Dictionary-based compression replaces repeated patterns, not just repeated single values. Each pattern is stored once in a dictionary, and the data becomes a list of references to dictionary entries.
For the text GO LEFT GO RIGHT GO LEFT GO LEFT:
| Index | Word |
|---|---|
| 0 | GO |
| 1 | LEFT |
| 2 | RIGHT |
the data is 0 1 0 2 0 1 0 1. Decompression looks each index up and puts the words back, so it is lossless.
Two points examiners like. First, the dictionary must be stored or sent with the data, so it counts towards the compressed size; on a short message it can make the result bigger. Second, the more often a pattern repeats, the bigger the saving, because each repeat costs only one short reference. Real methods such as LZW (used in GIF images) build the dictionary as they go, so the decoder can rebuild the same dictionary without it being sent.
Task: a compressed route
route is a string of words separated by single spaces: FWD <cm> and LEFT <degrees> commands.
- Write
encode(text). The parametertextis a string of words separated by single spaces. It returns a pair: a list of the different words in the order they first appear (the dictionary), and a string of each word's index in that list, separated by single spaces. - Write
decode(dictionary, encoded), which takes those two values and returns the original string. - Print
dictionary: <the words separated by spaces>, thenencoded: <the encoded string>, thenoriginal: <n> characters, encoded: <m> charactersusinglenofrouteand of the encoded string, thendecoded matches: <True or False>comparing the decoded string withroute. - Drive the decoded route:
FWD nisforward(50, distance=n)andLEFT nisturn_left(angle=n), wherenis the number after the word.
Build the encoded string with your function; do not type it in.
# the two lines every program starts with: the commands, then the robot
from bugbot import *
connect()
route = "FWD 20 LEFT 90 FWD 20 LEFT 90 FWD 20 LEFT 90 FWD 20 LEFT 90"
def encode(text):
dictionary = []
codes = []
return dictionary, " ".join(codes)
def decode(dictionary, encoded):
return ""
Breaking the Caesar cipher
A cipher's key space is the number of possible keys. The Caesar cipher has only 25 useful keys, so an attacker can simply try them all, a brute force attack:
def caesar(text, key):
out = ""
for ch in text:
if ch.isalpha():
out = out + chr((ord(ch) - ord("A") + key) % 26 + ord("A"))
else:
out = out + ch
return out
intercepted = "EFCY WPQE LE ESP HLWW"
for key in range(1, 26):
print(key, caesar(intercepted, -key))
Only key 11 gives English. Even with a larger key space, a cipher that always turns the same letter into the same letter falls to frequency analysis: E is the most common letter in English, so the most common ciphertext letter is probably E.
The Vernam cipher
The Vernam cipher, or one-time pad, combines each character of the plaintext with a character of the key using XOR (lesson A7.5). XOR with the same key byte again restores the original, so the same operation decrypts.
| Bits | |
|---|---|
plaintext M (77) |
01001101 |
| key byte (90) | 01011010 |
| ciphertext (XOR) | 00010111 |
| XOR with the key again | 01001101 = M |
The Vernam cipher offers perfect security, provided the key:
- is truly random,
- is at least as long as the plaintext,
- is used only once, then destroyed,
- is kept secret and shared securely in advance.
Perfect security means the ciphertext gives an attacker no information at all. For any plaintext of the same length there is a key that would produce this ciphertext, so every message is equally likely, and trying every key just produces every possible message. No amount of computing power helps. Reuse the key, and XORing two ciphertexts cancels the key and leaks the XOR of the plaintexts.
Every other cipher in use offers computational security: it could in principle be broken, by trying every key, but it would take so long with current computers that the data would be worthless by then.
Symmetric and asymmetric encryption
- Symmetric encryption uses the same key to encrypt and decrypt (Caesar, Vernam, AES). It is fast. The problem is key exchange: the key must reach the receiver without being intercepted.
- Asymmetric encryption uses a key pair. Data encrypted with someone's public key can be decrypted only with their private key. The public key can be published, so there is no secret to exchange. It is much slower.
In practice they are combined: asymmetric encryption is used to send a randomly chosen symmetric key, and that fast symmetric key encrypts the rest of the session. This is how HTTPS works.
Hashing
A hash function turns data of any length into a fixed-size value, the hash (or digest). A good one is quick to calculate, always gives the same hash for the same input, gives a completely different hash for a tiny change, and is one-way: you cannot work back from the hash to the data. Hashing is not encryption, because there is no key and no way back.
import hashlib
for password in ["bugbot", "Bugbot"]:
print(password, hashlib.sha256(password.encode()).hexdigest())
Uses of hashing:
- Storing passwords. The system stores only the hash. At login it hashes what was typed and compares hashes. A stolen table of hashes does not reveal the passwords. A random salt added to each password first means two users with the same password get different hashes.
- Checking integrity. A download site publishes a file's hash; if your copy's hash matches, the file was not corrupted or altered.
- Hash tables. Hashing a key gives the position to store it in an array, so it can be found again in one step instead of by searching.
Because many inputs map to a limited number of hashes, two inputs can give the same hash, a collision. With a toy hash into 16 slots, LEFT and BACK collide:
def toy_hash(text, size=16):
h = 0
for ch in text:
h = (h * 31 + ord(ch)) % size
return h
for word in ["GO", "STOP", "LEFT", "RIGHT", "BACK", "WAIT"]:
print(word, toy_hash(word))
A hash table must handle collisions; a cryptographic hash is designed so that finding one is computationally infeasible.
Task: a one-time pad
KEY is a list of 10 whole numbers from 0 to 255.
- Write
vernam(data, key). Both parameters are lists of whole numbers from 0 to 255. Ifkeyis shorter thandata, raise aValueError. Otherwise return a new list, the same length asdata, in which each item isdata[i]XORkey[i]. - Turn
"MEET AT B4"into a list of character codes withord, encrypt it withKEY, and printcipher:followed by each byte as two uppercase hex digits (for exampleformat(b, "02X")), separated by single spaces. - Decrypt the ciphertext with the same function and key, turn the codes back into text with
chr, and printdecrypted: <text>. - Call
vernamon the message with only the first 4 key bytes,KEY[:4]; catch theValueErrorand printshort key refused.
# the two lines every program starts with: the commands, then the robot
from bugbot import *
connect()
KEY = [0x5A, 0x13, 0xC7, 0x88, 0x2E, 0x71, 0xB0, 0x09, 0xF4, 0x3D]
def vernam(data, key):
return data
Challenges
- Write an RLE decoder for the format above. What goes wrong if a run is longer than 9?
- Encrypt two different messages with the same
KEY, XOR the two ciphertexts, and compare with the XOR of the two plaintexts. What does this tell an attacker? - Use
toy_hashas a hash table of 16 slots for ten commands. How will you store two commands that collide?