Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 0 additions & 39 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -46,46 +46,11 @@ RELAY_URL=ws://localhost:3000
# Stable relay signing key. Set this in dev if you want REST-created forum posts
# to keep resolving to the original author across relay restarts.
# SPROUT_RELAY_PRIVATE_KEY=<32-byte hex private key>
# Set to true in production to require bearer token authentication
SPROUT_REQUIRE_AUTH_TOKEN=false

# Optional: path to the web UI dist directory. When set, the relay serves
# the web frontend at / for browser requests. Leave unset for local dev
# (use `just web` for Vite HMR instead).
# SPROUT_WEB_DIR=./web/dist

# -----------------------------------------------------------------------------
# Auth
# -----------------------------------------------------------------------------
# Set to false for dev (accepts NIP-42 without JWT, allows X-Pubkey header).
# Set to true in production to require bearer token authentication.
SPROUT_REQUIRE_AUTH_TOKEN=false

# JWKS endpoint for verifying JWT access tokens.
# Claim that carries the user's Nostr public key (hex, 32 bytes).
OKTA_PUBKEY_CLAIM=nostr_pubkey

# ── Keycloak (local OAuth testing — stands in for Okta in prod) ──────────────
# Keycloak is NOT a production dependency. It lets you test the full OAuth
# flow locally without needing an Okta tenant. Run `docker compose up -d`
# then `./scripts/setup-keycloak.sh` to create the realm, client, and users.
#
# Admin UI: http://localhost:8180 (admin / admin)
# Get a token:
# curl -s -X POST http://localhost:8180/realms/sprout/protocol/openid-connect/token \
# -d 'client_id=sprout-desktop&grant_type=password&username=tyler&password=password123' \
# | jq -r .access_token
OKTA_JWKS_URI=http://localhost:8180/realms/sprout/protocol/openid-connect/certs
OKTA_ISSUER=http://localhost:8180/realms/sprout
OKTA_AUDIENCE=sprout-desktop

# ── Okta (production / staging) ──────────────────────────────────────────────
# Uncomment and fill in when deploying against a real Okta tenant.
# OKTA_JWKS_URI=https://dev-example.okta.com/oauth2/default/v1/keys
# OKTA_ISSUER=https://dev-example.okta.com/oauth2/default
# OKTA_AUDIENCE=sprout-api
# OKTA_PUBKEY_CLAIM=nostr_pubkey

# -----------------------------------------------------------------------------
# Ephemeral Channels (TTL testing)
# -----------------------------------------------------------------------------
Expand Down Expand Up @@ -126,9 +91,6 @@ RUST_LOG=sprout_relay=debug,sprout_db=debug,sprout_auth=debug,sprout_pubsub=debu
# Nostr private key (hex or bech32). REQUIRED — identifies the agent on the relay.
# SPROUT_PRIVATE_KEY=<32-byte hex or nsec1… private key>

# Bearer token for relay authentication (when SPROUT_REQUIRE_AUTH_TOKEN=true).
# SPROUT_API_TOKEN=

# Relay WebSocket URL the harness connects to.
# Note: the relay itself uses RELAY_URL (above); this is the ACP harness's
# connection target — they happen to point at the same place in local dev.
Expand Down Expand Up @@ -224,4 +186,3 @@ RUST_LOG=sprout_relay=debug,sprout_db=debug,sprout_auth=debug,sprout_pubsub=debu
# These are accepted for backward compatibility but the canonical names above
# are preferred:
# SPROUT_ACP_PRIVATE_KEY → SPROUT_PRIVATE_KEY
# SPROUT_ACP_API_TOKEN → SPROUT_API_TOKEN
21 changes: 21 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,27 @@ jobs:
- name: Dependency policy
run: cargo-deny check

dead-token-guard:
name: Dead Token Reference Guard
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Check for dead API token references in client code
run: |
# Fail if dead API token patterns reappear in desktop, mobile, docs, or config.
# Relay crates are excluded — they still use token auth internally.
PATTERNS='TokenScope|MintTokenResponse|hasApiToken|spr_tok_'
PATHS='desktop/src/ desktop/tests/ mobile/test/ mobile/lib/ .env.example'
EXCLUDES='--exclude-dir=node_modules --exclude-dir=.dart_tool'
if grep -rn $EXCLUDES -E "$PATTERNS" $PATHS 2>/dev/null; then
echo "::error::Dead API token references found in client code. See above."
exit 1
fi
echo "No dead token references found."

