diff --git a/package-lock.json b/package-lock.json index 3c08fc83a0..6fecfda4be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53,6 +53,7 @@ "highlight.js": "^11.11.1", "ieee754": "^1.2.1", "jimp": "1.6.0", + "jose": "^6.2.3", "jq-web": "^0.5.1", "jquery": "3.7.1", "js-sha3": "^0.9.3", @@ -12443,6 +12444,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/jpeg-js": { "version": "0.4.4", "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz", diff --git a/package.json b/package.json index d7e2f23c5b..6f2daa1cbd 100644 --- a/package.json +++ b/package.json @@ -137,6 +137,7 @@ "highlight.js": "^11.11.1", "ieee754": "^1.2.1", "jimp": "1.6.0", + "jose": "^6.2.3", "jq-web": "^0.5.1", "jquery": "3.7.1", "js-sha3": "^0.9.3", diff --git a/src/core/lib/RSA.mjs b/src/core/lib/RSA.mjs index 9037379cf8..1909172a32 100644 --- a/src/core/lib/RSA.mjs +++ b/src/core/lib/RSA.mjs @@ -7,6 +7,7 @@ */ import forge from "node-forge"; +import * as asn1js from "asn1js"; export const MD_ALGORITHMS = { "SHA-1": forge.md.sha1, @@ -15,3 +16,51 @@ export const MD_ALGORITHMS = { "SHA-384": forge.md.sha384, "SHA-512": forge.md.sha512, }; + +const rsaEncryptionOID = "1.2.840.113549.1.1.1"; + +/** + * Convert PKCS#1 RSA private key (PEM) to PKCS#8 PEM + * @param {string} originalPem + * @returns {string} + */ +export function pkcs1ToPkcs8(originalPem) { + // remove PEM headers + const b64 = originalPem + .replace(/-----BEGIN RSA PRIVATE KEY-----/g, "") + .replace(/-----END RSA PRIVATE KEY-----/g, "") + .replace(/\s+/g, ""); + + const pkcs1Der = Uint8Array.from(atob(b64), c => c.charCodeAt(0)).buffer; + + // PKCS#8 structure: + // PrivateKeyInfo ::= SEQUENCE { + // version INTEGER, + // privateKeyAlgorithm AlgorithmIdentifier, + // privateKey OCTET STRING + // } + + const pkcs8Schema = new asn1js.Sequence({ + value: [ + new asn1js.Integer({ value: 0 }), + new asn1js.Sequence({ + value: [ + // rsaEncryption OID + new asn1js.ObjectIdentifier({ value: rsaEncryptionOID }), + new asn1js.Null() + ] + }), + new asn1js.OctetString({ valueHex: pkcs1Der }) + ] + }); + + const pkcs8Der = pkcs8Schema.toBER(false); + + const pkcs8B64 = btoa( + String.fromCharCode(...new Uint8Array(pkcs8Der)) + ); + + const lines = pkcs8B64.match(/.{1,64}/g).join("\n"); + + return `-----BEGIN PRIVATE KEY-----\n${lines}\n-----END PRIVATE KEY-----`; +} diff --git a/src/core/operations/JWTSign.mjs b/src/core/operations/JWTSign.mjs index 66831efad1..f2c81a4087 100644 --- a/src/core/operations/JWTSign.mjs +++ b/src/core/operations/JWTSign.mjs @@ -4,10 +4,10 @@ * @license Apache-2.0 */ import Operation from "../Operation.mjs"; -import jwt from "jsonwebtoken"; +import { SignJWT, importPKCS8 } from "jose"; import OperationError from "../errors/OperationError.mjs"; import {JWT_ALGORITHMS} from "../lib/JWT.mjs"; - +import {pkcs1ToPkcs8} from "../lib/RSA.mjs"; /** * JWT Sign operation @@ -50,21 +50,47 @@ class JWTSign extends Operation { * @param {Object[]} args * @returns {string} */ - run(input, args) { + async run(input, args) { const [key, algorithm, header] = args; + let secret; try { - return jwt.sign(input, key, { - algorithm: algorithm === "None" ? "none" : algorithm, - header: JSON.parse(header || "{}") - }); + if (key.startsWith("-----BEGIN RSA PRIVATE KEY-----")) { + secret = await importPKCS8(pkcs1ToPkcs8(key), algorithm); + } else if (key.startsWith("-----BEGIN PRIVATE KEY-----")) { + secret = await importPKCS8(key, algorithm); + } else { + secret = new TextEncoder().encode(key); + } } catch (err) { throw new OperationError(`Error: Have you entered the key correctly? The key should be either the secret for HMAC algorithms or the PEM-encoded private key for RSA and ECDSA. ${err}`); } - } + const fullHeader = { alg: algorithm, typ: "JWT" }; + try { + if (header !== "{}") { + Object.assign(fullHeader, JSON.parse(header)); + } + } catch (err) { + throw new OperationError(`Header must be a valid (or empty) json object. + +${err}`); + } + + try { + const token = await new SignJWT(input) + .setProtectedHeader(fullHeader) + .sign(secret); + + return token; + } catch (err) { + throw new OperationError(`Error: Have you entered the key correctly? The key should be either the secret for HMAC algorithms or the PEM-encoded private key for RSA and ECDSA. + +${err}`); + } + }; } export default JWTSign; diff --git a/tests/browser/02_ops.js b/tests/browser/02_ops.js index 9d29f50dfe..fc06cc96b6 100644 --- a/tests/browser/02_ops.js +++ b/tests/browser/02_ops.js @@ -219,7 +219,7 @@ module.exports = { // testOp(browser, "JSON Minify", "test input", "test_output"); // testOp(browser, "JSON to CSV", "test input", "test_output"); // testOp(browser, "JWT Decode", "test input", "test_output"); - // testOp(browser, "JWT Sign", "test input", "test_output"); + testOp(browser, "JWT Sign", '{"a":{"b":1}}', "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhIjp7ImIiOjF9fQ.5PSBsZ9_B-qZa8H3l9tRAEV6qt8NgEHNJaoyjVcnTsU", ["A-key-of-256-bits-or-larger-as-per-RFC7518", "HS256", "{}"]); // testOp(browser, "JWT Verify", "test input", "test_output"); // testOp(browser, "JavaScript Beautify", "test input", "test_output"); // testOp(browser, "JavaScript Minify", "test input", "test_output"); diff --git a/tests/operations/tests/JWTSign.mjs b/tests/operations/tests/JWTSign.mjs index 9954174b58..7a1a0a4b93 100644 --- a/tests/operations/tests/JWTSign.mjs +++ b/tests/operations/tests/JWTSign.mjs @@ -89,6 +89,19 @@ TestRegister.addTests([ } ], }, + { + name: "JWT Sign: HS256, invalid header", + input: inputObject, + expectedOutput: `Header must be a valid (or empty) json object. + +SyntaxError: Unexpected token 'h', "this is not JSON" is not valid JSON`, + recipeConfig: [ + { + op: "JWT Sign", + args: [hsKey, "HS256", "this is not JSON"], + } + ], + }, { name: "JWT Sign: HS256 with custom header", input: inputObject, @@ -142,7 +155,7 @@ TestRegister.addTests([ input: inputObject, expectedOutput: `Error: Have you entered the key correctly? The key should be either the secret for HMAC algorithms or the PEM-encoded private key for RSA and ECDSA. -Error: "alg" parameter "ES384" requires curve "secp384r1".`, +DataError: Named curve mismatch`, recipeConfig: [ { op: "JWT Sign", @@ -189,7 +202,7 @@ Error: "alg" parameter "ES384" requires curve "secp384r1".`, input: inputObject, expectedOutput: `Error: Have you entered the key correctly? The key should be either the secret for HMAC algorithms or the PEM-encoded private key for RSA and ECDSA. -Error: secretOrPrivateKey has a minimum key size of 2048 bits for RS256`, +TypeError: RS256 requires key modulusLength to be 2048 bits or larger`, recipeConfig: [ { op: "JWT Sign",