From 966586e74fde809df7f5f3d3042f2283d20ef067 Mon Sep 17 00:00:00 2001 From: ThunderBlaze Date: Fri, 24 Apr 2026 14:54:04 +0530 Subject: [PATCH] feat(relay): gate REQ/COUNT/NEG-OPEN for configured kinds behind NIP-42 AUTH --- CHANGES | 3 + src/apps/relay/RelayIngester.cpp | 46 ++++++++++++++- src/apps/relay/RelayServer.h | 20 ++++++- src/apps/relay/golpe.yaml | 6 ++ src/filters.h | 99 +++++++++++++++++++++++++------- strfry.conf | 9 +++ 6 files changed, 159 insertions(+), 24 deletions(-) diff --git a/CHANGES b/CHANGES index 1d0e1d58..31086605 100644 --- a/CHANGES +++ b/CHANGES @@ -1,4 +1,7 @@ next + * Gate REQ/COUNT/NEG-OPEN behind NIP-42 AUTH for configured kinds via new + relay.auth.restrictedReadKinds and relay.auth.restrictReadToInvolvedPubkey + config options. Intended for locking down DM reads (kinds 4, 1059). * New --print-missing argument for strfry sync. This option tells sync to just print out the event IDs that you have and/or need, instead of actually trying to transfer them. diff --git a/src/apps/relay/RelayIngester.cpp b/src/apps/relay/RelayIngester.cpp index 1da00f99..208fde0d 100644 --- a/src/apps/relay/RelayIngester.cpp +++ b/src/apps/relay/RelayIngester.cpp @@ -72,7 +72,7 @@ void RelayServer::runIngester(ThreadPool::Thread &thr) { if (!cfg().relay__negentropy__enabled) throw herr("negentropy disabled"); try { - ingesterProcessNegentropy(txn, msg->connId, arr); + ingesterProcessNegentropy(txn, rsctx, msg->connId, arr); } catch (std::exception &e) { sendNoticeError(msg->connId, std::string("negentropy error: ") + e.what()); } @@ -222,6 +222,11 @@ void RelayServer::ingesterProcessReq(lmdb::txn &txn, RelayServerCtx &rsctx, uint throw herr("filter validation failed: ", e.what()); } + if (auto reason = checkReadAuth(rsctx, connId, filterGroup); !reason.empty()) { + sendClosed(connId, outSubIdStr, reason); + return; + } + Subscription sub(connId, outSubIdStr, std::move(filterGroup), countOnly); tpReqWorker.dispatch(connId, MsgReqWorker{MsgReqWorker::NewSub{std::move(sub)}}); @@ -289,7 +294,7 @@ void RelayServer::ingesterProcessAuth(RelayServerCtx &rsctx, uint64_t connId, co sendOKResponse(connId, to_hex(packed.id()), true, "successfully authenticated"); } -void RelayServer::ingesterProcessNegentropy(lmdb::txn &txn, uint64_t connId, const tao::json::value &arr) { +void RelayServer::ingesterProcessNegentropy(lmdb::txn &txn, RelayServerCtx &rsctx, uint64_t connId, const tao::json::value &arr) { const auto &subscriptionStr = jsonGetString(arr[1], "NEG-OPEN subscription id was not a string"); if (arr.at(0) == "NEG-OPEN") { @@ -301,6 +306,12 @@ void RelayServer::ingesterProcessNegentropy(lmdb::txn &txn, uint64_t connId, con if (!filterJson.is_object()) throw herr("negentropy filter must be an object"); NostrFilterGroup filter(filterJson, maxFilterLimit); + + if (auto reason = checkReadAuth(rsctx, connId, filter); !reason.empty()) { + sendNegErr(connId, subscriptionStr, reason); + return; + } + Subscription sub(connId, subscriptionStr, std::move(filter)); filterJson.get_object().erase("since"); @@ -319,3 +330,34 @@ void RelayServer::ingesterProcessNegentropy(lmdb::txn &txn, uint64_t connId, con throw herr("unknown command"); } } + +std::string RelayServer::checkReadAuth(RelayServerCtx &rsctx, uint64_t connId, const NostrFilterGroup &fg) { + rsctx.readAuthGate.ensureSetup(); + if (rsctx.readAuthGate.empty()) return ""; + if (!rsctx.readAuthGate.isGroupRestricted(fg)) return ""; + + if (!cfg().relay__auth__enabled || cfg().relay__auth__serviceUrl.empty()) { + LI << "[" << connId << "] restricted read kind requested but relay has no usable AUTH configuration"; + return "blocked: restricted kind requires AUTH but relay has no serviceUrl configured"; + } + + auto it = rsctx.connIdToAuthStatus.find(connId); + if (it == rsctx.connIdToAuthStatus.end()) { + auto challenge = rsctx.challengeGenerator.get(); + rsctx.connIdToAuthStatus.emplace(connId, challenge); + sendAuthChallenge(connId, challenge); + return "auth-required: restricted kind requires authentication"; + } + + const auto &as = it->second; + if (!as.isAuthed()) { + return "auth-required: restricted kind requires authentication"; + } + + if (cfg().relay__auth__restrictReadToInvolvedPubkey && + !rsctx.readAuthGate.authedInvolvedInAllRestrictedFilters(fg, as.authed)) { + return "restricted: must include authenticated pubkey in authors or #p tag"; + } + + return ""; +} diff --git a/src/apps/relay/RelayServer.h b/src/apps/relay/RelayServer.h index d8685ca5..9ee792f6 100644 --- a/src/apps/relay/RelayServer.h +++ b/src/apps/relay/RelayServer.h @@ -171,6 +171,7 @@ struct AuthStatus { struct RelayServerCtx { secp256k1_context *secpCtx = secp256k1_context_create(SECP256K1_CONTEXT_VERIFY); FilterValidator filterValidator; + ReadAuthGate readAuthGate; SessionToken::Generator challengeGenerator; flat_hash_map connIdToAuthStatus; }; @@ -199,7 +200,10 @@ struct RelayServer { void ingesterProcessReq(lmdb::txn &txn, RelayServerCtx &rsctx, uint64_t connId, const tao::json::value &arr, bool countOnly, std::string &outSubIdStr); void ingesterProcessClose(lmdb::txn &txn, uint64_t connId, const tao::json::value &arr); void ingesterProcessAuth(RelayServerCtx &rsctx, uint64_t connId, const tao::json::value &eventJson); - void ingesterProcessNegentropy(lmdb::txn &txn, uint64_t connId, const tao::json::value &origJson); + void ingesterProcessNegentropy(lmdb::txn &txn, RelayServerCtx &rsctx, uint64_t connId, const tao::json::value &origJson); + + // Returns rejection reason for REQ/COUNT/NEG-OPEN, or empty if allowed. + std::string checkReadAuth(RelayServerCtx &rsctx, uint64_t connId, const NostrFilterGroup &fg); void runWriter(ThreadPool::Thread &thr); @@ -262,6 +266,20 @@ struct RelayServer { hubTrigger->send(); } + void sendClosed(uint64_t connId, const std::string &subId, std::string_view reason) { + PROM_INC_RELAY_MSG("CLOSED"); + auto reply = tao::json::value::array({ "CLOSED", subId, std::string(reason) }); + tpWebsocket.dispatch(0, MsgWebsocket{MsgWebsocket::Send{connId, std::move(tao::json::to_string(reply))}}); + hubTrigger->send(); + } + + void sendNegErr(uint64_t connId, const std::string &subId, std::string_view reason) { + PROM_INC_RELAY_MSG("NEG-ERR"); + auto reply = tao::json::value::array({ "NEG-ERR", subId, std::string(reason) }); + tpWebsocket.dispatch(0, MsgWebsocket{MsgWebsocket::Send{connId, std::move(tao::json::to_string(reply))}}); + hubTrigger->send(); + } + void sendOKResponse(uint64_t connId, std::string_view eventIdHex, bool written, std::string_view message) { PROM_INC_RELAY_MSG("OK"); auto reply = tao::json::value::array({ "OK", eventIdHex, written, message }); diff --git a/src/apps/relay/golpe.yaml b/src/apps/relay/golpe.yaml index 864d117b..f2a62b7f 100644 --- a/src/apps/relay/golpe.yaml +++ b/src/apps/relay/golpe.yaml @@ -21,6 +21,12 @@ config: - name: relay__auth__serviceUrl desc: "External relay URL (beginning with wss://). Required in order to validate challenge responses." default: "" + - name: relay__auth__restrictedReadKinds + desc: "Comma-separated list of event kinds that require NIP-42 AUTH to read via REQ/COUNT/NEG-OPEN. A filter with no 'kinds' field is treated as restricted. Example for DMs: '4,1059'." + default: "" + - name: relay__auth__restrictReadToInvolvedPubkey + desc: "When a filter matches restrictedReadKinds, also require the authenticated pubkey to appear in its 'authors' or '#p' set." + default: true - name: relay__info__name desc: "NIP-11: Name of this server. Short/descriptive (< 30 characters)" diff --git a/src/filters.h b/src/filters.h index cfb45386..24dd2030 100644 --- a/src/filters.h +++ b/src/filters.h @@ -2,6 +2,7 @@ #include "golpe.h" +#include "Bytes32.h" #include "jsonParseUtils.h" @@ -297,32 +298,33 @@ struct NostrFilterGroup { } }; +inline void parseCommaSeparatedKinds(std::string_view str, flat_hash_set &out) { + out.clear(); + if (str.empty()) return; + + size_t pos = 0; + while (pos < str.size()) { + size_t nextComma = str.find(',', pos); + if (nextComma == std::string::npos) nextComma = str.size(); + + std::string_view kindStr = str.substr(pos, nextComma - pos); + size_t start = kindStr.find_first_not_of(" \t"); + size_t end = kindStr.find_last_not_of(" \t"); + if (start != std::string::npos && end != std::string::npos) { + kindStr = kindStr.substr(start, end - start + 1); + if (!kindStr.empty()) out.insert(std::stoull(std::string(kindStr))); + } + + pos = nextComma + 1; + } +} + struct FilterValidator { uint64_t configVer = 0; flat_hash_set allowedKinds; void setupValidator() { - allowedKinds.clear(); - - std::string allowedKindsStr = cfg().relay__filterValidation__allowedKinds; - - if (!allowedKindsStr.empty()) { - size_t pos = 0; - while (pos < allowedKindsStr.size()) { - size_t nextComma = allowedKindsStr.find(',', pos); - if (nextComma == std::string::npos) nextComma = allowedKindsStr.size(); - - std::string kindStr = allowedKindsStr.substr(pos, nextComma - pos); - size_t start = kindStr.find_first_not_of(" \t"); - size_t end = kindStr.find_last_not_of(" \t"); - if (start != std::string::npos && end != std::string::npos) { - kindStr = kindStr.substr(start, end - start + 1); - if (!kindStr.empty()) allowedKinds.insert(std::stoull(kindStr)); - } - - pos = nextComma + 1; - } - } + parseCommaSeparatedKinds(cfg().relay__filterValidation__allowedKinds, allowedKinds); } void validate(const NostrFilterGroup &fg) { @@ -373,3 +375,58 @@ struct FilterValidator { } } }; + +struct ReadAuthGate { + uint64_t configVer = 0; + flat_hash_set restrictedKinds; + + void setup() { + parseCommaSeparatedKinds(cfg().relay__auth__restrictedReadKinds, restrictedKinds); + } + + void ensureSetup() { + if (configVer != cfg().version()) { + setup(); + configVer = cfg().version(); + } + } + + bool empty() const { + return restrictedKinds.empty(); + } + + // Absent "kinds" matches all kinds and is treated as restricted. + bool isFilterRestricted(const NostrFilter &f) const { + if (restrictedKinds.empty()) return false; + if (!f.kinds) return true; + for (size_t i = 0; i < f.kinds->size(); i++) { + if (restrictedKinds.contains(f.kinds->at(i))) return true; + } + return false; + } + + bool isGroupRestricted(const NostrFilterGroup &fg) const { + if (restrictedKinds.empty()) return false; + for (const auto &f : fg.filters) { + if (isFilterRestricted(f)) return true; + } + return false; + } + + // True iff every restricted filter has authedPubkey in authors or #p. + bool authedInvolvedInAllRestrictedFilters(const NostrFilterGroup &fg, const Bytes32 &authedPubkey) const { + auto sv = authedPubkey.sv(); + for (const auto &f : fg.filters) { + if (!isFilterRestricted(f)) continue; + + bool involved = false; + if (f.authors && f.authors->doesMatch(sv)) involved = true; + if (!involved) { + auto it = f.tags.find('p'); + if (it != f.tags.end() && it->second.doesMatch(sv)) involved = true; + } + if (!involved) return false; + } + return true; + } +}; diff --git a/strfry.conf b/strfry.conf index 6f4cdcc9..f7d4a75e 100644 --- a/strfry.conf +++ b/strfry.conf @@ -58,6 +58,15 @@ relay { # External relay URL (beginning with wss://). Required in order to validate challenge responses. serviceUrl = "" + + # Comma-separated list of kinds that require NIP-42 AUTH to read via + # REQ/COUNT/NEG-OPEN. A filter with no "kinds" field is treated as + # restricted. Example for DMs: "4,1059". + restrictedReadKinds = "" + + # When a filter matches restrictedReadKinds, also require the authed + # pubkey to appear in its "authors" or "#p" set. + restrictReadToInvolvedPubkey = true } info {