From 1ac3190dae9ed6fd5fb226e3abbf735dc8307503 Mon Sep 17 00:00:00 2001 From: consigcody94 Date: Mon, 23 Mar 2026 13:59:24 -0400 Subject: [PATCH 1/3] Modernize CyberChef build system, dependencies, UI, and testing Build System: - Replace Grunt with npm scripts and Node.js helper scripts - Remove worker-loader, use native Webpack 5 worker syntax - Add Rspack config as faster alternative bundler (5-23x faster) - Replace platform-specific sed postinstall hacks with cross-platform Node.js script - Update browser targets from Chrome 50/Firefox 38 to Chrome 80/Firefox 78/Safari 14 - Fix import assertions (assert -> with) for Node 24 compatibility Dependencies: - Replace deprecated crypto-js with native RC4 implementation and @noble/hashes EVP KDF - Replace blakejs with @noble/hashes/blake2b and blake2s - Add @noble/hashes for MD5, SHA1/2/3, RIPEMD160, HMAC, HKDF (crypto-api fallback for legacy algos) - Replace lodash with native CaseConvert.mjs utility - Replace moment.js in web layer with date-fns - Add date-fns and date-fns-tz dependencies - Remove jquery, snackbarjs, arrive, bootstrap-colorpicker, bootstrap-material-design, popper.js v1, lodash, blakejs, crypto-js UI Modernization: - Upgrade Bootstrap 4.6.2 to Bootstrap 5.3 (87 data attribute renames) - Remove bootstrap-material-design (unmaintained) - Remove jQuery dependency entirely (38 calls replaced with native DOM + BS5 JS API) - Add custom Snackbar.mjs notification utility (replaces snackbarjs) - Replace bootstrap-colorpicker with native HTML color input Testing: - Add Vitest config and adapter for running existing TestRegister tests - Add Playwright config and E2E test replacing Nightwatch - Update CI workflows to use npm scripts and Playwright --- .github/workflows/master.yml | 17 +- .github/workflows/pull_requests.yml | 14 +- Dockerfile | 6 +- babel.config.js | 9 +- package.json | 101 +++++----- playwright.config.mjs | 45 +++++ rspack.config.js | 207 +++++++++++++++++++++ rspack.dev.config.js | 51 +++++ rspack.prod.config.js | 51 +++++ scripts/buildStandalone.mjs | 86 +++++++++ scripts/listEntryModules.mjs | 44 +++++ scripts/listEntryModulesSync.cjs | 32 ++++ scripts/postinstall.mjs | 81 ++++++++ scripts/prepareGhPages.mjs | 32 ++++ scripts/testNodeConsumers.mjs | 51 +++++ scripts/watchConfig.mjs | 43 +++++ src/core/ChefWorker.js | 2 +- src/core/Recipe.mjs | 2 +- src/core/lib/CaseConvert.mjs | 56 ++++++ src/core/lib/Ciphers.mjs | 16 -- src/core/lib/Hash.mjs | 52 ++++++ src/core/lib/Magic.mjs | 2 +- src/core/lib/RC4.mjs | 74 ++++++++ src/core/operations/BLAKE2b.mjs | 23 ++- src/core/operations/BLAKE2s.mjs | 23 ++- src/core/operations/DeriveEVPKey.mjs | 151 +++++++-------- src/core/operations/FlaskSessionSign.mjs | 21 ++- src/core/operations/FlaskSessionVerify.mjs | 21 ++- src/core/operations/Magic.mjs | 2 +- src/core/operations/ParseColourCode.mjs | 28 +-- src/core/operations/RC4.mjs | 105 ++++++++++- src/core/operations/RC4Drop.mjs | 100 +++++++++- src/core/operations/ShowBase64Offsets.mjs | 2 +- src/core/operations/ToCamelCase.mjs | 2 +- src/core/operations/ToKebabCase.mjs | 2 +- src/core/operations/ToSnakeCase.mjs | 2 +- src/node/api.mjs | 2 +- src/web/App.mjs | 68 ++++--- src/web/HTMLCategory.mjs | 4 +- src/web/HTMLIngredient.mjs | 8 +- src/web/HTMLOperation.mjs | 4 +- src/web/html/index.html | 102 +++++----- src/web/index.js | 14 +- src/web/stylesheets/index.js | 3 +- src/web/utils/Snackbar.mjs | 64 +++++++ src/web/utils/fileDetails.mjs | 7 +- src/web/utils/statusBar.mjs | 14 +- src/web/waiters/BackgroundWorkerWaiter.mjs | 5 +- src/web/waiters/BindingsWaiter.mjs | 4 +- src/web/waiters/ControlsWaiter.mjs | 22 ++- src/web/waiters/InputWaiter.mjs | 19 +- src/web/waiters/OperationsWaiter.mjs | 64 +++++-- src/web/waiters/OptionsWaiter.mjs | 4 +- src/web/waiters/OutputWaiter.mjs | 21 ++- src/web/waiters/RecipeWaiter.mjs | 22 +-- src/web/waiters/SeasonalWaiter.mjs | 2 +- src/web/waiters/WorkerWaiter.mjs | 9 +- tests/browser/app.spec.mjs | 75 ++++++++ tests/lib/VitestAdapter.mjs | 76 ++++++++ tests/node/tests/Categories.mjs | 4 +- tests/operations/operations.test.mjs | 181 ++++++++++++++++++ vitest.config.mjs | 12 ++ webpack.config.js | 13 +- webpack.dev.config.js | 56 ++++++ webpack.prod.config.js | 67 +++++++ 65 files changed, 2085 insertions(+), 417 deletions(-) create mode 100644 playwright.config.mjs create mode 100644 rspack.config.js create mode 100644 rspack.dev.config.js create mode 100644 rspack.prod.config.js create mode 100644 scripts/buildStandalone.mjs create mode 100644 scripts/listEntryModules.mjs create mode 100644 scripts/listEntryModulesSync.cjs create mode 100644 scripts/postinstall.mjs create mode 100644 scripts/prepareGhPages.mjs create mode 100644 scripts/testNodeConsumers.mjs create mode 100644 scripts/watchConfig.mjs create mode 100644 src/core/lib/CaseConvert.mjs create mode 100644 src/core/lib/RC4.mjs create mode 100644 src/web/utils/Snackbar.mjs create mode 100644 tests/browser/app.spec.mjs create mode 100644 tests/lib/VitestAdapter.mjs create mode 100644 tests/operations/operations.test.mjs create mode 100644 vitest.config.mjs create mode 100644 webpack.dev.config.js create mode 100644 webpack.prod.config.js diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index 13d280f64f..bd2afd1033 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -26,12 +26,11 @@ jobs: - name: Install run: | - export DETECT_CHROMEDRIVER_VERSION=true npm install npm run setheapsize - name: Lint - run: npx grunt lint + run: npm run lint - name: Unit Tests run: | @@ -40,20 +39,22 @@ jobs: - name: Production Build if: success() - run: npx grunt prod --msg="" + run: npm run build - name: Generate sitemap - run: npx grunt exec:sitemap + run: npm run build:sitemap + + - name: Install Playwright Browsers + if: success() + run: npx playwright install --with-deps chromium - name: UI Tests if: success() - run: | - sudo apt-get install xvfb - xvfb-run --server-args="-screen 0 1200x800x24" npx grunt testui + run: npm run testui - name: Prepare for GitHub Pages if: success() - run: npx grunt copy:ghPages + run: npm run build:ghpages - name: Deploy to GitHub Pages if: success() && github.ref == 'refs/heads/master' diff --git a/.github/workflows/pull_requests.yml b/.github/workflows/pull_requests.yml index 8f04df72c9..e71571d22f 100644 --- a/.github/workflows/pull_requests.yml +++ b/.github/workflows/pull_requests.yml @@ -22,12 +22,11 @@ jobs: - name: Install run: | - export DETECT_CHROMEDRIVER_VERSION=true npm install npm run setheapsize - name: Lint - run: npx grunt lint + run: npm run lint - name: Unit Tests run: | @@ -36,7 +35,7 @@ jobs: - name: Production Build if: success() - run: npx grunt prod + run: npm run build - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -50,8 +49,11 @@ jobs: uses: docker/build-push-action@v6 with: platforms: linux/amd64,linux/arm64 + + - name: Install Playwright Browsers + if: success() + run: npx playwright install --with-deps chromium + - name: UI Tests if: success() - run: | - sudo apt-get install xvfb - xvfb-run --server-args="-screen 0 1200x800x24" npx grunt testui + run: npm run testui diff --git a/Dockerfile b/Dockerfile index 856a16ce44..1323ad9c56 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,14 +12,14 @@ COPY package.json . COPY package-lock.json . # Install dependencies -# --ignore-scripts prevents postinstall script (which runs grunt) as it depends on files other than package.json +# --ignore-scripts prevents postinstall script as it depends on files other than package.json RUN npm ci --ignore-scripts # Copy files needed for postinstall and build COPY . . -# npm postinstall runs grunt, which depends on files other than package.json -RUN npm run postinstall +# Run postinstall to fix dependency issues +RUN node scripts/postinstall.mjs # Build the app RUN npm run build diff --git a/babel.config.js b/babel.config.js index 178271fb51..909cc141a6 100644 --- a/babel.config.js +++ b/babel.config.js @@ -5,17 +5,12 @@ module.exports = function(api) { "presets": [ ["@babel/preset-env", { "modules": false, - "useBuiltIns": "entry", + "useBuiltIns": "usage", "corejs": 3 }] ], "plugins": [ - "@babel/plugin-syntax-import-assertions", - [ - "@babel/plugin-transform-runtime", { - "regenerator": true - } - ] + "@babel/plugin-syntax-import-assertions" ] }; }; diff --git a/package.json b/package.json index 4c18856002..5938e685b5 100644 --- a/package.json +++ b/package.json @@ -34,80 +34,59 @@ }, "bugs": "https://github.com/gchq/CyberChef/issues", "browserslist": [ - "Chrome >= 50", - "Firefox >= 38", - "node >= 16" + "Chrome >= 80", + "Firefox >= 78", + "Safari >= 14", + "node >= 18" ], "devDependencies": { "@babel/eslint-parser": "^7.28.6", - "@babel/plugin-syntax-import-assertions": "^7.28.6", - "@babel/plugin-transform-runtime": "^7.29.0", - "@babel/preset-env": "^7.29.2", - "@babel/runtime": "^7.29.2", "@codemirror/commands": "^6.10.3", "@codemirror/language": "^6.12.2", "@codemirror/search": "^6.6.0", "@codemirror/state": "^6.5.4", "@codemirror/view": "^6.40.0", + "archiver": "^7.0.0", "autoprefixer": "^10.4.27", - "babel-loader": "^10.1.1", "base64-loader": "^1.0.0", - "chromedriver": "^130.0.4", "cli-progress": "^3.12.0", "colors": "^1.4.0", - "compression-webpack-plugin": "^11.1.0", - "copy-webpack-plugin": "^13.0.1", - "core-js": "^3.49.0", + "concurrently": "^9.0.0", "cspell": "^8.19.4", "css-loader": "7.1.4", "eslint": "^9.39.4", "eslint-plugin-jsdoc": "^50.8.0", "globals": "^15.15.0", - "grunt": "^1.6.1", - "grunt-chmod": "~1.1.1", - "grunt-concurrent": "^3.0.0", - "grunt-contrib-clean": "~2.0.1", - "grunt-contrib-connect": "^5.0.1", - "grunt-contrib-copy": "~1.0.0", - "grunt-contrib-watch": "^1.1.0", - "grunt-eslint": "^25.0.0", - "grunt-exec": "~3.0.0", - "grunt-webpack": "^6.0.0", - "grunt-zip": "^1.0.0", - "html-webpack-plugin": "^5.6.6", "imports-loader": "^5.0.0", - "mini-css-extract-plugin": "2.10.1", - "modify-source-webpack-plugin": "^4.1.0", - "nightwatch": "^3.15.0", + "@playwright/test": "^1.50.0", "postcss": "^8.5.8", "postcss-css-variables": "^0.19.0", "postcss-import": "^16.1.1", "postcss-loader": "^8.2.1", "prompt": "^1.3.0", + "rimraf": "^6.0.0", + "vitest": "^3.0.0", "sitemap": "^8.0.3", "terser": "^5.46.1", - "webpack": "^5.105.4", - "webpack-bundle-analyzer": "^4.10.2", - "webpack-dev-server": "5.0.4", - "webpack-node-externals": "^3.0.0", - "worker-loader": "^3.0.8" + "@rspack/cli": "^1.2.0", + "@rspack/core": "^1.2.0" }, "dependencies": { "@alexaltea/capstone-js": "^3.0.5", + "@noble/hashes": "^1.7.0", + "date-fns": "^4.1.0", + "date-fns-tz": "^3.2.0", "@astronautlabs/amf": "^0.0.6", "@blu3r4y/lzma": "^2.3.3", "@wavesenterprise/crypto-gost-js": "^2.1.0-RC1", "@xmldom/xmldom": "^0.8.11", "argon2-browser": "^1.18.0", - "arrive": "^2.5.2", "assert": "^2.1.0", "avsc": "^5.7.9", "bcryptjs": "^2.4.3", "bignumber.js": "^9.3.1", - "blakejs": "^1.2.1", - "bootstrap": "4.6.2", - "bootstrap-colorpicker": "^3.4.0", - "bootstrap-material-design": "^4.1.3", + "@popperjs/core": "^2.11.8", + "bootstrap": "^5.3.3", "browserify-zlib": "^0.2.0", "bson": "^4.7.2", "buffer": "^6.0.3", @@ -116,7 +95,6 @@ "codepage": "^1.15.0", "crypto-api": "^0.8.5", "crypto-browserify": "^3.12.1", - "crypto-js": "^4.2.0", "ctph.js": "0.0.5", "d3": "7.9.0", "d3-hexbin": "^0.2.2", @@ -137,7 +115,6 @@ "ieee754": "^1.2.1", "jimp": "^1.6.0", "jq-web": "^0.5.1", - "jquery": "3.7.1", "js-sha3": "^0.9.3", "jsesc": "^3.1.0", "json5": "^2.2.3", @@ -149,7 +126,6 @@ "kbpgp": "^2.1.17", "libbzip2-wasm": "0.0.4", "libyara-wasm": "^1.2.1", - "lodash": "^4.17.23", "loglevel": "^1.9.2", "loglevel-message-prefix": "^3.0.0", "lz-string": "^1.5.0", @@ -166,14 +142,12 @@ "nwmatcher": "^1.4.4", "otpauth": "9.3.6", "path": "^0.12.7", - "popper.js": "^1.16.1", "process": "^0.11.10", "protobufjs": "^7.5.4", "qr-image": "^3.2.0", "reflect-metadata": "^0.2.2", "rison": "^0.1.1", "scryptsy": "^2.1.0", - "snackbarjs": "^1.1.0", "sortablejs": "^1.15.7", "split.js": "^1.6.5", "sql-formatter": "^15.6.12", @@ -191,19 +165,38 @@ "zlibjs": "^0.3.1" }, "scripts": { - "start": "npx grunt dev", - "build": "npx grunt prod", - "node": "npx grunt node", - "repl": "node --experimental-modules --experimental-json-modules --experimental-specifier-resolution=node --no-experimental-fetch --no-warnings src/node/repl.mjs", - "test": "npx grunt configTests && node --experimental-modules --experimental-json-modules --no-warnings --no-deprecation --openssl-legacy-provider --no-experimental-fetch tests/node/index.mjs && node --experimental-modules --experimental-json-modules --no-warnings --no-deprecation --openssl-legacy-provider --no-experimental-fetch --trace-uncaught tests/operations/index.mjs", - "testnodeconsumer": "npx grunt testnodeconsumer", - "testui": "npx grunt testui", - "testuidev": "npx nightwatch --env=dev", - "lint": "npx grunt lint", + "start": "npm run dev", + "dev": "npm run clean:config && npm run generate:config && concurrently --names config,webpack \"npm run watch:config\" \"npm run dev:server\"", + "dev:server": "rspack serve --config rspack.dev.config.js", + "watch:config": "node --watch-path=src/core/operations scripts/watchConfig.mjs", + + "build": "npm run lint && npm run clean:prod && npm run clean:config && npm run generate:config && rspack build --config rspack.prod.config.js && node scripts/buildStandalone.mjs", + "build:node": "npm run clean:node && npm run clean:config && npm run clean:nodeConfig && npm run generate:config && npm run generate:nodeIndex", + "build:ghpages": "node scripts/prepareGhPages.mjs", + "build:sitemap": "node --no-warnings --no-deprecation src/web/static/sitemap.mjs > build/prod/sitemap.xml", + + "clean:dev": "rimraf build/dev/*", + "clean:prod": "rimraf build/prod/*", + "clean:node": "rimraf build/node/*", + "clean:config": "rimraf src/core/config/OperationConfig.json src/core/config/modules/* src/core/operations/index.mjs", + "clean:nodeConfig": "rimraf src/node/index.mjs src/node/config/OperationConfig.json", + + "generate:config": "echo [] > src/core/config/OperationConfig.json && node --no-warnings --no-deprecation src/core/config/scripts/generateOpsIndex.mjs && node --no-warnings --no-deprecation src/core/config/scripts/generateConfig.mjs", + "generate:nodeIndex": "node --no-warnings --no-deprecation src/node/config/scripts/generateNodeIndex.mjs", + + "test": "npm run pretest && npx vitest run", + "test:legacy": "npm run pretest && node --no-warnings --no-deprecation --openssl-legacy-provider --no-experimental-fetch tests/node/index.mjs && node --no-warnings --no-deprecation --openssl-legacy-provider --no-experimental-fetch --trace-uncaught tests/operations/index.mjs", + "pretest": "npm run clean:config && npm run clean:nodeConfig && npm run generate:config && npm run generate:nodeIndex", + "testui": "npx playwright test", + "testui:headed": "npx playwright test --headed", + "testnodeconsumer": "node scripts/testNodeConsumers.mjs", + + "lint": "eslint *.{js,mjs} src/core/**/*.{js,mjs} src/web/**/*.{js,mjs} src/node/**/*.{js,mjs} tests/**/*.{js,mjs} --ignore-pattern src/core/vendor/** --ignore-pattern src/core/operations/legacy/** --ignore-pattern src/web/static/**", "lint:grammar": "cspell ./src", - "postinstall": "npx grunt exec:fixCryptoApiImports && npx grunt exec:fixSnackbarMarkup", - "newop": "node --experimental-modules --experimental-json-modules src/core/config/scripts/newOperation.mjs", - "minor": "node --experimental-modules --experimental-json-modules src/core/config/scripts/newMinorVersion.mjs && npm version minor --git-tag-version=false && echo \"Updated to version v$(npm pkg get version | xargs), please create a pull request and once merged use 'npm run tag'\"", + + "postinstall": "node scripts/postinstall.mjs", + "newop": "node --no-warnings --no-deprecation src/core/config/scripts/newOperation.mjs", + "minor": "node --no-warnings --no-deprecation src/core/config/scripts/newMinorVersion.mjs && npm version minor --git-tag-version=false && echo \"Updated to version v$(npm pkg get version | xargs), please create a pull request and once merged use 'npm run tag'\"", "tag": "git tag -s \"v$(npm pkg get version | xargs)\" -m \"$(npm pkg get version | xargs)\" && echo \"Created v$(npm pkg get version | xargs), now check and push the tag\"", "getheapsize": "node -e 'console.log(`node heap limit = ${require(\"v8\").getHeapStatistics().heap_size_limit / (1024 * 1024)} Mb`)'", "setheapsize": "export NODE_OPTIONS=--max_old_space_size=2048" diff --git a/playwright.config.mjs b/playwright.config.mjs new file mode 100644 index 0000000000..9d4d821b40 --- /dev/null +++ b/playwright.config.mjs @@ -0,0 +1,45 @@ +/** + * Playwright configuration for CyberChef E2E tests. + * Replaces Nightwatch browser testing setup. + * + * @author CyberChef Modernization + * @copyright Crown Copyright 2023 + * @license Apache-2.0 + */ + +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests/browser", + testMatch: "**/*.spec.mjs", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: "html", + timeout: 30000, + + use: { + baseURL: "http://localhost:8080", + trace: "on-first-retry", + screenshot: "only-on-failure", + }, + + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + { + name: "firefox", + use: { ...devices["Desktop Firefox"] }, + }, + ], + + webServer: { + command: "npm run dev:server", + url: "http://localhost:8080", + reuseExistingServer: !process.env.CI, + timeout: 120000, + }, +}); diff --git a/rspack.config.js b/rspack.config.js new file mode 100644 index 0000000000..5ed96f9c6d --- /dev/null +++ b/rspack.config.js @@ -0,0 +1,207 @@ +const rspack = require("@rspack/core"); +const path = require("path"); +const zlib = require("zlib"); + +/** + * Rspack configuration for CyberChef. + * Migrated from Webpack 5 for faster build times. + * + * @author CyberChef Modernization + * @copyright Crown Copyright 2017 + * @license Apache-2.0 + */ + +const d = new Date(); +const banner = `/** + * CyberChef - The Cyber Swiss Army Knife + * + * @copyright Crown Copyright 2016-${d.getUTCFullYear()} + * @license Apache-2.0 + * + * Copyright 2016-${d.getUTCFullYear()} Crown Copyright + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */`; + + +module.exports = { + output: { + publicPath: "", + globalObject: "this", + assetModuleFilename: "assets/[hash][ext][query]" + }, + plugins: [ + new rspack.ProvidePlugin({ + log: "loglevel", + process: "process", + Buffer: ["buffer", "Buffer"] + }), + new rspack.BannerPlugin({ + banner: banner, + raw: true, + entryOnly: true + }), + new rspack.DefinePlugin({ + "process.browser": "true" + }), + new rspack.CssExtractRspackPlugin({ + filename: "assets/[name].css" + }), + new rspack.CopyRspackPlugin({ + patterns: [ + { + context: "src/core/vendor/", + from: "tesseract/**/*", + to: "assets/" + }, { + context: "node_modules/tesseract.js/", + from: "dist/worker.min.js", + to: "assets/tesseract" + }, { + context: "node_modules/tesseract.js-core/", + from: "tesseract-core.wasm.js", + to: "assets/tesseract" + }, { + context: "node_modules/node-forge/dist", + from: "prime.worker.min.js", + to: "assets/forge/" + } + ] + }), + ], + resolve: { + extensions: [".mjs", ".js", ".json"], + alias: {}, + fallback: { + "assert": require.resolve("assert/"), + "buffer": require.resolve("buffer/"), + "child_process": false, + "crypto": require.resolve("crypto-browserify"), + "events": require.resolve("events/"), + "fs": false, + "net": false, + "path": require.resolve("path/"), + "process": false, + "stream": require.resolve("stream-browserify"), + "tls": false, + "url": require.resolve("url/"), + "vm": false, + "zlib": require.resolve("browserify-zlib") + } + }, + module: { + noParse: /argon2\.wasm$/, + rules: [ + { + test: /\.m?js$/, + exclude: /node_modules\/(?!crypto-api|bootstrap)/, + loader: "builtin:swc-loader", + options: { + jsc: { + parser: { + syntax: "ecmascript", + dynamicImport: true, + importAssertions: true, + }, + target: "es2020", + }, + env: { + targets: "Chrome >= 80, Firefox >= 78, Safari >= 14", + }, + }, + type: "javascript/auto", + }, + { + test: /node-forge/, + loader: "imports-loader", + options: { + additionalCode: "var jQuery = false;" + } + }, + { + test: /argon2\.wasm$/, + loader: "base64-loader", + type: "javascript/auto" + }, + { + test: /prime.worker.min.js$/, + type: "asset/source" + }, + { + test: /blueimp-load-image/, + loader: "imports-loader", + options: { + type: "commonjs", + imports: "single min-document document" + } + }, + { + test: /\.css$/, + use: [ + { + loader: rspack.CssExtractRspackPlugin.loader, + options: { + publicPath: "../" + } + }, + "css-loader", + "postcss-loader", + ] + }, + { + test: /\.(ico|eot|ttf|woff|woff2)$/, + type: "asset/resource", + }, + { + test: /\.svg$/, + type: "asset/inline", + }, + { + test: /(\.fnt$|bmfonts\/.+\.png$)/, + type: "asset/resource", + generator: { + filename: "assets/fonts/[name][ext]" + } + }, + { + test: /\.(png|jpg|gif)$/, + exclude: /(node_modules|bmfonts)/, + type: "asset/resource", + generator: { + filename: "images/[name][ext]" + } + }, + { + test: /\.(png|jpg|gif)$/, + exclude: /web\/static/, + type: "asset/inline", + }, + ] + }, + stats: { + children: false, + chunks: false, + modules: false, + entrypoints: false + }, + ignoreWarnings: [ + /source-map/, + /source map/, + /dependency is an expression/, + /export 'default'/, + /Can't resolve 'sodium'/ + ], + performance: { + hints: false + } +}; diff --git a/rspack.dev.config.js b/rspack.dev.config.js new file mode 100644 index 0000000000..46978c7b33 --- /dev/null +++ b/rspack.dev.config.js @@ -0,0 +1,51 @@ +"use strict"; + +const rspack = require("@rspack/core"); +const baseConfig = require("./rspack.config.js"); +const { listEntryModules } = require("./scripts/listEntryModulesSync.cjs"); +const pkg = require("./package.json"); + +const d = new Date(); +const compileYear = d.getUTCFullYear().toString(); +const compileTime = `${String(d.getUTCDate()).padStart(2, "0")}/${String(d.getUTCMonth() + 1).padStart(2, "0")}/${d.getUTCFullYear()} ${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}:${String(d.getUTCSeconds()).padStart(2, "0")} UTC`; + +const BUILD_CONSTANTS = { + COMPILE_YEAR: JSON.stringify(compileYear), + COMPILE_TIME: JSON.stringify(compileTime), + COMPILE_MSG: JSON.stringify(process.env.COMPILE_MSG || ""), + PKG_VERSION: JSON.stringify(pkg.version), +}; + +const moduleEntryPoints = listEntryModules(); + +module.exports = { + ...baseConfig, + mode: "development", + target: "web", + entry: Object.assign({ + main: "./src/web/index.js" + }, moduleEntryPoints), + resolve: { + ...baseConfig.resolve, + alias: { + ...baseConfig.resolve.alias, + "./config/modules/OpModules.mjs": "./config/modules/Default.mjs" + } + }, + devServer: { + port: parseInt(process.env.PORT || "8080", 10), + client: { + logging: "error", + overlay: true + }, + hot: "only" + }, + plugins: [ + ...baseConfig.plugins, + new rspack.DefinePlugin(BUILD_CONSTANTS), + new rspack.HtmlRspackPlugin({ + filename: "index.html", + template: "./src/web/html/index.html", + }), + ] +}; diff --git a/rspack.prod.config.js b/rspack.prod.config.js new file mode 100644 index 0000000000..434a725d19 --- /dev/null +++ b/rspack.prod.config.js @@ -0,0 +1,51 @@ +"use strict"; + +const rspack = require("@rspack/core"); +const baseConfig = require("./rspack.config.js"); +const { listEntryModules } = require("./scripts/listEntryModulesSync.cjs"); +const pkg = require("./package.json"); + +const d = new Date(); +const compileYear = d.getUTCFullYear().toString(); +const compileTime = `${String(d.getUTCDate()).padStart(2, "0")}/${String(d.getUTCMonth() + 1).padStart(2, "0")}/${d.getUTCFullYear()} ${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}:${String(d.getUTCSeconds()).padStart(2, "0")} UTC`; + +const BUILD_CONSTANTS = { + COMPILE_YEAR: JSON.stringify(compileYear), + COMPILE_TIME: JSON.stringify(compileTime), + COMPILE_MSG: JSON.stringify(process.env.COMPILE_MSG || ""), + PKG_VERSION: JSON.stringify(pkg.version), +}; + +const moduleEntryPoints = listEntryModules(); + +module.exports = { + ...baseConfig, + mode: "production", + target: "web", + entry: Object.assign({ + main: "./src/web/index.js" + }, moduleEntryPoints), + output: { + ...baseConfig.output, + path: __dirname + "/build/prod", + filename: chunkData => { + return chunkData.chunk.name === "main" ? "assets/[name].js" : "[name].js"; + }, + }, + resolve: { + ...baseConfig.resolve, + alias: { + ...baseConfig.resolve.alias, + "./config/modules/OpModules.mjs": "./config/modules/Default.mjs" + } + }, + plugins: [ + ...baseConfig.plugins, + new rspack.DefinePlugin(BUILD_CONSTANTS), + new rspack.HtmlRspackPlugin({ + filename: "index.html", + template: "./src/web/html/index.html", + minify: true, + }), + ] +}; diff --git a/scripts/buildStandalone.mjs b/scripts/buildStandalone.mjs new file mode 100644 index 0000000000..d248c374a6 --- /dev/null +++ b/scripts/buildStandalone.mjs @@ -0,0 +1,86 @@ +/** + * Builds standalone CyberChef HTML file, creates zip archive, and calculates SHA256 hash. + * Replaces the Grunt tasks: copy:standalone, zip:standalone, clean:standalone, exec:calcDownloadHash + * + * @author CyberChef Modernization + * @copyright Crown Copyright 2016 + * @license Apache-2.0 + */ + +import { readFileSync, writeFileSync, unlinkSync, createReadStream, createWriteStream, readdirSync, statSync } from "fs"; +import { createHash } from "crypto"; +import { join, relative } from "path"; +import { createGzip } from "zlib"; +import { pipeline } from "stream/promises"; + +const pkg = JSON.parse(readFileSync("package.json", "utf8")); +const version = pkg.version; +const buildDir = "build/prod"; + +// Step 1: Create standalone HTML (copy:standalone equivalent) +console.log("--- Creating standalone HTML ---"); +let indexHtml = readFileSync(join(buildDir, "index.html"), "utf8"); + +// Replace download link with version number +indexHtml = indexHtml.replace(/]+>Download CyberChef.+?<\/a>/, + `Version ${version}`); + +const standaloneFilename = `CyberChef_v${version}.html`; +writeFileSync(join(buildDir, standaloneFilename), indexHtml); +console.log(`Created ${standaloneFilename}`); + +// Step 2: Create zip archive (zip:standalone equivalent) +console.log("--- Creating zip archive ---"); +const archiver = (await import("archiver")).default; +const zipFilename = `CyberChef_v${version}.zip`; +const zipPath = join(buildDir, zipFilename); + +const output = createWriteStream(zipPath); +const archive = archiver("zip", { zlib: { level: 9 } }); + +await new Promise((resolve, reject) => { + output.on("close", resolve); + archive.on("error", reject); + + archive.pipe(output); + + // Add all files from build/prod except index.html and BundleAnalyzerReport.html + const addDir = (dir) => { + const entries = readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = join(dir, entry.name); + const relPath = relative(buildDir, fullPath); + + if (entry.isDirectory()) { + addDir(fullPath); + } else if (entry.isFile()) { + if (relPath === "index.html" || relPath === "BundleAnalyzerReport.html") continue; + if (relPath.startsWith("CyberChef_v")) continue; // skip standalone files + archive.file(fullPath, { name: relPath }); + } + } + }; + + addDir(buildDir); + archive.finalize(); +}); + +console.log(`Created ${zipFilename}`); + +// Step 3: Clean standalone HTML (clean:standalone equivalent) +unlinkSync(join(buildDir, standaloneFilename)); +console.log(`Cleaned ${standaloneFilename}`); + +// Step 4: Calculate SHA256 hash (exec:calcDownloadHash equivalent) +console.log("--- Calculating SHA256 hash ---"); +const zipBuffer = readFileSync(zipPath); +const hash = createHash("sha256").update(zipBuffer).digest("hex"); +writeFileSync(join(buildDir, "sha256digest.txt"), hash); + +// Replace placeholder in index.html +let prodIndexHtml = readFileSync(join(buildDir, "index.html"), "utf8"); +prodIndexHtml = prodIndexHtml.replace(/DOWNLOAD_HASH_PLACEHOLDER/g, hash); +writeFileSync(join(buildDir, "index.html"), prodIndexHtml); + +console.log(`SHA256: ${hash}`); +console.log("--- Standalone build complete ---"); diff --git a/scripts/listEntryModules.mjs b/scripts/listEntryModules.mjs new file mode 100644 index 0000000000..506e9428ee --- /dev/null +++ b/scripts/listEntryModules.mjs @@ -0,0 +1,44 @@ +/** + * Lists all generated module entry points for Webpack/Rspack. + * Replaces the Grunt findModules task. + * + * @author CyberChef Modernization + * @copyright Crown Copyright 2017 + * @license Apache-2.0 + */ + +import { readdirSync } from "fs"; +import { basename, resolve } from "path"; + +/** + * Generates an entry list for all the modules. + * @returns {Object} Entry point mapping + */ +export function listEntryModules() { + const entryModules = {}; + const modulesDir = "./src/core/config/modules"; + + try { + const files = readdirSync(modulesDir).filter(f => f.endsWith(".mjs")); + for (const file of files) { + if (file !== "Default.mjs" && file !== "OpModules.mjs") { + const name = basename(file, ".mjs"); + entryModules["modules/" + name] = resolve(modulesDir, file); + } + } + } catch (e) { + // Modules directory may not exist yet during initial config generation + console.warn("Warning: Could not read modules directory:", e.message); + } + + return entryModules; +} + +// When run directly, print the module list +if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith("listEntryModules.mjs")) { + const modules = listEntryModules(); + console.log(`Found ${Object.keys(modules).length} modules:`); + for (const [name, path] of Object.entries(modules)) { + console.log(` ${name}: ${path}`); + } +} diff --git a/scripts/listEntryModulesSync.cjs b/scripts/listEntryModulesSync.cjs new file mode 100644 index 0000000000..6785c76f3f --- /dev/null +++ b/scripts/listEntryModulesSync.cjs @@ -0,0 +1,32 @@ +/** + * Lists all generated module entry points for Webpack/Rspack (CommonJS version). + * Used by webpack config files which must be CommonJS. + * + * @author CyberChef Modernization + * @copyright Crown Copyright 2017 + * @license Apache-2.0 + */ + +const { readdirSync } = require("fs"); +const { basename, resolve } = require("path"); + +function listEntryModules() { + const entryModules = {}; + const modulesDir = "./src/core/config/modules"; + + try { + const files = readdirSync(modulesDir).filter(f => f.endsWith(".mjs")); + for (const file of files) { + if (file !== "Default.mjs" && file !== "OpModules.mjs") { + const name = basename(file, ".mjs"); + entryModules["modules/" + name] = resolve(modulesDir, file); + } + } + } catch (e) { + console.warn("Warning: Could not read modules directory:", e.message); + } + + return entryModules; +} + +module.exports = { listEntryModules }; diff --git a/scripts/postinstall.mjs b/scripts/postinstall.mjs new file mode 100644 index 0000000000..9014f890a6 --- /dev/null +++ b/scripts/postinstall.mjs @@ -0,0 +1,81 @@ +/** + * Cross-platform postinstall script. + * Fixes known issues in dependencies without requiring platform-specific `sed`. + * Replaces Grunt exec:fixCryptoApiImports and exec:fixSnackbarMarkup. + * + * @author CyberChef Modernization + * @copyright Crown Copyright 2017 + * @license Apache-2.0 + */ + +import { readFileSync, writeFileSync, readdirSync, statSync } from "fs"; +import { join, extname } from "path"; + +/** + * Recursively find all files in a directory. + */ +function findFiles(dir, files = []) { + try { + const entries = readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = join(dir, entry.name); + if (entry.isDirectory() && entry.name !== ".git") { + findFiles(fullPath, files); + } else if (entry.isFile()) { + files.push(fullPath); + } + } + } catch { + // Directory may not exist + } + return files; +} + +// Fix 1: crypto-api imports - add .mjs extensions to relative imports +console.log("Fixing crypto-api imports..."); +const cryptoApiDir = join("node_modules", "crypto-api", "src"); +let cryptoApiFixed = 0; + +try { + const files = findFiles(cryptoApiDir); + for (const file of files) { + let content = readFileSync(file, "utf8"); + const original = content; + + // Add .mjs extension to relative imports that don't already have it + content = content.replace(/from\s+"(\.[^"]*?)(? { + // Don't add .mjs if it already has a file extension + if (extname(importPath) !== "") return match; + return `from "${importPath}.mjs";`; + }); + + if (content !== original) { + writeFileSync(file, content); + cryptoApiFixed++; + } + } + console.log(` Fixed ${cryptoApiFixed} files in crypto-api.`); +} catch (e) { + console.log(` Skipping crypto-api fix (not installed or not found): ${e.message}`); +} + +// Fix 2: snackbarjs - fix self-closing div +console.log("Fixing snackbarjs markup..."); +const snackbarFile = join("node_modules", "snackbarjs", "src", "snackbar.js"); + +try { + let content = readFileSync(snackbarFile, "utf8"); + const original = content; + content = content.replace(/
/g, "
"); + + if (content !== original) { + writeFileSync(snackbarFile, content); + console.log(" Fixed snackbarjs self-closing div."); + } else { + console.log(" snackbarjs already fixed or pattern not found."); + } +} catch (e) { + console.log(` Skipping snackbarjs fix (not installed or not found): ${e.message}`); +} + +console.log("Postinstall complete."); diff --git a/scripts/prepareGhPages.mjs b/scripts/prepareGhPages.mjs new file mode 100644 index 0000000000..bca2b10f80 --- /dev/null +++ b/scripts/prepareGhPages.mjs @@ -0,0 +1,32 @@ +/** + * Prepares build/prod/index.html for GitHub Pages deployment. + * Adds Google Analytics and Structured Data. + * Replaces the Grunt copy:ghPages task. + * + * @author CyberChef Modernization + * @copyright Crown Copyright 2017 + * @license Apache-2.0 + */ + +import { readFileSync, writeFileSync } from "fs"; +import { join } from "path"; + +const buildDir = "build/prod"; + +console.log("--- Preparing for GitHub Pages ---"); + +let indexHtml = readFileSync(join(buildDir, "index.html"), "utf8"); + +// Add Google Analytics code +const gaHtml = readFileSync("src/web/static/ga.html", "utf8"); +indexHtml = indexHtml.replace("", gaHtml + ""); + +// Add Structured Data for SEO +const structuredData = JSON.parse(readFileSync("src/web/static/structuredData.json", "utf8")); +indexHtml = indexHtml.replace("", + ""); + +writeFileSync(join(buildDir, "index.html"), indexHtml); +console.log("--- GitHub Pages preparation complete ---"); diff --git a/scripts/testNodeConsumers.mjs b/scripts/testNodeConsumers.mjs new file mode 100644 index 0000000000..d1ef15107d --- /dev/null +++ b/scripts/testNodeConsumers.mjs @@ -0,0 +1,51 @@ +/** + * Tests that CyberChef can be consumed as both CJS and ESM modules. + * Replaces Grunt exec:setupNodeConsumers, testCJSNodeConsumer, testESMNodeConsumer, teardownNodeConsumers. + * + * @author CyberChef Modernization + * @copyright Crown Copyright 2017 + * @license Apache-2.0 + */ + +import { execSync } from "child_process"; +import { mkdirSync, cpSync, rmSync, existsSync } from "fs"; +import { join } from "path"; +import { homedir } from "os"; + +const testPath = join(homedir(), "tmp-cyberchef"); +const nodeFlags = "--no-warnings --no-deprecation"; + +try { + console.log("\n--- Testing node consumers ---"); + + // Setup + execSync("npm link", { stdio: "inherit" }); + mkdirSync(testPath, { recursive: true }); + + // Copy consumer test files + const consumersDir = "tests/node/consumers"; + cpSync(consumersDir, testPath, { recursive: true }); + + // Link cyberchef in the test directory + execSync("npm link cyberchef", { cwd: testPath, stdio: "inherit" }); + + // Test CJS consumer + console.log("Testing CJS consumer..."); + execSync(`node ${nodeFlags} cjs-consumer.js`, { cwd: testPath, stdio: "pipe" }); + console.log("CJS consumer test passed."); + + // Test ESM consumer + console.log("Testing ESM consumer..."); + execSync(`node ${nodeFlags} esm-consumer.mjs`, { cwd: testPath, stdio: "pipe" }); + console.log("ESM consumer test passed."); + + console.log("\n--- Node consumer tests complete ---"); +} catch (e) { + console.error("Node consumer test failed:", e.message); + process.exitCode = 1; +} finally { + // Teardown + if (existsSync(testPath)) { + rmSync(testPath, { recursive: true, force: true }); + } +} diff --git a/scripts/watchConfig.mjs b/scripts/watchConfig.mjs new file mode 100644 index 0000000000..73ed9d4a4e --- /dev/null +++ b/scripts/watchConfig.mjs @@ -0,0 +1,43 @@ +/** + * Watches for changes in operations and regenerates config files. + * Replaces Grunt watch:config task. + * Uses Node.js --watch-path flag for file watching. + * + * @author CyberChef Modernization + * @copyright Crown Copyright 2017 + * @license Apache-2.0 + */ + +import { execSync } from "child_process"; +import { watch } from "fs"; +import { join } from "path"; + +const opsDir = "src/core/operations"; +let debounceTimer = null; + +function regenerateConfig() { + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + console.log("\n--- Regenerating config files ---"); + try { + execSync("node --no-warnings --no-deprecation src/node/config/scripts/generateNodeIndex.mjs", { stdio: "inherit" }); + execSync("node --no-warnings --no-deprecation src/core/config/scripts/generateOpsIndex.mjs", { stdio: "inherit" }); + execSync("node --no-warnings --no-deprecation src/core/config/scripts/generateConfig.mjs", { stdio: "inherit" }); + console.log("--- Config regenerated ---\n"); + } catch (e) { + console.error("Config generation failed:", e.message); + } + }, 500); +} + +console.log(`Watching ${opsDir} for changes...`); + +watch(opsDir, { recursive: true }, (eventType, filename) => { + if (filename && filename !== "index.mjs") { + console.log(`Change detected: ${filename}`); + regenerateConfig(); + } +}); + +// Keep process alive +process.on("SIGINT", () => process.exit(0)); diff --git a/src/core/ChefWorker.js b/src/core/ChefWorker.js index a43993f925..3f56d855ed 100644 --- a/src/core/ChefWorker.js +++ b/src/core/ChefWorker.js @@ -7,7 +7,7 @@ */ import Chef from "./Chef.mjs"; -import OperationConfig from "./config/OperationConfig.json" assert {type: "json"}; +import OperationConfig from "./config/OperationConfig.json" with {type: "json"}; import OpModules from "./config/modules/OpModules.mjs"; import loglevelMessagePrefix from "loglevel-message-prefix"; diff --git a/src/core/Recipe.mjs b/src/core/Recipe.mjs index b4a10e03a3..c359253e56 100755 --- a/src/core/Recipe.mjs +++ b/src/core/Recipe.mjs @@ -4,7 +4,7 @@ * @license Apache-2.0 */ -import OperationConfig from "./config/OperationConfig.json" assert {type: "json"}; +import OperationConfig from "./config/OperationConfig.json" with {type: "json"}; import OperationError from "./errors/OperationError.mjs"; import Operation from "./Operation.mjs"; import DishError from "./errors/DishError.mjs"; diff --git a/src/core/lib/CaseConvert.mjs b/src/core/lib/CaseConvert.mjs new file mode 100644 index 0000000000..072347ae32 --- /dev/null +++ b/src/core/lib/CaseConvert.mjs @@ -0,0 +1,56 @@ +/** + * Case conversion utilities. + * Replaces lodash/camelCase, lodash/kebabCase, lodash/snakeCase. + * + * @author CyberChef Modernization + * @copyright Crown Copyright 2017 + * @license Apache-2.0 + */ + +/** + * Splits a string into words, handling camelCase, PascalCase, snake_case, + * kebab-case, spaces, and mixed separators. + * + * @param {string} str + * @returns {string[]} + */ +function splitWords(str) { + if (!str) return []; + // Insert boundary before uppercase letters following lowercase or digits + return str + .replace(/([a-z\d])([A-Z])/g, "$1\0$2") + .replace(/([A-Z]+)([A-Z][a-z])/g, "$1\0$2") + .split(/[\0\s_\-./\\]+/) + .filter(Boolean) + .map(w => w.toLowerCase()); +} + +/** + * Converts a string to camelCase. + * @param {string} str + * @returns {string} + */ +export function camelCase(str) { + const words = splitWords(str); + return words + .map((w, i) => i === 0 ? w : w.charAt(0).toUpperCase() + w.slice(1)) + .join(""); +} + +/** + * Converts a string to kebab-case. + * @param {string} str + * @returns {string} + */ +export function kebabCase(str) { + return splitWords(str).join("-"); +} + +/** + * Converts a string to snake_case. + * @param {string} str + * @returns {string} + */ +export function snakeCase(str) { + return splitWords(str).join("_"); +} diff --git a/src/core/lib/Ciphers.mjs b/src/core/lib/Ciphers.mjs index 6266a8e1de..71eddfad78 100644 --- a/src/core/lib/Ciphers.mjs +++ b/src/core/lib/Ciphers.mjs @@ -12,7 +12,6 @@ import OperationError from "../errors/OperationError.mjs"; import Utils from "../Utils.mjs"; -import CryptoJS from "crypto-js"; /** * Affine Cipher Encode operation. @@ -71,18 +70,3 @@ export function genPolybiusSquare (keyword) { return polybius; } -/** - * A mapping of string formats to their classes in the CryptoJS library. - * - * @private - * @constant - */ -export const format = { - "Hex": CryptoJS.enc.Hex, - "Base64": CryptoJS.enc.Base64, - "UTF8": CryptoJS.enc.Utf8, - "UTF16": CryptoJS.enc.Utf16, - "UTF16LE": CryptoJS.enc.Utf16LE, - "UTF16BE": CryptoJS.enc.Utf16BE, - "Latin1": CryptoJS.enc.Latin1, -}; diff --git a/src/core/lib/Hash.mjs b/src/core/lib/Hash.mjs index d572b8b092..dc9bf8228a 100644 --- a/src/core/lib/Hash.mjs +++ b/src/core/lib/Hash.mjs @@ -8,11 +8,53 @@ */ import Utils from "../Utils.mjs"; +import { md5, sha1, ripemd160 } from "@noble/hashes/legacy"; +import { sha224, sha256, sha384, sha512, sha512_224, sha512_256 } from "@noble/hashes/sha2"; +import { sha3_224, sha3_256, sha3_384, sha3_512 } from "@noble/hashes/sha3"; +import { hmac } from "@noble/hashes/hmac"; +import { hkdf } from "@noble/hashes/hkdf"; +import { bytesToHex } from "@noble/hashes/utils"; import CryptoApi from "crypto-api/src/crypto-api.mjs"; +/** + * Map of hash algorithm names (lowercased) to noble hash functions. + */ +const NOBLE_HASH_FUNCTIONS = { + "md5": md5, + "sha1": sha1, + "sha224": sha224, + "sha256": sha256, + "sha384": sha384, + "sha512": sha512, + "sha512/224": sha512_224, + "sha512/256": sha512_256, + "sha3-224": sha3_224, + "sha3-256": sha3_256, + "sha3-384": sha3_384, + "sha3-512": sha3_512, + "ripemd160": ripemd160, + // Legacy aliases + "sha-1": sha1, + "sha-224": sha224, + "sha-256": sha256, + "sha-384": sha384, + "sha-512": sha512, +}; + +/** + * Gets a noble hash function by name. Returns null if not supported by noble. + * + * @param {string} name - Hash algorithm name (case-insensitive) + * @returns {Function|null} Noble hash function or null + */ +export function getHashFunction(name) { + const normalizedName = name.toLowerCase().replace(/\s+/g, ""); + return NOBLE_HASH_FUNCTIONS[normalizedName] || null; +} /** * Generic hash function. + * Uses @noble/hashes for common algorithms, falls back to crypto-api for legacy ones. * * @param {string} name * @param {ArrayBuffer} input @@ -20,9 +62,19 @@ import CryptoApi from "crypto-api/src/crypto-api.mjs"; * @returns {string} */ export function runHash(name, input, options={}) { + const hashFn = getHashFunction(name); + + if (hashFn) { + // Use noble for supported algorithms + const data = new Uint8Array(input); + return bytesToHex(hashFn(data)); + } + + // Fall back to crypto-api for legacy algorithms (Snefru, Whirlpool, MD2, MD4, SHA0, HAS160, etc.) const msg = Utils.arrayBufferToStr(input, false), hasher = CryptoApi.getHasher(name, options); hasher.update(msg); return CryptoApi.encoder.toHex(hasher.finalize()); } +export { hmac, hkdf, bytesToHex }; diff --git a/src/core/lib/Magic.mjs b/src/core/lib/Magic.mjs index 14111ec733..d1a0f2d08f 100644 --- a/src/core/lib/Magic.mjs +++ b/src/core/lib/Magic.mjs @@ -1,4 +1,4 @@ -import OperationConfig from "../config/OperationConfig.json" assert {type: "json"}; +import OperationConfig from "../config/OperationConfig.json" with {type: "json"}; import Utils, { isWorkerEnvironment } from "../Utils.mjs"; import Recipe from "../Recipe.mjs"; import Dish from "../Dish.mjs"; diff --git a/src/core/lib/RC4.mjs b/src/core/lib/RC4.mjs new file mode 100644 index 0000000000..fbb7b962c3 --- /dev/null +++ b/src/core/lib/RC4.mjs @@ -0,0 +1,74 @@ +/** + * RC4 stream cipher implementation. + * Replaces crypto-js RC4 for CyberChef operations. + * + * @author CyberChef Modernization + * @copyright Crown Copyright 2016 + * @license Apache-2.0 + */ + +/** + * RC4 Key Scheduling Algorithm (KSA). + * + * @param {Uint8Array} key + * @returns {Uint8Array} The initialized S-box + */ +function ksa(key) { + const S = new Uint8Array(256); + for (let i = 0; i < 256; i++) S[i] = i; + + let j = 0; + for (let i = 0; i < 256; i++) { + j = (j + S[i] + key[i % key.length]) & 0xFF; + [S[i], S[j]] = [S[j], S[i]]; + } + return S; +} + +/** + * RC4 Pseudo-Random Generation Algorithm (PRGA). + * + * @param {Uint8Array} S - The S-box from KSA + * @param {number} length - Number of keystream bytes to generate + * @param {number} [drop=0] - Number of initial bytes to drop + * @returns {Uint8Array} Keystream bytes + */ +function prga(S, length, drop = 0) { + const output = new Uint8Array(length); + let i = 0, j = 0; + + // Drop initial bytes + for (let d = 0; d < drop; d++) { + i = (i + 1) & 0xFF; + j = (j + S[i]) & 0xFF; + [S[i], S[j]] = [S[j], S[i]]; + } + + // Generate keystream + for (let k = 0; k < length; k++) { + i = (i + 1) & 0xFF; + j = (j + S[i]) & 0xFF; + [S[i], S[j]] = [S[j], S[i]]; + output[k] = S[(S[i] + S[j]) & 0xFF]; + } + return output; +} + +/** + * Encrypt/decrypt data using RC4. + * RC4 is symmetric — encryption and decryption are the same operation. + * + * @param {Uint8Array} data - Input data + * @param {Uint8Array} key - Key bytes + * @param {number} [drop=0] - Number of initial keystream bytes to drop (for RC4-drop) + * @returns {Uint8Array} Output data + */ +export function rc4(data, key, drop = 0) { + const S = ksa(key); + const keystream = prga(S, data.length, drop); + const output = new Uint8Array(data.length); + for (let i = 0; i < data.length; i++) { + output[i] = data[i] ^ keystream[i]; + } + return output; +} diff --git a/src/core/operations/BLAKE2b.mjs b/src/core/operations/BLAKE2b.mjs index 6218f7f083..934a116813 100644 --- a/src/core/operations/BLAKE2b.mjs +++ b/src/core/operations/BLAKE2b.mjs @@ -5,7 +5,8 @@ */ import Operation from "../Operation.mjs"; -import blakejs from "blakejs"; +import { blake2b } from "@noble/hashes/blake2b"; +import { bytesToHex } from "@noble/hashes/utils"; import OperationError from "../errors/OperationError.mjs"; import Utils from "../Utils.mjs"; import { toBase64 } from "../lib/Base64.mjs"; @@ -23,7 +24,7 @@ class BLAKE2b extends Operation { this.name = "BLAKE2b"; this.module = "Hashing"; - this.description = `Performs BLAKE2b hashing on the input. + this.description = `Performs BLAKE2b hashing on the input.

