From 392bc85d97c136247e9f63c62822166c69d1936d Mon Sep 17 00:00:00 2001 From: Ewan Croft Date: Wed, 29 Jul 2026 12:28:21 +0100 Subject: [PATCH] feat(agent): expose CA bundle and TLS RNG on the agent (#14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wf_agent is opaque and owns its XRPC clients, so the settings added for consoles in #13 were unreachable through it — making the whole high-level API unusable on any platform without a system trust store or a usable entropy source. The settings are remembered on the agent rather than applied once, because the chat-service client is created lazily on first chat.bsky.convo use and would otherwise fall back to library defaults for a different host over the same transport. wf_agent_set_tls_rng installs nothing on failure, so the agent is never left with some clients using the application's RNG and others not. --- CMakeLists.txt | 5 +++ include/wolfram/agent.h | 26 ++++++++++++++ src/agent/_internal.h | 13 +++++++ src/agent/agent.c | 79 +++++++++++++++++++++++++++++++++++++++++ src/agent/chat_typed.c | 3 ++ test/test_agent_tls.c | 72 +++++++++++++++++++++++++++++++++++++ 6 files changed, 198 insertions(+) create mode 100644 test/test_agent_tls.c diff --git a/CMakeLists.txt b/CMakeLists.txt index cdd5f72..2358117 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1519,6 +1519,11 @@ if(WOLFRAM_BUILD_TESTS) target_include_directories(test_agent_moderation PRIVATE test ${cjson_SOURCE_DIR}) add_test(NAME agent_moderation COMMAND test_agent_moderation) + add_executable(test_agent_tls test/test_agent_tls.c) + target_link_libraries(test_agent_tls PRIVATE wolfram) + target_include_directories(test_agent_tls PRIVATE test ${cjson_SOURCE_DIR}) + add_test(NAME agent_tls COMMAND test_agent_tls) + add_executable(test_bsky_agent test/test_bsky_agent.c) target_link_libraries(test_bsky_agent PRIVATE wolfram) target_include_directories(test_bsky_agent PRIVATE test ${cjson_SOURCE_DIR}) diff --git a/include/wolfram/agent.h b/include/wolfram/agent.h index 895bf14..e660d8e 100644 --- a/include/wolfram/agent.h +++ b/include/wolfram/agent.h @@ -31,6 +31,32 @@ typedef struct wf_agent wf_agent; wf_agent *wf_agent_new(const char *service_url); void wf_agent_free(wf_agent *agent); +/* + * TLS configuration. + * + * Both settings are remembered by the agent and applied to every XRPC client it + * owns — the data-plane client, the session's client, and the chat-service + * client, which is created lazily on first use and would otherwise miss them. + * + * These exist because the low-level equivalents on wf_xrpc_client are + * unreachable through the agent: wf_agent is opaque, which on a platform with + * no system trust store and no usable entropy source made the whole high-level + * API unusable. Every console is such a platform. + */ + +/* Use `path` as the CA bundle for TLS verification instead of the system + * default. NULL restores default behaviour. Returns WF_ERR_INVALID_ARG for a + * NULL agent and WF_ERR_ALLOC if the path cannot be copied. */ +wf_status wf_agent_set_ca_bundle(wf_agent *agent, const char *path); + +/* + * Supply the RNG used for the TLS handshake — see wf_xrpc_client_set_tls_rng + * for what this is for and when it is honoured. Returns that function's status + * for the data-plane client; on anything but WF_OK nothing is installed on any + * client, so the agent is never left half-configured. + */ +wf_status wf_agent_set_tls_rng(wf_agent *agent, wf_tls_rng_fn fn, void *userdata); + /* Session management */ wf_status wf_agent_login(wf_agent *agent, const char *identifier, const char *password); wf_status wf_agent_resume(wf_agent *agent, const wf_session_data *data); diff --git a/src/agent/_internal.h b/src/agent/_internal.h index 819038a..6cf3440 100644 --- a/src/agent/_internal.h +++ b/src/agent/_internal.h @@ -23,6 +23,14 @@ typedef struct wf_agent { char *mirror_did; char *mirror_signing_key; wf_car mirror; + /* TLS settings, remembered rather than applied once: an agent owns three + * clients (data plane, session, and the lazily-created chat client), and a + * platform that needs a CA bundle or its own handshake RNG needs every one + * of them configured, including ones that do not exist yet at the time the + * application sets this. */ + char *ca_bundle; + wf_tls_rng_fn tls_rng; + void *tls_rng_userdata; #ifdef WOLFRAM_BUILD_STORE /* Optional persistence target. Caller-owned; never freed by the agent. */ wf_store *store; @@ -33,6 +41,11 @@ typedef struct wf_agent { #endif } wf_agent; +/* Apply the agent's remembered TLS settings to one of its clients. Called for + * each client the agent creates, so a lazily-created one is not left with the + * library defaults. */ +void wf_agent_apply_tls(wf_agent *agent, wf_xrpc_client *client); + /* Helper: convert int to string */ static inline int wf_agent_int_to_str(int value, char *buf, size_t buf_len) { return snprintf(buf, buf_len, "%d", value) > 0; diff --git a/src/agent/agent.c b/src/agent/agent.c index 024ab2a..f781dc7 100644 --- a/src/agent/agent.c +++ b/src/agent/agent.c @@ -50,6 +50,14 @@ typedef struct wf_agent { char *mirror_signing_key; /* Local repo mirror — a wf_car whose root is the latest verified commit. */ wf_car mirror; + /* TLS settings, remembered rather than applied once: an agent owns three + * clients (data plane, session, and the lazily-created chat client), and a + * platform that needs a CA bundle or its own handshake RNG needs every one + * of them configured, including ones that do not exist yet at the time the + * application sets this. */ + char *ca_bundle; + wf_tls_rng_fn tls_rng; + void *tls_rng_userdata; #ifdef WOLFRAM_BUILD_STORE /* Optional persistence target. Caller-owned; never freed by the agent. */ wf_store *store; @@ -958,6 +966,76 @@ wf_agent *wf_agent_new(const char *service_url) { return agent; } +void wf_agent_apply_tls(wf_agent *agent, wf_xrpc_client *client) { + if (!agent || !client) { + return; + } + if (agent->ca_bundle) { + wf_xrpc_client_set_ca_bundle(client, agent->ca_bundle); + } + if (agent->tls_rng) { + /* Deliberately unchecked: an application that asked for its own RNG + * has already been told by wf_agent_set_tls_rng whether this build can + * honour it, and failing a chat-service call here would be a confusing + * place to report it a second time. */ + (void)wf_xrpc_client_set_tls_rng(client, agent->tls_rng, + agent->tls_rng_userdata); + } +} + +wf_status wf_agent_set_ca_bundle(wf_agent *agent, const char *path) { + if (!agent) { + return WF_ERR_INVALID_ARG; + } + + char *copy = NULL; + if (path) { + copy = wf_agent_strdup(path); + if (!copy) { + return WF_ERR_ALLOC; + } + } + + free(agent->ca_bundle); + agent->ca_bundle = copy; + + /* Every client that already exists. The chat client picks it up when it is + * created, via wf_agent_apply_tls. */ + wf_xrpc_client_set_ca_bundle(agent->client, path); + if (agent->session) { + wf_xrpc_client_set_ca_bundle(agent->session->client, path); + } + if (agent->chat_client) { + wf_xrpc_client_set_ca_bundle(agent->chat_client, path); + } + return WF_OK; +} + +wf_status wf_agent_set_tls_rng(wf_agent *agent, wf_tls_rng_fn fn, + void *userdata) { + if (!agent) { + return WF_ERR_INVALID_ARG; + } + + wf_status status = wf_xrpc_client_set_tls_rng(agent->client, fn, userdata); + if (status != WF_OK) { + /* Report the first refusal and install nothing, so the agent never + * ends up with some clients using the application RNG and others not. */ + return status; + } + + agent->tls_rng = fn; + agent->tls_rng_userdata = userdata; + + if (agent->session) { + (void)wf_xrpc_client_set_tls_rng(agent->session->client, fn, userdata); + } + if (agent->chat_client) { + (void)wf_xrpc_client_set_tls_rng(agent->chat_client, fn, userdata); + } + return WF_OK; +} + void wf_agent_free(wf_agent *agent) { if (!agent) { return; @@ -967,6 +1045,7 @@ void wf_agent_free(wf_agent *agent) { wf_xrpc_client_free(agent->client); wf_xrpc_client_free(agent->chat_client); free(agent->service_url); + free(agent->ca_bundle); free(agent->mirror_did); free(agent->mirror_signing_key); wf_car_free(&agent->mirror); diff --git a/src/agent/chat_typed.c b/src/agent/chat_typed.c index 2bede6b..c8b2f0d 100644 --- a/src/agent/chat_typed.c +++ b/src/agent/chat_typed.c @@ -601,6 +601,9 @@ wf_status wf_agent_chat_service_resolve(wf_agent *agent) { if (!agent->chat_client) { return WF_ERR_ALLOC; } + /* The chat service is a separate host reached over the same transport, so + * it needs the same CA bundle and handshake RNG as the data plane. */ + wf_agent_apply_tls(agent, agent->chat_client); return WF_OK; } diff --git a/test/test_agent_tls.c b/test/test_agent_tls.c new file mode 100644 index 0000000..97d56c3 --- /dev/null +++ b/test/test_agent_tls.c @@ -0,0 +1,72 @@ +/** + * test_agent_tls.c — offline tests for the agent's TLS configuration. + * + * No handshake happens here; nothing in this file touches the network. What is + * being checked is that the settings are accepted, remembered, and reported + * honestly — the failure mode that matters is an agent silently keeping the + * library defaults on a platform where those defaults do not work. + */ + +#include +#include + +#include "wolfram/agent.h" +#include "test.h" + +/* Stand-in application RNG. Never invoked offline; it only needs a valid + * wf_tls_rng_fn address to install. */ +static int test_rng(void *userdata, unsigned char *output, size_t len) { + (void)userdata; + memset(output, 0x5A, len); + return 0; +} + +int main(void) { + /* NULL agent is rejected rather than crashing. */ + WF_CHECK(wf_agent_set_ca_bundle(NULL, "/etc/ssl/cert.pem") == + WF_ERR_INVALID_ARG); + WF_CHECK(wf_agent_set_tls_rng(NULL, test_rng, NULL) == WF_ERR_INVALID_ARG); + + wf_agent *agent = wf_agent_new("https://bsky.social"); + WF_CHECK(agent != NULL); + if (!agent) { + WF_TEST_SUMMARY(); + } + + /* A CA bundle can be set, replaced, and cleared. The agent copies the path, + * so a caller's buffer going away afterwards must not matter. */ + char path[64]; + snprintf(path, sizeof(path), "/tmp/cobalt-cacert.pem"); + WF_CHECK(wf_agent_set_ca_bundle(agent, path) == WF_OK); + memset(path, 0, sizeof(path)); + + WF_CHECK(wf_agent_set_ca_bundle(agent, "/tmp/another.pem") == WF_OK); + WF_CHECK(wf_agent_set_ca_bundle(agent, NULL) == WF_OK); + + /* + * The RNG's outcome depends on the linked libcurl's backend — mbedTLS on + * the console targets, usually OpenSSL on a desktop — so it is asserted + * against wf_xrpc_tls_rng_supported() rather than hardcoded. The point is + * that the two cases stay distinguishable: a build that cannot honour the + * RNG must say so instead of quietly accepting one it will never call. + */ + wf_status installed = wf_agent_set_tls_rng(agent, test_rng, agent); + if (wf_xrpc_tls_rng_supported()) { + WF_CHECK(installed == WF_OK); + } else { + WF_CHECK(installed == WF_ERR_UNSUPPORTED); + } + + /* Clearing restores libcurl's own RNG and works on every build. */ + WF_CHECK(wf_agent_set_tls_rng(agent, NULL, NULL) == WF_OK); + + /* Settings applied before login must survive it — the session's client is + * a different client from the data-plane one, and configuring only one of + * them is the bug this API exists to prevent. */ + WF_CHECK(wf_agent_set_ca_bundle(agent, "/tmp/cobalt-cacert.pem") == WF_OK); + + wf_agent_free(agent); + wf_agent_free(NULL); /* must be safe */ + + WF_TEST_SUMMARY(); +} -- 2.51.2