Forráskód Böngészése

Add Enigma operation

s2224834 6 éve
szülő
commit
088864fd9c

+ 1 - 0
.gitignore

@@ -6,6 +6,7 @@ docs/*
 !docs/*.conf.json
 !docs/*.ico
 .vscode
+.*.swp
 src/core/config/modules/*
 src/core/config/OperationConfig.json
 src/core/operations/index.mjs

+ 2 - 1
src/core/config/Categories.json

@@ -102,7 +102,8 @@
             "JWT Decode",
             "Citrix CTX1 Encode",
             "Citrix CTX1 Decode",
-            "Pseudo-Random Number Generator"
+            "Pseudo-Random Number Generator",
+            "Enigma"
         ]
     },
     {

+ 349 - 0
src/core/lib/Enigma.mjs

@@ -0,0 +1,349 @@
+/**
+ * Emulation of the Enigma machine.
+ *
+ * @author s2224834
+ * @copyright Crown Copyright 2019
+ * @license Apache-2.0
+ */
+import OperationError from "../errors/OperationError";
+import Utils from "../Utils";
+
+/**
+ * Provided default Enigma rotor set.
+ * These are specified as a list of mappings from the letters A through Z in order, optionally
+ * followed by < and a list of letters at which the rotor steps.
+ */
+export const ROTORS = [
+    {name: "I", value: "EKMFLGDQVZNTOWYHXUSPAIBRCJ<R"},
+    {name: "II", value: "AJDKSIRUXBLHWTMCQGZNPYFVOE<F"},
+    {name: "III", value: "BDFHJLCPRTXVZNYEIWGAKMUSQO<W"},
+    {name: "IV", value: "ESOVPZJAYQUIRHXLNFTGKDCMWB<K"},
+    {name: "V", value: "VZBRGITYUPSDNHLXAWMJQOFECK<A"},
+    {name: "VI", value: "JPGVOUMFYQBENHZRDKASXLICTW<AN"},
+    {name: "VII", value: "NZJHGRCXMYSWBOUFAIVLPEKQDT<AN"},
+    {name: "VIII", value: "FKQHTLXOCBJSPDZRAMEWNIUYGV<AN"},
+    {name: "Beta", value: "LEYJVCNIXWPBQMDRTAKZGFUHOS"},
+    {name: "Gamma", value: "FSOKANUERHMBTIYCWLQPZXVGJD"},
+];
+
+export const ROTORS_OPTIONAL = [].concat(ROTORS).concat([
+    {name: "None", value: ""},
+]);
+
+/**
+ * Provided default Enigma reflector set.
+ * These are specified as 13 space-separated transposed pairs covering every letter.
+ */
+export const REFLECTORS = [
+    {name: "B", value: "AY BR CU DH EQ FS GL IP JX KN MO TZ VW"},
+    {name: "C", value: "AF BV CP DJ EI GO HY KR LZ MX NW TQ SU"},
+    {name: "B Thin", value: "AE BN CK DQ FU GY HW IJ LO MP RX SZ TV"},
+    {name: "C Thin", value: "AR BD CO EJ FN GT HK IV LM PW QZ SX UY"},
+];
+
+export const LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("");
+
+/**
+ * Map a letter to a number in 0..25.
+ *
+ * @param {char} c
+ * @param {boolean} permissive - Case insensitive; don't throw errors on other chars.
+ * @returns {number}
+ */
+export function a2i(c, permissive=false) {
+    const i = Utils.ord(c);
+    if (i >= 65 && i <= 90) {
+        return i - 65;
+    }
+    if (permissive) {
+        // Allow case insensitivity
+        if (i >= 97 && i <= 122) {
+            return i - 97;
+        }
+        return -1;
+    }
+    throw new OperationError("a2i called on non-uppercase ASCII character");
+}
+
+/**
+ * Map a number in 0..25 to a letter.
+ *
+ * @param {number} i
+ * @returns {char}
+ */
+export function i2a(i) {
+    if (i >= 0 && i < 26) {
+        return Utils.chr(i+65);
+    }
+    throw new OperationError("i2a called on value outside 0..25");
+}
+
+/**
+ * A rotor in the Enigma machine.
+ */
+export class Rotor {
+    /**
+     * Rotor constructor.
+     *
+     * @param {string} wiring - A 26 character string of the wiring order.
+     * @param {string} steps - A 0..26 character string of stepping points.
+     * @param {char} ringSetting - The ring setting.
+     * @param {char} initialPosition - The initial position of the rotor.
+     */
+    constructor(wiring, steps, ringSetting, initialPosition) {
+        if (!/^[A-Z]{26}$/.test(wiring)) {
+            throw new OperationError("Rotor wiring must be 26 unique uppercase letters");
+        }
+        if (!/^[A-Z]{0,26}$/.test(steps)) {
+            throw new OperationError("Rotor steps must be 0-26 unique uppercase letters");
+        }
+        if (!/^[A-Z]$/.test(ringSetting)) {
+            throw new OperationError("Rotor ring setting must be exactly one uppercase letter");
+        }
+        if (!/^[A-Z]$/.test(initialPosition)) {
+            throw new OperationError("Rotor initial position must be exactly one uppercase letter");
+        }
+        this.map = {};
+        this.revMap = {};
+        for (let i=0; i<LETTERS.length; i++) {
+            const a = a2i(LETTERS[i]);
+            const b = a2i(wiring[i]);
+            this.map[a] = b;
+            this.revMap[b] = a;
+        }
+        if (Object.keys(this.revMap).length !== LETTERS.length) {
+            throw new OperationError("Rotor wiring must have each letter exactly once");
+        }
+        const rs = a2i(ringSetting);
+        this.steps = new Set();
+        for (const x of steps) {
+            this.steps.add(Utils.mod(a2i(x) - rs, 26));
+        }
+        if (this.steps.size !== steps.length) {
+            // This isn't strictly fatal, but it's probably a mistake
+            throw new OperationError("Rotor steps must be unique");
+        }
+        this.pos = Utils.mod(a2i(initialPosition) - rs, 26);
+    }
+
+    /**
+     * Step the rotor forward by one.
+     */
+    step() {
+        this.pos = Utils.mod(this.pos + 1, 26);
+        return this.pos;
+    }
+
+    /**
+     * Transform a character through this rotor forwards.
+     *
+     * @param {number} c - The character.
+     * @returns {number}
+     */
+    transform(c) {
+        return Utils.mod(this.map[Utils.mod(c + this.pos, 26)] - this.pos, 26);
+    }
+
+    /**
+     * Transform a character through this rotor backwards.
+     *
+     * @param {number} c - The character.
+     * @returns {number}
+     */
+    revTransform(c) {
+        return Utils.mod(this.revMap[Utils.mod(c + this.pos, 26)] - this.pos, 26);
+    }
+}
+
+/**
+ * Base class for plugboard and reflector (since these do effectively the same
+ * thing).
+ */
+class PairMapBase {
+    /**
+     * PairMapBase constructor.
+     *
+     * @param {string} pairs - A whitespace separated string of letter pairs to swap.
+     * @param {string} [name='PairMapBase'] - For errors, the name of this object.
+     */
+    constructor(pairs, name="PairMapBase") {
+        // I've chosen to make whitespace significant here to make a) code and
+        // b) inputs easier to read
+        this.map = {};
+        if (pairs === "") {
+            return;
+        }
+        pairs.split(/\s+/).forEach(pair => {
+            if (!/^[A-Z]{2}$/.test(pair)) {
+                throw new OperationError(name + " must be a whitespace-separated list of uppercase letter pairs");
+            }
+            const a = a2i(pair[0]), b = a2i(pair[1]);
+            if (a === b) {
+                throw new OperationError(`${name}: cannot connect ${pair[0]} to itself`);
+            }
+            if (this.map.hasOwnProperty(a)) {
+                throw new OperationError(`${name} connects ${pair[0]} more than once`);
+            }
+            if (this.map.hasOwnProperty(b)) {
+                throw new OperationError(`${name} connects ${pair[1]} more than once`);
+            }
+            this.map[a] = b;
+            this.map[b] = a;
+        });
+    }
+
+    /**
+     * Transform a character through this object.
+     * Returns other characters unchanged.
+     *
+     * @param {number} c - The character.
+     * @returns {number}
+     */
+    transform(c) {
+        if (!this.map.hasOwnProperty(c)) {
+            return c;
+        }
+        return this.map[c];
+    }
+
+    /**
+     * Alias for transform, to allow interchangeable use with rotors.
+     *
+     * @param {number} c - The character.
+     * @returns {number}
+     */
+    revTransform(c) {
+        return this.transform(c);
+    }
+}
+
+/**
+ * Reflector. PairMapBase but requires that all characters are accounted for.
+ */
+export class Reflector extends PairMapBase {
+    /**
+     * Reflector constructor. See PairMapBase.
+     * Additional restriction: every character must be accounted for.
+     */
+    constructor(pairs) {
+        super(pairs, "Reflector");
+        const s = Object.keys(this.map).length;
+        if (s !== 26) {
+            throw new OperationError("Reflector must have exactly 13 pairs covering every letter");
+        }
+    }
+}
+
+/**
+ * Plugboard. Unmodified PairMapBase.
+ */
+export class Plugboard extends PairMapBase {
+    /**
+     * Plugboard constructor. See PairMapbase.
+     */
+    constructor(pairs) {
+        super(pairs, "Plugboard");
+    }
+}
+
+/**
+ * Base class for the Enigma machine itself. Holds rotors, a reflector, and a plugboard.
+ */
+export class EnigmaBase {
+    /**
+     * EnigmaBase constructor.
+     *
+     * @param {Object[]} rotors - List of Rotors.
+     * @param {Object} reflector - A Reflector.
+     * @param {Plugboard} plugboard - A Plugboard.
+     */
+    constructor(rotors, reflector, plugboard) {
+        this.rotors = rotors;
+        this.rotorsRev = [].concat(rotors).reverse();
+        this.reflector = reflector;
+        this.plugboard = plugboard;
+    }
+
+    /**
+     * Step the rotors forward by one.
+     *
+     * This happens before the output character is generated.
+     *
+     * Note that rotor 4, if it's there, never steps.
+     *
+     * Why is all the logic in EnigmaBase and not a nice neat method on
+     * Rotor that knows when it should advance the next item?
+     * Because the double stepping anomaly is a thing. tl;dr if the left rotor
+     * should step the next time the middle rotor steps, the middle rotor will
+     * immediately step.
+     */
+    step() {
+        const r0 = this.rotors[0];
+        const r1 = this.rotors[1];
+        r0.step();
+        // The second test here is the double-stepping anomaly
+        if (r0.steps.has(r0.pos) || r1.steps.has(Utils.mod(r1.pos + 1, 26))) {
+            r1.step();
+            if (r1.steps.has(r1.pos)) {
+                const r2 = this.rotors[2];
+                r2.step();
+            }
+        }
+    }
+
+    /**
+     * Encrypt (or decrypt) some data.
+     * Takes an arbitrary string and runs the Engima machine on that data from
+     * *its current state*, and outputs the result. Non-alphabetic characters
+     * are returned unchanged.
+     *
+     * @param {string} input - Data to encrypt.
+     * @returns {string}
+     */
+    crypt(input) {
+        let result = "";
+        for (const c of input) {
+            let letter = a2i(c, true);
+            if (letter === -1) {
+                result += c;
+                continue;
+            }
+            // First, step the rotors forward.
+            this.step();
+            // Now, run through the plugboard.
+            letter = this.plugboard.transform(letter);
+            // Then through each wheel in sequence, through the reflector, and
+            // backwards through the wheels again.
+            for (const rotor of this.rotors) {
+                letter = rotor.transform(letter);
+            }
+            letter = this.reflector.transform(letter);
+            for (const rotor of this.rotorsRev) {
+                letter = rotor.revTransform(letter);
+            }
+            // Finally, back through the plugboard.
+            letter = this.plugboard.revTransform(letter);
+            result += i2a(letter);
+        }
+        return result;
+    }
+}
+
+/**
+ * The Enigma machine itself. Holds 3-4 rotors, a reflector, and a plugboard.
+ */
+export class EnigmaMachine extends EnigmaBase {
+    /**
+     * EnigmaMachine constructor.
+     *
+     * @param {Object[]} rotors - List of Rotors.
+     * @param {Object} reflector - A Reflector.
+     * @param {Plugboard} plugboard - A Plugboard.
+     */
+    constructor(rotors, reflector, plugboard) {
+        super(rotors, reflector, plugboard);
+        if (rotors.length !== 3 && rotors.length !== 4) {
+            throw new OperationError("Enigma must have 3 or 4 rotors");
+        }
+    }
+}

