From 08ca613a0e289c7d4a254d14dab2b949b46bfc01 Mon Sep 17 00:00:00 2001 From: WHC Date: Wed, 28 Oct 2026 22:49:33 +0800 Subject: [PATCH] feat: mutual Realm peer handshake over TCP (0.9.11) Exchange enrolled certificates and Ed25519 challenge signatures so same-Realm instances can authenticate each other after discovery. Co-authored-by: Cursor --- CMakeLists.txt | 3 +- Makefile | 2 + include/cst_peer.h | 36 +++++ include/cst_version.h | 2 +- src/peer_auth.c | 334 +++++++++++++++++++++++++++++++++++++++++ tests/test_peer_auth.c | 85 +++++++++++ 6 files changed, 460 insertions(+), 2 deletions(-) create mode 100644 include/cst_peer.h create mode 100644 src/peer_auth.c create mode 100644 tests/test_peer_auth.c diff --git a/CMakeLists.txt b/CMakeLists.txt index 0366576..6fbbeb7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -123,6 +123,7 @@ set(CST_CORE_SOURCES src/worktree.c src/realm.c src/discover.c + src/peer_auth.c src/attach.c src/clipboard.c src/pdf_text.c @@ -388,7 +389,7 @@ set(_CST_CORE_TESTS test_llm_body test_prefix_stable test_sse_decode test_undo test_config test_models test_model_registry test_sandbox test_keymap test_shell_compact test_skills test_plugins test_mcp test_subagent test_runtime test_realm test_discover - test_lowil + test_peer_auth test_lowil test_win_utf8 test_os_dir test_tmpdir test_tool_meta test_embed) if(CST_BUILD_WEBUI) diff --git a/Makefile b/Makefile index 0e311f8..56615b4 100644 --- a/Makefile +++ b/Makefile @@ -106,6 +106,7 @@ CORE_SRC = \ src/extensions.c src/plugins.c src/mcp.c src/subagent.c src/attach.c src/clipboard.c \ src/pdf_text.c \ src/session.c src/runtime.c src/worktree.c src/realm.c src/discover.c \ + src/peer_auth.c \ src/tools/tools.c \ src/tools/patch.c \ src/tools/registry.c \ @@ -175,6 +176,7 @@ TESTS = \ tests/test_runtime \ tests/test_realm \ tests/test_discover \ + tests/test_peer_auth \ tests/test_lowil \ tests/test_win_utf8 \ tests/test_os_dir \ diff --git a/include/cst_peer.h b/include/cst_peer.h new file mode 100644 index 0000000..cf98973 --- /dev/null +++ b/include/cst_peer.h @@ -0,0 +1,36 @@ +#ifndef CST_PEER_H +#define CST_PEER_H + +#include "cst_realm.h" +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* Mutual Realm certificate challenge over TCP (cleartext for now; TLS wrap + * is the next slice). Same Realm public key required on both sides. */ + +typedef struct { + cst_realm_cert_t remote; + char host[64]; + int port; +} cst_peer_session_t; + +/* Dial host:port, prove our cert, verify theirs. 0 on success. */ +int cst_peer_dial(const char *host, int port, const cst_realm_cert_t *self, + const uint8_t self_sk[64], const uint8_t realm_pk[32], + int timeout_ms, cst_peer_session_t *out, char *err, + size_t err_sz); + +/* Accept one connection on listening socket (from os_net_listen). */ +int cst_peer_accept(intptr_t listen_sock, const cst_realm_cert_t *self, + const uint8_t self_sk[64], const uint8_t realm_pk[32], + int timeout_ms, cst_peer_session_t *out, char *err, + size_t err_sz); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/include/cst_version.h b/include/cst_version.h index 02a8847..25d3cd3 100644 --- a/include/cst_version.h +++ b/include/cst_version.h @@ -3,7 +3,7 @@ #define CST_VERSION_MAJOR 0 #define CST_VERSION_MINOR 9 -#define CST_VERSION_PATCH 10 +#define CST_VERSION_PATCH 11 #define CST_VERSION_STR_(x) #x #define CST_VERSION_STR(x) CST_VERSION_STR_(x) diff --git a/src/peer_auth.c b/src/peer_auth.c new file mode 100644 index 0000000..c63f0d3 --- /dev/null +++ b/src/peer_auth.c @@ -0,0 +1,334 @@ +#ifndef _DEFAULT_SOURCE +#define _DEFAULT_SOURCE +#endif +#include "cst_peer.h" +#include "cJSON.h" +#include "cst_net.h" +#include "cst_util.h" +#include "monocypher.h" + +#include +#include +#include +#include +#include + +static void hex_encode(const uint8_t *in, size_t n, char *out) { + static const char *H = "0123456789abcdef"; + for (size_t i = 0; i < n; i++) { + out[i * 2] = H[in[i] >> 4]; + out[i * 2 + 1] = H[in[i] & 15]; + } + out[n * 2] = 0; +} + +static int hex_nibble(int c) { + if (c >= '0' && c <= '9') + return c - '0'; + if (c >= 'a' && c <= 'f') + return c - 'a' + 10; + if (c >= 'A' && c <= 'F') + return c - 'A' + 10; + return -1; +} + +static int hex_decode(const char *s, uint8_t *out, size_t n) { + if (!s || strlen(s) != n * 2) + return -1; + for (size_t i = 0; i < n; i++) { + int a = hex_nibble(s[i * 2]), b = hex_nibble(s[i * 2 + 1]); + if (a < 0 || b < 0) + return -1; + out[i] = (uint8_t)((a << 4) | b); + } + return 0; +} + +static int fill_random(uint8_t *buf, size_t n) { + FILE *f = fopen("/dev/urandom", "rb"); + if (!f) + return -1; + size_t got = fread(buf, 1, n, f); + fclose(f); + return got == n ? 0 : -1; +} + +static int send_line(os_socket_t s, const char *line, int timeout_ms) { + size_t n = strlen(line); + cst_buf_t b = {0}; + if (cst_buf_append(&b, line, n) || cst_buf_puts(&b, "\n")) { + cst_buf_free(&b); + return -1; + } + int rc = os_net_send_all(s, b.buf, b.len, timeout_ms); + cst_buf_free(&b); + return rc; +} + +static char *recv_line(os_socket_t s, int timeout_ms) { + cst_buf_t b = {0}; + char ch; + for (;;) { + long r = os_net_recv(s, &ch, 1, timeout_ms); + if (r <= 0) { + cst_buf_free(&b); + return NULL; + } + if (ch == '\n') + break; + if (ch != '\r') + cst_buf_append(&b, &ch, 1); + if (b.len > 65536) { + cst_buf_free(&b); + return NULL; + } + } + if (!b.buf) + cst_buf_puts(&b, ""); + return cst_buf_steal(&b); +} + +static int send_hello(os_socket_t s, const cst_realm_cert_t *self, int timeout_ms) { + char *cert = cst_realm_cert_to_json(self); + if (!cert) + return -1; + cJSON *o = cJSON_CreateObject(); + cJSON_AddNumberToObject(o, "v", 1); + cJSON_AddStringToObject(o, "op", "hello"); + cJSON *c = cJSON_Parse(cert); + free(cert); + if (!c) { + cJSON_Delete(o); + return -1; + } + cJSON_AddItemToObject(o, "cert", c); + char *line = cJSON_PrintUnformatted(o); + cJSON_Delete(o); + if (!line) + return -1; + int rc = send_line(s, line, timeout_ms); + free(line); + return rc; +} + +static int read_hello(os_socket_t s, const uint8_t realm_pk[32], int timeout_ms, + cst_realm_cert_t *remote) { + char *line = recv_line(s, timeout_ms); + if (!line) + return -1; + cJSON *o = cJSON_Parse(line); + free(line); + if (!o) + return -1; + const cJSON *op = cJSON_GetObjectItemCaseSensitive(o, "op"); + const cJSON *cert = cJSON_GetObjectItemCaseSensitive(o, "cert"); + int ok = 0; + if (cJSON_IsString(op) && !strcmp(op->valuestring, "hello") && cert) { + char *js = cJSON_PrintUnformatted(cert); + if (js && cst_realm_cert_from_json(js, remote) == 0 && + cst_realm_verify_cert(realm_pk, remote, (int64_t)time(NULL)) == 0) + ok = 1; + free(js); + } + cJSON_Delete(o); + return ok ? 0 : -1; +} + +static int send_chal(os_socket_t s, const uint8_t nonce[32], int timeout_ms) { + char hx[65]; + hex_encode(nonce, 32, hx); + cJSON *o = cJSON_CreateObject(); + cJSON_AddNumberToObject(o, "v", 1); + cJSON_AddStringToObject(o, "op", "chal"); + cJSON_AddStringToObject(o, "nonce", hx); + char *line = cJSON_PrintUnformatted(o); + cJSON_Delete(o); + if (!line) + return -1; + int rc = send_line(s, line, timeout_ms); + free(line); + return rc; +} + +static int read_chal(os_socket_t s, int timeout_ms, uint8_t nonce[32]) { + char *line = recv_line(s, timeout_ms); + if (!line) + return -1; + cJSON *o = cJSON_Parse(line); + free(line); + if (!o) + return -1; + const cJSON *op = cJSON_GetObjectItemCaseSensitive(o, "op"); + const cJSON *n = cJSON_GetObjectItemCaseSensitive(o, "nonce"); + int ok = cJSON_IsString(op) && !strcmp(op->valuestring, "chal") && + cJSON_IsString(n) && hex_decode(n->valuestring, nonce, 32) == 0; + cJSON_Delete(o); + return ok ? 0 : -1; +} + +static int send_auth(os_socket_t s, const uint8_t sk[64], const uint8_t nonce[32], + int timeout_ms) { + uint8_t sig[64]; + if (cst_realm_challenge_sign(sk, nonce, 32, sig) != 0) + return -1; + char hx[129]; + hex_encode(sig, 64, hx); + cJSON *o = cJSON_CreateObject(); + cJSON_AddNumberToObject(o, "v", 1); + cJSON_AddStringToObject(o, "op", "auth"); + cJSON_AddStringToObject(o, "sig", hx); + char *line = cJSON_PrintUnformatted(o); + cJSON_Delete(o); + if (!line) + return -1; + int rc = send_line(s, line, timeout_ms); + free(line); + return rc; +} + +static int read_auth(os_socket_t s, const cst_realm_cert_t *remote, + const uint8_t nonce[32], int timeout_ms) { + char *line = recv_line(s, timeout_ms); + if (!line) + return -1; + cJSON *o = cJSON_Parse(line); + free(line); + if (!o) + return -1; + const cJSON *op = cJSON_GetObjectItemCaseSensitive(o, "op"); + const cJSON *sig = cJSON_GetObjectItemCaseSensitive(o, "sig"); + uint8_t raw[64]; + int ok = cJSON_IsString(op) && !strcmp(op->valuestring, "auth") && + cJSON_IsString(sig) && hex_decode(sig->valuestring, raw, 64) == 0 && + cst_realm_challenge_check(remote, nonce, 32, raw) == 0; + cJSON_Delete(o); + return ok ? 0 : -1; +} + +static int send_ok(os_socket_t s, int timeout_ms) { + return send_line(s, "{\"v\":1,\"op\":\"ok\"}", timeout_ms); +} + +static int read_ok(os_socket_t s, int timeout_ms) { + char *line = recv_line(s, timeout_ms); + if (!line) + return -1; + cJSON *o = cJSON_Parse(line); + free(line); + if (!o) + return -1; + const cJSON *op = cJSON_GetObjectItemCaseSensitive(o, "op"); + int ok = cJSON_IsString(op) && !strcmp(op->valuestring, "ok"); + cJSON_Delete(o); + return ok ? 0 : -1; +} + +static int handshake_as_client(os_socket_t s, const cst_realm_cert_t *self, + const uint8_t self_sk[64], + const uint8_t realm_pk[32], int timeout_ms, + cst_realm_cert_t *remote) { + if (send_hello(s, self, timeout_ms) != 0) + return -1; + uint8_t nonce[32]; + if (read_chal(s, timeout_ms, nonce) != 0) + return -1; + if (send_auth(s, self_sk, nonce, timeout_ms) != 0) + return -1; + if (read_hello(s, realm_pk, timeout_ms, remote) != 0) + return -1; + if (fill_random(nonce, 32) != 0) + return -1; + if (send_chal(s, nonce, timeout_ms) != 0) + return -1; + if (read_auth(s, remote, nonce, timeout_ms) != 0) + return -1; + if (send_ok(s, timeout_ms) != 0 || read_ok(s, timeout_ms) != 0) + return -1; + return 0; +} + +static int handshake_as_server(os_socket_t s, const cst_realm_cert_t *self, + const uint8_t self_sk[64], + const uint8_t realm_pk[32], int timeout_ms, + cst_realm_cert_t *remote) { + if (read_hello(s, realm_pk, timeout_ms, remote) != 0) + return -1; + uint8_t nonce[32]; + if (fill_random(nonce, 32) != 0) + return -1; + if (send_chal(s, nonce, timeout_ms) != 0) + return -1; + if (read_auth(s, remote, nonce, timeout_ms) != 0) + return -1; + if (send_hello(s, self, timeout_ms) != 0) + return -1; + if (read_chal(s, timeout_ms, nonce) != 0) + return -1; + if (send_auth(s, self_sk, nonce, timeout_ms) != 0) + return -1; + if (read_ok(s, timeout_ms) != 0 || send_ok(s, timeout_ms) != 0) + return -1; + return 0; +} + +int cst_peer_dial(const char *host, int port, const cst_realm_cert_t *self, + const uint8_t self_sk[64], const uint8_t realm_pk[32], + int timeout_ms, cst_peer_session_t *out, char *err, + size_t err_sz) { + if (err && err_sz) + err[0] = 0; + if (!host || !self || !self_sk || !realm_pk || !out) + return -1; + os_net_init(); + os_socket_t s = os_net_connect(host, port, timeout_ms); + if (s == OS_SOCKET_BAD) { + if (err && err_sz) + snprintf(err, err_sz, "connect failed"); + return -1; + } + cst_realm_cert_t remote; + if (handshake_as_client(s, self, self_sk, realm_pk, timeout_ms, &remote) != + 0) { + if (err && err_sz) + snprintf(err, err_sz, "peer handshake failed"); + os_net_close(s); + return -1; + } + memset(out, 0, sizeof(*out)); + out->remote = remote; + snprintf(out->host, sizeof(out->host), "%s", host); + out->port = port; + os_net_close(s); + return 0; +} + +int cst_peer_accept(intptr_t listen_sock, const cst_realm_cert_t *self, + const uint8_t self_sk[64], const uint8_t realm_pk[32], + int timeout_ms, cst_peer_session_t *out, char *err, + size_t err_sz) { + if (err && err_sz) + err[0] = 0; + if (!self || !self_sk || !realm_pk || !out) + return -1; + int timed = 0; + os_socket_t s = os_net_accept((os_socket_t)listen_sock, timeout_ms, &timed); + if (s == OS_SOCKET_BAD) { + if (err && err_sz) + snprintf(err, err_sz, timed ? "accept timeout" : "accept failed"); + return -1; + } + cst_realm_cert_t remote; + if (handshake_as_server(s, self, self_sk, realm_pk, timeout_ms, &remote) != + 0) { + if (err && err_sz) + snprintf(err, err_sz, "peer handshake failed"); + os_net_close(s); + return -1; + } + memset(out, 0, sizeof(*out)); + out->remote = remote; + os_net_peer(s, out->host, sizeof(out->host)); + out->port = 0; + os_net_close(s); + return 0; +} diff --git a/tests/test_peer_auth.c b/tests/test_peer_auth.c new file mode 100644 index 0000000..fe25d96 --- /dev/null +++ b/tests/test_peer_auth.c @@ -0,0 +1,85 @@ +#define _POSIX_C_SOURCE 200809L +#include "cst_net.h" +#include "cst_peer.h" +#include "cst_realm.h" +#include "cst_test_util.h" + +#include +#include +#include +#include + +typedef struct { + os_socket_t listen; + cst_realm_cert_t cert; + uint8_t sk[64]; + uint8_t realm_pk[32]; + int ok; + char remote_id[64]; +} srv_t; + +static void *server_main(void *p) { + srv_t *s = p; + cst_peer_session_t sess; + char err[128]; + s->ok = cst_peer_accept((intptr_t)s->listen, &s->cert, s->sk, s->realm_pk, + 5000, &sess, err, sizeof(err)) == 0; + if (s->ok) + snprintf(s->remote_id, sizeof(s->remote_id), "%s", sess.remote.instance_id); + return NULL; +} + +int main(void) { + os_net_init(); + cst_realm_keys_t realm; + assert(cst_realm_generate(&realm) == 0); + + cst_realm_cert_t ca, cb; + uint8_t sa[32], ska[64], sb[32], skb[64]; + assert(cst_realm_enroll(&realm, "peer-a", "A", 0, &ca, sa, ska) == 0); + assert(cst_realm_enroll(&realm, "peer-b", "B", 0, &cb, sb, skb) == 0); + + char err[128]; + os_socket_t ls = os_net_listen("127.0.0.1", 0, err, sizeof(err)); + assert(ls != OS_SOCKET_BAD); + int port = os_net_port(ls); + assert(port > 0); + + srv_t srv = {.listen = ls, .cert = cb}; + memcpy(srv.sk, skb, 64); + memcpy(srv.realm_pk, realm.pk, 32); + pthread_t th; + assert(pthread_create(&th, NULL, server_main, &srv) == 0); + + cst_peer_session_t sess; + assert(cst_peer_dial("127.0.0.1", port, &ca, ska, realm.pk, 5000, &sess, err, + sizeof(err)) == 0); + assert(!strcmp(sess.remote.instance_id, "peer-b")); + pthread_join(th, NULL); + assert(srv.ok); + assert(!strcmp(srv.remote_id, "peer-a")); + + /* Foreign realm must fail. */ + cst_realm_keys_t other; + assert(cst_realm_generate(&other) == 0); + cst_realm_cert_t cf; + uint8_t sf[32], skf[64]; + assert(cst_realm_enroll(&other, "peer-f", "F", 0, &cf, sf, skf) == 0); + os_socket_t ls2 = os_net_listen("127.0.0.1", 0, err, sizeof(err)); + assert(ls2 != OS_SOCKET_BAD); + int port2 = os_net_port(ls2); + srv_t srv2 = {.listen = ls2, .cert = cb}; + memcpy(srv2.sk, skb, 64); + memcpy(srv2.realm_pk, realm.pk, 32); + assert(pthread_create(&th, NULL, server_main, &srv2) == 0); + assert(cst_peer_dial("127.0.0.1", port2, &cf, skf, other.pk, 3000, &sess, err, + sizeof(err)) != 0); + pthread_join(th, NULL); + assert(!srv2.ok); + + os_net_close(ls); + os_net_close(ls2); + os_net_cleanup(); + puts("test_peer_auth: OK"); + return 0; +}