Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.

[7.0.17]: https://github.com/microsoft/CCF/releases/tag/ccf-7.0.17

### Changed

- JWT/JWK auto-refresh and SEV-SNP endorsement fetches now verify outbound TLS connections against the node host's system trust store, which can be overridden with the `SSL_CERT_FILE` and `SSL_CERT_DIR` environment variables. The `set_ca_cert_bundle` and `remove_ca_cert_bundle` governance actions, `ca_cert_bundle_name` JWT issuer metadata, and `/gov/service/jwk` CA bundle reporting have been removed (#8027).

### Fixed

- JS registry tables and their configured namespace (`public:custom_endpoints.*` by default) are now read-only to JS endpoints. The governance-driven registry uses `public:ccf.gov.*` and leaves application namespaces unchanged. Apps requiring writes can opt out with `set_js_kv_namespace_restriction(restriction, false)`; platform permissions still apply (#8359).
Expand Down
9 changes: 0 additions & 9 deletions doc/audit/builtin_maps.rst
Original file line number Diff line number Diff line change
Expand Up @@ -400,15 +400,6 @@ JavaScript endpoint definitions.
.. doxygenenum:: ccf::endpoints::ForwardingRequired
:project: CCF

``tls.ca_cert_bundles``
~~~~~~~~~~~~~~~~~~~~~~~

CA cert bundle storage table, these bundles are used to authenticate connections to JWT issuers.

**Key** Bundle name, represented as a string.

**Value** Cert bundle, represented as a PEM-encoded string.

``jwt.issuers``
~~~~~~~~~~~~~~~

Expand Down
28 changes: 5 additions & 23 deletions doc/build_apps/auth/jwt.rst
Original file line number Diff line number Diff line change
Expand Up @@ -66,29 +66,9 @@ In such cases, token issuers in CCF can be configured to automatically refresh t
The following extra conditions must be true compared to setting up an issuer with manual key refresh:

- The ``issuer`` must be an OpenID Connect issuer URL, for example ``https://login.microsoftonline.com/common/v2.0``. During auto-refresh, the keys are fetched from that URL by appending ``/.well-known/openid-configuration``.
- A CA certificate for the issuer URL must be stored so that the TLS connection to the IdP can be validated during key refresh.
- The certificate presented by the issuer URL must be trusted by the node host's system trust store so that the TLS connection to the IdP can be validated during key refresh.

The CA certificate is stored with a ``set_ca_cert_bundle`` proposal:

.. code-block:: json

{
"actions": [
{
"name": "set_ca_cert_bundle",
"args": {
"name": "jwt_ms",
"cert_bundle": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n"
}
}
]
}

.. note::

The ``cert_bundle`` in the example proposal above is a placeholder. The actual value should contain PEM-encoded certificates of all the root CAs for the given issuer, separated by newlines. For Microsoft Entra, the list of root CAs is `here <https://learn.microsoft.com/en-us/azure/security/fundamentals/azure-CA-details>`_.

Now the issuer can be created with auto-refresh enabled:
The issuer can be created with auto-refresh enabled:

.. code-block:: json

Expand All @@ -98,7 +78,6 @@ Now the issuer can be created with auto-refresh enabled:
"name": "set_jwt_issuer",
"args": {
"issuer": "https://login.microsoftonline.com/common/v2.0",
"ca_cert_bundle_name": "jwt_ms",
"auto_refresh": true
}
}
Expand All @@ -108,6 +87,9 @@ Now the issuer can be created with auto-refresh enabled:
.. note::

The key refresh interval is set via the ``jwt.key_refresh_interval`` configuration entry, where the default is 30 min (1800 seconds). Failed refreshes are retried after the lesser of 5 seconds and the configured interval, with exponential backoff capped at the configured interval. The maximum response body size accepted when fetching OpenID metadata and JWKS is set via ``jwt.key_refresh_max_response_size``, where the default is 1 MB.
If auto-refresh fails with a TLS certificate verification error, make sure the IdP's issuing root CA is installed in the trust store of the host or container image running the CCF node.
Alternatively, point the node process at a PEM bundle with the ``SSL_CERT_FILE`` environment variable, or at a directory of hashed certificates with ``SSL_CERT_DIR``. These replace the system CA bundle and CA directory respectively, so they must also contain any public roots the node needs.
The trust store is read again for each fetch, so updates apply to subsequent refreshes without restarting the node.

Removing a token issuer
-----------------------
Expand Down
15 changes: 0 additions & 15 deletions include/ccf/service/tables/cert_bundles.h

This file was deleted.

3 changes: 2 additions & 1 deletion include/ccf/service/tables/jwt.h
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ namespace ccf