server-cross-compile:
name: Server Cross-Compile
runs-on: ubuntu-latest
Expand Down
42 changes: 13 additions & 29 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ Sprout is a Rust monorepo (~72K LOC across 17 crates), licensed Apache 2.0 under
sprout-core (zero I/O — types, verification, filter matching, kind registry)
├── sprout-db (Postgres: events, channels, tokens, workflows, audit)
├── sprout-auth (NIP-42, Okta JWT, API tokens, scopes, rate limiting)
├── sprout-auth (NIP-42, NIP-98, API tokens, scopes, rate limiting)
├── sprout-pubsub (Redis pub/sub, presence, typing indicators)
├── sprout-search (Typesense: index, query, delete)
├── sprout-audit (hash-chain tamper-evident log)
Expand Down Expand Up @@ -171,14 +171,12 @@ The relay immediately sends `["AUTH", "<challenge>"]`. The challenge is a random

### Step 3: Authentication

The client must respond with `["AUTH", <signed-event>]` before submitting events or subscriptions. Four authentication paths:
The client must respond with `["AUTH", <signed-event>]` before submitting events or subscriptions. Authentication paths:

| Path | Mechanism | Use Case |
|------|-----------|---------|
| NIP-42 only | Signed challenge, pubkey verified | Dev mode / open relay |
| NIP-42 + Okta JWT | Challenge + JWKS-validated JWT in `auth_token` tag | Human SSO via Okta |
| NIP-42 + API token | Challenge + `auth_token` tag, constant-time hash verify | Agent/service accounts |
| HTTP Bearer JWT | `Authorization: Bearer <jwt>` header on REST endpoints | REST API clients |
| NIP-42 | Signed challenge, pubkey verified | WebSocket connections |
| NIP-98 HTTP Auth | Schnorr-signed `kind:27235` event on REST endpoints | REST API clients |

On success, `ConnectionState.auth_state` transitions from `Pending` → `Authenticated(AuthContext)`. On failure → `Failed`. Unauthenticated EVENT/REQ messages are rejected with `["CLOSED", ...]` or `["OK", ..., false, "auth-required: ..."]`.

Expand Down Expand Up @@ -347,22 +345,20 @@ pub const ALL_KINDS: &[u32] // 80 entries (KIND_AUTH excluded — never stored)

### sprout-auth — Authentication and Authorization

**2,310 LOC.** Handles all four authentication paths, JWKS caching, scope enforcement, and token operations.
**2,310 LOC.** Handles authentication paths, scope enforcement, and token operations.

**Four auth paths:**
**Auth paths:**

| Path | Entry Point | Notes |
|------|-------------|-------|
| NIP-42 only | `verify_auth_event()` | Dev mode; grants `Scope::all_known()` (all 14 scopes) |
| NIP-42 + Okta JWT | `verify_auth_event()` | JWT in `auth_token` tag; JWKS-validated |
| NIP-42 + API token | Relay AUTH handler → DB lookup | `auth_token` tag with `sprout_` prefix; relay intercepts before `verify_auth_event()` (which has no DB access) |
| HTTP Bearer JWT | `validate_bearer_jwt()` | REST endpoints; skips pubkey cross-check; always adds `ChannelsRead` |
| NIP-42 | `verify_auth_event()` | Schnorr-signed challenge/response; grants `Scope::all_known()` (all 14 scopes) |
| NIP-98 HTTP Auth | `validate_nip98_auth()` | REST endpoints; Schnorr-signed `kind:27235` event |

**Key types:**

```rust
pub struct AuthContext { pub pubkey: PublicKey, pub scopes: Vec<Scope>, pub auth_method: AuthMethod }
pub enum AuthMethod { Nip42PubkeyOnly, Nip42Okta, Nip42ApiToken }
pub enum AuthMethod { Nip42, Nip98 }
pub enum Scope { MessagesRead, MessagesWrite, ChannelsRead, ChannelsWrite,
AdminChannels, UsersRead, UsersWrite, AdminUsers,
JobsRead, JobsWrite, SubscriptionsRead, SubscriptionsWrite,
Expand All @@ -372,10 +368,7 @@ pub trait RateLimiter: Send + Sync { ... }
```

