From 088864fd9c45105e8e311e5f64adda751614652a Mon Sep 17 00:00:00 2001 From: s2224834 <46319860+s2224834@users.noreply.github.com> Date: Thu, 3 Jan 2019 16:36:56 +0000 Subject: [PATCH] Add Enigma operation --- .gitignore | 1 + src/core/config/Categories.json | 3 +- src/core/lib/Enigma.mjs | 349 ++++++++++++++++++++ src/core/operations/Enigma.mjs | 200 ++++++++++++ tests/operations/index.mjs | 1 + tests/operations/tests/Enigma.mjs | 518 ++++++++++++++++++++++++++++++ 6 files changed, 1071 insertions(+), 1 deletion(-) create mode 100644 src/core/lib/Enigma.mjs create mode 100644 src/core/operations/Enigma.mjs create mode 100644 tests/operations/tests/Enigma.mjs diff --git a/.gitignore b/.gitignore index 3ca816f6..edbcf679 100755 --- a/.gitignore +++ b/.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 diff --git a/src/core/config/Categories.json b/src/core/config/Categories.json index 686c9842..3e0d108e 100755 --- a/src/core/config/Categories.json +++ b/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" ] }, { diff --git a/src/core/lib/Enigma.mjs b/src/core/lib/Enigma.mjs new file mode 100644 index 00000000..845ce413 --- /dev/null +++ b/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= 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 { + 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"); + } + } +} diff --git a/src/core/operations/Enigma.mjs b/src/core/operations/Enigma.mjs new file mode 100644 index 00000000..d43d780d --- /dev/null +++ b/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.

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. AB CD EF 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 < then a list of stepping points.
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; diff --git a/tests/operations/index.mjs b/tests/operations/index.mjs index da9d41be..119f3ac4 100644 --- a/tests/operations/index.mjs +++ b/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"; diff --git a/tests/operations/tests/Enigma.mjs b/tests/operations/tests/Enigma.mjs new file mode 100644 index 00000000..a1acaf33 --- /dev/null +++ b/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