struct JwtIssuerMetadata
{
/// Optional CA bundle name used for authentication when auto-refreshing
/// Legacy field accepted when reading existing issuer metadata, but ignored
/// for new outbound TLS verification.
std::optional<std::string> ca_cert_bundle_name;
/// Whether to auto-refresh keys from the issuer
bool auto_refresh = false;
Expand Down
91 changes: 1 addition & 90 deletions samples/constitutions/default/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -107,46 +107,6 @@ function base64UrlByteLength(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`);
Expand Down Expand Up @@ -1149,42 +1109,12 @@ const actions = new Map([
},
),
],
[
"set_ca_cert_bundle",
new Action(
function (args) {
checkType(args.name, "string", "name");
checkX509CACertBundle(args.cert_bundle, "cert_bundle");
},
function (args) {
const name = args.name;
const bundle = args.cert_bundle;
const nameBuf = ccf.strToBuf(name);
const bundleBuf = ccf.jsonCompatibleToBuf(bundle);
ccf.kv["public:ccf.gov.tls.ca_cert_bundles"].set(nameBuf, bundleBuf);
},
),
],
[
"remove_ca_cert_bundle",
new Action(
function (args) {
checkType(args.name, "string", "name");
},
function (args) {
const name = args.name;
const nameBuf = ccf.strToBuf(name);
ccf.kv["public:ccf.gov.tls.ca_cert_bundles"].delete(nameBuf);
},
),
],
[
"set_jwt_issuer",
new Action(
function (args) {
checkType(args.issuer, "string", "issuer");
checkType(args.auto_refresh, "boolean?", "auto_refresh");
checkType(args.ca_cert_bundle_name, "string?", "ca_cert_bundle_name");
checkType(args.jwks, "object?", "jwks");
if (args.jwks) {
checkJwks(args.jwks, "jwks");
Expand All @@ -1201,31 +1131,12 @@ const actions = new Map([
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",
);
}
}
},
function (args) {
if (args.auto_refresh) {
const caCertBundleName = args.ca_cert_bundle_name;
const caCertBundleNameBuf = ccf.strToBuf(args.ca_cert_bundle_name);
if (
!ccf.kv["public:ccf.gov.tls.ca_cert_bundles"].has(
caCertBundleNameBuf,
)
) {
throw new Error(
`No CA cert bundle found with name '${caCertBundleName}'`,
);
}
}
const issuer = args.issuer;
const jwks = args.jwks;
delete args.jwks;
delete args.ca_cert_bundle_name;
const metadata = args;
if (jwks) {
ccf.setJwtPublicSigningKeys(issuer, metadata, jwks);
Expand Down
91 changes: 1 addition & 90 deletions samples/minimal_ccf/app/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -107,46 +107,6 @@ function base64UrlByteLength(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`);
Expand Down Expand Up @@ -1128,42 +1088,12 @@ const actions = new Map([
},
),
],
[
"set_ca_cert_bundle",
new Action(
function (args) {
checkType(args.name, "string", "name");
checkX509CACertBundle(args.cert_bundle, "cert_bundle");
},
function (args) {
const name = args.name;
const bundle = args.cert_bundle;
const nameBuf = ccf.strToBuf(name);
const bundleBuf = ccf.jsonCompatibleToBuf(bundle);
ccf.kv["public:ccf.gov.tls.ca_cert_bundles"].set(nameBuf, bundleBuf);
},
),
],
[
"remove_ca_cert_bundle",
new Action(
function (args) {
checkType(args.name, "string", "name");
},
function (args) {
const name = args.name;
const nameBuf = ccf.strToBuf(name);
ccf.kv["public:ccf.gov.tls.ca_cert_bundles"].delete(nameBuf);
},
),
],
[
"set_jwt_issuer",
new Action(
function (args) {
checkType(args.issuer, "string", "issuer");
checkType(args.auto_refresh, "boolean?", "auto_refresh");
checkType(args.ca_cert_bundle_name, "string?", "ca_cert_bundle_name");
checkType(args.jwks, "object?", "jwks");
if (args.jwks) {
checkJwks(args.jwks, "jwks");
Expand All @@ -1180,31 +1110,12 @@ const actions = new Map([
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",
);
}
}
},
function (args) {
if (args.auto_refresh) {
const caCertBundleName = args.ca_cert_bundle_name;
const caCertBundleNameBuf = ccf.strToBuf(args.ca_cert_bundle_name);
if (
!ccf.kv["public:ccf.gov.tls.ca_cert_bundles"].has(
caCertBundleNameBuf,
)
) {
throw new Error(
`No CA cert bundle found with name '${caCertBundleName}'`,
);
}
}
const issuer = args.issuer;
const jwks = args.jwks;
delete args.jwks;
delete args.ca_cert_bundle_name;
const metadata = args;
if (jwks) {
ccf.setJwtPublicSigningKeys(issuer, metadata, jwks);
Expand Down
26 changes: 26 additions & 0 deletions src/http_client/curl.h
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <curl/curl.h>
#include <curl/multi.h>
Expand Down Expand Up @@ -168,6 +169,31 @@ namespace ccf::http_client
{
CHECK_CURL_EASY_SETOPT(p.get(), option, value);
}

// Verify peer certificates against the system trust store. libcurl uses
// the CA bundle compiled into it by default, but ignores the OpenSSL
// environment variables, so SSL_CERT_FILE and SSL_CERT_DIR are applied
// here to override the CA bundle file and CA directory respectively.
// The trust store is re-read for each request rather than cached, so
// that updates to it apply without restarting the process.
void use_system_trust_store()
{
// NOLINTNEXTLINE(concurrency-mt-unsafe)
const char* cert_file = std::getenv("SSL_CERT_FILE");
if (cert_file != nullptr && cert_file[0] != '\0')
{
set_opt(CURLOPT_CAINFO, cert_file);
}

// NOLINTNEXTLINE(concurrency-mt-unsafe)
const char* cert_dir = std::getenv("SSL_CERT_DIR");
if (cert_dir != nullptr && cert_dir[0] != '\0')
{
set_opt(CURLOPT_CAPATH, cert_dir);
}

set_opt(CURLOPT_CA_CACHE_TIMEOUT, 0L);
}
};

class UniqueCURLM
Expand Down
Loading