diff options
author | Vasil Dimov <vd@FreeBSD.org> | 2020-11-27 13:59:26 +0100 |
---|---|---|
committer | Vasil Dimov <vd@FreeBSD.org> | 2021-03-01 18:19:37 +0100 |
commit | c22daa2ecff1acd25426cd46f98f2587d1d324c3 (patch) | |
tree | f8fca7ea86ea24f19ef0d498c4ed4ef15a43feba /src | |
parent | 5bac7e45e1d3a07115b5ff002d988438fcc92a53 (diff) |
net: implement the necessary parts of the I2P SAM protocol
Implement the following commands from the I2P SAM protocol:
* HELLO: needed for all of the remaining ones
* DEST GENERATE: to generate our private key and destination
* NAMING LOOKUP: to convert .i2p addresses to destinations
* SESSION CREATE: needed for STREAM CONNECT and STREAM ACCEPT
* STREAM CONNECT: to make outgoing connections
* STREAM ACCEPT: to accept incoming connections
Diffstat (limited to 'src')
-rw-r--r-- | src/Makefile.am | 2 | ||||
-rw-r--r-- | src/i2p.cpp | 407 | ||||
-rw-r--r-- | src/i2p.h | 260 | ||||
-rw-r--r-- | src/logging.cpp | 1 | ||||
-rw-r--r-- | src/logging.h | 1 |
5 files changed, 671 insertions, 0 deletions
diff --git a/src/Makefile.am b/src/Makefile.am index d6af3049e8..1c6d97b714 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -148,6 +148,7 @@ BITCOIN_CORE_H = \ fs.h \ httprpc.h \ httpserver.h \ + i2p.h \ index/base.h \ index/blockfilterindex.h \ index/disktxpos.h \ @@ -315,6 +316,7 @@ libbitcoin_server_a_SOURCES = \ flatfile.cpp \ httprpc.cpp \ httpserver.cpp \ + i2p.cpp \ index/base.cpp \ index/blockfilterindex.cpp \ index/txindex.cpp \ diff --git a/src/i2p.cpp b/src/i2p.cpp new file mode 100644 index 0000000000..42270deaeb --- /dev/null +++ b/src/i2p.cpp @@ -0,0 +1,407 @@ +// Copyright (c) 2020-2020 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include <chainparams.h> +#include <compat.h> +#include <compat/endian.h> +#include <crypto/sha256.h> +#include <fs.h> +#include <i2p.h> +#include <logging.h> +#include <netaddress.h> +#include <netbase.h> +#include <random.h> +#include <util/strencodings.h> +#include <tinyformat.h> +#include <util/readwritefile.h> +#include <util/sock.h> +#include <util/spanparsing.h> +#include <util/system.h> + +#include <chrono> +#include <stdexcept> +#include <string> + +namespace i2p { + +/** + * Swap Standard Base64 <-> I2P Base64. + * Standard Base64 uses `+` and `/` as last two characters of its alphabet. + * I2P Base64 uses `-` and `~` respectively. + * So it is easy to detect in which one is the input and convert to the other. + * @param[in] from Input to convert. + * @return converted `from` + */ +static std::string SwapBase64(const std::string& from) +{ + std::string to; + to.resize(from.size()); + for (size_t i = 0; i < from.size(); ++i) { + switch (from[i]) { + case '-': + to[i] = '+'; + break; + case '~': + to[i] = '/'; + break; + case '+': + to[i] = '-'; + break; + case '/': + to[i] = '~'; + break; + default: + to[i] = from[i]; + break; + } + } + return to; +} + +/** + * Decode an I2P-style Base64 string. + * @param[in] i2p_b64 I2P-style Base64 string. + * @return decoded `i2p_b64` + * @throw std::runtime_error if decoding fails + */ +static Binary DecodeI2PBase64(const std::string& i2p_b64) +{ + const std::string& std_b64 = SwapBase64(i2p_b64); + bool invalid; + Binary decoded = DecodeBase64(std_b64.c_str(), &invalid); + if (invalid) { + throw std::runtime_error(strprintf("Cannot decode Base64: \"%s\"", i2p_b64)); + } + return decoded; +} + +/** + * Derive the .b32.i2p address of an I2P destination (binary). + * @param[in] dest I2P destination. + * @return the address that corresponds to `dest` + * @throw std::runtime_error if conversion fails + */ +static CNetAddr DestBinToAddr(const Binary& dest) +{ + CSHA256 hasher; + hasher.Write(dest.data(), dest.size()); + unsigned char hash[CSHA256::OUTPUT_SIZE]; + hasher.Finalize(hash); + + CNetAddr addr; + const std::string addr_str = EncodeBase32(hash, false) + ".b32.i2p"; + if (!addr.SetSpecial(addr_str)) { + throw std::runtime_error(strprintf("Cannot parse I2P address: \"%s\"", addr_str)); + } + + return addr; +} + +/** + * Derive the .b32.i2p address of an I2P destination (I2P-style Base64). + * @param[in] dest I2P destination. + * @return the address that corresponds to `dest` + * @throw std::runtime_error if conversion fails + */ +static CNetAddr DestB64ToAddr(const std::string& dest) +{ + const Binary& decoded = DecodeI2PBase64(dest); + return DestBinToAddr(decoded); +} + +namespace sam { + +Session::Session(const fs::path& private_key_file, + const CService& control_host, + CThreadInterrupt* interrupt) + : m_private_key_file(private_key_file), m_control_host(control_host), m_interrupt(interrupt) +{ +} + +Session::~Session() +{ + LOCK(m_mutex); + Disconnect(); +} + +bool Session::Listen(Connection& conn) +{ + try { + LOCK(m_mutex); + CreateIfNotCreatedAlready(); + conn.me = m_my_addr; + conn.sock = StreamAccept(); + return true; + } catch (const std::runtime_error& e) { + Log("Error listening: %s", e.what()); + CheckControlSock(); + } + return false; +} + +bool Session::Accept(Connection& conn) +{ + try { + while (!*m_interrupt) { + Sock::Event occurred; + conn.sock.Wait(MAX_WAIT_FOR_IO, Sock::RECV, &occurred); + + if ((occurred & Sock::RECV) == 0) { + // Timeout, no incoming connections within MAX_WAIT_FOR_IO. + continue; + } + + const std::string& peer_dest = + conn.sock.RecvUntilTerminator('\n', MAX_WAIT_FOR_IO, *m_interrupt); + + conn.peer = CService(DestB64ToAddr(peer_dest), Params().GetDefaultPort()); + + return true; + } + } catch (const std::runtime_error& e) { + Log("Error accepting: %s", e.what()); + CheckControlSock(); + } + return false; +} + +bool Session::Connect(const CService& to, Connection& conn, bool& proxy_error) +{ + proxy_error = true; + + std::string session_id; + Sock sock; + conn.peer = to; + + try { + { + LOCK(m_mutex); + CreateIfNotCreatedAlready(); + session_id = m_session_id; + conn.me = m_my_addr; + sock = Hello(); + } + + const Reply& lookup_reply = + SendRequestAndGetReply(sock, strprintf("NAMING LOOKUP NAME=%s", to.ToStringIP())); + + const std::string& dest = lookup_reply.Get("VALUE"); + + const Reply& connect_reply = SendRequestAndGetReply( + sock, strprintf("STREAM CONNECT ID=%s DESTINATION=%s SILENT=false", session_id, dest), + false); + + const std::string& result = connect_reply.Get("RESULT"); + + if (result == "OK") { + conn.sock = std::move(sock); + return true; + } + + if (result == "INVALID_ID") { + LOCK(m_mutex); + Disconnect(); + throw std::runtime_error("Invalid session id"); + } + + if (result == "CANT_REACH_PEER" || result == "TIMEOUT") { + proxy_error = false; + } + + throw std::runtime_error(strprintf("\"%s\"", connect_reply.full)); + } catch (const std::runtime_error& e) { + Log("Error connecting to %s: %s", to.ToString(), e.what()); + CheckControlSock(); + return false; + } +} + +// Private methods + +std::string Session::Reply::Get(const std::string& key) const +{ + const auto& pos = keys.find(key); + if (pos == keys.end() || !pos->second.has_value()) { + throw std::runtime_error( + strprintf("Missing %s= in the reply to \"%s\": \"%s\"", key, request, full)); + } + return pos->second.value(); +} + +template <typename... Args> +void Session::Log(const std::string& fmt, const Args&... args) const +{ + LogPrint(BCLog::I2P, "I2P: %s\n", tfm::format(fmt, args...)); +} + +Session::Reply Session::SendRequestAndGetReply(const Sock& sock, + const std::string& request, + bool check_result_ok) const +{ + sock.SendComplete(request + "\n", MAX_WAIT_FOR_IO, *m_interrupt); + + Reply reply; + + // Don't log the full "SESSION CREATE ..." because it contains our private key. + reply.request = request.substr(0, 14) == "SESSION CREATE" ? "SESSION CREATE ..." : request; + + // It could take a few minutes for the I2P router to reply as it is querying the I2P network + // (when doing name lookup, for example). Notice: `RecvUntilTerminator()` is checking + // `m_interrupt` more often, so we would not be stuck here for long if `m_interrupt` is + // signaled. + static constexpr auto recv_timeout = 3min; + + reply.full = sock.RecvUntilTerminator('\n', recv_timeout, *m_interrupt); + + for (const auto& kv : spanparsing::Split(reply.full, ' ')) { + const auto& pos = std::find(kv.begin(), kv.end(), '='); + if (pos != kv.end()) { + reply.keys.emplace(std::string{kv.begin(), pos}, std::string{pos + 1, kv.end()}); + } else { + reply.keys.emplace(std::string{kv.begin(), kv.end()}, std::nullopt); + } + } + + if (check_result_ok && reply.Get("RESULT") != "OK") { + throw std::runtime_error( + strprintf("Unexpected reply to \"%s\": \"%s\"", request, reply.full)); + } + + return reply; +} + +Sock Session::Hello() const +{ + auto sock = CreateSock(m_control_host); + + if (!sock) { + throw std::runtime_error("Cannot create socket"); + } + + if (!ConnectSocketDirectly(m_control_host, sock->Get(), nConnectTimeout, true)) { + throw std::runtime_error(strprintf("Cannot connect to %s", m_control_host.ToString())); + } + + SendRequestAndGetReply(*sock, "HELLO VERSION MIN=3.1 MAX=3.1"); + + return std::move(*sock); +} + +void Session::CheckControlSock() +{ + LOCK(m_mutex); + + std::string errmsg; + if (!m_control_sock.IsConnected(errmsg)) { + Log("Control socket error: %s", errmsg); + Disconnect(); + } +} + +void Session::DestGenerate(const Sock& sock) +{ + // https://geti2p.net/spec/common-structures#key-certificates + // "7" or "EdDSA_SHA512_Ed25519" - "Recent Router Identities and Destinations". + // Use "7" because i2pd <2.24.0 does not recognize the textual form. + const Reply& reply = SendRequestAndGetReply(sock, "DEST GENERATE SIGNATURE_TYPE=7", false); + + m_private_key = DecodeI2PBase64(reply.Get("PRIV")); +} + +void Session::GenerateAndSavePrivateKey(const Sock& sock) +{ + DestGenerate(sock); + + // umask is set to 077 in init.cpp, which is ok (unless -sysperms is given) + if (!WriteBinaryFile(m_private_key_file, + std::string(m_private_key.begin(), m_private_key.end()))) { + throw std::runtime_error( + strprintf("Cannot save I2P private key to %s", m_private_key_file)); + } +} + +Binary Session::MyDestination() const +{ + // From https://geti2p.net/spec/common-structures#destination: + // "They are 387 bytes plus the certificate length specified at bytes 385-386, which may be + // non-zero" + static constexpr size_t DEST_LEN_BASE = 387; + static constexpr size_t CERT_LEN_POS = 385; + + uint16_t cert_len; + memcpy(&cert_len, &m_private_key.at(CERT_LEN_POS), sizeof(cert_len)); + cert_len = be16toh(cert_len); + + const size_t dest_len = DEST_LEN_BASE + cert_len; + + return Binary{m_private_key.begin(), m_private_key.begin() + dest_len}; +} + +void Session::CreateIfNotCreatedAlready() +{ + std::string errmsg; + if (m_control_sock.IsConnected(errmsg)) { + return; + } + + Log("Creating SAM session with %s", m_control_host.ToString()); + + Sock sock = Hello(); + + const auto& [read_ok, data] = ReadBinaryFile(m_private_key_file); + if (read_ok) { + m_private_key.assign(data.begin(), data.end()); + } else { + GenerateAndSavePrivateKey(sock); + } + + const std::string& session_id = GetRandHash().GetHex().substr(0, 10); // full is an overkill, too verbose in the logs + const std::string& private_key_b64 = SwapBase64(EncodeBase64(m_private_key)); + + SendRequestAndGetReply(sock, strprintf("SESSION CREATE STYLE=STREAM ID=%s DESTINATION=%s", + session_id, private_key_b64)); + + m_my_addr = CService(DestBinToAddr(MyDestination()), Params().GetDefaultPort()); + m_session_id = session_id; + m_control_sock = std::move(sock); + + LogPrintf("I2P: SAM session created: session id=%s, my address=%s\n", m_session_id, + m_my_addr.ToString()); +} + +Sock Session::StreamAccept() +{ + Sock sock = Hello(); + + const Reply& reply = SendRequestAndGetReply( + sock, strprintf("STREAM ACCEPT ID=%s SILENT=false", m_session_id), false); + + const std::string& result = reply.Get("RESULT"); + + if (result == "OK") { + return sock; + } + + if (result == "INVALID_ID") { + // If our session id is invalid, then force session re-creation on next usage. + Disconnect(); + } + + throw std::runtime_error(strprintf("\"%s\"", reply.full)); +} + +void Session::Disconnect() +{ + if (m_control_sock.Get() != INVALID_SOCKET) { + if (m_session_id.empty()) { + Log("Destroying incomplete session"); + } else { + Log("Destroying session %s", m_session_id); + } + } + m_control_sock.Reset(); + m_session_id.clear(); +} +} // namespace sam +} // namespace i2p diff --git a/src/i2p.h b/src/i2p.h new file mode 100644 index 0000000000..8fafe0a4d0 --- /dev/null +++ b/src/i2p.h @@ -0,0 +1,260 @@ +// Copyright (c) 2020-2020 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#ifndef BITCOIN_I2P_H +#define BITCOIN_I2P_H + +#include <compat.h> +#include <fs.h> +#include <netaddress.h> +#include <sync.h> +#include <threadinterrupt.h> +#include <util/sock.h> + +#include <optional> +#include <string> +#include <unordered_map> +#include <vector> + +namespace i2p { + +/** + * Binary data. + */ +using Binary = std::vector<uint8_t>; + +/** + * An established connection with another peer. + */ +struct Connection { + /** Connected socket. */ + Sock sock; + + /** Our I2P address. */ + CService me; + + /** The peer's I2P address. */ + CService peer; +}; + +namespace sam { + +/** + * I2P SAM session. + */ +class Session +{ +public: + /** + * Construct a session. This will not initiate any IO, the session will be lazily created + * later when first used. + * @param[in] private_key_file Path to a private key file. If the file does not exist then the + * private key will be generated and saved into the file. + * @param[in] control_host Location of the SAM proxy. + * @param[in,out] interrupt If this is signaled then all operations are canceled as soon as + * possible and executing methods throw an exception. Notice: only a pointer to the + * `CThreadInterrupt` object is saved, so it must not be destroyed earlier than this + * `Session` object. + */ + Session(const fs::path& private_key_file, + const CService& control_host, + CThreadInterrupt* interrupt); + + /** + * Destroy the session, closing the internally used sockets. The sockets that have been + * returned by `Accept()` or `Connect()` will not be closed, but they will be closed by + * the SAM proxy because the session is destroyed. So they will return an error next time + * we try to read or write to them. + */ + ~Session(); + + /** + * Start listening for an incoming connection. + * @param[out] conn Upon successful completion the `sock` and `me` members will be set + * to the listening socket and address. + * @return true on success + */ + bool Listen(Connection& conn); + + /** + * Wait for and accept a new incoming connection. + * @param[in,out] conn The `sock` member is used for waiting and accepting. Upon successful + * completion the `peer` member will be set to the address of the incoming peer. + * @return true on success + */ + bool Accept(Connection& conn); + + /** + * Connect to an I2P peer. + * @param[in] to Peer to connect to. + * @param[out] conn Established connection. Only set if `true` is returned. + * @param[out] proxy_error If an error occurs due to proxy or general network failure, then + * this is set to `true`. If an error occurs due to unreachable peer (likely peer is down), then + * it is set to `false`. Only set if `false` is returned. + * @return true on success + */ + bool Connect(const CService& to, Connection& conn, bool& proxy_error); + +private: + /** + * A reply from the SAM proxy. + */ + struct Reply { + /** + * Full, unparsed reply. + */ + std::string full; + + /** + * Request, used for detailed error reporting. + */ + std::string request; + + /** + * A map of keywords from the parsed reply. + * For example, if the reply is "A=X B C=YZ", then the map will be + * keys["A"] == "X" + * keys["B"] == (empty std::optional) + * keys["C"] == "YZ" + */ + std::unordered_map<std::string, std::optional<std::string>> keys; + + /** + * Get the value of a given key. + * For example if the reply is "A=X B" then: + * Value("A") -> "X" + * Value("B") -> throws + * Value("C") -> throws + * @param[in] key Key whose value to retrieve + * @returns the key's value + * @throws std::runtime_error if the key is not present or if it has no value + */ + std::string Get(const std::string& key) const; + }; + + /** + * Log a message in the `BCLog::I2P` category. + * @param[in] fmt printf(3)-like format string. + * @param[in] args printf(3)-like arguments that correspond to `fmt`. + */ + template <typename... Args> + void Log(const std::string& fmt, const Args&... args) const; + + /** + * Send request and get a reply from the SAM proxy. + * @param[in] sock A socket that is connected to the SAM proxy. + * @param[in] request Raw request to send, a newline terminator is appended to it. + * @param[in] check_result_ok If true then after receiving the reply a check is made + * whether it contains "RESULT=OK" and an exception is thrown if it does not. + * @throws std::runtime_error if an error occurs + */ + Reply SendRequestAndGetReply(const Sock& sock, + const std::string& request, + bool check_result_ok = true) const; + + /** + * Open a new connection to the SAM proxy. + * @return a connected socket + * @throws std::runtime_error if an error occurs + */ + Sock Hello() const EXCLUSIVE_LOCKS_REQUIRED(m_mutex); + + /** + * Check the control socket for errors and possibly disconnect. + */ + void CheckControlSock(); + + /** + * Generate a new destination with the SAM proxy and set `m_private_key` to it. + * @param[in] sock Socket to use for talking to the SAM proxy. + * @throws std::runtime_error if an error occurs + */ + void DestGenerate(const Sock& sock) EXCLUSIVE_LOCKS_REQUIRED(m_mutex); + + /** + * Generate a new destination with the SAM proxy, set `m_private_key` to it and save + * it on disk to `m_private_key_file`. + * @param[in] sock Socket to use for talking to the SAM proxy. + * @throws std::runtime_error if an error occurs + */ + void GenerateAndSavePrivateKey(const Sock& sock) EXCLUSIVE_LOCKS_REQUIRED(m_mutex); + + /** + * Derive own destination from `m_private_key`. + * @see https://geti2p.net/spec/common-structures#destination + * @return an I2P destination + */ + Binary MyDestination() const EXCLUSIVE_LOCKS_REQUIRED(m_mutex); + + /** + * Create the session if not already created. Reads the private key file and connects to the + * SAM proxy. + * @throws std::runtime_error if an error occurs + */ + void CreateIfNotCreatedAlready() EXCLUSIVE_LOCKS_REQUIRED(m_mutex); + + /** + * Open a new connection to the SAM proxy and issue "STREAM ACCEPT" request using the existing + * session id. Return the idle socket that is waiting for a peer to connect to us. + * @throws std::runtime_error if an error occurs + */ + Sock StreamAccept() EXCLUSIVE_LOCKS_REQUIRED(m_mutex); + + /** + * Destroy the session, closing the internally used sockets. + */ + void Disconnect() EXCLUSIVE_LOCKS_REQUIRED(m_mutex); + + /** + * The name of the file where this peer's private key is stored (in binary). + */ + const fs::path m_private_key_file; + + /** + * The host and port of the SAM control service. + */ + const CService m_control_host; + + /** + * Cease network activity when this is signaled. + */ + CThreadInterrupt* const m_interrupt; + + /** + * Mutex protecting the members that can be concurrently accessed. + */ + mutable Mutex m_mutex; + + /** + * The private key of this peer. + * @see The reply to the "DEST GENERATE" command in https://geti2p.net/en/docs/api/samv3 + */ + Binary m_private_key GUARDED_BY(m_mutex); + + /** + * SAM control socket. + * Used to connect to the I2P SAM service and create a session + * ("SESSION CREATE"). With the established session id we later open + * other connections to the SAM service to accept incoming I2P + * connections and make outgoing ones. + * See https://geti2p.net/en/docs/api/samv3 + */ + Sock m_control_sock GUARDED_BY(m_mutex); + + /** + * Our .b32.i2p address. + * Derived from `m_private_key`. + */ + CService m_my_addr GUARDED_BY(m_mutex); + + /** + * SAM session id. + */ + std::string m_session_id GUARDED_BY(m_mutex); +}; + +} // namespace sam +} // namespace i2p + +#endif /* BITCOIN_I2P_H */ diff --git a/src/logging.cpp b/src/logging.cpp index e82f2c2810..866213786e 100644 --- a/src/logging.cpp +++ b/src/logging.cpp @@ -156,6 +156,7 @@ const CLogCategoryDesc LogCategories[] = {BCLog::QT, "qt"}, {BCLog::LEVELDB, "leveldb"}, {BCLog::VALIDATION, "validation"}, + {BCLog::I2P, "i2p"}, {BCLog::ALL, "1"}, {BCLog::ALL, "all"}, }; diff --git a/src/logging.h b/src/logging.h index 4ece8f5e3a..436f0cd12e 100644 --- a/src/logging.h +++ b/src/logging.h @@ -57,6 +57,7 @@ namespace BCLog { QT = (1 << 19), LEVELDB = (1 << 20), VALIDATION = (1 << 21), + I2P = (1 << 22), ALL = ~(uint32_t)0, }; |