+ 200 - 0
src/core/operations/Enigma.mjs

@@ -0,0 +1,200 @@
+/**
+ * Emulation of the Enigma machine.
+ *
+ * @author s2224834
+ * @copyright Crown Copyright 2019
+ * @license Apache-2.0
+ */
+
+import Operation from "../Operation";
+import OperationError from "../errors/OperationError";
+import * as Enigma from "../lib/Enigma";
+
+/**
+ * Enigma operation
+ */
+class EnigmaOp extends Operation {
+    /**
+     * Enigma constructor
+     */
+    constructor() {
+        super();
+
+        this.name = "Enigma";
+        this.module = "Default";
+        this.description = "Encipher/decipher with the WW2 Enigma machine.<br><br>The standard set of German military rotors and reflectors are provided. To configure the plugboard, enter a string of connected pairs of letters, e.g. <code>AB CD EF</code> connects A to B, C to D, and E to F. This is also used to create your own reflectors. To create your own rotor, enter the letters that the rotor maps A to Z to, in order, optionally followed by <code>&lt;</code> then a list of stepping points.<br>This is deliberately fairly permissive with rotor placements etc compared to a real Enigma (on which, for example, a four-rotor Enigma uses the thin reflectors and the beta or gamma rotor in the 4th slot).";
+        this.infoURL = "https://wikipedia.org/wiki/Enigma_machine";
+        this.inputType = "string";
+        this.outputType = "string";
+        this.args = [
+            {
+                name: "1st (right-hand) rotor",
+                type: "editableOption",
+                value: Enigma.ROTORS,
+                // Default config is the rotors I-III *left to right*
+                defaultIndex: 2
+            },
+            {
+                name: "1st rotor ring setting",
+                type: "option",
+                value: Enigma.LETTERS
+            },
+            {
+                name: "1st rotor initial value",
+                type: "option",
+                value: Enigma.LETTERS
+            },
+            {
+                name: "2nd rotor",
+                type: "editableOption",
+                value: Enigma.ROTORS,
+                defaultIndex: 1
+            },
+            {
+                name: "2nd rotor ring setting",
+                type: "option",
+                value: Enigma.LETTERS
+            },
+            {
+                name: "2nd rotor initial value",
+                type: "option",
+                value: Enigma.LETTERS
+            },
+            {
+                name: "3rd rotor",
+                type: "editableOption",
+                value: Enigma.ROTORS,
+                defaultIndex: 0
+            },
+            {
+                name: "3rd rotor ring setting",
+                type: "option",
+                value: Enigma.LETTERS
+            },
+            {
+                name: "3rd rotor initial value",
+                type: "option",
+                value: Enigma.LETTERS
+            },
+            {
+                name: "4th rotor",
+                type: "editableOption",
+                value: Enigma.ROTORS_OPTIONAL,
+                defaultIndex: 10
+            },
+            {
+                name: "4th rotor initial value",
+                type: "option",
+                value: Enigma.LETTERS
+            },
+            {
+                name: "Reflector",
+                type: "editableOption",
+                value: Enigma.REFLECTORS
+            },
+            {
+                name: "Plugboard",
+                type: "string",
+                value: ""
+            },
+            {
+                name: "Strict output",
+                hint: "Remove non-alphabet letters and group output",
+                type: "boolean",
+                value: true
+            },
+        ];
+    }
+
+    /**
+     * Helper - for ease of use rotors are specified as a single string; this
+     * method breaks the spec string into wiring and steps parts.
+     *
+     * @param {string} rotor - Rotor specification string.
+     * @param {number} i - For error messages, the number of this rotor.
+     * @returns {string[]}
+     */
+    parseRotorStr(rotor, i) {
+        if (rotor === "") {
+            throw new OperationError(`Rotor ${i} must be provided.`);
+        }
+        if (!rotor.includes("<")) {
+            return [rotor, ""];
+        }
+        return rotor.split("<", 2);
+    }
+
+    /**
+     * @param {string} input
+     * @param {Object[]} args
+     * @returns {string}
+     */
+    run(input, args) {
+        const [
+            rotor1str, rotor1ring, rotor1pos,
+            rotor2str, rotor2ring, rotor2pos,
+            rotor3str, rotor3ring, rotor3pos,
+            rotor4str, rotor4pos,
+            reflectorstr, plugboardstr,
+            removeOther
+        ] = args;
+        const rotors = [];
+        const [rotor1wiring, rotor1steps] = this.parseRotorStr(rotor1str, 1);
+        rotors.push(new Enigma.Rotor(rotor1wiring, rotor1steps, rotor1ring, rotor1pos));
+        const [rotor2wiring, rotor2steps] = this.parseRotorStr(rotor2str, 2);
+        rotors.push(new Enigma.Rotor(rotor2wiring, rotor2steps, rotor2ring, rotor2pos));
+        const [rotor3wiring, rotor3steps] = this.parseRotorStr(rotor3str, 3);
+        rotors.push(new Enigma.Rotor(rotor3wiring, rotor3steps, rotor3ring, rotor3pos));
+        if (rotor4str !== "") {
+            // Fourth rotor doesn't have a ring setting - A is equivalent to no setting
+            const [rotor4wiring, rotor4steps] = this.parseRotorStr(rotor4str, 4);
+            rotors.push(new Enigma.Rotor(rotor4wiring, rotor4steps, "A", rotor4pos));
+        }
+        const reflector = new Enigma.Reflector(reflectorstr);
+        const plugboard = new Enigma.Plugboard(plugboardstr);
+        if (removeOther) {
+            input = input.replace(/[^A-Za-z]/g, "");
+        }
+        const enigma = new Enigma.EnigmaMachine(rotors, reflector, plugboard);
+        let result = enigma.crypt(input);
+        if (removeOther) {
+            // Five character cipher groups is traditional
+            result = result.replace(/([A-Z]{5})(?!$)/g, "$1 ");
+        }
+        return result;
+    }
+
+    /**
+     * Highlight Enigma
+     * This is only possible if we're passing through non-alphabet characters.
+     *
+     * @param {Object[]} pos
+     * @param {number} pos[].start
+     * @param {number} pos[].end
+     * @param {Object[]} args
+     * @returns {Object[]} pos
+     */
+    highlight(pos, args) {
+        if (args[13] === false) {
+            return pos;
+        }
+    }
+
+    /**
+     * Highlight Enigma in reverse
+     *
+     * @param {Object[]} pos
+     * @param {number} pos[].start
+     * @param {number} pos[].end
+     * @param {Object[]} args
+     * @returns {Object[]} pos
+     */
+    highlightReverse(pos, args) {
+        if (args[13] === false) {
+            return pos;
+        }
+    }
+
+}
+
+export default EnigmaOp;

