# 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 `local_shell_exec` and `local_file_read` tools — a sufficiently clever prompt injection could theoretically: - Read `config.json` via `local_file_read` (contains nsec) - Execute `cat /proc/self/maps` or similar via `local_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 (local_shell_exec, local_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.