Skip to content
Open
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
24 changes: 24 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,30 @@ OKTA_AUDIENCE=sprout-desktop
# OKTA_AUDIENCE=sprout-api
# OKTA_PUBKEY_CLAIM=nostr_pubkey

# ── Corporate identity mode ─────────────────────────────────────────────────
# Identity mode: "disabled" (default), "proxy", "hybrid".
# proxy — all connections must present a valid identity JWT (no fallback).
# hybrid — identity JWT preferred; connections without it fall through to
# standard auth (API tokens, Okta JWTs) for agents.
# SPROUT_IDENTITY_MODE=disabled
#
# JWT claim names for uid (identity binding key) and username (display only).
# SPROUT_IDENTITY_UID_CLAIM=uid
# SPROUT_IDENTITY_USER_CLAIM=user
#
# Identity provider JWKS configuration. When set, identity JWTs are validated
# against this IdP independently of the main Okta/Keycloak JWKS config above.
# If unset, falls back to OKTA_JWKS_URI / OKTA_ISSUER / OKTA_AUDIENCE.
# SPROUT_IDENTITY_JWKS_URI=http://localhost:9200/certs
# SPROUT_IDENTITY_ISSUER=my-identity-provider
# SPROUT_IDENTITY_AUDIENCE=my-audience
#
# HTTP header names for the proxy-injected identity JWT and device CN.
# These default to the values shown below. Override if your auth proxy uses
# different header names.
# SPROUT_IDENTITY_JWT_HEADER=x-forwarded-identity-token
# SPROUT_IDENTITY_DEVICE_CN_HEADER=x-block-client-cert-subject-cn

# -----------------------------------------------------------------------------
# Ephemeral Channels (TTL testing)
# -----------------------------------------------------------------------------
Expand Down
21 changes: 21 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,27 @@ simple and testable.
thread root events. Any code that inserts replies must update these counters —
check existing reply handlers for the pattern.

**Identity binding (proxy mode)**: In corporate deployments the relay sits
behind a trusted auth proxy that injects identity JWT and device CN headers
(configured via `SPROUT_IDENTITY_JWT_HEADER` and `SPROUT_IDENTITY_DEVICE_CN_HEADER`).
`SPROUT_IDENTITY_MODE` controls behaviour:

- `disabled` (default) — standard Nostr key-based auth only.
- `proxy` — all connections must present a valid identity JWT; the relay binds
(uid, device\_cn) → pubkey in the `identity_bindings` table. NIP-42 is still
required to prove pubkey ownership.
- `hybrid` — identity JWT preferred for humans; connections without the header
fall through to standard auth (API tokens, Okta JWTs) for agents.

Identity bindings are **immutable** — once a (uid, device\_cn) is bound to a
pubkey, a different pubkey for the same slot returns a mismatch error. Use
`sprout-admin unbind-identity` to clear a binding (e.g., key rotation, device
reset, offboarding).

**Trusted-proxy security invariant**: The relay trusts proxy headers
unconditionally. It **must** be deployed behind the trusted reverse proxy —
direct access to the relay port would allow header injection.

---

