diff --git a/.github/workflows/ubuntu.yml b/.github/workflows/ubuntu.yml index 8b6ac21d..b85768b4 100644 --- a/.github/workflows/ubuntu.yml +++ b/.github/workflows/ubuntu.yml @@ -34,7 +34,7 @@ jobs: echo "filename=$filename" >> $GITHUB_ENV - name: Upload strfry deb - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: ${{ env.filename }} path: ${{ env.filename }} diff --git a/src/apps/relay/RelayIngester.cpp b/src/apps/relay/RelayIngester.cpp index 6b1febad..c63b2b6a 100644 --- a/src/apps/relay/RelayIngester.cpp +++ b/src/apps/relay/RelayIngester.cpp @@ -1,9 +1,12 @@ #include "RelayServer.h" +#include "jsonParseUtils.h" +#include void RelayServer::runIngester(ThreadPool::Thread &thr) { secp256k1_context *secpCtx = secp256k1_context_create(SECP256K1_CONTEXT_VERIFY); Decompressor decomp; + flat_hash_map connIdToAuthStatus; while(1) { auto newMsgs = thr.inbox.pop_all(); @@ -29,12 +32,20 @@ void RelayServer::runIngester(ThreadPool::Thread &thr) { if (cfg().relay__logging__dumpInEvents) LI << "[" << msg->connId << "] dumpInEvent: " << msg->payload; try { - ingesterProcessEvent(txn, msg->connId, msg->ipAddr, secpCtx, arr[1], writerMsgs); + ingesterProcessEvent(txn, msg->connId, connIdToAuthStatus, msg->ipAddr, secpCtx, arr[1], writerMsgs); } catch (std::exception &e) { sendOKResponse(msg->connId, arr[1].is_object() && arr[1].at("id").is_string() ? arr[1].at("id").get_string() : "?", false, std::string("invalid: ") + e.what()); if (cfg().relay__logging__invalidEvents) LI << "Rejected invalid event: " << e.what(); } + } else if (cmd == "AUTH") { + if (cfg().relay__logging__dumpInAll) LI << "[" << msg->connId << "] dumpInAuth: " << msg->payload; + + try { + ingesterProcessAuth(msg->connId, connIdToAuthStatus, secpCtx, arr[1]); + } catch (std::exception &e) { + sendNoticeError(msg->connId, std::string("auth failed: ") + e.what()); + } } else if (cmd == "REQ") { if (cfg().relay__logging__dumpInReqs) LI << "[" << msg->connId << "] dumpInReq: " << msg->payload; @@ -85,7 +96,7 @@ void RelayServer::runIngester(ThreadPool::Thread &thr) { } } -void RelayServer::ingesterProcessEvent(lmdb::txn &txn, uint64_t connId, std::string ipAddr, secp256k1_context *secpCtx, const tao::json::value &origJson, std::vector &output) { +void RelayServer::ingesterProcessEvent(lmdb::txn &txn, uint64_t connId, flat_hash_map &connIdToAuthStatus, std::string ipAddr, secp256k1_context *secpCtx, const tao::json::value &origJson, std::vector &output) { std::string packedStr, jsonStr; parseAndVerifyEvent(origJson, secpCtx, true, true, packedStr, jsonStr); @@ -104,9 +115,38 @@ void RelayServer::ingesterProcessEvent(lmdb::txn &txn, uint64_t connId, std::str }); if (foundProtected) { - LI << "Protected event, skipping"; - sendOKResponse(connId, to_hex(packed.id()), false, "blocked: event marked as protected"); - return; + // NIP-70 protected events must be rejected unless published by an authenticated public key + // that matches the event author, so we do all the AUTH flow here + if (cfg().relay__serviceUrl.empty()) { + // except if we don't have a serviceUrl, in that case just fail + LI << "Protected event and no serviceUrl configured, skipping"; + sendOKResponse(connId, to_hex(packed.id()), false, "blocked: event marked as protected"); + return; + } + + auto as = connIdToAuthStatus.find(connId); + if (as == connIdToAuthStatus.end()) { + // we haven't sent an AUTH event for this, so first we generate a challenge for this connection + auto authStatus = new AuthStatus(); + authStatus->challenge = std::to_string(int64_t(std::pow(packed.created_at(), connId + 1))); + connIdToAuthStatus.emplace(connId, authStatus); + LI << "Protected event, requesting AUTH"; + sendAuthChallenge(connId, authStatus->challenge); + sendOKResponse(connId, to_hex(packed.id()), false, "auth-required: event marked as protected"); + return; + } + + const auto authed = (*as->second).authed; + if (authed.empty()) { + // not authenticated + sendOKResponse(connId, to_hex(packed.id()), false, "auth-required: event marked as protected"); + return; + } else if (authed != packed.pubkey()) { + // authenticated as someone else + sendOKResponse(connId, to_hex(packed.id()), false, "restricted: must be published by the author"); + return; + } + // otherwise we proceed to accept the event } } @@ -137,6 +177,57 @@ void RelayServer::ingesterProcessClose(lmdb::txn &txn, uint64_t connId, const ta tpReqWorker.dispatch(connId, MsgReqWorker{MsgReqWorker::RemoveSub{connId, SubId(jsonGetString(arr[1], "CLOSE subscription id was not a string"))}}); } +void RelayServer::ingesterProcessAuth(uint64_t connId, flat_hash_map connIdToAuthStatus, secp256k1_context *secpCtx, const tao::json::value &eventJson) { + if (cfg().relay__serviceUrl.empty()) { + throw herr("relay needs serviceUrl to be configured before AUTH can work"); + } + + std::string packedStr, jsonStr; + parseAndVerifyEvent(eventJson, secpCtx, true, true, packedStr, jsonStr); + + PackedEventView packed(packedStr); + + if (packed.kind() != 22242) { + throw herr("wrong event kind, expected 22242"); + } + + auto as = connIdToAuthStatus.find(connId); + if (as == connIdToAuthStatus.end()) { + throw herr("no auth status available for connection"); + } + if (!(*as->second).authed.empty()) { + throw herr("already authenticated"); + } + const auto challenge = (*as->second).challenge; + + bool foundChallenge = false; + bool foundCorrectRelayUrl = false; + + for (const auto &tagj : eventJson.at("tags").get_array()) { + const auto &tag = tagj.get_array(); + if (tag.size() < 2) continue; + const auto name = tag[0].as(); + const auto value = tag[1].as(); + if (name == "relay" && value == cfg().relay__serviceUrl) { + foundCorrectRelayUrl = true; + } else if (name == "challenge" && value == challenge) { + foundChallenge = true; + } + } + + if (!foundChallenge) { + throw herr("challenge string mismatch"); + } + if (!foundCorrectRelayUrl) { + throw herr("incorrect or missing relay tag, expected: " + cfg().relay__serviceUrl); + } + + // set the connection as authenticated with this pubkey + (*as->second).authed = packed.pubkey(); + + sendOKResponse(connId, to_hex(packed.id()), true, "successfully authenticated"); +} + void RelayServer::ingesterProcessNegentropy(lmdb::txn &txn, Decompressor &decomp, uint64_t connId, const tao::json::value &arr) { const auto &subscriptionStr = jsonGetString(arr[1], "NEG-OPEN subscription id was not a string"); diff --git a/src/apps/relay/RelayServer.h b/src/apps/relay/RelayServer.h index 083a2e6c..4ffd163a 100644 --- a/src/apps/relay/RelayServer.h +++ b/src/apps/relay/RelayServer.h @@ -147,6 +147,12 @@ struct MsgNegentropy : NonCopyable { MsgNegentropy(Var &&msg_) : msg(std::move(msg_)) {} }; +// NIP-42 stuff +struct AuthStatus { + std::string challenge; + std::string authed; +}; + struct RelayServer { uS::Async *hubTrigger = nullptr; @@ -167,9 +173,10 @@ struct RelayServer { void runWebsocket(ThreadPool::Thread &thr); 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 ingesterProcessEvent(lmdb::txn &txn, uint64_t connId, flat_hash_map &connIdToAuthStatus, 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 ingesterProcessClose(lmdb::txn &txn, uint64_t connId, const tao::json::value &origJson); + void ingesterProcessAuth(uint64_t connId, flat_hash_map connIdToAuthStatus, secp256k1_context *secpCtx, const tao::json::value &eventJson); void ingesterProcessNegentropy(lmdb::txn &txn, Decompressor &decomp, uint64_t connId, const tao::json::value &origJson); void runWriter(ThreadPool::Thread &thr); @@ -228,4 +235,10 @@ struct RelayServer { tpWebsocket.dispatch(0, MsgWebsocket{MsgWebsocket::Send{connId, std::move(tao::json::to_string(reply))}}); hubTrigger->send(); } + + void sendAuthChallenge(uint64_t connId, std::string_view challenge) { + auto reply = tao::json::value::array({ "AUTH", challenge }); + tpWebsocket.dispatch(0, MsgWebsocket{MsgWebsocket::Send{connId, std::move(tao::json::to_string(reply))}}); + hubTrigger->send(); + } }; diff --git a/src/apps/relay/golpe.yaml b/src/apps/relay/golpe.yaml index a845a1e6..d5bca88c 100644 --- a/src/apps/relay/golpe.yaml +++ b/src/apps/relay/golpe.yaml @@ -110,3 +110,7 @@ config: - name: relay__negentropy__maxSyncEvents desc: "Maximum records that sync will process before returning an error" default: 1000000 + + - name: relay__serviceUrl + desc: "Relay URL (beginning with wss://) that will be used to check NIP-42 AUTH" + default: "" diff --git a/strfry.conf b/strfry.conf index 7c07c67c..42f6fc6b 100644 --- a/strfry.conf +++ b/strfry.conf @@ -144,4 +144,7 @@ relay { # Maximum records that sync will process before returning an error maxSyncEvents = 1000000 } + + # NIP-42: URL that clients should use in the relay tag when authenticating (optional) + serviceUrl = "ws://localhost:7777" }