11 KiB
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
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:
- In
on_event()insrc/nostr_handler.c, callnostr_verify_event_signature(event)fromnostr_core_lib/nostr_core/nip001.h - If verification fails, drop the event silently with a
DEBUG_WARNlog - This prevents relay-forged pubkey fields from being trusted
- Controlled by
security.verify_signaturesconfig 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.enabledisfalse, 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 stringkind_3_contacts— array of followed pubkey hex strings extracted from kind 3ptagskind_3_contact_count— countkind_10002_json— latest kind 10002 content stringkind_1_notes— array of recent kind 1 content strings with timestampskind_1_note_count— count
Subscription mechanics
- Single subscription filter:
{"authors": ["<admin_pubkey>"], "kinds": [0, 3, 10002, 1], "limit": <configurable>} - On EOSE: initial load complete, WoT set is ready
- After EOSE: live updates as admin posts new content
- Kind 3
ptags 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:
{
"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; iffalse, 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
-
Add signature verification to
on_event()- File:
src/nostr_handler.c - Call
nostr_verify_event_signature(event)at the top ofon_event() - If it returns non-zero, log and drop
- File:
-
Add
admin_context_tstruct and security config structs- File:
src/config.h - New structs for admin context storage, security config, tier config
- File:
-
Parse new config sections
- File:
src/config.c - Parse
securityandadmin_contextfrom config.json - Apply defaults when sections are missing
- File:
-
Add admin context subscription
- File:
src/nostr_handler.c - New function
nostr_handler_subscribe_admin_context() - Callback that populates
admin_context_tfrom incoming events - Extract kind 3
ptags into WoT set - Store kind 1 notes in a ring buffer capped at
kind_1_limit
- File:
-
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
- File:
-
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_tsignature to include tier enum - STRANGER with
stranger.enabled: send canned response directly, no callback - STRANGER without: drop silently
- File:
-
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
- File:
-
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
- File:
-
Update soul content in
config.json- Allow sharing public key with anyone
- Forbid revealing private key under any circumstances
-
Update
config.jsonwith new sections- Add
securityandadmin_contextsections
- Add
-
Update
README.md- Document privilege tiers
- Document new config sections
- Update architecture diagram
-
Build and validate
- Run
./build_static.sh - Verify compilation succeeds
- Run
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.jsonvialocal_file_read(contains nsec) - Execute
cat /proc/self/mapsor similar vialocal_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.
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
-
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
-
Didactyl as NIP-46 client — instead of calling
nostr_create_and_sign_event()directly:- Sends
sign_eventRPC to the remote signer - Sends
nip04_encrypt/nip04_decryptRPC for DM handling - Only holds a disposable
client-keypairfor NIP-46 communication
- Sends
-
Config change —
config.jsonreplaceskeys.nsecwith:{ "keys": { "bunker_url": "bunker://<remote-signer-pubkey>?relay=wss://relay.example.com&secret=<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
-
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
-
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
-
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
- Every
-
Startup flow change
- Parse bunker URL from config
- Generate client keypair
- Send
connectrequest to remote signer - Call
get_public_keyto learn user pubkey - Proceed with normal startup
-
Backward compatibility
- If
keys.nsecis present, use direct signing (current behavior) - If
keys.bunker_urlis present, use NIP-46 remote signing - This allows gradual migration
- If
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.