diff --git a/.gitignore b/.gitignore index 3ca816f6..b5aad5d0 100755 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ docs/* !docs/*.conf.json !docs/*.ico .vscode +.*.swp +.DS_Store src/core/config/modules/* src/core/config/OperationConfig.json src/core/operations/index.mjs diff --git a/package-lock.json b/package-lock.json index 04cd8f71..425b3f76 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5125,14 +5125,12 @@ "balanced-match": { "version": "1.0.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, "dev": true, - "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -5152,8 +5150,7 @@ "concat-map": { "version": "0.0.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "console-control-strings": { "version": "1.1.0", @@ -5301,7 +5298,6 @@ "version": "3.0.4", "bundled": true, "dev": true, - "optional": true, "requires": { "brace-expansion": "^1.1.7" } @@ -5730,7 +5726,7 @@ }, "get-stream": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", + "resolved": "http://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", "integrity": "sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ=", "dev": true }, @@ -6486,7 +6482,7 @@ }, "html-webpack-plugin": { "version": "3.2.0", - "resolved": "http://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-3.2.0.tgz", + "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-3.2.0.tgz", "integrity": "sha1-sBq71yOsqqeze2r0SS69oD2d03s=", "dev": true, "requires": { @@ -6611,7 +6607,7 @@ }, "http-proxy-middleware": { "version": "0.18.0", - "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-0.18.0.tgz", + "resolved": "http://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-0.18.0.tgz", "integrity": "sha512-Fs25KVMPAIIcgjMZkVHJoKg9VcXcC1C8yb9JUgeDvVXY0S/zgVIhMb+qVswDIgtJe2DfckMSY2d6TuTEutlk6Q==", "dev": true, "requires": { @@ -9530,7 +9526,7 @@ }, "parse-asn1": { "version": "5.1.1", - "resolved": "https://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.1.tgz", + "resolved": "http://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.1.tgz", "integrity": "sha512-KPx7flKXg775zZpnp9SxJlz00gTd4BmJ2yJufSc44gMCRrRQ7NSzAcSJQfifuOLgW6bEi+ftrALtsgALeB2Adw==", "dev": true, "requires": { @@ -12148,6 +12144,28 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=" }, + "svg-url-loader": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/svg-url-loader/-/svg-url-loader-2.3.2.tgz", + "integrity": "sha1-3YaybBn+O5FPBOoQ7zlZTq3gRGQ=", + "dev": true, + "requires": { + "file-loader": "1.1.11", + "loader-utils": "1.1.0" + }, + "dependencies": { + "file-loader": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/file-loader/-/file-loader-1.1.11.tgz", + "integrity": "sha512-TGR4HU7HUsGg6GCOPJnFk06RhWgEWFLAGWiT6rcD+GRC2keU3s9RGJ+b3Z6/U73jwwNb2gKLJ7YCrp+jvU4ALg==", + "dev": true, + "requires": { + "loader-utils": "^1.0.2", + "schema-utils": "^0.4.5" + } + } + } + }, "symbol-tree": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.2.tgz", diff --git a/package.json b/package.json index 69d8c19c..453c9d96 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "sass-loader": "^7.1.0", "sitemap": "^2.1.0", "style-loader": "^0.23.1", + "svg-url-loader": "^2.3.2", "url-loader": "^1.1.2", "web-resource-inliner": "^4.2.1", "webpack": "^4.28.3", diff --git a/src/core/Chef.mjs b/src/core/Chef.mjs index ed111b65..33b7acbe 100755 --- a/src/core/Chef.mjs +++ b/src/core/Chef.mjs @@ -89,23 +89,26 @@ class Chef { progress = err.progress; } - // Depending on the size of the output, we may send it back as a string or an ArrayBuffer. - // This can prevent unnecessary casting as an ArrayBuffer can be easily downloaded as a file. - // The threshold is specified in KiB. - const threshold = (options.ioDisplayThreshold || 1024) * 1024; - const returnType = this.dish.size > threshold ? Dish.ARRAY_BUFFER : Dish.STRING; - // Create a raw version of the dish, unpresented const rawDish = this.dish.clone(); // Present the raw result await recipe.present(this.dish); + // Depending on the size of the output, we may send it back as a string or an ArrayBuffer. + // This can prevent unnecessary casting as an ArrayBuffer can be easily downloaded as a file. + // The threshold is specified in KiB. + const threshold = (options.ioDisplayThreshold || 1024) * 1024; + const returnType = + this.dish.size > threshold ? + Dish.ARRAY_BUFFER : + this.dish.type === Dish.HTML ? + Dish.HTML : + Dish.STRING; + return { dish: rawDish, - result: this.dish.type === Dish.HTML ? - await this.dish.get(Dish.HTML, notUTF8) : - await this.dish.get(returnType, notUTF8), + result: await this.dish.get(returnType, notUTF8), type: Dish.enumLookup(this.dish.type), progress: progress, duration: new Date().getTime() - startTime, diff --git a/src/core/config/Categories.json b/src/core/config/Categories.json index 115e8436..2fe8e8f7 100755 --- a/src/core/config/Categories.json +++ b/src/core/config/Categories.json @@ -102,7 +102,11 @@ "JWT Decode", "Citrix CTX1 Encode", "Citrix CTX1 Decode", - "Pseudo-Random Number Generator" + "Pseudo-Random Number Generator", + "Enigma", + "Bombe", + "Multiple Bombe", + "Typex" ] }, { diff --git a/src/core/lib/Bombe.mjs b/src/core/lib/Bombe.mjs new file mode 100644 index 00000000..122edd40 --- /dev/null +++ b/src/core/lib/Bombe.mjs @@ -0,0 +1,756 @@ +/** + * Emulation of the Bombe machine. + * + * @author s2224834 + * @author The National Museum of Computing - Bombe Rebuild Project + * @copyright Crown Copyright 2019 + * @license Apache-2.0 + */ + +import OperationError from "../errors/OperationError"; +import Utils from "../Utils"; +import {Rotor, Plugboard, a2i, i2a} from "./Enigma"; + +/** + * Convenience/optimisation subclass of Rotor + * + * This allows creating multiple Rotors which share backing maps, to avoid repeatedly parsing the + * rotor spec strings and duplicating the maps in memory. + */ +class CopyRotor extends Rotor { + /** + * Return a copy of this Rotor. + * @returns {Object} + */ + copy() { + const clone = { + map: this.map, + revMap: this.revMap, + pos: this.pos, + step: this.step, + transform: this.transform, + revTransform: this.revTransform, + }; + return clone; + } +} + +/** + * Node in the menu graph + * + * A node represents a cipher/plaintext letter. + */ +class Node { + /** + * Node constructor. + * @param {number} letter - The plain/ciphertext letter this node represents (as a number). + */ + constructor(letter) { + this.letter = letter; + this.edges = new Set(); + this.visited = false; + } +} + +/** + * Edge in the menu graph + * + * An edge represents an Enigma machine transformation between two letters. + */ +class Edge { + /** + * Edge constructor - an Enigma machine mapping between letters + * @param {number} pos - The rotor position, relative to the beginning of the crib, at this edge + * @param {number} node1 - Letter at one end (as a number) + * @param {number} node2 - Letter at the other end + */ + constructor(pos, node1, node2) { + this.pos = pos; + this.node1 = node1; + this.node2 = node2; + node1.edges.add(this); + node2.edges.add(this); + this.visited = false; + } + + /** + * Given the node at one end of this edge, return the other end. + * @param node {number} - The node we have + * @returns {number} + */ + getOther(node) { + return this.node1 === node ? this.node2 : this.node1; + } +} + +/** + * As all the Bombe's rotors move in step, at any given point the vast majority of the scramblers + * in the machine share the majority of their state, which is hosted in this class. + */ +class SharedScrambler { + /** + * SharedScrambler constructor. + * @param {Object[]} rotors - List of rotors in the shared state _only_. + * @param {Object} reflector - The reflector in use. + */ + constructor(rotors, reflector) { + this.lowerCache = new Array(26); + this.higherCache = new Array(26); + for (let i=0; i<26; i++) { + this.higherCache[i] = new Array(26); + } + this.changeRotors(rotors, reflector); + } + + /** + * Replace the rotors and reflector in this SharedScrambler. + * This takes care of flushing caches as well. + * @param {Object[]} rotors - List of rotors in the shared state _only_. + * @param {Object} reflector - The reflector in use. + */ + changeRotors(rotors, reflector) { + this.reflector = reflector; + this.rotors = rotors; + this.rotorsRev = [].concat(rotors).reverse(); + this.cacheGen(); + } + + /** + * Step the rotors forward. + * @param {number} n - How many rotors to step. This includes the rotors which are not part of + * the shared state, so should be 2 or more. + */ + step(n) { + for (let i=0; i 25) { + // A crib longer than this will definitely cause the middle rotor to step somewhere + // A shorter crib is preferable to reduce this chance, of course + throw new OperationError("Crib is too long"); + } + for (let i=0; i nConnections) { + mostConnected = oMostConnected; + nConnections = oNConnections; + } + } + return [loops, nNodes, mostConnected, nConnections, edges]; + } + + /** + * Build a menu from the ciphertext and crib. + * A menu is just a graph where letters in either the ciphertext or crib (Enigma is symmetric, + * so there's no difference mathematically) are nodes and states of the Enigma machine itself + * are the edges. + * Additionally, we want a single connected graph, and of the subgraphs available, we want the + * one with the most loops (since these generate feedback cycles which efficiently close off + * disallowed states). + * Finally, we want to identify the most connected node in that graph (as it's the best choice + * of measurement point). + * @returns [Object, Object[]] - the most connected node, and the list of edges in the subgraph + */ + makeMenu() { + // First, we make a graph of all of the mappings given by the crib + // Make all nodes first + const nodes = new Map(); + for (const c of this.ciphertext + this.crib) { + if (!nodes.has(c)) { + const node = new Node(c); + nodes.set(c, node); + } + } + // Then all edges + for (let i=0; i { + let result = b[0] - a[0]; + if (result === 0) { + result = b[1] - a[1]; + } + return result; + }); + this.nLoops = graphs[0][0]; + return [graphs[0][2], graphs[0][4]]; + } + + /** + * Bombe electrical simulation. Energise a wire. For all connected wires (both via the diagonal + * board and via the scramblers), energise them too, recursively. + * @param {number} i - Bombe wire bundle + * @param {number} j - Bombe stecker hypothesis wire within bundle + */ + energise(i, j) { + const idx = 26*i + j; + if (this.wires[idx]) { + return; + } + this.wires[idx] = true; + // Welchman's diagonal board: if A steckers to B, that implies B steckers to A. Handle + // both. + const idxPair = 26*j + i; + this.wires[idxPair] = true; + if (i === this.testRegister || j === this.testRegister) { + this.energiseCount++; + if (this.energiseCount === 26) { + // no point continuing, bail out + return; + } + } + + for (let k=0; k 1) { + // This is an invalid stop. + return ""; + } else if (count === 0) { + // No information about steckering from this wire + continue; + } + results.add(this.formatPair(i, other)); + } + return [...results].join(" "); + } + + /** + * Check to see if the Bombe has stopped. If so, process the stop. + * @returns {(undefined|string[3])} - Undefined for no stop, or [rotor settings, plugboard settings, decryption preview] + */ + checkStop() { + // Count the energised outputs + const count = this.energiseCount; + if (count === 26) { + return undefined; + } + // If it's not all of them, we have a stop + let steckerPair; + // The Bombe tells us one stecker pair as well. The input wire and test register we + // started with are hypothesised to be a stecker pair. + if (count === 25) { + // Our steckering hypothesis is wrong. Correct value is the un-energised wire. + for (let j=0; j<26; j++) { + if (!this.wires[26*this.testRegister + j]) { + steckerPair = j; + break; + } + } + } else if (count === 1) { + // This means our hypothesis for the steckering is correct. + steckerPair = this.testInput[1]; + } else { + // This was known as a "boxing stop" - we have a stop but not a single hypothesis. + // If this happens a lot it implies the menu isn't good enough. + // If we have the checking machine enabled, we're going to just check each wire in + // turn. If we get 0 or 1 hit, great. + // If we get multiple hits, or the checking machine is off, the user will just have to + // deal with it. + if (!this.check) { + // We can't draw any conclusions about the steckering (one could maybe suggest + // options in some cases, but too hard to present clearly). + return [this.indicator.getPos(), "??", this.tryDecrypt("")]; + } + let stecker = undefined; + for (let i = 0; i < 26; i++) { + const newStecker = this.checkingMachine(i); + if (newStecker !== "") { + if (stecker !== undefined) { + // Multiple hypotheses can't be ruled out. + return [this.indicator.getPos(), "??", this.tryDecrypt("")]; + } + stecker = newStecker; + } + } + if (stecker === undefined) { + // Checking machine ruled all possibilities out. + return undefined; + } + // If we got here, there was just one possibility allowed by the checking machine. Success. + return [this.indicator.getPos(), stecker, this.tryDecrypt(stecker)]; + } + let stecker; + if (this.check) { + stecker = this.checkingMachine(steckerPair); + if (stecker === "") { + // Invalid stop - don't count it, don't return it + return undefined; + } + } else { + stecker = `${i2a(this.testRegister)}${i2a(steckerPair)}`; + } + const testDecrypt = this.tryDecrypt(stecker); + return [this.indicator.getPos(), stecker, testDecrypt]; + } + + /** + * Having set up the Bombe, do the actual attack run. This tries every possible rotor setting + * and attempts to logically invalidate them. If it can't, it's added to the list of candidate + * solutions. + * @returns {string[][3]} - list of 3-tuples of candidate rotor setting, plugboard settings, and decryption preview + */ + run() { + let stops = 0; + const result = []; + // For each possible rotor setting + const nChecks = Math.pow(26, this.baseRotors.length); + for (let i=1; i<=nChecks; i++) { + // Benchmarking suggests this is faster than using .fill() + for (let i=0; i 1) { + this.sharedScrambler.step(n); + } + for (const scrambler of this.allScramblers) { + scrambler.step(); + } + // Send status messages at what seems to be a reasonably sensible frequency + // (note this won't be triggered on 3-rotor runs - they run fast enough it doesn't seem necessary) + if (n > 3) { + this.update(this.nLoops, stops, i/nChecks); + } + } + return result; + } +} diff --git a/src/core/lib/Enigma.mjs b/src/core/lib/Enigma.mjs new file mode 100644 index 00000000..39193f69 --- /dev/null +++ b/src/core/lib/Enigma.mjs @@ -0,0 +1,369 @@ +/** + * 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 = new Array(26); + this.revMap = new Array(26); + const uniq = {}; + 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) { + // self-stecker + return; + } + 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. + * + * Includes a couple of optimisations on that basis. + */ +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"); + } + const optMap = new Array(26); + for (const x of Object.keys(this.map)) { + optMap[x] = this.map[x]; + } + this.map = optMap; + } + + /** + * Transform a character through this object. + * + * @param {number} c - The character. + * @returns {number} + */ + transform(c) { + return this.map[c]; + } +} + +/** + * 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/lib/Typex.mjs b/src/core/lib/Typex.mjs new file mode 100644 index 00000000..484a1e6b --- /dev/null +++ b/src/core/lib/Typex.mjs @@ -0,0 +1,227 @@ +/** + * Emulation of the Typex machine. + * + * @author s2224834 + * @author The National Museum of Computing - Bombe Rebuild Project + * @copyright Crown Copyright 2019 + * @license Apache-2.0 + */ +import OperationError from "../errors/OperationError"; +import * as Enigma from "../lib/Enigma"; +import Utils from "../Utils"; + +/** + * A set of example Typex rotors. No Typex rotor wirings are publicly available, so these are + * randomised. + */ +export const ROTORS = [ + {name: "Example 1", value: "MCYLPQUVRXGSAOWNBJEZDTFKHI { + return reversed[Enigma.a2i(x)]; + }); + try { + super(wiring, "", "A", "A"); + } catch (err) { + throw new OperationError(err.message.replace("Rotor", "Plugboard")); + } + } + + /** + * 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); + } +} diff --git a/src/core/operations/Bombe.mjs b/src/core/operations/Bombe.mjs new file mode 100644 index 00000000..c2ea82bf --- /dev/null +++ b/src/core/operations/Bombe.mjs @@ -0,0 +1,175 @@ +/** + * Emulation of the Bombe machine. + * + * @author s2224834 + * @copyright Crown Copyright 2019 + * @license Apache-2.0 + */ + +import Operation from "../Operation"; +import OperationError from "../errors/OperationError"; +import {BombeMachine} from "../lib/Bombe"; +import {ROTORS, ROTORS_FOURTH, REFLECTORS, Reflector} from "../lib/Enigma"; + +/** + * Bombe operation + */ +class Bombe extends Operation { + /** + * Bombe constructor + */ + constructor() { + super(); + + this.name = "Bombe"; + this.module = "Default"; + this.description = "Emulation of the Bombe machine used at Bletchley Park to attack Enigma, based on work by Polish and British cryptanalysts.

To run this you need to have a 'crib', which is some known plaintext for a chunk of the target ciphertext, and know the rotors used. (See the 'Bombe (multiple runs)' operation if you don't know the rotors.) The machine will suggest possible configurations of the Enigma. Each suggestion has the rotor start positions (left to right) and known plugboard pairs.

Choosing a crib: First, note that Enigma cannot encrypt a letter to itself, which allows you to rule out some positions for possible cribs. Secondly, the Bombe does not simulate the Enigma's middle rotor stepping. The longer your crib, the more likely a step happened within it, which will prevent the attack working. However, other than that, longer cribs are generally better. The attack produces a 'menu' which maps ciphertext letters to plaintext, and the goal is to produce 'loops': for example, with ciphertext ABC and crib CAB, we have the mappings A<->C, B<->A, and C<->B, which produces a loop A-B-C-A. The more loops, the better the crib. The operation will output this: if your menu has too few loops or is too short, a large number of incorrect outputs will usually be produced. Try a different crib. If the menu seems good but the right answer isn't produced, your crib may be wrong, or you may have overlapped the middle rotor stepping - try a different crib.

Output is not sufficient to fully decrypt the data. You will have to recover the rest of the plugboard settings by inspection. And the ring position is not taken into account: this affects when the middle rotor steps. If your output is correct for a bit, and then goes wrong, adjust the ring and start position on the right-hand rotor together until the output improves. If necessary, repeat for the middle rotor.

By default this operation runs the checking machine, a manual process to verify the quality of Bombe stops, on each stop, discarding stops which fail. If you want to see how many times the hardware actually stops for a given input, disable the checking machine.

More detailed descriptions of the Enigma, Typex and Bombe operations can be found here."; + this.infoURL = "https://wikipedia.org/wiki/Bombe"; + this.inputType = "string"; + this.outputType = "JSON"; + this.presentType = "html"; + this.args = [ + { + name: "Model", + type: "argSelector", + value: [ + { + name: "3-rotor", + off: [1] + }, + { + name: "4-rotor", + on: [1] + } + ] + }, + { + name: "Left-most (4th) rotor", + type: "editableOption", + value: ROTORS_FOURTH, + defaultIndex: 0 + }, + { + name: "Left-hand rotor", + type: "editableOption", + value: ROTORS, + defaultIndex: 0 + }, + { + name: "Middle rotor", + type: "editableOption", + value: ROTORS, + defaultIndex: 1 + }, + { + name: "Right-hand rotor", + type: "editableOption", + value: ROTORS, + defaultIndex: 2 + }, + { + name: "Reflector", + type: "editableOption", + value: REFLECTORS + }, + { + name: "Crib", + type: "string", + value: "" + }, + { + name: "Crib offset", + type: "number", + value: 0 + }, + { + name: "Use checking machine", + type: "boolean", + value: true + } + ]; + } + + /** + * Format and send a status update message. + * @param {number} nLoops - Number of loops in the menu + * @param {number} nStops - How many stops so far + * @param {number} progress - Progress (as a float in the range 0..1) + */ + updateStatus(nLoops, nStops, progress) { + const msg = `Bombe run with ${nLoops} loop${nLoops === 1 ? "" : "s"} in menu (2+ desirable): ${nStops} stops, ${Math.floor(100 * progress)}% done`; + self.sendStatusMessage(msg); + } + + /** + * @param {string} input + * @param {Object[]} args + * @returns {string} + */ + run(input, args) { + const model = args[0]; + const reflectorstr = args[5]; + let crib = args[6]; + const offset = args[7]; + const check = args[8]; + const rotors = []; + for (let i=0; i<4; i++) { + if (i === 0 && model === "3-rotor") { + // No fourth rotor + continue; + } + let rstr = args[i + 1]; + // The Bombe doesn't take stepping into account so we'll just ignore it here + if (rstr.includes("<")) { + rstr = rstr.split("<", 2)[0]; + } + rotors.push(rstr); + } + // Rotors are handled in reverse + rotors.reverse(); + if (crib.length === 0) { + throw new OperationError("Crib cannot be empty"); + } + if (offset < 0) { + throw new OperationError("Offset cannot be negative"); + } + // For symmetry with the Enigma op, for the input we'll just remove all invalid characters + input = input.replace(/[^A-Za-z]/g, "").toUpperCase(); + crib = crib.replace(/[^A-Za-z]/g, "").toUpperCase(); + const ciphertext = input.slice(offset); + const reflector = new Reflector(reflectorstr); + let update; + if (ENVIRONMENT_IS_WORKER()) { + update = this.updateStatus; + } else { + update = undefined; + } + const bombe = new BombeMachine(rotors, reflector, ciphertext, crib, check, update); + const result = bombe.run(); + return { + nLoops: bombe.nLoops, + result: result + }; + } + + + /** + * Displays the Bombe results in an HTML table + * + * @param {Object} output + * @param {number} output.nLoops + * @param {Array[]} output.result + * @returns {html} + */ + present(output) { + let html = `Bombe run on menu with ${output.nLoops} loop${output.nLoops === 1 ? "" : "s"} (2+ desirable). Note: Rotor positions are listed left to right and start at the beginning of the crib, and ignore stepping and the ring setting. Some plugboard settings are determined. A decryption preview starting at the beginning of the crib and ignoring stepping is also provided.\n\n`; + html += "\n"; + for (const [setting, stecker, decrypt] of output.result) { + html += `\n`; + } + html += "
Rotor stops Partial plugboard Decryption preview
${setting} ${stecker} ${decrypt}
"; + return html; + } +} + +export default Bombe; diff --git a/src/core/operations/Enigma.mjs b/src/core/operations/Enigma.mjs new file mode 100644 index 00000000..542e8281 --- /dev/null +++ b/src/core/operations/Enigma.mjs @@ -0,0 +1,214 @@ +/** + * 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 {ROTORS, LETTERS, ROTORS_FOURTH, REFLECTORS, Rotor, Reflector, Plugboard, EnigmaMachine} from "../lib/Enigma"; + +/** + * Enigma operation + */ +class Enigma extends Operation { + /** + * Enigma constructor + */ + constructor() { + super(); + + this.name = "Enigma"; + this.module = "Default"; + this.description = "Encipher/decipher with the WW2 Enigma machine.

Enigma was used by the German military, among others, around the WW2 era as a portable cipher machine to protect sensitive military, diplomatic and commercial communications.

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 only the thin reflectors and the beta or gamma rotor in the 4th slot).

More detailed descriptions of the Enigma, Typex and Bombe operations can be found here."; + this.infoURL = "https://wikipedia.org/wiki/Enigma_machine"; + this.inputType = "string"; + this.outputType = "string"; + this.args = [ + { + name: "Model", + type: "argSelector", + value: [ + { + name: "3-rotor", + off: [1, 2, 3] + }, + { + name: "4-rotor", + on: [1, 2, 3] + } + ] + }, + { + name: "Left-most (4th) rotor", + type: "editableOption", + value: ROTORS_FOURTH, + defaultIndex: 0 + }, + { + name: "Left-most rotor ring setting", + type: "option", + value: LETTERS + }, + { + name: "Left-most rotor initial value", + type: "option", + value: LETTERS + }, + { + name: "Left-hand rotor", + type: "editableOption", + value: ROTORS, + defaultIndex: 0 + }, + { + name: "Left-hand rotor ring setting", + type: "option", + value: LETTERS + }, + { + name: "Left-hand rotor initial value", + type: "option", + value: LETTERS + }, + { + name: "Middle rotor", + type: "editableOption", + value: ROTORS, + defaultIndex: 1 + }, + { + name: "Middle rotor ring setting", + type: "option", + value: LETTERS + }, + { + name: "Middle rotor initial value", + type: "option", + value: LETTERS + }, + { + name: "Right-hand rotor", + type: "editableOption", + value: ROTORS, + // Default config is the rotors I-III *left to right* + defaultIndex: 2 + }, + { + name: "Right-hand rotor ring setting", + type: "option", + value: LETTERS + }, + { + name: "Right-hand rotor initial value", + type: "option", + value: LETTERS + }, + { + name: "Reflector", + type: "editableOption", + value: 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 model = args[0]; + const reflectorstr = args[13]; + const plugboardstr = args[14]; + const removeOther = args[15]; + const rotors = []; + for (let i=0; i<4; i++) { + if (i === 0 && model === "3-rotor") { + // Skip the 4th rotor settings + continue; + } + const [rotorwiring, rotorsteps] = this.parseRotorStr(args[i*3 + 1], 1); + rotors.push(new Rotor(rotorwiring, rotorsteps, args[i*3 + 2], args[i*3 + 3])); + } + // Rotors are handled in reverse + rotors.reverse(); + const reflector = new Reflector(reflectorstr); + const plugboard = new Plugboard(plugboardstr); + if (removeOther) { + input = input.replace(/[^A-Za-z]/g, ""); + } + const enigma = new 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 Enigma; diff --git a/src/core/operations/MultipleBombe.mjs b/src/core/operations/MultipleBombe.mjs new file mode 100644 index 00000000..b6a48872 --- /dev/null +++ b/src/core/operations/MultipleBombe.mjs @@ -0,0 +1,305 @@ +/** + * Emulation of the Bombe machine. + * This version carries out multiple Bombe runs to handle unknown rotor configurations. + * + * @author s2224834 + * @copyright Crown Copyright 2019 + * @license Apache-2.0 + */ + +import Operation from "../Operation"; +import OperationError from "../errors/OperationError"; +import {BombeMachine} from "../lib/Bombe"; +import {ROTORS, ROTORS_FOURTH, REFLECTORS, Reflector} from "../lib/Enigma"; + +/** + * Convenience method for flattening the preset ROTORS object into a newline-separated string. + * @param {Object[]} - Preset rotors object + * @param {number} s - Start index + * @param {number} n - End index + * @returns {string} + */ +function rotorsFormat(rotors, s, n) { + const res = []; + for (const i of rotors.slice(s, n)) { + res.push(i.value); + } + return res.join("\n"); +} + +/** + * Combinatorics choose function + * @param {number} n + * @param {number} k + * @returns number + */ +function choose(n, k) { + let res = 1; + for (let i=1; i<=k; i++) { + res *= (n + 1 - i) / i; + } + return res; +} + +/** + * Bombe operation + */ +class MultipleBombe extends Operation { + /** + * Bombe constructor + */ + constructor() { + super(); + + this.name = "Multiple Bombe"; + this.module = "Default"; + this.description = "Emulation of the Bombe machine used to attack Enigma. This version carries out multiple Bombe runs to handle unknown rotor configurations.

You should test your menu on the single Bombe operation before running it here. See the description of the Bombe operation for instructions on choosing a crib.

More detailed descriptions of the Enigma, Typex and Bombe operations can be found here."; + this.infoURL = "https://wikipedia.org/wiki/Bombe"; + this.inputType = "string"; + this.outputType = "JSON"; + this.presentType = "html"; + this.args = [ + { + "name": "Standard Enigmas", + "type": "populateMultiOption", + "value": [ + { + name: "German Service Enigma (First - 3 rotor)", + value: [ + rotorsFormat(ROTORS, 0, 5), + "", + rotorsFormat(REFLECTORS, 0, 1) + ] + }, + { + name: "German Service Enigma (Second - 3 rotor)", + value: [ + rotorsFormat(ROTORS, 0, 8), + "", + rotorsFormat(REFLECTORS, 0, 2) + ] + }, + { + name: "German Service Enigma (Third - 4 rotor)", + value: [ + rotorsFormat(ROTORS, 0, 8), + rotorsFormat(ROTORS_FOURTH, 1, 2), + rotorsFormat(REFLECTORS, 2, 3) + ] + }, + { + name: "German Service Enigma (Fourth - 4 rotor)", + value: [ + rotorsFormat(ROTORS, 0, 8), + rotorsFormat(ROTORS_FOURTH, 1, 3), + rotorsFormat(REFLECTORS, 2, 4) + ] + }, + { + name: "User defined", + value: ["", "", ""] + }, + ], + "target": [1, 2, 3] + }, + { + name: "Main rotors", + type: "text", + value: "" + }, + { + name: "4th rotor", + type: "text", + value: "" + }, + { + name: "Reflectors", + type: "text", + value: "" + }, + { + name: "Crib", + type: "string", + value: "" + }, + { + name: "Crib offset", + type: "number", + value: 0 + }, + { + name: "Use checking machine", + type: "boolean", + value: true + } + ]; + } + + /** + * Format and send a status update message. + * @param {number} nLoops - Number of loops in the menu + * @param {number} nStops - How many stops so far + * @param {number} progress - Progress (as a float in the range 0..1) + */ + updateStatus(nLoops, nStops, progress, start) { + const elapsed = new Date().getTime() - start; + const remaining = (elapsed / progress) * (1 - progress) / 1000; + const hours = Math.floor(remaining / 3600); + const minutes = `0${Math.floor((remaining % 3600) / 60)}`.slice(-2); + const seconds = `0${Math.floor(remaining % 60)}`.slice(-2); + const msg = `Bombe run with ${nLoops} loop${nLoops === 1 ? "" : "s"} in menu (2+ desirable): ${nStops} stops, ${Math.floor(100 * progress)}% done, ${hours}:${minutes}:${seconds} remaining`; + self.sendStatusMessage(msg); + } + + /** + * Early rotor description string validation. + * Drops stepping information. + * @param {string} rstr - The rotor description string + * @returns {string} - Rotor description with stepping stripped, if any + */ + validateRotor(rstr) { + // The Bombe doesn't take stepping into account so we'll just ignore it here + if (rstr.includes("<")) { + rstr = rstr.split("<", 2)[0]; + } + // Duplicate the validation of the rotor strings here, otherwise you might get an error + // thrown halfway into a big Bombe run + if (!/^[A-Z]{26}$/.test(rstr)) { + throw new OperationError("Rotor wiring must be 26 unique uppercase letters"); + } + if (new Set(rstr).size !== 26) { + throw new OperationError("Rotor wiring must be 26 unique uppercase letters"); + } + return rstr; + } + + /** + * @param {string} input + * @param {Object[]} args + * @returns {string} + */ + run(input, args) { + const mainRotorsStr = args[1]; + const fourthRotorsStr = args[2]; + const reflectorsStr = args[3]; + let crib = args[4]; + const offset = args[5]; + const check = args[6]; + const rotors = []; + const fourthRotors = []; + const reflectors = []; + for (let rstr of mainRotorsStr.split("\n")) { + rstr = this.validateRotor(rstr); + rotors.push(rstr); + } + if (rotors.length < 3) { + throw new OperationError("A minimum of three rotors must be supplied"); + } + if (fourthRotorsStr !== "") { + for (let rstr of fourthRotorsStr.split("\n")) { + rstr = this.validateRotor(rstr); + fourthRotors.push(rstr); + } + } + if (fourthRotors.length === 0) { + fourthRotors.push(""); + } + for (const rstr of reflectorsStr.split("\n")) { + const reflector = new Reflector(rstr); + reflectors.push(reflector); + } + if (reflectors.length === 0) { + throw new OperationError("A minimum of one reflector must be supplied"); + } + if (crib.length === 0) { + throw new OperationError("Crib cannot be empty"); + } + if (offset < 0) { + throw new OperationError("Offset cannot be negative"); + } + // For symmetry with the Enigma op, for the input we'll just remove all invalid characters + input = input.replace(/[^A-Za-z]/g, "").toUpperCase(); + crib = crib.replace(/[^A-Za-z]/g, "").toUpperCase(); + const ciphertext = input.slice(offset); + let update; + if (ENVIRONMENT_IS_WORKER()) { + update = this.updateStatus; + } else { + update = undefined; + } + let bombe = undefined; + const output = {bombeRuns: []}; + // I could use a proper combinatorics algorithm here... but it would be more code to + // write one, and we don't seem to have one in our existing libraries, so massively nested + // for loop it is + const totalRuns = choose(rotors.length, 3) * 6 * fourthRotors.length * reflectors.length; + let nRuns = 0; + let nStops = 0; + const start = new Date().getTime(); + for (const rotor1 of rotors) { + for (const rotor2 of rotors) { + if (rotor2 === rotor1) { + continue; + } + for (const rotor3 of rotors) { + if (rotor3 === rotor2 || rotor3 === rotor1) { + continue; + } + for (const rotor4 of fourthRotors) { + for (const reflector of reflectors) { + nRuns++; + const runRotors = [rotor1, rotor2, rotor3]; + if (rotor4 !== "") { + runRotors.push(rotor4); + } + if (bombe === undefined) { + bombe = new BombeMachine(runRotors, reflector, ciphertext, crib, check); + output.nLoops = bombe.nLoops; + } else { + bombe.changeRotors(runRotors, reflector); + } + const result = bombe.run(); + nStops += result.length; + if (update !== undefined) { + update(bombe.nLoops, nStops, nRuns / totalRuns, start); + } + if (result.length > 0) { + output.bombeRuns.push({ + rotors: runRotors, + reflector: reflector.pairs, + result: result + }); + } + } + } + } + } + } + return output; + } + + + /** + * Displays the MultiBombe results in an HTML table + * + * @param {Object} output + * @param {number} output.nLoops + * @param {Array[]} output.result + * @returns {html} + */ + present(output) { + let html = `Bombe run on menu with ${output.nLoops} loop${output.nLoops === 1 ? "" : "s"} (2+ desirable). Note: Rotors and rotor positions are listed left to right, ignore stepping and the ring setting, and positions start at the beginning of the crib. Some plugboard settings are determined. A decryption preview starting at the beginning of the crib and ignoring stepping is also provided.\n`; + + for (const run of output.bombeRuns) { + html += `\nRotors: ${run.rotors.slice().reverse().join(", ")}\nReflector: ${run.reflector}\n`; + html += "\n"; + for (const [setting, stecker, decrypt] of run.result) { + html += `\n`; + } + html += "
Rotor stops Partial plugboard Decryption preview
${setting} ${stecker} ${decrypt}
\n"; + } + return html; + } +} + +export default MultipleBombe; diff --git a/src/core/operations/Typex.mjs b/src/core/operations/Typex.mjs new file mode 100644 index 00000000..70b5f6c3 --- /dev/null +++ b/src/core/operations/Typex.mjs @@ -0,0 +1,250 @@ +/** + * Emulation of the Typex machine. + * + * @author s2224834 + * @copyright Crown Copyright 2019 + * @license Apache-2.0 + */ + +import Operation from "../Operation"; +import OperationError from "../errors/OperationError"; +import {LETTERS, Reflector} from "../lib/Enigma"; +import {ROTORS, REFLECTORS, TypexMachine, Plugboard, Rotor} from "../lib/Typex"; + +/** + * Typex operation + */ +class Typex extends Operation { + /** + * Typex constructor + */ + constructor() { + super(); + + this.name = "Typex"; + this.module = "Default"; + this.description = "Encipher/decipher with the WW2 Typex machine.

Typex was originally built by the British Royal Air Force prior to WW2, and is based on the Enigma machine with some improvements made, including using five rotors with more stepping points and interchangeable wiring cores. It was used across the British and Commonewealth militaries. A number of later variants were produced; here we simulate a WW2 era Mark 22 Typex with plugboards for the reflector and input. Typex rotors were changed regularly and none are public: a random example set are provided.

To configure the reflector plugboard, enter a string of connected pairs of letters in the reflector box, e.g. AB CD EF connects A to B, C to D, and E to F (you'll need to connect every letter). There is also an input plugboard: unlike Enigma's plugboard, it's not restricted to pairs, so it's entered like a rotor (without stepping). 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.

More detailed descriptions of the Enigma, Typex and Bombe operations can be found here."; + this.infoURL = "https://wikipedia.org/wiki/Typex"; + this.inputType = "string"; + this.outputType = "string"; + this.args = [ + { + name: "1st (left-hand) rotor", + type: "editableOption", + value: ROTORS, + defaultIndex: 0 + }, + { + name: "1st rotor reversed", + type: "boolean", + value: false + }, + { + name: "1st rotor ring setting", + type: "option", + value: LETTERS + }, + { + name: "1st rotor initial value", + type: "option", + value: LETTERS + }, + { + name: "2nd rotor", + type: "editableOption", + value: ROTORS, + defaultIndex: 1 + }, + { + name: "2nd rotor reversed", + type: "boolean", + value: false + }, + { + name: "2nd rotor ring setting", + type: "option", + value: LETTERS + }, + { + name: "2nd rotor initial value", + type: "option", + value: LETTERS + }, + { + name: "3rd (middle) rotor", + type: "editableOption", + value: ROTORS, + defaultIndex: 2 + }, + { + name: "3rd rotor reversed", + type: "boolean", + value: false + }, + { + name: "3rd rotor ring setting", + type: "option", + value: LETTERS + }, + { + name: "3rd rotor initial value", + type: "option", + value: LETTERS + }, + { + name: "4th (static) rotor", + type: "editableOption", + value: ROTORS, + defaultIndex: 3 + }, + { + name: "4th rotor reversed", + type: "boolean", + value: false + }, + { + name: "4th rotor ring setting", + type: "option", + value: LETTERS + }, + { + name: "4th rotor initial value", + type: "option", + value: LETTERS + }, + { + name: "5th (right-hand, static) rotor", + type: "editableOption", + value: ROTORS, + defaultIndex: 4 + }, + { + name: "5th rotor reversed", + type: "boolean", + value: false + }, + { + name: "5th rotor ring setting", + type: "option", + value: LETTERS + }, + { + name: "5th rotor initial value", + type: "option", + value: LETTERS + }, + { + name: "Reflector", + type: "editableOption", + value: REFLECTORS + }, + { + name: "Plugboard", + type: "string", + value: "" + }, + { + name: "Typex keyboard emulation", + type: "option", + value: ["None", "Encrypt", "Decrypt"] + }, + { + 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 reflectorstr = args[20]; + const plugboardstr = args[21]; + const typexKeyboard = args[22]; + const removeOther = args[23]; + const rotors = []; + for (let i=0; i<5; i++) { + const [rotorwiring, rotorsteps] = this.parseRotorStr(args[i*4]); + rotors.push(new Rotor(rotorwiring, rotorsteps, args[i*4 + 1], args[i*4+2], args[i*4+3])); + } + // Rotors are handled in reverse + rotors.reverse(); + const reflector = new Reflector(reflectorstr); + let plugboardstrMod = plugboardstr; + if (plugboardstrMod === "") { + plugboardstrMod = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + } + const plugboard = new Plugboard(plugboardstrMod); + if (removeOther) { + if (typexKeyboard === "Encrypt") { + input = input.replace(/[^A-Za-z0-9 /%£()',.-]/g, ""); + } else { + input = input.replace(/[^A-Za-z]/g, ""); + } + } + const typex = new TypexMachine(rotors, reflector, plugboard, typexKeyboard); + let result = typex.crypt(input); + if (removeOther && typexKeyboard !== "Decrypt") { + // Five character cipher groups is traditional + result = result.replace(/([A-Z]{5})(?!$)/g, "$1 "); + } + return result; + } + + /** + * Highlight Typex + * 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[18] === false) { + return pos; + } + } + + /** + * Highlight Typex in reverse + * + * @param {Object[]} pos + * @param {number} pos[].start + * @param {number} pos[].end + * @param {Object[]} args + * @returns {Object[]} pos + */ + highlightReverse(pos, args) { + if (args[18] === false) { + return pos; + } + } + +} + +export default Typex; diff --git a/src/web/App.mjs b/src/web/App.mjs index 846803a1..ac97de4c 100755 --- a/src/web/App.mjs +++ b/src/web/App.mjs @@ -51,10 +51,12 @@ class App { */ setup() { document.dispatchEvent(this.manager.appstart); + this.initialiseSplitter(); this.loadLocalStorage(); this.populateOperationsList(); this.manager.setup(); + this.manager.output.saveBombe(); this.resetLayout(); this.setCompileMessage(); @@ -122,6 +124,9 @@ class App { // Reset attemptHighlight flag this.options.attemptHighlight = true; + // Remove all current indicators + this.manager.recipe.updateBreakpointIndicator(false); + this.manager.worker.bake( this.getInput(), // The user's input this.getRecipeConfig(), // The configuration of the recipe @@ -474,6 +479,7 @@ class App { const item = this.manager.recipe.addOperation(recipeConfig[i].op); // Populate arguments + log.debug(`Populating arguments for ${recipeConfig[i].op}`); const args = item.querySelectorAll(".arg"); for (let j = 0; j < args.length; j++) { if (recipeConfig[i].args[j] === undefined) continue; @@ -499,6 +505,8 @@ class App { item.querySelector(".breakpoint").click(); } + this.manager.recipe.triggerArgEvents(item); + this.progress = 0; } diff --git a/src/web/HTMLIngredient.mjs b/src/web/HTMLIngredient.mjs index 98d63be7..d8ac9511 100755 --- a/src/web/HTMLIngredient.mjs +++ b/src/web/HTMLIngredient.mjs @@ -45,7 +45,7 @@ class HTMLIngredient { */ toHtml() { let html = "", - i, m; + i, m, eventFn; switch (this.type) { case "string": @@ -151,10 +151,11 @@ class HTMLIngredient { `; break; case "populateOption": + case "populateMultiOption": html += `
${this.hint ? "" + this.hint + "" : ""}
`; - this.manager.addDynamicListener("#" + this.id, "change", this.populateOptionChange, this); + eventFn = this.type === "populateMultiOption" ? + this.populateMultiOptionChange : + this.populateOptionChange; + this.manager.addDynamicListener("#" + this.id, "change", eventFn, this); break; case "editableOption": html += `
@@ -243,6 +250,27 @@ class HTMLIngredient { ${this.hint ? "" + this.hint + "" : ""}
`; break; + case "argSelector": + html += `
+ + + ${this.hint ? "" + this.hint + "" : ""} +
`; + + this.manager.addDynamicListener(".arg-selector", "change", this.argSelectorChange, this); + break; default: break; } @@ -258,6 +286,9 @@ class HTMLIngredient { * @param {event} e */ populateOptionChange(e) { + e.preventDefault(); + e.stopPropagation(); + const el = e.target; const op = el.parentNode.parentNode; const target = op.querySelectorAll(".arg")[this.target]; @@ -270,6 +301,37 @@ class HTMLIngredient { } + /** + * Handler for populate multi option changes. + * Populates the relevant arguments with the specified values. + * + * @param {event} e + */ + populateMultiOptionChange(e) { + e.preventDefault(); + e.stopPropagation(); + + const el = e.target; + const op = el.parentNode.parentNode; + const args = op.querySelectorAll(".arg"); + const targets = this.target.map(i => args[i]); + const vals = JSON.parse(el.childNodes[el.selectedIndex].getAttribute("populate-value")); + const evt = new Event("change"); + + for (let i = 0; i < targets.length; i++) { + targets[i].value = vals[i]; + } + + // Fire change event after all targets have been assigned + this.manager.recipe.ingChange(); + + // Send change event for each target once all have been assigned, to update the label placement. + for (const target of targets) { + target.dispatchEvent(evt); + } + } + + /** * Handler for editable option clicks. * Populates the input box with the selected value. @@ -290,6 +352,33 @@ class HTMLIngredient { this.manager.recipe.ingChange(); } + + /** + * Handler for argument selector changes. + * Shows or hides the relevant arguments for this operation. + * + * @param {event} e + */ + argSelectorChange(e) { + e.preventDefault(); + e.stopPropagation(); + + const option = e.target.options[e.target.selectedIndex]; + const op = e.target.closest(".operation"); + const args = op.querySelectorAll(".ingredients .form-group"); + const turnon = JSON.parse(option.getAttribute("turnon")); + const turnoff = JSON.parse(option.getAttribute("turnoff")); + + args.forEach((arg, i) => { + if (turnon.includes(i)) { + arg.classList.remove("d-none"); + } + if (turnoff.includes(i)) { + arg.classList.add("d-none"); + } + }); + } + } export default HTMLIngredient; diff --git a/src/web/OutputWaiter.mjs b/src/web/OutputWaiter.mjs index 0a10b8b2..eaafeb8b 100755 --- a/src/web/OutputWaiter.mjs +++ b/src/web/OutputWaiter.mjs @@ -336,24 +336,54 @@ class OutputWaiter { /** - * Shows or hides the loading icon. + * Save bombe object then remove it from the DOM so that it does not cause performance issues. + */ + saveBombe() { + this.bombeEl = document.getElementById("bombe"); + this.bombeEl.parentNode.removeChild(this.bombeEl); + } + + + /** + * Shows or hides the output loading screen. + * The animated Bombe SVG, whilst quite aesthetically pleasing, is reasonably CPU + * intensive, so we remove it from the DOM when not in use. We only show it if the + * recipe is taking longer than 200ms. We add it to the DOM just before that so that + * it is ready to fade in without stuttering. * - * @param {boolean} value + * @param {boolean} value - true == show loader */ toggleLoader(value) { + clearTimeout(this.appendBombeTimeout); + clearTimeout(this.outputLoaderTimeout); + const outputLoader = document.getElementById("output-loader"), - outputElement = document.getElementById("output-text"); + outputElement = document.getElementById("output-text"), + animation = document.getElementById("output-loader-animation"); if (value) { this.manager.controls.hideStaleIndicator(); - this.bakingStatusTimeout = setTimeout(function() { + + // Start a timer to add the Bombe to the DOM just before we make it + // visible so that there is no stuttering + this.appendBombeTimeout = setTimeout(function() { + animation.appendChild(this.bombeEl); + }.bind(this), 150); + + // Show the loading screen + this.outputLoaderTimeout = setTimeout(function() { outputElement.disabled = true; outputLoader.style.visibility = "visible"; outputLoader.style.opacity = 1; this.manager.controls.toggleBakeButtonFunction(true); }.bind(this), 200); } else { - clearTimeout(this.bakingStatusTimeout); + // Remove the Bombe from the DOM to save resources + this.outputLoaderTimeout = setTimeout(function () { + try { + animation.removeChild(this.bombeEl); + } catch (err) {} + }.bind(this), 500); outputElement.disabled = false; outputLoader.style.opacity = 0; outputLoader.style.visibility = "hidden"; diff --git a/src/web/RecipeWaiter.mjs b/src/web/RecipeWaiter.mjs index 98457bb3..f2edd725 100755 --- a/src/web/RecipeWaiter.mjs +++ b/src/web/RecipeWaiter.mjs @@ -205,6 +205,7 @@ class RecipeWaiter { * @fires Manager#statechange */ ingChange(e) { + if (e && e.target && e.target.classList.contains("no-state-change")) return; window.dispatchEvent(this.manager.statechange); } @@ -340,10 +341,11 @@ class RecipeWaiter { /** * Moves or removes the breakpoint indicator in the recipe based on the position. * - * @param {number} position + * @param {number|boolean} position - If boolean, turn off all indicators */ updateBreakpointIndicator(position) { const operations = document.querySelectorAll("#rec-list li.operation"); + if (typeof position === "boolean") position = operations.length; for (let i = 0; i < operations.length; i++) { if (i === position) { operations[i].classList.add("break"); @@ -429,6 +431,23 @@ class RecipeWaiter { } + /** + * Triggers various change events for operation arguments that have just been initialised. + * + * @param {HTMLElement} op + */ + triggerArgEvents(op) { + // Trigger populateOption and argSelector events + const triggerableOptions = op.querySelectorAll(".populate-option, .arg-selector"); + const evt = new Event("change", {bubbles: true}); + if (triggerableOptions.length) { + for (const el of triggerableOptions) { + el.dispatchEvent(evt); + } + } + } + + /** * Handler for operationadd events. * @@ -438,6 +457,8 @@ class RecipeWaiter { */ opAdd(e) { log.debug(`'${e.target.querySelector(".op-title").textContent}' added to recipe`); + + this.triggerArgEvents(e.target); window.dispatchEvent(this.manager.statechange); } diff --git a/src/web/html/index.html b/src/web/html/index.html index 302355d9..119e8ada 100755 --- a/src/web/html/index.html +++ b/src/web/html/index.html @@ -81,7 +81,11 @@ if (!el.classList.contains("loading")) el.classList.add("loading"); // Causes CSS transition on first message el.innerHTML = msg; - } catch (err) {} // Ignore errors if DOM not yet ready + } catch (err) { + // This error was likely caused by the DOM not being ready yet, + // so we wait another second and then try again. + setTimeout(changeLoadingMsg, 1000); + } } changeLoadingMsg(); @@ -319,7 +323,9 @@
-
+
+ +
diff --git a/src/web/static/images/bombe.svg b/src/web/static/images/bombe.svg new file mode 100644 index 00000000..a970903a --- /dev/null +++ b/src/web/static/images/bombe.svg @@ -0,0 +1,261 @@ + + + + + + + + + + + + + + Z + Y + X + W + V + U + T + S + R + Q + P + O + N + M + L + K + J + I + H + G + F + E + D + C + B + A + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/web/stylesheets/layout/_io.css b/src/web/stylesheets/layout/_io.css index 83d37aaf..2578b57d 100755 --- a/src/web/stylesheets/layout/_io.css +++ b/src/web/stylesheets/layout/_io.css @@ -73,6 +73,30 @@ background-color: var(--primary-background-colour); visibility: hidden; opacity: 0; + display: flex; + justify-content: center; + align-items: center; + + transition: all 0.5s ease; +} + +#output-loader-animation { + display: block; + position: absolute; + width: 60%; + height: 60%; + top: 10%; + transition: all 0.5s ease; +} + +#output-loader .loading-msg { + opacity: 1; + font-family: var(--primary-font-family); + line-height: var(--primary-line-height); + color: var(--primary-font-colour); + left: unset; + top: 30%; + position: relative; transition: all 0.5s ease; } @@ -139,16 +163,6 @@ margin-bottom: 5px; } -#output-loader .loading-msg { - opacity: 1; - font-family: var(--primary-font-family); - line-height: var(--primary-line-height); - color: var(--primary-font-colour); - top: 50%; - - transition: all 0.5s ease; -} - #magic { opacity: 1; visibility: visibile; diff --git a/src/web/stylesheets/preloader.css b/src/web/stylesheets/preloader.css index 702d04a6..288ffc28 100755 --- a/src/web/stylesheets/preloader.css +++ b/src/web/stylesheets/preloader.css @@ -65,8 +65,8 @@ left: calc(50% - 200px); top: calc(50% + 50px); text-align: center; - margin-top: 50px; opacity: 0; + font-size: 18px; } .loading-msg.loading { diff --git a/tests/operations/index.mjs b/tests/operations/index.mjs index fb68ed9c..cff77217 100644 --- a/tests/operations/index.mjs +++ b/tests/operations/index.mjs @@ -83,6 +83,10 @@ import "./tests/Media"; import "./tests/ToFromInsensitiveRegex"; import "./tests/YARA.mjs"; import "./tests/ConvertCoordinateFormat"; +import "./tests/Enigma"; +import "./tests/Bombe"; +import "./tests/MultipleBombe"; +import "./tests/Typex"; // Cannot test operations that use the File type yet //import "./tests/SplitColourChannels"; diff --git a/tests/operations/tests/Bombe.mjs b/tests/operations/tests/Bombe.mjs new file mode 100644 index 00000000..b44e032c --- /dev/null +++ b/tests/operations/tests/Bombe.mjs @@ -0,0 +1,242 @@ +/** + * Bombe machine tests. + * @author s2224834 + * @copyright Crown Copyright 2019 + * @license Apache-2.0 + */ +import TestRegister from "../TestRegister"; + +TestRegister.addTests([ + { + // Plugboard for this test is BO LC KE GA + name: "Bombe: 3 rotor (self-stecker)", + input: "BBYFLTHHYIJQAYBBYS", + expectedMatch: /LGA<\/td> {2}SS<\/td> {2}VFISUSGTKSTMPSUNAK<\/td>/, + recipeConfig: [ + { + "op": "Bombe", + "args": [ + "3-rotor", + "", + "EKMFLGDQVZNTOWYHXUSPAIBRCJLGA<\/td> {2}AG<\/td> {2}QFIMUMAFKMQSKMYNGW<\/td>/, + recipeConfig: [ + { + "op": "Bombe", + "args": [ + "3-rotor", + "", + "EKMFLGDQVZNTOWYHXUSPAIBRCJLGA<\/td> {2}SS<\/td> {2}VFISUSGTKSTMPSUNAK<\/td>/, + recipeConfig: [ + { + "op": "Bombe", + "args": [ + "3-rotor", + "", + "EKMFLGDQVZNTOWYHXUSPAIBRCJLGA<\/td> {2}TT<\/td> {2}VFISUSGTKSTMPSUNAK<\/td>/, + recipeConfig: [ + { + "op": "Bombe", + "args": [ + "3-rotor", + "", + "EKMFLGDQVZNTOWYHXUSPAIBRCJLGA<\/td> {2}TT AG BO CL EK FF HH II JJ SS YY<\/td> {2}THISISATESTMESSAGE<\/td>/, + recipeConfig: [ + { + "op": "Bombe", + "args": [ + "3-rotor", + "", + "EKMFLGDQVZNTOWYHXUSPAIBRCJLHSC<\/td> {2}SS<\/td> {2}HHHSSSGQUUQPKSEKWK<\/td>/, + recipeConfig: [ + { + "op": "Bombe", + "args": [ + "4-rotor", + "LEYJVCNIXWPBQMDRTAKZGFUHOS", // Beta + "EKMFLGDQVZNTOWYHXUSPAIBRCJLGA<\/td> {2}SS<\/td> {2}VFISUSGTKSTMPSUNAK<\/td>/, + recipeConfig: [ + { + "op": "Multiple Bombe", + "args": [ + // I, II and III + "User defined", + "EKMFLGDQVZNTOWYHXUSPAIBRCJLHSC<\/td>SS<\/td>HHHSSSGQUUQPKSEKWK<\/td>/, + recipeConfig: [ + { + "op": "Multiple Bombe", + "args": [ + // I, II and III + "User defined", + "EKMFLGDQVZNTOWYHXUSPAIBRCJ