diff --git a/README.md b/README.md index 0d5e827..456b7cf 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,10 @@ Because all identity, communication, and memory live on Nostr, the agent is **po - Connects to configured relays with auto-reconnect and relay state transition logging - Publishes configured startup events per relay as each relay becomes connected - Uses kind `31120` startup content as live Soul at boot -- Listens for NIP-04 encrypted DMs from authorized admin -- Builds LLM context from system prompt + startup events + last 12 DM turns +- Verifies Nostr event signatures before processing inbound messages +- Applies privilege tiers: ADMIN (tools), WoT (chat-only), STRANGER (configurable canned reply or ignore) +- Subscribes to admin context kinds (`0`,`3`,`10002`,`1`) for WoT + contextual awareness +- Builds LLM context from system prompt + startup events + admin DM history + admin recent notes - Supports tool-calling loop with configurable max turns and local safety limits - Appends every outbound LLM context payload to [`context.log`](context.log) @@ -87,6 +89,20 @@ Edit [`config.json`](config.json): "working_directory": "." } }, + "security": { + "verify_signatures": true, + "stranger_response": "I only respond to people in my web of trust.", + "tiers": { + "admin": { "tools_enabled": true }, + "wot": { "enabled": true, "tools_enabled": false }, + "stranger": { "enabled": true } + } + }, + "admin_context": { + "enabled": true, + "subscribe_kinds": [0, 3, 10002, 1], + "kind_1_limit": 10 + }, "startup_events": [ { "kind": 31120, @@ -123,7 +139,7 @@ Options: ### Talk to it -Send an encrypted DM to the agent's pubkey from the admin account using any Nostr client (Damus, Amethyst, Primal, etc.). +Send an encrypted DM to the agent pubkey using any Nostr client (Damus, Amethyst, Primal, etc.): ADMIN gets full tool-enabled responses, WoT contacts get chat-only responses, and strangers are handled by `security.tiers.stranger` + `security.stranger_response`. ## Architecture @@ -201,12 +217,16 @@ On boot, Didactyl attempts startup publishes to each relay as that relay transit ## Runtime Context Model -For each admin DM request, Didactyl builds message context in this order: +Didactyl builds tier-aware context: -1. Soul message from kind `31120` (or fallback default) -2. Startup events memory block (`kinds/content/tags` snapshot) -3. Last 12 decrypted DM turns between admin and agent -4. Current user message +- **ADMIN** request context order: + 1. Soul message from kind `31120` (or fallback default) + 2. Startup events memory block (`kinds/content/tags` snapshot) + 3. Last 12 decrypted DM turns between admin and agent + 4. Recent admin kind `1` notes (from configured admin-context subscription) + 5. Current user message +- **WoT** request context: Soul + WoT chat-only instruction + current user message (no tools) +- **STRANGER**: no LLM call when configured to reply statically Every serialized LLM context payload is appended to [`context.log`](context.log). diff --git a/plans/security_and_admin_context.md b/plans/security_and_admin_context.md new file mode 100644 index 0000000..b846252 --- /dev/null +++ b/plans/security_and_admin_context.md @@ -0,0 +1,286 @@ +# Security & Admin Context Plan + +## Overview + +Add signature verification, privilege tiers, admin context awareness, and key isolation to Didactyl. Implemented in two phases. + +--- + +## Phase 1 — Privilege Tiers, Signature Verification & Admin Context + +### Privilege Tiers + +```mermaid +flowchart TD + A[Incoming Event] --> B{Verify Signature via nostr_verify_event_signature} + B -->|Invalid| C[Drop silently + DEBUG_WARN] + B -->|Valid| D{Identify sender} + D -->|Admin pubkey| E[ADMIN tier] + D -->|In admin kind 3 contact list| F[WOT tier] + D -->|Unknown| G[STRANGER tier] + E --> H[Full tool access + all skills + full context] + F --> I[Chat only - no tools - LLM response] + G --> J[Configurable canned response OR silent ignore] +``` + +| Tier | Identity | Tools | Response | Context | +|------|----------|-------|----------|---------| +| **ADMIN** | `config.admin.pubkey` exact match | All tools | Full LLM with all info except private key | Soul + startup events + admin notes | +| **WOT** | Pubkey in admin kind `3` contact list | None | Chat-only LLM response | Soul + tier instruction | +| **STRANGER** | Anyone else | None | Configurable static response or silent ignore | N/A - no LLM call | + +### Signature Verification + +Every incoming event must be cryptographically verified before processing: + +1. In `on_event()` in `src/nostr_handler.c`, call `nostr_verify_event_signature(event)` from `nostr_core_lib/nostr_core/nip001.h` +2. If verification fails, drop the event silently with a `DEBUG_WARN` log +3. This prevents relay-forged pubkey fields from being trusted +4. Controlled by `security.verify_signatures` config flag (default: `true`) + +### Stranger Response + +Non-WoT senders get a **hardcoded configurable response** — no LLM tokens spent: + +- Config field: `security.stranger_response` +- Example: `"I only respond to people in my web of trust. My npub is npub12kvnu0dsa4alquu4l4zv9454cgjdr9248gd07ueq5x8yzeuan37ql02960"` +- If `security.tiers.stranger.enabled` is `false`, silently ignore instead +- If `true`, send the canned response as a DM + +### Soul Update + +Update the soul event content in `config.json` to: +- Explicitly allow sharing the agent public key (npub) with anyone +- Explicitly forbid revealing the private key (nsec) under any circumstances +- Note: Phase 2 will remove the private key from agent memory entirely + +### Admin Context Subscription + +At startup, Didactyl subscribes to the administrator's Nostr activity to build context awareness. + +#### What to subscribe to + +| Kind | Purpose | Retention | +|------|---------|-----------| +| `0` | Admin profile metadata | Latest only - replaceable | +| `3` | Admin contact/follow list - WoT source | Latest only - replaceable | +| `10002` | Admin relay list | Latest only - replaceable | +| `1` | Admin public notes | Last N posts - configurable, default 10 | + +#### Storage + +In-memory struct `admin_context_t` holding: +- `kind_0_json` — latest kind 0 content string +- `kind_3_contacts` — array of followed pubkey hex strings extracted from kind 3 `p` tags +- `kind_3_contact_count` — count +- `kind_10002_json` — latest kind 10002 content string +- `kind_1_notes` — array of recent kind 1 content strings with timestamps +- `kind_1_note_count` — count + +#### Subscription mechanics + +- Single subscription filter: `{"authors": [""], "kinds": [0, 3, 10002, 1], "limit": }` +- On EOSE: initial load complete, WoT set is ready +- After EOSE: live updates as admin posts new content +- Kind 3 `p` tags are extracted into a sorted array for O(log n) WoT lookup + +#### Context injection + +- Admin kind 1 notes are injected into the LLM context as a system message: "Administrator recent public notes:" followed by the content +- Only injected for ADMIN-tier conversations + +### Config Schema — Phase 1 + +New sections in `config.json`: + +```json +{ + "security": { + "verify_signatures": true, + "stranger_response": "I only respond to people in my web of trust.", + "tiers": { + "admin": { + "tools_enabled": true + }, + "wot": { + "enabled": true, + "tools_enabled": false + }, + "stranger": { + "enabled": true + } + } + }, + "admin_context": { + "enabled": true, + "subscribe_kinds": [0, 3, 10002, 1], + "kind_1_limit": 10 + } +} +``` + +- `security.verify_signatures` — master toggle for signature verification (default: `true`) +- `security.stranger_response` — canned DM text for non-WoT senders (default: empty = silent ignore) +- `security.tiers.wot.enabled` — whether WoT contacts can converse (default: `true`) +- `security.tiers.stranger.enabled` — whether strangers get the canned response (default: `true`; if `false`, silent ignore) +- `admin_context.enabled` — whether to subscribe to admin activity (default: `true`) +- `admin_context.subscribe_kinds` — which kinds to track (default: `[0, 3, 10002, 1]`) +- `admin_context.kind_1_limit` — how many kind 1 notes to retain (default: `10`) + +### Phase 1 Implementation Steps + +1. **Add signature verification to `on_event()`** + - File: `src/nostr_handler.c` + - Call `nostr_verify_event_signature(event)` at the top of `on_event()` + - If it returns non-zero, log and drop + +2. **Add `admin_context_t` struct and security config structs** + - File: `src/config.h` + - New structs for admin context storage, security config, tier config + +3. **Parse new config sections** + - File: `src/config.c` + - Parse `security` and `admin_context` from config.json + - Apply defaults when sections are missing + +4. **Add admin context subscription** + - File: `src/nostr_handler.c` + - New function `nostr_handler_subscribe_admin_context()` + - Callback that populates `admin_context_t` from incoming events + - Extract kind 3 `p` tags into WoT set + - Store kind 1 notes in a ring buffer capped at `kind_1_limit` + +5. **Add WoT lookup function** + - File: `src/nostr_handler.c` + - `int nostr_handler_is_wot_contact(const char* pubkey_hex)` — checks if pubkey is in admin kind 3 list + +6. **Refactor `on_event()` for tier dispatch** + - File: `src/nostr_handler.c` + - After signature verification, classify sender into ADMIN / WOT / STRANGER + - Pass tier information to the callback — extend `dm_callback_t` signature to include tier enum + - STRANGER with `stranger.enabled`: send canned response directly, no callback + - STRANGER without: drop silently + +7. **Refactor `agent_on_message()` for tier-aware behavior** + - File: `src/agent.c` + - ADMIN: current behavior — all tools, full context, admin notes injected + - WOT: build messages with soul + tier instruction, no tools_json, LLM chat-only + - STRANGER: should not reach here — handled in nostr_handler + +8. **Inject admin context into ADMIN conversations** + - File: `src/agent.c` + - After startup events context, append admin kind 1 notes as a system message + - Only for ADMIN tier + +9. **Update soul content in `config.json`** + - Allow sharing public key with anyone + - Forbid revealing private key under any circumstances + +10. **Update `config.json` with new sections** + - Add `security` and `admin_context` sections + +11. **Update `README.md`** + - Document privilege tiers + - Document new config sections + - Update architecture diagram + +12. **Build and validate** + - Run `./build_static.sh` + - Verify compilation succeeds + +--- + +## Phase 2 — NIP-46 Remote Signer for Key Isolation + +### Problem + +Even with Phase 1 protections, the private key (nsec) still lives in Didactyl process memory and in `config.json` on disk. The LLM has `shell_exec` and `file_read` tools — a sufficiently clever prompt injection could theoretically: +- Read `config.json` via `file_read` (contains nsec) +- Execute `cat /proc/self/maps` or similar via `shell_exec` +- Exfiltrate the key via `nostr_post` + +### Solution: NIP-46 Remote Signer + +Remove the private key from Didactyl entirely. All cryptographic operations go through a NIP-46 remote signer. + +```mermaid +flowchart LR + A[Didactyl Agent] -->|kind 24133 sign_event / nip04_encrypt / nip04_decrypt| B[Remote Signer] + B -->|signed events / ciphertext / plaintext| A + A -.->|NO private key in memory| A + B -->|Private key held ONLY here| B + C[config.json] -.->|bunker URL only, no nsec| A +``` + +### Architecture + +1. **Remote signer process** — a separate lightweight daemon that: + - Holds the private key only in working memory (never on disk after initial load) + - Listens for NIP-46 requests via relay (kind `24133`) + - Responds with signed events, encrypted/decrypted content + - Can run on the same machine or a different one + +2. **Didactyl as NIP-46 client** — instead of calling `nostr_create_and_sign_event()` directly: + - Sends `sign_event` RPC to the remote signer + - Sends `nip04_encrypt` / `nip04_decrypt` RPC for DM handling + - Only holds a disposable `client-keypair` for NIP-46 communication + +3. **Config change** — `config.json` replaces `keys.nsec` with: + ```json + { + "keys": { + "bunker_url": "bunker://?relay=wss://relay.example.com&secret=", + "npub": "npub1..." + } + } + ``` + +### NIP-46 Methods Required + +| Method | Current direct call | Used for | +|--------|-------------------|----------| +| `sign_event` | `nostr_create_and_sign_event()` | Publishing all events | +| `nip04_encrypt` | `nostr_nip04_encrypt()` | Sending DMs | +| `nip04_decrypt` | `nostr_nip04_decrypt()` | Receiving DMs | +| `get_public_key` | `config.keys.public_key_hex` | Identity | + +### Implementation Scope + +1. **Build NIP-46 client protocol in nostr_core_lib** + - NIP-44 encryption for request/response content + - Kind 24133 event creation and parsing + - JSON-RPC request/response handling + - Connection establishment (bunker URL parsing) + - Async request/response matching by request ID + +2. **Build or integrate a remote signer** + - Option A: Build a minimal signer binary in C (ships with Didactyl) + - Option B: Support existing signers (nsecBunker, etc.) + - Option C: Both — ship a minimal one, support external ones + +3. **Refactor all signing/encryption calls** + - Every `nostr_create_and_sign_event()` → `nip46_sign_event()` (async RPC) + - Every `nostr_nip04_encrypt()` → `nip46_nip04_encrypt()` (async RPC) + - Every `nostr_nip04_decrypt()` → `nip46_nip04_decrypt()` (async RPC) + - These become blocking calls that send a request and wait for a response + +4. **Startup flow change** + - Parse bunker URL from config + - Generate client keypair + - Send `connect` request to remote signer + - Call `get_public_key` to learn user pubkey + - Proceed with normal startup + +5. **Backward compatibility** + - If `keys.nsec` is present, use direct signing (current behavior) + - If `keys.bunker_url` is present, use NIP-46 remote signing + - This allows gradual migration + +### Security Properties Achieved + +- Private key never in Didactyl process memory +- Private key never on disk in config.json +- LLM tools (shell_exec, file_read) cannot access the key +- Even full process compromise of Didactyl does not leak the signing key +- Remote signer can be on a separate hardened machine +- Remote signer can implement rate limiting, approval flows, etc. diff --git a/src/agent.c b/src/agent.c index a8200ca..13157fb 100644 --- a/src/agent.c +++ b/src/agent.c @@ -434,32 +434,52 @@ int agent_init(didactyl_config_t* config, const char* system_context) { return 0; } -void agent_on_message(const char* sender_pubkey_hex, const char* message, void* user_data) { +void agent_on_message(const char* sender_pubkey_hex, + const char* message, + didactyl_sender_tier_t tier, + void* user_data) { (void)user_data; if (!g_cfg || !g_system_context || !sender_pubkey_hex || !message) { return; } - fprintf(stdout, "[didactyl] incoming message from %.16s...\n", sender_pubkey_hex); + if (tier == DIDACTYL_SENDER_STRANGER) { + return; + } + + fprintf(stdout, "[didactyl] incoming message from %.16s... tier=%d\n", sender_pubkey_hex, (int)tier); if (agent_message_is_debounced(sender_pubkey_hex, message)) { fprintf(stdout, "[didactyl] debounced duplicate inbound message from %.16s...\n", sender_pubkey_hex); return; } - fprintf(stdout, "[didactyl] calling llm for sender %.16s...\n", sender_pubkey_hex); + int allow_tools = (tier == DIDACTYL_SENDER_ADMIN) && g_cfg->tools.enabled && g_cfg->security.admin.tools_enabled; - if (!g_cfg->tools.enabled) { - size_t context_len = strlen("system:\n\nuser:\n") + strlen(g_system_context) + strlen(message) + 1U; + if (!allow_tools) { + const char* tier_prefix = (tier == DIDACTYL_SENDER_WOT) + ? "You are responding to a web-of-trust contact. Keep the response helpful and concise. Tool use is disabled for this tier." + : "You are responding in chat-only mode. Tool use is disabled."; + + size_t ctx_len = strlen(g_system_context) + strlen("\n\n") + strlen(tier_prefix) + 1U; + char* system_for_chat = (char*)malloc(ctx_len); + if (!system_for_chat) { + return; + } + + snprintf(system_for_chat, ctx_len, "%s\n\n%s", g_system_context, tier_prefix); + + size_t context_len = strlen("system:\n\nuser:\n") + strlen(system_for_chat) + strlen(message) + 1U; char* plain_context = (char*)malloc(context_len); if (plain_context) { - snprintf(plain_context, context_len, "system:\n%s\n\nuser:\n%s", g_system_context, message); + snprintf(plain_context, context_len, "system:\n%s\n\nuser:\n%s", system_for_chat, message); append_context_log(sender_pubkey_hex, "llm_chat", plain_context); free(plain_context); } - char* response = llm_chat(g_system_context, message); + char* response = llm_chat(system_for_chat, message); + free(system_for_chat); if (!response) { const char* fallback = "I could not get a response from the LLM right now."; fprintf(stdout, "[didactyl] llm response unavailable, sending fallback\n"); @@ -490,8 +510,20 @@ void agent_on_message(const char* sender_pubkey_hex, const char* message, void* if (append_simple_message(messages, "system", g_system_context) != 0 || append_startup_events_context(messages) != 0 || - append_recent_admin_dm_history(messages, message) != 0 || - append_simple_message(messages, "user", message) != 0) { + append_recent_admin_dm_history(messages, message) != 0) { + cJSON_Delete(messages); + free(tools_json); + (void)nostr_handler_send_dm(sender_pubkey_hex, "Failed to initialize conversation messages."); + return; + } + + char* admin_notes = nostr_handler_get_admin_kind1_notes_context(); + if (admin_notes) { + (void)append_simple_message(messages, "system", admin_notes); + free(admin_notes); + } + + if (append_simple_message(messages, "user", message) != 0) { cJSON_Delete(messages); free(tools_json); (void)nostr_handler_send_dm(sender_pubkey_hex, "Failed to initialize conversation messages."); diff --git a/src/agent.h b/src/agent.h index fcccaec..842d37e 100644 --- a/src/agent.h +++ b/src/agent.h @@ -2,9 +2,13 @@ #define OPEN_WING_AGENT_H #include "config.h" +#include "nostr_handler.h" int agent_init(didactyl_config_t* config, const char* system_context); -void agent_on_message(const char* sender_pubkey_hex, const char* message, void* user_data); +void agent_on_message(const char* sender_pubkey_hex, + const char* message, + didactyl_sender_tier_t tier, + void* user_data); void agent_cleanup(void); #endif \ No newline at end of file diff --git a/src/config.c b/src/config.c index cf98520..02d0ec6 100644 --- a/src/config.c +++ b/src/config.c @@ -161,6 +161,101 @@ static int parse_tools_config(cJSON* root, didactyl_config_t* config) { return 0; } +static int parse_security_config(cJSON* root, didactyl_config_t* config) { + cJSON* security = cJSON_GetObjectItemCaseSensitive(root, "security"); + if (!security || !cJSON_IsObject(security)) { + return 0; + } + + cJSON* verify_signatures = cJSON_GetObjectItemCaseSensitive(security, "verify_signatures"); + if (verify_signatures && cJSON_IsBool(verify_signatures)) { + config->security.verify_signatures = cJSON_IsTrue(verify_signatures) ? 1 : 0; + } + + if (copy_json_string(security, + "stranger_response", + config->security.stranger_response, + sizeof(config->security.stranger_response), + 0) != 0) { + return -1; + } + + cJSON* tiers = cJSON_GetObjectItemCaseSensitive(security, "tiers"); + if (!tiers || !cJSON_IsObject(tiers)) { + return 0; + } + + cJSON* admin_tier = cJSON_GetObjectItemCaseSensitive(tiers, "admin"); + if (admin_tier && cJSON_IsObject(admin_tier)) { + cJSON* tools_enabled = cJSON_GetObjectItemCaseSensitive(admin_tier, "tools_enabled"); + if (tools_enabled && cJSON_IsBool(tools_enabled)) { + config->security.admin.tools_enabled = cJSON_IsTrue(tools_enabled) ? 1 : 0; + } + } + + cJSON* wot_tier = cJSON_GetObjectItemCaseSensitive(tiers, "wot"); + if (wot_tier && cJSON_IsObject(wot_tier)) { + cJSON* enabled = cJSON_GetObjectItemCaseSensitive(wot_tier, "enabled"); + cJSON* tools_enabled = cJSON_GetObjectItemCaseSensitive(wot_tier, "tools_enabled"); + if (enabled && cJSON_IsBool(enabled)) { + config->security.wot.enabled = cJSON_IsTrue(enabled) ? 1 : 0; + } + if (tools_enabled && cJSON_IsBool(tools_enabled)) { + config->security.wot.tools_enabled = cJSON_IsTrue(tools_enabled) ? 1 : 0; + } + } + + cJSON* stranger_tier = cJSON_GetObjectItemCaseSensitive(tiers, "stranger"); + if (stranger_tier && cJSON_IsObject(stranger_tier)) { + cJSON* enabled = cJSON_GetObjectItemCaseSensitive(stranger_tier, "enabled"); + if (enabled && cJSON_IsBool(enabled)) { + config->security.stranger.enabled = cJSON_IsTrue(enabled) ? 1 : 0; + } + } + + return 0; +} + +static int parse_admin_context_config(cJSON* root, didactyl_config_t* config) { + cJSON* admin_context = cJSON_GetObjectItemCaseSensitive(root, "admin_context"); + if (!admin_context || !cJSON_IsObject(admin_context)) { + return 0; + } + + cJSON* enabled = cJSON_GetObjectItemCaseSensitive(admin_context, "enabled"); + if (enabled && cJSON_IsBool(enabled)) { + config->admin_context.enabled = cJSON_IsTrue(enabled) ? 1 : 0; + } + + cJSON* kind_1_limit = cJSON_GetObjectItemCaseSensitive(admin_context, "kind_1_limit"); + if (kind_1_limit && cJSON_IsNumber(kind_1_limit)) { + config->admin_context.kind_1_limit = (int)kind_1_limit->valuedouble; + } + + cJSON* subscribe_kinds = cJSON_GetObjectItemCaseSensitive(admin_context, "subscribe_kinds"); + if (subscribe_kinds && cJSON_IsArray(subscribe_kinds)) { + config->admin_context.track_kind_0 = 0; + config->admin_context.track_kind_3 = 0; + config->admin_context.track_kind_10002 = 0; + config->admin_context.track_kind_1 = 0; + + int n = cJSON_GetArraySize(subscribe_kinds); + for (int i = 0; i < n; i++) { + cJSON* k = cJSON_GetArrayItem(subscribe_kinds, i); + if (!k || !cJSON_IsNumber(k)) { + continue; + } + int kind = (int)k->valuedouble; + if (kind == 0) config->admin_context.track_kind_0 = 1; + else if (kind == 3) config->admin_context.track_kind_3 = 1; + else if (kind == 10002) config->admin_context.track_kind_10002 = 1; + else if (kind == 1) config->admin_context.track_kind_1 = 1; + } + } + + return 0; +} + static cJSON* find_tag_value_string(cJSON* tags, const char* tag_key) { if (!tags || !cJSON_IsArray(tags) || !tag_key) { return NULL; @@ -402,6 +497,22 @@ int config_load(const char* path, didactyl_config_t* config) { config->tools.shell.max_output_bytes = 65536; strcpy(config->tools.shell.working_directory, "."); + config->security.verify_signatures = 1; + config->security.admin.enabled = 1; + config->security.admin.tools_enabled = 1; + config->security.wot.enabled = 1; + config->security.wot.tools_enabled = 0; + config->security.stranger.enabled = 1; + config->security.stranger.tools_enabled = 0; + config->security.stranger_response[0] = '\0'; + + config->admin_context.enabled = 1; + config->admin_context.track_kind_0 = 1; + config->admin_context.track_kind_3 = 1; + config->admin_context.track_kind_10002 = 1; + config->admin_context.track_kind_1 = 1; + config->admin_context.kind_1_limit = 10; + char* json_buf = NULL; size_t json_len = 0; if (read_file_to_buffer(path, &json_buf, &json_len) != 0) { @@ -470,6 +581,14 @@ int config_load(const char* path, didactyl_config_t* config) { goto cleanup; } + if (parse_security_config(root, config) != 0) { + goto cleanup; + } + + if (parse_admin_context_config(root, config) != 0) { + goto cleanup; + } + if (parse_startup_events(root, config) != 0) { goto cleanup; } diff --git a/src/config.h b/src/config.h index 1466db7..a7eb9e2 100644 --- a/src/config.h +++ b/src/config.h @@ -9,6 +9,8 @@ #define OW_MAX_KEY_LEN 256 #define OW_MAX_MODEL_LEN 128 +#define OW_MAX_STRANGER_RESPONSE_LEN 512 + typedef struct { char nsec[OW_MAX_KEY_LEN]; unsigned char private_key[32]; @@ -48,6 +50,28 @@ typedef struct { char* tags_json; // JSON array string for tags, optional } startup_event_t; +typedef struct { + int enabled; + int tools_enabled; +} security_tier_config_t; + +typedef struct { + int verify_signatures; + char stranger_response[OW_MAX_STRANGER_RESPONSE_LEN]; + security_tier_config_t admin; + security_tier_config_t wot; + security_tier_config_t stranger; +} security_config_t; + +typedef struct { + int enabled; + int track_kind_0; + int track_kind_3; + int track_kind_10002; + int track_kind_1; + int kind_1_limit; +} admin_context_config_t; + typedef struct { agent_keys_t keys; admin_config_t admin; @@ -55,6 +79,8 @@ typedef struct { int relay_count; llm_config_t llm; tools_config_t tools; + security_config_t security; + admin_context_config_t admin_context; startup_event_t* startup_events; int startup_event_count; } didactyl_config_t; diff --git a/src/main.c b/src/main.c index 0ff75da..fa9ff3a 100644 --- a/src/main.c +++ b/src/main.c @@ -84,6 +84,12 @@ int main(int argc, char** argv) { return 1; } + DEBUG_INFO("[didactyl] startup phase: subscribe admin context begin"); + if (nostr_handler_subscribe_admin_context() != 0) { + DEBUG_WARN("[didactyl] startup phase: subscribe admin context failed (continuing)"); + } + DEBUG_INFO("[didactyl] startup phase: subscribe admin context end"); + DEBUG_INFO("[didactyl] startup phase: subscribe DMs begin"); if (nostr_handler_subscribe_dms(agent_on_message, NULL) != 0) { DEBUG_ERROR("[didactyl] startup phase: subscribe DMs failed"); diff --git a/src/main.h b/src/main.h index d57ade4..18e8fb5 100644 --- a/src/main.h +++ b/src/main.h @@ -12,8 +12,8 @@ // Using DIDACTYL_ prefix to avoid conflicts with nostr_core_lib VERSION macros #define DIDACTYL_VERSION_MAJOR 0 #define DIDACTYL_VERSION_MINOR 0 -#define DIDACTYL_VERSION_PATCH 6 -#define DIDACTYL_VERSION "v0.0.6" +#define DIDACTYL_VERSION_PATCH 7 +#define DIDACTYL_VERSION "v0.0.7" // Agent metadata #define DIDACTYL_NAME "Didactyl" diff --git a/src/nostr_handler.c b/src/nostr_handler.c index 9e5e948..f8b89a1 100644 --- a/src/nostr_handler.c +++ b/src/nostr_handler.c @@ -24,6 +24,21 @@ static char* g_system_context = NULL; static unsigned char* g_startup_published = NULL; static int g_startup_publish_tracking_enabled = 0; +static char* g_admin_kind0_json = NULL; +static char* g_admin_kind10002_json = NULL; +static char** g_admin_wot_contacts = NULL; +static int g_admin_wot_contact_count = 0; + +typedef struct { + time_t created_at; + char* content; +} admin_kind1_note_t; + +static admin_kind1_note_t* g_admin_kind1_notes = NULL; +static int g_admin_kind1_note_count = 0; + +static pthread_mutex_t g_admin_ctx_mutex = PTHREAD_MUTEX_INITIALIZER; + #define DM_DEDUP_CACHE_SIZE 256 static char g_seen_dm_ids[DM_DEDUP_CACHE_SIZE][65]; @@ -87,6 +102,9 @@ static int publish_kind_event_to_relays(int kind, const char** relay_urls, int relay_count, const char* reason_label); +static void on_admin_context_event(cJSON* event, const char* relay_url, void* user_data); +static int parse_kind3_wot_contacts(cJSON* tags); +static void upsert_kind1_note(time_t created_at, const char* content); static void log_relay_statuses(const char* reason) { if (!g_pool || !g_cfg) { @@ -258,6 +276,12 @@ static void on_event(cJSON* event, const char* relay_url, void* user_data) { return; } + if (g_cfg->security.verify_signatures && nostr_verify_event_signature(event) != 0) { + DEBUG_WARN("[didactyl] dropped event with invalid signature via %s", + relay_url ? relay_url : "unknown relay"); + return; + } + cJSON* id = cJSON_GetObjectItemCaseSensitive(event, "id"); cJSON* kind = cJSON_GetObjectItemCaseSensitive(event, "kind"); cJSON* pubkey = cJSON_GetObjectItemCaseSensitive(event, "pubkey"); @@ -277,13 +301,6 @@ static void on_event(cJSON* event, const char* relay_url, void* user_data) { return; } - if (strcmp(pubkey->valuestring, g_cfg->admin.pubkey) != 0) { - fprintf(stdout, "[didactyl] ignored DM from unauthorized pubkey %.16s... via %s\n", - pubkey->valuestring, - relay_url ? relay_url : "unknown relay"); - return; - } - char recipient_pubkey_hex[65] = {0}; if (extract_first_p_tag(tags, recipient_pubkey_hex) != 0) { return; @@ -293,6 +310,27 @@ static void on_event(cJSON* event, const char* relay_url, void* user_data) { return; } + didactyl_sender_tier_t tier = DIDACTYL_SENDER_STRANGER; + if (strcmp(pubkey->valuestring, g_cfg->admin.pubkey) == 0) { + tier = DIDACTYL_SENDER_ADMIN; + } else if (g_cfg->security.wot.enabled && nostr_handler_is_wot_contact(pubkey->valuestring)) { + tier = DIDACTYL_SENDER_WOT; + } + + if (tier == DIDACTYL_SENDER_STRANGER) { + if (!g_cfg->security.stranger.enabled) { + DEBUG_LOG("[didactyl] ignored DM from stranger %.16s... via %s", + pubkey->valuestring, + relay_url ? relay_url : "unknown relay"); + return; + } + + if (g_cfg->security.stranger_response[0] != '\0') { + (void)nostr_handler_send_dm(pubkey->valuestring, g_cfg->security.stranger_response); + } + return; + } + unsigned char sender_pubkey[32]; if (hex_to_pubkey(pubkey->valuestring, sender_pubkey) != 0) { return; @@ -324,12 +362,13 @@ static void on_event(cJSON* event, const char* relay_url, void* user_data) { return; } - DEBUG_INFO("[didactyl] received kind %d event %.16s... from %.16s... via %s", + DEBUG_INFO("[didactyl] received kind %d event %.16s... from %.16s... via %s tier=%d", (int)kind->valuedouble, event_id_hex ? event_id_hex : "", pubkey->valuestring, - relay_url ? relay_url : "unknown relay"); - g_dm_callback(pubkey->valuestring, decrypted, g_dm_user_data); + relay_url ? relay_url : "unknown relay", + (int)tier); + g_dm_callback(pubkey->valuestring, decrypted, tier, g_dm_user_data); free(decrypted); } @@ -339,6 +378,162 @@ static void on_eose(cJSON** events, int event_count, void* user_data) { (void)user_data; } +static void free_admin_context_locked(void) { + free(g_admin_kind0_json); + g_admin_kind0_json = NULL; + free(g_admin_kind10002_json); + g_admin_kind10002_json = NULL; + + if (g_admin_wot_contacts) { + for (int i = 0; i < g_admin_wot_contact_count; i++) { + free(g_admin_wot_contacts[i]); + } + free(g_admin_wot_contacts); + } + g_admin_wot_contacts = NULL; + g_admin_wot_contact_count = 0; + + if (g_admin_kind1_notes) { + for (int i = 0; i < g_admin_kind1_note_count; i++) { + free(g_admin_kind1_notes[i].content); + } + free(g_admin_kind1_notes); + } + g_admin_kind1_notes = NULL; + g_admin_kind1_note_count = 0; +} + +static int parse_kind3_wot_contacts(cJSON* tags) { + if (!tags || !cJSON_IsArray(tags)) { + return 0; + } + + if (g_admin_wot_contacts) { + for (int i = 0; i < g_admin_wot_contact_count; i++) { + free(g_admin_wot_contacts[i]); + } + free(g_admin_wot_contacts); + g_admin_wot_contacts = NULL; + g_admin_wot_contact_count = 0; + } + + int n = cJSON_GetArraySize(tags); + for (int i = 0; i < n; i++) { + cJSON* tag = cJSON_GetArrayItem(tags, i); + if (!tag || !cJSON_IsArray(tag) || cJSON_GetArraySize(tag) < 2) { + continue; + } + + cJSON* key = cJSON_GetArrayItem(tag, 0); + cJSON* val = cJSON_GetArrayItem(tag, 1); + if (!key || !val || !cJSON_IsString(key) || !cJSON_IsString(val) || !key->valuestring || !val->valuestring) { + continue; + } + + if (strcmp(key->valuestring, "p") != 0 || strlen(val->valuestring) != 64U) { + continue; + } + + char* dup = strdup(val->valuestring); + if (!dup) { + return -1; + } + + char** grown = (char**)realloc(g_admin_wot_contacts, (size_t)(g_admin_wot_contact_count + 1) * sizeof(char*)); + if (!grown) { + free(dup); + return -1; + } + + g_admin_wot_contacts = grown; + g_admin_wot_contacts[g_admin_wot_contact_count++] = dup; + } + + return 0; +} + +static void upsert_kind1_note(time_t created_at, const char* content) { + if (!content) { + return; + } + + int limit = g_cfg->admin_context.kind_1_limit > 0 ? g_cfg->admin_context.kind_1_limit : 10; + if (limit > 256) { + limit = 256; + } + + char* dup = strdup(content); + if (!dup) { + return; + } + + admin_kind1_note_t* grown = (admin_kind1_note_t*)realloc(g_admin_kind1_notes, + (size_t)(g_admin_kind1_note_count + 1) * sizeof(admin_kind1_note_t)); + if (!grown) { + free(dup); + return; + } + + g_admin_kind1_notes = grown; + g_admin_kind1_notes[g_admin_kind1_note_count].created_at = created_at; + g_admin_kind1_notes[g_admin_kind1_note_count].content = dup; + g_admin_kind1_note_count++; + + while (g_admin_kind1_note_count > limit) { + free(g_admin_kind1_notes[0].content); + memmove(&g_admin_kind1_notes[0], + &g_admin_kind1_notes[1], + (size_t)(g_admin_kind1_note_count - 1) * sizeof(admin_kind1_note_t)); + g_admin_kind1_note_count--; + } +} + +static void on_admin_context_event(cJSON* event, const char* relay_url, void* user_data) { + (void)relay_url; + (void)user_data; + + if (!event || !g_cfg || !g_cfg->admin_context.enabled) { + return; + } + + if (g_cfg->security.verify_signatures && nostr_verify_event_signature(event) != 0) { + return; + } + + cJSON* kind = cJSON_GetObjectItemCaseSensitive(event, "kind"); + cJSON* pubkey = cJSON_GetObjectItemCaseSensitive(event, "pubkey"); + cJSON* content = cJSON_GetObjectItemCaseSensitive(event, "content"); + cJSON* tags = cJSON_GetObjectItemCaseSensitive(event, "tags"); + cJSON* created_at = cJSON_GetObjectItemCaseSensitive(event, "created_at"); + + if (!kind || !pubkey || !cJSON_IsNumber(kind) || !cJSON_IsString(pubkey) || !pubkey->valuestring) { + return; + } + + if (strcmp(pubkey->valuestring, g_cfg->admin.pubkey) != 0) { + return; + } + + int k = (int)kind->valuedouble; + + pthread_mutex_lock(&g_admin_ctx_mutex); + + if (k == 0 && g_cfg->admin_context.track_kind_0 && content && cJSON_IsString(content) && content->valuestring) { + free(g_admin_kind0_json); + g_admin_kind0_json = strdup(content->valuestring); + } else if (k == 3 && g_cfg->admin_context.track_kind_3 && tags && cJSON_IsArray(tags)) { + (void)parse_kind3_wot_contacts(tags); + } else if (k == 10002 && g_cfg->admin_context.track_kind_10002 && content && cJSON_IsString(content) && content->valuestring) { + free(g_admin_kind10002_json); + g_admin_kind10002_json = strdup(content->valuestring); + } else if (k == 1 && g_cfg->admin_context.track_kind_1 && content && cJSON_IsString(content) && content->valuestring) { + time_t ts = (created_at && cJSON_IsNumber(created_at)) ? (time_t)created_at->valuedouble : time(NULL); + upsert_kind1_note(ts, content->valuestring); + } + + pthread_mutex_unlock(&g_admin_ctx_mutex); +} + int nostr_handler_init(didactyl_config_t* config) { if (!config) { return -1; @@ -390,6 +585,104 @@ int nostr_handler_init(didactyl_config_t* config) { return 0; } +int nostr_handler_subscribe_admin_context(void) { + if (!g_cfg || !g_pool || !g_cfg->admin_context.enabled) { + return 0; + } + + int rc = 0; + + cJSON* profile_filter = cJSON_CreateObject(); + cJSON* profile_kinds = cJSON_CreateArray(); + cJSON* profile_authors = cJSON_CreateArray(); + if (!profile_filter || !profile_kinds || !profile_authors) { + cJSON_Delete(profile_filter); + cJSON_Delete(profile_kinds); + cJSON_Delete(profile_authors); + return -1; + } + + if (g_cfg->admin_context.track_kind_0) cJSON_AddItemToArray(profile_kinds, cJSON_CreateNumber(0)); + if (g_cfg->admin_context.track_kind_3) cJSON_AddItemToArray(profile_kinds, cJSON_CreateNumber(3)); + if (g_cfg->admin_context.track_kind_10002) cJSON_AddItemToArray(profile_kinds, cJSON_CreateNumber(10002)); + + if (cJSON_GetArraySize(profile_kinds) > 0) { + cJSON_AddItemToObject(profile_filter, "kinds", profile_kinds); + cJSON_AddItemToArray(profile_authors, cJSON_CreateString(g_cfg->admin.pubkey)); + cJSON_AddItemToObject(profile_filter, "authors", profile_authors); + cJSON_AddNumberToObject(profile_filter, "limit", 32); + + nostr_pool_subscription_t* profile_sub = nostr_relay_pool_subscribe( + g_pool, + (const char**)g_cfg->relays, + g_cfg->relay_count, + profile_filter, + on_admin_context_event, + on_eose, + NULL, + 0, + 1, + NOSTR_POOL_EOSE_FULL_SET, + 30, + 120); + + if (!profile_sub) { + rc = -1; + } + } else { + cJSON_Delete(profile_kinds); + cJSON_Delete(profile_authors); + } + cJSON_Delete(profile_filter); + + if (g_cfg->admin_context.track_kind_1) { + cJSON* notes_filter = cJSON_CreateObject(); + cJSON* notes_kinds = cJSON_CreateArray(); + cJSON* notes_authors = cJSON_CreateArray(); + if (!notes_filter || !notes_kinds || !notes_authors) { + cJSON_Delete(notes_filter); + cJSON_Delete(notes_kinds); + cJSON_Delete(notes_authors); + return -1; + } + + int kind1_limit = g_cfg->admin_context.kind_1_limit > 0 ? g_cfg->admin_context.kind_1_limit : 10; + if (kind1_limit > 256) { + kind1_limit = 256; + } + + cJSON_AddItemToArray(notes_kinds, cJSON_CreateNumber(1)); + cJSON_AddItemToObject(notes_filter, "kinds", notes_kinds); + cJSON_AddItemToArray(notes_authors, cJSON_CreateString(g_cfg->admin.pubkey)); + cJSON_AddItemToObject(notes_filter, "authors", notes_authors); + cJSON_AddNumberToObject(notes_filter, "limit", kind1_limit); + + nostr_pool_subscription_t* notes_sub = nostr_relay_pool_subscribe( + g_pool, + (const char**)g_cfg->relays, + g_cfg->relay_count, + notes_filter, + on_admin_context_event, + on_eose, + NULL, + 0, + 1, + NOSTR_POOL_EOSE_FULL_SET, + 30, + 120); + + cJSON_Delete(notes_filter); + if (!notes_sub) { + rc = -1; + } + } + + if (rc == 0) { + DEBUG_INFO("[didactyl] admin context subscriptions active for admin %.16s...", g_cfg->admin.pubkey); + } + return rc; +} + int nostr_handler_subscribe_dms(dm_callback_t callback, void* user_data) { if (!g_cfg || !g_pool || !callback) { return -1; @@ -731,6 +1024,58 @@ const char* nostr_handler_get_system_context(void) { return g_system_context; } +char* nostr_handler_get_admin_kind1_notes_context(void) { + if (!g_cfg || !g_cfg->admin_context.enabled || !g_cfg->admin_context.track_kind_1) { + return NULL; + } + + pthread_mutex_lock(&g_admin_ctx_mutex); + + if (g_admin_kind1_note_count <= 0 || !g_admin_kind1_notes) { + pthread_mutex_unlock(&g_admin_ctx_mutex); + return NULL; + } + + size_t total = strlen("Administrator recent public notes:\n") + 1U; + for (int i = 0; i < g_admin_kind1_note_count; i++) { + total += strlen("- ") + strlen(g_admin_kind1_notes[i].content ? g_admin_kind1_notes[i].content : "") + 1U; + } + + char* out = (char*)malloc(total); + if (!out) { + pthread_mutex_unlock(&g_admin_ctx_mutex); + return NULL; + } + + out[0] = '\0'; + strcat(out, "Administrator recent public notes:\n"); + for (int i = 0; i < g_admin_kind1_note_count; i++) { + strcat(out, "- "); + strcat(out, g_admin_kind1_notes[i].content ? g_admin_kind1_notes[i].content : ""); + strcat(out, "\n"); + } + + pthread_mutex_unlock(&g_admin_ctx_mutex); + return out; +} + +int nostr_handler_is_wot_contact(const char* pubkey_hex) { + if (!pubkey_hex || strlen(pubkey_hex) != 64U) { + return 0; + } + + pthread_mutex_lock(&g_admin_ctx_mutex); + int found = 0; + for (int i = 0; i < g_admin_wot_contact_count; i++) { + if (g_admin_wot_contacts[i] && strcmp(g_admin_wot_contacts[i], pubkey_hex) == 0) { + found = 1; + break; + } + } + pthread_mutex_unlock(&g_admin_ctx_mutex); + return found; +} + int nostr_handler_poll(int timeout_ms) { if (!g_pool) { return -1; @@ -772,4 +1117,8 @@ void nostr_handler_cleanup(void) { memset(g_seen_dm_ids, 0, sizeof(g_seen_dm_ids)); g_seen_dm_count = 0; g_seen_dm_next = 0; + + pthread_mutex_lock(&g_admin_ctx_mutex); + free_admin_context_locked(); + pthread_mutex_unlock(&g_admin_ctx_mutex); } diff --git a/src/nostr_handler.h b/src/nostr_handler.h index 3e8f908..f1f838f 100644 --- a/src/nostr_handler.h +++ b/src/nostr_handler.h @@ -4,9 +4,19 @@ #include "config.h" #include "cjson/cJSON.h" -typedef void (*dm_callback_t)(const char* sender_pubkey_hex, const char* message, void* user_data); +typedef enum { + DIDACTYL_SENDER_ADMIN = 1, + DIDACTYL_SENDER_WOT = 2, + DIDACTYL_SENDER_STRANGER = 3 +} didactyl_sender_tier_t; + +typedef void (*dm_callback_t)(const char* sender_pubkey_hex, + const char* message, + didactyl_sender_tier_t tier, + void* user_data); int nostr_handler_init(didactyl_config_t* config); +int nostr_handler_subscribe_admin_context(void); int nostr_handler_subscribe_dms(dm_callback_t callback, void* user_data); int nostr_handler_send_dm(const char* recipient_pubkey_hex, const char* message); int nostr_handler_publish_kind_event(int kind, const char* content, cJSON* tags); @@ -14,6 +24,8 @@ char* nostr_handler_query_json(cJSON* filter, int timeout_ms); int nostr_handler_poll(int timeout_ms); int nostr_handler_reconcile_startup_events(void); const char* nostr_handler_get_system_context(void); +char* nostr_handler_get_admin_kind1_notes_context(void); +int nostr_handler_is_wot_contact(const char* pubkey_hex); void nostr_handler_cleanup(void); #endif \ No newline at end of file