From 64b3ec78580e99eb83fcacde7d5712f1fbd8298a Mon Sep 17 00:00:00 2001 From: Udara Pathum <46132469+hwupathum@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:22:26 +0530 Subject: [PATCH] MLDSA signature validation support --- packages/javascript/src/DefaultCrypto.ts | 2 +- .../src/__tests__/DefaultCrypto.test.ts | 90 +++++++++++++++++++ .../src/constants/TokenConstants.ts | 3 +- packages/javascript/src/models/crypto.ts | 6 +- packages/node/src/utils/NodeCryptoUtils.ts | 2 +- pnpm-lock.yaml | 20 ++--- pnpm-workspace.yaml | 2 +- 7 files changed, 109 insertions(+), 16 deletions(-) create mode 100644 packages/javascript/src/__tests__/DefaultCrypto.test.ts diff --git a/packages/javascript/src/DefaultCrypto.ts b/packages/javascript/src/DefaultCrypto.ts index 62de4de..f3d4b0c 100644 --- a/packages/javascript/src/DefaultCrypto.ts +++ b/packages/javascript/src/DefaultCrypto.ts @@ -55,7 +55,7 @@ export class DefaultCrypto implements Crypto { clockTolerance?: number, validateJwtIssuer = true, ): Promise { - const key: jose.KeyLike | Uint8Array = await jose.importJWK(jwk as jose.JWK); + const key: jose.CryptoKey | Uint8Array = await jose.importJWK(jwk as jose.JWK); await jose.jwtVerify(idToken, key, { algorithms, diff --git a/packages/javascript/src/__tests__/DefaultCrypto.test.ts b/packages/javascript/src/__tests__/DefaultCrypto.test.ts new file mode 100644 index 0000000..72a504b --- /dev/null +++ b/packages/javascript/src/__tests__/DefaultCrypto.test.ts @@ -0,0 +1,90 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you 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. + */ + +import {exportJWK, generateKeyPair, SignJWT} from 'jose'; +import {DefaultCrypto} from '../DefaultCrypto'; +import TokenConstants from '../constants/TokenConstants'; +import {JWKInterface} from '../models/crypto'; + +describe('DefaultCrypto.verifyJwt with ML-DSA (AKP JWKs)', (): void => { + it.each(['ML-DSA-44', 'ML-DSA-65', 'ML-DSA-87'] as const)( + 'verifies an id_token signed with %s against its AKP JWK', + async (alg): Promise => { + const {publicKey, privateKey} = await generateKeyPair(alg, {extractable: true}); + const jwk = (await exportJWK(publicKey)) as unknown as JWKInterface; + jwk.alg = alg; + jwk.kid = `test-${alg}`; + jwk.use = 'sig'; + + const idToken: string = await new SignJWT({}) + .setProtectedHeader({alg, kid: jwk.kid}) + .setIssuedAt() + .setIssuer('https://issuer.example') + .setAudience('client-id') + .setSubject('user-id') + .setExpirationTime('1h') + .sign(privateKey); + + const crypto = new DefaultCrypto(); + const result: boolean = await crypto.verifyJwt( + idToken, + jwk, + TokenConstants.SignatureValidation.SUPPORTED_ALGORITHMS as unknown as string[], + 'client-id', + 'https://issuer.example', + 'user-id', + ); + + expect(result).toBe(true); + }, + ); + + it('rejects an ML-DSA id_token with a tampered signature', async (): Promise => { + const alg = 'ML-DSA-65'; + const {publicKey, privateKey} = await generateKeyPair(alg, {extractable: true}); + const jwk = (await exportJWK(publicKey)) as unknown as JWKInterface; + jwk.alg = alg; + jwk.kid = 'test-tampered'; + jwk.use = 'sig'; + + const idToken: string = await new SignJWT({}) + .setProtectedHeader({alg, kid: jwk.kid}) + .setIssuedAt() + .setIssuer('https://issuer.example') + .setAudience('client-id') + .setSubject('user-id') + .setExpirationTime('1h') + .sign(privateKey); + + const parts: string[] = idToken.split('.'); + const tamperedSignature: string = parts[2].slice(0, -4) + (parts[2].endsWith('AAAA') ? 'BBBB' : 'AAAA'); + const tamperedToken = `${parts[0]}.${parts[1]}.${tamperedSignature}`; + + const crypto = new DefaultCrypto(); + await expect( + crypto.verifyJwt( + tamperedToken, + jwk, + TokenConstants.SignatureValidation.SUPPORTED_ALGORITHMS as unknown as string[], + 'client-id', + 'https://issuer.example', + 'user-id', + ), + ).rejects.toThrow(); + }); +}); diff --git a/packages/javascript/src/constants/TokenConstants.ts b/packages/javascript/src/constants/TokenConstants.ts index 5b4fe7e..5623a47 100644 --- a/packages/javascript/src/constants/TokenConstants.ts +++ b/packages/javascript/src/constants/TokenConstants.ts @@ -60,8 +60,9 @@ const TokenConstants: { * - `RS512` - RSASSA-PKCS1-v1_5 using SHA-512 * - `RS384` - RSASSA-PKCS1-v1_5 using SHA-384 * - `PS256` - RSASSA-PSS using SHA-256 and MGF1 with SHA-256 + * - `ML-DSA-44` / `ML-DSA-65` / `ML-DSA-87` - post-quantum ML-DSA (RFC 9864), AKP JWKs */ - SUPPORTED_ALGORITHMS: ['RS256', 'RS512', 'RS384', 'PS256'], + SUPPORTED_ALGORITHMS: ['RS256', 'RS512', 'RS384', 'PS256', 'ML-DSA-44', 'ML-DSA-65', 'ML-DSA-87'], }, /** diff --git a/packages/javascript/src/models/crypto.ts b/packages/javascript/src/models/crypto.ts index 4ecab6e..c85d64f 100644 --- a/packages/javascript/src/models/crypto.ts +++ b/packages/javascript/src/models/crypto.ts @@ -21,10 +21,12 @@ */ export interface JWKInterface { alg: string; - e: string; + e?: string; kid: string; kty: string; - n: string; + n?: string; + /** Raw public key bytes (base64url), for AKP JWKs (RFC 9864, e.g. ML-DSA). */ + pub?: string; use: string; } diff --git a/packages/node/src/utils/NodeCryptoUtils.ts b/packages/node/src/utils/NodeCryptoUtils.ts index d3d2770..6f14ef8 100644 --- a/packages/node/src/utils/NodeCryptoUtils.ts +++ b/packages/node/src/utils/NodeCryptoUtils.ts @@ -52,7 +52,7 @@ class NodeCryptoUtils implements Crypto { subject: string, clockTolerance?: number, ): Promise { - const key: jose.KeyLike | Uint8Array = await jose.importJWK(jwk); + const key: jose.CryptoKey | Uint8Array = await jose.importJWK(jwk); return jose .jwtVerify(idToken, key, { algorithms, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef86b2d..df33611 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -257,8 +257,8 @@ catalogs: specifier: 1.3.0 version: 1.3.0 jose: - specifier: 5.2.0 - version: 5.2.0 + specifier: 6.2.3 + version: 6.2.3 jsdom: specifier: 27.0.1 version: 27.0.1 @@ -356,7 +356,7 @@ importers: version: 1.3.0 jose: specifier: 'catalog:' - version: 5.2.0 + version: 6.2.3 process: specifier: 'catalog:' version: 0.11.10 @@ -457,7 +457,7 @@ importers: dependencies: jose: specifier: 'catalog:' - version: 5.2.0 + version: 6.2.3 tslib: specifier: 'catalog:' version: 2.8.1 @@ -503,7 +503,7 @@ importers: version: 19.2.14 jose: specifier: 'catalog:' - version: 5.2.0 + version: 6.2.3 tslib: specifier: 'catalog:' version: 2.8.1 @@ -558,7 +558,7 @@ importers: version: 1.3.0 jose: specifier: 'catalog:' - version: 5.2.0 + version: 6.2.3 memory-cache: specifier: 'catalog:' version: 0.2.0 @@ -619,7 +619,7 @@ importers: version: 6.1.5 jose: specifier: 'catalog:' - version: 5.2.0 + version: 6.2.3 vue: specifier: '>=3.5.0' version: 3.5.30(typescript@5.9.3) @@ -5739,8 +5739,8 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true - jose@5.2.0: - resolution: {integrity: sha512-oW3PCnvyrcm1HMvGTzqjxxfnEs9EoFOFWi2HsEGhlFVOXxTE3K9GKWVMFoFw06yPUqwpvEWic1BmtUZBI/tIjw==} + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} js-beautify@1.15.4: resolution: {integrity: sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==} @@ -13140,7 +13140,7 @@ snapshots: jiti@2.7.0: {} - jose@5.2.0: {} + jose@6.2.3: {} js-beautify@1.15.4: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 97eb68b..913af85 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -33,7 +33,7 @@ catalog: dompurify: 3.4.11 eslint: 9.39.4 fast-sha256: 1.3.0 - jose: 5.2.0 + jose: 6.2.3 jsdom: 27.0.1 memory-cache: 0.2.0 playwright: 1.60.0