Skip to content

CipherMachine - a modern Enigma

This article is about building an Enigma-style toy cipher machine but with strong modern cryptography and a security microcontroller.

cipherMachine

CipherMachine - Rendering

The idea

Some time ago when I was doomscrolling, I stumbled upon a short video showing a handheld toy Enigma, that was basically just a black PCB with some push buttons and LEDs. I'm not sure whether it was a real thing or just an AI video. However, I wasn't able to find it anywhere on the internet later on. But I decided that I wanted to have one. I like the idea to have a small cipher machine, that can fit into my hand, is cheap to produce and where I have control over the encryption. Nowadays, all major messengers are already using encryption. But you have to put all your trust in the big tech companies that create those apps, the backend servers, your smartphone, the operating system, etc...

With a small handheld encryption device, you have the ability to add another layer of encryption onto your message, regardless of which messenger you're using. Given that the encryption method on the device is secure, you can be sure, that no tech company will ever read your message. You can even use it to encrypt a letter (that's an old way to write a message on a piece of paper and asking a stranger guy to deliver it).

Obviously, if I want to build it, I can't simply use the original Enigma encryption algorithm, as I don't want a random guy in Bletchley Park to come around and break it. So I had the idea to replace it with a strong modern symmetric block cipher such as AES-256.

The cryptography

Enigma in a nutshell

Enigma

Original Enigma in use - by Bundesarchiv, Bild 183-2007-0705-502 / Walther / CC BY-SA 3.0 de

I won't explain in detail how the original Enigma worked, as there are plenty of really good Youtube videos available. But in a nutshell, the original Enigma had a keyboard with 26 buttons from A to Z, 26 light bulbs from A to Z, 3-4 rotors for scrabling the letters and a plugboard for fixed substitutions. The clue was, that both operators set their machine to the exact same initial configuration. Then encryption and decryption is the same operation. One operator enters the plaintext and gets the ciphertext, the operator on the other end enters the ciphertext and gets the plaintext back. This required both operators to know exactly, how to configure their machine.

Some settings, such as the choice and order of rotors, the ring settings, and plugboard connections, were usually changed daily according to a shared codebook. For each individual message, the sender also selected a unique message key (typically a starting rotor position) which was encrypted and sent along with the message. The receiver used the daily settings from the codebook and the transmitted message key to configure their machine exactly the same way, allowing them to decrypt the message correctly.

Key Handling

My plan was to do something similar by using two different keys:

  • ENCKEY: A secret key, that both parties agree on beforehand (either personally or through a secure channel) and which is used for encryption/decryption. It shall be stored in the non-volatile memory, so you don't have to enter it every time.
  • MSGKEY: A message key, which shall be unique for each message and is sent in plain alongside the ciphertext. The user shall choose a unique message key for every message to be sent under a fixed ENCKEY.

To be able to enter both keys on the push button keyboard, we want them to live in the alphabet \(\Sigma\) of ASCII encoded capital letters A-Z only, or more specifically:

\[ \begin{gathered} \Sigma = \{A,\dots,Z\} \\ \text{ENCKEY}, \text{MSGKEY} \in \Sigma ^* \end{gathered} \]

Encryption/Decryption

Now the questions arises, how to do the encryption. At this point I have to bring a big disclaimer:

Big Disclaimer

It is generally a very bad idea to build cryptographic algorithms on your own. Even if you reuse provenly secure building blocks for it. There are many pitfalls and things that could go catastrophically wrong, when trying to combine padding, encoding, key generation etc. As I am very curious to see, if someone finds a security flaw in it and since it is just for an educational toy cipher machine, I think it is okay to do so 😜.

Obviously we cannot simply put our plaintext into a ready-to-use encryption algorithm like AES-CBC, since we want the ciphertext to be capital letters only (and not random bytes). Furthermore, we want some sort of streaming cipher. This means that we do not provide the full message as a whole. We provide the plaintext letter by letter and also get back the ciphertext letter by letter. Another requirement (to be closer to the original Enigma) is, that both encryption and decryption shall be the same operation. Mathematically, what we want is called an involution:

