diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..1521c8b --- /dev/null +++ b/.eslintignore @@ -0,0 +1 @@ +dist diff --git a/.eslintrc.yaml b/.eslintrc.yaml new file mode 100644 index 0000000..148e8e2 --- /dev/null +++ b/.eslintrc.yaml @@ -0,0 +1,6 @@ + +--- +root: true +extends: plugin:coremail/standard +env: + jasmine: true diff --git a/.gitignore b/.gitignore index dfd296d..3129dd3 100644 --- a/.gitignore +++ b/.gitignore @@ -105,3 +105,6 @@ dist # IDEA .idea + +# locked dependencies +package-lock.json diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..ec93124 --- /dev/null +++ b/.npmignore @@ -0,0 +1,3 @@ +/* +!/dist +!/lib diff --git a/README.md b/README.md index bbe67b4..c2898ee 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,46 @@ -## IDEA +# IDEA ![npm](https://badges.aleen42.com/src/npm.svg) ![javascript](https://badges.aleen42.com/src/javascript.svg) -The IDEA cypher implementation in JavaScript +It is about the IDEA cypher which is implemented in JavaScript within ~10 KiB. If you want better compatibility, you may need the polyfill version `dist/idea.all.js`, which has shimmed [`Int8Array`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Int8Array) for you. + +## Compatibility + +- IE6+ (polyfills required for ES7- browsers) +- NodeJS + +## Install + +```bash +npm install @cormeail/idea +``` + +## Usage + +- without padding: + + ```js + function paddingToBytes(str) { + typeof str !== 'string' && (str = JSON.stringify(str)); + + const blockSize = 8; + const srcBytes = encoder.encode(str); + const len = Math.ceil(srcBytes.length / blockSize) * blockSize; // padding with \x00 + const src = new Int8Array(len); + src.set(srcBytes); + return src; + } + + const IDEA = require('@coremail/idea'); + const idea = new IDEA(str2bytes('private key'), /* no padding */-1); + idea.encrypt(paddingToBytes('message')); // => Int8Array[] + ``` + +- with xor padding (ENC3 by default): + + ```js + const encoder = new TextEncoder(); + const IDEA = require('@coremail/idea'); + const idea = new IDEA(str2bytes('private key'), /* ENC3 by default */197); + idea.encrypt(encoder.encode('message')); // => Int8Array[] + ``` diff --git a/build.js b/build.js new file mode 100644 index 0000000..94aeb45 --- /dev/null +++ b/build.js @@ -0,0 +1,21 @@ +const webpack = require('webpack'); +const config = require('./webpack.config'); + +// build uncompressed +webpack(config(0)).run(webpackCallback); +// build minimized +webpack(config(1)).run(webpackCallback); + +function log(msg) { + log.logged ? console.log('') : (log.logged = true); // add blank line + console.log(msg); +} + +function webpackCallback(err, stats) { + if (err) { + process.exit(1); + } + log(stats.toString({ + colors : true, + })); +} diff --git a/build/ES3HarmonyPlugin.js b/build/ES3HarmonyPlugin.js new file mode 100644 index 0000000..76d058d --- /dev/null +++ b/build/ES3HarmonyPlugin.js @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2018 Coremail.cn, Ltd. All Rights Reserved. + */ + +// see https://github.com/inferpse/es3-harmony-webpack-plugin + +const name = 'ES3HarmonyPlugin'; + +module.exports = class ES3HarmonyPlugin { + apply({hooks, webpack : {javascript : {JavascriptModulesPlugin}}}) { + // noinspection JSUnresolvedVariable + hooks.compilation.tap({name}, compilation => { + // noinspection JSUnresolvedVariable, JSUnresolvedFunction + JavascriptModulesPlugin.getCompilationHooks(compilation).renderMain.tap({name}, replaceSource) + }); + } +}; + +function replaceSource(source) { + source = source['original'] ? source['original']() : source; + if (source['getChildren']) { + source['getChildren']().forEach(replaceSource); + } else { + // pattern: RegExp|substr, replacement: newSubstr|function + replacements.forEach(([pattern, replacement]) => { + if (pattern.test(source.source())) { + source._value = source.source().replace(pattern, replacement); + } + }); + } +} + +const toReplace = (pattern, replacement) => [ + new RegExp(pattern.trim().replace(/.*noinspection.*\n/g, '').replace(/[?.[\]()]/g, '\\$&').replace(/\s+/g, '\\s*'), 'g'), + // trimIndent + replacement.trim().replace(/^ {8}/mg, ''), +]; + +/* global __webpack_require__ */// eslint-disable-line no-unused-vars +// language=JS +const replacements = [ + // @formatter:off + toReplace(` + __webpack_require__.d = function (exports, definition) { + for (var key in definition) { + // noinspection JSUnfilteredForInLoop, JSUnresolvedFunction + if (__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) { + // noinspection JSUnfilteredForInLoop + Object.defineProperty(exports, key, { enumerable : true, get : definition[key] }); + } + } + }; + `, ` + __webpack_require__.d = function (exports, definition) { + for (var key in definition) { + // noinspection JSUnfilteredForInLoop, JSUnresolvedFunction + if (__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) { + // noinspection JSUnfilteredForInLoop + exports[key] = definition[key](); // patched by ${name} + } + } + }; + `), + // @formatter:on + + // remove "use strict" + [/(['"])use\s+strict(['"]);?/gm, ''], +]; diff --git a/karma.config.js b/karma.config.js new file mode 100644 index 0000000..d8c7c1d --- /dev/null +++ b/karma.config.js @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2021 Coremail.cn, Ltd. All Rights Reserved. + */ + +const webpackConfig = Object.assign({}, require('./webpack.config')(1)); +delete webpackConfig.entry; +delete webpackConfig.output; + +module.exports = config => { + config.set({ + webpack : webpackConfig, + files : ['test/index.js'], + preprocessors : {'test/index.js' : ['webpack', 'sourcemap']}, + frameworks : ['jasmine', 'webpack', 'detectBrowsers'], + reporters : ['mocha'], + singleRun : true, + plugins : [ + 'karma-jasmine', + 'karma-mocha-reporter', + 'karma-sourcemap-loader', + 'karma-webpack', + 'karma-chrome-launcher', + 'karma-safari-launcher', + 'karma-firefox-launcher', + 'karma-ie-launcher', + 'karma-edge-launcher', + 'karma-detect-browsers', + ], + + client : {jasmine : {random : false}}, + + detectBrowsers : { + usePhantomJS : false, + // ref: https://github.com/karma-runner/karma-safari-launcher/issues/12 + 'postDetection' : availableBrowser => availableBrowser.filter(name => name !== 'Safari'), + }, + }); +}; diff --git a/lib/index.js b/lib/index.js new file mode 100644 index 0000000..7a79d87 --- /dev/null +++ b/lib/index.js @@ -0,0 +1,287 @@ +/* + * Copyright (c) 1999-2003 AppGate Network Security AB. All Rights Reserved. + * + * This file contains Original Code and/or Modifications of Original Code as + * defined in and that are subject to the MindTerm Public Source License, + * Version 2.0, (the 'License'). You may not use this file except in compliance + * with the License. + * + * You should have received a copy of the MindTerm Public Source License + * along with this software; see the file LICENSE. If not, write to + * AppGate Network Security AB, Stora Badhusgatan 18-20, 41121 Goteborg, SWEDEN + * + *****************************************************************************/ + +/* + * Author's comment: The contents of this file is heavily based upon Bruce + * Schneier's c-code found in his book: Bruce Schneier: Applied Cryptography 2nd + * ed., John Wiley & Sons, 1996 + * + * The IDEA mathematical formula may be covered by one or more of the following + * patents: PCT/CH91/00117, EP 0 482 154 B1, US Pat. 5,214,703. + * Hence it might be subject to licensing for commercial use. + */ +const DEFAULT_XOR_KEY = 197; // default xor key using by ENC3 +const BLOCK_SIZE = 8; // bytes in a data-block + +/** + * @param {Int8Array} key + * @param {number} xorKey + * @returns {exports} + */ +module.exports = function IDEA(key, xorKey = DEFAULT_XOR_KEY) { + this.encryptor = new Engine() + this.decryptor = new Engine(); + + function Engine() { + this.keySchedule = []; + this.getBlockSize = () => BLOCK_SIZE; + + this.processBlock = (src, inOff, out, outOff) => { + ideaCipher(src, inOff, out, outOff, this.keySchedule); + return BLOCK_SIZE; + }; + } + + /** + * @param {Int8Array} key + * @param {number} xorKey + */ + this.setKey = function (key, xorKey) { + ideaExpandKey(key, this.encryptor.keySchedule); + ideaInvertKey(this.encryptor.keySchedule, this.decryptor.keySchedule); + xorKey && (this.xorKey = xorKey); + }; + + /** + * the method to encrypt + * @param {Int8Array | Uint8Array} src + * @returns {Int8Array} + */ + this.encrypt = function (src) { + const len = src.length, out = new Int8Array(len), srcOff = 0, outOff = 0; + + if (this.xorKey === -1) { + Padding.noPaddingFinal(this.encryptor, src, srcOff, out, outOff, len); + } else { + Padding.xorPaddingFinal(this.encryptor, this.xorKey, src, srcOff, out, outOff, len); + } + + return out; + }; + + /** + * the method to decrypt + * @param {Int8Array | Uint8Array} src + * @returns {Int8Array} + */ + this.decrypt = function (src) { + const len = src.length, out = new Int8Array(len), srcOff = 0, outOff = 0; + if (this.xorKey === -1) { + Padding.noPaddingFinal(this.decryptor, src, srcOff, out, outOff, len); + } else { + Padding.xorPaddingFinal(this.decryptor, this.xorKey, src, srcOff, out, outOff, len); + } + + return out; + }; + + /** + * @param {Int8Array} key + * @param {int[]} keySchedule + */ + function ideaExpandKey(key, keySchedule) { + let i, ki = 0, j; + for (i = 0; i < 8; i++) { + keySchedule[i] = ((key[2 * i] & 0xff) << 8) | (key[(2 * i) + 1] & 0xff); + } + + for (i = 8, j = 0; i < 52; i++) { + j++; + keySchedule[ki + j + 7] + = ((keySchedule[ki + (j & 7)] << 9) + | (keySchedule[ki + ((j + 1) & 7)] >>> 7)) & 0xffff; + ki += j & 8; + j &= 7; + } + } + + /** + * @param {int[]} key + * @param {int[]} keySchedule + */ + function ideaInvertKey(key, keySchedule) { + let i, j, k, t1, t2, t3; + + j = 0; + k = 51; + + t1 = mulInv(key[j++]); + t2 = (-key[j++]) & 0xffff; + t3 = (-key[j++]) & 0xffff; + keySchedule[k--] = mulInv(key[j++]); + keySchedule[k--] = t3; + keySchedule[k--] = t2; + keySchedule[k--] = t1; + + for (i = 1; i < 8; i++) { + t1 = key[j++]; + keySchedule[k--] = key[j++]; + keySchedule[k--] = t1; + + t1 = mulInv(key[j++]); + t2 = (-key[j++]) & 0xffff; + t3 = (-key[j++]) & 0xffff; + keySchedule[k--] = mulInv(key[j++]); + keySchedule[k--] = t2; + keySchedule[k--] = t3; + keySchedule[k--] = t1; + } + + t1 = key[j++]; + keySchedule[k--] = key[j++]; + keySchedule[k--] = t1; + + t1 = mulInv(key[j++]); + t2 = (-key[j++]) & 0xffff; + t3 = (-key[j++]) & 0xffff; + // noinspection UnusedAssignment + keySchedule[k--] = mulInv(key[j++]); + keySchedule[k--] = t3; + keySchedule[k--] = t2; + // noinspection UnusedAssignment + keySchedule[k--] = t1; + } + + + function ideaCipher(src, srcOffset, out, outOffset, keySchedule) { + let t1 = 0, t2, x1, x2, x3, x4, ki = 0; + let l = getIntMSBO(src, srcOffset); + let r = getIntMSBO(src, srcOffset + 4); + + x1 = (l >>> 16); + x2 = (l & 0xffff); + x3 = (r >>> 16); + x4 = (r & 0xffff); + + for (let round = 0; round < 8; round++) { + x1 = mul(x1 & 0xffff, keySchedule[ki++]); + x2 = (x2 + keySchedule[ki++]); + x3 = (x3 + keySchedule[ki++]); + x4 = mul(x4 & 0xffff, keySchedule[ki++]); + + t1 = (x1 ^ x3); + t2 = (x2 ^ x4); + t1 = mul(t1 & 0xffff, keySchedule[ki++]); + t2 = (t1 + t2); + t2 = mul(t2 & 0xffff, keySchedule[ki++]); + t1 = (t1 + t2); + + x1 = (x1 ^ t2); + x4 = (x4 ^ t1); + t1 = (t1 ^ x2); + x2 = (t2 ^ x3); + x3 = t1; + } + + t2 = x2; + x1 = mul(x1 & 0xffff, keySchedule[ki++]); + x2 = (t1 + keySchedule[ki++]); + x3 = ((t2 + keySchedule[ki++]) & 0xffff); + x4 = mul(x4 & 0xffff, keySchedule[ki]); + + putIntMSBO((x1 << 16) | (x2 & 0xffff), out, outOffset); + putIntMSBO((x3 << 16) | (x4 & 0xffff), out, outOffset + 4); + } + + function mul(a, b) { + const ab = a * b; + if (ab !== 0) { + const lo = ab & 0xffff; + const hi = (ab >>> 16) & 0xffff; + return ((lo - hi) + ((lo < hi) ? 1 : 0)); + } + if (a === 0) { + return (1 - b); + } + return (1 - a); + } + + function mulInv(x) { + let t0, t1, q, y; + if (x <= 1) { + return x; + } + t1 = Math.floor(0x10001 / x); + y = 0x10001 % x; + if (y === 1) { + return ((1 - t1) & 0xffff); + } + t0 = 1; + do { + q = Math.floor(x / y); + x = x % y; + t0 += q * t1; + if (x === 1) { + return t0; + } + q = Math.floor(y / x); + y = y % x; + t1 += q * t0; + } while (y !== 1); + return ((1 - t1) & 0xffff); + } + + function getIntMSBO(src, srcOffset) { + return (((src[srcOffset] & 0xff) << 24) + | ((src[srcOffset + 1] & 0xff) << 16) + | ((src[srcOffset + 2] & 0xff) << 8) + | (src[srcOffset + 3] & 0xff)); + } + + function putIntMSBO(val, dest, destOffset) { + dest[destOffset] = ((val >>> 24) & 0xff); + dest[destOffset + 1] = ((val >>> 16) & 0xff); + dest[destOffset + 2] = ((val >>> 8) & 0xff); + dest[destOffset + 3] = (val & 0xff); + } + + this.setKey(key, xorKey); + return this; +} + + +/** + * @see org.bouncycastle.jcajce.provider.symmetric.util.BaseBlockCipher#engineSetPadding + * BaseBlockCipher.engineSetPadding("NOPADDING") + */ +const Padding = { + noPaddingFinal : (cipher, src, inOff, out, outOff, len) => { + doFinal(cipher, false, 0, src, inOff, out, outOff, len); + }, + xorPaddingFinal : (cipher, xorKey, src, inOff, out, outOff, len) => { + doFinal(cipher, true, xorKey, src, inOff, out, outOff, len); + }, +}; + +function doFinal(cipher, xorPadding, xorKey, src, inOff, out, outOff, len) { + const blockSize = cipher.getBlockSize(); // assert Integer.bitCount(blockSize) == 1; + const nBlocks = Math.floor(len / blockSize); + + // use the cipher algorithm for parts divided by the block size. + for (let i = 0; i < nBlocks; i++) { + cipher.processBlock(src, inOff, out, outOff); + inOff += blockSize; + outOff += blockSize; + len -= blockSize; + } + + if (!xorPadding && len > 0) { + throw new Error('no padding, expecting more inputs'); + } + + // use xor encryption for remain parts + for (let i = 0; i < len; i++) { + out[outOff + i] = src[inOff + i] ^ xorKey; + } +} diff --git a/lib/polyfill.js b/lib/polyfill.js new file mode 100644 index 0000000..8356891 --- /dev/null +++ b/lib/polyfill.js @@ -0,0 +1 @@ +require('core-js/es/typed-array') diff --git a/package.json b/package.json new file mode 100644 index 0000000..4ea191b --- /dev/null +++ b/package.json @@ -0,0 +1,52 @@ +{ + "name" : "@coremail/idea", + "version" : "1.0.0", + "description" : "The IDEA cypher implementation in JavaScript", + "main" : "dist/idea.min.js", + "scripts" : { + "lint" : "eslint --ext .js .", + "prepublishOnly" : "npm run lint && npm t && npm run build", + "build" : "node build.js", + "test" : "karma start karma.config.js" + }, + "repository" : { + "type" : "git", + "url" : "git+https://github.com/coremail/IDEA.git" + }, + "keywords" : [ + "IDEA", + "cypher" + ], + "author" : "Aleen ", + "license" : "MIT", + "bugs" : { + "url" : "https://github.com/coremail/IDEA/issues" + }, + "homepage" : "https://github.com/coremail/IDEA#readme", + "devDependencies" : { + "@babel/core" : "^7.16.5", + "@babel/preset-env" : "^7.16.5", + "babel-loader" : "^8.2.3", + "core-js" : "^3.20.1", + "eslint" : "^7.4.0", + "eslint-plugin-coremail" : "0.4.1", + "terser-webpack-plugin" : "^5.3.0", + "webpack" : "^5.65.0", + + "jasmine-core" : "3.7.1", + "karma" : "6.3.2", + "karma-chrome-launcher" : "^3.1.0", + "karma-detect-browsers" : "2.3.3", + "karma-edge-launcher" : "^0.4.2", + "karma-firefox-launcher" : "^2.1.0", + "karma-ie-launcher" : "^1.0.0", + "karma-jasmine" : "4.0.1", + "karma-mocha-reporter" : "2.2.5", + "karma-safari-launcher" : "^1.0.0", + "karma-sourcemap-loader" : "0.3.8", + "karma-webpack" : "5.0.0" + }, + "dependencies" : { + "text-encoding" : "^0.7.0" + } +} diff --git a/test/index-spec.js b/test/index-spec.js new file mode 100644 index 0000000..04f8070 --- /dev/null +++ b/test/index-spec.js @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2021 Coremail.cn, Ltd. All Rights Reserved. + */ + +const IDEA = require('../lib/index.js'); + +/* eslint-disable no-global-assign */ +const encoding = require('text-encoding'); + +typeof TextEncoder === 'undefined' && (TextEncoder = encoding.TextEncoder); +typeof TextDecoder === 'undefined' && (TextDecoder = encoding.TextEncoder); + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +const str2bytes = str => { + const len = str.length; + const bytes = new Int8Array(len); + for (let i = 0; i < len; i++) { + bytes[i] = str.charCodeAt(i); + } + return bytes; +}; + +describe('IDEA', () => { + function input(str) { + typeof str !== 'string' && (str = JSON.stringify(str)); + + const blockSize = 8; + const srcBytes = encoder.encode(str); + const len = Math.ceil(srcBytes.length / blockSize) * blockSize; // padding with \x00 + const src = new Int8Array(len); + src.set(srcBytes); + return src; + } + + function output(out) { + // eslint-disable-next-line no-control-regex + return decoder.decode(out).replace(/\x00/g, ''); // strip \x00 + } + + // Convert a hex string to a byte array + // REF: https://stackoverflow.com/a/34356351 + function hexToBytes(hex) { + let bytes, c; + for (bytes = [], c = 0; c < hex.length; c += 2) { + bytes.push(parseInt(hex.substr(c, 2), 16)); + } + return bytes; + } + + // Convert a byte array to a hex string + // REF: https://stackoverflow.com/a/34356351 + function bytesToHex(bytes) { + let hex, i; + for (hex = [], i = 0; i < bytes.length; i++) { + let current = bytes[i] < 0 ? bytes[i] + 256 : bytes[i]; + hex.push((current >>> 4).toString(16)); + hex.push((current & 0xF).toString(16)); + } + return hex.join(''); + } + + it('encrypt / decrypt', () => { + const idea = new IDEA(str2bytes('private key'), /* no padding */-1); + + // encrypt + expect(bytesToHex(idea.encrypt(input('null')))).toBe('c675f487ebb3e6cc'); + expect(bytesToHex(idea.encrypt(input(null)))).toBe('c675f487ebb3e6cc'); + expect(bytesToHex(idea.encrypt(input(false)))).toBe('aa4fb94e2fd3adb7'); + expect(bytesToHex(idea.encrypt(input(true)))).toBe('7cbd11e0ffc70e35'); + expect(bytesToHex(idea.encrypt(input({})))).toBe('95a64d37f8411ed6'); + expect(bytesToHex(idea.encrypt(input({a : 1, b : false})))).toBe('ae98e3c26bdab47476173ddd1fb16ae83b694639e5d4e433'); + + // decrypt + expect(output(idea.decrypt(hexToBytes('c675f487ebb3e6cc')))).toBe('null'); + expect(output(idea.decrypt(hexToBytes('aa4fb94e2fd3adb7')))).toBe('false'); + expect(output(idea.decrypt(hexToBytes('7cbd11e0ffc70e35')))).toBe('true'); + expect(output(idea.decrypt(hexToBytes('95a64d37f8411ed6')))).toBe('{}'); + expect(output(idea.decrypt(hexToBytes('ae98e3c26bdab47476173ddd1fb16ae83b694639e5d4e433')))) + .toBe('{"a":1,"b":false}'); + }); + + it('no padding', () => { + const idea = new IDEA(str2bytes('private key'), /* no padding */-1); + expect(() => { + idea.encrypt(encoder.encode('null')); + }).toThrow(new Error('no padding, expecting more inputs')); + }); + + it('padding', () => { + const idea = new IDEA(str2bytes('private key')); + expect(() => { + idea.encrypt(encoder.encode('null')); + }).not.toThrow(); + }); +}); diff --git a/test/index.js b/test/index.js new file mode 100644 index 0000000..138a334 --- /dev/null +++ b/test/index.js @@ -0,0 +1,4 @@ +require('../lib/polyfill') + +const testsContext = require.context('.', true, /-spec$/); +testsContext.keys().forEach(testsContext); diff --git a/webpack.config.js b/webpack.config.js new file mode 100644 index 0000000..1b0a915 --- /dev/null +++ b/webpack.config.js @@ -0,0 +1,45 @@ +const path = require('path'); +const TerserPlugin = require('terser-webpack-plugin'); +const ES3HarmonyPlugin = require('./build/ES3HarmonyPlugin'); + +module.exports = minimize => ({ + mode : 'production', + target : ['web', 'es5'], + output : { + path : path.resolve(__dirname, 'dist'), + libraryTarget : 'umd', + globalObject : 'typeof window !== \'undefined\' ? window : this', + }, + + module : { + rules : [{ + test : /\..?js$/, + use : { + loader : 'babel-loader', + options : { + presets : [ + ['@babel/env', { + forceAllTransforms : true, + loose : true, + modules : false, // ES6 modules should be processed only by webpack + + // see https://github.com/babel/babel/issues/1087#issuecomment-373375175, naming anonymous functions is problematic + exclude : ['@babel/plugin-transform-function-name'], + }], + ], + }, + }, + }], + }, + + entry : { + [`idea${minimize ? '.min' : ''}`] : './lib/index', + [`idea.all${minimize ? '.min' : ''}`] : ['./lib/polyfill', './lib/index'], + }, + + optimization : minimize ? { + minimizer : [new TerserPlugin({terserOptions : {ie8 : true}})], + } : {minimize : false}, + + plugins : [new ES3HarmonyPlugin()], +});