feat: password Realm, single-chair login, shared parliament context (0.9.12)
Derive Realm keys from a user password (Argon2id, no private key on disk). Login claims the sole chair with handover; peers share parliament context; --new-project clears it. CLI peer/discover helpers and Docker alpine recipe included; headless empty-answer retries raised for unattended runs. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
.git
|
||||
result
|
||||
build/
|
||||
build-*/
|
||||
dist/
|
||||
third_party/
|
||||
backups/
|
||||
reports/
|
||||
*.o
|
||||
*.a
|
||||
cst-agent
|
||||
tests/test_*
|
||||
!tests/test_*.c
|
||||
app/
|
||||
.cursor/
|
||||
*.apk
|
||||
*.tar.gz
|
||||
+3
-1
@@ -124,6 +124,8 @@ set(CST_CORE_SOURCES
|
||||
src/realm.c
|
||||
src/discover.c
|
||||
src/peer_auth.c
|
||||
src/parliament.c
|
||||
src/parliament_cli.c
|
||||
src/attach.c
|
||||
src/clipboard.c
|
||||
src/pdf_text.c
|
||||
@@ -389,7 +391,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_peer_auth test_lowil
|
||||
test_peer_auth test_parliament test_lowil
|
||||
test_win_utf8 test_os_dir test_tmpdir
|
||||
test_tool_meta test_embed)
|
||||
if(CST_BUILD_WEBUI)
|
||||
|
||||
@@ -106,7 +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/peer_auth.c src/parliament.c src/parliament_cli.c \
|
||||
src/tools/tools.c \
|
||||
src/tools/patch.c \
|
||||
src/tools/registry.c \
|
||||
@@ -177,6 +177,7 @@ TESTS = \
|
||||
tests/test_realm \
|
||||
tests/test_discover \
|
||||
tests/test_peer_auth \
|
||||
tests/test_parliament \
|
||||
tests/test_lowil \
|
||||
tests/test_win_utf8 \
|
||||
tests/test_os_dir \
|
||||
|
||||
@@ -497,6 +497,8 @@ typedef struct {
|
||||
char effort[16];
|
||||
char mode[16]; /* coding | general, "" = not chosen yet */
|
||||
char approval[8]; /* "" = not chosen yet */
|
||||
/* legacy_workers | parliament — parliament replaces in-process subagents. */
|
||||
char collaboration[24];
|
||||
int show_thinking; /* TUI: Thought blocks start expanded */
|
||||
/* workspaces whose .agent/plugins and .agent/mcp.json may run */
|
||||
int ntrusted;
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
#ifndef CST_PARLIAMENT_H
|
||||
#define CST_PARLIAMENT_H
|
||||
|
||||
#include "cst_realm.h"
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* Parliament is one shared whole across agents. Password unlock claims the
|
||||
* single chair on the logging-in device; peers sync the same context. A new
|
||||
* project replaces all parliament context. */
|
||||
|
||||
typedef struct {
|
||||
char parliament_id[64];
|
||||
char project_id[64];
|
||||
char chair_instance[64];
|
||||
char chair_device[128];
|
||||
unsigned long revision;
|
||||
char status[32]; /* idle | open | dissolved */
|
||||
} cst_parliament_meta_t;
|
||||
|
||||
int cst_parliament_path(char *out, size_t n);
|
||||
int cst_parliament_load(cst_parliament_meta_t *m);
|
||||
int cst_parliament_save(const cst_parliament_meta_t *m);
|
||||
|
||||
/* Login: unlock Realm with password, enroll/ensure instance, claim chair,
|
||||
* return handover summary (malloc'd) or NULL. */
|
||||
char *cst_parliament_login(const char *password, const char *instance_id,
|
||||
const char *device_label, char *err, size_t err_sz);
|
||||
|
||||
/* Only one chair: previous chair is demoted; handover notes left in meta. */
|
||||
int cst_parliament_claim_chair(const char *instance_id, const char *device_label);
|
||||
|
||||
/* Probe local environment as device-private context (malloc'd text). */
|
||||
char *cst_parliament_device_context(void);
|
||||
|
||||
/* New project: wipe shared parliament context; keep Realm keys. */
|
||||
int cst_parliament_new_project(const char *project_id);
|
||||
|
||||
/* Append/replace synchronized context blob (all agents share this file). */
|
||||
int cst_parliament_context_put(const char *text);
|
||||
char *cst_parliament_context_get(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,26 @@
|
||||
#ifndef CST_PARLIAMENT_CLI_H
|
||||
#define CST_PARLIAMENT_CLI_H
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* Standalone parliament/Realm ops for Docker and unattended smoke tests.
|
||||
* Returns a process exit code (0 = success). */
|
||||
int cst_parliament_cli_realm_new(void);
|
||||
int cst_parliament_cli_realm_password_init(const char *password);
|
||||
int cst_parliament_cli_login(const char *password, const char *instance_id,
|
||||
const char *device_label);
|
||||
int cst_parliament_cli_new_project(const char *project_id);
|
||||
int cst_parliament_cli_realm_enroll(const char *instance_id, const char *label);
|
||||
int cst_parliament_cli_peer_listen(int port, const char *instance_id);
|
||||
int cst_parliament_cli_peer_dial(const char *host, int port,
|
||||
const char *instance_id);
|
||||
int cst_parliament_cli_discover(const char *instance_id, int peer_port,
|
||||
int wait_ms);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
@@ -36,6 +36,15 @@ int cst_realm_load(const char *dir, cst_realm_keys_t *out);
|
||||
/* 32 hex chars (blake2b-128 of pk). */
|
||||
int cst_realm_fingerprint(const uint8_t pk[32], char out[33]);
|
||||
|
||||
/* Password-string Realm: Argon2id(password, salt) → seed. Private key is not
|
||||
* stored; only salt + public.key live on disk. Unlock derives sk in memory. */
|
||||
int cst_realm_derive_password(cst_realm_keys_t *out, const char *password,
|
||||
const uint8_t salt[16]);
|
||||
int cst_realm_init_password(const char *dir, const char *password);
|
||||
int cst_realm_unlock_password(const char *dir, const char *password,
|
||||
cst_realm_keys_t *out);
|
||||
int cst_realm_is_password_mode(const char *dir);
|
||||
|
||||
int cst_realm_enroll(const cst_realm_keys_t *realm, const char *instance_id,
|
||||
const char *label, int64_t not_after, cst_realm_cert_t *cert,
|
||||
uint8_t instance_seed[32], uint8_t instance_sk[64]);
|
||||
@@ -52,6 +61,12 @@ int cst_realm_challenge_check(const cst_realm_cert_t *cert, const void *chal,
|
||||
int cst_realm_revoke_add(const char *dir, const char *instance_id);
|
||||
int cst_realm_revoked(const char *dir, const char *instance_id);
|
||||
|
||||
/* Persist an enrolled instance under <realm>/instances/<id>/. */
|
||||
int cst_realm_instance_save(const char *instance_id, const cst_realm_cert_t *cert,
|
||||
const uint8_t instance_seed[32]);
|
||||
int cst_realm_instance_load(const char *instance_id, cst_realm_cert_t *cert,
|
||||
uint8_t instance_sk[64]);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
#define CST_VERSION_MAJOR 0
|
||||
#define CST_VERSION_MINOR 9
|
||||
#define CST_VERSION_PATCH 11
|
||||
#define CST_VERSION_PATCH 12
|
||||
|
||||
#define CST_VERSION_STR_(x) #x
|
||||
#define CST_VERSION_STR(x) CST_VERSION_STR_(x)
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# Build inside Alpine so the binary does not depend on the host Nix store.
|
||||
FROM alpine:latest AS build
|
||||
RUN apk add --no-cache build-base curl-dev ncurses-dev pkgconf python3
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
# Skip WebUI asset embedding quirks in minimal images if needed; keep default on.
|
||||
RUN make -j"$(nproc)" cst-agent
|
||||
|
||||
FROM alpine:latest
|
||||
RUN apk add --no-cache libcurl ncurses-libs ca-certificates
|
||||
COPY --from=build /src/cst-agent /usr/local/bin/cst-agent
|
||||
ENV XDG_CONFIG_HOME=/realm-config
|
||||
WORKDIR /work
|
||||
ENTRYPOINT ["cst-agent"]
|
||||
Executable
+59
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env bash
|
||||
# Real Docker smoke: shared Realm volume, chair listens, member dials.
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
IMG="${PARLIAMENT_IMAGE:-cst-parliament:local}"
|
||||
NET="${PARLIAMENT_NET:-cst-parliament-net}"
|
||||
VOL="${PARLIAMENT_VOL:-cst-parliament-realm}"
|
||||
LISTEN_PORT=9454
|
||||
|
||||
cd "$ROOT"
|
||||
echo "== docker build (alpine) =="
|
||||
docker build -f scripts/Dockerfile.parliament -t "$IMG" .
|
||||
|
||||
docker network inspect "$NET" >/dev/null 2>&1 || docker network create "$NET"
|
||||
docker volume inspect "$VOL" >/dev/null 2>&1 || docker volume create "$VOL"
|
||||
|
||||
cleanup() {
|
||||
docker rm -f cst-parl-a cst-parl-b >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
cleanup
|
||||
docker volume rm "$VOL" >/dev/null 2>&1 || true
|
||||
docker volume create "$VOL" >/dev/null
|
||||
|
||||
echo "== realm + enroll on shared volume =="
|
||||
docker run --rm -v "$VOL:/realm-config" -e XDG_CONFIG_HOME=/realm-config "$IMG" --realm-new
|
||||
docker run --rm -v "$VOL:/realm-config" -e XDG_CONFIG_HOME=/realm-config "$IMG" \
|
||||
--realm-enroll chair-a --realm-label chair
|
||||
docker run --rm -v "$VOL:/realm-config" -e XDG_CONFIG_HOME=/realm-config "$IMG" \
|
||||
--realm-enroll member-b --realm-label member
|
||||
|
||||
echo "== chair listens =="
|
||||
docker run -d --name cst-parl-a --network "$NET" \
|
||||
-v "$VOL:/realm-config" -e XDG_CONFIG_HOME=/realm-config \
|
||||
"$IMG" --peer-listen="$LISTEN_PORT" --instance chair-a
|
||||
|
||||
for i in $(seq 1 50); do
|
||||
if docker logs cst-parl-a 2>&1 | grep -q "peer_listen port="; then
|
||||
break
|
||||
fi
|
||||
sleep 0.2
|
||||
done
|
||||
docker logs cst-parl-a 2>&1 | tail -8
|
||||
|
||||
echo "== member dials =="
|
||||
docker run --rm --name cst-parl-b --network "$NET" \
|
||||
-v "$VOL:/realm-config" -e XDG_CONFIG_HOME=/realm-config \
|
||||
"$IMG" --peer-dial="cst-parl-a:${LISTEN_PORT}" --instance member-b
|
||||
|
||||
echo "== chair result =="
|
||||
for i in $(seq 1 50); do
|
||||
if docker logs cst-parl-a 2>&1 | grep -q "peer_ok"; then
|
||||
break
|
||||
fi
|
||||
sleep 0.2
|
||||
done
|
||||
docker logs cst-parl-a 2>&1 | grep peer_ok
|
||||
|
||||
echo "docker_parliament_test: OK"
|
||||
+9
-1
@@ -3251,12 +3251,20 @@ static int run_turn_once(cst_agent_t *a, const char *user_text,
|
||||
}
|
||||
turn_free(&t);
|
||||
|
||||
if (empty_retries < 2 && step + 1 < a->cfg.max_steps) {
|
||||
/* Headless / unattended turns allow more empty-answer retries so a
|
||||
* flaky gateway does not end the turn blank. CST_EMPTY_RETRIES overrides. */
|
||||
{
|
||||
int empty_cap = a->cfg.headless ? 4 : 2;
|
||||
const char *env_er = getenv("CST_EMPTY_RETRIES");
|
||||
if (env_er && atoi(env_er) > 0)
|
||||
empty_cap = atoi(env_er);
|
||||
if (empty_retries < empty_cap && step + 1 < a->cfg.max_steps) {
|
||||
empty_retries++;
|
||||
nudge = NUDGE_EMPTY;
|
||||
hook_status(&t, "empty response — retrying...");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
/* Last resort: surface the latest tool output so the turn isn't blank. */
|
||||
if (tools_ran) {
|
||||
int n = cst_session_count(a->session);
|
||||
|
||||
+135
@@ -1,6 +1,7 @@
|
||||
#include "cst_agent.h"
|
||||
#include "cst_ext.h"
|
||||
#include "cst_models.h"
|
||||
#include "cst_parliament_cli.h"
|
||||
#include "cst_tools.h"
|
||||
#include "cst_version.h"
|
||||
#include "cst_webui.h"
|
||||
@@ -47,6 +48,15 @@ static void usage(const char *argv0) {
|
||||
" -n, --max-steps N model calls per turn (30)\n"
|
||||
" --orchestrate delegate edits and shell work to role workers\n"
|
||||
" --no-orchestrate override workspace orchestration setting\n"
|
||||
" --realm-new create Realm file keys (legacy/dev)\n"
|
||||
" --realm-password-init create password-derived Realm (no sk on disk)\n"
|
||||
" --login unlock with password; this device becomes chair\n"
|
||||
" --new-project ID replace all parliament context for a new project\n"
|
||||
" --realm-enroll ID enroll this instance under the Realm\n"
|
||||
" --realm-label S display label for enroll/login\n"
|
||||
" --peer-listen [N] accept one Realm peer handshake (port N)\n"
|
||||
" --peer-dial H:N dial host:port and complete handshake\n"
|
||||
" --discover-wait MS advertise and list same-Realm peers\n"
|
||||
" -H, --headless -p TEXT run one prompt without the TUI\n"
|
||||
" --web serve the UI over HTTP on 127.0.0.1:8787\n"
|
||||
" --web-port N port (default 8787, 0 = pick a free one)\n"
|
||||
@@ -363,6 +373,12 @@ int main(int argc, char **argv) {
|
||||
const char *prompt = NULL;
|
||||
int new_session = 0, do_list = 0, do_migrate = 0;
|
||||
const char *resume_id = NULL;
|
||||
int do_realm_new = 0, do_realm_enroll = 0, do_peer_listen = 0, do_peer_dial = 0,
|
||||
do_discover = 0, do_realm_pw_init = 0, do_login = 0, do_new_project = 0;
|
||||
const char *realm_enroll_id = NULL, *realm_label = NULL;
|
||||
const char *peer_dial_spec = NULL, *parliament_instance = NULL;
|
||||
const char *new_project_id = NULL;
|
||||
int peer_listen_port = 0, discover_wait_ms = 3000, discover_peer_port = 9100;
|
||||
/* WebUI (--web): a second front end on the same engine. */
|
||||
int web_mode = 0;
|
||||
cst_webui_opts_t web;
|
||||
@@ -412,6 +428,17 @@ int main(int argc, char **argv) {
|
||||
{"web-open", no_argument, 0, 0x106},
|
||||
{"orchestrate", no_argument, 0, 0x108},
|
||||
{"no-orchestrate", no_argument, 0, 0x109},
|
||||
{"realm-new", no_argument, 0, 0x110},
|
||||
{"realm-enroll", required_argument, 0, 0x111},
|
||||
{"realm-label", required_argument, 0, 0x112},
|
||||
{"peer-listen", optional_argument, 0, 0x113},
|
||||
{"peer-dial", required_argument, 0, 0x114},
|
||||
{"discover-wait", required_argument, 0, 0x115},
|
||||
{"instance", required_argument, 0, 0x116},
|
||||
{"peer-port", required_argument, 0, 0x117},
|
||||
{"realm-password-init", no_argument, 0, 0x118},
|
||||
{"login", no_argument, 0, 0x119},
|
||||
{"new-project", required_argument, 0, 0x11a},
|
||||
{0, 0, 0, 0}};
|
||||
int c;
|
||||
while ((c = getopt_long(argc, argv, "w:s:b:k:m:P:r:n:T:Hp:dahVNcR:LyA:C:e:M:",
|
||||
@@ -423,6 +450,48 @@ int main(int argc, char **argv) {
|
||||
case 0x109:
|
||||
cfg.orchestrate_override = -1;
|
||||
break;
|
||||
case 0x110:
|
||||
do_realm_new = 1;
|
||||
break;
|
||||
case 0x111:
|
||||
do_realm_enroll = 1;
|
||||
realm_enroll_id = optarg;
|
||||
break;
|
||||
case 0x112:
|
||||
realm_label = optarg;
|
||||
break;
|
||||
case 0x113:
|
||||
do_peer_listen = 1;
|
||||
peer_listen_port = optarg && optarg[0] ? atoi(optarg) : 0;
|
||||
break;
|
||||
case 0x114:
|
||||
do_peer_dial = 1;
|
||||
peer_dial_spec = optarg;
|
||||
break;
|
||||
case 0x115:
|
||||
do_discover = 1;
|
||||
discover_wait_ms = atoi(optarg);
|
||||
if (discover_wait_ms <= 0)
|
||||
discover_wait_ms = 3000;
|
||||
break;
|
||||
case 0x116:
|
||||
parliament_instance = optarg;
|
||||
break;
|
||||
case 0x117:
|
||||
discover_peer_port = atoi(optarg);
|
||||
if (discover_peer_port <= 0)
|
||||
discover_peer_port = 9100;
|
||||
break;
|
||||
case 0x118:
|
||||
do_realm_pw_init = 1;
|
||||
break;
|
||||
case 0x119:
|
||||
do_login = 1;
|
||||
break;
|
||||
case 0x11a:
|
||||
do_new_project = 1;
|
||||
new_project_id = optarg;
|
||||
break;
|
||||
case 'w':
|
||||
snprintf(cfg.workspace_root, sizeof(cfg.workspace_root), "%s", optarg);
|
||||
break;
|
||||
@@ -580,6 +649,72 @@ int main(int argc, char **argv) {
|
||||
return rc;
|
||||
}
|
||||
|
||||
if (do_realm_new) {
|
||||
int rc = cst_parliament_cli_realm_new();
|
||||
curl_global_cleanup();
|
||||
return rc;
|
||||
}
|
||||
if (do_realm_pw_init) {
|
||||
const char *pw = getenv("CST_REALM_PASSWORD");
|
||||
int rc = cst_parliament_cli_realm_password_init(pw);
|
||||
curl_global_cleanup();
|
||||
return rc;
|
||||
}
|
||||
if (do_login) {
|
||||
const char *pw = getenv("CST_REALM_PASSWORD");
|
||||
const char *iid = parliament_instance ? parliament_instance
|
||||
: getenv("CST_INSTANCE_ID");
|
||||
if (!iid || !iid[0])
|
||||
iid = "chair-local";
|
||||
int rc = cst_parliament_cli_login(pw, iid, realm_label);
|
||||
curl_global_cleanup();
|
||||
return rc;
|
||||
}
|
||||
if (do_new_project) {
|
||||
int rc = cst_parliament_cli_new_project(new_project_id);
|
||||
curl_global_cleanup();
|
||||
return rc;
|
||||
}
|
||||
if (do_realm_enroll) {
|
||||
int rc = cst_parliament_cli_realm_enroll(realm_enroll_id, realm_label);
|
||||
curl_global_cleanup();
|
||||
return rc;
|
||||
}
|
||||
if (do_peer_listen) {
|
||||
const char *iid = parliament_instance ? parliament_instance : realm_enroll_id;
|
||||
if (!iid)
|
||||
iid = getenv("CST_INSTANCE_ID");
|
||||
int rc = cst_parliament_cli_peer_listen(peer_listen_port, iid);
|
||||
curl_global_cleanup();
|
||||
return rc;
|
||||
}
|
||||
if (do_peer_dial) {
|
||||
const char *iid = parliament_instance ? parliament_instance : getenv("CST_INSTANCE_ID");
|
||||
char host[256];
|
||||
int port = 0;
|
||||
host[0] = 0;
|
||||
if (peer_dial_spec) {
|
||||
const char *colon = strrchr(peer_dial_spec, ':');
|
||||
if (colon && colon > peer_dial_spec) {
|
||||
size_t hl = (size_t)(colon - peer_dial_spec);
|
||||
if (hl >= sizeof(host))
|
||||
hl = sizeof(host) - 1;
|
||||
memcpy(host, peer_dial_spec, hl);
|
||||
host[hl] = 0;
|
||||
port = atoi(colon + 1);
|
||||
}
|
||||
}
|
||||
int rc = cst_parliament_cli_peer_dial(host, port, iid);
|
||||
curl_global_cleanup();
|
||||
return rc;
|
||||
}
|
||||
if (do_discover) {
|
||||
const char *iid = parliament_instance ? parliament_instance : getenv("CST_INSTANCE_ID");
|
||||
int rc = cst_parliament_cli_discover(iid, discover_peer_port, discover_wait_ms);
|
||||
curl_global_cleanup();
|
||||
return rc;
|
||||
}
|
||||
|
||||
if (do_list) {
|
||||
int rc = list_sessions(cfg.workspace_root);
|
||||
curl_global_cleanup();
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
#ifndef _DEFAULT_SOURCE
|
||||
#define _DEFAULT_SOURCE
|
||||
#endif
|
||||
#include "cst_parliament.h"
|
||||
#include "cJSON.h"
|
||||
#include "cst_util.h"
|
||||
#include "monocypher.h"
|
||||
#include "os_compat.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
#else
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
|
||||
int cst_parliament_path(char *out, size_t n) {
|
||||
char dir[OS_PATH_MAX];
|
||||
if (cst_realm_dir(dir, sizeof(dir)) != 0)
|
||||
return -1;
|
||||
if (os_mkdir_p(dir, 0700) != 0)
|
||||
return -1;
|
||||
return os_path_join(out, n, dir, "parliament.json");
|
||||
}
|
||||
|
||||
static int context_path(char *out, size_t n) {
|
||||
char dir[OS_PATH_MAX];
|
||||
if (cst_realm_dir(dir, sizeof(dir)) != 0)
|
||||
return -1;
|
||||
return os_path_join(out, n, dir, "parliament.context");
|
||||
}
|
||||
|
||||
int cst_parliament_load(cst_parliament_meta_t *m) {
|
||||
if (!m)
|
||||
return -1;
|
||||
memset(m, 0, sizeof(*m));
|
||||
char path[OS_PATH_MAX];
|
||||
if (cst_parliament_path(path, sizeof(path)) != 0)
|
||||
return -1;
|
||||
FILE *f = fopen(path, "rb");
|
||||
if (!f) {
|
||||
snprintf(m->status, sizeof(m->status), "%s", "idle");
|
||||
return 0;
|
||||
}
|
||||
cst_buf_t b = {0};
|
||||
char chunk[4096];
|
||||
size_t n;
|
||||
while ((n = fread(chunk, 1, sizeof(chunk), f)) && b.len < 256 * 1024)
|
||||
cst_buf_append(&b, chunk, n);
|
||||
fclose(f);
|
||||
cJSON *o = cJSON_Parse(b.buf ? b.buf : "{}");
|
||||
cst_buf_free(&b);
|
||||
if (!o)
|
||||
return -1;
|
||||
const cJSON *v;
|
||||
v = cJSON_GetObjectItemCaseSensitive(o, "parliament_id");
|
||||
if (cJSON_IsString(v))
|
||||
snprintf(m->parliament_id, sizeof(m->parliament_id), "%s", v->valuestring);
|
||||
v = cJSON_GetObjectItemCaseSensitive(o, "project_id");
|
||||
if (cJSON_IsString(v))
|
||||
snprintf(m->project_id, sizeof(m->project_id), "%s", v->valuestring);
|
||||
v = cJSON_GetObjectItemCaseSensitive(o, "chair_instance");
|
||||
if (cJSON_IsString(v))
|
||||
snprintf(m->chair_instance, sizeof(m->chair_instance), "%s",
|
||||
v->valuestring);
|
||||
v = cJSON_GetObjectItemCaseSensitive(o, "chair_device");
|
||||
if (cJSON_IsString(v))
|
||||
snprintf(m->chair_device, sizeof(m->chair_device), "%s", v->valuestring);
|
||||
v = cJSON_GetObjectItemCaseSensitive(o, "revision");
|
||||
if (cJSON_IsNumber(v))
|
||||
m->revision = (unsigned long)v->valuedouble;
|
||||
v = cJSON_GetObjectItemCaseSensitive(o, "status");
|
||||
if (cJSON_IsString(v))
|
||||
snprintf(m->status, sizeof(m->status), "%s", v->valuestring);
|
||||
else
|
||||
snprintf(m->status, sizeof(m->status), "%s", "idle");
|
||||
cJSON_Delete(o);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int cst_parliament_save(const cst_parliament_meta_t *m) {
|
||||
if (!m)
|
||||
return -1;
|
||||
char path[OS_PATH_MAX], tmp[OS_PATH_MAX + 8];
|
||||
if (cst_parliament_path(path, sizeof(path)) != 0)
|
||||
return -1;
|
||||
if (snprintf(tmp, sizeof(tmp), "%s.tmp", path) >= (int)sizeof(tmp))
|
||||
return -1;
|
||||
cJSON *o = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(o, "parliament_id", m->parliament_id);
|
||||
cJSON_AddStringToObject(o, "project_id", m->project_id);
|
||||
cJSON_AddStringToObject(o, "chair_instance", m->chair_instance);
|
||||
cJSON_AddStringToObject(o, "chair_device", m->chair_device);
|
||||
cJSON_AddNumberToObject(o, "revision", (double)m->revision);
|
||||
cJSON_AddStringToObject(o, "status", m->status);
|
||||
char *txt = cJSON_PrintUnformatted(o);
|
||||
cJSON_Delete(o);
|
||||
if (!txt)
|
||||
return -1;
|
||||
FILE *f = fopen(tmp, "wb");
|
||||
int ok = 0;
|
||||
if (f) {
|
||||
ok = fputs(txt, f) >= 0 && fputc('\n', f) >= 0;
|
||||
if (fclose(f) != 0)
|
||||
ok = 0;
|
||||
}
|
||||
free(txt);
|
||||
if (!ok) {
|
||||
remove(tmp);
|
||||
return -1;
|
||||
}
|
||||
if (os_rename_replace(tmp, path) != 0) {
|
||||
remove(tmp);
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
char *cst_parliament_device_context(void) {
|
||||
cst_buf_t b = {0};
|
||||
char host[128] = "unknown", cwd[OS_PATH_MAX] = ".";
|
||||
#ifdef _WIN32
|
||||
DWORD n = sizeof(host);
|
||||
GetComputerNameA(host, &n);
|
||||
_getcwd(cwd, (int)sizeof(cwd));
|
||||
cst_buf_printf(&b, "os=windows\nhost=%s\ncwd=%s\npid=%d\n", host, cwd,
|
||||
(int)GetCurrentProcessId());
|
||||
#else
|
||||
gethostname(host, sizeof(host));
|
||||
if (!getcwd(cwd, sizeof(cwd)))
|
||||
snprintf(cwd, sizeof(cwd), ".");
|
||||
cst_buf_printf(&b, "os=unix\nhost=%s\ncwd=%s\npid=%d\n", host, cwd,
|
||||
(int)getpid());
|
||||
#ifdef __linux__
|
||||
cst_buf_puts(&b, "platform=linux\n");
|
||||
#elif defined(__APPLE__)
|
||||
cst_buf_puts(&b, "platform=macos\n");
|
||||
#endif
|
||||
#endif
|
||||
return cst_buf_steal(&b);
|
||||
}
|
||||
|
||||
int cst_parliament_claim_chair(const char *instance_id,
|
||||
const char *device_label) {
|
||||
if (!instance_id || !instance_id[0])
|
||||
return -1;
|
||||
cst_parliament_meta_t m;
|
||||
if (cst_parliament_load(&m) != 0)
|
||||
return -1;
|
||||
if (!m.parliament_id[0]) {
|
||||
snprintf(m.parliament_id, sizeof(m.parliament_id), "parl-%ld",
|
||||
(long)time(NULL));
|
||||
snprintf(m.status, sizeof(m.status), "%s", "open");
|
||||
}
|
||||
/* Single chair: logging-in device always takes the chair (user will). */
|
||||
snprintf(m.chair_instance, sizeof(m.chair_instance), "%s", instance_id);
|
||||
snprintf(m.chair_device, sizeof(m.chair_device), "%s",
|
||||
device_label ? device_label : "");
|
||||
m.revision++;
|
||||
snprintf(m.status, sizeof(m.status), "%s", "open");
|
||||
return cst_parliament_save(&m);
|
||||
}
|
||||
|
||||
int cst_parliament_context_put(const char *text) {
|
||||
char path[OS_PATH_MAX], tmp[OS_PATH_MAX + 8];
|
||||
if (context_path(path, sizeof(path)) != 0)
|
||||
return -1;
|
||||
if (snprintf(tmp, sizeof(tmp), "%s.tmp", path) >= (int)sizeof(tmp))
|
||||
return -1;
|
||||
FILE *f = fopen(tmp, "wb");
|
||||
if (!f)
|
||||
return -1;
|
||||
int ok = fputs(text ? text : "", f) >= 0;
|
||||
if (fclose(f) != 0)
|
||||
ok = 0;
|
||||
if (!ok) {
|
||||
remove(tmp);
|
||||
return -1;
|
||||
}
|
||||
if (os_rename_replace(tmp, path) != 0) {
|
||||
remove(tmp);
|
||||
return -1;
|
||||
}
|
||||
cst_parliament_meta_t m;
|
||||
if (cst_parliament_load(&m) == 0) {
|
||||
m.revision++;
|
||||
cst_parliament_save(&m);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
char *cst_parliament_context_get(void) {
|
||||
char path[OS_PATH_MAX];
|
||||
if (context_path(path, sizeof(path)) != 0)
|
||||
return strdup("");
|
||||
FILE *f = fopen(path, "rb");
|
||||
if (!f)
|
||||
return strdup("");
|
||||
cst_buf_t b = {0};
|
||||
char chunk[4096];
|
||||
size_t n;
|
||||
while ((n = fread(chunk, 1, sizeof(chunk), f)) && b.len < 2 * 1024 * 1024)
|
||||
cst_buf_append(&b, chunk, n);
|
||||
fclose(f);
|
||||
return b.buf ? cst_buf_steal(&b) : strdup("");
|
||||
}
|
||||
|
||||
int cst_parliament_new_project(const char *project_id) {
|
||||
cst_parliament_meta_t m;
|
||||
memset(&m, 0, sizeof(m));
|
||||
snprintf(m.parliament_id, sizeof(m.parliament_id), "parl-%ld",
|
||||
(long)time(NULL));
|
||||
snprintf(m.project_id, sizeof(m.project_id), "%s",
|
||||
project_id && project_id[0] ? project_id : "default");
|
||||
snprintf(m.status, sizeof(m.status), "%s", "open");
|
||||
m.revision = 1;
|
||||
/* Keep chair empty until next login claims it. */
|
||||
if (cst_parliament_save(&m) != 0)
|
||||
return -1;
|
||||
return cst_parliament_context_put("");
|
||||
}
|
||||
|
||||
char *cst_parliament_login(const char *password, const char *instance_id,
|
||||
const char *device_label, char *err, size_t err_sz) {
|
||||
if (err && err_sz)
|
||||
err[0] = 0;
|
||||
if (!password || !password[0] || !instance_id || !instance_id[0]) {
|
||||
if (err && err_sz)
|
||||
snprintf(err, err_sz, "password and instance id required");
|
||||
return NULL;
|
||||
}
|
||||
char dir[OS_PATH_MAX];
|
||||
if (cst_realm_dir(dir, sizeof(dir)) != 0) {
|
||||
if (err && err_sz)
|
||||
snprintf(err, err_sz, "realm dir missing");
|
||||
return NULL;
|
||||
}
|
||||
if (!cst_realm_is_password_mode(dir)) {
|
||||
if (err && err_sz)
|
||||
snprintf(err, err_sz, "realm is not password mode; run --realm-password-init");
|
||||
return NULL;
|
||||
}
|
||||
cst_realm_keys_t keys;
|
||||
if (cst_realm_unlock_password(dir, password, &keys) != 0) {
|
||||
if (err && err_sz)
|
||||
snprintf(err, err_sz, "login failed (bad password)");
|
||||
return NULL;
|
||||
}
|
||||
/* Ensure this device has an enrolled instance cert. */
|
||||
cst_realm_cert_t cert;
|
||||
uint8_t sk[64];
|
||||
if (cst_realm_instance_load(instance_id, &cert, sk) != 0) {
|
||||
uint8_t seed[32];
|
||||
if (cst_realm_enroll(&keys, instance_id, device_label, 0, &cert, seed, sk) !=
|
||||
0 ||
|
||||
cst_realm_instance_save(instance_id, &cert, seed) != 0) {
|
||||
if (err && err_sz)
|
||||
snprintf(err, err_sz, "enroll instance failed");
|
||||
crypto_wipe(keys.seed, sizeof(keys.seed));
|
||||
crypto_wipe(keys.sk, sizeof(keys.sk));
|
||||
return NULL;
|
||||
}
|
||||
}
|
||||
cst_parliament_meta_t before;
|
||||
cst_parliament_load(&before);
|
||||
char prev_chair[64];
|
||||
snprintf(prev_chair, sizeof(prev_chair), "%s", before.chair_instance);
|
||||
if (cst_parliament_claim_chair(instance_id, device_label) != 0) {
|
||||
if (err && err_sz)
|
||||
snprintf(err, err_sz, "claim chair failed");
|
||||
crypto_wipe(keys.seed, sizeof(keys.seed));
|
||||
crypto_wipe(keys.sk, sizeof(keys.sk));
|
||||
return NULL;
|
||||
}
|
||||
char *ctx = cst_parliament_context_get();
|
||||
char *dev = cst_parliament_device_context();
|
||||
cst_buf_t hand = {0};
|
||||
cst_buf_puts(&hand, "LOGIN_OK\n");
|
||||
cst_buf_printf(&hand, "chair=%s\n", instance_id);
|
||||
if (prev_chair[0] && strcmp(prev_chair, instance_id) != 0)
|
||||
cst_buf_printf(&hand, "handover_from=%s\n", prev_chair);
|
||||
else
|
||||
cst_buf_puts(&hand, "handover_from=\n");
|
||||
cst_buf_printf(&hand, "device_context=\n%s", dev ? dev : "");
|
||||
cst_buf_puts(&hand, "--- parliament_context ---\n");
|
||||
cst_buf_puts(&hand, ctx ? ctx : "");
|
||||
free(ctx);
|
||||
free(dev);
|
||||
crypto_wipe(keys.seed, sizeof(keys.seed));
|
||||
crypto_wipe(keys.sk, sizeof(keys.sk));
|
||||
crypto_wipe(sk, sizeof(sk));
|
||||
return cst_buf_steal(&hand);
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
#ifndef _DEFAULT_SOURCE
|
||||
#define _DEFAULT_SOURCE
|
||||
#endif
|
||||
#include "cst_parliament_cli.h"
|
||||
#include "cst_discover.h"
|
||||
#include "cst_net.h"
|
||||
#include "cst_parliament.h"
|
||||
#include "cst_peer.h"
|
||||
#include "cst_realm.h"
|
||||
#include "os_compat.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
#else
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
|
||||
static void sleep_ms(int ms) {
|
||||
#ifdef _WIN32
|
||||
Sleep((DWORD)ms);
|
||||
#else
|
||||
struct timespec ts = {ms / 1000, (ms % 1000) * 1000000L};
|
||||
nanosleep(&ts, NULL);
|
||||
#endif
|
||||
}
|
||||
|
||||
int cst_parliament_cli_realm_new(void) {
|
||||
char dir[OS_PATH_MAX], fp[33];
|
||||
if (cst_realm_dir(dir, sizeof(dir)) != 0) {
|
||||
fprintf(stderr, "cannot resolve realm directory\n");
|
||||
return 1;
|
||||
}
|
||||
cst_realm_keys_t k;
|
||||
if (cst_realm_generate(&k) != 0 || cst_realm_save(dir, &k) != 0) {
|
||||
fprintf(stderr, "cannot create realm keys in %s\n", dir);
|
||||
return 1;
|
||||
}
|
||||
cst_realm_fingerprint(k.pk, fp);
|
||||
printf("realm_created dir=%s fingerprint=%s\n", dir, fp);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int cst_parliament_cli_realm_password_init(const char *password) {
|
||||
if (!password || !password[0]) {
|
||||
fprintf(stderr, "password required (or CST_REALM_PASSWORD)\n");
|
||||
return 1;
|
||||
}
|
||||
char dir[OS_PATH_MAX], fp[33];
|
||||
if (cst_realm_dir(dir, sizeof(dir)) != 0) {
|
||||
fprintf(stderr, "cannot resolve realm directory\n");
|
||||
return 1;
|
||||
}
|
||||
if (cst_realm_init_password(dir, password) != 0) {
|
||||
fprintf(stderr, "password realm init failed\n");
|
||||
return 1;
|
||||
}
|
||||
cst_realm_keys_t k;
|
||||
if (cst_realm_unlock_password(dir, password, &k) != 0)
|
||||
return 1;
|
||||
cst_realm_fingerprint(k.pk, fp);
|
||||
printf("realm_password_mode dir=%s fingerprint=%s\n", dir, fp);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int cst_parliament_cli_login(const char *password, const char *instance_id,
|
||||
const char *device_label) {
|
||||
char err[256];
|
||||
char *hand = cst_parliament_login(password, instance_id, device_label, err,
|
||||
sizeof(err));
|
||||
if (!hand) {
|
||||
fprintf(stderr, "%s\n", err[0] ? err : "login failed");
|
||||
return 1;
|
||||
}
|
||||
fputs(hand, stdout);
|
||||
free(hand);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int cst_parliament_cli_new_project(const char *project_id) {
|
||||
if (cst_parliament_new_project(project_id) != 0) {
|
||||
fprintf(stderr, "new project failed\n");
|
||||
return 1;
|
||||
}
|
||||
printf("parliament_new_project id=%s\n",
|
||||
project_id && project_id[0] ? project_id : "default");
|
||||
return 0;
|
||||
}
|
||||
|
||||
int cst_parliament_cli_realm_enroll(const char *instance_id, const char *label) {
|
||||
if (!instance_id || !instance_id[0]) {
|
||||
fprintf(stderr, "instance id required\n");
|
||||
return 1;
|
||||
}
|
||||
char dir[OS_PATH_MAX];
|
||||
cst_realm_keys_t k;
|
||||
if (cst_realm_dir(dir, sizeof(dir)) != 0 || cst_realm_load(dir, &k) != 0) {
|
||||
fprintf(stderr, "load realm keys failed (run --realm-new first)\n");
|
||||
return 1;
|
||||
}
|
||||
cst_realm_cert_t cert;
|
||||
uint8_t seed[32], sk[64];
|
||||
if (cst_realm_enroll(&k, instance_id, label, 0, &cert, seed, sk) != 0 ||
|
||||
cst_realm_instance_save(instance_id, &cert, seed) != 0) {
|
||||
fprintf(stderr, "enroll/save failed\n");
|
||||
return 1;
|
||||
}
|
||||
char fp[33];
|
||||
cst_realm_fingerprint(k.pk, fp);
|
||||
printf("enrolled id=%s label=%s realm=%s\n", instance_id,
|
||||
label ? label : "", fp);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int cst_parliament_cli_peer_listen(int port, const char *instance_id) {
|
||||
if (!instance_id || !instance_id[0]) {
|
||||
fprintf(stderr, "instance id required\n");
|
||||
return 1;
|
||||
}
|
||||
char dir[OS_PATH_MAX], err[256];
|
||||
cst_realm_keys_t realm;
|
||||
cst_realm_cert_t cert;
|
||||
uint8_t sk[64];
|
||||
if (cst_realm_dir(dir, sizeof(dir)) != 0 || cst_realm_load(dir, &realm) != 0 ||
|
||||
cst_realm_instance_load(instance_id, &cert, sk) != 0) {
|
||||
fprintf(stderr, "cannot load realm/instance %s\n", instance_id);
|
||||
return 1;
|
||||
}
|
||||
if (cst_realm_revoked(dir, instance_id)) {
|
||||
fprintf(stderr, "instance revoked\n");
|
||||
return 1;
|
||||
}
|
||||
os_net_init();
|
||||
os_socket_t ls = os_net_listen("0.0.0.0", port > 0 ? port : 0, err, sizeof(err));
|
||||
if (ls == OS_SOCKET_BAD) {
|
||||
fprintf(stderr, "listen failed: %s\n", err);
|
||||
return 1;
|
||||
}
|
||||
int bound = os_net_port(ls);
|
||||
printf("peer_listen port=%d id=%s\n", bound, instance_id);
|
||||
fflush(stdout);
|
||||
cst_peer_session_t sess;
|
||||
if (cst_peer_accept((intptr_t)ls, &cert, sk, realm.pk, 60000, &sess, err,
|
||||
sizeof(err)) != 0) {
|
||||
fprintf(stderr, "accept/handshake failed: %s\n", err);
|
||||
os_net_close(ls);
|
||||
return 1;
|
||||
}
|
||||
printf("peer_ok remote=%s host=%s\n", sess.remote.instance_id, sess.host);
|
||||
os_net_close(ls);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int cst_parliament_cli_peer_dial(const char *host, int port,
|
||||
const char *instance_id) {
|
||||
if (!host || port <= 0 || !instance_id) {
|
||||
fprintf(stderr, "host, port and instance id required\n");
|
||||
return 1;
|
||||
}
|
||||
char dir[OS_PATH_MAX], err[256];
|
||||
cst_realm_keys_t realm;
|
||||
cst_realm_cert_t cert;
|
||||
uint8_t sk[64];
|
||||
if (cst_realm_dir(dir, sizeof(dir)) != 0 || cst_realm_load(dir, &realm) != 0 ||
|
||||
cst_realm_instance_load(instance_id, &cert, sk) != 0) {
|
||||
fprintf(stderr, "cannot load realm/instance %s\n", instance_id);
|
||||
return 1;
|
||||
}
|
||||
cst_peer_session_t sess;
|
||||
if (cst_peer_dial(host, port, &cert, sk, realm.pk, 30000, &sess, err,
|
||||
sizeof(err)) != 0) {
|
||||
fprintf(stderr, "dial/handshake failed: %s\n", err);
|
||||
return 1;
|
||||
}
|
||||
printf("peer_ok remote=%s\n", sess.remote.instance_id);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int cst_parliament_cli_discover(const char *instance_id, int peer_port,
|
||||
int wait_ms) {
|
||||
if (!instance_id || peer_port <= 0) {
|
||||
fprintf(stderr, "instance id and peer port required\n");
|
||||
return 1;
|
||||
}
|
||||
char dir[OS_PATH_MAX], fp[33];
|
||||
cst_realm_keys_t realm;
|
||||
if (cst_realm_dir(dir, sizeof(dir)) != 0 || cst_realm_load(dir, &realm) != 0 ||
|
||||
cst_realm_fingerprint(realm.pk, fp) != 0) {
|
||||
fprintf(stderr, "cannot load realm\n");
|
||||
return 1;
|
||||
}
|
||||
cst_discover_t *d = cst_discover_start(fp, instance_id, instance_id, peer_port);
|
||||
if (!d) {
|
||||
fprintf(stderr, "discover start failed\n");
|
||||
return 1;
|
||||
}
|
||||
int left = wait_ms > 0 ? wait_ms : 3000;
|
||||
while (left > 0) {
|
||||
cst_peer_info_t peers[16];
|
||||
int n = cst_discover_peers(d, peers, 16);
|
||||
for (int i = 0; i < n; i++) {
|
||||
printf("peer iid=%s host=%s port=%d name=%s\n", peers[i].instance_id,
|
||||
peers[i].host, peers[i].port, peers[i].name);
|
||||
}
|
||||
if (n > 0)
|
||||
break;
|
||||
sleep_ms(200);
|
||||
left -= 200;
|
||||
}
|
||||
cst_discover_stop(d);
|
||||
return 0;
|
||||
}
|
||||
+173
@@ -213,6 +213,101 @@ int cst_realm_fingerprint(const uint8_t pk[32], char out[33]) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int cst_realm_derive_password(cst_realm_keys_t *out, const char *password,
|
||||
const uint8_t salt[16]) {
|
||||
if (!out || !password || !password[0] || !salt)
|
||||
return -1;
|
||||
crypto_argon2_config cfg = {
|
||||
.algorithm = CRYPTO_ARGON2_ID,
|
||||
.nb_blocks = 8192, /* 8 MiB */
|
||||
.nb_passes = 3,
|
||||
.nb_lanes = 1,
|
||||
};
|
||||
crypto_argon2_inputs in = {
|
||||
.pass = (const uint8_t *)password,
|
||||
.salt = salt,
|
||||
.pass_size = (uint32_t)strlen(password),
|
||||
.salt_size = 16,
|
||||
};
|
||||
void *work = malloc((size_t)cfg.nb_blocks * 1024);
|
||||
if (!work)
|
||||
return -1;
|
||||
uint8_t seed[32];
|
||||
crypto_argon2(seed, 32, work, cfg, in, crypto_argon2_no_extras);
|
||||
free(work);
|
||||
int rc = cst_realm_derive(out, seed);
|
||||
crypto_wipe(seed, sizeof(seed));
|
||||
return rc;
|
||||
}
|
||||
|
||||
int cst_realm_is_password_mode(const char *dir) {
|
||||
if (!dir)
|
||||
return 0;
|
||||
char path[OS_PATH_MAX];
|
||||
if (os_path_join(path, sizeof(path), dir, "password.salt") != 0)
|
||||
return 0;
|
||||
FILE *f = fopen(path, "rb");
|
||||
if (!f)
|
||||
return 0;
|
||||
fclose(f);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int cst_realm_init_password(const char *dir, const char *password) {
|
||||
if (!dir || !password || !password[0])
|
||||
return -1;
|
||||
if (os_mkdir_p(dir, 0700) != 0)
|
||||
return -1;
|
||||
uint8_t salt[16];
|
||||
if (fill_random(salt, sizeof(salt)) != 0)
|
||||
return -1;
|
||||
cst_realm_keys_t k;
|
||||
if (cst_realm_derive_password(&k, password, salt) != 0)
|
||||
return -1;
|
||||
char path[OS_PATH_MAX];
|
||||
if (os_path_join(path, sizeof(path), dir, "password.salt") != 0)
|
||||
return -1;
|
||||
if (write_hex_file(path, salt, 16) != 0)
|
||||
return -1;
|
||||
if (os_path_join(path, sizeof(path), dir, "public.key") != 0)
|
||||
return -1;
|
||||
if (write_hex_file(path, k.pk, 32) != 0)
|
||||
return -1;
|
||||
/* Never persist the seed/private key in password mode. */
|
||||
if (os_path_join(path, sizeof(path), dir, "mode") != 0)
|
||||
return -1;
|
||||
FILE *f = fopen(path, "wb");
|
||||
if (!f)
|
||||
return -1;
|
||||
fputs("password\n", f);
|
||||
fclose(f);
|
||||
crypto_wipe(k.seed, sizeof(k.seed));
|
||||
crypto_wipe(k.sk, sizeof(k.sk));
|
||||
return 0;
|
||||
}
|
||||
|
||||
int cst_realm_unlock_password(const char *dir, const char *password,
|
||||
cst_realm_keys_t *out) {
|
||||
if (!dir || !password || !out)
|
||||
return -1;
|
||||
char path[OS_PATH_MAX];
|
||||
uint8_t salt[16], pk_file[32];
|
||||
if (os_path_join(path, sizeof(path), dir, "password.salt") != 0 ||
|
||||
read_hex_file(path, salt, 16) != 0)
|
||||
return -1;
|
||||
if (cst_realm_derive_password(out, password, salt) != 0)
|
||||
return -1;
|
||||
if (os_path_join(path, sizeof(path), dir, "public.key") != 0 ||
|
||||
read_hex_file(path, pk_file, 32) != 0 ||
|
||||
memcmp(pk_file, out->pk, 32) != 0) {
|
||||
crypto_wipe(out->seed, sizeof(out->seed));
|
||||
crypto_wipe(out->sk, sizeof(out->sk));
|
||||
memset(out, 0, sizeof(*out));
|
||||
return -1; /* wrong password */
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int cst_realm_enroll(const cst_realm_keys_t *realm, const char *instance_id,
|
||||
const char *label, int64_t not_after, cst_realm_cert_t *cert,
|
||||
uint8_t instance_seed[32], uint8_t instance_sk[64]) {
|
||||
@@ -367,3 +462,81 @@ int cst_realm_revoked(const char *dir, const char *instance_id) {
|
||||
fclose(f);
|
||||
return hit;
|
||||
}
|
||||
|
||||
static int instance_dir(const char *instance_id, char *out, size_t n) {
|
||||
char realm[OS_PATH_MAX], base[OS_PATH_MAX];
|
||||
if (cst_realm_dir(realm, sizeof(realm)) != 0)
|
||||
return -1;
|
||||
if (os_path_join(base, sizeof(base), realm, "instances") != 0)
|
||||
return -1;
|
||||
if (os_mkdir_p(base, 0700) != 0)
|
||||
return -1;
|
||||
return os_path_join(out, n, base, instance_id);
|
||||
}
|
||||
|
||||
int cst_realm_instance_save(const char *instance_id, const cst_realm_cert_t *cert,
|
||||
const uint8_t instance_seed[32]) {
|
||||
if (!instance_id || !cert || !instance_seed)
|
||||
return -1;
|
||||
char dir[OS_PATH_MAX], path[OS_PATH_MAX];
|
||||
if (instance_dir(instance_id, dir, sizeof(dir)) != 0 ||
|
||||
os_mkdir_p(dir, 0700) != 0)
|
||||
return -1;
|
||||
char *json = cst_realm_cert_to_json(cert);
|
||||
if (!json)
|
||||
return -1;
|
||||
if (os_path_join(path, sizeof(path), dir, "cert.json") != 0) {
|
||||
free(json);
|
||||
return -1;
|
||||
}
|
||||
FILE *f = fopen(path, "wb");
|
||||
int ok = 0;
|
||||
if (f) {
|
||||
ok = fputs(json, f) >= 0 && fputc('\n', f) >= 0;
|
||||
if (fclose(f) != 0)
|
||||
ok = 0;
|
||||
}
|
||||
free(json);
|
||||
if (!ok)
|
||||
return -1;
|
||||
(void)os_chmod_private(path);
|
||||
if (os_path_join(path, sizeof(path), dir, "seed") != 0)
|
||||
return -1;
|
||||
return write_hex_file(path, instance_seed, 32);
|
||||
}
|
||||
|
||||
int cst_realm_instance_load(const char *instance_id, cst_realm_cert_t *cert,
|
||||
uint8_t instance_sk[64]) {
|
||||
if (!instance_id || !cert || !instance_sk)
|
||||
return -1;
|
||||
char dir[OS_PATH_MAX], path[OS_PATH_MAX];
|
||||
if (instance_dir(instance_id, dir, sizeof(dir)) != 0)
|
||||
return -1;
|
||||
if (os_path_join(path, sizeof(path), dir, "cert.json") != 0)
|
||||
return -1;
|
||||
FILE *f = fopen(path, "rb");
|
||||
if (!f)
|
||||
return -1;
|
||||
cst_buf_t b = {0};
|
||||
char chunk[4096];
|
||||
size_t n;
|
||||
while ((n = fread(chunk, 1, sizeof(chunk), f)) && b.len < 256 * 1024)
|
||||
cst_buf_append(&b, chunk, n);
|
||||
fclose(f);
|
||||
int ok = b.buf && cst_realm_cert_from_json(b.buf, cert) == 0;
|
||||
cst_buf_free(&b);
|
||||
if (!ok)
|
||||
return -1;
|
||||
uint8_t seed[32];
|
||||
if (os_path_join(path, sizeof(path), dir, "seed") != 0 ||
|
||||
read_hex_file(path, seed, 32) != 0)
|
||||
return -1;
|
||||
uint8_t seed_copy[32], pk[32];
|
||||
memcpy(seed_copy, seed, 32);
|
||||
crypto_ed25519_key_pair(instance_sk, pk, seed_copy);
|
||||
crypto_wipe(seed_copy, sizeof(seed_copy));
|
||||
crypto_wipe(seed, sizeof(seed));
|
||||
if (memcmp(pk, cert->instance_pk, 32) != 0)
|
||||
return -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -51,6 +51,8 @@ int cst_settings_load(cst_settings_t *s) {
|
||||
cJSON_GetObjectItemCaseSensitive(root, "mode"));
|
||||
copy_str(s->approval, sizeof(s->approval),
|
||||
cJSON_GetObjectItemCaseSensitive(root, "approval"));
|
||||
copy_str(s->collaboration, sizeof(s->collaboration),
|
||||
cJSON_GetObjectItemCaseSensitive(root, "collaboration"));
|
||||
s->show_thinking =
|
||||
cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(root, "show_thinking"));
|
||||
const cJSON *tr = cJSON_GetObjectItemCaseSensitive(root, "trusted_workspaces");
|
||||
@@ -111,6 +113,8 @@ int cst_settings_save(const cst_settings_t *s) {
|
||||
cJSON_AddStringToObject(root, "mode", s->mode);
|
||||
if (s->approval[0])
|
||||
cJSON_AddStringToObject(root, "approval", s->approval);
|
||||
if (s->collaboration[0])
|
||||
cJSON_AddStringToObject(root, "collaboration", s->collaboration);
|
||||
if (s->show_thinking)
|
||||
cJSON_AddTrueToObject(root, "show_thinking");
|
||||
if (s->ntrusted) {
|
||||
|
||||
@@ -775,6 +775,14 @@ static int task_run(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
|
||||
(void)ud;
|
||||
if (!ctx->agent)
|
||||
return out_text(r, 1, "error: task needs a running agent");
|
||||
{
|
||||
cst_settings_t s; cst_settings_load(&s);
|
||||
const char *mode = getenv("CST_COLLABORATION");
|
||||
if ((!mode || !mode[0]) && s.collaboration[0]) mode = s.collaboration;
|
||||
if (mode && !strcmp(mode, "parliament"))
|
||||
return out_text(r, 1, "error: collaboration=parliament replaces in-process "
|
||||
"workers; use Realm peer seats");
|
||||
}
|
||||
const cst_agent_config_t *pcfg = cst_agent_config(ctx->agent);
|
||||
if (pcfg->depth > 0)
|
||||
return out_text(r, 1, "error: a sub-agent cannot start sub-agents; do "
|
||||
@@ -1179,6 +1187,13 @@ static int async_worker_tool(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c
|
||||
if (!ctx->agent) return out_text(out, 1, "error: no agent");
|
||||
const cst_agent_config_t *cfg = cst_agent_config(ctx->agent);
|
||||
if (cfg->depth || !cfg->runtime) return out_text(out, 1, "error: only the main agent can schedule workers");
|
||||
{
|
||||
cst_settings_t s; cst_settings_load(&s);
|
||||
const char *mode = getenv("CST_COLLABORATION");
|
||||
if ((!mode || !mode[0]) && s.collaboration[0]) mode = s.collaboration;
|
||||
if (mode && !strcmp(mode, "parliament"))
|
||||
return out_text(out, 1, "error: collaboration=parliament replaces in-process workers; use Realm peer seats");
|
||||
}
|
||||
if (strcmp(c->name, "worker_start")) {
|
||||
const char *wt = arg(c, "worktree"); int code;
|
||||
if (wt && cst_runtime_resource_busy(cfg->runtime, wt)) return out_text(out, 1, "error: worker is still using this worktree; wait before review/integration");
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
#include "cst_parliament.h"
|
||||
#include "cst_realm.h"
|
||||
#include "cst_test_util.h"
|
||||
#include "os_compat.h"
|
||||
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
int main(void) {
|
||||
char dir[2048], cfg[512];
|
||||
assert(!test_tmpdir(dir, sizeof(dir), "parliament"));
|
||||
assert(snprintf(cfg, sizeof(cfg), "%s/cfg", dir) < (int)sizeof(cfg));
|
||||
assert(!os_mkdir_p(cfg, 0700));
|
||||
setenv("XDG_CONFIG_HOME", cfg, 1);
|
||||
|
||||
char realm[OS_PATH_MAX];
|
||||
assert(cst_realm_dir(realm, sizeof(realm)) == 0);
|
||||
assert(cst_realm_init_password(realm, "correct horse battery") == 0);
|
||||
assert(cst_realm_is_password_mode(realm));
|
||||
|
||||
cst_realm_keys_t bad, good;
|
||||
assert(cst_realm_unlock_password(realm, "wrong", &bad) != 0);
|
||||
assert(cst_realm_unlock_password(realm, "correct horse battery", &good) == 0);
|
||||
|
||||
char err[128];
|
||||
char *hand = cst_parliament_login("correct horse battery", "phone-1", "pixel",
|
||||
err, sizeof(err));
|
||||
assert(hand && strstr(hand, "LOGIN_OK") && strstr(hand, "chair=phone-1"));
|
||||
free(hand);
|
||||
|
||||
cst_parliament_meta_t m;
|
||||
assert(cst_parliament_load(&m) == 0);
|
||||
assert(!strcmp(m.chair_instance, "phone-1"));
|
||||
|
||||
assert(cst_parliament_context_put("shared agenda v1") == 0);
|
||||
hand = cst_parliament_login("correct horse battery", "laptop-2", "thinkpad",
|
||||
err, sizeof(err));
|
||||
assert(hand && strstr(hand, "handover_from=phone-1"));
|
||||
assert(strstr(hand, "shared agenda v1"));
|
||||
free(hand);
|
||||
assert(cst_parliament_load(&m) == 0);
|
||||
assert(!strcmp(m.chair_instance, "laptop-2"));
|
||||
|
||||
assert(cst_parliament_new_project("proj-b") == 0);
|
||||
char *ctx = cst_parliament_context_get();
|
||||
assert(ctx && ctx[0] == 0);
|
||||
free(ctx);
|
||||
assert(cst_parliament_load(&m) == 0);
|
||||
assert(!strcmp(m.project_id, "proj-b"));
|
||||
assert(m.chair_instance[0] == 0);
|
||||
|
||||
test_rmrf(dir);
|
||||
puts("test_parliament: OK");
|
||||
return 0;
|
||||
}
|
||||
Reference in New Issue
Block a user