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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGES
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
46 changes: 44 additions & 2 deletions src/apps/relay/RelayIngester.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ void RelayServer::runIngester(ThreadPool<MsgIngester>::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());
}
Expand Down Expand Up @@ -219,6 +219,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)}});
Expand Down Expand Up @@ -286,7 +291,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 &vals = arr.get_array();

if (vals.size() < 2) throw herr("negentropy query missing elements");
Expand All @@ -303,6 +308,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");
Expand All @@ -323,3 +334,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 "";
}
20 changes: 19 additions & 1 deletion src/apps/relay/RelayServer.h
Original file line number Diff line number Diff line change
Expand Up @@ -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<uint64_t, AuthStatus> connIdToAuthStatus;
};
Expand Down Expand Up @@ -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<MsgWriter>::Thread &thr);

Expand Down Expand Up @@ -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 });
Expand Down
6 changes: 6 additions & 0 deletions src/apps/relay/golpe.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down
99 changes: 78 additions & 21 deletions src/filters.h
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

#include "golpe.h"

#include "Bytes32.h"
#include "jsonParseUtils.h"


Expand Down Expand Up @@ -301,32 +302,33 @@ struct NostrFilterGroup : NonCopyable {
}
};

inline void parseCommaSeparatedKinds(std::string_view str, flat_hash_set<uint64_t> &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 : NonCopyable {
uint64_t configVer = 0;
flat_hash_set<uint64_t> 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) {
Expand Down Expand Up @@ -380,3 +382,58 @@ struct FilterValidator : NonCopyable {
}
}
};

struct ReadAuthGate {
uint64_t configVer = 0;
flat_hash_set<uint64_t> 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;
}
};
9 changes: 9 additions & 0 deletions strfry.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading