287 lines
11 KiB
Markdown
287 lines
11 KiB
Markdown
# 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": ["<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 `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://<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
|
|
|
|
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.
|