Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
c6fe4e2
Initial plan
Copilot Jun 5, 2026
fc6c0fc
Harden constitution validator inputs
Copilot Jun 5, 2026
7c40d78
Validate JWKS embedded public keys
Copilot Jun 5, 2026
fe739aa
Clarify base64url byte length calculation
Copilot Jun 5, 2026
0fc1214
Address validation review feedback
Copilot Jun 5, 2026
0416dc7
Add P-521 EC key validation support
Copilot Jun 5, 2026
4434f1f
Add changelog entry for validation hardening
Copilot Jun 5, 2026
bdfdd14
Clarify changelog validation scope
Copilot Jun 5, 2026
ce0d13d
Merge branch 'main' into copilot/fix-constitution-validators-strictness
achamayou Jun 5, 2026
ef579f0
Merge branch 'main' into copilot/fix-constitution-validators-strictness
achamayou Jun 5, 2026
6ada538
Fix P-521 JWK serialisation and JWT issuer URL in tests
achamayou Jun 8, 2026
7a0740f
Merge branch 'main' into copilot/fix-constitution-validators-strictness
achamayou Jun 8, 2026
b68c4b9
Merge branch 'main' into copilot/fix-constitution-validators-strictness
achamayou Jun 8, 2026
92b94a6
Use base64url (not base64) for JWK n/e/x/y in JwtIssuer
achamayou Jun 8, 2026
6e954a3
Accept intermediate CA bundles and bind EC alg to crv
Jun 8, 2026
6bdf382
Clean up constitution validators and expand CHANGELOG entry
Jun 8, 2026
f1b91ab
Split CHANGELOG validator entry and fix base64url RFC citation
Jun 8, 2026
d9b8c15
CHANGELOG: move #7924 entries to 7.0.5
Jun 8, 2026
222bed7
Merge branch 'main' into copilot/fix-constitution-validators-strictness
achamayou Jun 9, 2026
c0806d0
Merge branch 'main' into copilot/fix-constitution-validators-strictness
achamayou Jun 9, 2026
ce5d77a
Merge branch 'main' into copilot/fix-constitution-validators-strictness
achamayou Jun 9, 2026
6de1419
Restrict CA bundle to root (self-signed) CAs only; reject intermediat…
Copilot Jun 9, 2026
4c2f995
Fix intermediate CA test: use a proper CA cert signed by a different key
Copilot Jun 9, 2026
9486d33
Harden splitX509CertBundle to validate PEM structure
Copilot Jun 9, 2026
3dee01b
Fix duplicate cert handling: use replaceAll instead of replace
Copilot Jun 9, 2026
4b5efff
Use single-pass regex replace for cleaner validation
Copilot Jun 9, 2026
810c698
Merge branch 'main' into copilot/fix-constitution-validators-strictness
achamayou Jun 10, 2026
297781c
Apply Python formatting to tests/ca_certs.py
Copilot Jun 10, 2026
13249dd
Fix clang-tidy readability-container-data-pointer warning in crypto.cpp
Copilot Jun 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Comment thread
achamayou marked this conversation as resolved.
- 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).
Expand Down
4 changes: 2 additions & 2 deletions doc/build_apps/auth/jwt.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand All @@ -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": [
{
Expand Down
9 changes: 7 additions & 2 deletions include/ccf/crypto/curve.h
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ namespace ccf::crypto
SECP256R1,
/// The CURVE25519 curve
CURVE25519,
X25519
X25519,
/// The SECP521R1 curve
SECP521R1
};

DECLARE_JSON_ENUM(
Expand All @@ -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
Expand All @@ -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));
Expand Down
6 changes: 4 additions & 2 deletions include/ccf/crypto/jwk.h
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Expand All @@ -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:
Expand All @@ -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;
Expand Down
5 changes: 5 additions & 0 deletions js/ccf-app/src/crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
9 changes: 8 additions & 1 deletion js/ccf-app/src/global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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.
*
Expand Down
32 changes: 32 additions & 0 deletions js/ccf-app/src/polyfill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (<any>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,
Expand Down Expand Up @@ -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");
}
Expand Down
60 changes: 60 additions & 0 deletions js/ccf-app/test/crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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++) {
Expand Down
47 changes: 45 additions & 2 deletions js/ccf-app/test/polyfill.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () {
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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);
{
Expand Down Expand Up @@ -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"));
});
});
});
Loading