diff --git a/README.md b/README.md index e14f0edd..bf76e95b 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ strfry is a relay for the [nostr protocol](https://github.com/nostr-protocol/nostr) -* Supports most applicable NIPs: 1, 2, 4, 9, 11, 22, 28, 40, 70, 77 +* Supports most applicable NIPs: 1, 2, 4, 9, 11, 22, 28, 40, 42, 70, 77 * No external database required: All data is stored locally on the filesystem in LMDB * Hot reloading of config file: No server restart needed for many config param changes * Zero downtime restarts, for upgrading binary without impacting users @@ -37,6 +37,11 @@ If you are using strfry, please [join our telegram chat](https://t.me/strfry_use * [Zero Downtime Restarts](#zero-downtime-restarts) * [Plugins](#plugins) * [Router](#router) + * [Authentication (NIP-42)](#authentication-nip-42) + * [Configuration](#auth-configuration) + * [Session Tokens](#session-tokens) + * [HTTP Verification Endpoint](#http-verification-endpoint) + * [Authenticating Extensions and Clients](#authenticating-extensions-and-clients) * [Syncing](#syncing) * [Compression Dictionaries](#compression-dictionaries) * [Architecture](#architecture) @@ -256,6 +261,326 @@ If you are building a "mesh" topology of routers, or mirroring events to neighbo +### Authentication (NIP-42) + +strfry supports [NIP-42](https://nips.nostr.com/42) client authentication with an optional session token extension. This allows clients to authenticate once via a standard NIP-42 challenge-response, then use a lightweight session token for subsequent reconnections and HTTP requests — eliminating redundant signing. + +Session tokens support **client binding**: each token can be bound to a specific client identity (e.g., a browser origin), so a token obtained by one client app cannot be reused by another. This provides per-client isolation even when multiple Nostr apps share the same signing key. + +When auth is enabled, every new WebSocket connection receives an `AUTH` challenge immediately upon connecting. Clients that don't need to authenticate can simply ignore it. When auth is **required**, all `EVENT` and `REQ` operations are gated behind authentication. + +#### Auth Configuration + +Add the following to your `strfry.conf` inside the `relay { }` block: + +``` +auth { + # Enable NIP-42 authentication support + enabled = true + + # Require authentication for all operations (EVENT, REQ). + # If false, auth is available but optional. + required = false + + # The relay URL clients use in AUTH events. + # e.g. "wss://relay.example.com" + # If empty, the relay tag in AUTH events will not be verified. + relayUrl = "wss://relay.example.com" + + # Issue session tokens after successful NIP-42 auth + sessionTokenEnabled = true + + # How long session tokens remain valid, in seconds (default 1 hour) + sessionTokenLifetimeSeconds = 3600 + + # Anti-abuse: delay responses after N failed AUTH attempts per connection (0 = disabled) + tarpitThreshold = 10 + + # Seconds to delay after tarpit threshold is reached + tarpitDelaySeconds = 30 + + # JSON array of event kinds only returned to sender/recipient when auth is enabled. + # Example: "[4,1059,1060,24,25,26,27,35834]" + sensitiveKinds = "" + + # Authorization plugin (see below). Empty = allow all authenticated users. + authorizationPlugin = "" +} +``` + +When `enabled = true`, NIP-42 is advertised in the NIP-11 relay information document (supported NIPs list includes 42, and `limitation.auth_required` reflects the `required` setting). + +**Important:** Set `relayUrl` to your relay's canonical WebSocket URL. This is checked against the `relay` tag in NIP-42 auth events. If left empty, the relay tag is not verified (less secure but functional). + +#### Session Tokens + +After a successful NIP-42 authentication, the relay issues a session token via a new `SESSION` message: + +```json +["SESSION", "", ] +``` + +The token is an HMAC-SHA256 authenticated blob containing the client's pubkey, issue time, expiry time, and an optional client ID. The HMAC secret is generated randomly on each relay startup, so tokens do not survive restarts (clients simply fall back to NIP-42). + +**Client-bound tokens:** If the AUTH event includes a `["client", ""]` tag (32 hex chars / 16 bytes), the session token is bound to that client ID. On reconnection, the client must present the same client ID alongside the token. Tokens without a client tag are "unbound" and work without a client ID (backward compatible). + +**On reconnection**, clients can skip the full NIP-42 challenge-response by sending: + +```json +["SESSION", ""] +["SESSION", "", ""] // for client-bound tokens +``` + +If valid and not expired (and client ID matches, if bound), the relay authenticates the connection immediately and issues a fresh token (rolling refresh, preserving client binding). If the token is invalid, expired, or the client ID doesn't match, the relay responds with an error and re-sends an `AUTH` challenge so the client can fall back to standard NIP-42. + +#### HTTP Verification Endpoint + +When auth and session tokens are enabled, strfry exposes a `GET /auth/verify` HTTP endpoint. This allows HTTP services co-located with the relay to validate session tokens and identify the authenticated user. + +**Request:** + +``` +GET /auth/verify HTTP/1.1 +Authorization: Nostr-Session +Nostr-Client: (optional, for client-bound tokens) +``` + +**Success response (200):** + +```json +{"pubkey": "<64-char-hex-pubkey>", "expires_at": 1700000000, "client_id": ""} +``` + +(The `client_id` field is only present if the token is client-bound.) + +**Failure response (401):** + +```json +{"error": "invalid, expired, or client-mismatched session token"} +``` + +This unifies WebSocket and HTTP authentication under a single token, so extensions and services behind the same domain can verify identity without requiring additional NIP-98 signatures. + + +#### Authenticating Extensions and Clients + +Extensions, bots, and custom clients should authenticate with strfry using the following protocol: + +**Step 1: Connect and receive the challenge** + +Upon opening a WebSocket connection, the relay sends: + +```json +["AUTH", ""] +``` + +**Step 2: Sign and send a NIP-42 auth event** + +Construct a `kind:22242` event with the following structure: + +```json +{ + "kind": 22242, + "created_at": , + "tags": [ + ["relay", "wss://relay.example.com"], + ["challenge", ""], + ["client", "<32-hex-char-client-id>"] // optional, for client binding + ], + "content": "" +} +``` + +The `client` tag is optional. If provided (32 hex characters = 16 random bytes), the resulting session token is bound to this client ID. Different clients (e.g., different web apps) should use different client IDs so their tokens cannot be cross-used. + +Sign this event with the extension's or user's private key (standard Nostr Schnorr signature), then send: + +```json +["AUTH", ] +``` + +**Step 3: Receive confirmation and session token** + +On success, the relay responds with: + +```json +["OK", "", true, ""] +["SESSION", "", ] +``` + +**Step 4: Store and reuse the session token** + +Save the session token along with the client ID you used (if any). On subsequent reconnections, send the token immediately after connecting: + +```json +["SESSION", ""] +["SESSION", "", ""] // if client-bound +``` + +If accepted, the relay responds with a `NOTICE` and a fresh token (preserving client binding). If rejected (expired, relay restarted, or wrong client ID), fall back to Step 2 using the `AUTH` challenge that was sent on connection. + +**Step 5 (optional): Use the token for HTTP requests** + +For any HTTP endpoints served by the relay, include the token in the `Authorization` header: + +``` +Authorization: Nostr-Session +``` + +**Example flow (pseudocode):** + +``` +ws = connect("wss://relay.example.com") + +msg = ws.recv() // ["AUTH", ""] + +client_id = get_or_generate_client_id() // 32 hex chars, unique per app + +if has_saved_token(): + ws.send(["SESSION", saved_token, client_id]) + resp = ws.recv() + if resp is error: + // Token expired or invalid, fall back to NIP-42 + auth_event = sign_event(kind=22242, tags=[ + ["relay", "wss://relay.example.com"], + ["challenge", msg[1]], + ["client", client_id] + ]) + ws.send(["AUTH", auth_event]) +else: + auth_event = sign_event(kind=22242, tags=[ + ["relay", "wss://relay.example.com"], + ["challenge", msg[1]], + ["client", client_id] + ]) + ws.send(["AUTH", auth_event]) + +// After auth, listen for SESSION token +msg = ws.recv() // ["SESSION", "", ] +save_token(msg[1], msg[2], client_id) + +// Now authenticated — proceed with EVENT, REQ, etc. +``` + +**Security notes for extension developers:** + +- Session tokens are bound to a specific pubkey and cannot be transferred between identities. +- **Use client-bound tokens.** Always include a `["client", ""]` tag in your AUTH event and present the same client ID with `["SESSION", token, clientId]`. This ensures a token stolen from one app cannot be reused by another. +- Generate unique client IDs per application instance (e.g., per browser origin). The client ID should be 16 random bytes (32 hex chars), generated once and stored. +- Tokens are time-limited (default 1 hour) and cryptographically signed by the relay. They cannot be forged. +- Tokens are invalidated when the relay restarts. Always implement NIP-42 as a fallback. +- Store tokens securely with their client IDs. A client-bound token is useless without the matching client ID, providing defense-in-depth. +- The `kind:22242` auth event is ephemeral — do **not** send it via `["EVENT", ...]`. Always use `["AUTH", ...]`. The relay will reject kind 22242 events sent via EVENT. + +#### Anti-Abuse Tarpit + +strfry includes a tarpit mechanism to slow down brute-force AUTH attempts. After a configurable number of failed AUTH attempts on a single connection, the relay introduces an artificial delay before responding to further messages on that connection. + +``` +tarpitThreshold = 10 # failures before tarpit activates (0 = disabled) +tarpitDelaySeconds = 30 # delay per response after threshold +``` + +After 10 failed AUTH attempts, each subsequent message on that connection is delayed by 30 seconds. This makes automated credential-stuffing or challenge-replay attacks impractical without affecting legitimate clients (who typically succeed on the first attempt). + +#### Sensitive Event Filtering + +When `sensitiveKinds` is configured, strfry automatically filters events of those kinds so they are only returned to the event's author or a pubkey listed in a `p` tag. This protects DMs, gift wraps, and other private content from being read by unauthorized subscribers. + +``` +sensitiveKinds = "[4,1059,1060,24,25,26,27,35834]" +``` + +**How it works:** + +- When a REQ result or live subscription delivers an event whose kind is in the `sensitiveKinds` list, strfry checks the subscriber's authenticated pubkey against: + 1. The event's `pubkey` field (author) + 2. All `p` tags in the event (recipients) +- If the subscriber matches neither, the event is silently dropped from the response. +- If `sensitiveKinds` is empty (default), no filtering is applied. +- If the subscriber is not authenticated (auth optional mode), sensitive filtering is skipped (they see everything, same as before). + +**Recommended sensitive kinds:** + +| Kind | Description | +|------|-------------| +| 4 | Encrypted Direct Messages (NIP-04) | +| 1059 | Gift Wrap (NIP-59) | +| 1060 | Sealed (NIP-59) | +| 24 | Channel Message (NIP-28) | +| 25 | Channel Hide Message | +| 26 | Channel Mute User | +| 27 | Channel Metadata | +| 35834 | Private Zap | + +#### Authorization Plugin + +The authorization plugin allows relay operators to implement custom access control logic. After a successful NIP-42 or SESSION authentication, strfry calls the plugin to decide whether the pubkey is allowed to use the relay and at what access tier. + +``` +authorizationPlugin = "/path/to/check-access.sh" +``` + +**Protocol:** The plugin is a long-running process that communicates via stdin/stdout (one JSON object per line, same pattern as the existing `writePolicy` plugin). + +**Request (relay → plugin):** + +```json +{"type":"auth","pubkey":"<64-char-hex-pubkey>"} +``` + +**Response (plugin → relay):** + +```json +{"allowed": true, "tier": "full"} +``` + +**Fields:** + +- `allowed` (required): `true` to grant access, `false` to reject. +- `tier` (optional, default `"full"`): Access tier for the authenticated user. + - `"full"` — Full access (EVENT + REQ) + - `"partial"` — Write-only access (EVENT allowed, REQ blocked). Useful for DM inbox relays where senders can write but only recipients can read. + +**If the plugin rejects a pubkey**, the relay responds with `["OK", "", false, "restricted: pubkey not authorized on this relay"]` and does not authenticate the connection. + +**If the plugin crashes or returns an error**, the relay denies access (fail-closed) and logs the error. + +**Example plugin (bash whitelist):** + +```bash +#!/bin/bash +# Simple whitelist authorization plugin +ALLOWED_PUBKEYS="/etc/strfry/allowed_pubkeys.txt" + +while IFS= read -r line; do + pubkey=$(echo "$line" | jq -r '.pubkey') + if grep -q "$pubkey" "$ALLOWED_PUBKEYS" 2>/dev/null; then + echo '{"allowed":true,"tier":"full"}' + else + echo '{"allowed":false}' + fi +done +``` + +**Example plugin (tiered access):** + +```bash +#!/bin/bash +# Tiered access: full members get full access, others get partial (write-only) +FULL_MEMBERS="/etc/strfry/full_members.txt" + +while IFS= read -r line; do + pubkey=$(echo "$line" | jq -r '.pubkey') + if grep -q "$pubkey" "$FULL_MEMBERS" 2>/dev/null; then + echo '{"allowed":true,"tier":"full"}' + else + echo '{"allowed":true,"tier":"partial"}' + fi +done +``` + + ### Syncing The most original feature of strfry is a set reconcillation protocol based on [negentropy](https://github.com/hoytech/negentropy). This is implemented over a [nostr protocol extension](https://github.com/hoytech/strfry/blob/master/docs/negentropy.md) that allows two parties to synchronise their sets of stored messages with minimal bandwidth overhead. Negentropy can be used by both clients and relays. diff --git a/src/ActiveMonitors.h b/src/ActiveMonitors.h index 633728aa..b91c640e 100644 --- a/src/ActiveMonitors.h +++ b/src/ActiveMonitors.h @@ -85,6 +85,12 @@ struct ActiveMonitors : NonCopyable { conns.erase(connId); } + std::string getSubAuthedPubkey(uint64_t connId, const SubId &subId) { + auto *mon = findMonitor(connId, subId); + if (mon) return mon->sub.authedPubkey; + return ""; + } + void process(lmdb::txn &txn, defaultDb::environment::View_Event &ev, const std::function &cb) { RecipientList recipients; diff --git a/src/SessionToken.h b/src/SessionToken.h new file mode 100644 index 00000000..301d02ec --- /dev/null +++ b/src/SessionToken.h @@ -0,0 +1,160 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include + +#include +#include + +#include "golpe.h" + + +struct SessionToken { + static constexpr size_t SECRET_SIZE = 32; + static constexpr size_t HMAC_SIZE = 32; + static constexpr size_t PUBKEY_SIZE = 32; + static constexpr size_t CLIENT_ID_SIZE = 16; + + // Token binary layout v2: pubkey(32) + issuedAt(8) + expiresAt(8) + clientId(16) + hmac(32) = 96 bytes + static constexpr size_t DATA_SIZE = PUBKEY_SIZE + 8 + 8 + CLIENT_ID_SIZE; + static constexpr size_t TOKEN_SIZE = DATA_SIZE + HMAC_SIZE; + + // Legacy token layout v1: pubkey(32) + issuedAt(8) + expiresAt(8) + hmac(32) = 80 bytes + static constexpr size_t LEGACY_DATA_SIZE = PUBKEY_SIZE + 8 + 8; + static constexpr size_t LEGACY_TOKEN_SIZE = LEGACY_DATA_SIZE + HMAC_SIZE; + + static std::string generateSecret() { + uint8_t buf[SECRET_SIZE]; + if (RAND_bytes(buf, SECRET_SIZE) != 1) throw herr("failed to generate random bytes for session secret"); + return std::string(reinterpret_cast(buf), SECRET_SIZE); + } + + static std::string generateChallenge() { + uint8_t buf[16]; + if (RAND_bytes(buf, sizeof(buf)) != 1) throw herr("failed to generate random bytes for challenge"); + return to_hex(std::string_view(reinterpret_cast(buf), sizeof(buf))); + } + + static void computeHmac(const std::string &secret, const uint8_t *data, size_t dataLen, uint8_t *out) { + unsigned int hmacLen = HMAC_SIZE; + HMAC(EVP_sha256(), + reinterpret_cast(secret.data()), secret.size(), + data, dataLen, + out, &hmacLen); + } + + // Generate a session token bound to a clientId. + // If clientIdHex is empty, clientId is zeroed (backward compatible / unbound token). + static std::string generate(const std::string &secret, std::string_view pubkeyHex, uint64_t lifetimeSeconds, const std::string &clientIdHex = "") { + auto pubkeyBin = from_hex(pubkeyHex, false); + if (pubkeyBin.size() != PUBKEY_SIZE) throw herr("invalid pubkey size for session token"); + + uint8_t clientId[CLIENT_ID_SIZE] = {}; + if (clientIdHex.size()) { + auto clientIdBin = from_hex(clientIdHex, false); + if (clientIdBin.size() != CLIENT_ID_SIZE) throw herr("invalid clientId size: expected 16 bytes (32 hex chars)"); + memcpy(clientId, clientIdBin.data(), CLIENT_ID_SIZE); + } + + uint64_t issuedAt = hoytech::curr_time_s(); + uint64_t expiresAt = issuedAt + lifetimeSeconds; + + uint8_t data[DATA_SIZE]; + memcpy(data, pubkeyBin.data(), PUBKEY_SIZE); + memcpy(data + PUBKEY_SIZE, &issuedAt, 8); + memcpy(data + PUBKEY_SIZE + 8, &expiresAt, 8); + memcpy(data + PUBKEY_SIZE + 16, clientId, CLIENT_ID_SIZE); + + uint8_t token[TOKEN_SIZE]; + memcpy(token, data, DATA_SIZE); + computeHmac(secret, data, DATA_SIZE, token + DATA_SIZE); + + return to_hex(std::string_view(reinterpret_cast(token), TOKEN_SIZE)); + } + + struct ValidatedToken { + std::string pubkeyHex; + uint64_t issuedAt; + uint64_t expiresAt; + std::string clientIdHex; // empty string if unbound (zeroed) + }; + + // Validate a session token. If clientIdHex is provided, it must match the clientId baked into the token. + // Accepts both v2 (192 hex) and legacy v1 (160 hex) tokens. + static std::optional validate(const std::string &secret, const std::string &tokenHex, const std::string &clientIdHex = "") { + std::string tokenBin; + try { + tokenBin = from_hex(tokenHex, false); + } catch (...) { + return std::nullopt; + } + + // Accept v2 tokens (96 bytes) and legacy v1 tokens (80 bytes) + bool isLegacy = (tokenBin.size() == LEGACY_TOKEN_SIZE); + if (tokenBin.size() != TOKEN_SIZE && !isLegacy) return std::nullopt; + + auto *raw = reinterpret_cast(tokenBin.data()); + + // Check expiration first (cheap) + uint64_t issuedAt, expiresAt; + memcpy(&issuedAt, raw + PUBKEY_SIZE, 8); + memcpy(&expiresAt, raw + PUBKEY_SIZE + 8, 8); + + uint64_t now = hoytech::curr_time_s(); + if (now > expiresAt) return std::nullopt; + if (issuedAt > now + 60) return std::nullopt; // issued in the future (clock skew tolerance) + + if (isLegacy) { + // Legacy v1: no clientId in token, validate with legacy data size + uint8_t expectedHmac[HMAC_SIZE]; + computeHmac(secret, raw, LEGACY_DATA_SIZE, expectedHmac); + if (CRYPTO_memcmp(expectedHmac, raw + LEGACY_DATA_SIZE, HMAC_SIZE) != 0) return std::nullopt; + + // If caller provided a clientId, legacy tokens can't match (they have no binding) + if (clientIdHex.size()) return std::nullopt; + + ValidatedToken result; + result.pubkeyHex = to_hex(std::string_view(reinterpret_cast(raw), PUBKEY_SIZE)); + result.issuedAt = issuedAt; + result.expiresAt = expiresAt; + return result; + } + + // V2: verify HMAC over full data including clientId + uint8_t expectedHmac[HMAC_SIZE]; + computeHmac(secret, raw, DATA_SIZE, expectedHmac); + if (CRYPTO_memcmp(expectedHmac, raw + DATA_SIZE, HMAC_SIZE) != 0) return std::nullopt; + + // Extract clientId from token + const uint8_t *tokenClientId = raw + PUBKEY_SIZE + 16; + + // Check if the token's clientId is all zeros (unbound) + static const uint8_t zeroes[CLIENT_ID_SIZE] = {}; + bool tokenIsUnbound = (memcmp(tokenClientId, zeroes, CLIENT_ID_SIZE) == 0); + + // If caller provided a clientId, it must match the token's clientId + if (clientIdHex.size()) { + std::string callerBin; + try { callerBin = from_hex(clientIdHex, false); } catch (...) { return std::nullopt; } + if (callerBin.size() != CLIENT_ID_SIZE) return std::nullopt; + if (CRYPTO_memcmp(tokenClientId, callerBin.data(), CLIENT_ID_SIZE) != 0) return std::nullopt; + } else if (!tokenIsUnbound) { + // Token is client-bound but no clientId was provided — reject + return std::nullopt; + } + + ValidatedToken result; + result.pubkeyHex = to_hex(std::string_view(reinterpret_cast(raw), PUBKEY_SIZE)); + result.issuedAt = issuedAt; + result.expiresAt = expiresAt; + if (!tokenIsUnbound) { + result.clientIdHex = to_hex(std::string_view(reinterpret_cast(tokenClientId), CLIENT_ID_SIZE)); + } + return result; + } +}; diff --git a/src/Subscription.h b/src/Subscription.h index 4ffe786a..7cde26f1 100644 --- a/src/Subscription.h +++ b/src/Subscription.h @@ -47,13 +47,14 @@ namespace std { struct Subscription : NonCopyable { - Subscription(uint64_t connId_, std::string subId_, NostrFilterGroup filterGroup_) : connId(connId_), subId(subId_), filterGroup(filterGroup_) {} + Subscription(uint64_t connId_, std::string subId_, NostrFilterGroup filterGroup_, std::string authedPubkey_ = "") : connId(connId_), subId(subId_), filterGroup(filterGroup_), authedPubkey(authedPubkey_) {} // Params uint64_t connId; SubId subId; NostrFilterGroup filterGroup; + std::string authedPubkey; // hex pubkey of authenticated user (empty if unauthenticated) // State diff --git a/src/apps/relay/RelayIngester.cpp b/src/apps/relay/RelayIngester.cpp index 6b1febad..a91676d8 100644 --- a/src/apps/relay/RelayIngester.cpp +++ b/src/apps/relay/RelayIngester.cpp @@ -1,10 +1,154 @@ #include "RelayServer.h" +#include "PluginEventSifter.h" + + +struct ConnectionAuth { + std::string challenge; + bool authenticated = false; + std::string authedPubkey; // hex + std::string authTier; // "full", "partial", or empty (default = full) + uint32_t authFailCount = 0; +}; + +// Authorization plugin: called after successful NIP-42 or SESSION auth. +// Returns {"allowed": bool, "tier": "full"|"partial"} +// If no plugin is configured, all authenticated users get full access. +struct AuthorizationPlugin { + struct RunningPlugin { + pid_t pid; + std::string currPluginCmd; + FILE *r; + FILE *w; + + RunningPlugin(pid_t pid, int rfd, int wfd, std::string cmd) : pid(pid), currPluginCmd(cmd) { + r = fdopen(rfd, "r"); + w = fdopen(wfd, "w"); + setlinebuf(w); + } + + ~RunningPlugin() { + fclose(r); + fclose(w); + kill(pid, SIGTERM); + waitpid(pid, nullptr, 0); + } + }; + + std::unique_ptr running; + + struct AuthzResult { + bool allowed = true; + std::string tier = "full"; + }; + + AuthzResult checkAuthorization(const std::string &pluginCmd, const std::string &pubkeyHex) { + if (pluginCmd.empty()) return { true, "full" }; + + try { + if (running && pluginCmd != running->currPluginCmd) running.reset(); + if (!running) setupPlugin(pluginCmd); + + auto request = tao::json::value({ + { "type", "auth" }, + { "pubkey", pubkeyHex }, + }); + + std::string output = tao::json::to_string(request) + "\n"; + if (::fwrite(output.data(), 1, output.size(), running->w) != output.size()) throw herr("error writing to authz plugin"); + + char buf[4096]; + if (!fgets(buf, sizeof(buf), running->r)) throw herr("authz plugin pipe closed"); + + auto response = tao::json::from_string(buf); + bool allowed = response.at("allowed").get_boolean(); + std::string tier = response.optional("tier").value_or("full"); + return { allowed, tier }; + } catch (std::exception &e) { + LE << "Authorization plugin error: " << e.what(); + running.reset(); + return { false, "" }; + } + } + +private: + void setupPlugin(const std::string &pluginCmd) { + LI << "Setting up authorization plugin: " << pluginCmd; + + int outPipe[2], inPipe[2]; + if (::pipe(outPipe) || ::pipe(inPipe)) throw herr("pipe failed"); + + pid_t pid; + const char * const argv[] = { "/bin/sh", "-c", pluginCmd.c_str(), nullptr }; + + posix_spawn_file_actions_t file_actions; + posix_spawn_file_actions_init(&file_actions); + posix_spawn_file_actions_adddup2(&file_actions, outPipe[0], 0); + posix_spawn_file_actions_adddup2(&file_actions, inPipe[1], 1); + posix_spawn_file_actions_addclose(&file_actions, outPipe[0]); + posix_spawn_file_actions_addclose(&file_actions, outPipe[1]); + posix_spawn_file_actions_addclose(&file_actions, inPipe[0]); + posix_spawn_file_actions_addclose(&file_actions, inPipe[1]); + + auto ret = posix_spawnp(&pid, "sh", &file_actions, nullptr, (char* const*)(&argv[0]), environ); + if (ret) throw herr("posix_spawn failed: ", strerror(errno)); + + ::close(outPipe[0]); + ::close(inPipe[1]); + running = std::make_unique(pid, inPipe[0], outPipe[1], pluginCmd); + } +}; void RelayServer::runIngester(ThreadPool::Thread &thr) { secp256k1_context *secpCtx = secp256k1_context_create(SECP256K1_CONTEXT_VERIFY); Decompressor decomp; + flat_hash_map connAuth; + AuthorizationPlugin authzPlugin; + + auto isAuthenticated = [&](uint64_t connId) -> bool { + if (!cfg().relay__auth__enabled) return true; + if (!cfg().relay__auth__required) return true; + auto it = connAuth.find(connId); + return it != connAuth.end() && it->second.authenticated; + }; + + auto getAuthedPubkey = [&](uint64_t connId) -> std::string { + auto it = connAuth.find(connId); + if (it != connAuth.end() && it->second.authenticated) return it->second.authedPubkey; + return ""; + }; + + auto getAuthTier = [&](uint64_t connId) -> std::string { + auto it = connAuth.find(connId); + if (it != connAuth.end() && it->second.authenticated) return it->second.authTier; + return ""; + }; + + // Run authorization plugin after successful auth, returns true if authorized + auto runAuthzCheck = [&](uint64_t connId, const std::string &pubkeyHex) -> bool { + auto result = authzPlugin.checkAuthorization(cfg().relay__auth__authorizationPlugin, pubkeyHex); + if (!result.allowed) { + LI << "[" << connId << "] Authorization denied for " << pubkeyHex; + return false; + } + auto &auth = connAuth[connId]; + auth.authTier = result.tier; + if (result.tier != "full") { + LI << "[" << connId << "] Authorized with tier '" << result.tier << "' for " << pubkeyHex; + } + return true; + }; + + auto sendRestricted = [&](uint64_t connId, const std::string &reason) { + auto it = connAuth.find(connId); + if (it != connAuth.end() && it->second.challenge.size()) { + sendAuthChallenge(connId, it->second.challenge); + } + auto reply = tao::json::value::array({ "NOTICE", std::string("restricted: ") + reason }); + sendToConn(connId, tao::json::to_string(reply)); + }; + while(1) { auto newMsgs = thr.inbox.pop_all(); @@ -13,7 +157,13 @@ void RelayServer::runIngester(ThreadPool::Thread &thr) { std::vector writerMsgs; for (auto &newMsg : newMsgs) { - if (auto msg = std::get_if(&newMsg.msg)) { + if (auto msg = std::get_if(&newMsg.msg)) { + auto &auth = connAuth[msg->connId]; + auth.challenge = SessionToken::generateChallenge(); + LI << "[" << msg->connId << "] Sending AUTH challenge"; + sendAuthChallenge(msg->connId, auth.challenge); + + } else if (auto msg = std::get_if(&newMsg.msg)) { try { if (msg->payload.starts_with('[')) { auto payload = tao::json::from_string(msg->payload); @@ -28,6 +178,12 @@ void RelayServer::runIngester(ThreadPool::Thread &thr) { if (cmd == "EVENT") { if (cfg().relay__logging__dumpInEvents) LI << "[" << msg->connId << "] dumpInEvent: " << msg->payload; + if (!isAuthenticated(msg->connId)) { + auto eventId = arr[1].is_object() && arr[1].at("id").is_string() ? arr[1].at("id").get_string() : "?"; + sendOKResponse(msg->connId, eventId, false, "restricted: authentication required to publish events"); + continue; + } + try { ingesterProcessEvent(txn, msg->connId, msg->ipAddr, secpCtx, arr[1], writerMsgs); } catch (std::exception &e) { @@ -38,8 +194,22 @@ void RelayServer::runIngester(ThreadPool::Thread &thr) { } else if (cmd == "REQ") { if (cfg().relay__logging__dumpInReqs) LI << "[" << msg->connId << "] dumpInReq: " << msg->payload; + if (!isAuthenticated(msg->connId)) { + sendRestricted(msg->connId, "authentication required to request events"); + continue; + } + + // Partial-access users cannot perform REQs + if (getAuthTier(msg->connId) == "partial") { + auto subId = arr.get_array().size() >= 2 && arr[1].is_string() ? arr[1].get_string() : "?"; + sendToConn(msg->connId, tao::json::to_string( + tao::json::value::array({ "CLOSED", subId, "restricted: partial access does not allow subscriptions" }) + )); + continue; + } + try { - ingesterProcessReq(txn, msg->connId, arr); + ingesterProcessReq(txn, msg->connId, arr, getAuthedPubkey(msg->connId)); } catch (std::exception &e) { sendNoticeError(msg->connId, std::string("bad req: ") + e.what()); } @@ -51,6 +221,157 @@ void RelayServer::runIngester(ThreadPool::Thread &thr) { } catch (std::exception &e) { sendNoticeError(msg->connId, std::string("bad close: ") + e.what()); } + } else if (cmd == "AUTH") { + if (!cfg().relay__auth__enabled) throw herr("auth not enabled on this relay"); + + try { + if (arr.size() < 2) throw herr("AUTH message too short"); + + if (!arr[1].is_object()) throw herr("AUTH message second element must be a signed event object"); + + auto &authEvent = arr[1]; + + // Parse and verify the auth event signature + std::string packedStr, jsonStr; + parseAndVerifyEvent(authEvent, secpCtx, true, false, packedStr, jsonStr); + PackedEventView packed(packedStr); + + // Verify kind == 22242 + if (packed.kind() != 22242) throw herr("AUTH event must be kind 22242"); + + // Verify created_at is within ~10 minutes + auto now = hoytech::curr_time_s(); + auto ts = packed.created_at(); + if (ts > now + 600 || ts < now - 600) throw herr("AUTH event created_at is too far from current time"); + + // Extract and verify tags + std::string challengeTag, relayTag, clientTag; + auto &tags = authEvent.at("tags").get_array(); + for (auto &tag : tags) { + auto &tagArr = tag.get_array(); + if (tagArr.size() >= 2) { + auto &tagName = tagArr[0].get_string(); + auto &tagVal = tagArr[1].get_string(); + if (tagName == "challenge") challengeTag = tagVal; + else if (tagName == "relay") relayTag = tagVal; + else if (tagName == "client") clientTag = tagVal; + } + } + + if (challengeTag.empty()) throw herr("AUTH event missing challenge tag"); + if (relayTag.empty()) throw herr("AUTH event missing relay tag"); + + // Verify challenge matches + auto authIt = connAuth.find(msg->connId); + if (authIt == connAuth.end()) throw herr("no AUTH challenge pending for this connection"); + if (authIt->second.challenge != challengeTag) throw herr("AUTH challenge mismatch"); + + // Verify relay URL if configured + if (cfg().relay__auth__relayUrl.size()) { + if (relayTag != cfg().relay__auth__relayUrl) { + LI << "[" << msg->connId << "] AUTH relay tag mismatch: got '" << relayTag << "' expected '" << cfg().relay__auth__relayUrl << "'"; + throw herr("AUTH relay URL mismatch"); + } + } + + // Auth successful — run authorization plugin + auto pubkeyHex = to_hex(packed.pubkey()); + + if (!runAuthzCheck(msg->connId, pubkeyHex)) { + auto eventIdHex = to_hex(packed.id()); + sendOKResponse(msg->connId, eventIdHex, false, "restricted: pubkey not authorized on this relay"); + throw std::runtime_error("authorization denied"); + } + + authIt->second.authenticated = true; + authIt->second.authedPubkey = pubkeyHex; + + LI << "[" << msg->connId << "] Authenticated as " << pubkeyHex; + + auto eventIdHex = to_hex(packed.id()); + sendOKResponse(msg->connId, eventIdHex, true, ""); + + // Issue session token if enabled + if (cfg().relay__auth__sessionTokenEnabled && sessionSecret.size()) { + uint64_t lifetime = cfg().relay__auth__sessionTokenLifetimeSeconds; + auto token = SessionToken::generate(sessionSecret, pubkeyHex, lifetime, clientTag); + uint64_t expiresAt = hoytech::curr_time_s() + lifetime; + sendSessionToken(msg->connId, token, expiresAt); + if (clientTag.size()) { + LI << "[" << msg->connId << "] Issued client-bound session token (client=" << clientTag << "), expires in " << lifetime << "s"; + } else { + LI << "[" << msg->connId << "] Issued unbound session token, expires in " << lifetime << "s"; + } + } + + } catch (std::exception &e) { + LI << "[" << msg->connId << "] AUTH failed: " << e.what(); + sendOKResponse(msg->connId, "?", false, std::string("auth-required: ") + e.what()); + + // Tarpit: track failures and delay after threshold + auto tarpitIt = connAuth.find(msg->connId); + if (tarpitIt != connAuth.end()) { + tarpitIt->second.authFailCount++; + uint32_t threshold = cfg().relay__auth__tarpitThreshold; + if (threshold > 0 && tarpitIt->second.authFailCount >= threshold) { + uint64_t delay = cfg().relay__auth__tarpitDelaySeconds; + LI << "[" << msg->connId << "] Tarpit: " << tarpitIt->second.authFailCount << " failures, delaying " << delay << "s"; + std::this_thread::sleep_for(std::chrono::seconds(delay)); + } + } + } + } else if (cmd == "SESSION") { + if (!cfg().relay__auth__enabled) throw herr("auth not enabled on this relay"); + if (!cfg().relay__auth__sessionTokenEnabled) throw herr("session tokens not enabled on this relay"); + + try { + if (arr.size() < 2) throw herr("SESSION message too short"); + auto &tokenStr = jsonGetString(arr[1], "SESSION token must be a string"); + + // Optional clientId: ["SESSION", "", ""] + std::string clientId; + if (arr.size() >= 3 && arr[2].is_string()) { + clientId = arr[2].get_string(); + } + + auto validated = SessionToken::validate(sessionSecret, tokenStr, clientId); + if (!validated) throw herr("invalid, expired, or client-mismatched session token"); + + // Run authorization plugin for session token auth too + if (!runAuthzCheck(msg->connId, validated->pubkeyHex)) { + throw herr("pubkey not authorized on this relay"); + } + + auto &auth = connAuth[msg->connId]; + auth.authenticated = true; + auth.authedPubkey = validated->pubkeyHex; + + if (validated->clientIdHex.size()) { + LI << "[" << msg->connId << "] Client-bound session token accepted for " << validated->pubkeyHex + << " (client=" << validated->clientIdHex << ", expires at " << validated->expiresAt << ")"; + } else { + LI << "[" << msg->connId << "] Unbound session token accepted for " << validated->pubkeyHex + << " (expires at " << validated->expiresAt << ")"; + } + + sendToConn(msg->connId, tao::json::to_string( + tao::json::value::array({ "NOTICE", "session token accepted" }) + )); + + // Issue a fresh token (preserving client binding) so the client always has a valid one + uint64_t lifetime = cfg().relay__auth__sessionTokenLifetimeSeconds; + auto newToken = SessionToken::generate(sessionSecret, validated->pubkeyHex, lifetime, clientId); + uint64_t expiresAt = hoytech::curr_time_s() + lifetime; + sendSessionToken(msg->connId, newToken, expiresAt); + + } catch (std::exception &e) { + LI << "[" << msg->connId << "] SESSION failed: " << e.what(); + sendNoticeError(msg->connId, std::string("session error: ") + e.what()); + // Fall back: send an AUTH challenge so client can do NIP-42 + if (connAuth.count(msg->connId)) { + sendAuthChallenge(msg->connId, connAuth[msg->connId].challenge); + } + } } else if (cmd.starts_with("NEG-")) { if (!cfg().relay__negentropy__enabled) throw herr("negentropy disabled"); @@ -73,6 +394,7 @@ void RelayServer::runIngester(ThreadPool::Thread &thr) { } } else if (auto msg = std::get_if(&newMsg.msg)) { auto connId = msg->connId; + connAuth.erase(connId); tpWriter.dispatch(connId, MsgWriter{MsgWriter::CloseConn{connId}}); tpReqWorker.dispatch(connId, MsgReqWorker{MsgReqWorker::CloseConn{connId}}); tpNegentropy.dispatch(connId, MsgNegentropy{MsgNegentropy::CloseConn{connId}}); @@ -92,6 +414,11 @@ void RelayServer::ingesterProcessEvent(lmdb::txn &txn, uint64_t connId, std::str PackedEventView packed(packedStr); + if (packed.kind() == 22242) { + sendOKResponse(connId, to_hex(packed.id()), false, "blocked: kind 22242 events should be sent via AUTH, not EVENT"); + return; + } + { bool foundProtected = false; @@ -122,11 +449,11 @@ void RelayServer::ingesterProcessEvent(lmdb::txn &txn, uint64_t connId, std::str output.emplace_back(MsgWriter{MsgWriter::AddEvent{connId, std::move(ipAddr), std::move(packedStr), std::move(jsonStr)}}); } -void RelayServer::ingesterProcessReq(lmdb::txn &txn, uint64_t connId, const tao::json::value &arr) { +void RelayServer::ingesterProcessReq(lmdb::txn &txn, uint64_t connId, const tao::json::value &arr, const std::string &authedPubkey) { if (arr.get_array().size() < 2 + 1) throw herr("arr too small"); if (arr.get_array().size() > 2 + cfg().relay__maxReqFilterSize) throw herr("arr too big"); - Subscription sub(connId, jsonGetString(arr[1], "REQ subscription id was not a string"), NostrFilterGroup(arr)); + Subscription sub(connId, jsonGetString(arr[1], "REQ subscription id was not a string"), NostrFilterGroup(arr), authedPubkey); tpReqWorker.dispatch(connId, MsgReqWorker{MsgReqWorker::NewSub{std::move(sub)}}); } diff --git a/src/apps/relay/RelayReqMonitor.cpp b/src/apps/relay/RelayReqMonitor.cpp index e83cbebf..174ae8bf 100644 --- a/src/apps/relay/RelayReqMonitor.cpp +++ b/src/apps/relay/RelayReqMonitor.cpp @@ -17,6 +17,7 @@ void RelayServer::runReqMonitor(ThreadPool::Thread &thr) { Decompressor decomp; ActiveMonitors monitors; uint64_t currEventId = MAX_U64; + auto sensitiveKinds = parseSensitiveKinds(cfg().relay__auth__sensitiveKinds); while (1) { auto newMsgs = thr.inbox.pop_all(); @@ -29,10 +30,14 @@ void RelayServer::runReqMonitor(ThreadPool::Thread &thr) { for (auto &newMsg : newMsgs) { if (auto msg = std::get_if(&newMsg.msg)) { auto connId = msg->sub.connId; + auto &authedPubkey = msg->sub.authedPubkey; env.foreach_Event(txn, [&](auto &ev){ if (msg->sub.filterGroup.doesMatch(PackedEventView(ev.buf))) { - sendEvent(connId, msg->sub.subId, getEventJson(txn, decomp, ev.primaryKeyId)); + auto evJson = getEventJson(txn, decomp, ev.primaryKeyId); + if (isSensitiveEventAllowed(sensitiveKinds, evJson, authedPubkey)) { + sendEvent(connId, msg->sub.subId, evJson); + } } return true; @@ -50,7 +55,22 @@ void RelayServer::runReqMonitor(ThreadPool::Thread &thr) { } else if (std::get_if(&newMsg.msg)) { env.foreach_Event(txn, [&](auto &ev){ monitors.process(txn, ev, [&](RecipientList &&recipients, uint64_t levId){ - sendEventToBatch(std::move(recipients), std::string(getEventJson(txn, decomp, levId))); + if (sensitiveKinds.empty()) { + sendEventToBatch(std::move(recipients), std::string(getEventJson(txn, decomp, levId))); + } else { + auto evJson = std::string(getEventJson(txn, decomp, levId)); + // For sensitive events, filter per-recipient using their subscription's authedPubkey + RecipientList filtered; + for (auto &r : recipients) { + auto subPubkey = monitors.getSubAuthedPubkey(r.connId, r.subId); + if (isSensitiveEventAllowed(sensitiveKinds, evJson, subPubkey)) { + filtered.push_back(r); + } + } + if (filtered.size()) { + sendEventToBatch(std::move(filtered), std::move(evJson)); + } + } }); return true; }, false, currEventId + 1); diff --git a/src/apps/relay/RelayReqWorker.cpp b/src/apps/relay/RelayReqWorker.cpp index 92ec2f31..0d0c5c49 100644 --- a/src/apps/relay/RelayReqWorker.cpp +++ b/src/apps/relay/RelayReqWorker.cpp @@ -5,9 +5,12 @@ void RelayServer::runReqWorker(ThreadPool::Thread &thr) { Decompressor decomp; QueryScheduler queries; + auto sensitiveKinds = parseSensitiveKinds(cfg().relay__auth__sensitiveKinds); queries.onEvent = [&](lmdb::txn &txn, const auto &sub, uint64_t levId, std::string_view eventPayload){ - sendEvent(sub.connId, sub.subId, decodeEventPayload(txn, decomp, eventPayload, nullptr, nullptr)); + auto evJson = decodeEventPayload(txn, decomp, eventPayload, nullptr, nullptr); + if (!isSensitiveEventAllowed(sensitiveKinds, evJson, sub.authedPubkey)) return; + sendEvent(sub.connId, sub.subId, evJson); }; queries.onComplete = [&](lmdb::txn &, Subscription &sub){ diff --git a/src/apps/relay/RelayServer.h b/src/apps/relay/RelayServer.h index 083a2e6c..37146b07 100644 --- a/src/apps/relay/RelayServer.h +++ b/src/apps/relay/RelayServer.h @@ -18,6 +18,7 @@ #include "filters.h" #include "jsonParseUtils.h" #include "Decompressor.h" +#include "SessionToken.h" @@ -53,11 +54,16 @@ struct MsgIngester : NonCopyable { std::string payload; }; + struct OpenConn { + uint64_t connId; + std::string ipAddr; + }; + struct CloseConn { uint64_t connId; }; - using Var = std::variant; + using Var = std::variant; Var msg; MsgIngester(Var &&msg_) : msg(std::move(msg_)) {} }; @@ -150,6 +156,7 @@ struct MsgNegentropy : NonCopyable { struct RelayServer { uS::Async *hubTrigger = nullptr; + std::string sessionSecret; // Thread Pools @@ -168,7 +175,7 @@ struct RelayServer { void runIngester(ThreadPool::Thread &thr); void ingesterProcessEvent(lmdb::txn &txn, uint64_t connId, std::string ipAddr, secp256k1_context *secpCtx, const tao::json::value &origJson, std::vector &output); - void ingesterProcessReq(lmdb::txn &txn, uint64_t connId, const tao::json::value &origJson); + void ingesterProcessReq(lmdb::txn &txn, uint64_t connId, const tao::json::value &origJson, const std::string &authedPubkey = ""); void ingesterProcessClose(lmdb::txn &txn, uint64_t connId, const tao::json::value &origJson); void ingesterProcessNegentropy(lmdb::txn &txn, Decompressor &decomp, uint64_t connId, const tao::json::value &origJson); @@ -228,4 +235,71 @@ struct RelayServer { tpWebsocket.dispatch(0, MsgWebsocket{MsgWebsocket::Send{connId, std::move(tao::json::to_string(reply))}}); hubTrigger->send(); } + + void sendAuthChallenge(uint64_t connId, const std::string &challenge) { + auto reply = tao::json::value::array({ "AUTH", challenge }); + sendToConn(connId, tao::json::to_string(reply)); + } + + void sendSessionToken(uint64_t connId, const std::string &token, uint64_t expiresAt) { + auto reply = tao::json::value::array({ "SESSION", token, expiresAt }); + sendToConn(connId, tao::json::to_string(reply)); + } + + // Parse the sensitiveKinds config string (JSON array) into a set. + // Called once at startup or when config changes. + static flat_hash_set parseSensitiveKinds(const std::string &configStr) { + flat_hash_set kinds; + if (configStr.empty()) return kinds; + try { + auto arr = tao::json::from_string(configStr); + for (auto &v : arr.get_array()) { + kinds.insert(v.get_unsigned()); + } + } catch (std::exception &e) { + LE << "Failed to parse sensitiveKinds config: " << e.what(); + } + return kinds; + } + + // Check if a sensitive event should be sent to a subscriber. + // Returns true if the event is allowed (not sensitive, or subscriber is sender/recipient). + // evJson is the raw JSON string of the event. + static bool isSensitiveEventAllowed(const flat_hash_set &sensitiveKinds, std::string_view evJson, const std::string &subscriberPubkey) { + if (sensitiveKinds.empty()) return true; + if (subscriberPubkey.empty()) return true; // no auth = no filtering (unauthenticated mode) + + try { + auto ev = tao::json::from_string(std::string(evJson)); + + uint64_t kind = 0; + if (ev.at("kind").is_unsigned()) kind = ev.at("kind").get_unsigned(); + else if (ev.at("kind").is_signed()) kind = (uint64_t)ev.at("kind").get_signed(); + + if (!sensitiveKinds.contains(kind)) return true; + + // Sensitive kind — check if subscriber is the author + auto &pubkey = ev.at("pubkey").get_string(); + if (pubkey == subscriberPubkey) return true; + + // Check p-tags for the subscriber + if (ev.find("tags")) { + auto &tags = ev.at("tags").get_array(); + for (auto &tag : tags) { + auto &tagArr = tag.get_array(); + if (tagArr.size() >= 2) { + if (tagArr[0].get_string() == "p" && tagArr[1].get_string() == subscriberPubkey) { + return true; + } + } + } + } + + // Subscriber is neither author nor recipient — filter it out + return false; + } catch (...) { + // If we can't parse, allow it through (fail-open for non-sensitive) + return true; + } + } }; diff --git a/src/apps/relay/RelayWebsocket.cpp b/src/apps/relay/RelayWebsocket.cpp index b87de564..17103423 100644 --- a/src/apps/relay/RelayWebsocket.cpp +++ b/src/apps/relay/RelayWebsocket.cpp @@ -50,6 +50,11 @@ void RelayServer::runWebsocket(ThreadPool::Thread &thr) { auto supportedNips = []{ tao::json::value output = tao::json::value::array({ 1, 2, 4, 9, 11, 22, 28, 40, 70, 77 }); + + if (cfg().relay__auth__enabled) { + output.get_array().emplace_back(42); + } + if (cfg().relay__info__nips.size() == 0) return output; try { @@ -72,6 +77,7 @@ void RelayServer::runWebsocket(ThreadPool::Thread &thr) { { "max_message_length", cfg().relay__maxWebsocketPayloadSize }, { "max_subscriptions", cfg().relay__maxSubsPerConnection }, { "max_limit", cfg().relay__maxFilterLimit }, + { "auth_required", cfg().relay__auth__enabled && cfg().relay__auth__required }, }) }, }); @@ -169,13 +175,51 @@ void RelayServer::runWebsocket(ThreadPool::Thread &thr) { if (cfg().relay__autoPingSeconds) hubGroup->startAutoPing(cfg().relay__autoPingSeconds * 1'000); + auto makeHttpResponse = [](int statusCode, const std::string &statusText, const std::string &contentType, const std::string &body) -> std::string { + std::string output = "HTTP/1.1 " + std::to_string(statusCode) + " " + statusText + "\r\n"; + output += "Content-Type: " + contentType + "\r\n"; + output += "Access-Control-Allow-Origin: *\r\n"; + output += "Connection: keep-alive\r\n"; + output += "Content-Length: " + std::to_string(body.size()) + "\r\n"; + output += "\r\n"; + output += body; + return output; + }; + hubGroup->onHttpRequest([&](uWS::HttpResponse *res, uWS::HttpRequest req, char *data, size_t length, size_t remainingBytes){ LI << "HTTP request for [" << req.getUrl().toString() << "]"; std::string host = req.getHeader("host").toString(); std::string url = req.getUrl().toString(); - if (url == "/.well-known/nodeinfo") { + if (url == "/auth/verify" && cfg().relay__auth__enabled && cfg().relay__auth__sessionTokenEnabled) { + std::string authHeader = req.getHeader("authorization").toString(); + std::string clientIdHeader = req.getHeader("nostr-client").toString(); + + if (authHeader.starts_with("Nostr-Session ")) { + auto tokenHex = authHeader.substr(14); + auto validated = SessionToken::validate(sessionSecret, tokenHex, clientIdHeader); + + if (validated) { + auto bodyObj = tao::json::value({ + { "pubkey", validated->pubkeyHex }, + { "expires_at", validated->expiresAt }, + }); + if (validated->clientIdHex.size()) { + bodyObj["client_id"] = validated->clientIdHex; + } + auto body = tao::json::to_string(bodyObj); + auto resp = makeHttpResponse(200, "OK", "application/json", body); + res->write(resp.data(), resp.size()); + } else { + auto resp = makeHttpResponse(401, "Unauthorized", "application/json", "{\"error\":\"invalid, expired, or client-mismatched session token\"}"); + res->write(resp.data(), resp.size()); + } + } else { + auto resp = makeHttpResponse(401, "Unauthorized", "application/json", "{\"error\":\"missing or invalid Authorization header, expected: Nostr-Session \"}"); + res->write(resp.data(), resp.size()); + } + } else if (url == "/.well-known/nodeinfo") { auto nodeInfo = getNodeInfoHttpResponse(host); res->write(nodeInfo.data(), nodeInfo.size()); } else if (url == "/nodeinfo/2.1") { @@ -224,6 +268,10 @@ void RelayServer::runWebsocket(ThreadPool::Thread &thr) { LW << "Failed to enable TCP keepalive: " << strerror(errno); } } + + if (cfg().relay__auth__enabled) { + tpIngester.dispatch(connId, MsgIngester{MsgIngester::OpenConn{connId, c->ipAddr}}); + } }); hubGroup->onDisconnection([&](uWS::WebSocket *ws, int code, char *message, size_t length) { diff --git a/src/apps/relay/cmd_relay.cpp b/src/apps/relay/cmd_relay.cpp index 03da21fa..cfd9a054 100644 --- a/src/apps/relay/cmd_relay.cpp +++ b/src/apps/relay/cmd_relay.cpp @@ -18,6 +18,20 @@ static void checkConfig() { if (cfg().events__rejectEphemeralEventsOlderThanSeconds >= cfg().events__ephemeralEventsLifetimeSeconds) { LW << "rejectEphemeralEventsOlderThanSeconds is >= ephemeralEventsLifetimeSeconds, which could result in unnecessary disk activity"; } + + if (cfg().relay__auth__enabled) { + if (cfg().relay__auth__relayUrl.empty()) { + LW << "relay.auth.enabled is true but relay.auth.relayUrl is empty. AUTH relay tag will not be verified."; + } + if (cfg().relay__auth__required) { + LI << "NIP-42 authentication is REQUIRED for all operations"; + } else { + LI << "NIP-42 authentication is enabled but optional"; + } + if (cfg().relay__auth__sessionTokenEnabled) { + LI << "Session tokens enabled (lifetime: " << cfg().relay__auth__sessionTokenLifetimeSeconds << "s)"; + } + } } @@ -35,6 +49,11 @@ void RelayServer::run() { if (s != 0) throw herr("Unable to set sigmask: ", strerror(errno)); } + if (cfg().relay__auth__enabled && cfg().relay__auth__sessionTokenEnabled) { + sessionSecret = SessionToken::generateSecret(); + LI << "Generated session token secret (valid for this process lifetime)"; + } + tpWebsocket.init("Websocket", 1, [this](auto &thr){ runWebsocket(thr); }); diff --git a/src/apps/relay/golpe.yaml b/src/apps/relay/golpe.yaml index a845a1e6..579e63ed 100644 --- a/src/apps/relay/golpe.yaml +++ b/src/apps/relay/golpe.yaml @@ -110,3 +110,31 @@ config: - name: relay__negentropy__maxSyncEvents desc: "Maximum records that sync will process before returning an error" default: 1000000 + + - name: relay__auth__enabled + desc: "Enable NIP-42 authentication support" + default: false + - name: relay__auth__required + desc: "Require authentication for all operations (EVENT, REQ). If false, auth is available but optional." + default: false + - name: relay__auth__relayUrl + desc: "The relay URL clients use in AUTH events (e.g. wss://relay.example.com). Required if auth is enabled." + default: "" + - name: relay__auth__sessionTokenEnabled + desc: "Issue session tokens after successful NIP-42 auth, allowing reconnection without re-signing" + default: true + - name: relay__auth__sessionTokenLifetimeSeconds + desc: "How long session tokens remain valid, in seconds" + default: 3600 + - name: relay__auth__tarpitThreshold + desc: "Number of failed AUTH attempts before introducing a delay (0 = disabled)" + default: 10 + - name: relay__auth__tarpitDelaySeconds + desc: "Seconds to delay responses after tarpit threshold is reached" + default: 30 + - name: relay__auth__sensitiveKinds + desc: "JSON array of event kinds that should only be returned to sender/recipient when auth is enabled. Example: \"[4,1059,1060,24,25,26,27,35834]\"" + default: "" + - name: relay__auth__authorizationPlugin + desc: "Path to an executable that decides whether an authenticated pubkey is authorized. Receives JSON on stdin, returns JSON on stdout. Empty = allow all authenticated users." + default: "" diff --git a/strfry.conf b/strfry.conf index 7c07c67c..ad360b2f 100644 --- a/strfry.conf +++ b/strfry.conf @@ -144,4 +144,39 @@ relay { # Maximum records that sync will process before returning an error maxSyncEvents = 1000000 } + + auth { + # Enable NIP-42 authentication support + enabled = false + + # Require authentication for all operations (EVENT, REQ). If false, auth is available but optional. + required = false + + # The relay URL clients use in AUTH events (e.g. wss://relay.example.com). Required if auth is enabled. + relayUrl = "" + + # Issue session tokens after successful NIP-42 auth, allowing reconnection without re-signing + sessionTokenEnabled = true + + # How long session tokens remain valid, in seconds (default 1 hour) + sessionTokenLifetimeSeconds = 3600 + + # Anti-abuse: delay responses after this many failed AUTH attempts per connection (0 = disabled) + tarpitThreshold = 10 + + # Seconds to delay after tarpit threshold is reached + tarpitDelaySeconds = 30 + + # JSON array of event kinds that should only be returned to sender/recipient. + # DMs, gift wraps, sealed messages, etc. Empty string = no filtering. + # Example: "[4,1059,1060,24,25,26,27,35834]" + sensitiveKinds = "" + + # Path to an authorization plugin script. Called after successful NIP-42/SESSION auth. + # Receives JSON: {"type":"auth","pubkey":""} + # Must return JSON: {"allowed":true,"tier":"full"} or {"allowed":false} + # Tier can be "full" (default) or "partial" (write-only, no REQ). + # Empty string = allow all authenticated users. + authorizationPlugin = "" + } }