Files
didactyl/plans/security_and_admin_context.md

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:

  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:

{
  "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.

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 changeconfig.json replaces keys.nsec with:

    {
      "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.