Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: [WLEO-228] Enhance Request Object Handling and JWKS Retrieval using x5c #181

Merged
merged 11 commits into from
Jan 31, 2025
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -108,10 +108,12 @@
]
},
"dependencies": {
"@types/jsrsasign": "^10.5.15",
"ajv": "^8.17.1",
"js-base64": "^3.7.7",
"js-sha256": "^0.9.0",
"jsonpath-plus": "^10.2.0",
"jsrsasign": "^11.1.0",
"parse-url": "^9.2.0",
"react-native-url-polyfill": "^2.0.0",
"react-native-uuid": "^2.0.1",
Expand Down
156 changes: 122 additions & 34 deletions src/credential/presentation/04-retrieve-rp-jwks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ import { hasStatusOrThrow } from "../../utils/misc";
import { RelyingPartyEntityConfiguration } from "../../entity/trust/types";
import { decode as decodeJwt } from "@pagopa/io-react-native-jwt";
import { NoSuitableKeysFoundInEntityConfiguration } from "./errors";
import { RequestObject } from "./types";
import {
convertCertToPem,
parsePublicKey,
getSigningJwk,
} from "../../utils/crypto";

/**
* Defines the signature for a function that retrieves JSON Web Key Sets (JWKS) from a client.
Expand All @@ -16,54 +22,136 @@ export type FetchJwks<T extends Array<unknown> = []> = (...args: T) => Promise<{
}>;

/**
* Retrieves the JSON Web Key Set (JWKS) from the specified client's well-known endpoint.
* It is formed using `{issUrl.base}/.well-known/jar-issuer${issUrl.pah}` as explained in SD-JWT VC issuer metadata section
* Fetches and parses JWKS from a given URI.
*
* @param requestObjectEncodedJwt - Request Object in JWT format.
* @param options - Optional context containing a custom fetch implementation.
* @param options.context - Optional context object.
* @param options.context.appFetch - Optional custom fetch function to use instead of the global `fetch`.
* @returns A promise resolving to an object containing an array of JWKs.
* @throws Will throw an error if the JWKS retrieval fails.
* @param jwksUri - The JWKS URI.
* @param fetchFn - The fetch function to use.
* @returns An array of JWKs.
*/
const fetchJwksFromUri = async (
jwksUri: string,
appFetch: GlobalFetch["fetch"]
): Promise<JWK[]> => {
const jwks = await appFetch(jwksUri, {
method: "GET",
})
.then(hasStatusOrThrow(200))
.then((raw) => raw.json())
.then((json) => (json.jwks ? JWKS.parse(json.jwks) : JWKS.parse(json)));
return jwks.keys;
};

/**
* Retrieves JWKS when the client ID scheme includes x509 SAN DNS.
*
* @param decodedJwt - The decoded JWT.
* @param fetchFn - The fetch function to use.
* @returns An array of JWKs.
* @throws Will throw an error if no suitable keys are found.
*/
const getJwksFromX509Cert = async (certChain: string[]): Promise<JWK[]> => {
if (!Array.isArray(certChain) || certChain.length === 0 || !certChain[0]) {
throw new NoSuitableKeysFoundInEntityConfiguration(
"No RP encrypt key found!"
);
}

const pemCert = convertCertToPem(certChain[0]);
const publicKey = parsePublicKey(pemCert);
if (!publicKey) {
throw new NoSuitableKeysFoundInEntityConfiguration(
"Unsupported public key type."
);
}
const signingJwk = getSigningJwk(publicKey);

return [signingJwk];
};

/**
* Constructs the well-known JWKS URL based on the issuer claim.
*
* @param issuer - The issuer URL.
* @returns The well-known JWKS URL.
*/
const constructWellKnownJwksUrl = (issuer: string): string => {
const issuerUrl = new URL(issuer);
return new URL(
`/.well-known/jar-issuer${issuerUrl.pathname}`,
`${issuerUrl.protocol}//${issuerUrl.host}`
).toString();
};

/**
* Fetches the JSON Web Key Set (JWKS) based on the provided Request Object encoded as a JWT.
* The retrieval process follows these steps in order:
*
* 1. **Direct JWK Retrieval**: If the JWT's protected header contains a `jwk` attribute, it uses this key directly.
* 2. **X.509 Certificate Retrieval**: If the protected header includes an `x5c` attribute, it extracts the JWKs from the provided X.509 certificate chain.
* 3. **Issuer's Well-Known Endpoint**: If neither `jwk` nor `x5c` are present, it constructs the JWKS URL using the issuer (`iss`) claim and fetches the keys from the issuer's well-known JWKS endpoint.
*
* The JWKS URL is constructed in the format `{issUrl.base}/.well-known/jar-issuer${issUrl.path}`,
* as detailed in the SD-JWT VC issuer metadata specification.
*
* @param requestObjectEncodedJwt - The Request Object encoded as a JWT.
* @param options - Optional parameters for fetching the JWKS.
* @param options.context - Optional context providing a custom fetch implementation.
* @param options.context.appFetch - A custom fetch function to replace the global `fetch` if provided.
* @returns A promise that resolves to an object containing an array of JSON Web Keys (JWKs).
* @throws {NoSuitableKeysFoundInEntityConfiguration} Throws an error if JWKS retrieval or key extraction fails.
*/
export const fetchJwksFromRequestObject: FetchJwks<
[string, { context?: { appFetch?: GlobalFetch["fetch"] } }?]
> = async (requestObjectEncodedJwt, { context = {} } = {}) => {
const { appFetch = fetch } = context;
const requestObjectJwt = decodeJwt(requestObjectEncodedJwt);
const jwks: JWK[] = [];

// 1. check if request object jwt contains the 'jwk' attribute
if (requestObjectJwt.protectedHeader?.jwk) {
return {
keys: [JWK.parse(requestObjectJwt.protectedHeader.jwk)],
};
const keys = [JWK.parse(requestObjectJwt.protectedHeader.jwk)];
jwks.push(...keys);
}

// 2. check if request object jwt contains the 'x5c' attribute
if (requestObjectJwt.protectedHeader.x5c) {
const keys = await getJwksFromX509Cert(
requestObjectJwt.protectedHeader.x5c
);
jwks.push(...keys);
}

// 3. check if client_metadata contains the 'jwks' or 'jwks_uri' attribute
const requestObject = RequestObject.parse(requestObjectJwt.payload);
const { client_metadata } = requestObject;

if (client_metadata?.jwks_uri) {
const fetchedJwks = await fetchJwksFromUri(
new URL(client_metadata.jwks_uri).toString(),
appFetch
);
jwks.push(...fetchedJwks);
}

if (client_metadata?.jwks) {
jwks.push(...client_metadata.jwks.keys);
}

// 3. According to Potential profile, retrieve from RP endpoint using iss claim
const issuer = requestObjectJwt.payload?.iss;
if (jwks.length === 0 && typeof issuer === "string") {
const wellKnownJwksUrl = constructWellKnownJwksUrl(issuer);
const jwksKeys = await fetchJwksFromUri(wellKnownJwksUrl, appFetch);
jwks.push(...jwksKeys);
}

// 2. According to Potential profile, retrieve from RP endpoint using iss claim
const issClaimValue = requestObjectJwt.payload?.iss as string;
if (issClaimValue) {
const issUrl = new URL(issClaimValue);
const wellKnownUrl = new URL(
`/.well-known/jar-issuer${issUrl.pathname}`,
`${issUrl.protocol}//${issUrl.host}`
).toString();

// Fetches the JWKS from a specific endpoint of the entity's well-known configuration
const jwks = await appFetch(wellKnownUrl, {
method: "GET",
})
.then(hasStatusOrThrow(200))
.then((raw) => raw.json())
.then((json) => JWKS.parse(json.jwks));

return {
keys: jwks.keys,
};
if (jwks.length === 0) {
throw new NoSuitableKeysFoundInEntityConfiguration(
"Request Object signature verification"
);
}

throw new NoSuitableKeysFoundInEntityConfiguration(
"Request Object signature verification"
);
return { keys: jwks };
};

/**
Expand Down
7 changes: 4 additions & 3 deletions src/credential/presentation/05-verify-request-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@ export const verifyRequestObjectSignature: VerifyRequestObjectSignature =
const requestObjectJwt = decodeJwt(requestObjectEncodedJwt);

// verify token signature to ensure the request object is authentic
const pubKey = jwkKeys?.find(
({ kid }) => kid === requestObjectJwt.protectedHeader.kid
);
const pubKey =
jwkKeys?.find(
({ kid }) => kid === requestObjectJwt.protectedHeader.kid
) || jwkKeys?.find(({ use }) => use === "sig");

if (!pubKey) {
throw new UnverifiedEntityError("Request Object signature verification!");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,13 @@ import { JWKS, JWK } from "../../../utils/jwk";
import { RelyingPartyEntityConfiguration } from "../../../entity/trust/types";
import { decode as decodeJwt } from "@pagopa/io-react-native-jwt";
import { NoSuitableKeysFoundInEntityConfiguration } from "../errors";
import { RequestObject } from "../types";

// Mock the JWKS and JWK utilities
jest.mock("../../../utils/jwk", () => ({
JWKS: {
parse: jest.fn(),
},
JWK: {
parse: jest.fn(),
},
}));
beforeEach(() => {
jest.spyOn(JWKS, "parse").mockImplementation(jest.fn());
jest.spyOn(JWK, "parse").mockImplementation(jest.fn());
jest.spyOn(RequestObject, "parse").mockImplementation(jest.fn());
});

// Mock the RelyingPartyEntityConfiguration
jest.mock("../../../entity/trust/types", () => ({
Expand All @@ -30,8 +27,14 @@ jest.mock("../../../entity/trust/types", () => ({
jest.mock("@pagopa/io-react-native-jwt", () => ({ decode: jest.fn() }));

describe("fetchJwksFromRequestObject", () => {
const mockRequestObject = {} as unknown as RequestObject;

beforeEach(() => {
jest.clearAllMocks();

(RequestObject.parse as jest.Mock).mockImplementation(
(_) => mockRequestObject
);
});

it("returns keys from protected header when JWT contains jwk attribute", async () => {
Expand Down
9 changes: 8 additions & 1 deletion src/credential/presentation/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { CryptoContext } from "@pagopa/io-react-native-jwt";
import { UnixTime } from "../../sd-jwt/types";
import * as z from "zod";
import { JWKS } from "../../utils/jwk";

/**
* A pair that associate a tokenized Verified Credential with the claims presented or requested to present.
Expand Down Expand Up @@ -77,7 +78,13 @@ export const RequestObject = z.object({
response_type: z.literal("vp_token"),
response_mode: z.enum(["direct_post.jwt", "direct_post"]),
client_id: z.string(),
client_id_scheme: z.string(), // previous z.literal("entity_id"),
client_id_scheme: z.string().optional(), // previous z.literal("entity_id"),
client_metadata: z
.object({
jwks_uri: z.string().optional(),
jwks: JWKS.optional(),
})
.optional(), // previous z.literal("entity_id"),
scope: z.string().optional(),
presentation_definition: PresentationDefinition.optional(),
});
43 changes: 43 additions & 0 deletions src/utils/crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
import uuid from "react-native-uuid";
import { thumbprint, type CryptoContext } from "@pagopa/io-react-native-jwt";
import { fixBase64EncodingOnKey } from "./jwk";
import { X509, KEYUTIL, RSAKey, KJUR } from "jsrsasign";
import { JWK } from "./jwk";

/**
* Create a CryptoContext bound to a key pair.
Expand Down Expand Up @@ -63,3 +65,44 @@ export const withEphemeralKey = async <R>(
const ephemeralContext = createCryptoContextFor(keytag);
return fn(ephemeralContext).finally(() => deleteKey(keytag));
};

/**
* Converts a certificate string to PEM format.
*
* @param certificate - The certificate string.
* @returns The PEM-formatted certificate.
*/
export const convertCertToPem = (certificate: string): string =>
`-----BEGIN CERTIFICATE-----\n${certificate}\n-----END CERTIFICATE-----`;

/**
* Parses the public key from a PEM-formatted certificate.
*
* @param pemCert - The PEM-formatted certificate.
* @returns The public key object.
* @throws Will throw an error if the public key is unsupported.
*/
export const parsePublicKey = (
pemCert: string
): RSAKey | KJUR.crypto.ECDSA | undefined => {
const x509 = new X509();
x509.readCertPEM(pemCert);
const publicKey = x509.getPublicKey();

if (publicKey instanceof RSAKey || publicKey instanceof KJUR.crypto.ECDSA) {
return publicKey;
}

return undefined;
};

/**
* Retrieves the signing JWK from the public key.
*
* @param publicKey - The public key object.
* @returns The signing JWK.
*/
export const getSigningJwk = (publicKey: RSAKey | KJUR.crypto.ECDSA): JWK => ({
...JWK.parse(KEYUTIL.getJWKFromKey(publicKey)),
use: "sig",
});
10 changes: 10 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2594,6 +2594,11 @@
resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==

"@types/jsrsasign@^10.5.15":
version "10.5.15"
resolved "https://registry.yarnpkg.com/@types/jsrsasign/-/jsrsasign-10.5.15.tgz#5cf1ee506b2fa2435b6e1786a873285c7110eb82"
integrity sha512-3stUTaSRtN09PPzVWR6aySD9gNnuymz+WviNHoTb85dKu+BjaV4uBbWWGykBBJkfwPtcNZVfTn2lbX00U+yhpQ==

"@types/minimist@^1.2.0":
version "1.2.5"
resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.5.tgz#ec10755e871497bcd83efe927e43ec46e8c0747e"
Expand Down Expand Up @@ -6173,6 +6178,11 @@ jsonpath-plus@^10.2.0:
"@jsep-plugin/regex" "^1.0.4"
jsep "^1.4.0"

jsrsasign@^11.1.0:
version "11.1.0"
resolved "https://registry.yarnpkg.com/jsrsasign/-/jsrsasign-11.1.0.tgz#195e788102731102fbf3e36b33fde28936f4bf57"
integrity sha512-Ov74K9GihaK9/9WncTe1mPmvrO7Py665TUfUKvraXBpu+xcTWitrtuOwcjf4KMU9maPaYn0OuaWy0HOzy/GBXg==

"jsx-ast-utils@^2.4.1 || ^3.0.0":
version "3.3.4"
resolved "https://registry.yarnpkg.com/jsx-ast-utils/-/jsx-ast-utils-3.3.4.tgz#b896535fed5b867650acce5a9bd4135ffc7b3bf9"
Expand Down
Loading