## Testing
Expand Down
27 changes: 24 additions & 3 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ sprout-huddle (LiveKit audio/video integration — standalone, not wired i
sprout-sdk (typed Nostr event builders — used by sprout-mcp, sprout-acp, and sprout-cli)
sprout-media (Blossom/S3 media storage)
sprout-cli (agent-first CLI)
sprout-admin (operator CLI: mint/list API tokens)
sprout-admin (operator CLI: mint/list API tokens, unbind identities)
sprout-test-client (integration test harness + manual CLI)
```

Expand Down Expand Up @@ -179,9 +179,25 @@ The client must respond with `["AUTH", <signed-event>]` before submitting events
| 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 + proxy identity | Identity JWT at upgrade + NIP-42 challenge | Corporate SSO via auth proxy (proxy/hybrid mode) |

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: ..."]`.

#### Proxy Identity Mode (Corporate SSO)

When `SPROUT_IDENTITY_MODE` is `proxy` or `hybrid`, the relay sits behind a trusted auth proxy that injects an identity JWT via the configured header (`SPROUT_IDENTITY_JWT_HEADER`). The flow adds a two-phase binding step:

1. **WS Upgrade** — The relay validates the identity JWT (signature + expiry via JWKS), extracts `uid` and `username` claims, and stashes them as `PendingProxyIdentity` on the connection. No pubkey is known yet.
2. **NIP-42 AUTH** — The client signs the challenge with its Nostr keypair. The AUTH handler verifies the signature, then calls `bind_or_validate_identity(uid, device_cn, pubkey)` to create or validate the binding. On success, `AuthState` transitions to `Authenticated`.
3. **REST API** — Proxy-authenticated REST requests validate the identity JWT, then look up the existing `(uid, device_cn) → pubkey` binding from the `identity_bindings` table.
4. **Registration** — `POST /api/identity/register` allows initial binding of a pubkey to a corporate identity via NIP-98 proof of key ownership.

**Binding semantics:** Once a `(uid, device_cn)` pair is bound to a pubkey, the binding is immutable. A different pubkey for the same slot returns a 409 Conflict. Admin can unbind via `sprout-admin unbind-identity`.

**Hybrid mode:** When the identity JWT header is absent, the connection falls through to standard NIP-42 auth (API tokens, Okta JWTs). This allows agents without corporate JWTs to authenticate alongside human users.

**Security invariant:** The relay trusts proxy headers unconditionally. It **must** be deployed behind the trusted reverse proxy — direct access would allow header injection.

### Step 4: Active Loops

Three concurrent tasks run for the lifetime of the connection:
Expand Down Expand Up @@ -764,15 +780,18 @@ Sprout Relay ──WS──→ sprout-acp ──stdio (ACP/JSON-RPC)──→ Ag

### sprout-admin — Operator CLI

**213 LOC.** Two subcommands:
**~280 LOC.** Three subcommands:

| Subcommand | Purpose |
|------------|---------|
| `mint-token` | Generate API token, store SHA-256 hash in DB, display raw token once |
| `list-tokens` | List all active tokens (ID, name, scopes, created) |
| `unbind-identity` | Remove identity binding(s) for key rotation or offboarding |

`mint-token` options: `--name`, `--scopes` (comma-separated), optional `--pubkey`. If `--pubkey` omitted, generates a new keypair and displays `nsec` (bech32) and pubkey.

`unbind-identity` options: `--uid` (required), optional `--device-cn` (omit to remove all devices), `--clear-name` (also clears verified_name from user records). Cache propagation delay: up to 2 minutes.

Raw token is shown exactly once and never stored. Only the SHA-256 hash reaches the database.

---
Expand Down Expand Up @@ -818,6 +837,7 @@ Every security-sensitive operation uses an explicit, verified pattern. No implic
| 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]` only — least-privilege default |
| Proxy identity | JWT validated via JWKS; headers trusted from auth proxy only; `require_auth_token` forced true |

### Input Validation

Expand Down Expand Up @@ -890,6 +910,7 @@ Docker Compose provides the full local development stack. All services include h
| `api_tokens` | API token records (hash only, never plaintext) |
| `audit_log` | Hash-chain audit entries |
| `delivery_log` | Delivery tracking (partitioned; Rust module pending) |
| `identity_bindings` | Proxy mode: (uid, device_cn) → pubkey binding for corporate identity |

### Redis Key Patterns

Expand Down Expand Up @@ -939,7 +960,7 @@ These are verified gaps in the current implementation — not design aspirations
| sprout-sdk | 1,237 | Shared library |
| sprout-media | 977 | Media storage |
| sprout-cli | 2,919 | Tooling |
| sprout-admin | 213 | Tooling |
| sprout-admin | ~280 | Tooling |
| sprout-test-client | 9,319 | Tooling |
| **Total** | **~72,126** | |

Expand Down
62 changes: 62 additions & 0 deletions crates/sprout-admin/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,20 @@ enum Command {
},
/// List all active API tokens.
ListTokens,
/// Remove an identity binding (for key rotation or offboarding).
UnbindIdentity {
/// Corporate user identifier (UID from identity JWT).
#[arg(long)]
uid: String,

/// Device common name. If omitted, removes all bindings for the UID.
#[arg(long)]
device_cn: Option<String>,

/// Also clear verified_name from the user record(s).
#[arg(long, default_value_t = false)]
clear_name: bool,
},
}

#[tokio::main]
Expand All @@ -61,6 +75,11 @@ async fn main() -> Result<()> {
owner_pubkey,
} => mint_token(&db, &name, &scopes, pubkey.as_deref(), owner_pubkey).await?,
Command::ListTokens => list_tokens(&db).await?,
Command::UnbindIdentity {
uid,
device_cn,
clear_name,
} => unbind_identity(&db, &uid, device_cn.as_deref(), clear_name).await?,
}

Ok(())
Expand Down Expand Up @@ -183,6 +202,49 @@ async fn mint_token(
Ok(())
}

async fn unbind_identity(
db: &Db,
uid: &str,
device_cn: Option<&str>,
clear_name: bool,
) -> Result<()> {
if let Some(device_cn) = device_cn {
// Single binding removal
let binding = db.get_identity_binding(uid, device_cn).await?;
let deleted = db.delete_identity_binding(uid, device_cn).await?;
if deleted {
println!("Removed identity binding for uid={uid}, device_cn={device_cn}");
if clear_name {
if let Some(binding) = binding {
let cleared = db.clear_verified_name(&binding.pubkey).await?;
if cleared {
println!("Cleared verified_name for the bound pubkey");
}
}
}
} else {
println!("No binding found for uid={uid}, device_cn={device_cn}");
}
} else {
// Remove all bindings for the UID
let bindings = db.get_bindings_for_uid(uid).await?;
let count = db.delete_bindings_for_uid(uid).await?;
println!("Removed {count} identity binding(s) for uid={uid}");
if clear_name {
for binding in &bindings {
let cleared = db.clear_verified_name(&binding.pubkey).await?;
if cleared {
println!(
"Cleared verified_name for pubkey bound to device_cn={}",
binding.device_cn
);
}
}
}
}
Ok(())
}

async fn list_tokens(db: &Db) -> Result<()> {
let tokens = db.list_active_tokens().await?;

Expand Down
Loading