**Security details:**
- JWKS double-checked locking: two read-lock checks before fetching, HTTP fetch with no lock held, write-lock re-check after. Cache TTL: 300 seconds.
- Token comparison: `subtle::ConstantTimeEq` — constant-time, prevents timing attacks.
- Token format: `sprout_<64-hex-chars>` (71 chars). `hash_token()` → SHA-256 → stored hash.
- Scopeless JWT defaults to `[MessagesRead]` only (not read+write).
- NIP-98 auth: Schnorr-signed `kind:27235` events with URL + method tags.
- NIP-42 timestamp tolerance: ±60 seconds.
- Dev-only key derivation: `SHA-256("sprout-test-key:{username}")` — gated behind `#[cfg(any(test, feature = "dev"))]`. The `dev` feature must not be enabled in production relay deployments.

Expand All @@ -396,7 +389,6 @@ pub trait RateLimiter: Send + Sync { ... }
| `feed.rs` | `query_mentions` (INNER JOIN event_mentions), `query_needs_action`, `query_activity` |
| `workflow.rs` | Full workflow/run/approval CRUD; SHA-256 hashed approval tokens |
| `partition.rs` | Monthly range partitioning for `events` and `delivery_log` tables |
| `api_token.rs` | Token creation; receives pre-hashed token from caller |
| `dm.rs` | DM channel management |
| `reaction.rs` | Reaction storage and retrieval |
| `thread.rs` | Thread/reply tracking |
Expand All @@ -415,8 +407,7 @@ pub trait RateLimiter: Send + Sync { ... }
- Soft-delete for channel members: `remove_member` sets `removed_at`; re-adding reverses it.
- Feed hard cap: `FEED_MAX_LIMIT = 100` rows regardless of caller-requested limit.
- `query_mentions` uses `INNER JOIN event_mentions` — normalized table with composite index on `(pubkey_hex, created_at)`.
- API tokens: raw token never reaches the DB — caller hashes with SHA-256 before passing to `create_api_token`.
- Approval tokens: separate path — `create_approval` receives the raw token and hashes it internally.
- Approval tokens: `create_approval` receives the raw token and hashes it internally with SHA-256.
- DDL injection protection in partition manager: allowlist of table names + strict suffix/date validators.

