From 068cf26478f602be72d5396bc7f2f99e596ad5fc Mon Sep 17 00:00:00 2001 From: Tekkadan <6tekkadan9@gmail.com> Date: Fri, 20 Feb 2026 11:11:19 -0600 Subject: [PATCH 1/3] feat: NIP-42 authentication with session token extension Implement NIP-42 (AUTH) challenge-response authentication for strfry, plus a session token mechanism that eliminates redundant signing on reconnection and unifies WebSocket/HTTP auth. Changes: - New src/SessionToken.h: HMAC-SHA256 session token generation/validation - RelayIngester: AUTH and SESSION command handling, per-connection auth state tracking, operation gating when auth is required - RelayServer.h: OpenConn message variant, session secret, auth helpers - RelayWebsocket: AUTH challenge on connect, NIP-11 auth advertising, GET /auth/verify HTTP endpoint for session token validation - cmd_relay: session secret init, auth config validation on startup - golpe.yaml + strfry.conf: auth config (enabled, required, relayUrl, sessionTokenEnabled, sessionTokenLifetimeSeconds) - README: full documentation including extension authentication guide - Reject kind:22242 events via EVENT (must use AUTH per NIP-42 spec) --- README.md | 188 +++++++++++++++++++++++++++++- src/SessionToken.h | 106 +++++++++++++++++ src/apps/relay/RelayIngester.cpp | 161 ++++++++++++++++++++++++- src/apps/relay/RelayServer.h | 19 ++- src/apps/relay/RelayWebsocket.cpp | 45 ++++++- src/apps/relay/cmd_relay.cpp | 19 +++ src/apps/relay/golpe.yaml | 16 +++ strfry.conf | 17 +++ 8 files changed, 567 insertions(+), 4 deletions(-) create mode 100644 src/SessionToken.h diff --git a/README.md b/README.md index e14f0edd..ad2695ad 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,187 @@ 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. + +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 +} +``` + +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, and expiry time. The HMAC secret is generated randomly on each relay startup, so tokens do not survive restarts (clients simply fall back to NIP-42). + +**On reconnection**, clients can skip the full NIP-42 challenge-response by sending: + +```json +["SESSION", ""] +``` + +If valid and not expired, the relay authenticates the connection immediately and issues a fresh token (rolling refresh). If the token is invalid or expired, 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 +``` + +**Success response (200):** + +```json +{"pubkey": "<64-char-hex-pubkey>", "expires_at": 1700000000} +``` + +**Failure response (401):** + +```json +{"error": "invalid or expired 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", ""] + ], + "content": "" +} +``` + +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. On subsequent reconnections, send it immediately after connecting: + +```json +["SESSION", ""] +``` + +If accepted, the relay responds with a `NOTICE` and a fresh session token. If rejected (expired, relay restarted), 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", ""] + +if has_saved_token(): + ws.send(["SESSION", saved_token]) + 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]] + ]) + ws.send(["AUTH", auth_event]) +else: + auth_event = sign_event(kind=22242, tags=[ + ["relay", "wss://relay.example.com"], + ["challenge", msg[1]] + ]) + ws.send(["AUTH", auth_event]) + +// After auth, listen for SESSION token +msg = ws.recv() // ["SESSION", "", ] +save_token(msg[1], msg[2]) + +// 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. +- 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. Anyone possessing a valid token can authenticate as the associated pubkey for the token's remaining lifetime. +- 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. + + ### 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/SessionToken.h b/src/SessionToken.h new file mode 100644 index 00000000..74c4911e --- /dev/null +++ b/src/SessionToken.h @@ -0,0 +1,106 @@ +#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; + + // Token binary layout: pubkey(32) + issuedAt(8) + expiresAt(8) + hmac(32) = 80 bytes + static constexpr size_t DATA_SIZE = PUBKEY_SIZE + 8 + 8; + static constexpr size_t TOKEN_SIZE = 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); + } + + static std::string generate(const std::string &secret, std::string_view pubkeyHex, uint64_t lifetimeSeconds) { + auto pubkeyBin = from_hex(pubkeyHex, false); + if (pubkeyBin.size() != PUBKEY_SIZE) throw herr("invalid pubkey size for session token"); + + 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); + + 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; + }; + + static std::optional validate(const std::string &secret, const std::string &tokenHex) { + std::string tokenBin; + try { + tokenBin = from_hex(tokenHex, false); + } catch (...) { + return std::nullopt; + } + + if (tokenBin.size() != TOKEN_SIZE) 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) + + // Verify HMAC (constant-time comparison) + uint8_t expectedHmac[HMAC_SIZE]; + computeHmac(secret, raw, DATA_SIZE, expectedHmac); + + if (CRYPTO_memcmp(expectedHmac, raw + DATA_SIZE, HMAC_SIZE) != 0) { + 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; + } +}; diff --git a/src/apps/relay/RelayIngester.cpp b/src/apps/relay/RelayIngester.cpp index 6b1febad..906d217e 100644 --- a/src/apps/relay/RelayIngester.cpp +++ b/src/apps/relay/RelayIngester.cpp @@ -1,10 +1,35 @@ #include "RelayServer.h" +struct ConnectionAuth { + std::string challenge; + bool authenticated = false; + std::string authedPubkey; // hex +}; + + void RelayServer::runIngester(ThreadPool::Thread &thr) { secp256k1_context *secpCtx = secp256k1_context_create(SECP256K1_CONTEXT_VERIFY); Decompressor decomp; + flat_hash_map connAuth; + + 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 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 +38,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 +59,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,6 +75,11 @@ 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; + } + try { ingesterProcessReq(txn, msg->connId, arr); } catch (std::exception &e) { @@ -51,6 +93,117 @@ 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; + 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; + } + } + + 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 + auto pubkeyHex = to_hex(packed.pubkey()); + 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); + uint64_t expiresAt = hoytech::curr_time_s() + lifetime; + sendSessionToken(msg->connId, token, expiresAt); + LI << "[" << msg->connId << "] Issued 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()); + } + } 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"); + + auto validated = SessionToken::validate(sessionSecret, tokenStr); + if (!validated) throw herr("invalid or expired session token"); + + auto &auth = connAuth[msg->connId]; + auth.authenticated = true; + auth.authedPubkey = validated->pubkeyHex; + + LI << "[" << msg->connId << "] 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 so the client always has a valid one + uint64_t lifetime = cfg().relay__auth__sessionTokenLifetimeSeconds; + auto newToken = SessionToken::generate(sessionSecret, validated->pubkeyHex, lifetime); + 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 +226,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 +246,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; diff --git a/src/apps/relay/RelayServer.h b/src/apps/relay/RelayServer.h index 083a2e6c..25a70aa3 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 @@ -228,4 +235,14 @@ 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)); + } }; diff --git a/src/apps/relay/RelayWebsocket.cpp b/src/apps/relay/RelayWebsocket.cpp index b87de564..77bc7658 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,46 @@ 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(); + + if (authHeader.starts_with("Nostr-Session ")) { + auto tokenHex = authHeader.substr(14); + auto validated = SessionToken::validate(sessionSecret, tokenHex); + + if (validated) { + auto body = tao::json::to_string(tao::json::value({ + { "pubkey", validated->pubkeyHex }, + { "expires_at", validated->expiresAt }, + })); + auto resp = makeHttpResponse(200, "OK", "application/json", body); + res->write(resp.data(), resp.size()); + } else { + auto resp = makeHttpResponse(401, "Unauthorized", "application/json", "{\"error\":\"invalid or expired 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 +263,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..0580c041 100644 --- a/src/apps/relay/golpe.yaml +++ b/src/apps/relay/golpe.yaml @@ -110,3 +110,19 @@ 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 diff --git a/strfry.conf b/strfry.conf index 7c07c67c..74f0462c 100644 --- a/strfry.conf +++ b/strfry.conf @@ -144,4 +144,21 @@ 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 + } } From 4be0b9a1b429b214a772fc4a3333e34f3892c34b Mon Sep 17 00:00:00 2001 From: Tekkadan <6tekkadan9@gmail.com> Date: Fri, 20 Feb 2026 22:20:01 -0600 Subject: [PATCH 2/3] feat: client-bound session tokens for per-client isolation --- README.md | 42 +++++++++++++------ src/SessionToken.h | 70 +++++++++++++++++++++++++++---- src/apps/relay/RelayIngester.cpp | 34 +++++++++++---- src/apps/relay/RelayWebsocket.cpp | 13 ++++-- 4 files changed, 126 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index ad2695ad..2f4e4552 100644 --- a/README.md +++ b/README.md @@ -265,6 +265,8 @@ If you are building a "mesh" topology of routers, or mirroring events to neighbo 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 @@ -305,15 +307,18 @@ After a successful NIP-42 authentication, the relay issues a session token via a ["SESSION", "", ] ``` -The token is an HMAC-SHA256 authenticated blob containing the client's pubkey, issue time, and expiry time. The HMAC secret is generated randomly on each relay startup, so tokens do not survive restarts (clients simply fall back to NIP-42). +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, the relay authenticates the connection immediately and issues a fresh token (rolling refresh). If the token is invalid or expired, the relay responds with an error and re-sends an `AUTH` challenge so the client can fall back to standard NIP-42. +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 @@ -324,18 +329,21 @@ When auth and session tokens are enabled, strfry exposes a `GET /auth/verify` HT ``` 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} +{"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 or expired session token"} +{"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. @@ -363,12 +371,15 @@ Construct a `kind:22242` event with the following structure: "created_at": , "tags": [ ["relay", "wss://relay.example.com"], - ["challenge", ""] + ["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 @@ -386,13 +397,14 @@ On success, the relay responds with: **Step 4: Store and reuse the session token** -Save the session token. On subsequent reconnections, send it immediately after connecting: +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 session token. If rejected (expired, relay restarted), fall back to Step 2 using the `AUTH` challenge that was sent on connection. +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** @@ -409,26 +421,30 @@ 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]) + 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]] + ["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]] + ["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]) +save_token(msg[1], msg[2], client_id) // Now authenticated — proceed with EVENT, REQ, etc. ``` @@ -436,9 +452,11 @@ save_token(msg[1], msg[2]) **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. Anyone possessing a valid token can authenticate as the associated pubkey for the token's remaining lifetime. +- 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. diff --git a/src/SessionToken.h b/src/SessionToken.h index 74c4911e..301d02ec 100644 --- a/src/SessionToken.h +++ b/src/SessionToken.h @@ -18,11 +18,16 @@ 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: pubkey(32) + issuedAt(8) + expiresAt(8) + hmac(32) = 80 bytes - static constexpr size_t DATA_SIZE = PUBKEY_SIZE + 8 + 8; + // 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"); @@ -43,10 +48,19 @@ struct SessionToken { out, &hmacLen); } - static std::string generate(const std::string &secret, std::string_view pubkeyHex, uint64_t lifetimeSeconds) { + // 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; @@ -54,6 +68,7 @@ struct SessionToken { 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); @@ -66,9 +81,12 @@ struct SessionToken { std::string pubkeyHex; uint64_t issuedAt; uint64_t expiresAt; + std::string clientIdHex; // empty string if unbound (zeroed) }; - static std::optional validate(const std::string &secret, const std::string &tokenHex) { + // 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); @@ -76,7 +94,9 @@ struct SessionToken { return std::nullopt; } - if (tokenBin.size() != TOKEN_SIZE) 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()); @@ -89,11 +109,42 @@ struct SessionToken { if (now > expiresAt) return std::nullopt; if (issuedAt > now + 60) return std::nullopt; // issued in the future (clock skew tolerance) - // Verify HMAC (constant-time comparison) + 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) { + 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; } @@ -101,6 +152,9 @@ struct SessionToken { 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/apps/relay/RelayIngester.cpp b/src/apps/relay/RelayIngester.cpp index 906d217e..5d415581 100644 --- a/src/apps/relay/RelayIngester.cpp +++ b/src/apps/relay/RelayIngester.cpp @@ -117,7 +117,7 @@ void RelayServer::runIngester(ThreadPool::Thread &thr) { 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; + std::string challengeTag, relayTag, clientTag; auto &tags = authEvent.at("tags").get_array(); for (auto &tag : tags) { auto &tagArr = tag.get_array(); @@ -126,6 +126,7 @@ void RelayServer::runIngester(ThreadPool::Thread &thr) { auto &tagVal = tagArr[1].get_string(); if (tagName == "challenge") challengeTag = tagVal; else if (tagName == "relay") relayTag = tagVal; + else if (tagName == "client") clientTag = tagVal; } } @@ -158,10 +159,14 @@ void RelayServer::runIngester(ThreadPool::Thread &thr) { // 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); + auto token = SessionToken::generate(sessionSecret, pubkeyHex, lifetime, clientTag); uint64_t expiresAt = hoytech::curr_time_s() + lifetime; sendSessionToken(msg->connId, token, expiresAt); - LI << "[" << msg->connId << "] Issued session token, expires in " << lifetime << "s"; + 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) { @@ -176,23 +181,34 @@ void RelayServer::runIngester(ThreadPool::Thread &thr) { if (arr.size() < 2) throw herr("SESSION message too short"); auto &tokenStr = jsonGetString(arr[1], "SESSION token must be a string"); - auto validated = SessionToken::validate(sessionSecret, tokenStr); - if (!validated) throw herr("invalid or expired session token"); + // 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"); auto &auth = connAuth[msg->connId]; auth.authenticated = true; auth.authedPubkey = validated->pubkeyHex; - LI << "[" << msg->connId << "] Session token accepted for " << validated->pubkeyHex - << " (expires at " << validated->expiresAt << ")"; + 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 so the client always has a valid one + // 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); + auto newToken = SessionToken::generate(sessionSecret, validated->pubkeyHex, lifetime, clientId); uint64_t expiresAt = hoytech::curr_time_s() + lifetime; sendSessionToken(msg->connId, newToken, expiresAt); diff --git a/src/apps/relay/RelayWebsocket.cpp b/src/apps/relay/RelayWebsocket.cpp index 77bc7658..17103423 100644 --- a/src/apps/relay/RelayWebsocket.cpp +++ b/src/apps/relay/RelayWebsocket.cpp @@ -194,20 +194,25 @@ void RelayServer::runWebsocket(ThreadPool::Thread &thr) { 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); + auto validated = SessionToken::validate(sessionSecret, tokenHex, clientIdHeader); if (validated) { - auto body = tao::json::to_string(tao::json::value({ + 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 or expired session token\"}"); + auto resp = makeHttpResponse(401, "Unauthorized", "application/json", "{\"error\":\"invalid, expired, or client-mismatched session token\"}"); res->write(resp.data(), resp.size()); } } else { From 3a91f482001223319d06d9dc2ad1f53183a46882 Mon Sep 17 00:00:00 2001 From: Tekkadan <6tekkadan9@gmail.com> Date: Fri, 20 Feb 2026 23:25:04 -0600 Subject: [PATCH 3/3] feat: tarpit, sensitive event filtering, authorization plugin Three new auth features absorbing the best ideas from interceptor-proxy into strfry natively, eliminating the need for a separate proxy process. 1. Anti-abuse tarpit (RelayIngester.cpp): - Track failed AUTH attempts per connection (authFailCount) - After tarpitThreshold (default 10) failures, delay responses by tarpitDelaySeconds (default 30s) on that connection - Makes brute-force/replay attacks impractical 2. Sensitive event filtering (RelayReqWorker.cpp, RelayReqMonitor.cpp): - Configurable sensitiveKinds JSON array (e.g. [4,1059,1060,...]) - Events of sensitive kinds only returned to author or p-tag recipients - Subscription carries authedPubkey for per-subscriber filtering - Batch sends in monitor filtered per-recipient via getSubAuthedPubkey() - Protects DMs, gift wraps, sealed messages from unauthorized readers 3. Authorization plugin (RelayIngester.cpp): - Long-running stdin/stdout plugin (same pattern as writePolicy) - Called after successful NIP-42 or SESSION auth - Returns {allowed, tier} where tier is 'full' or 'partial' - Partial tier: write-only (EVENT allowed, REQ blocked) - Fail-closed: plugin errors deny access - Replaces interceptor-proxy's external config API Supporting changes: - golpe.yaml: 4 new config entries - strfry.conf: documented all new options - Subscription.h: added authedPubkey field - ActiveMonitors.h: added getSubAuthedPubkey() lookup - RelayServer.h: added parseSensitiveKinds(), isSensitiveEventAllowed() static helpers, updated ingesterProcessReq signature - ConnectionAuth: added authTier, authFailCount fields - README.md: full documentation for all three features with examples --- README.md | 121 ++++++++++++++++++++++ src/ActiveMonitors.h | 6 ++ src/Subscription.h | 3 +- src/apps/relay/RelayIngester.cpp | 160 ++++++++++++++++++++++++++++- src/apps/relay/RelayReqMonitor.cpp | 24 ++++- src/apps/relay/RelayReqWorker.cpp | 5 +- src/apps/relay/RelayServer.h | 59 ++++++++++- src/apps/relay/golpe.yaml | 12 +++ strfry.conf | 18 ++++ 9 files changed, 399 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 2f4e4552..bf76e95b 100644 --- a/README.md +++ b/README.md @@ -292,6 +292,19 @@ auth { # 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 = "" } ``` @@ -459,6 +472,114 @@ save_token(msg[1], msg[2], client_id) - 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 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/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 5d415581..a91676d8 100644 --- a/src/apps/relay/RelayIngester.cpp +++ b/src/apps/relay/RelayIngester.cpp @@ -1,10 +1,101 @@ #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); + } }; @@ -13,6 +104,7 @@ void RelayServer::runIngester(ThreadPool::Thread &thr) { Decompressor decomp; flat_hash_map connAuth; + AuthorizationPlugin authzPlugin; auto isAuthenticated = [&](uint64_t connId) -> bool { if (!cfg().relay__auth__enabled) return true; @@ -21,6 +113,33 @@ void RelayServer::runIngester(ThreadPool::Thread &thr) { 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()) { @@ -80,8 +199,17 @@ void RelayServer::runIngester(ThreadPool::Thread &thr) { 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()); } @@ -146,8 +274,15 @@ void RelayServer::runIngester(ThreadPool::Thread &thr) { } } - // Auth successful + // 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; @@ -172,6 +307,18 @@ void RelayServer::runIngester(ThreadPool::Thread &thr) { } 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"); @@ -190,6 +337,11 @@ void RelayServer::runIngester(ThreadPool::Thread &thr) { 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; @@ -297,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 25a70aa3..37146b07 100644 --- a/src/apps/relay/RelayServer.h +++ b/src/apps/relay/RelayServer.h @@ -175,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); @@ -245,4 +245,61 @@ struct RelayServer { 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/golpe.yaml b/src/apps/relay/golpe.yaml index 0580c041..579e63ed 100644 --- a/src/apps/relay/golpe.yaml +++ b/src/apps/relay/golpe.yaml @@ -126,3 +126,15 @@ config: - 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 74f0462c..ad360b2f 100644 --- a/strfry.conf +++ b/strfry.conf @@ -160,5 +160,23 @@ relay { # 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 = "" } }