# 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.