**Does NOT:** cache queries, implement connection pooling logic (delegated to sqlx), or make network calls outside Postgres.
Expand Down Expand Up @@ -664,8 +655,6 @@ pub enum AuthState { Pending { challenge: String }, Authenticated(AuthContext),
| POST | `/api/approvals/{token}/deny` | Deny a workflow step (🚧 unreachable — see WF-08) |
| POST | `/api/approvals/by-hash/{hash}/grant` | Approve by hash (🚧 unreachable — see WF-08) |
| POST | `/api/approvals/by-hash/{hash}/deny` | Deny by hash (🚧 unreachable — see WF-08) |
| GET/POST/DELETE | `/api/tokens` | List/create/delete all API tokens |
| DELETE | `/api/tokens/{id}` | Delete specific API token |
| GET | `/api/dms` | List DM channels |
| POST | `/api/dms` | Open a DM channel |
| POST | `/api/dms/{channel_id}/members` | Add DM member |
Expand Down Expand Up @@ -715,7 +704,7 @@ pub enum AuthState { Pending { challenge: String }, Authenticated(AuthContext),
- Connects to relay via WebSocket (`tokio_tungstenite`). Handles NIP-42 auth automatically.
- Ephemeral keypair generated if `SPROUT_PRIVATE_KEY` not set (printed to stderr).
- Exponential backoff reconnection: 1s → 30s. Resubscribes all active subscriptions after reconnect.
- REST calls use `Authorization: Bearer <token>` when `SPROUT_API_TOKEN` is set; falls back to `X-Pubkey: <hex>` in dev mode.
- REST calls use NIP-98 Schnorr-signed auth when `SPROUT_PRIVATE_KEY` is set; falls back to `X-Pubkey: <hex>` in dev mode.
- `create_channel` sends a signed Nostr kind 9007 event (NIP-29 group creation, not a REST call).
- `set_canvas` sends kind 40100 with `h` tag pointing to channel UUID.
- UUID validation at tool boundary before any network call.
Expand Down Expand Up @@ -812,12 +801,9 @@ Every security-sensitive operation uses an explicit, verified pattern. No implic

| Concern | Mechanism |
|---------|-----------|
| Token comparison | `subtle::ConstantTimeEq` — prevents timing attacks |
| Token storage | SHA-256 hash only — raw token shown once at mint, never stored |
| JWKS cache | Double-checked locking; HTTP fetch with no lock held (prevents global DoS) |
| NIP-42 timestamp | ±60 second tolerance — prevents replay attacks |
| AUTH events | Never stored in Postgres, never logged in audit chain |
| Scopeless JWT | Defaults to `[MessagesRead]` onlyleast-privilege default |
| NIP-98 HTTP Auth | Schnorr-signed `kind:27235` eventsURL and method verification |

### Input Validation

Expand Down Expand Up @@ -873,7 +859,6 @@ Docker Compose provides the full local development stack. All services include h
| Redis | `redis:7-alpine` | 6379 | Pub/sub fan-out, presence (SET EX), typing (sorted sets) |
| Typesense | `typesense/typesense:27.1` | 8108 | Full-text search index |
| Adminer | `adminer` | 8082 | DB web UI (dev only) |
| Keycloak | `quay.io/keycloak/keycloak:26` | 8180 | Local OAuth/OIDC stand-in for Okta |
| MinIO | `minio/minio` | 9000 (API), 9001 (console) | S3-compatible object storage (media) |
| Prometheus | `prom/prometheus` | 9090 | Metrics collection |

Expand All @@ -887,7 +872,6 @@ Docker Compose provides the full local development stack. All services include h
| `workflows` | Workflow definitions (YAML stored as canonical JSON) |
| `workflow_runs` | Execution records with trigger context and trace |
| `workflow_approvals` | Approval gates (token stored as SHA-256 hash) |
| `api_tokens` | API token records (hash only, never plaintext) |
| `audit_log` | Hash-chain audit entries |
| `delivery_log` | Delivery tracking (partitioned; Rust module pending) |

Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,7 @@ The short version:
sprout-relay ← WebSocket server, REST API, event ingestion
sprout-core ← Shared types, event verification, filter matching
sprout-db ← Postgres access layer (sqlx)
sprout-auth ← NIP-42 + OIDC JWT + API token scopes
sprout-auth ← NIP-42 + NIP-98 + API token scopes
sprout-pubsub ← Redis fan-out
sprout-search ← Typesense full-text search
sprout-audit ← Tamper-evident hash-chain audit log
Expand Down
4 changes: 0 additions & 4 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -91,10 +91,6 @@ zeroize = "1.8"
dashmap = "6"
moka = { version = "0.12", features = ["sync"] }

# JWT validation (Okta JWKS)
jsonwebtoken = "9"


# Async stream utilities
futures-util = "0.3"

Expand Down
27 changes: 13 additions & 14 deletions NOSTR.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ third-party Nostr clients to connect:

**Direct** is simpler — no extra process, no translation layer. Use it when your client speaks
NIP-29. **Proxy** is for external guests (investors, press, partners, etc.) who use standard NIP-28
clients and don't have company Okta/API-token credentials.
clients and don't have company credentials.

Both paths require NIP-42 authentication.

Expand Down Expand Up @@ -83,10 +83,10 @@ PGPASSWORD=sprout_dev psql -h localhost -U sprout -d sprout -c \
### Pubkey Allowlist

When `SPROUT_PUBKEY_ALLOWLIST=true`, NIP-42 connections that authenticate with only a pubkey
(no JWT, no API token) are checked against the `pubkey_allowlist` table. This lets you open the
relay to specific external Nostr identities without granting full Okta/API-token access.
(no API token) are checked against the `pubkey_allowlist` table. This lets you open the
relay to specific external Nostr identities without granting full access.

- Users with valid **API tokens** (`sprout_*`) or **Okta JWTs** bypass the allowlist.
- Users with valid **API tokens** bypass the allowlist.
- **Fail-closed:** if the DB lookup fails, the connection is denied.
- Default: `false` (all authenticated pubkeys accepted).
- Auth failure returns generic `auth-required: verification failed` (no allowlist-specific message).
Expand Down Expand Up @@ -212,11 +212,11 @@ external user maps to a consistent identity on the relay.
export SPROUT_PROXY_SERVER_KEY=$(openssl rand -hex 32)
PROXY_PUBKEY=$(echo $SPROUT_PROXY_SERVER_KEY | nak key public)

# 3. Mint a proxy API token
cargo run -p sprout-admin -- mint-token \
--name "sprout-proxy" \
--scopes "proxy:submit,channels:read,messages:read" \
--pubkey $PROXY_PUBKEY
# 3. Mint a proxy API token (required until proxy is migrated to NIP-98 auth)
export SPROUT_PROXY_API_TOKEN=$(curl -s -X POST http://localhost:3000/api/tokens \
-H "Authorization: Nostr <base64-nip98-event>" \
-H "Content-Type: application/json" \
-d '{"name":"proxy"}' | jq -r .token)

# 4. Get the relay's public key (needed for attribution trust)
# This is the pubkey of the relay's signing keypair. If SPROUT_RELAY_PRIVATE_KEY
Expand All @@ -227,7 +227,6 @@ export SPROUT_RELAY_PUBKEY=<relay-hex-pubkey>
# 5. Start the proxy
export SPROUT_UPSTREAM_URL=ws://localhost:3000
export SPROUT_PROXY_SALT=$(openssl rand -hex 32)
export SPROUT_PROXY_API_TOKEN=<token from step 3>
export SPROUT_PROXY_ADMIN_SECRET=$(openssl rand -hex 16)
cargo run -p sprout-proxy # proxy on :4869

Expand Down Expand Up @@ -312,7 +311,7 @@ curl -X DELETE http://localhost:4869/admin/guests \
-d '{"pubkey": "<hex>"}'
```

> **Private channels:** The proxy authenticates upstream using its own server key and API token.
> **Private channels:** The proxy authenticates upstream using its own server key via NIP-42.
> `GET /api/channels` and relay REQ filters only return channels accessible to that identity.
> For the proxy to expose a private channel, the proxy's server pubkey must itself be a member
> of that channel. Guest registration alone is not sufficient for private channels.
Expand Down Expand Up @@ -464,9 +463,9 @@ is dual-sourced: local snapshot metadata plus upstream edit events (kind:40003
| Variable | Required | Default | Description |
|----------|:--------:|---------|-------------|
| `SPROUT_UPSTREAM_URL` | ✅ | — | WebSocket URL of the relay |
| `SPROUT_PROXY_API_TOKEN` | ✅ | — | Relay API token for REST calls (required until proxy is migrated to NIP-98 auth) |
| `SPROUT_PROXY_SERVER_KEY` | ✅ | — | Hex-encoded 32-byte secret key (raw hex, not bech32 `nsec`) |
| `SPROUT_PROXY_SALT` | ✅ | — | Hex 32-byte salt for shadow keys (keep stable and secret) |
| `SPROUT_PROXY_API_TOKEN` | ✅ | — | API token with `proxy:submit,channels:read,messages:read` |
| `SPROUT_RELAY_PUBKEY` | ✅ | — | Hex-encoded 64-char relay public key (for attribution trust) |
| `SPROUT_PROXY_BIND_ADDR` | ❌ | `0.0.0.0:4869` | Listen address |
| `SPROUT_PROXY_RELAY_URL` | ❌ | derived from bind addr | Public WebSocket URL for NIP-42 relay-tag validation. Set if behind a reverse proxy. |
Expand All @@ -481,15 +480,15 @@ is dual-sourced: local snapshot metadata plus upstream edit events (kind:40003
|----------|:--------:|---------|-------------|
| `SPROUT_PUBKEY_ALLOWLIST` | ❌ | `false` | Enable pubkey allowlist for NIP-42 pubkey-only auth |
| `SPROUT_RELAY_PRIVATE_KEY` | ❌ | random | Hex secret key for relay signing (discovery events, system messages) |
| `SPROUT_REQUIRE_AUTH_TOKEN` | ❌ | `false` | Require JWT/API token for all connections |
| `SPROUT_REQUIRE_AUTH_TOKEN` | ❌ | `false` | Require authenticated NIP-42 for all connections |

---

## Security Notes

### Direct Path
- **Pubkey allowlist is fail-closed.** DB errors deny the connection.
- **API token / Okta JWT users bypass the allowlist.** The allowlist only gates pubkey-only NIP-42.
- **API token users bypass the allowlist.** The allowlist only gates pubkey-only NIP-42.
- **kind:9 requires `#h` tag.** Messages without a channel-scoped `#h` tag are rejected.
- **kind:7 derives channel from target.** Reactions look up the target event's channel via `#e` — client-supplied `#h` tags are ignored. Reactions to unknown events are rejected (fail-closed).
- **kind:5 uses `#h` if present, but doesn't require it.** Deletions validate author-match against target events via `#e` tags. Only self-authored events can be deleted (admin deletions use kind:9005).
Expand Down
Loading
Loading