\[ \begin{gathered} C = \operatorname{ENC}(P) \\ P = \operatorname{ENC}(C) = \operatorname{ENC}(\operatorname{Enc}(P)) \end{gathered} \]

A further functional requirement is, that the encryption scheme shall be fully deterministic. We want both machines to behave exactly the same, when they share the same initial configuration.

In terms of security of course, we want to keep sufficient distance from the original Enigma 😄. One commonly known flaw was, that one plaintext letter could never encrypt to itself (due to the reflector rotor). But there also have been human errors and procedural mistakes. What we want instead, is, that the ciphertext is indistinguishable from a stream of random letters. Or in other words, an observer cannot tell, wether the machine outputs the real ciphertext or just random dummy letters, given that he does not know ENCKEY.

For all those reasons, I decided to implement a Beaufort-Cipher with a strong pseudo random function (PRF) for key stream generation. (Yes, that's the same guy who invented the Beaufort scale for wind speeds). Beaufort-Cipher is a polyalphabetic, self-inverse/involutory substitution cipher, and therefore exactly what we want:

\[ \begin{gathered} \text{Encryption: } C_i = (K_i - P_i) \bmod 26 \\ \text{Decryption: } P_i = (K_i - C_i) \bmod 26 \end{gathered} \]

where \(P_i\) corresponds to the i-th plaintext letter, \(C_i\) to the i-th ciphertext letter and \(K_i\) to the i-th key letter. The scheme works on the ring of integers modulo 26, as we have 26 letters A-Z. One can clearly see, that encryption and decryption is the same operation. By applying it twice, we get the plaintext back:

\[ \begin{align} P_i &= (K_i - C_i) \bmod 26 \\ &= (K_i - (K_i - P_i)) \bmod 26\\ &= P_i \bmod 26 \end{align} \]

Fun Fact

The Beaufort cipher was also used in the Hagelin M-209 cipher machines, which were used by the US military during WW2. Of course it feels odd to use it in an Enigma toy, but who cares.

Furthermore, it is also a stream cipher, as encryption and decryption works letter-wise. If \(K_i\) is chosen uniformly and independently at random for every letter, then this gives a perfectly secure scheme (very similar to the famous One-Time-Pad). To understand this, just do the same scheme on a clock face (integers modulo 12). If the clock dial points to the plaintext time (e.g. 5 o'clock) and you turn the dial by a random amount of hours (this is the key), then the resulting time (ciphertext) is also indistinguishable from random.

To map the letters A-Z from their ASCII encoding to numbers in \(\{0,\dots,25\}\), we apply the following trivial mapping function \(m\) to a letter \(c\):

\[ \begin{gathered} m:\{A,\dots,Z\} \to \{0,\dots,25\} = \mathbb{Z}_{26}\\ m(c) = \operatorname{ord}(c) - \operatorname{ord}(A) \end{gathered} \]

with \(\operatorname{ord}(c)\) giving the index of letter \(c\) in the ASCII table.

The encryption/decryption can be implemented very easily in Python:

def EnDecrypt(self, instring):
        outstring = ''
        for char in instring:
            if char < 'A' or char > 'Z':
                raise ValueError(f"Invalid character in input: {char}")
            P_i = ord(char) - 65 # map A-Z to 0-25
            K_i = self._samplePRF() # get the next key value in 0-25 from the PRF
            C_i = (K_i - P_i) % 26 # Encrypt/Decrypt operation
            outstring += chr(C_i + 65) # map 0-25 back to A-Z
        return outstring
The magic of course lies in the samplePRF() function, that must create a cryptographically strong random keystream for the encryption to be secure.

Keystream generation

To get a random-looking, yet deterministic stream of numbers in \(\{0,\dots,25\}\), I built a pseudo-random-function (PRF) out of the AES-256 block cipher in counter mode of operation (CTR) with subsequent rejection sampling.

AES-CTR is usually used as an encryption algorithm. It takes as input a secret 256bit key \(K_{AES}\) and a 12 byte nonce (backronym: n_umber, that is only used _once). The nonce must be unique for each message to be encrypted under a fixed key \(K_{AES}\). This is to make sure, that a message is never encrypted with the same key stream, which would lead to plaintext leak (i.e. an attacker is able to calculcate the "difference" between two plaintexts \(P_1, P_2\) based on two ciphertexts \(C_1, C_2\). This is why the Enigma operators also used a unique message-key (German: "Spruchschlüssel") for each message.

In AES-CTR mode, the 12 byte nonce and a 4 byte counter is encrypted using the AES block cipher. The resulting key stream is then XOR-ed with the 16 byte plaintext blocks to get the 16 byte ciphertext blocks. To directly access the key stream, we simply encrypt 16 bytes of zeros.

The keystream however, consists of blocks of 16 bytes, each in \(\{0,\dots,255\}\). We cannot simply do a modulo 26 operation on a byte or a full block, as the resulting numbers will have a slight bias. This can be seen really easy with the following python snippet:

foo = [b%26 for b in range(256)]
{i:foo.count(i) for i in foo}

Numbers \(\{0,\dots,21\}\) : 10 occurences

Numbers \(\{22,23,24,25\}\) : 9 occurences

So the resulting numbers are not evenly distributed (i.e. they are not indistinguishable from random).

A way around this problem is to use rejection sampling. We do only accept bytes in \(\{0,\dots,233\}\):

foo = [b%26 for b in range(234)]
{i:foo.count(i) for i in foo}

Then all numbers \(\{0,\dots,25\}\) occur exactly 9 times.

Here is the full PRF function based on AES-CTR with rejection sampling:

def _samplePRF(self):
        keystream_block = self.AES.encrypt(bytes(16)) # encrypt 16 zero bytes to get the next keystream block
        for byte in keystream_block:
            if byte < 234:
                break
        return byte % 26 # return unbiased random number in 0-25

Of course, the code shown above only tries 16 times to get a byte, which is lower than 234. If all 16 bytes of the keystream block are greater than or equal 234, we simply stick to the last byte. This again introduces a slight bias, but the probability is quite low, and therefore negligible for our educational toy application here. The probability, that all 16 bytes are greater than or equal 234 and therefore the probability, that we use a slightly-biased random number to encrypt a letter is:

\[ P = \left( \frac{255-234+1}{256} \right) ^{16} \approx 2^{-56} \]

Of course you can simply introduce a while-loop in the python function to retrieve a fresh keystream block, if all 16 bytes are rejected. But I am fine with the low probability calculated above.

Key/Nonce derivation

We still need to talk about how we get to our \(K_{AES}\) and our 12 byte nonce from the following two keys:

  • ENCKEY: The secret key both parties agree on beforehand. Shall be used to derive \(K_{AES}\).
  • MSGKEY: A unique message key sent in plain alongside the ciphertext. Shall be used to derive the nonce.

To derive a proper AES key from a user-provided (potentially low-entropy) password, I simply use a key-derivation-function (KDF) based on HMAC-SHA256. It gets as input the ASCII-encoded ENCKEY (characters A-Z only) and a hardcoded internal key \(K_{KDF-ENC}\) and outputs \(K_{AES}\), which is then used for AES-CTR.

To map MSGKEY to a 12 byte nonce, I just re-use the same KDF (due to laziness) and truncate the output. However, to separate the two domains, I use a different hardcoded internal key \(K_{KDF-MSG}\). This is potentially overkill (a simple hash digest might be enough here to derive a nonce), but I can re-use the same code for both purposes. As I've explained above, that a nonce should never be used twice under a given ENCKEY, it would be better to simply use a message counter as the nonce (which is increased with each sent message). But as we restricted ourselves to letter A-Z only, it is easier to use a KDF here. However, there is always a certain probability, that two distinct MSGKEY result in the same nonce. For this to happen, we have to encrypt a lot of messages. A rough estimation can be done by applying the birthday bound, which can be approximated as the square root of possible 12 byte (96bit) nonces:

\[ N_\text{birthday} = \sqrt{2^{96}} \approx 2.8 \cdot 10^{14} \]

According to WolframAlpha, this is 14-times the number of red blood cells in the human body. If you enter such high amounts of messages with your fingers using the push buttons, you'll probably loose a lot of those red blood cells onto the floor.

The full scheme

Here is the full scheme represented in a diagram:

crypto

Cryptographic scheme used in the cipherMachine

Here is the full Python code:

import hmac
import hashlib
from Crypto.Cipher import AES

DEBUG = True

class cipherMachine:
    DEFAULT_K_KDFMSG = bytes([0x63, 0x91, 0x4d, 0x5d, 0x4d, 0x07, 0x8a, 0x9f, 0x13, 0x69, 0x38, 0x0e, 0x6a, 0x14, 0x35, 0x8f, 0x69, 0xc6, 0xb7, 0xa2, 0x34, 0x11, 0x50, 0xad, 0xd4, 0x4d, 0xf9, 0x4a, 0x44, 0x93, 0x16, 0x1e])
    DEFAULT_K_KDFENC = bytes([0x48, 0x2e, 0x5b, 0x2f, 0xb9, 0xd9, 0x67, 0x8b, 0x4f, 0x60, 0x43, 0x08, 0xaf, 0x19, 0x50, 0x22, 0xa1, 0x9a, 0x24, 0x06, 0xf3, 0xc6, 0x1b, 0xb1, 0xe7, 0xfb, 0x3d, 0x1c, 0xc0, 0x5d, 0xec, 0xb6])

    def __init__(self, ENCKEY='', MSGKEY='', K_KDFMSG=DEFAULT_K_KDFMSG, K_KDFENC=DEFAULT_K_KDFENC):
        self.ENCKEY = ENCKEY
        self.MSGKEY = MSGKEY
        self.K_KDFMSG = K_KDFMSG
        self.K_KDFENC = K_KDFENC
        self.K_AES = None
        self.nonce = None
        self.setENCKEY(ENCKEY)
        self.setMSGKEY(MSGKEY)
        self._initAES()
        self.i = -1

    def _initAES(self):
        if self.K_AES is None or self.nonce is None:
            return
        self.AES = AES.new(self.K_AES, AES.MODE_CTR, nonce=self.nonce)

    def _HMAC_SHA256(self, key, message):
        if isinstance(message, str):
            message = message.encode('ascii')
        return hmac.new(key, message, hashlib.sha256).digest()

    def _samplePRF(self):
        # Key stream generation using AES-CTR-256 with rejection sampling to get an unbiased random number in 0-25
        keystream_block = self.AES.encrypt(bytes(16)) # encrypt 16 zero bytes to get the next keystream block
        self.i += 1
        for byte in keystream_block: # if no byte is less than 234, we just use the last byte. This will rarely introduce a bias, but it's acceptable for this application.
            if byte < 234:
                break
        return byte % 26 # return unbiased random number in 0-25

    def setENCKEY(self, string):
        # derive K_AES from ENCKEY and K_KDFENC using HMAC-SHA256
        self.ENCKEY = string
        self.K_AES = self._HMAC_SHA256(self.K_KDFENC, self.ENCKEY)
        if DEBUG:
            print(f"Derived K_AES from ENCKEY='{self.ENCKEY}': {self.K_AES.hex()}")
        self._initAES() # reinitialize AES with the new key

    def setMSGKEY(self, string):
        # derive 12B nonce from MSGKEY and K_KDFMSG using HMAC-SHA256
        self.MSGKEY = string
        self.nonce = self._HMAC_SHA256(self.K_KDFMSG, self.MSGKEY)[:12]
        if DEBUG:
            print(f"Derived nonce from MSGKEY='{self.MSGKEY}': {self.nonce.hex()}")
        self._initAES() # reinitialize AES with the new nonce

    def EnDecrypt(self, instring):
        outstring = ''
        for char in instring:
            if char < 'A' or char > 'Z':
                raise ValueError(f"Invalid character in input: {char}")
            P_i = ord(char) - 65 # map A-Z to 0-25
            K_i = self._samplePRF() # get the next key value in 0-25 from the PRF
            C_i = (K_i - P_i) % 26 # Encrypt/Decrypt operation
            outstring += chr(C_i + 65) # map 0-25 back to A-Z
            if DEBUG:
                print(f"{self.i} : {char} -> {outstring[-1]} : ({K_i} - {P_i}) mod 26 = {C_i}")
        return outstring

The hardware

(coming soon)

The firmware

(coming soon)