diff --git a/CHANGELOG.md b/CHANGELOG.md index febfb4931044..fb3ee355f06b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. [7.0.5]: https://github.com/microsoft/CCF/releases/tag/ccf-7.0.5 +### Changed + +- The default and minimal sample constitutions reject `set_jwt_issuer` proposals whose `issuer` is not an `https://` URL with no query or fragment. Previously, any string was accepted when `auto_refresh` was `false` (#7924). +- The default and minimal sample constitutions reject `set_ca_cert_bundle` proposals containing non-CA certificates or intermediate CA certificates; every certificate in the bundle must be a self-signed (root) CA (#7924). +- The default and minimal sample constitutions validate every JWK in `set_jwt_issuer` and `set_jwt_public_signing_keys` proposals: `n`/`e`/`x`/`y` must be base64url-encoded, `kty` must match the supplied key material, `kid` must be unique within a key set, `use` (if present) must be `"sig"`, and `alg` (if present) must match the key type and curve per RFC 7518 section 3.4 (`RS256` for RSA; `ES256`/`ES384`/`ES512` bound to `P-256`/`P-384`/`P-521`). RSA keys must be at least 2048 bits, and EC coordinates must use the full zero-padded length for their curve (RFC 7518 section 6.2.1.2). P-521 is now an accepted EC curve (#7924). +- The default and minimal sample constitutions validate that `set_member`'s `encryption_pub_key`, when present, is a well-formed RSA public key (#7924). + ### Security - Host-created files (ledger chunks, snapshots, PID file, and node certificate/key files) are now created with restrictive permissions (`0600`) instead of relying on the process `umask`. Existing deployments will not see existing files affected; only newly created files will have these restricted permissions (#7916). diff --git a/doc/build_apps/auth/jwt.rst b/doc/build_apps/auth/jwt.rst index f3bf72053c44..48dd1fcdd4c9 100644 --- a/doc/build_apps/auth/jwt.rst +++ b/doc/build_apps/auth/jwt.rst @@ -20,7 +20,7 @@ Before adding public token signing keys to a running CCF network, the IdP has to { "name": "set_jwt_issuer", "args": { - "issuer": "my_issuer", + "issuer": "https://my.issuer", "auto_refresh": false } } @@ -38,7 +38,7 @@ After this proposal is accepted, signing keys for an issuer can be updated with { "name": "set_jwt_public_signing_keys", "args": { - "issuer": "my_issuer", + "issuer": "https://my.issuer", "jwks": { "keys": [ { diff --git a/include/ccf/crypto/curve.h b/include/ccf/crypto/curve.h index 7bbf890d5d44..f78494740935 100644 --- a/include/ccf/crypto/curve.h +++ b/include/ccf/crypto/curve.h @@ -24,7 +24,9 @@ namespace ccf::crypto SECP256R1, /// The CURVE25519 curve CURVE25519, - X25519 + X25519, + /// The SECP521R1 curve + SECP521R1 }; DECLARE_JSON_ENUM( @@ -33,7 +35,8 @@ namespace ccf::crypto {CurveID::SECP384R1, "Secp384R1"}, {CurveID::SECP256R1, "Secp256R1"}, {CurveID::CURVE25519, "Curve25519"}, - {CurveID::X25519, "X25519"}}); + {CurveID::X25519, "X25519"}, + {CurveID::SECP521R1, "Secp521R1"}}); static constexpr CurveID service_identity_curve_choice = CurveID::SECP384R1; // SNIPPET_END: supported_curves @@ -53,6 +56,8 @@ namespace ccf::crypto return MDType::SHA384; case CurveID::SECP256R1: return MDType::SHA256; + case CurveID::SECP521R1: + return MDType::SHA512; default: { throw std::logic_error(fmt::format("Unhandled CurveId: {}", ec)); diff --git a/include/ccf/crypto/jwk.h b/include/ccf/crypto/jwk.h index 2f9fd2546818..bcbceee61e06 100644 --- a/include/ccf/crypto/jwk.h +++ b/include/ccf/crypto/jwk.h @@ -78,6 +78,8 @@ namespace ccf::crypto return JsonWebKeyECCurve::P384; case CurveID::SECP256R1: return JsonWebKeyECCurve::P256; + case CurveID::SECP521R1: + return JsonWebKeyECCurve::P521; default: throw std::logic_error(fmt::format("Unknown curve {}", curve_id)); } @@ -88,8 +90,7 @@ namespace ccf::crypto switch (jwk_curve) { case JsonWebKeyECCurve::P521: - throw std::logic_error( - fmt::format("Unsupported JWK curve {}", jwk_curve)); + return CurveID::SECP521R1; case JsonWebKeyECCurve::P384: return CurveID::SECP384R1; case JsonWebKeyECCurve::P256: @@ -116,6 +117,7 @@ namespace ccf::crypto case CurveID::NONE: case CurveID::SECP384R1: case CurveID::SECP256R1: + case CurveID::SECP521R1: throw std::logic_error(fmt::format("Invalid EdDSA curve {}", curve_id)); case CurveID::CURVE25519: return JsonWebKeyEdDSACurve::ED25519; diff --git a/js/ccf-app/src/crypto.ts b/js/ccf-app/src/crypto.ts index 6eef6aa4b01f..caacc1c17806 100644 --- a/js/ccf-app/src/crypto.ts +++ b/js/ccf-app/src/crypto.ts @@ -72,6 +72,11 @@ export const isValidX509CertBundle = ccf.crypto.isValidX509CertBundle; */ export const isValidX509CertChain = ccf.crypto.isValidX509CertChain; +/** + * @inheritDoc global!CCFCrypto.isValidX509RootCACert + */ +export const isValidX509RootCACert = ccf.crypto.isValidX509RootCACert; + /** * @inheritDoc global!CCFCrypto.pubPemToJwk */ diff --git a/js/ccf-app/src/global.ts b/js/ccf-app/src/global.ts index 9497cb9ee6d6..0d2be6d8bcff 100644 --- a/js/ccf-app/src/global.ts +++ b/js/ccf-app/src/global.ts @@ -386,7 +386,8 @@ export interface CCFCrypto { /** * Generate an ECDSA key pair. * - * @param curve The name of the curve, one of "secp256r1", "secp384r1". + * @param curve The name of the curve, one of "secp256r1", "secp384r1", + * "secp521r1". */ generateEcdsaKeyPair(curve: string): CryptoKeyPair; @@ -441,6 +442,12 @@ export interface CCFCrypto { */ isValidX509CertChain(chain: string, trusted: string): boolean; + /** + * Returns whether a single PEM-encoded X.509 certificate is a self-signed (root) CA. + * Returns false for intermediate CA certificates, non-CA certificates, and malformed PEM. + */ + isValidX509RootCACert(pem: string): boolean; + /** * Converts an elliptic curve public key as PEM to JSON Web Key (JWK) object. * diff --git a/js/ccf-app/src/polyfill.ts b/js/ccf-app/src/polyfill.ts index 36a51c22202b..65f65e764900 100644 --- a/js/ccf-app/src/polyfill.ts +++ b/js/ccf-app/src/polyfill.ts @@ -463,6 +463,34 @@ class CCFPolyfill implements CCF { return false; } }, + isValidX509RootCACert(pem: string): boolean { + if (!("X509Certificate" in jscrypto)) { + throw new Error( + "X509 validation unsupported, Node.js version too old (< 15.6.0)", + ); + } + try { + const sep = "-----END CERTIFICATE-----"; + const items = pem.split(sep); + // Expect exactly one certificate. + if (items.length !== 2 || items[0].trim() === "") { + return false; + } + const cert = new (jscrypto).X509Certificate(items[0] + sep); + // Must be a CA certificate. + if (!cert.ca) { + return false; + } + // Must be self-signed: the certificate's public key must verify its own signature. + if (!cert.verify(cert.publicKey)) { + return false; + } + return true; + } catch (e: any) { + console.error(`isValidX509RootCACert validation failed: ${e.message}`); + return false; + } + }, pubPemToJwk(pem: string, kid?: string): JsonWebKeyECPublic { const key = jscrypto.createPublicKey({ key: pem, @@ -665,6 +693,10 @@ class CCFPolyfill implements CCF { return this.crypto.isValidX509CertChain(chain, trusted); } + isValidX509RootCACert(pem: string): boolean { + return this.crypto.isValidX509RootCACert(pem); + } + enableUntrustedDateTime(enable: boolean): boolean { throw new Error("Not implemented"); } diff --git a/js/ccf-app/test/crypto.ts b/js/ccf-app/test/crypto.ts index 77ecafa481e2..9bb4e29a5ea4 100644 --- a/js/ccf-app/test/crypto.ts +++ b/js/ccf-app/test/crypto.ts @@ -27,6 +27,66 @@ export function generateSelfSignedCert() { }; } +export function generateSelfSignedCACert(): string { + const keys = crypto.generateKeyPairSync("rsa", { + modulusLength: 2048, + publicKeyEncoding: { + type: "spki", + format: "pem", + }, + privateKeyEncoding: { + type: "pkcs8", + format: "pem", + }, + }); + const cert = forge.pki.createCertificate(); + cert.publicKey = forge.pki.publicKeyFromPem(keys.publicKey); + cert.setExtensions([ + { + name: "basicConstraints", + cA: true, + critical: true, + }, + ]); + cert.sign( + forge.pki.privateKeyFromPem(keys.privateKey), + forge.md.sha256.create(), + ); + return forge.pki.certificateToPem(cert); +} + +/** + * Returns a PEM-encoded intermediate CA certificate (CA:TRUE, signed by a + * freshly-generated root CA). The certificate is NOT self-signed. + */ +export function generateIntermediateCACert(): string { + const rootKeys = crypto.generateKeyPairSync("rsa", { + modulusLength: 2048, + publicKeyEncoding: { type: "spki", format: "pem" }, + privateKeyEncoding: { type: "pkcs8", format: "pem" }, + }); + const intermediateKeys = crypto.generateKeyPairSync("rsa", { + modulusLength: 2048, + publicKeyEncoding: { type: "spki", format: "pem" }, + privateKeyEncoding: { type: "pkcs8", format: "pem" }, + }); + const cert = forge.pki.createCertificate(); + cert.publicKey = forge.pki.publicKeyFromPem(intermediateKeys.publicKey); + cert.setExtensions([ + { + name: "basicConstraints", + cA: true, + critical: true, + }, + ]); + // Sign with the root key so the cert is NOT self-signed. + cert.sign( + forge.pki.privateKeyFromPem(rootKeys.privateKey), + forge.md.sha256.create(), + ); + return forge.pki.certificateToPem(cert); +} + export function generateCertChain(len: number): string[] { const keyPairs = []; for (let i = 0; i < len; i++) { diff --git a/js/ccf-app/test/polyfill.test.ts b/js/ccf-app/test/polyfill.test.ts index eb0ca46937fb..05f57f1b3e44 100644 --- a/js/ccf-app/test/polyfill.test.ts +++ b/js/ccf-app/test/polyfill.test.ts @@ -9,7 +9,12 @@ import type { } from "../src/global.js"; import { ccf } from "../src/global.js"; import * as textcodec from "../src/textcodec.js"; -import { generateSelfSignedCert, generateCertChain } from "./crypto.js"; +import { + generateSelfSignedCert, + generateSelfSignedCACert, + generateIntermediateCACert, + generateCertChain, +} from "./crypto.js"; import { toArrayBuffer } from "../src/utils.js"; beforeEach(function () { @@ -97,6 +102,13 @@ describe("polyfill", function () { assert.isTrue(pair.privateKey.startsWith("-----BEGIN PRIVATE KEY-----")); }); }); + describe("generateEcdsaKeyPair/secp521r1", function () { + it("generates a random ECDSA P521R1 key pair", function () { + const pair = ccf.crypto.generateEcdsaKeyPair("secp521r1"); + assert.isTrue(pair.publicKey.startsWith("-----BEGIN PUBLIC KEY-----")); + assert.isTrue(pair.privateKey.startsWith("-----BEGIN PRIVATE KEY-----")); + }); + }); describe("generateEddsaKeyPair/Curve25519", function () { it("generates a random EdDSA Curve25519 key pair", function () { const pair = ccf.crypto.generateEddsaKeyPair("curve25519"); @@ -584,7 +596,7 @@ describe("polyfill", function () { describe("pemToJwk and jwkToPem", function () { it("EC", function () { const my_kid = "my_kid"; - const curves = ["secp256r1", "secp384r1"]; + const curves = ["secp256r1", "secp384r1", "secp521r1"]; for (const curve of curves) { const pair = ccf.crypto.generateEcdsaKeyPair(curve); { @@ -779,4 +791,35 @@ describe("polyfill", function () { assert.isFalse(ccf.crypto.isValidX509CertChain(chain, trusted)); }); }); + describe("isValidX509RootCACert", function (this) { + const supported = "X509Certificate" in crypto; + it("returns true for a self-signed CA certificate", function () { + if (!supported) { + this.skip(); + } + const pem = generateSelfSignedCACert(); + assert.isTrue(ccf.crypto.isValidX509RootCACert(pem)); + }); + it("returns false for a non-CA self-signed certificate", function () { + if (!supported) { + this.skip(); + } + const pem = generateSelfSignedCert().cert; + assert.isFalse(ccf.crypto.isValidX509RootCACert(pem)); + }); + it("returns false for an intermediate CA certificate", function () { + if (!supported) { + this.skip(); + } + // An intermediate CA has CA:TRUE but is signed by a different key (not self-signed). + const pem = generateIntermediateCACert(); + assert.isFalse(ccf.crypto.isValidX509RootCACert(pem)); + }); + it("returns false for malformed input", function () { + if (!supported) { + this.skip(); + } + assert.isFalse(ccf.crypto.isValidX509RootCACert("garbage")); + }); + }); }); diff --git a/samples/constitutions/default/actions.js b/samples/constitutions/default/actions.js index 667a044071ae..20c9bd69bb0e 100644 --- a/samples/constitutions/default/actions.js +++ b/samples/constitutions/default/actions.js @@ -95,6 +95,109 @@ function checkArrayBufferLength(value, min, max, field) { } } +function checkBase64Url(value, field) { + checkType(value, "string", field); + if (!/^[A-Za-z0-9_-]+$/.test(value) || value.length % 4 === 1) { + throw new Error(`${field} must be base64url encoded`); + } +} + +function base64UrlByteLength(value, field) { + checkBase64Url(value, field); + return Math.floor(value.length / 4) * 3 + [0, 0, 1, 2][value.length % 4]; +} + +function splitX509CertBundle(value) { + // Match complete PEM certificates with both BEGIN and END markers. + // This ensures we only extract valid PEM blocks and reject malformed input. + const pemPattern = + /-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g; + const certs = value.match(pemPattern); + + if (!certs || certs.length === 0) { + throw new Error("No valid PEM certificates found in bundle"); + } + + // Verify the input contains only certificates and whitespace. + // Use a single-pass approach: replace all matched certificates with empty string + // using the global regex, then check if only whitespace remains. + const remaining = value.replace(pemPattern, ""); + + if (remaining.trim() !== "") { + throw new Error( + "Certificate bundle contains invalid content between certificates", + ); + } + + return certs; +} + +function checkX509CACertBundle(value, field) { + checkX509CertBundle(value, field); + // isValidX509RootCACert(pem) is backed by a C++ function that checks both + // X509_check_ca (CA:TRUE or self-signed x509v1) and EXFLAG_SS (self-signed). + // Every certificate in the bundle must be a root (self-signed) CA; intermediate + // CAs are rejected even when their signing root is also present in the bundle. + for (const [i, cert] of splitX509CertBundle(value).entries()) { + if (!ccf.crypto.isValidX509RootCACert(cert)) { + throw new Error( + `${field}[${i}] must be a self-signed (root) CA certificate`, + ); + } + } +} + +function checkRsaPublicKey(jwk, field) { + checkType(jwk.n, "string", `${field}.n`); + checkType(jwk.e, "string", `${field}.e`); + checkBase64Url(jwk.e, `${field}.e`); + // RFC 7518 section 6.3.1.1 requires `n` to be the unsigned big-endian modulus with + // no leading zero octets. Under that encoding a 2048-bit modulus is exactly + // 256 octets, so anything shorter is conclusively below 2048 bits and the + // key is too weak. Encoded length is a sufficient (and cheap) proxy here; + // the precise bit length is not exposed to JS, but pubRsaJwkToPem below + // catches any structurally-invalid modulus. + if (base64UrlByteLength(jwk.n, `${field}.n`) < 256) { + throw new Error(`${field}.n must be at least 2048 bits`); + } + try { + ccf.crypto.pubRsaJwkToPem({ + kty: "RSA", + kid: jwk.kid, + n: jwk.n, + e: jwk.e, + }); + } catch (e) { + throw new Error(`${field} must be a valid RSA public key`); + } +} + +function checkEcPublicKey(jwk, field) { + checkType(jwk.x, "string", `${field}.x`); + checkType(jwk.y, "string", `${field}.y`); + checkType(jwk.crv, "string", `${field}.crv`); + checkEnum(jwk.crv, ["P-256", "P-384", "P-521"], `${field}.crv`); + const coordinateLengths = { "P-256": 32, "P-384": 48, "P-521": 66 }; + const coordinateLength = coordinateLengths[jwk.crv]; + if (base64UrlByteLength(jwk.x, `${field}.x`) !== coordinateLength) { + throw new Error(`${field}.x must be ${coordinateLength} bytes`); + } + if (base64UrlByteLength(jwk.y, `${field}.y`) !== coordinateLength) { + throw new Error(`${field}.y must be ${coordinateLength} bytes`); + } + try { + ccf.crypto.pubJwkToPem({ + kty: "EC", + kid: jwk.kid, + crv: jwk.crv, + x: jwk.x, + y: jwk.y, + }); + } catch (e) { + throw new Error(`${field} must be a valid EC public key`); + } +} + const cpuid_length_bytes = 4; function checkValidCpuid(value, field) { checkType(value, "string", field); @@ -174,26 +277,88 @@ function getActiveRecoveryMembersCount() { function checkJwks(value, field) { checkType(value, "object", field); checkType(value.keys, "array", `${field}.keys`); + const kids = new Set(); for (const [i, jwk] of value.keys.entries()) { + const keyField = `${field}.keys[${i}]`; checkType(jwk.kid, "string", `${field}.keys[${i}].kid`); + if (kids.has(jwk.kid)) { + throw new Error(`${field}.keys[${i}].kid must be unique`); + } + kids.add(jwk.kid); checkType(jwk.kty, "string", `${field}.keys[${i}].kty`); + checkEnum(jwk.kty, ["RSA", "EC"], `${field}.keys[${i}].kty`); + if (jwk.use !== undefined) { + checkType(jwk.use, "string", `${keyField}.use`); + checkEnum(jwk.use, ["sig"], `${keyField}.use`); + } + if (jwk.alg !== undefined) { + checkType(jwk.alg, "string", `${keyField}.alg`); + let allowedAlg; + if (jwk.kty === "RSA") { + allowedAlg = ["RS256"]; + } else { + // Per RFC 7518 section 3.4, EC alg is determined by the curve. When + // only x5c is supplied, crv may not be present on the JWK; in that + // case allow any of the supported ES* algorithms and rely on the cert + // to bind alg to curve. + const ecAlgByCrv = { + "P-256": "ES256", + "P-384": "ES384", + "P-521": "ES512", + }; + allowedAlg = + jwk.crv && ecAlgByCrv[jwk.crv] + ? [ecAlgByCrv[jwk.crv]] + : Object.values(ecAlgByCrv); + } + checkEnum(jwk.alg, allowedAlg, `${keyField}.alg`); + } if (jwk.x5c) { checkArrayLength(jwk.x5c, 1, null, `${field}.keys[${i}].x5c`); + let certBundle = ""; for (const [j, b64der] of jwk.x5c.entries()) { checkType(b64der, "string", `${field}.keys[${i}].x5c[${j}]`); + if (!/^[A-Za-z0-9+/]+={0,2}$/.test(b64der)) { + throw new Error( + `${field}.keys[${i}].x5c[${j}] must be base64 encoded`, + ); + } const pem = "-----BEGIN CERTIFICATE-----\n" + b64der + "\n-----END CERTIFICATE-----"; checkX509CertBundle(pem, `${field}.keys[${i}].x5c[${j}]`); + certBundle += pem; + } + const trustedRoot = + "-----BEGIN CERTIFICATE-----\n" + + jwk.x5c[jwk.x5c.length - 1] + + "\n-----END CERTIFICATE-----"; + if (!ccf.crypto.isValidX509CertChain(certBundle, trustedRoot)) { + throw new Error(`${field}.keys[${i}].x5c must chain to its root`); + } + if (jwk.n !== undefined || jwk.e !== undefined) { + if (jwk.kty !== "RSA") { + throw new Error(`${field}.keys[${i}].kty must be RSA for n/e keys`); + } + checkRsaPublicKey(jwk, keyField); + } + if (jwk.x !== undefined || jwk.y !== undefined || jwk.crv !== undefined) { + if (jwk.kty !== "EC") { + throw new Error(`${field}.keys[${i}].kty must be EC for x/y keys`); + } + checkEcPublicKey(jwk, keyField); } } else if (jwk.n && jwk.e) { - checkType(jwk.n, "string", `${field}.keys[${i}].n`); - checkType(jwk.e, "string", `${field}.keys[${i}].e`); + if (jwk.kty !== "RSA") { + throw new Error(`${field}.keys[${i}].kty must be RSA for n/e keys`); + } + checkRsaPublicKey(jwk, keyField); } else if (jwk.x && jwk.y) { - checkType(jwk.x, "string", `${field}.keys[${i}].x`); - checkType(jwk.y, "string", `${field}.keys[${i}].y`); - checkType(jwk.crv, "string", `${field}.keys[${i}].crv`); + if (jwk.kty !== "EC") { + throw new Error(`${field}.keys[${i}].kty must be EC for x/y keys`); + } + checkEcPublicKey(jwk, keyField); } else { throw new Error( "JWK must contain either x5c, or n/e for RSA key type, or x/y/crv for EC key type", @@ -437,7 +602,15 @@ const actions = new Map([ "Cannot specify a recovery_role value when encryption_pub_key is not specified", ); } - // Also check that public encryption key is well formed, if it exists + if ( + args.encryption_pub_key !== null && + args.encryption_pub_key !== undefined + ) { + checkRsaPublicKey( + ccf.crypto.pubRsaPemToJwk(args.encryption_pub_key), + "encryption_pub_key", + ); + } }, function (args) { @@ -928,7 +1101,7 @@ const actions = new Map([ new Action( function (args) { checkType(args.name, "string", "name"); - checkX509CertBundle(args.cert_bundle, "cert_bundle"); + checkX509CACertBundle(args.cert_bundle, "cert_bundle"); }, function (args) { const name = args.name; @@ -963,28 +1136,24 @@ const actions = new Map([ if (args.jwks) { checkJwks(args.jwks, "jwks"); } + let url; + try { + url = parseUrl(args.issuer); + } catch (e) { + throw new Error("issuer must be a URL"); + } + if (url.scheme != "https" || !url.authority) { + throw new Error("issuer must be a URL starting with https://"); + } + if (url.query || url.fragment) { + throw new Error("issuer must be a URL without query/fragment"); + } if (args.auto_refresh) { if (!args.ca_cert_bundle_name) { throw new Error( "ca_cert_bundle_name is missing but required if auto_refresh is true", ); } - let url; - try { - url = parseUrl(args.issuer); - } catch (e) { - throw new Error("issuer must be a URL if auto_refresh is true"); - } - if (url.scheme != "https") { - throw new Error( - "issuer must be a URL starting with https:// if auto_refresh is true", - ); - } - if (url.query || url.fragment) { - throw new Error( - "issuer must be a URL without query/fragment if auto_refresh is true", - ); - } } }, function (args) { diff --git a/samples/minimal_ccf/app/actions.js b/samples/minimal_ccf/app/actions.js index 5531ace40060..d04e54c83494 100644 --- a/samples/minimal_ccf/app/actions.js +++ b/samples/minimal_ccf/app/actions.js @@ -95,6 +95,109 @@ function checkArrayBufferLength(value, min, max, field) { } } +function checkBase64Url(value, field) { + checkType(value, "string", field); + if (!/^[A-Za-z0-9_-]+$/.test(value) || value.length % 4 === 1) { + throw new Error(`${field} must be base64url encoded`); + } +} + +function base64UrlByteLength(value, field) { + checkBase64Url(value, field); + return Math.floor(value.length / 4) * 3 + [0, 0, 1, 2][value.length % 4]; +} + +function splitX509CertBundle(value) { + // Match complete PEM certificates with both BEGIN and END markers. + // This ensures we only extract valid PEM blocks and reject malformed input. + const pemPattern = + /-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g; + const certs = value.match(pemPattern); + + if (!certs || certs.length === 0) { + throw new Error("No valid PEM certificates found in bundle"); + } + + // Verify the input contains only certificates and whitespace. + // Use a single-pass approach: replace all matched certificates with empty string + // using the global regex, then check if only whitespace remains. + const remaining = value.replace(pemPattern, ""); + + if (remaining.trim() !== "") { + throw new Error( + "Certificate bundle contains invalid content between certificates", + ); + } + + return certs; +} + +function checkX509CACertBundle(value, field) { + checkX509CertBundle(value, field); + // isValidX509RootCACert(pem) is backed by a C++ function that checks both + // X509_check_ca (CA:TRUE or self-signed x509v1) and EXFLAG_SS (self-signed). + // Every certificate in the bundle must be a root (self-signed) CA; intermediate + // CAs are rejected even when their signing root is also present in the bundle. + for (const [i, cert] of splitX509CertBundle(value).entries()) { + if (!ccf.crypto.isValidX509RootCACert(cert)) { + throw new Error( + `${field}[${i}] must be a self-signed (root) CA certificate`, + ); + } + } +} + +function checkRsaPublicKey(jwk, field) { + checkType(jwk.n, "string", `${field}.n`); + checkType(jwk.e, "string", `${field}.e`); + checkBase64Url(jwk.e, `${field}.e`); + // RFC 7518 section 6.3.1.1 requires `n` to be the unsigned big-endian modulus with + // no leading zero octets. Under that encoding a 2048-bit modulus is exactly + // 256 octets, so anything shorter is conclusively below 2048 bits and the + // key is too weak. Encoded length is a sufficient (and cheap) proxy here; + // the precise bit length is not exposed to JS, but pubRsaJwkToPem below + // catches any structurally-invalid modulus. + if (base64UrlByteLength(jwk.n, `${field}.n`) < 256) { + throw new Error(`${field}.n must be at least 2048 bits`); + } + try { + ccf.crypto.pubRsaJwkToPem({ + kty: "RSA", + kid: jwk.kid, + n: jwk.n, + e: jwk.e, + }); + } catch (e) { + throw new Error(`${field} must be a valid RSA public key`); + } +} + +function checkEcPublicKey(jwk, field) { + checkType(jwk.x, "string", `${field}.x`); + checkType(jwk.y, "string", `${field}.y`); + checkType(jwk.crv, "string", `${field}.crv`); + checkEnum(jwk.crv, ["P-256", "P-384", "P-521"], `${field}.crv`); + const coordinateLengths = { "P-256": 32, "P-384": 48, "P-521": 66 }; + const coordinateLength = coordinateLengths[jwk.crv]; + if (base64UrlByteLength(jwk.x, `${field}.x`) !== coordinateLength) { + throw new Error(`${field}.x must be ${coordinateLength} bytes`); + } + if (base64UrlByteLength(jwk.y, `${field}.y`) !== coordinateLength) { + throw new Error(`${field}.y must be ${coordinateLength} bytes`); + } + try { + ccf.crypto.pubJwkToPem({ + kty: "EC", + kid: jwk.kid, + crv: jwk.crv, + x: jwk.x, + y: jwk.y, + }); + } catch (e) { + throw new Error(`${field} must be a valid EC public key`); + } +} + function checkValidCpuid(value, field) { checkType(value, "string", field); if (value !== value.toLowerCase()) { @@ -154,26 +257,88 @@ function getActiveRecoveryMembersCount() { function checkJwks(value, field) { checkType(value, "object", field); checkType(value.keys, "array", `${field}.keys`); + const kids = new Set(); for (const [i, jwk] of value.keys.entries()) { + const keyField = `${field}.keys[${i}]`; checkType(jwk.kid, "string", `${field}.keys[${i}].kid`); + if (kids.has(jwk.kid)) { + throw new Error(`${field}.keys[${i}].kid must be unique`); + } + kids.add(jwk.kid); checkType(jwk.kty, "string", `${field}.keys[${i}].kty`); + checkEnum(jwk.kty, ["RSA", "EC"], `${field}.keys[${i}].kty`); + if (jwk.use !== undefined) { + checkType(jwk.use, "string", `${keyField}.use`); + checkEnum(jwk.use, ["sig"], `${keyField}.use`); + } + if (jwk.alg !== undefined) { + checkType(jwk.alg, "string", `${keyField}.alg`); + let allowedAlg; + if (jwk.kty === "RSA") { + allowedAlg = ["RS256"]; + } else { + // Per RFC 7518 section 3.4, EC alg is determined by the curve. When + // only x5c is supplied, crv may not be present on the JWK; in that + // case allow any of the supported ES* algorithms and rely on the cert + // to bind alg to curve. + const ecAlgByCrv = { + "P-256": "ES256", + "P-384": "ES384", + "P-521": "ES512", + }; + allowedAlg = + jwk.crv && ecAlgByCrv[jwk.crv] + ? [ecAlgByCrv[jwk.crv]] + : Object.values(ecAlgByCrv); + } + checkEnum(jwk.alg, allowedAlg, `${keyField}.alg`); + } if (jwk.x5c) { checkArrayLength(jwk.x5c, 1, null, `${field}.keys[${i}].x5c`); + let certBundle = ""; for (const [j, b64der] of jwk.x5c.entries()) { checkType(b64der, "string", `${field}.keys[${i}].x5c[${j}]`); + if (!/^[A-Za-z0-9+/]+={0,2}$/.test(b64der)) { + throw new Error( + `${field}.keys[${i}].x5c[${j}] must be base64 encoded`, + ); + } const pem = "-----BEGIN CERTIFICATE-----\n" + b64der + "\n-----END CERTIFICATE-----"; checkX509CertBundle(pem, `${field}.keys[${i}].x5c[${j}]`); + certBundle += pem; + } + const trustedRoot = + "-----BEGIN CERTIFICATE-----\n" + + jwk.x5c[jwk.x5c.length - 1] + + "\n-----END CERTIFICATE-----"; + if (!ccf.crypto.isValidX509CertChain(certBundle, trustedRoot)) { + throw new Error(`${field}.keys[${i}].x5c must chain to its root`); + } + if (jwk.n !== undefined || jwk.e !== undefined) { + if (jwk.kty !== "RSA") { + throw new Error(`${field}.keys[${i}].kty must be RSA for n/e keys`); + } + checkRsaPublicKey(jwk, keyField); + } + if (jwk.x !== undefined || jwk.y !== undefined || jwk.crv !== undefined) { + if (jwk.kty !== "EC") { + throw new Error(`${field}.keys[${i}].kty must be EC for x/y keys`); + } + checkEcPublicKey(jwk, keyField); } } else if (jwk.n && jwk.e) { - checkType(jwk.n, "string", `${field}.keys[${i}].n`); - checkType(jwk.e, "string", `${field}.keys[${i}].e`); + if (jwk.kty !== "RSA") { + throw new Error(`${field}.keys[${i}].kty must be RSA for n/e keys`); + } + checkRsaPublicKey(jwk, keyField); } else if (jwk.x && jwk.y) { - checkType(jwk.x, "string", `${field}.keys[${i}].x`); - checkType(jwk.y, "string", `${field}.keys[${i}].y`); - checkType(jwk.crv, "string", `${field}.keys[${i}].crv`); + if (jwk.kty !== "EC") { + throw new Error(`${field}.keys[${i}].kty must be EC for x/y keys`); + } + checkEcPublicKey(jwk, keyField); } else { throw new Error( "JWK must contain either x5c, or n/e for RSA key type, or x/y/crv for EC key type", @@ -416,7 +581,15 @@ const actions = new Map([ "Cannot specify a recovery_role value when encryption_pub_key is not specified", ); } - // Also check that public encryption key is well formed, if it exists + if ( + args.encryption_pub_key !== null && + args.encryption_pub_key !== undefined + ) { + checkRsaPublicKey( + ccf.crypto.pubRsaPemToJwk(args.encryption_pub_key), + "encryption_pub_key", + ); + } }, function (args) { @@ -907,7 +1080,7 @@ const actions = new Map([ new Action( function (args) { checkType(args.name, "string", "name"); - checkX509CertBundle(args.cert_bundle, "cert_bundle"); + checkX509CACertBundle(args.cert_bundle, "cert_bundle"); }, function (args) { const name = args.name; @@ -942,28 +1115,24 @@ const actions = new Map([ if (args.jwks) { checkJwks(args.jwks, "jwks"); } + let url; + try { + url = parseUrl(args.issuer); + } catch (e) { + throw new Error("issuer must be a URL"); + } + if (url.scheme != "https" || !url.authority) { + throw new Error("issuer must be a URL starting with https://"); + } + if (url.query || url.fragment) { + throw new Error("issuer must be a URL without query/fragment"); + } if (args.auto_refresh) { if (!args.ca_cert_bundle_name) { throw new Error( "ca_cert_bundle_name is missing but required if auto_refresh is true", ); } - let url; - try { - url = parseUrl(args.issuer); - } catch (e) { - throw new Error("issuer must be a URL if auto_refresh is true"); - } - if (url.scheme != "https") { - throw new Error( - "issuer must be a URL starting with https:// if auto_refresh is true", - ); - } - if (url.query || url.fragment) { - throw new Error( - "issuer must be a URL without query/fragment if auto_refresh is true", - ); - } } }, function (args) { diff --git a/src/crypto/openssl/ec_key_pair.cpp b/src/crypto/openssl/ec_key_pair.cpp index ef7e3541ff03..c2236cd3b306 100644 --- a/src/crypto/openssl/ec_key_pair.cpp +++ b/src/crypto/openssl/ec_key_pair.cpp @@ -511,7 +511,10 @@ namespace ccf::crypto // As per https://www.openssl.org/docs/man1.0.2/man3/BN_num_bytes.html, size // should not be calculated with BN_num_bytes(d)! - size_t size = EVP_PKEY_bits(key) / CHAR_BIT; + // Use ceiling division so curves whose bit-size is not a multiple of 8 + // (e.g. P-521 with 521 bits) get the full 66-byte buffer rather than a + // truncated 65-byte one. + size_t size = (EVP_PKEY_bits(key) + CHAR_BIT - 1) / CHAR_BIT; std::vector bytes(size); Unique_BIGNUM d; BIGNUM* bn_d = nullptr; diff --git a/src/crypto/openssl/ec_public_key.cpp b/src/crypto/openssl/ec_public_key.cpp index e6c46930a573..0c97462d957d 100644 --- a/src/crypto/openssl/ec_public_key.cpp +++ b/src/crypto/openssl/ec_public_key.cpp @@ -127,6 +127,8 @@ namespace ccf::crypto return CurveID::SECP384R1; case NID_X9_62_prime256v1: return CurveID::SECP256R1; + case NID_secp521r1: + return CurveID::SECP521R1; default: throw std::runtime_error(fmt::format("Unknown OpenSSL curve {}", nid)); } @@ -151,6 +153,11 @@ namespace ccf::crypto return NID_X9_62_prime256v1; } + if (gname == SN_secp521r1) + { + return NID_secp521r1; + } + throw std::runtime_error(fmt::format("Unknown OpenSSL group {}", gname)); } @@ -164,6 +171,8 @@ namespace ccf::crypto return NID_secp384r1; case CurveID::SECP256R1: return NID_X9_62_prime256v1; + case CurveID::SECP521R1: + return NID_secp521r1; default: throw std::logic_error( fmt::format("unsupported OpenSSL CurveID {}", gid)); @@ -307,7 +316,11 @@ namespace ccf::crypto x.reset(bn_x); CHECK1(EVP_PKEY_get_bn_param(key, OSSL_PKEY_PARAM_EC_PUB_Y, &bn_y)); y.reset(bn_y); - int sz = EC_GROUP_get_degree(group) / CHAR_BIT; + // Use ceiling division so curves whose bit-size is not a multiple of 8 + // (e.g. P-521 with 521 bits) get the full 66-byte coordinate buffer + // rather than a truncated 65-byte one, which would cause BN_bn2binpad to + // fail or silently drop a leading zero byte. + int sz = (EC_GROUP_get_degree(group) + CHAR_BIT - 1) / CHAR_BIT; r.x.resize(sz); r.y.resize(sz); BN_bn2binpad(x, r.x.data(), sz); diff --git a/src/crypto/test/crypto.cpp b/src/crypto/test/crypto.cpp index dc0d81ec1224..dce3f0ec72b0 100644 --- a/src/crypto/test/crypto.cpp +++ b/src/crypto/test/crypto.cpp @@ -1006,7 +1006,7 @@ TEST_CASE("PEM to JWK and back") INFO("EC"); { - auto curves = {CurveID::SECP384R1, CurveID::SECP256R1}; + auto curves = {CurveID::SECP384R1, CurveID::SECP256R1, CurveID::SECP521R1}; for (auto const& curve : curves) { @@ -1252,6 +1252,30 @@ TEST_CASE("COSE algorithm validation") REQUIRE_THROWS_WITH( p384_pubkey->check_is_cose_compatible(-100), "secp384r1 key cannot be used with COSE algorithm -100"); + + // P-521 (secp521r1) requires COSE alg -36 + auto p521_kp = ccf::crypto::make_ec_key_pair(CurveID::SECP521R1); + auto p521_pubkey = std::dynamic_pointer_cast( + ccf::crypto::make_ec_public_key(p521_kp->public_key_pem())); + + // Correct algorithm should work + REQUIRE_NOTHROW(p521_pubkey->check_is_cose_compatible(-36)); + + // Wrong algorithms should throw + REQUIRE_THROWS_WITH( + p521_pubkey->check_is_cose_compatible(-7), + "secp521r1 key cannot be used with COSE algorithm -7"); + REQUIRE_THROWS_WITH( + p521_pubkey->check_is_cose_compatible(-35), + "secp521r1 key cannot be used with COSE algorithm -35"); + + // Unknown COSE algorithm for EC keys should throw + REQUIRE_THROWS_WITH( + p521_pubkey->check_is_cose_compatible(0), + "secp521r1 key cannot be used with COSE algorithm 0"); + REQUIRE_THROWS_WITH( + p521_pubkey->check_is_cose_compatible(-100), + "secp521r1 key cannot be used with COSE algorithm -100"); } INFO("RSA keys accept PS256, PS384, and PS512"); diff --git a/src/js/extensions/ccf/crypto.cpp b/src/js/extensions/ccf/crypto.cpp index c5f860405d6a..02eeb642f1ca 100644 --- a/src/js/extensions/ccf/crypto.cpp +++ b/src/js/extensions/ccf/crypto.cpp @@ -154,10 +154,15 @@ namespace ccf::js::extensions { cid = ccf::crypto::CurveID::SECP384R1; } + else if (curve == "secp521r1") + { + cid = ccf::crypto::CurveID::SECP521R1; + } else { return JS_ThrowRangeError( - ctx, "Unsupported curve id, supported: secp256r1, secp384r1"); + ctx, + "Unsupported curve id, supported: secp256r1, secp384r1, secp521r1"); } try @@ -392,6 +397,69 @@ namespace ccf::js::extensions return ccf::js::core::constants::True; } + JSValue js_is_valid_x509_root_ca_cert( + JSContext* ctx, JSValueConst, int argc, JSValueConst* argv) + { + // Returns true iff the argument is a single, self-signed CA certificate. + // Unlike isValidX509CertChain, this rejects intermediate CAs: a cert must + // be self-signed (EXFLAG_SS) as well as passing X509_check_ca. + if (argc != 1) + { + return JS_ThrowTypeError( + ctx, "Passed %d arguments, but expected 1", argc); + } + + js::core::Context& jsctx = + *reinterpret_cast(JS_GetContextOpaque(ctx)); + + auto pem_str = jsctx.to_str(argv[0]); + if (!pem_str) + { + return ccf::js::core::constants::Exception; + } + + try + { + auto certs = ccf::crypto::split_x509_cert_bundle(*pem_str); + if (certs.size() != 1) + { + throw std::runtime_error( + "expected exactly one certificate, got " + + std::to_string(certs.size())); + } + + auto verifier = ccf::crypto::make_unique_verifier(certs[0]); + + // Reject intermediate CAs: the cert must be self-signed. + if (!verifier->is_self_signed()) + { + return ccf::js::core::constants::False; + } + + // Confirm it is a CA by verifying it against itself; verify_certificate + // runs X509_check_ca on each trusted cert and rejects non-CA certs. + const ccf::crypto::Pem* pem_ptr = certs.data(); + std::vector trusted = {pem_ptr}; + std::vector chain = {}; + if (!verifier->verify_certificate(trusted, chain)) + { + return ccf::js::core::constants::False; + } + } + catch (const std::runtime_error& e) + { + LOG_DEBUG_FMT("isValidX509RootCACert: {}", e.what()); + return ccf::js::core::constants::False; + } + catch (const std::logic_error& e) + { + return JS_ThrowInternalError( + ctx, "isValidX509RootCACert failed: %s", e.what()); + } + + return ccf::js::core::constants::True; + } + template JSValue js_pem_to_jwk( JSContext* ctx, JSValueConst, int argc, JSValueConst* argv) @@ -1214,6 +1282,10 @@ namespace ccf::js::extensions "isValidX509CertChain", ctx.new_c_function( js_is_valid_x509_cert_chain, "isValidX509CertChain", 2))); + JS_CHECK_OR_THROW(crypto.set( + "isValidX509RootCACert", + ctx.new_c_function( + js_is_valid_x509_root_ca_cert, "isValidX509RootCACert", 1))); auto ccf = ctx.get_or_create_global_property("ccf", ctx.new_obj()); JS_CHECK_OR_THROW(ccf.set("crypto", std::move(crypto))); diff --git a/tests/ca_certs.py b/tests/ca_certs.py index 9c2ff41775ad..caca8663f139 100644 --- a/tests/ca_certs.py +++ b/tests/ca_certs.py @@ -45,11 +45,51 @@ def test_cert_store(network, args): else: assert False, "Proposal should not have been accepted" + LOG.info("Member makes a ca cert update proposal with a non-CA cert") + key_priv_pem, _ = infra.crypto.generate_rsa_keypair(2048) + non_ca_cert_pem = infra.crypto.generate_cert(key_priv_pem) + with tempfile.NamedTemporaryFile(prefix="ccf", mode="w+") as cert_pem_fp: + cert_pem_fp.write(non_ca_cert_pem) + cert_pem_fp.flush() + try: + network.consortium.set_ca_cert_bundle(primary, cert_name, cert_pem_fp.name) + except (infra.proposal.ProposalNotAccepted, infra.proposal.ProposalNotCreated): + pass + else: + assert False, "Proposal should not have accepted a non-CA certificate" + + LOG.info( + "Member makes a ca cert update proposal with an intermediate CA " + "signed by a root CA also in the bundle -- must be rejected" + ) + root_priv_pem, _ = infra.crypto.generate_rsa_keypair(2048) + root_cert_pem = infra.crypto.generate_cert(root_priv_pem, cn="root", ca=True) + intermediate_priv_pem, _ = infra.crypto.generate_rsa_keypair(2048) + intermediate_cert_pem = infra.crypto.generate_cert( + intermediate_priv_pem, + cn="intermediate", + issuer_priv_key_pem=root_priv_pem, + issuer_cn="root", + ca=True, + ) + with tempfile.NamedTemporaryFile(prefix="ccf", mode="w+") as cert_pem_fp: + cert_pem_fp.write(intermediate_cert_pem) + cert_pem_fp.write(root_cert_pem) + cert_pem_fp.flush() + try: + network.consortium.set_ca_cert_bundle(primary, cert_name, cert_pem_fp.name) + except (infra.proposal.ProposalNotAccepted, infra.proposal.ProposalNotCreated): + pass + else: + assert ( + False + ), "Proposal should not have accepted an intermediate CA certificate" + LOG.info("Member makes a ca cert update proposal with valid certs") key_priv_pem, _ = infra.crypto.generate_rsa_keypair(2048) - cert_pem = infra.crypto.generate_cert(key_priv_pem) + cert_pem = infra.crypto.generate_cert(key_priv_pem, ca=True) key2_priv_pem, _ = infra.crypto.generate_rsa_keypair(2048) - cert2_pem = infra.crypto.generate_cert(key2_priv_pem) + cert2_pem = infra.crypto.generate_cert(key2_priv_pem, ca=True) with tempfile.NamedTemporaryFile(prefix="ccf", mode="w+") as cert_pem_fp: cert_pem_fp.write(cert_pem) cert_pem_fp.write(cert2_pem) diff --git a/tests/infra/jwt_issuer.py b/tests/infra/jwt_issuer.py index 93844b535e91..00611ff9be18 100644 --- a/tests/infra/jwt_issuer.py +++ b/tests/infra/jwt_issuer.py @@ -141,7 +141,9 @@ def get_jwt_keys(args, node): def to_b64(number: int): as_bytes = number.to_bytes((number.bit_length() + 7) // 8, "big") - return base64.b64encode(as_bytes).decode("ascii") + # JWK numeric fields use unpadded base64url (RFC 7518 section 6 referencing + # RFC 4648 section 5). + return base64.urlsafe_b64encode(as_bytes).rstrip(b"=").decode("ascii") class JwtIssuer: @@ -156,7 +158,7 @@ def _generate_auth_data(self, cn=None): else: raise ValueError(f"Unsupported algorithm: {self._alg}") - cert = infra.crypto.generate_cert(key_priv, cn=cn) + cert = infra.crypto.generate_cert(key_priv, cn=cn, ca=True) return (key_priv, key_pub), cert def __init__( diff --git a/tests/js-custom-authorization/custom_authorization.py b/tests/js-custom-authorization/custom_authorization.py index 010e4441923d..b259f1989199 100644 --- a/tests/js-custom-authorization/custom_authorization.py +++ b/tests/js-custom-authorization/custom_authorization.py @@ -447,7 +447,11 @@ def test_jwt_auth_raw_key(network, args): primary, _ = network.find_nodes() for alg in [JwtAlg.RS256, JwtAlg.ES256]: - issuer = JwtIssuer("noautorefresh://issuer", alg=alg, auth_type=JwtAuthType.KEY) + issuer = JwtIssuer( + "https://noautorefresh.example/issuer", + alg=alg, + auth_type=JwtAuthType.KEY, + ) jwt_kid = "my_key_id" issuer.register(network, kid=jwt_kid) diff --git a/tests/jwt_test.py b/tests/jwt_test.py index 3acf789ea1b3..ca7d67f7769e 100644 --- a/tests/jwt_test.py +++ b/tests/jwt_test.py @@ -36,6 +36,83 @@ def set_issuer_with_keys(network, primary, issuer, kids): ) +def assert_set_jwt_issuer_rejected(network, primary, metadata): + with tempfile.NamedTemporaryFile(prefix="ccf", mode="w+") as metadata_fp: + json.dump(metadata, metadata_fp) + metadata_fp.flush() + try: + network.consortium.set_jwt_issuer(primary, metadata_fp.name) + except infra.proposal.ProposalNotCreated as e: + assert e.response.status_code == 400, e.response.body.text() + assert ( + e.response.body.json()["error"]["code"] == "ProposalFailedToValidate" + ), e.response.body.text() + else: + assert False, "set_jwt_issuer should have failed to validate" + + +@reqs.description("JWT issuer and JWKS validation") +def test_jwt_issuer_and_jwks_validation(network, args): + primary, _ = network.find_nodes() + issuer = infra.jwt_issuer.JwtIssuer("https://example.issuer") + valid_key = issuer.create_jwks("kid1")["keys"][0] + + assert_set_jwt_issuer_rejected(network, primary, {"issuer": "example.issuer"}) + assert_set_jwt_issuer_rejected( + network, primary, {"issuer": "https://example.issuer?foo=bar"} + ) + assert_set_jwt_issuer_rejected( + network, primary, {"issuer": "https://example.issuer#fragment"} + ) + assert_set_jwt_issuer_rejected( + network, + primary, + {"issuer": issuer.name, "jwks": {"keys": [valid_key, valid_key]}}, + ) + assert_set_jwt_issuer_rejected( + network, + primary, + {"issuer": issuer.name, "jwks": {"keys": [{**valid_key, "kty": "oct"}]}}, + ) + assert_set_jwt_issuer_rejected( + network, + primary, + {"issuer": issuer.name, "jwks": {"keys": [{**valid_key, "alg": "HS256"}]}}, + ) + assert_set_jwt_issuer_rejected( + network, + primary, + {"issuer": issuer.name, "jwks": {"keys": [{**valid_key, "use": "enc"}]}}, + ) + + # An RSA key (with n/e) tagged with an EC alg must be rejected even though + # ES256 is otherwise an accepted alg value. + assert_set_jwt_issuer_rejected( + network, + primary, + {"issuer": issuer.name, "jwks": {"keys": [{**valid_key, "alg": "ES256"}]}}, + ) + + # EC keys must have alg matching crv per RFC 7518 section 3.4: ES256 binds + # to P-256, ES384 to P-384, ES512 to P-521. An ES256 alg on a P-256 key + # should pass; any other alg on a P-256 key should be rejected. + ec_issuer = infra.jwt_issuer.JwtIssuer( + "https://example.issuer", + alg=infra.jwt_issuer.JwtAlg.ES256, + auth_type=infra.jwt_issuer.JwtAuthType.KEY, + ) + ec_key = ec_issuer.create_jwks("kid1")["keys"][0] + for wrong_alg in ("ES384", "ES512", "RS256"): + assert_set_jwt_issuer_rejected( + network, + primary, + { + "issuer": ec_issuer.name, + "jwks": {"keys": [{**ec_key, "alg": wrong_alg}]}, + }, + ) + + @reqs.description("Refresh JWT issuer") def test_refresh_jwt_issuer(network, args): assert network.jwt_issuer.server, "JWT server is not started" @@ -701,6 +778,7 @@ def run_auto(args): args.nodes, args.binary_dir, args.debug_nodes, pdb=args.pdb ) as network: network.start_and_open(args) + test_jwt_issuer_and_jwks_validation(network, args) test_jwt_mulitple_issuers_same_kids_different_pem(network, args) test_jwt_mulitple_issuers_same_kids_same_pem(network, args) test_jwt_same_issuer_constraint_overwritten(network, args) diff --git a/tests/npm_tests.py b/tests/npm_tests.py index a8099de1c202..fe24dd371f15 100644 --- a/tests/npm_tests.py +++ b/tests/npm_tests.py @@ -44,7 +44,7 @@ def generate_and_verify_jwk(client): assert r.status_code != http.HTTPStatus.OK # Elliptic curve - curves = [ec.SECP256R1, ec.SECP384R1] + curves = [ec.SECP256R1, ec.SECP384R1, ec.SECP521R1] for curve in curves: priv_pem, pub_pem = infra.crypto.generate_ec_keypair(curve) # Private