BLAKE2b is a flavour of the BLAKE cryptographic hash function that is optimized for 64-bit platforms and produces digests of any size between 1 and 64 bytes.

Supports the use of an optional key.`; this.infoURL = "https://wikipedia.org/wiki/BLAKE_(hash_function)#BLAKE2b_algorithm"; @@ -56,19 +57,27 @@ class BLAKE2b extends Operation { const [outSize, outFormat] = args; let key = Utils.convertToByteArray(args[2].string || "", args[2].option); if (key.length === 0) { - key = null; + key = undefined; } else if (key.length > 64) { throw new OperationError(["Key cannot be greater than 64 bytes", "It is currently " + key.length + " bytes."].join("\n")); + } else { + key = new Uint8Array(key); } - input = new Uint8Array(input); + const data = new Uint8Array(input); + const dkLen = outSize / 8; + const opts = { dkLen }; + if (key) opts.key = key; + + const hash = blake2b(data, opts); + switch (outFormat) { case "Hex": - return blakejs.blake2bHex(input, key, outSize / 8); + return bytesToHex(hash); case "Base64": - return toBase64(blakejs.blake2b(input, key, outSize / 8)); + return toBase64(hash); case "Raw": - return Utils.arrayBufferToStr(blakejs.blake2b(input, key, outSize / 8).buffer); + return Utils.arrayBufferToStr(hash.buffer); default: return new OperationError("Unsupported Output Type"); } diff --git a/src/core/operations/BLAKE2s.mjs b/src/core/operations/BLAKE2s.mjs index 8f84e04142..6b24688ded 100644 --- a/src/core/operations/BLAKE2s.mjs +++ b/src/core/operations/BLAKE2s.mjs @@ -5,7 +5,8 @@ */ import Operation from "../Operation.mjs"; -import blakejs from "blakejs"; +import { blake2s } from "@noble/hashes/blake2s"; +import { bytesToHex } from "@noble/hashes/utils"; import OperationError from "../errors/OperationError.mjs"; import Utils from "../Utils.mjs"; import { toBase64 } from "../lib/Base64.mjs"; @@ -23,7 +24,7 @@ class BLAKE2s extends Operation { this.name = "BLAKE2s"; this.module = "Hashing"; - this.description = `Performs BLAKE2s hashing on the input. + this.description = `Performs BLAKE2s hashing on the input.

BLAKE2s is a flavour of the BLAKE cryptographic hash function that is optimized for 8- to 32-bit platforms and produces digests of any size between 1 and 32 bytes.

Supports the use of an optional key.`; this.infoURL = "https://wikipedia.org/wiki/BLAKE_(hash_function)#BLAKE2"; @@ -57,19 +58,27 @@ class BLAKE2s extends Operation { const [outSize, outFormat] = args; let key = Utils.convertToByteArray(args[2].string || "", args[2].option); if (key.length === 0) { - key = null; + key = undefined; } else if (key.length > 32) { throw new OperationError(["Key cannot be greater than 32 bytes", "It is currently " + key.length + " bytes."].join("\n")); + } else { + key = new Uint8Array(key); } - input = new Uint8Array(input); + const data = new Uint8Array(input); + const dkLen = outSize / 8; + const opts = { dkLen }; + if (key) opts.key = key; + + const hash = blake2s(data, opts); + switch (outFormat) { case "Hex": - return blakejs.blake2sHex(input, key, outSize / 8); + return bytesToHex(hash); case "Base64": - return toBase64(blakejs.blake2s(input, key, outSize / 8)); + return toBase64(hash); case "Raw": - return Utils.arrayBufferToStr(blakejs.blake2s(input, key, outSize / 8).buffer); + return Utils.arrayBufferToStr(hash.buffer); default: return new OperationError("Unsupported Output Type"); } diff --git a/src/core/operations/DeriveEVPKey.mjs b/src/core/operations/DeriveEVPKey.mjs index 3d67aa512b..c9655a612a 100644 --- a/src/core/operations/DeriveEVPKey.mjs +++ b/src/core/operations/DeriveEVPKey.mjs @@ -6,7 +6,58 @@ import Operation from "../Operation.mjs"; import Utils from "../Utils.mjs"; -import CryptoJS from "crypto-js"; +import { md5, sha1 } from "@noble/hashes/legacy"; +import { sha256, sha384, sha512 } from "@noble/hashes/sha2"; +import { bytesToHex } from "@noble/hashes/utils"; + +/** + * Map of hash function names to noble implementations. + */ +const HASH_MAP = { + "MD5": md5, + "SHA1": sha1, + "SHA256": sha256, + "SHA384": sha384, + "SHA512": sha512, +}; + +/** + * EVP_BytesToKey key derivation function (OpenSSL). + * Derives key material from password + salt using iterated hashing. + * + * @param {Uint8Array} password + * @param {Uint8Array} salt + * @param {number} keySize - Key size in bytes + * @param {number} iterations + * @param {Function} hashFn - Noble hash function + * @returns {Uint8Array} + */ +function evpKDF(password, salt, keySize, iterations, hashFn) { + let derivedKey = new Uint8Array(0); + let block = new Uint8Array(0); + + while (derivedKey.length < keySize) { + // Concatenate previous block + password + salt + const input = new Uint8Array(block.length + password.length + salt.length); + input.set(block, 0); + input.set(password, block.length); + input.set(salt, block.length + password.length); + + // Hash with iterations + block = hashFn(input); + for (let i = 1; i < iterations; i++) { + block = hashFn(block); + } + + // Append to derived key + const combined = new Uint8Array(derivedKey.length + block.length); + combined.set(derivedKey, 0); + combined.set(block, derivedKey.length); + derivedKey = combined; + } + + return derivedKey.slice(0, keySize); +} /** * Derive EVP key operation @@ -62,86 +113,28 @@ class DeriveEVPKey extends Operation { * @returns {string} */ run(input, args) { - const passphrase = CryptoJS.enc.Latin1.parse( - Utils.convertToByteString(args[0].string, args[0].option)), - keySize = args[1] / 32, - iterations = args[2], - hasher = args[3], - salt = CryptoJS.enc.Latin1.parse( - Utils.convertToByteString(args[4].string, args[4].option)), - key = CryptoJS.EvpKDF(passphrase, salt, { // lgtm [js/insufficient-password-hash] - keySize: keySize, - hasher: CryptoJS.algo[hasher], - iterations: iterations, - }); - - return key.toString(CryptoJS.enc.Hex); + const passStr = Utils.convertToByteString(args[0].string, args[0].option); + const keySize = args[1] / 8; // Convert bits to bytes + const iterations = args[2]; + const hasherName = args[3]; + const saltStr = Utils.convertToByteString(args[4].string, args[4].option); + + const hashFn = HASH_MAP[hasherName]; + if (!hashFn) { + throw new Error(`Unsupported hash function: ${hasherName}`); + } + + // Convert strings to Uint8Array + const password = new Uint8Array(passStr.length); + for (let i = 0; i < passStr.length; i++) password[i] = passStr.charCodeAt(i) & 0xFF; + + const salt = new Uint8Array(saltStr.length); + for (let i = 0; i < saltStr.length; i++) salt[i] = saltStr.charCodeAt(i) & 0xFF; + + const key = evpKDF(password, salt, keySize, iterations, hashFn); + return bytesToHex(key); } } export default DeriveEVPKey; - -/** - * Overwriting the CryptoJS OpenSSL key derivation function so that it is possible to not pass a - * salt in. - - * @param {string} password - The password to derive from. - * @param {number} keySize - The size in words of the key to generate. - * @param {number} ivSize - The size in words of the IV to generate. - * @param {WordArray|string} salt (Optional) A 64-bit salt to use. If omitted, a salt will be - * generated randomly. If set to false, no salt will be added. - * - * @returns {CipherParams} A cipher params object with the key, IV, and salt. - * - * @static - * - * @example - * // Randomly generates a salt - * var derivedParams = CryptoJS.kdf.OpenSSL.execute('Password', 256/32, 128/32); - * // Uses the salt 'saltsalt' - * var derivedParams = CryptoJS.kdf.OpenSSL.execute('Password', 256/32, 128/32, 'saltsalt'); - * // Does not use a salt - * var derivedParams = CryptoJS.kdf.OpenSSL.execute('Password', 256/32, 128/32, false); - */ -CryptoJS.kdf.OpenSSL.execute = function (password, keySize, ivSize, salt) { - // Generate random salt if no salt specified and not set to false - // This line changed from `if (!salt) {` to the following - if (salt === undefined || salt === null) { - salt = CryptoJS.lib.WordArray.random(64/8); - } - - // Derive key and IV - const key = CryptoJS.algo.EvpKDF.create({ keySize: keySize + ivSize }).compute(password, salt); - - // Separate key and IV - const iv = CryptoJS.lib.WordArray.create(key.words.slice(keySize), ivSize * 4); - key.sigBytes = keySize * 4; - - // Return params - return CryptoJS.lib.CipherParams.create({ key: key, iv: iv, salt: salt }); -}; - - -/** - * Override for the CryptoJS Hex encoding parser to remove whitespace before attempting to parse - * the hex string. - * - * @param {string} hexStr - * @returns {CryptoJS.lib.WordArray} - */ -CryptoJS.enc.Hex.parse = function (hexStr) { - // Remove whitespace - hexStr = hexStr.replace(/\s/g, ""); - - // Shortcut - const hexStrLength = hexStr.length; - - // Convert - const words = []; - for (let i = 0; i < hexStrLength; i += 2) { - words[i >>> 3] |= parseInt(hexStr.substr(i, 2), 16) << (24 - (i % 8) * 4); - } - - return new CryptoJS.lib.WordArray.init(words, hexStrLength / 2); -}; diff --git a/src/core/operations/FlaskSessionSign.mjs b/src/core/operations/FlaskSessionSign.mjs index 01ee8b1d1a..987852af13 100644 --- a/src/core/operations/FlaskSessionSign.mjs +++ b/src/core/operations/FlaskSessionSign.mjs @@ -4,10 +4,11 @@ */ import Operation from "../Operation.mjs"; -import CryptoApi from "crypto-api/src/crypto-api.mjs"; import Utils from "../Utils.mjs"; import { toBase64 } from "../lib/Base64.mjs"; import OperationError from "../errors/OperationError.mjs"; +import { getHashFunction } from "../lib/Hash.mjs"; +import { hmac } from "@noble/hashes/hmac"; /** * Flask Session Sign operation @@ -57,12 +58,15 @@ class FlaskSessionSign extends Operation { const key = Utils.convertToByteString(args[0].string, args[0].option); const salt = Utils.convertToByteString(args[1].string || "cookie-session", args[1].option); const algorithm = args[2] || "sha1"; + const hashFn = getHashFunction(algorithm); const payloadB64 = toBase64(Utils.strToByteArray(JSON.stringify(input))); const payload = payloadB64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); - const derivedKey = CryptoApi.getHmac(key, CryptoApi.getHasher(algorithm)); - derivedKey.update(salt); + // Derive key: HMAC(secret, salt) + const keyBytes = new Uint8Array(Utils.strToArrayBuffer(key)); + const saltBytes = new Uint8Array(Utils.strToArrayBuffer(salt)); + const derivedKeyBytes = hmac(hashFn, keyBytes, saltBytes); const currentTimeStamp = Math.ceil(Date.now() / 1000); const buffer = new ArrayBuffer(4); @@ -75,10 +79,15 @@ class FlaskSessionSign extends Operation { const time = timeB64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); const data = Utils.convertToByteString(payload + "." + time, "utf8"); - const sign = CryptoApi.getHmac(derivedKey.finalize(), CryptoApi.getHasher(algorithm)); - sign.update(data); + const dataBytes = new Uint8Array(Utils.strToArrayBuffer(data)); - const signB64 = toBase64(sign.finalize()); + // Sign: HMAC(derivedKey, data) + const signBytes = hmac(hashFn, derivedKeyBytes, dataBytes); + + // Convert Uint8Array back to byte string for toBase64 + let signStr = ""; + signBytes.forEach(b => signStr += String.fromCharCode(b)); + const signB64 = toBase64(Utils.strToByteArray(signStr)); const sign64 = signB64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); return payload + "." + time + "." + sign64; diff --git a/src/core/operations/FlaskSessionVerify.mjs b/src/core/operations/FlaskSessionVerify.mjs index 7603ba1f25..825c258abf 100644 --- a/src/core/operations/FlaskSessionVerify.mjs +++ b/src/core/operations/FlaskSessionVerify.mjs @@ -5,9 +5,10 @@ import Operation from "../Operation.mjs"; import OperationError from "../errors/OperationError.mjs"; -import CryptoApi from "crypto-api/src/crypto-api.mjs"; import Utils from "../Utils.mjs"; import { toBase64, fromBase64 } from "../lib/Base64.mjs"; +import { getHashFunction } from "../lib/Hash.mjs"; +import { hmac } from "@noble/hashes/hmac"; /** * Flask Session Verify operation @@ -64,6 +65,7 @@ class FlaskSessionVerify extends Operation { const key = Utils.convertToByteString(args[0].string, args[0].option); const salt = Utils.convertToByteString(args[1].string || "cookie-session", args[1].option); const algorithm = args[2] || "sha1"; + const hashFn = getHashFunction(algorithm); input = input.trim(); @@ -75,12 +77,14 @@ class FlaskSessionVerify extends Operation { const data = Utils.convertToByteString(parts[0] + "." + parts[1], "utf8"); + // Derive key: HMAC(secret, salt) + const keyBytes = new Uint8Array(Utils.strToArrayBuffer(key)); + const saltBytes = new Uint8Array(Utils.strToArrayBuffer(salt)); + const derivedKeyBytes = hmac(hashFn, keyBytes, saltBytes); - const derivedKey = CryptoApi.getHmac(key, CryptoApi.getHasher(algorithm)); - derivedKey.update(salt); - - const sign = CryptoApi.getHmac(derivedKey.finalize(), CryptoApi.getHasher(algorithm)); - sign.update(data); + // Sign: HMAC(derivedKey, data) + const dataBytes = new Uint8Array(Utils.strToArrayBuffer(data)); + const signBytes = hmac(hashFn, derivedKeyBytes, dataBytes); const payloadB64 = parts[0]; const base64 = payloadB64.replace(/-/g, "+").replace(/_/g, "/"); @@ -104,7 +108,10 @@ class FlaskSessionVerify extends Operation { throw new OperationError("Invalid Base64 payload"); } - const signB64 = toBase64(sign.finalize()); + // Convert Uint8Array back to byte string for toBase64 + let signStr = ""; + signBytes.forEach(b => signStr += String.fromCharCode(b)); + const signB64 = toBase64(Utils.strToByteArray(signStr)); const sign64 = signB64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); if (sign64 !== parts[2]) { diff --git a/src/core/operations/Magic.mjs b/src/core/operations/Magic.mjs index 69cad1db1f..6d52f49be9 100644 --- a/src/core/operations/Magic.mjs +++ b/src/core/operations/Magic.mjs @@ -154,7 +154,7 @@ class Magic extends Operation { `; }); - output += ""; + output += ""; if (!options.length) { output = "Nothing of interest could be detected about the input data.\nHave you tried modifying the operation arguments?"; diff --git a/src/core/operations/ParseColourCode.mjs b/src/core/operations/ParseColourCode.mjs index 31e575a1b5..ad55ba5799 100644 --- a/src/core/operations/ParseColourCode.mjs +++ b/src/core/operations/ParseColourCode.mjs @@ -104,17 +104,23 @@ HSL: ${hsl} HSLA: ${hsla} CMYK: ${cmyk} `; } diff --git a/src/core/operations/RC4.mjs b/src/core/operations/RC4.mjs index 183db74271..dd3d108612 100644 --- a/src/core/operations/RC4.mjs +++ b/src/core/operations/RC4.mjs @@ -5,8 +5,10 @@ */ import Operation from "../Operation.mjs"; -import CryptoJS from "crypto-js"; -import { format } from "../lib/Ciphers.mjs"; +import Utils from "../Utils.mjs"; +import { rc4 } from "../lib/RC4.mjs"; +import { toHex, fromHex } from "../lib/Hex.mjs"; +import { toBase64, fromBase64 } from "../lib/Base64.mjs"; /** * RC4 operation @@ -51,11 +53,16 @@ class RC4 extends Operation { * @returns {string} */ run(input, args) { - const message = format[args[1]].parse(input), - passphrase = format[args[0].option].parse(args[0].string), - encrypted = CryptoJS.RC4.encrypt(message, passphrase); + const inputFormat = args[1]; + const outputFormat = args[2]; + const keyFormat = args[0].option; + const keyStr = args[0].string; - return encrypted.ciphertext.toString(format[args[2]]); + const inputBytes = formatParse(input, inputFormat); + const keyBytes = formatParse(keyStr, keyFormat); + const outputBytes = rc4(inputBytes, keyBytes); + + return formatStringify(outputBytes, outputFormat); } /** @@ -86,4 +93,90 @@ class RC4 extends Operation { } +/** + * Parse a string in the given format to Uint8Array. + * + * @param {string} str + * @param {string} format + * @returns {Uint8Array} + */ +function formatParse(str, format) { + switch (format) { + case "Hex": + return new Uint8Array(fromHex(str, "Auto")); + case "Base64": + return Utils.strToByteArray(fromBase64(str)); + case "UTF8": + return new TextEncoder().encode(str); + case "Latin1": + default: { + const bytes = new Uint8Array(str.length); + for (let i = 0; i < str.length; i++) { + bytes[i] = str.charCodeAt(i) & 0xFF; + } + return bytes; + } + case "UTF16": + case "UTF16BE": { + const bytes = new Uint8Array(str.length * 2); + for (let i = 0; i < str.length; i++) { + const code = str.charCodeAt(i); + bytes[i * 2] = (code >> 8) & 0xFF; + bytes[i * 2 + 1] = code & 0xFF; + } + return bytes; + } + case "UTF16LE": { + const bytes = new Uint8Array(str.length * 2); + for (let i = 0; i < str.length; i++) { + const code = str.charCodeAt(i); + bytes[i * 2] = code & 0xFF; + bytes[i * 2 + 1] = (code >> 8) & 0xFF; + } + return bytes; + } + } +} + +/** + * Stringify a Uint8Array in the given format. + * + * @param {Uint8Array} bytes + * @param {string} format + * @returns {string} + */ +function formatStringify(bytes, format) { + switch (format) { + case "Hex": + return toHex(bytes, ""); + case "Base64": + return toBase64(bytes); + case "UTF8": + return new TextDecoder("utf-8").decode(bytes); + case "Latin1": + default: { + let str = ""; + for (let i = 0; i < bytes.length; i++) { + str += String.fromCharCode(bytes[i]); + } + return str; + } + case "UTF16": + case "UTF16BE": { + let str = ""; + for (let i = 0; i < bytes.length - 1; i += 2) { + str += String.fromCharCode((bytes[i] << 8) | bytes[i + 1]); + } + return str; + } + case "UTF16LE": { + let str = ""; + for (let i = 0; i < bytes.length - 1; i += 2) { + str += String.fromCharCode(bytes[i] | (bytes[i + 1] << 8)); + } + return str; + } + } +} + export default RC4; diff --git a/src/core/operations/RC4Drop.mjs b/src/core/operations/RC4Drop.mjs index c6bb536a95..2cd951dfd5 100644 --- a/src/core/operations/RC4Drop.mjs +++ b/src/core/operations/RC4Drop.mjs @@ -5,8 +5,10 @@ */ import Operation from "../Operation.mjs"; -import { format } from "../lib/Ciphers.mjs"; -import CryptoJS from "crypto-js"; +import Utils from "../Utils.mjs"; +import { rc4 } from "../lib/RC4.mjs"; +import { toHex, fromHex } from "../lib/Hex.mjs"; +import { toBase64, fromBase64 } from "../lib/Base64.mjs"; /** * RC4 Drop operation @@ -56,12 +58,18 @@ class RC4Drop extends Operation { * @returns {string} */ run(input, args) { - const message = format[args[1]].parse(input), - passphrase = format[args[0].option].parse(args[0].string), - drop = args[3], - encrypted = CryptoJS.RC4Drop.encrypt(message, passphrase, { drop: drop }); + const inputFormat = args[1]; + const outputFormat = args[2]; + const drop = args[3]; + const keyFormat = args[0].option; + const keyStr = args[0].string; - return encrypted.ciphertext.toString(format[args[2]]); + const inputBytes = formatParse(input, inputFormat); + const keyBytes = formatParse(keyStr, keyFormat); + // RC4Drop drops dwords (4 bytes each) + const outputBytes = rc4(inputBytes, keyBytes, drop * 4); + + return formatStringify(outputBytes, outputFormat); } /** @@ -92,4 +100,82 @@ class RC4Drop extends Operation { } +/** + * Parse a string in the given format to Uint8Array. + */ +function formatParse(str, format) { + switch (format) { + case "Hex": + return new Uint8Array(fromHex(str, "Auto")); + case "Base64": + return Utils.strToByteArray(fromBase64(str)); + case "UTF8": + return new TextEncoder().encode(str); + case "Latin1": + default: { + const bytes = new Uint8Array(str.length); + for (let i = 0; i < str.length; i++) { + bytes[i] = str.charCodeAt(i) & 0xFF; + } + return bytes; + } + case "UTF16": + case "UTF16BE": { + const bytes = new Uint8Array(str.length * 2); + for (let i = 0; i < str.length; i++) { + const code = str.charCodeAt(i); + bytes[i * 2] = (code >> 8) & 0xFF; + bytes[i * 2 + 1] = code & 0xFF; + } + return bytes; + } + case "UTF16LE": { + const bytes = new Uint8Array(str.length * 2); + for (let i = 0; i < str.length; i++) { + const code = str.charCodeAt(i); + bytes[i * 2] = code & 0xFF; + bytes[i * 2 + 1] = (code >> 8) & 0xFF; + } + return bytes; + } + } +} + +/** + * Stringify a Uint8Array in the given format. + */ +function formatStringify(bytes, format) { + switch (format) { + case "Hex": + return toHex(bytes, ""); + case "Base64": + return toBase64(bytes); + case "UTF8": + return new TextDecoder("utf-8").decode(bytes); + case "Latin1": + default: { + let str = ""; + for (let i = 0; i < bytes.length; i++) { + str += String.fromCharCode(bytes[i]); + } + return str; + } + case "UTF16": + case "UTF16BE": { + let str = ""; + for (let i = 0; i < bytes.length - 1; i += 2) { + str += String.fromCharCode((bytes[i] << 8) | bytes[i + 1]); + } + return str; + } + case "UTF16LE": { + let str = ""; + for (let i = 0; i < bytes.length - 1; i += 2) { + str += String.fromCharCode(bytes[i] | (bytes[i + 1] << 8)); + } + return str; + } + } +} + export default RC4Drop; diff --git a/src/core/operations/ShowBase64Offsets.mjs b/src/core/operations/ShowBase64Offsets.mjs index 37d8a6ce76..b36fae6fa3 100644 --- a/src/core/operations/ShowBase64Offsets.mjs +++ b/src/core/operations/ShowBase64Offsets.mjs @@ -66,7 +66,7 @@ class ShowBase64Offsets extends Operation { const len0 = offset0.indexOf("="), len1 = offset1.indexOf("="), len2 = offset2.indexOf("="), - script = ""; + script = ""; if (input.length < 1) { throw new OperationError("Please enter a string."); diff --git a/src/core/operations/ToCamelCase.mjs b/src/core/operations/ToCamelCase.mjs index 8d7c544571..bd7a715a7a 100644 --- a/src/core/operations/ToCamelCase.mjs +++ b/src/core/operations/ToCamelCase.mjs @@ -4,7 +4,7 @@ * @license Apache-2.0 */ -import camelCase from "lodash/camelCase.js"; +import { camelCase } from "../lib/CaseConvert.mjs"; import Operation from "../Operation.mjs"; import { replaceVariableNames } from "../lib/Code.mjs"; diff --git a/src/core/operations/ToKebabCase.mjs b/src/core/operations/ToKebabCase.mjs index 27a8ecac0d..37c3c3f01c 100644 --- a/src/core/operations/ToKebabCase.mjs +++ b/src/core/operations/ToKebabCase.mjs @@ -4,7 +4,7 @@ * @license Apache-2.0 */ -import kebabCase from "lodash/kebabCase.js"; +import { kebabCase } from "../lib/CaseConvert.mjs"; import Operation from "../Operation.mjs"; import { replaceVariableNames } from "../lib/Code.mjs"; diff --git a/src/core/operations/ToSnakeCase.mjs b/src/core/operations/ToSnakeCase.mjs index 5cb566af0c..43859f62c4 100644 --- a/src/core/operations/ToSnakeCase.mjs +++ b/src/core/operations/ToSnakeCase.mjs @@ -4,7 +4,7 @@ * @license Apache-2.0 */ -import snakeCase from "lodash/snakeCase.js"; +import { snakeCase } from "../lib/CaseConvert.mjs"; import Operation from "../Operation.mjs"; import { replaceVariableNames } from "../lib/Code.mjs"; diff --git a/src/node/api.mjs b/src/node/api.mjs index 88b3f834ae..b704a2bb48 100644 --- a/src/node/api.mjs +++ b/src/node/api.mjs @@ -10,7 +10,7 @@ import NodeDish from "./NodeDish.mjs"; import NodeRecipe from "./NodeRecipe.mjs"; -import OperationConfig from "../core/config/OperationConfig.json" assert {type: "json"}; +import OperationConfig from "../core/config/OperationConfig.json" with {type: "json"}; import { sanitise, removeSubheadingsFromArray, sentenceToCamelCase } from "./apiUtils.mjs"; import ExcludedOperationError from "../core/errors/ExcludedOperationError.mjs"; diff --git a/src/web/App.mjs b/src/web/App.mjs index 143545d6a2..cb7dd6694a 100644 --- a/src/web/App.mjs +++ b/src/web/App.mjs @@ -10,8 +10,10 @@ import Manager from "./Manager.mjs"; import HTMLCategory from "./HTMLCategory.mjs"; import HTMLOperation from "./HTMLOperation.mjs"; import Split from "split.js"; -import moment from "moment-timezone"; +import { formatDistance } from "date-fns"; import cptable from "codepage"; +import {showSnackbar} from "./utils/Snackbar.mjs"; +import * as bootstrap from "bootstrap"; /** @@ -638,7 +640,7 @@ class App { // Display time since last build and compile message const now = new Date(), msSinceCompile = now.getTime() - window.compileTime, - timeSinceCompile = moment.duration(msSinceCompile, "milliseconds").humanize(); + timeSinceCompile = formatDistance(new Date(window.compileTime), now, { addSuffix: false }); // Calculate previous version to compare to const prev = PKG_VERSION.split(".").map(n => { @@ -703,14 +705,11 @@ class App { log.info("[" + time.toLocaleString() + "] " + str); if (silent) return; - this.snackbars.push($.snackbar({ + showSnackbar({ content: str, timeout: timeout, - htmlAllowed: true, - onClose: () => { - this.snackbars.shift().remove(); - } - })); + style: "snackbar" + }); } @@ -738,25 +737,40 @@ class App { document.getElementById("confirm-modal").style.display = "block"; this.confirmClosed = false; - $("#confirm-modal").modal() - .one("show.bs.modal", function(e) { - this.confirmClosed = false; - }.bind(this)) - .one("click", "#confirm-yes", function() { - this.confirmClosed = true; - callback.bind(scope)(true); - $("#confirm-modal").modal("hide"); - }.bind(this)) - .one("click", "#confirm-no", function() { - this.confirmClosed = true; - callback.bind(scope)(false); - }.bind(this)) - .one("hide.bs.modal", function(e) { - if (!this.confirmClosed) { - callback.bind(scope)(undefined); - } - this.confirmClosed = true; - }.bind(this)); + + const confirmModalEl = document.getElementById("confirm-modal"); + const confirmModal = bootstrap.Modal.getOrCreateInstance(confirmModalEl); + + const onShow = (e) => { + this.confirmClosed = false; + }; + const onYes = () => { + this.confirmClosed = true; + callback.bind(scope)(true); + confirmModal.hide(); + }; + const onNo = () => { + this.confirmClosed = true; + callback.bind(scope)(false); + }; + const onHide = (e) => { + if (!this.confirmClosed) { + callback.bind(scope)(undefined); + } + this.confirmClosed = true; + // Clean up one-time listeners + confirmModalEl.removeEventListener("show.bs.modal", onShow); + confirmModalEl.removeEventListener("hide.bs.modal", onHide); + document.getElementById("confirm-yes").removeEventListener("click", onYes); + document.getElementById("confirm-no").removeEventListener("click", onNo); + }; + + confirmModalEl.addEventListener("show.bs.modal", onShow, { once: true }); + confirmModalEl.addEventListener("hide.bs.modal", onHide, { once: true }); + document.getElementById("confirm-yes").addEventListener("click", onYes, { once: true }); + document.getElementById("confirm-no").addEventListener("click", onNo, { once: true }); + + confirmModal.show(); } diff --git a/src/web/HTMLCategory.mjs b/src/web/HTMLCategory.mjs index b61a674080..69f178e3a4 100755 --- a/src/web/HTMLCategory.mjs +++ b/src/web/HTMLCategory.mjs @@ -40,13 +40,13 @@ class HTMLCategory { toHtml() { const catName = "cat" + this.name.replace(/[\s/\-:_]/g, ""); let html = `
- + ${this.name} -
+
    `; for (let i = 0; i < this.opList.length; i++) { diff --git a/src/web/HTMLIngredient.mjs b/src/web/HTMLIngredient.mjs index 91cbed891d..6b9230694d 100755 --- a/src/web/HTMLIngredient.mjs +++ b/src/web/HTMLIngredient.mjs @@ -49,7 +49,7 @@ class HTMLIngredient { toHtml() { let html = "", i, m, eventFn; - const hintHtml = this.hint ? `data-toggle="tooltip" title="${this.hint}"` : ""; + const hintHtml = this.hint ? `data-bs-toggle="tooltip" title="${this.hint}"` : ""; switch (this.type) { case "string": @@ -95,7 +95,7 @@ class HTMLIngredient { ${this.maxLength ? `maxlength="${this.maxLength}"` : ""}>
- + @@ -842,7 +842,7 @@
Results
@@ -850,7 +850,7 @@
Results
diff --git a/src/web/index.js b/src/web/index.js index 90142b3420..77fd52c353 100755 --- a/src/web/index.js +++ b/src/web/index.js @@ -8,17 +8,14 @@ import "./stylesheets/index.js"; // Libs -import "arrive"; -import "snackbarjs"; -import "bootstrap-material-design/js/index"; -import "bootstrap-colorpicker"; -import moment from "moment-timezone"; +import "bootstrap"; +import { parse } from "date-fns"; import * as CanvasComponents from "../core/lib/CanvasComponents.mjs"; // CyberChef import App from "./App.mjs"; -import Categories from "../core/config/Categories.json" assert {type: "json"}; -import OperationConfig from "../core/config/OperationConfig.json" assert {type: "json"}; +import Categories from "../core/config/Categories.json" with {type: "json"}; +import OperationConfig from "../core/config/OperationConfig.json" with {type: "json"}; /** @@ -60,7 +57,8 @@ function main() { window.app.setup(); } -window.compileTime = moment.tz(COMPILE_TIME, "DD/MM/YYYY HH:mm:ss z", "UTC").valueOf(); +// Parse compile time string (format: "DD/MM/YYYY HH:mm:ss UTC") +window.compileTime = parse(COMPILE_TIME.replace(/ UTC$/, ""), "dd/MM/yyyy HH:mm:ss", new Date()).getTime(); window.compileMessage = COMPILE_MSG; // Make libs available to operation outputs diff --git a/src/web/stylesheets/index.js b/src/web/stylesheets/index.js index 98c8f01901..27c0064d32 100755 --- a/src/web/stylesheets/index.js +++ b/src/web/stylesheets/index.js @@ -10,8 +10,7 @@ import "highlight.js/styles/vs.css"; /* Frameworks */ -import "bootstrap-material-design/dist/css/bootstrap-material-design.css"; -import "bootstrap-colorpicker/dist/css/bootstrap-colorpicker.css"; +import "bootstrap/dist/css/bootstrap.css"; /* CyberChef styles */ import "./index.css"; diff --git a/src/web/utils/Snackbar.mjs b/src/web/utils/Snackbar.mjs new file mode 100644 index 0000000000..a81fb3232b --- /dev/null +++ b/src/web/utils/Snackbar.mjs @@ -0,0 +1,64 @@ +/** + * Lightweight snackbar/toast notification utility. + * Replaces snackbarjs dependency (which required jQuery). + * + * @author CyberChef Modernization + * @copyright Crown Copyright 2016 + * @license Apache-2.0 + */ + +let container = null; + +/** + * Get or create the snackbar container. + * @returns {HTMLElement} + */ +function getContainer() { + if (!container) { + container = document.getElementById("snackbar-container"); + if (!container) { + container = document.createElement("div"); + container.id = "snackbar-container"; + container.style.cssText = "position:fixed;bottom:20px;left:50%;transform:translateX(-50%);z-index:9999;display:flex;flex-direction:column;align-items:center;gap:8px;pointer-events:none;"; + document.body.appendChild(container); + } + } + return container; +} + +/** + * Show a snackbar notification. + * + * @param {Object} options + * @param {string} options.content - The message to display + * @param {number} [options.timeout=2000] - Duration in ms before auto-dismiss + * @param {string} [options.style="snackbar"] - CSS class style + */ +export function showSnackbar({ content, timeout = 2000, style = "snackbar" }) { + const el = document.createElement("div"); + el.className = `cc-snackbar ${style}`; + el.textContent = content; + el.style.cssText = "background:#323232;color:#fff;padding:10px 24px;border-radius:4px;font-size:14px;opacity:0;transition:opacity 0.3s;pointer-events:auto;max-width:500px;text-align:center;box-shadow:0 2px 8px rgba(0,0,0,0.3);"; + + const cont = getContainer(); + cont.appendChild(el); + + // Trigger fade-in + requestAnimationFrame(() => { + el.style.opacity = "1"; + }); + + // Auto-dismiss + if (timeout > 0) { + setTimeout(() => { + el.style.opacity = "0"; + setTimeout(() => el.remove(), 300); + }, timeout); + } +} + +/** + * jQuery-compatible $.snackbar() replacement. + * Called as: showSnackbar({ content: "message" }) + */ +export default showSnackbar; diff --git a/src/web/utils/fileDetails.mjs b/src/web/utils/fileDetails.mjs index 94f125f86f..ada368b7c5 100644 --- a/src/web/utils/fileDetails.mjs +++ b/src/web/utils/fileDetails.mjs @@ -7,6 +7,7 @@ import {showSidePanel} from "./sidePanel.mjs"; import Utils from "../../core/Utils.mjs"; import {isImage, detectFileType} from "../../core/lib/FileType.mjs"; +import * as bootstrap from "bootstrap"; /** * A File Details extension for CodeMirror @@ -40,7 +41,7 @@ class FileDetailsPanel { const fileThumb = require("../static/images/file-128x128.png"); dom.innerHTML = `
${this.hidden ? "❰" : "❱"}
@@ -130,7 +131,9 @@ function makePanel(opts) { update(update) { }, mount() { - $("[data-toggle='tooltip']").tooltip(); + document.querySelectorAll("[data-bs-toggle='tooltip']").forEach(el => { + new bootstrap.Tooltip(el); + }); } }; }; diff --git a/src/web/utils/statusBar.mjs b/src/web/utils/statusBar.mjs index 1adcd5be28..9bbc366c22 100644 --- a/src/web/utils/statusBar.mjs +++ b/src/web/utils/statusBar.mjs @@ -344,21 +344,21 @@ class StatusBarPanel { */ constructLHS() { return ` - + abc - + sort - + highlight_alt \u279E ( selected) - + location_on `; @@ -386,13 +386,13 @@ class StatusBarPanel { } return ` -