+ 1 - 0
tests/operations/index.mjs

@@ -82,6 +82,7 @@ import "./tests/TranslateDateTimeFormat";
 import "./tests/Magic";
 import "./tests/ParseTLV";
 import "./tests/Media";
+import "./tests/Enigma";
 
 // Cannot test operations that use the File type yet
 //import "./tests/SplitColourChannels";

+ 518 - 0
tests/operations/tests/Enigma.mjs

@@ -0,0 +1,518 @@
+/**
+ * Enigma machine tests.
+ * @author s2224834
+ * @copyright Crown Copyright 2019
+ * @license Apache-2.0
+ */
+import TestRegister from "../TestRegister";
+
+TestRegister.addTests([
+    {
+        // Simplest test: A single keypress in the default position on a basic
+        // Enigma.
+        name: "Enigma: basic wiring",
+        input: "G",
+        expectedOutput: "P",
+        recipeConfig: [
+            {
+                "op": "Enigma",
+                "args": [
+                    // Note: start on Z because it steps when the key is pressed
+                    "BDFHJLCPRTXVZNYEIWGAKMUSQO<W", "A", "Z", // III
+                    "AJDKSIRUXBLHWTMCQGZNPYFVOE<F", "A", "A", // II
+                    "EKMFLGDQVZNTOWYHXUSPAIBRCJ<R", "A", "A", // I
+                    "", "A",
+                    "AY BR CU DH EQ FS GL IP JX KN MO TZ VW", // B
+                    ""
+                ]
+            }
+        ]
+    },
+    {
+        // Rotor position test: single keypress, basic rotors, random start
+        // positions, no advancement of other rotors.
+        name: "Enigma: rotor position",
+        input: "A",
+        expectedOutput: "T",
+        recipeConfig: [
+            {
+                "op": "Enigma",
+                "args": [
+                    "BDFHJLCPRTXVZNYEIWGAKMUSQO<W", "A", "W",
+                    "AJDKSIRUXBLHWTMCQGZNPYFVOE<F", "A", "F",
+                    "EKMFLGDQVZNTOWYHXUSPAIBRCJ<R", "A", "N",
+                    "", "A",
+                    "AY BR CU DH EQ FS GL IP JX KN MO TZ VW",
+                    ""
+                ]
+            }
+        ]
+    },
+    {
+        // Rotor ring setting test: single keypress, basic rotors, one rotor
+        // ring offset by one, basic start position, no advancement of other
+        // rotors.
+        name: "Enigma: rotor ring setting",
+        input: "A",
+        expectedOutput: "O",
+        recipeConfig: [
+            {
+                "op": "Enigma",
+                "args": [
+                    "BDFHJLCPRTXVZNYEIWGAKMUSQO<W", "B", "Z",
+                    "AJDKSIRUXBLHWTMCQGZNPYFVOE<F", "A", "A",
+                    "EKMFLGDQVZNTOWYHXUSPAIBRCJ<R", "A", "A",
+                    "", "A",
+                    "AY BR CU DH EQ FS GL IP JX KN MO TZ VW",
+                    ""
+                ]
+            }
+        ]
+    },
+    {
+        // Rotor ring setting test: single keypress, basic rotors, random ring
+        // settings, basic start position, no advancement of other rotors.
+        name: "Enigma: rotor ring setting 2",
+        input: "A",
+        expectedOutput: "F",
+        recipeConfig: [
+            {
+                "op": "Enigma",
+                "args": [
+                    "BDFHJLCPRTXVZNYEIWGAKMUSQO<W", "W", "Z",
+                    "AJDKSIRUXBLHWTMCQGZNPYFVOE<F", "F", "A",
+                    "EKMFLGDQVZNTOWYHXUSPAIBRCJ<R", "N", "A",
+                    "", "A",
+                    "AY BR CU DH EQ FS GL IP JX KN MO TZ VW",
+                    ""
+                ]
+            }
+        ]
+    },
+    {
+        // Stepping: basic configuration, enough input to cause middle rotor to
+        // step
+        name: "Enigma: stepping",
+        input: "AAAAA AAAAA AAAAA AAAAA AAAAA A",
+        expectedOutput: "UBDZG OWCXL TKSBT MCDLP BMUQO F",
+        recipeConfig: [
+            {
+                "op": "Enigma",
+                "args": [
+                    "BDFHJLCPRTXVZNYEIWGAKMUSQO<W", "A", "Z",
+                    "AJDKSIRUXBLHWTMCQGZNPYFVOE<F", "A", "A",
+                    "EKMFLGDQVZNTOWYHXUSPAIBRCJ<R", "A", "A",
+                    "", "A",
+                    "AY BR CU DH EQ FS GL IP JX KN MO TZ VW",
+                    ""
+                ]
+            }
+        ]
+    },
+    {
+        // Ensure that we can decrypt an encrypted message.
+        name: "Enigma: reflectivity",
+        input: "AAAAA AAAAA AAAAA AAAAA AAAAA A",
+        expectedOutput: "AAAAA AAAAA AAAAA AAAAA AAAAA A",
+        recipeConfig: [
+            {
+                "op": "Enigma",
+                "args": [
+                    "BDFHJLCPRTXVZNYEIWGAKMUSQO<W", "A", "Z",
+                    "AJDKSIRUXBLHWTMCQGZNPYFVOE<F", "A", "A",
+                    "EKMFLGDQVZNTOWYHXUSPAIBRCJ<R", "A", "A",
+                    "", "A",
+                    "AY BR CU DH EQ FS GL IP JX KN MO TZ VW",
+                    ""
+                ]
+            },
+            {
+                "op": "Enigma",
+                "args": [
+                    "BDFHJLCPRTXVZNYEIWGAKMUSQO<W", "A", "Z",
+                    "AJDKSIRUXBLHWTMCQGZNPYFVOE<F", "A", "A",
+                    "EKMFLGDQVZNTOWYHXUSPAIBRCJ<R", "A", "A",
+                    "", "A",
+                    "AY BR CU DH EQ FS GL IP JX KN MO TZ VW",
+                    ""
+                ]
+            }
+        ]
+    },
+    {
+        // Stepping: with rotors set so we're about to trigger the double step
+        // anomaly
+        name: "Enigma: double step anomaly",
+        input: "AAAAA",
+        expectedOutput: "EQIBM",
+        recipeConfig: [
+            {
+                "op": "Enigma",
+                "args": [
+                    "BDFHJLCPRTXVZNYEIWGAKMUSQO<W", "A", "U",
+                    "AJDKSIRUXBLHWTMCQGZNPYFVOE<F", "A", "D",
+                    "EKMFLGDQVZNTOWYHXUSPAIBRCJ<R", "A", "A",
+                    "", "A",
+                    "AY BR CU DH EQ FS GL IP JX KN MO TZ VW",
+                    ""
+                ]
+            }
+        ]
+    },
+    {
+        // Stepping: with rotors set so we're about to trigger the double step
+        // anomaly
+        name: "Enigma: double step anomaly 2",
+        input: "AAAA",
+        expectedOutput: "BRNC",
+        recipeConfig: [
+            {
+                "op": "Enigma",
+                "args": [
+                    "BDFHJLCPRTXVZNYEIWGAKMUSQO<W", "A", "U",
+                    "AJDKSIRUXBLHWTMCQGZNPYFVOE<F", "A", "E",
+                    "EKMFLGDQVZNTOWYHXUSPAIBRCJ<R", "A", "A",
+                    "", "A",
+                    "AY BR CU DH EQ FS GL IP JX KN MO TZ VW",
+                    ""
+                ]
+            }
+        ]
+    },
+    {
+        // Stepping: with rotors set so we're about to trigger the double step
+        // anomaly
+        name: "Enigma: double step anomaly 3",
+        input: "AAAAA AAA",
+        expectedOutput: "ZEEQI BMG",
+        recipeConfig: [
+            {
+                "op": "Enigma",
+                "args": [
+                    "BDFHJLCPRTXVZNYEIWGAKMUSQO<W", "A", "S",
+                    "AJDKSIRUXBLHWTMCQGZNPYFVOE<F", "A", "D",
+                    "EKMFLGDQVZNTOWYHXUSPAIBRCJ<R", "A", "A",
+                    "", "A",
+                    "AY BR CU DH EQ FS GL IP JX KN MO TZ VW",
+                    ""
+                ]
+            }
+        ]
+    },
+    {
+        // Stepping: with a ring setting
+        name: "Enigma: ring setting stepping",
+        input: "AAAAA AAAAA AAAAA AAAAA AAAAA A",
+        expectedOutput: "PBMFE BOUBD ZGOWC XLTKS BTXSH I",
+        recipeConfig: [
+            {
+                "op": "Enigma",
+                "args": [
+                    "BDFHJLCPRTXVZNYEIWGAKMUSQO<W", "H", "Z",
+                    "AJDKSIRUXBLHWTMCQGZNPYFVOE<F", "A", "A",
+                    "EKMFLGDQVZNTOWYHXUSPAIBRCJ<R", "A", "A",
+                    "", "A",
+                    "AY BR CU DH EQ FS GL IP JX KN MO TZ VW",
+                    ""
+                ]
+            }
+        ]
+    },
+    {
+        // Stepping: with a ring setting and double step
+        name: "Enigma: ring setting double step",
+        input: "AAAAA AAAAA AAAAA AAAAA AAAAA A",
+        expectedOutput: "TEVFK UTIIW EDWVI JPMVP GDEZS P",
+        recipeConfig: [
+            {
+                "op": "Enigma",
+                "args": [
+                    "BDFHJLCPRTXVZNYEIWGAKMUSQO<W", "H", "F",
+                    "AJDKSIRUXBLHWTMCQGZNPYFVOE<F", "C", "D",
+                    "EKMFLGDQVZNTOWYHXUSPAIBRCJ<R", "Q", "A",
+                    "", "A",
+                    "AY BR CU DH EQ FS GL IP JX KN MO TZ VW",
+                    ""
+                ]
+            }
+        ]
+    },
+    {
+        // Four-rotor Enigma, random settings, no plugboard
+        name: "Enigma: four rotor",
+        input: "AAAAA AAAAA AAAAA AAAAA AAAAA A",
+        expectedOutput: "GZXGX QUSUW JPWVI GVBTU DQZNZ J",
+        recipeConfig: [
+            {
+                "op": "Enigma",
+                "args": [
+                    "BDFHJLCPRTXVZNYEIWGAKMUSQO<W", "D", "Q",
+                    "AJDKSIRUXBLHWTMCQGZNPYFVOE<F", "P", "F",
+                    "EKMFLGDQVZNTOWYHXUSPAIBRCJ<R", "O", "E",
+                    "LEYJVCNIXWPBQMDRTAKZGFUHOS", "X", // Beta
+                    "AE BN CK DQ FU GY HW IJ LO MP RX SZ TV", // B thin
+                    ""
+                ]
+            }
+        ]
+    },
+    {
+        // Four-rotor Enigma, different wheel set, no plugboard
+        name: "Enigma: four rotor 2",
+        input: "AAAAA AAAAA AAAAA AAAAA AAAAA A",
+        expectedOutput: "HZJLP IKWBZ XNCWF FIHWL EROOZ C",
+        recipeConfig: [
+            {
+                "op": "Enigma",
+                "args": [
+                    "ESOVPZJAYQUIRHXLNFTGKDCMWB<K", "W", "U", // IV
+                    "VZBRGITYUPSDNHLXAWMJQOFECK<A", "M", "G", // V
+                    "JPGVOUMFYQBENHZRDKASXLICTW<AN", "A", "J", // VI
+                    "FSOKANUERHMBTIYCWLQPZXVGJD", "L", // Gamma
+                    "AR BD CO EJ FN GT HK IV LM PW QZ SX UY", // C thin
+                    ""
+                ]
+            }
+        ]
+    },
+    {
+        // Four-rotor Enigma, different wheel set, random plugboard
+        name: "Enigma: plugboard",
+        input: "AAAAA AAAAA AAAAA AAAAA AAAAA A",
+        expectedOutput: "GHLIM OJIUW DKLWM JGNJK DYJVD K",
+        recipeConfig: [
+            {
+                "op": "Enigma",
+                "args": [
+                    "FKQHTLXOCBJSPDZRAMEWNIUYGV<AN", "U", "Z", // VIII
+                    "ESOVPZJAYQUIRHXLNFTGKDCMWB<K", "O", "O", // IV
+                    "NZJHGRCXMYSWBOUFAIVLPEKQDT<AN", "I", "V", // VII
+                    "FSOKANUERHMBTIYCWLQPZXVGJD", "I", // Gamma
+                    "AE BN CK DQ FU GY HW IJ LO MP RX SZ TV", // B thin
+                    "WN MJ LX YB FP QD US IH CE GR"
+                ]
+            }
+        ]
+    },
+    {
+        // Decryption test on above input
+        name: "Enigma: decryption",
+        input: "GHLIM OJIUW DKLWM JGNJK DYJVD K",
+        expectedOutput: "AAAAA AAAAA AAAAA AAAAA AAAAA A",
+        recipeConfig: [
+            {
+                "op": "Enigma",
+                "args": [
+                    "FKQHTLXOCBJSPDZRAMEWNIUYGV<AN", "U", "Z", // VIII
+                    "ESOVPZJAYQUIRHXLNFTGKDCMWB<K", "O", "O", // IV
+                    "NZJHGRCXMYSWBOUFAIVLPEKQDT<AN", "I", "V", // VII
+                    "FSOKANUERHMBTIYCWLQPZXVGJD", "I", // Gamma
+                    "AE BN CK DQ FU GY HW IJ LO MP RX SZ TV", // B thin
+                    "WN MJ LX YB FP QD US IH CE GR"
+                ]
+            }
+        ]
+    },
+    {
+        // Non-alphabet characters drop test
+        name: "Enigma: non-alphabet drop",
+        input: "Hello, world. This is a test.",
+        expectedOutput: "ILBDA AMTAZ MORNZ DDIOT U",
+        recipeConfig: [
+            {
+                "op": "Enigma",
+                "args": [
+                    "BDFHJLCPRTXVZNYEIWGAKMUSQO<W", "A", "A", // III
+                    "AJDKSIRUXBLHWTMCQGZNPYFVOE<F", "A", "A", // II
+                    "EKMFLGDQVZNTOWYHXUSPAIBRCJ<R", "A", "A", // I
+                    "", "A",
+                    "AY BR CU DH EQ FS GL IP JX KN MO TZ VW", // B
+                    "", true
+                ]
+            }
+        ]
+    },
+    {
+        // Non-alphabet characters passthrough test
+        name: "Enigma: non-alphabet passthrough",
+        input: "Hello, world. This is a test.",
+        expectedOutput: "ILBDA, AMTAZ. MORN ZD D IOTU.",
+        recipeConfig: [
+            {
+                "op": "Enigma",
+                "args": [
+                    "BDFHJLCPRTXVZNYEIWGAKMUSQO<W", "A", "A", // III
+                    "AJDKSIRUXBLHWTMCQGZNPYFVOE<F", "A", "A", // II
+                    "EKMFLGDQVZNTOWYHXUSPAIBRCJ<R", "A", "A", // I
+                    "", "A",
+                    "AY BR CU DH EQ FS GL IP JX KN MO TZ VW", // B
+                    "", false
+                ]
+            }
+        ]
+    },
+    {
+        name: "Enigma: rotor validation 1",
+        input: "Hello, world. This is a test.",
+        expectedOutput: "Rotor wiring must be 26 unique uppercase letters",
+        recipeConfig: [
+            {
+                "op": "Enigma",
+                "args": [
+                    "BDFHJLCPRTXVZNYEIWGAKMUSQ", "A", "A", // III
+                    "AJDKSIRUXBLHWTMCQGZNPYFVOE<F", "A", "A", // II
+                    "EKMFLGDQVZNTOWYHXUSPAIBRCJ<R", "A", "A", // I
+                    "", "A",
+                    "AY BR CU DH EQ FS GL IP JX KN MO TZ VW", // B
+                    ""
+                ]
+            }
+        ]
+    },
+    {
+        name: "Enigma: rotor validation 2",
+        input: "Hello, world. This is a test.",
+        expectedOutput: "Rotor wiring must be 26 unique uppercase letters",
+        recipeConfig: [
+            {
+                "op": "Enigma",
+                "args": [
+                    "BDFHJLCPRTXVZNYEIWGAKMUSQo", "A", "A", // III
+                    "AJDKSIRUXBLHWTMCQGZNPYFVOE<F", "A", "A", // II
+                    "EKMFLGDQVZNTOWYHXUSPAIBRCJ<R", "A", "A", // I
+                    "", "A",
+                    "AY BR CU DH EQ FS GL IP JX KN MO TZ VW", // B
+                    ""
+                ]
+            }
+        ]
+    },
+    {
+        name: "Enigma: rotor validation 3",
+        input: "Hello, world. This is a test.",
+        expectedOutput: "Rotor wiring must have each letter exactly once",
+        recipeConfig: [
+            {
+                "op": "Enigma",
+                "args": [
+                    "BDFHJLCPRTXVZNYEIWGAKMUSQA", "A", "A", // III
+                    "AJDKSIRUXBLHWTMCQGZNPYFVOE<F", "A", "A", // II
+                    "EKMFLGDQVZNTOWYHXUSPAIBRCJ<R", "A", "A", // I
+                    "", "A",
+                    "AY BR CU DH EQ FS GL IP JX KN MO TZ VW", // B
+                    ""
+                ]
+            }
+        ]
+    },
+    {
+        name: "Enigma: rotor validation 4",
+        input: "Hello, world. This is a test.",
+        expectedOutput: "Rotor steps must be unique",
+        recipeConfig: [
+            {
+                "op": "Enigma",
+                "args": [
+                    "BDFHJLCPRTXVZNYEIWGAKMUSQO<RR", "A", "A", // III
+                    "AJDKSIRUXBLHWTMCQGZNPYFVOE<F", "A", "A", // II
+                    "EKMFLGDQVZNTOWYHXUSPAIBRCJ<R", "A", "A", // I
+                    "", "A",
+                    "AY BR CU DH EQ FS GL IP JX KN MO TZ VW", // B
+                    ""
+                ]
+            }
+        ]
+    },
+    {
+        name: "Enigma: rotor validation 5",
+        input: "Hello, world. This is a test.",
+        expectedOutput: "Rotor steps must be 0-26 unique uppercase letters",
+        recipeConfig: [
+            {
+                "op": "Enigma",
+                "args": [
+                    "BDFHJLCPRTXVZNYEIWGAKMUSQO<a", "A", "A", // III
+                    "AJDKSIRUXBLHWTMCQGZNPYFVOE<F", "A", "A", // II
+                    "EKMFLGDQVZNTOWYHXUSPAIBRCJ<R", "A", "A", // I
+                    "", "A",
+                    "AY BR CU DH EQ FS GL IP JX KN MO TZ VW", // B
+                    ""
+                ]
+            }
+        ]
+    },
+    // The ring setting and positions are dropdowns in the interface so not
+    // gonna bother testing them
+    {
+        name: "Enigma: reflector validation 1",
+        input: "Hello, world. This is a test.",
+        expectedOutput: "Reflector must have exactly 13 pairs covering every letter",
+        recipeConfig: [
+            {
+                "op": "Enigma",
+                "args": [
+                    "BDFHJLCPRTXVZNYEIWGAKMUSQO<W", "A", "A", // III
+                    "AJDKSIRUXBLHWTMCQGZNPYFVOE<F", "A", "A", // II
+                    "EKMFLGDQVZNTOWYHXUSPAIBRCJ<R", "A", "A", // I
+                    "", "A",
+                    "AY BR CU DH EQ FS GL IP JX KN MO", // B
+                    ""
+                ]
+            }
+        ]
+    },
+    {
+        name: "Enigma: reflector validation 2",
+        input: "Hello, world. This is a test.",
+        expectedOutput: "Reflector: cannot connect A to itself",
+        recipeConfig: [
+            {
+                "op": "Enigma",
+                "args": [
+                    "BDFHJLCPRTXVZNYEIWGAKMUSQO<W", "A", "A", // III
+                    "AJDKSIRUXBLHWTMCQGZNPYFVOE<F", "A", "A", // II
+                    "EKMFLGDQVZNTOWYHXUSPAIBRCJ<R", "A", "A", // I
+                    "", "A",
+                    "AA BR CU DH EQ FS GL IP JX KN MO TZ", // B
+                    ""
+                ]
+            }
+        ]
+    },
+    {
+        name: "Enigma: reflector validation 3",
+        input: "Hello, world. This is a test.",
+        expectedOutput: "Reflector connects A more than once",
+        recipeConfig: [
+            {
+                "op": "Enigma",
+                "args": [
+                    "BDFHJLCPRTXVZNYEIWGAKMUSQO<W", "A", "A", // III
+                    "AJDKSIRUXBLHWTMCQGZNPYFVOE<F", "A", "A", // II
+                    "EKMFLGDQVZNTOWYHXUSPAIBRCJ<R", "A", "A", // I
+                    "", "A",
+                    "AY AR CU DH EQ FS GL IP JX KN MO TZ", // B
+                    ""
+                ]
+            }
+        ]
+    },
+    {
+        name: "Enigma: reflector validation 4",
+        input: "Hello, world. This is a test.",
+        expectedOutput: "Reflector must be a whitespace-separated list of uppercase letter pairs",
+        recipeConfig: [
+            {
+                "op": "Enigma",
+                "args": [
+                    "BDFHJLCPRTXVZNYEIWGAKMUSQO<W", "A", "A", // III
+                    "AJDKSIRUXBLHWTMCQGZNPYFVOE<F", "A", "A", // II
+                    "EKMFLGDQVZNTOWYHXUSPAIBRCJ<R", "A", "A", // I
+                    "", "A",
+                    "AYBR CU DH EQ FS GL IP JX KN MO TZ", // B
+                    ""
+                ]
+            }
+        ]
+    },
+]);