diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index 9fd6d68dc..06d47e3ff 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -15,7 +15,7 @@ jobs: - name: Set node version uses: actions/setup-node@v3 with: - node-version: '18.x' + node-version: '20.x' - name: Install run: | diff --git a/src/core/Utils.mjs b/src/core/Utils.mjs index a9c381d76..08eadbf17 100755 --- a/src/core/Utils.mjs +++ b/src/core/Utils.mjs @@ -1266,19 +1266,26 @@ class Utils { /** * Finds the modular inverse of two values. + * Uses the Extended Euclidean Algorithm. * - * @author Matt C [matt@artemisbot.uk] - * @param {number} x - * @param {number} y - * @returns {number} + * @author Barry B [profbbrown@gmail.com] + * @param {number} a + * @param {number} n + * @returns {number|null} */ - static modInv(x, y) { - x %= y; - for (let i = 1; i < y; i++) { - if ((x * i) % 26 === 1) { - return i; - } + static modInv(a, n) { + let t = 0, newT = 1, r = n, newR = a; + + while (newR !== 0) { + const q = Math.floor(r / newR); + [t, newT] = [newT, t - q * newT]; + [r, newR] = [newR, r - q * newR]; } + + if (r > 1) return null; + if (t < 0) t = t + n; + + return t; } diff --git a/src/core/lib/Ciphers.mjs b/src/core/lib/Ciphers.mjs index 6266a8e1d..9b9d4a7a9 100644 --- a/src/core/lib/Ciphers.mjs +++ b/src/core/lib/Ciphers.mjs @@ -4,6 +4,7 @@ * @author Matt C [matt@artemisbot.uk] * @author n1474335 [n1474335@gmail.com] * @author Evie H [evie@evie.sh] + * @author Barry B [profbbrown@gmail.com] * * @copyright Crown Copyright 2018 * @license Apache-2.0 @@ -17,6 +18,7 @@ import CryptoJS from "crypto-js"; /** * Affine Cipher Encode operation. * + * @deprecated Use affineEcrypt instead. * @author Matt C [matt@artemisbot.uk] * @param {string} input * @param {Object[]} args @@ -51,6 +53,166 @@ export function affineEncode(input, args) { return output; } +/** + * Generic affine encrypt/decrypt operation. + * Allows for an expanded alphabet. + * + * @author Barry B [profbbrown@gmail.com] + * @param {string} input + * @param {number} a + * @param {number} b + * @param {string} alphabet + * @param {function} affineFn + * @returns {string} + */ +export function affineApplication(input, a, b, alphabet, affineFn) { + if (alphabet === "") + throw new OperationError("The alphabet cannot be empty."); + + alphabet = Utils.expandAlphRange(alphabet); + let output = ""; + const modulus = alphabet.length; + + // If the alphabet contains letters of all the same case, + // the assumption will be to match case. + const hasLower = /[a-z]/.test(alphabet); + const hasUpper = /[A-Z]/.test(alphabet); + const matchCase = (hasLower && hasUpper) ? false : true; + + // If we are matching case, convert entire alphabet to lowercase. + // This will simplify the encryption. + if (matchCase) + alphabet = alphabet.map((c) => c.toLowerCase()); + + if (a === undefined || a === "" || isNaN(a)) a = 1; + if (b === undefined || b === "" || isNaN(b)) b = 0; + + if (!/^\+?(0|[1-9]\d*)$/.test(a) || !/^\+?(0|[1-9]\d*)$/.test(b)) { + throw new OperationError("The values of a and b can only be integers."); + } + + if (Utils.gcd(a, modulus) !== 1) { + throw new OperationError("The value of `a` (" + a + ") must be coprime to " + modulus + "."); + } + + // Apply affine function to each character in the input + for (let i = 0; i < input.length; i++) { + let outChar = ""; + + let inChar = input[i]; + if (matchCase && isUpperCase(inChar)) inChar = inChar.toLowerCase(); + + const inVal = alphabet.indexOf(inChar); + + if (inVal >= 0) { + outChar = alphabet[affineFn(inVal, a, b, modulus)]; + if (matchCase && isUpperCase(input[i])) outChar = outChar.toUpperCase(); + } else { + outChar += input[i]; + } + + output += outChar; + } + return output; +} + +/** + * Apply the affine encryption function to p. + * + * @author Barry B [profbbrown@gmail.com] + * @param {integer} p - Plaintext value + * @param {integer} a - Multiplier coefficient + * @param {integer} b - Addition coefficient + * @param {integer} m - Modulus + * @returns {integer} + */ +const encryptFn = function(p, a, b, m) { + return (a * p + b) % m; +}; + +/** + * Apply the affine decryption function to c. + * + * @author Barry B [profbbrown@gmail.com] + * @param {integer} c - Ciphertext value + * @param {integer} a - Multiplicative inverse coefficient + * @param {integer} b - Additive inverse coefficient + * @param {integer} m - Modulus + * @returns {integer} + */ +const decryptFn = function(c, a, b, m) { + return ((c + b) * a) % m; +}; + +/** + * Affine encrypt operation. + * Allows for an expanded alphabet. + * + * @author Barry B [profbbrown@gmail.com] + * @param {string} input + * @param {integer} a + * @param {integer} b + * @param {string} alphabet + * @returns {string} + */ +export function affineEncrypt(input, a, b, alphabet="a-z") { + return affineApplication(input, a, b, alphabet, encryptFn); +} + +/** + * Affine Cipher Decrypt operation using the coefficients that were used to encrypt. + * The modular inverses will be calculated. + * + * @author Barry B [profbbrown@gmail.com] + * @param {string} input + * @param {integer} a + * @param {integer} b + * @param {string} alphabet + * @returns {string} + */ +export function affineDecrypt(input, a, b, alphabet="a-z") { + // Because we are calculating the modulus and inverses here, we have to perform + // many of the same tests that the affineApplication function does. + // TODO: figure out a way to avoid doing the tests twice. + // Idea: make a checkInputs function. + // Idea: move the tests into the affineEncrypt and affineDecryptInverse functions + // so that affineApplication assumes valid inputs + if (alphabet === "") + throw new OperationError("The alphabet cannot be empty."); + + if (a === undefined || a === "" || isNaN(a)) a = 1; + if (b === undefined || b === "" || isNaN(b)) b = 0; + + if (!/^\+?(0|[1-9]\d*)$/.test(a) || !/^\+?(0|[1-9]\d*)$/.test(b)) { + throw new OperationError("The values of a and b can only be integers."); + } + + const m = Utils.expandAlphRange(alphabet).length; + if (Utils.gcd(a, m) !== 1) + throw new OperationError("The value of `a` (" + a + ") must be coprime to " + m + "."); + + const aInv = Utils.modInv(a, m); + const bInv = (m - b) % m; + if (aInv === null || aInv === undefined) + throw new OperationError("The value of `a` (" + a + ") must be coprime to " + m + "."); + else return affineApplication(input, aInv, bInv, alphabet, decryptFn); +} + +/** + * Affine Cipher Decrypt operation using modular inverse coefficients + * supplied by the user. + * + * @author Barry B [profbbrown@gmail.com] + * @param {string} input + * @param {number} a + * @param {number} b + * @param {string} alphabet + * @returns {string} + */ +export function affineDecryptInverse(input, a, b, alphabet="a-z") { + return affineApplication(input, a, b, alphabet, decryptFn); +} + /** * Generates a polybius square for the given keyword * @@ -86,3 +248,22 @@ export const format = { "UTF16BE": CryptoJS.enc.Utf16BE, "Latin1": CryptoJS.enc.Latin1, }; + +export const AFFINE_ALPHABETS = [ + {name: "Letters, match case: a-z", value: "a-z"}, + {name: "Letters, case sensitive: A-Za-z", value: "A-Za-z"}, + {name: "Word characters: A-Za-z0-9_", value: "A-Za-z0-9_"}, + {name: "Printable ASCII: space-~", value: "\\x20-~"} +]; + +/** + * Returns true if the given character is uppercase + * + * @private + * @author Barry B [profbbrown@gmail.com] + * @param {string} c - A character + * @returns {boolean} + */ +function isUpperCase(c) { + return c.toUpperCase() === c; +} diff --git a/src/core/operations/AffineCipherDecode.mjs b/src/core/operations/AffineCipherDecode.mjs index 869f231a4..9c6490797 100644 --- a/src/core/operations/AffineCipherDecode.mjs +++ b/src/core/operations/AffineCipherDecode.mjs @@ -5,8 +5,7 @@ */ import Operation from "../Operation.mjs"; -import Utils from "../Utils.mjs"; -import OperationError from "../errors/OperationError.mjs"; +import { affineDecrypt, affineDecryptInverse, AFFINE_ALPHABETS } from "../lib/Ciphers.mjs"; /** * Affine Cipher Decode operation @@ -21,7 +20,7 @@ class AffineCipherDecode extends Operation { this.name = "Affine Cipher Decode"; this.module = "Ciphers"; - this.description = "The Affine cipher is a type of monoalphabetic substitution cipher. To decrypt, each letter in an alphabet is mapped to its numeric equivalent, decrypted by a mathematical function, and converted back to a letter."; + this.description = "The Affine cipher is a type of monoalphabetic substitution cipher. To decrypt, each letter in an alphabet is mapped to its numeric equivalent, decrypted by a mathematical function (the inverse of ax+b % m), and converted back to a letter."; this.infoURL = "https://wikipedia.org/wiki/Affine_cipher"; this.inputType = "string"; this.outputType = "string"; @@ -35,6 +34,16 @@ class AffineCipherDecode extends Operation { "name": "b", "type": "number", "value": 0 + }, + { + "name": "Alphabet", + "type": "editableOption", + "value": AFFINE_ALPHABETS + }, + { + "name": "Use modular inverse values", + "type": "boolean", + "value": false } ]; } @@ -47,32 +56,9 @@ class AffineCipherDecode extends Operation { * @throws {OperationError} if a or b values are invalid */ run(input, args) { - const alphabet = "abcdefghijklmnopqrstuvwxyz", - [a, b] = args, - aModInv = Utils.modInv(a, 26); // Calculates modular inverse of a - let output = ""; - - if (!/^\+?(0|[1-9]\d*)$/.test(a) || !/^\+?(0|[1-9]\d*)$/.test(b)) { - throw new OperationError("The values of a and b can only be integers."); - } - - if (Utils.gcd(a, 26) !== 1) { - throw new OperationError("The value of `a` must be coprime to 26."); - } - - for (let i = 0; i < input.length; i++) { - if (alphabet.indexOf(input[i]) >= 0) { - // Uses the affine decode function (y-b * A') % m = x (where m is length of the alphabet and A' is modular inverse) - output += alphabet[Utils.mod((alphabet.indexOf(input[i]) - b) * aModInv, 26)]; - } else if (alphabet.indexOf(input[i].toLowerCase()) >= 0) { - // Same as above, accounting for uppercase - output += alphabet[Utils.mod((alphabet.indexOf(input[i].toLowerCase()) - b) * aModInv, 26)].toUpperCase(); - } else { - // Non-alphabetic characters - output += input[i]; - } - } - return output; + const a = args[0], b = args[1], alphabet = args[2], useInverse = args[3]; + if (useInverse) return affineDecryptInverse(input, a, b, alphabet); + else return affineDecrypt(input, a, b, alphabet); } /** diff --git a/src/core/operations/AffineCipherEncode.mjs b/src/core/operations/AffineCipherEncode.mjs index a9462ae8f..8a04fcd59 100644 --- a/src/core/operations/AffineCipherEncode.mjs +++ b/src/core/operations/AffineCipherEncode.mjs @@ -5,7 +5,7 @@ */ import Operation from "../Operation.mjs"; -import { affineEncode } from "../lib/Ciphers.mjs"; +import { affineEncrypt, AFFINE_ALPHABETS } from "../lib/Ciphers.mjs"; /** * Affine Cipher Encode operation @@ -20,7 +20,7 @@ class AffineCipherEncode extends Operation { this.name = "Affine Cipher Encode"; this.module = "Ciphers"; - this.description = "The Affine cipher is a type of monoalphabetic substitution cipher, wherein each letter in an alphabet is mapped to its numeric equivalent, encrypted using simple mathematical function, (ax + b) % 26, and converted back to a letter."; + this.description = "The Affine cipher is a type of monoalphabetic substitution cipher, wherein each letter in an alphabet is mapped to its numeric equivalent, encrypted using simple mathematical function, (ax + b) % m, and converted back to a letter."; this.infoURL = "https://wikipedia.org/wiki/Affine_cipher"; this.inputType = "string"; this.outputType = "string"; @@ -34,6 +34,11 @@ class AffineCipherEncode extends Operation { "name": "b", "type": "number", "value": 0 + }, + { + "name": "Alphabet", + "type": "editableOption", + "value": AFFINE_ALPHABETS } ]; } @@ -44,7 +49,8 @@ class AffineCipherEncode extends Operation { * @returns {string} */ run(input, args) { - return affineEncode(input, args); + const a = args[0], b = args[1], alphabet = args[2]; + return affineEncrypt(input, a, b, alphabet); } /** diff --git a/tests/operations/tests/Ciphers.mjs b/tests/operations/tests/Ciphers.mjs index 47453cf75..dd6fef08b 100644 --- a/tests/operations/tests/Ciphers.mjs +++ b/tests/operations/tests/Ciphers.mjs @@ -2,6 +2,7 @@ * Cipher tests. * * @author Matt C [matt@artemisbot.uk] + * @author Barry B [profbbrown@gmail.com] * @author n1474335 [n1474335@gmail.com] * * @copyright Crown Copyright 2018 @@ -18,7 +19,7 @@ TestRegister.addTests([ recipeConfig: [ { op: "Affine Cipher Encode", - args: [1, 0] + args: [1, 0, "a-z"] } ], }, @@ -29,7 +30,29 @@ TestRegister.addTests([ recipeConfig: [ { op: "Affine Cipher Encode", - args: [0.1, 0.00001] + args: [0.1, 0.00001, "a-z"] + } + ], + }, + { + name: "Affine Encode: invalid a & b, empty alphabet", + input: "some keys are shaped as locks. index[me]", + expectedOutput: "The alphabet cannot be empty.", + recipeConfig: [ + { + op: "Affine Cipher Encode", + args: [0.1, 0.00001, ""] + } + ], + }, + { + name: "Affine Encode: valid a & b, empty alphabet", + input: "some keys are shaped as locks. index[me]", + expectedOutput: "The alphabet cannot be empty.", + recipeConfig: [ + { + op: "Affine Cipher Encode", + args: [7, 23, ""] } ], }, @@ -40,18 +63,40 @@ TestRegister.addTests([ recipeConfig: [ { op: "Affine Cipher Encode", - args: [1, 0] + args: [1, 0, "a-z"] } ], }, { - name: "Affine Encode: normal", - input: "some keys are shaped as locks. index[me]", - expectedOutput: "vhnl tldv xyl vcxelo xv qhrtv. zkolg[nl]", + name: "Affine Encode: normal a-z", + input: "Some Keys Are Shaped As Locks. index[me]", + expectedOutput: "Vhnl Tldv Xyl Vcxelo Xv Qhrtv. zkolg[nl]", recipeConfig: [ { op: "Affine Cipher Encode", - args: [23, 23] + args: [23, 23, "a-z"] + } + ], + }, + { + name: "Affine Encode: normal A-Za-z", + input: "Some Keys Are Shaped As Locks. index[me]", + expectedOutput: "VHNl tldv XYl VCxelO Xv QHrTv. ZkOlG[Nl]", + recipeConfig: [ + { + op: "Affine Cipher Encode", + args: [23, 23, "A-Za-z"] + } + ], + }, + { + name: "Affine Encode: normal, printable ASCII", + input: "Some Keys Are Shaped As Locks. index[me]", + expectedOutput: "XCtz7^zk@76)z7X`}Zzc76@7uCLF@\\7w,czTRtz!", + recipeConfig: [ + { + op: "Affine Cipher Encode", + args: [23, 23, "\\u0020-~"] } ], }, @@ -62,7 +107,7 @@ TestRegister.addTests([ recipeConfig: [ { op: "Affine Cipher Decode", - args: [1, 0] + args: [1, 0, "a-z", false] } ], }, @@ -73,40 +118,128 @@ TestRegister.addTests([ recipeConfig: [ { op: "Affine Cipher Decode", - args: [0.1, 0.00001] + args: [0.1, 0.00001, "a-z", false] } ], }, { - name: "Affine Decode: invalid a (coprime)", + name: "Affine Decode: valid a & b, empty alphabet", input: "vhnl tldv xyl vcxelo xv qhrtv. zkolg[nl]", - expectedOutput: "The value of `a` must be coprime to 26.", + expectedOutput: "The alphabet cannot be empty.", recipeConfig: [ { op: "Affine Cipher Decode", - args: [8, 23] + args: [23, 23, "", false] } ], }, { - name: "Affine Decode: no effect", + name: "Affine Decode: invalid a & b (non-integer), empty alphabet", input: "vhnl tldv xyl vcxelo xv qhrtv. zkolg[nl]", - expectedOutput: "vhnl tldv xyl vcxelo xv qhrtv. zkolg[nl]", + expectedOutput: "The alphabet cannot be empty.", recipeConfig: [ { op: "Affine Cipher Decode", - args: [1, 0] + args: [0.1, 0.00001, "", false] } ], }, { - name: "Affine Decode: normal", + name: "Affine Decode: invalid a (non-coprime)", input: "vhnl tldv xyl vcxelo xv qhrtv. zkolg[nl]", - expectedOutput: "some keys are shaped as locks. index[me]", + expectedOutput: "The value of `a` (8) must be coprime to 26.", + recipeConfig: [ + { + op: "Affine Cipher Decode", + args: [8, 23, "a-z", false] + } + ], + }, + { + name: "Affine Decode: invalid a (non-coprime), printable ASCII", + input: "vhnl tldv xyl vcxelo xv qhrtv. zkolg[nl]", + expectedOutput: "The value of `a` (5) must be coprime to 95.", + recipeConfig: [ + { + op: "Affine Cipher Decode", + args: [5, 23, "\\u0020-~", false] + } + ], + }, + { + name: "Affine Decode: no effect, match case", + input: "Vhnl Tldv Xyl Vcxelo xv qhrtv. zkolg[nl]", + expectedOutput: "Vhnl Tldv Xyl Vcxelo xv qhrtv. zkolg[nl]", + recipeConfig: [ + { + op: "Affine Cipher Decode", + args: [1, 0, "a-z", false] + } + ], + }, + { + name: "Affine Decode: no effect, case sensitive", + input: "Vhnl Tldv Xyl Vcxelo xv qhrtv. zkolg[nl]", + expectedOutput: "Vhnl Tldv Xyl Vcxelo xv qhrtv. zkolg[nl]", + recipeConfig: [ + { + op: "Affine Cipher Decode", + args: [1, 0, "A-Za-z", false] + } + ], + }, + { + name: "Affine Decode: normal, case sensitive", + input: "Vhnl Tldv Xyl Vcxelo xv qhrtv. zkolg[nl]", + expectedOutput: "SOMe keys ARe SHapeD as lOcKs. InDeX[Me]", + recipeConfig: [ + { + op: "Affine Cipher Decode", + args: [23, 23, "A-Za-z", false] + } + ], + }, + { + name: "Affine Decode: normal, match case", + input: "Vhnl Tldv Xyl Vcxelo Xv Qhrtv. zkolg[nl]", + expectedOutput: "Some Keys Are Shaped As Locks. index[me]", + recipeConfig: [ + { + op: "Affine Cipher Decode", + args: [23, 23, "a-z", false] + } + ], + }, + { + name: "Affine Decode: normal, inverse", + input: "Vhnl Tldv Xyl Vcxelo Xv Qhrtv. zkolg[nl]", + expectedOutput: "Some Keys Are Shaped As Locks. index[me]", + recipeConfig: [ + { + op: "Affine Cipher Decode", + args: [17, 3, "a-z", true] + } + ], + }, + { + name: "Affine Decode: normal, printable ASCII", + input: "XCtz7^zk@76)z7X`}Zzc76@7uCLF@\\7w,czTRtz!", + expectedOutput: "Some Keys Are Shaped As Locks. index[me]", + recipeConfig: [ + { + op: "Affine Cipher Decode", + args: [23, 23, "\u0020-~", false] + } + ], + }, + { + name: "Affine Decode: normal, printable ASCII, inverse", + input: "XCtz7^zk@76)z7X`}Zzc76@7uCLF@\\7w,czTRtz!", + expectedOutput: "Some Keys Are Shaped As Locks. index[me]", recipeConfig: [ { op: "Affine Cipher Decode", - args: [23, 23] + args: [62, 72, "\u0020-~", true] } ], },