New internal SS-to-SS onion req endpoint

Replaces the sn.onion_req_v2 OMQ endpoint with a new sn.onion_request
that takes an extendable bencoded dict (the same as used extensively in
oxen-core and lokinet), thus allowing us to pass fields such as hop
number and encryption type in the request, remaining compact (binary
data has no overhead), and allows for future additions without requiring
a new endpoint.

The new endpoint activates for SN-to-SN onion data at HF18; before then
the sn.onion_req_v2 is still used and remains backwards compatible (but
cannot be extended with encryption type or hop info).

Currently on the wire we have four fields:

    p - end encrypted payload (required)
    ek - the ephemeral key (required)
    et - the encryption type (optional, aes-gcm if not provided)
    nh - the hop number, which get incremented on each hop

Max path length is limited to 15, to allow the client to choose to
obscure it's path knowledge somewhat by using a randomized starting hop
position from `[0, 15-actual]`
This commit is contained in:
Jason Rhinelander 2021-04-21 17:00:45 -03:00
parent c0e7deef2f
commit 10b9d5accb
10 changed files with 208 additions and 103 deletions

View file

@ -28,12 +28,6 @@ calculate_shared_secret(const x25519_seckey& seckey,
return secret;
}
EncryptType parse_enc_type(std::string_view enc_type) {
if (enc_type == "aes-gcm" || enc_type == "gcm") return EncryptType::aes_gcm;
if (enc_type == "aes-cbc" || enc_type == "cbc") return EncryptType::aes_cbc;
throw std::runtime_error{"Invalid encryption type " + std::string{enc_type}};
}
std::basic_string_view<unsigned char> to_uchar(std::string_view sv) {
return {reinterpret_cast<const unsigned char*>(sv.data()), sv.size()};
}
@ -68,6 +62,12 @@ using aes256cbc_ctx_ptr = std::unique_ptr<EVP_CIPHER_CTX, aes256_evp_deleter>;
}
EncryptType parse_enc_type(std::string_view enc_type) {
if (enc_type == "aes-gcm" || enc_type == "gcm") return EncryptType::aes_gcm;
if (enc_type == "aes-cbc" || enc_type == "cbc") return EncryptType::aes_cbc;
throw std::runtime_error{"Invalid encryption type " + std::string{enc_type}};
}
std::string ChannelEncryption::encrypt(EncryptType type, std::string_view plaintext, const x25519_pubkey& pubkey) const {
switch (type) {
case EncryptType::aes_gcm: return encrypt_gcm(plaintext, pubkey);

View file

@ -498,32 +498,43 @@ void connection_t::process_onion_req_v2() {
// Need to make sure we are not blocking waiting for the response
delay_response_ = true;
auto on_response = [wself = weak_from_this()](oxen::Response res) {
OXEN_LOG(debug, "Got an onion response as edge node");
OnionRequestMetadata data{
x25519_pubkey{},
[wself = weak_from_this()](oxen::Response res) {
OXEN_LOG(debug, "Got an onion response as edge node");
auto self = wself.lock();
if (!self) {
OXEN_LOG(debug,
"Connection is no longer valid, dropping onion response");
return;
}
auto self = wself.lock();
if (!self) {
OXEN_LOG(debug,
"Connection is no longer valid, dropping onion response");
return;
}
self->body_stream_ << res.message();
self->response_.result(static_cast<int>(res.status()));
self->body_stream_ << res.message();
self->response_.result(static_cast<int>(res.status()));
self->write_response();
self->write_response();
},
0, // hopno
EncryptType::aes_gcm,
};
try {
auto [ciphertext, json_req] = parse_combined_payload(req.body());
auto ephem_key = extract_x25519_from_hex(
data.ephem_key = extract_x25519_from_hex(
json_req.at("ephemeral_key").get_ref<const std::string&>());
if (auto it = json_req.find("enc_type"); it != json_req.end())
data.enc_type = parse_enc_type(it->get_ref<const std::string&>());
// Allows a fake starting hop number (to make it harder for intermediate hops to know where
// they are). If omitted, defaults to 0.
if (auto it = json_req.find("hop_no"); it != json_req.end())
data.hop_no = std::max(0, it->get<int>());
service_node_.record_onion_request();
request_handler_.process_onion_req(std::move(ciphertext), ephem_key,
on_response);
request_handler_.process_onion_req(ciphertext, std::move(data));
} catch (const std::exception& e) {
auto msg = fmt::format("Error parsing onion request: {}",

View file

@ -4,6 +4,7 @@
#include "oxen_common.h"
#include "oxen_logger.h"
#include "oxend_key.h"
#include "channel_encryption.hpp"
#include "oxenmq/connections.h"
#include "oxenmq/oxenmq.h"
#include "request_handler.h"
@ -105,11 +106,12 @@ void OxenmqServer::handle_ping(oxenmq::Message& message) {
message.send_reply("pong");
}
void OxenmqServer::handle_onion_request(oxenmq::Message& message) {
void OxenmqServer::handle_onion_request(
std::string_view payload,
OnionRequestMetadata&& data,
oxenmq::Message::DeferredSend send) {
OXEN_LOG(debug, "Got an onion request over OxenMQ");
auto on_response = [send=message.send_later()](oxen::Response res) {
data.cb = [send](oxen::Response res) {
if (OXEN_LOG_ENABLED(trace))
OXEN_LOG(trace, "on response: {}...", to_string(res).substr(0, 100));
@ -118,21 +120,54 @@ void OxenmqServer::handle_onion_request(oxenmq::Message& message) {
std::move(res).message());
};
if (data.hop_no > MAX_ONION_HOPS)
return data.cb({Status::BAD_REQUEST, "onion request max path length exceeded"});
request_handler_->process_onion_req(payload, std::move(data));
}
void OxenmqServer::handle_onion_request(oxenmq::Message& message) {
std::pair<std::string_view, OnionRequestMetadata> data;
try {
if (message.data.size() != 1)
throw std::runtime_error{"expected 1 part, got " + std::to_string(message.data.size())};
data = decode_onion_data(message.data[0]);
} catch (const std::exception& e) {
auto msg = "Invalid internal onion request: "s + e.what();
OXEN_LOG(error, "{}", msg);
message.send_reply(
std::to_string(static_cast<int>(Status::BAD_REQUEST)), msg);
return;
}
handle_onion_request(data.first, std::move(data.second), message.send_later());
}
void OxenmqServer::handle_onion_req_v2(oxenmq::Message& message) {
OXEN_LOG(debug, "Got a v2 onion request over OxenMQ");
const int bad_code = static_cast<int>(Status::BAD_REQUEST);
if (message.data.size() != 2) {
OXEN_LOG(error, "Expected 2 message parts, got {}",
message.data.size());
return on_response({Status::BAD_REQUEST, "Incorrect number of request parts"});
message.send_reply(std::to_string(bad_code),
"Incorrect number of onion request message parts");
return;
}
auto eph_key = extract_x25519_from_hex(message.data[0]);
if (!eph_key) {
OXEN_LOG(error, "no ephemeral key in omq onion request");
return on_response({Status::BAD_REQUEST, "Missing ephemeral key"});
message.send_reply(std::to_string(bad_code), "Missing ephemeral key");
return;
}
const auto& ciphertext = message.data[1];
request_handler_->process_onion_req(
std::string{ciphertext}, *eph_key, on_response);
handle_onion_request(
message.data[1], // ciphertext
{*eph_key, nullptr, 1 /* hopno */, EncryptType::aes_gcm},
message.send_later());
}
void OxenmqServer::handle_get_logs(oxenmq::Message& message) {
@ -218,7 +253,9 @@ OxenmqServer::OxenmqServer(
std::to_string(static_cast<int>(Status::BAD_REQUEST)),
"onion requests v1 not supported");
})
.add_request_command("onion_req_v2", [this](auto& m) { handle_onion_request(m); })
// TODO: Backwards compat, only used up until HF18
.add_request_command("onion_req_v2", [this](auto& m) { handle_onion_req_v2(m); })
.add_request_command("onion_request", [this](auto& m) { handle_onion_request(m); })
;
omq_.add_category("service", oxenmq::AuthLevel::admin)
@ -268,4 +305,39 @@ void OxenmqServer::init(ServiceNode* sn, RequestHandler* rh, oxenmq::address oxe
connect_oxend(oxend_rpc);
}
std::string OxenmqServer::encode_onion_data(std::string_view payload, const OnionRequestMetadata& data) {
return oxenmq::bt_serialize<oxenmq::bt_dict>({
{"d", payload},
{"ek", data.ephem_key.view()},
{"et", to_string(data.enc_type)},
{"nh", data.hop_no},
});
}
std::pair<std::string_view, OnionRequestMetadata> OxenmqServer::decode_onion_data(std::string_view data) {
// NB: stream parsing here is alphabetical
std::pair<std::string_view, OnionRequestMetadata> result;
auto& [payload, meta] = result;
oxenmq::bt_dict_consumer d{data};
if (!d.skip_until("d"))
throw std::runtime_error{"required data payload not found"};
payload = d.consume_string_view();
if (!d.skip_until("ek"))
throw std::runtime_error{"ephemeral key not found"};
meta.ephem_key = x25519_pubkey::from_bytes(d.consume_string_view());
if (d.skip_until("et"))
meta.enc_type = parse_enc_type(d.consume_string_view());
else
meta.enc_type = EncryptType::aes_gcm;
if (d.skip_until("nh"))
meta.hop_no = d.consume_integer<int>();
if (meta.hop_no < 1)
meta.hop_no = 1;
return result;
}
} // namespace oxen

View file

@ -15,6 +15,7 @@ namespace oxen {
struct oxend_key_pair_t;
class ServiceNode;
class RequestHandler;
struct OnionRequestMetadata;
void omq_logger(oxenmq::LogLevel level, const char* file, int line,
std::string message);
@ -39,8 +40,17 @@ class OxenmqServer {
void handle_sn_proxy_exit(oxenmq::Message& message);
// Called for the sn.onion_req_v2 endpoint
void handle_onion_req_v2(oxenmq::Message& message);
// Called starting at HF18 for SS-to-SS onion requests
void handle_onion_request(oxenmq::Message& message);
// Handles a decoded onion request
void handle_onion_request(
std::string_view payload,
OnionRequestMetadata&& data,
oxenmq::Message::DeferredSend send);
// sn.ping - sent by SNs to ping each other.
void handle_ping(oxenmq::Message& message);
@ -84,6 +94,12 @@ class OxenmqServer {
assert(oxend_conn_);
omq_.send(oxend_conn(), std::forward<Args>(args)...);
}
// Encodes the onion request data that we send for internal SN-to-SN onion requests starting at
// HF18.
static std::string encode_onion_data(std::string_view payload, const OnionRequestMetadata& data);
// Decodes onion request data; throws if invalid formatted or missing required fields.
static std::pair<std::string_view, OnionRequestMetadata> decode_onion_data(std::string_view data);
};
} // namespace oxen

View file

@ -55,7 +55,8 @@ auto process_inner_request(std::string plaintext) -> ParsedInfo {
ctext = std::move(ciphertext);
next = ed25519_pubkey::from_hex(
inner_json.at("destination").get_ref<const std::string&>());
eph_key = inner_json.at("ephemeral_key").get<std::string>();
eph_key = x25519_pubkey::from_hex(
inner_json.at("ephemeral_key").get_ref<const std::string&>());
}
} catch (const std::exception& e) {
OXEN_LOG(debug, "Error parsing inner JSON in onion request: {}",
@ -69,11 +70,12 @@ auto process_inner_request(std::string plaintext) -> ParsedInfo {
static auto
process_ciphertext_v2(const ChannelEncryption& decryptor,
std::string_view ciphertext,
const x25519_pubkey& ephem_key) -> ParsedInfo {
const x25519_pubkey& ephem_key,
EncryptType enc_type) -> ParsedInfo {
std::optional<std::string> plaintext;
try {
plaintext = decryptor.decrypt(EncryptType::aes_gcm, ciphertext, ephem_key);
plaintext = decryptor.decrypt(enc_type, ciphertext, ephem_key);
} catch (const std::exception& e) {
OXEN_LOG(debug, "Error decrypting an onion request: {}", e.what());
}
@ -125,46 +127,44 @@ static auto make_status(std::string_view status) -> oxen::Status {
// FIXME: why are these method definitions *here* instead of request_handler.cpp?
void RequestHandler::process_onion_req(std::string_view ciphertext,
const x25519_pubkey& ephem_key,
std::function<void(oxen::Response)> cb) {
OnionRequestMetadata data) {
if (!service_node_.snode_ready()) {
auto msg =
fmt::format("Snode not ready: {}",
service_node_.own_address().pubkey_ed25519);
return cb({Status::SERVICE_UNAVAILABLE, std::move(msg)});
return data.cb({Status::SERVICE_UNAVAILABLE, std::move(msg)});
}
OXEN_LOG(debug, "process_onion_req");
var::visit([&](auto&& x) { process_onion_req(std::move(x), ephem_key, std::move(cb)); },
process_ciphertext_v2(channel_cipher_, ciphertext, ephem_key));
var::visit([&](auto&& x) { process_onion_req(std::move(x), std::move(data)); },
process_ciphertext_v2(channel_cipher_, ciphertext, data.ephem_key, data.enc_type));
}
void RequestHandler::process_onion_req(FinalDestinationInfo&& info,
const x25519_pubkey& ephem_key, std::function<void(oxen::Response)> cb) {
OnionRequestMetadata&& data) {
OXEN_LOG(debug, "We are the final destination in the onion request!");
process_onion_exit(
ephem_key, info.body,
[this, ephem_key, cb = std::move(cb), json = info.json, b64 = info.base64]
info.body,
[this, data = std::move(data), json = info.json, b64 = info.base64]
(oxen::Response res) {
cb(wrap_proxy_response(std::move(res), ephem_key, EncryptType::aes_gcm, json, b64));
data.cb(wrap_proxy_response(std::move(res), data.ephem_key, data.enc_type, json, b64));
});
}
void RequestHandler::process_onion_req(RelayToNodeInfo&& info,
const x25519_pubkey& ephem_key, std::function<void(oxen::Response)> cb) {
OnionRequestMetadata&& data) {
auto& [payload, ekey, dest] = info;
auto dest_node = service_node_.find_node(dest);
if (!dest_node) {
auto msg = fmt::format("Next node not found: {}", dest);
OXEN_LOG(warn, "{}", msg);
return cb({Status::BAD_GATEWAY, std::move(msg)});
return data.cb({Status::BAD_GATEWAY, std::move(msg)});
}
auto on_response = [cb=std::move(cb)](bool success,
auto on_response = [cb=std::move(data.cb)](bool success,
std::vector<std::string> data) {
// Processing the result we got from upstream
@ -190,8 +190,9 @@ void RequestHandler::process_onion_req(RelayToNodeInfo&& info,
OXEN_LOG(debug, "send_onion_to_sn, sn: {}", dest_node->pubkey_legacy);
service_node_.send_onion_to_sn_v2(
*dest_node, std::move(payload), ekey, std::move(on_response));
data.ephem_key = ekey;
service_node_.send_onion_to_sn(
*dest_node, std::move(payload), std::move(data), std::move(on_response));
}
bool is_server_url_allowed(std::string_view url) {
@ -202,30 +203,30 @@ bool is_server_url_allowed(std::string_view url) {
}
void RequestHandler::process_onion_req(RelayToServerInfo&& info,
const x25519_pubkey& ephem_key, std::function<void(oxen::Response)> cb) {
OnionRequestMetadata&& data) {
OXEN_LOG(debug, "We are to forward the request to url: {}{}",
info.host, info.target);
// Forward the request to url but only if it ends in `/lsrpc`
if (is_server_url_allowed(info.target))
return process_onion_to_url(info.protocol, std::move(info.host), info.port,
std::move(info.target), std::move(info.payload), std::move(cb));
std::move(info.target), std::move(info.payload), std::move(data.cb));
return cb(wrap_proxy_response({Status::BAD_REQUEST, "Invalid url"},
ephem_key, EncryptType::aes_gcm));
return data.cb(wrap_proxy_response({Status::BAD_REQUEST, "Invalid url"},
data.ephem_key, data.enc_type));
}
void RequestHandler::process_onion_req(ProcessCiphertextError&& error,
const x25519_pubkey& ephem_key, std::function<void(oxen::Response)> cb) {
OnionRequestMetadata&& data) {
switch (error) {
case ProcessCiphertextError::INVALID_CIPHERTEXT:
// Should this error be propagated back to the client? (No, if we
// couldn't decrypt, we probably won't be able to encrypt either.)
return cb({Status::BAD_REQUEST, "Invalid ciphertext"});
return data.cb({Status::BAD_REQUEST, "Invalid ciphertext"});
case ProcessCiphertextError::INVALID_JSON:
return cb(wrap_proxy_response({Status::BAD_REQUEST, "Invalid json"},
ephem_key, EncryptType::aes_gcm));
return data.cb(wrap_proxy_response({Status::BAD_REQUEST, "Invalid json"},
data.ephem_key, data.enc_type));
}
}

View file

@ -7,6 +7,11 @@
namespace oxen {
// Maximum onion request hops we'll accept before we return an error; this is deliberately larger
// than we actually use so that the client can choose to obscure hop positioning by starting at
// somewhere higher than 0.
inline constexpr int MAX_ONION_HOPS = 15;
using CiphertextPlusJson = std::pair<std::string, nlohmann::json>;
/// The request is to be forwarded to another SS node
@ -14,7 +19,7 @@ struct RelayToNodeInfo {
/// Inner ciphertext for next node
std::string ciphertext;
// Key to be forwarded to next node for decryption
std::string ephemeral_key;
x25519_pubkey ephemeral_key;
// Next node's ed25519 key
ed25519_pubkey next_node;
};

View file

@ -349,7 +349,7 @@ Response RequestHandler::process_retrieve(const json& params) {
}
void RequestHandler::process_client_req(
const std::string& req_json, std::function<void(oxen::Response)> cb) {
std::string_view req_json, std::function<void(oxen::Response)> cb) {
OXEN_LOG(trace, "process_client_req str <{}>", req_json);
@ -469,7 +469,7 @@ void RequestHandler::process_lns_request(
}
void RequestHandler::process_onion_exit(
const x25519_pubkey& eph_key, const std::string& body,
std::string_view body,
std::function<void(oxen::Response)> cb) {
OXEN_LOG(debug, "Processing onion exit!");

View file

@ -1,5 +1,6 @@
#pragma once
#include "channel_encryption.hpp"
#include "onion_processing.h"
#include "oxen_common.h"
#include "oxend_key.h"
@ -12,8 +13,6 @@
namespace oxen {
class ChannelEncryption;
enum struct EncryptType;
class ServiceNode;
enum class Status {
@ -77,6 +76,13 @@ std::string computeMessageHash(const std::string& timestamp,
const std::string& recipient,
const std::string& data);
struct OnionRequestMetadata {
x25519_pubkey ephem_key;
std::function<void(oxen::Response)> cb;
int hop_no = 0;
EncryptType enc_type = EncryptType::aes_gcm;
};
class RequestHandler {
boost::asio::io_context& ioc_;
@ -105,8 +111,7 @@ class RequestHandler {
// Query the database and return requested messages
Response process_retrieve(const nlohmann::json& params);
void process_onion_exit(const x25519_pubkey& eph_key,
const std::string& payload,
void process_onion_exit(std::string_view payload,
std::function<void(oxen::Response)> cb);
void process_lns_request(std::string name_hash,
@ -119,7 +124,7 @@ class RequestHandler {
const ChannelEncryption& ce);
// Process all Session client requests
void process_client_req(const std::string& req_json,
void process_client_req(std::string_view req_json,
std::function<void(oxen::Response)> cb);
// Forwards a request to oxend RPC. `params` should contain:
@ -150,17 +155,12 @@ class RequestHandler {
std::function<void(oxen::Response)> cb);
// The result will arrive asynchronously, so it needs a callback handler
void process_onion_req(std::string_view ciphertext,
const x25519_pubkey& ephem_key,
std::function<void(oxen::Response)> cb);
void process_onion_req(std::string_view ciphertext, OnionRequestMetadata data);
void process_onion_req(FinalDestinationInfo&& res,
const x25519_pubkey& ekey, std::function<void(oxen::Response)> cb);
void process_onion_req(RelayToNodeInfo&& res,
const x25519_pubkey& ekey, std::function<void(oxen::Response)> cb);
void process_onion_req(RelayToServerInfo&& res,
const x25519_pubkey& ekey, std::function<void(oxen::Response)> cb);
void process_onion_req(ProcessCiphertextError&& res,
const x25519_pubkey& ekey, std::function<void(oxen::Response)> cb);
private:
void process_onion_req(FinalDestinationInfo&& res, OnionRequestMetadata&& data);
void process_onion_req(RelayToNodeInfo&& res, OnionRequestMetadata&& data);
void process_onion_req(RelayToServerInfo&& res, OnionRequestMetadata&& data);
void process_onion_req(ProcessCiphertextError&& res, OnionRequestMetadata&& data);
};
} // namespace oxen

View file

@ -284,24 +284,26 @@ bool ServiceNode::snode_ready(std::string* reason) {
return problems.empty() || force_start_;
}
void ServiceNode::send_onion_to_sn_v1(const sn_record_t& sn,
const std::string& payload,
const std::string& eph_key,
ss_client::Callback cb) const {
void ServiceNode::send_onion_to_sn(const sn_record_t& sn,
std::string_view payload,
OnionRequestMetadata&& data,
ss_client::Callback cb) const {
lmq_server_->request(sn.pubkey_x25519.view(), "sn.onion_req", std::move(cb),
oxenmq::send_option::request_timeout{30s}, eph_key,
payload);
}
void ServiceNode::send_onion_to_sn_v2(const sn_record_t& sn,
const std::string& payload,
const std::string& eph_key,
ss_client::Callback cb) const {
lmq_server_->request(
sn.pubkey_x25519.view(), "sn.onion_req_v2", std::move(cb),
oxenmq::send_option::request_timeout{30s}, eph_key, payload);
if (!hf_at_least(HARDFORK_OMQ_ONION_REQ_BENCODE)) {
// use the _v2 endpoint up until the hf:
lmq_server_->request(
sn.pubkey_x25519.view(), "sn.onion_req_v2", std::move(cb),
oxenmq::send_option::request_timeout{30s}, data.ephem_key.hex(), payload);
} else {
// Use the newer (v3, I suppose, though it's internal) where when bencode everything (which
// is a bit more compact than sending the eph_key in hex, plus allows other metadata such as
// the hop number and the encryption type).
data.hop_no++;
lmq_server_->request(
sn.pubkey_x25519.view(), "sn.onion_request", std::move(cb),
oxenmq::send_option::request_timeout{30s},
lmq_server_.encode_onion_data(payload, data));
}
}
// Calls callback on success only?

View file

@ -31,6 +31,8 @@ inline constexpr int STORAGE_SERVER_HARDFORK = 17;
inline constexpr int HARDFORK_SN_PING = 18;
// HF at which we can stop using hex conversion for pubkey sorting for testee/testers.
inline constexpr int HARDFORK_NO_HEX_SORT_HACK = 18;
// HF at which we start using the sn.onion_request endpoint instead of sn.onion_req_v2
inline constexpr int HARDFORK_OMQ_ONION_REQ_BENCODE = 18;
namespace storage {
struct Item;
@ -40,6 +42,8 @@ struct sn_response_t;
class OxenmqServer;
struct OnionRequestMetadata;
namespace ss_client {
class Request;
enum class ReqMethod;
@ -196,16 +200,10 @@ class ServiceNode {
void record_proxy_request();
void record_onion_request();
// This is new, so it does not need to support http, thus new (if temp)
// method
void send_onion_to_sn_v1(const sn_record_t& sn, const std::string& payload,
const std::string& eph_key,
ss_client::Callback cb) const;
/// Same as v1, but using the new protocol (ciphertext as binary)
void send_onion_to_sn_v2(const sn_record_t& sn, const std::string& payload,
const std::string& eph_key,
ss_client::Callback cb) const;
/// Sends an onion request to the next SS
void send_onion_to_sn(const sn_record_t& sn, std::string_view payload,
OnionRequestMetadata&& data,
ss_client::Callback cb) const;
// TODO: move this eventually out of SN
// Send by either http or omq