diff --git a/src/jwk.ts b/src/jwk.ts index 09318f6..409b1ff 100644 --- a/src/jwk.ts +++ b/src/jwk.ts @@ -70,7 +70,14 @@ type EcSignatureJwk = Jwk & { y: string; }; -export type SignatureJwk = RsaSignatureJwk | EcSignatureJwk; +type OkpSignatureJwk = Jwk & { + use?: "sig"; + kty: "OKP"; + crv: "Ed25519" | "Ed448"; + x: string; +}; + +export type SignatureJwk = RsaSignatureJwk | EcSignatureJwk | OkpSignatureJwk; export type JwkWithKid = Jwk & { kid: string; @@ -166,14 +173,32 @@ export function assertIsSignatureJwk(jwk: Jwk): asserts jwk is SignatureJwk { assertStringArrayContainsString( "JWK kty", jwk.kty, - ["EC", "RSA"], + ["EC", "RSA", "OKP"], JwkInvalidKtyError ); if (jwk.kty === "EC") { assertIsEsSignatureJwk(jwk); } else if (jwk.kty === "RSA") { assertIsRsaSignatureJwk(jwk); + } else if (jwk.kty === "OKP") { + assertIsEdDSASignatureJwk(jwk); + } +} + +function assertIsEdDSASignatureJwk(jwk: Jwk): asserts jwk is OkpSignatureJwk { + // Check JWK use + if (jwk.use) { + assertStringEquals("JWK use", jwk.use, "sig", JwkInvalidUseError); } + + // Check JWK kty + assertStringEquals("JWK kty", jwk.kty, "OKP", JwkInvalidKtyError); + + // Check Curve (crv) has a value + if (!jwk.crv) throw new JwkValidationError("Missing Curve (crv)"); + + // Check X Coordinate (x) has a value + if (!jwk.x) throw new JwkValidationError("Missing X Coordinate (x)"); } function assertIsEsSignatureJwk(jwk: Jwk): asserts jwk is EcSignatureJwk { diff --git a/src/jwt-verifier.ts b/src/jwt-verifier.ts index d3294b0..7d1e82b 100644 --- a/src/jwt-verifier.ts +++ b/src/jwt-verifier.ts @@ -45,6 +45,7 @@ export const supportedSignatureAlgorithms = [ "ES256", "ES384", "ES512", + "EdDSA", ] as const; export type SupportedSignatureAlgorithm = (typeof supportedSignatureAlgorithms)[number]; diff --git a/src/node-web-compat-node.ts b/src/node-web-compat-node.ts index 6089888..3d40398 100644 --- a/src/node-web-compat-node.ts +++ b/src/node-web-compat-node.ts @@ -3,7 +3,7 @@ // // Node.js implementations for the node-web-compatibility layer -import { createPublicKey, createVerify, KeyObject } from "crypto"; +import { createPublicKey, createVerify, KeyObject, verify } from "crypto"; import { SignatureJwk } from "./jwk.js"; import { fetch } from "./https-node.js"; import { NodeWebCompat } from "./node-web-compat.js"; @@ -11,13 +11,13 @@ import { NodeWebCompat } from "./node-web-compat.js"; /** * Enum to map supported JWT signature algorithms with OpenSSL message digest algorithm names */ -enum JwtSignatureAlgorithms { +enum JwtSignatureAlgorithmHashNames { RS256 = "RSA-SHA256", RS384 = "RSA-SHA384", RS512 = "RSA-SHA512", - ES256 = RS256, // yes, openssl uses the same algorithm name - ES384 = RS384, // yes, openssl uses the same algorithm name - ES512 = RS512, // yes, openssl uses the same algorithm name + ES256 = RS256, + ES384 = RS384, + ES512 = RS512, } export const nodeWebCompat: NodeWebCompat = { @@ -35,34 +35,25 @@ export const nodeWebCompat: NodeWebCompat = { parseB64UrlString: (b64: string): string => Buffer.from(b64, "base64").toString("utf8"), verifySignatureSync: ({ alg, keyObject, jwsSigningInput, signature }) => - // eslint-disable-next-line security/detect-object-injection - createVerify(JwtSignatureAlgorithms[alg]) - .update(jwsSigningInput) - .verify( - { - key: keyObject as KeyObject, - dsaEncoding: "ieee-p1363", // Signature format r || s (not used for RSA) - }, - signature, - "base64" - ), - verifySignatureAsync: async ({ - alg, - keyObject, - jwsSigningInput, - signature, - }) => - // eslint-disable-next-line security/detect-object-injection - createVerify(JwtSignatureAlgorithms[alg]) - .update(jwsSigningInput) - .verify( - { - key: keyObject as KeyObject, - dsaEncoding: "ieee-p1363", // Signature format r || s (not used for RSA) - }, - signature, - "base64" - ), + alg !== "EdDSA" + ? // eslint-disable-next-line security/detect-object-injection + createVerify(JwtSignatureAlgorithmHashNames[alg]) + .update(jwsSigningInput) + .verify( + { + key: keyObject as KeyObject, + dsaEncoding: "ieee-p1363", // Signature format r || s (not used for RSA) + }, + signature, + "base64" + ) + : verify( + null, + Buffer.from(jwsSigningInput), + keyObject as KeyObject, + Buffer.from(signature, "base64") + ), + verifySignatureAsync: async (args) => nodeWebCompat.verifySignatureSync(args), defaultFetchTimeouts: { socketIdle: 1500, response: 3000, diff --git a/src/node-web-compat-web.ts b/src/node-web-compat-web.ts index 55293f3..20770ec 100644 --- a/src/node-web-compat-web.ts +++ b/src/node-web-compat-web.ts @@ -3,6 +3,7 @@ // // Web implementations for the node-web-compatibility layer +import { SignatureJwk } from "jwk.js"; import { FetchError, NotSupportedError, @@ -20,6 +21,11 @@ enum NamedCurvesWebCrypto { ES512 = "P-521", // yes, 521 } +interface CryptoKeyWithJwk { + key: CryptoKey; + jwk: SignatureJwk; +} + export const nodeWebCompat: NodeWebCompat = { fetch: async ( uri: string, @@ -72,23 +78,22 @@ export const nodeWebCompat: NodeWebCompat = { alg ); } - return crypto.subtle.importKey( - "jwk", - jwk, - alg.startsWith("RS") + const algIdentifier = alg.startsWith("RS") + ? { + name: "RSASSA-PKCS1-v1_5", + hash: `SHA-${alg.slice(2)}`, + } + : alg.startsWith("ES") ? { - name: "RSASSA-PKCS1-v1_5", - hash: `SHA-${alg.slice(2)}`, - } - : { name: "ECDSA", // eslint-disable-next-line security/detect-object-injection namedCurve: NamedCurvesWebCrypto[alg as keyof typeof NamedCurvesWebCrypto], - }, - false, - ["verify"] - ); + } + : jwk.crv!; // Ed25519 or Ed448 + return crypto.subtle + .importKey("jwk", jwk, algIdentifier, false, ["verify"]) + .then((key) => ({ key, jwk })); }, verifySignatureSync: () => { throw new NotSupportedError( @@ -101,11 +106,13 @@ export const nodeWebCompat: NodeWebCompat = { ? { name: "RSASSA-PKCS1-v1_5", } - : { - name: "ECDSA", - hash: `SHA-${alg.slice(2)}`, - }, - keyObject as CryptoKey, + : alg.startsWith("ES") + ? { + name: "ECDSA", + hash: `SHA-${alg.slice(2)}`, + } + : { name: (keyObject as CryptoKeyWithJwk).jwk.crv! }, + (keyObject as CryptoKeyWithJwk).key, bufferFromBase64url(signature), new TextEncoder().encode(jwsSigningInput) ), diff --git a/tests/unit/jwt-verifier.test.ts b/tests/unit/jwt-verifier.test.ts index 2126eb7..2588c30 100644 --- a/tests/unit/jwt-verifier.test.ts +++ b/tests/unit/jwt-verifier.test.ts @@ -149,6 +149,40 @@ describe("unit tests jwt verifier", () => { verifyJwtSync(signedJwt, es512keypair.jwk, { issuer, audience }) ).toMatchObject({ hello: "world" }); }); + test("happy flow with jwk - Ed25519", () => { + const ed25519keypair = generateKeyPair({ + kty: "OKP", + alg: "EdDSA", + crv: "Ed25519", + }); + const issuer = "https://example.com"; + const audience = "1234"; + const signedJwt = signJwt( + { alg: "EdDSA", kid: keypair.jwk.kid }, + { aud: audience, iss: issuer, hello: "world" }, + ed25519keypair.privateKey + ); + expect( + verifyJwtSync(signedJwt, ed25519keypair.jwk, { issuer, audience }) + ).toMatchObject({ hello: "world" }); + }); + test("happy flow with jwk - Ed448", () => { + const ed448keypair = generateKeyPair({ + kty: "OKP", + alg: "EdDSA", + crv: "Ed448", + }); + const issuer = "https://example.com"; + const audience = "1234"; + const signedJwt = signJwt( + { alg: "EdDSA", kid: keypair.jwk.kid }, + { aud: audience, iss: issuer, hello: "world" }, + ed448keypair.privateKey + ); + expect( + verifyJwtSync(signedJwt, ed448keypair.jwk, { issuer, audience }) + ).toMatchObject({ hello: "world" }); + }); test("happy flow with jwk without alg", () => { const issuer = "https://example.com"; const audience = "1234"; @@ -999,6 +1033,22 @@ describe("unit tests jwt verifier", () => { expect(statement).toThrow("Missing Curve (crv)"); expect(statement).toThrow(JwkValidationError); }); + test("missing crv on JWK - EdDSA", () => { + const { jwk, privateKey } = generateKeyPair({ + kty: "OKP", + alg: "EdDSA", + crv: "Ed25519", + }); + delete jwk.crv; + const signedJwt = signJwt({ alg: "EdDSA" }, {}, privateKey); + const statement = () => + verifyJwtSync(signedJwt, jwk, { + audience: null, + issuer: null, + }); + expect(statement).toThrow("Missing Curve (crv)"); + expect(statement).toThrow(JwkValidationError); + }); test("missing x on JWK", () => { const { jwk, privateKey } = generateKeyPair({ kty: "EC", @@ -1014,6 +1064,22 @@ describe("unit tests jwt verifier", () => { expect(statement).toThrow("Missing X Coordinate (x)"); expect(statement).toThrow(JwkValidationError); }); + test("missing x on JWK - EdDSA", () => { + const { jwk, privateKey } = generateKeyPair({ + kty: "OKP", + alg: "EdDSA", + crv: "Ed448", + }); + delete jwk.x; + const signedJwt = signJwt({ alg: "EdDSA" }, {}, privateKey); + const statement = () => + verifyJwtSync(signedJwt, jwk, { + audience: null, + issuer: null, + }); + expect(statement).toThrow("Missing X Coordinate (x)"); + expect(statement).toThrow(JwkValidationError); + }); test("missing y on JWK", () => { const { jwk, privateKey } = generateKeyPair({ kty: "EC", diff --git a/tests/util/util.ts b/tests/util/util.ts index bf59460..a4e245f 100644 --- a/tests/util/util.ts +++ b/tests/util/util.ts @@ -2,7 +2,7 @@ * Utility functions used by unit and integration tests */ -import { createSign, generateKeyPairSync, KeyObject } from "crypto"; +import { createSign, generateKeyPairSync, KeyObject, sign } from "crypto"; import { Jwk } from "../../src/jwk"; /** RSA keypair with its various manifestations as properties, for use in automated tests */ @@ -38,6 +38,13 @@ export function generateKeyPair( alg?: "ES256" | "ES384" | "ES512"; kid?: string; use?: string; + } + | { + kty: "OKP"; + alg: "EdDSA"; + crv: "Ed25519" | "Ed448"; + kid?: string; + use?: string; } = { kty: "RSA", alg: "RS256", @@ -50,17 +57,27 @@ export function generateKeyPair( modulusLength: 4096, publicExponent: 0x10001, }) - : generateKeyPairSync("ec", { - namedCurve: { ES256: "P-256", ES384: "P-384", ES512: "P-521" }[ - options.alg ?? "ES256" - ], - }); + : options.kty === "EC" + ? generateKeyPairSync("ec", { + namedCurve: { ES256: "P-256", ES384: "P-384", ES512: "P-521" }[ + options.alg ?? "ES256" + ], + }) + : options.crv === "Ed25519" + ? generateKeyPairSync("ed25519") + : generateKeyPairSync("ed448"); const jwk = publicKey.export({ format: "jwk", }) as Jwk; jwk.alg = - "alg" in options ? options.alg : options.kty === "RSA" ? "RS256" : "ES256"; + "alg" in options + ? options.alg + : options.kty === "RSA" + ? "RS256" + : options.kty === "EC" + ? "ES256" + : "EdDSA"; jwk.kid = "kid" in options ? options.kid : "testkid"; jwk.use = "use" in options ? options.use : "sig"; @@ -82,7 +99,7 @@ export function generateKeyPair( /** * Enum to map supported JWT signature algorithms with OpenSSL message digest algorithm names */ -enum JwtSignatureAlgorithms { +enum JwtSignatureAlgorithmHashNames { RS256 = "RSA-SHA256", RS384 = "RSA-SHA384", RS512 = "RSA-SHA512", @@ -91,6 +108,10 @@ enum JwtSignatureAlgorithms { ES512 = RS512, } +type JwtSignatureAlgorithm = + | keyof typeof JwtSignatureAlgorithmHashNames + | "EdDSA"; + /** * Create a signed JWT with the given header and payload. * The signature algorithm will be taken from the "alg" in the header that you provide, and will default to RS256 if not given. @@ -119,16 +140,21 @@ export function signJwt( Buffer.from(JSON.stringify(header)).toString("base64url") + bogusPadding, Buffer.from(JSON.stringify(payload)).toString("base64url") + bogusPadding, ].join("."); - const alg = (header.alg as keyof typeof JwtSignatureAlgorithms) ?? "RS256"; - // eslint-disable-next-line security/detect-object-injection - const digestFunction = JwtSignatureAlgorithms[alg]; - const sign = createSign(digestFunction); - sign.write(toSign); - sign.end(); - const signature = sign.sign({ - key: privateKey, - dsaEncoding: "ieee-p1363", // Signature format r || s (not used for RSA) - }); + let signature: Buffer; + const alg = (header.alg as JwtSignatureAlgorithm) ?? "RS256"; + if (alg === "EdDSA") { + signature = sign(null, Buffer.from(toSign), privateKey); + } else { + // eslint-disable-next-line security/detect-object-injection + const digestFunction = JwtSignatureAlgorithmHashNames[alg]; + const sign = createSign(digestFunction); + sign.write(toSign); + sign.end(); + signature = sign.sign({ + key: privateKey, + dsaEncoding: "ieee-p1363", // Signature format r || s (not used for RSA) + }); + } if (options?.produceValidSignature === false) { // Invert the bits of a random byte const index = Math.floor(Math.random() * signature.length); diff --git a/tests/vite-app/cypress.config.ts b/tests/vite-app/cypress.config.ts index 4eddca9..b3dc819 100644 --- a/tests/vite-app/cypress.config.ts +++ b/tests/vite-app/cypress.config.ts @@ -8,6 +8,6 @@ export default defineConfig({ // eslint-disable-next-line @typescript-eslint/no-var-requires return require("./cypress/plugins/index.js")(on, config); }, - baseUrl: "http://localhost:5173/", + baseUrl: "http://127.0.0.1:5173/", }, }); diff --git a/tests/vite-app/cypress/e2e/unittests.cy.ts b/tests/vite-app/cypress/e2e/unittests.cy.ts index 52a3945..a72dadc 100644 --- a/tests/vite-app/cypress/e2e/unittests.cy.ts +++ b/tests/vite-app/cypress/e2e/unittests.cy.ts @@ -16,6 +16,8 @@ import { VALID_TOKEN_ES256, VALID_TOKEN_ES256_PADDED, VALID_TOKEN_ES512, + VALID_TOKEN_Ed25519, + // VALID_TOKEN_Ed448, EXPIRED_TOKEN, NOT_YET_VALID_TOKEN, } from "../fixtures/example-token-data.json"; @@ -79,6 +81,30 @@ describe("unit tests", () => { expect(payload).to.exist; }); + // Note: Ed25519 is not yet supported in chrome + it("valid token - Ed25519", async () => { + const verifier = JwtVerifier.create({ + issuer: ISSUER, + audience: AUDIENCE, + jwksUri: JWKSURI, + }); + const payload = await verifier.verify(VALID_TOKEN_Ed25519); + + expect(payload).to.exist; + }); + + // Note: Ed448 is not yet supported in chrome nor firefox + // it("valid token - Ed448", async () => { + // const verifier = JwtVerifier.create({ + // issuer: ISSUER, + // audience: AUDIENCE, + // jwksUri: JWKSURI, + // }); + // const payload = await verifier.verify(VALID_TOKEN_Ed448); + + // expect(payload).to.exist; + // }); + it("valid token for JWK without alg", async () => { const verifier = JwtVerifier.create({ issuer: ISSUER, diff --git a/tests/vite-app/package.json b/tests/vite-app/package.json index 3a97b53..05199f6 100644 --- a/tests/vite-app/package.json +++ b/tests/vite-app/package.json @@ -8,11 +8,11 @@ "preview": "vite preview", "reinstall": "npm uninstall aws-jwt-verify && npm install --no-save --force --no-package-lock ../../aws-jwt-verify.tgz", "tokengen": "cd util && ts-node generateExampleTokens.ts", - "cypress:run": "cypress run", - "cypress:run:preview": "cypress run --config baseUrl=http://localhost:4173/", + "cypress:run": "cypress run --browser firefox", + "cypress:run:preview": "cypress run --config baseUrl=http://127.0.0.1:4173/ --browser firefox", "cypress:open": "cypress open", "cypress:open:webpack": "DEBUG=cypress:webpack cypress open", - "cypress:open:preview": "cypress open --config baseUrl=http://localhost:4173/", + "cypress:open:preview": "cypress open --config baseUrl=http://127.0.0.1:4173/", "postinstall": "npm run reinstall" }, "devDependencies": { diff --git a/tests/vite-app/util/generateExampleTokens.ts b/tests/vite-app/util/generateExampleTokens.ts index e8766dd..5f9ffcd 100644 --- a/tests/vite-app/util/generateExampleTokens.ts +++ b/tests/vite-app/util/generateExampleTokens.ts @@ -71,6 +71,8 @@ const tokendata = { VALID_TOKEN_ES256: "", VALID_TOKEN_ES256_PADDED: "", VALID_TOKEN_ES512: "", + VALID_TOKEN_Ed25519: "", + VALID_TOKEN_Ed448: "", }; const main = async () => { @@ -91,13 +93,29 @@ const main = async () => { kid: randomUUID(), alg: "ES512", }); + const { privateKey: privateKeyEd25519, jwk: jwkEd25519 } = generateKeyPair({ + kty: "OKP", + kid: randomUUID(), + alg: "EdDSA", + crv: "Ed25519", + }); + const { privateKey: privateKeyEd448, jwk: jwkEd448 } = generateKeyPair({ + kty: "OKP", + kid: randomUUID(), + alg: "EdDSA", + crv: "Ed448", + }); - const jwks = { keys: [jwk, jwkWithoutAlg, jwkEs256, jwkEs512] }; + const jwks = { + keys: [jwk, jwkWithoutAlg, jwkEs256, jwkEs512, jwkEd25519, jwkEd448], + }; const jwtHeader = { kid: jwk.kid, alg: "RS256" }; const jwtHeaderForJwkWithoutAlg = { kid: jwkWithoutAlg.kid, alg: "RS256" }; const jwtHeaderEs256 = { kid: jwkEs256.kid, alg: "ES256" }; const jwtHeaderEs512 = { kid: jwkEs512.kid, alg: "ES512" }; + const jwtHeaderEd25519 = { kid: jwkEd25519.kid, alg: "EdDSA" }; + const jwtHeaderEd448 = { kid: jwkEd448.kid, alg: "EdDSA" }; saveFile("public", JWKSFILE, jwks); saveFile(join("cypress", "fixtures"), JWKSFILE, jwks); @@ -134,6 +152,16 @@ const main = async () => { notYetValidTokenPayload, privateKey ); + tokendata.VALID_TOKEN_Ed25519 = signJwt( + jwtHeaderEd25519, + validTokenPayload, + privateKeyEd25519 + ); + tokendata.VALID_TOKEN_Ed448 = signJwt( + jwtHeaderEd448, + validTokenPayload, + privateKeyEd448 + ); saveFile(join("cypress", "fixtures"), "example-token-data.json", tokendata);