From 256f84b4de586d36e381c47812d56fdd7c82a5cf Mon Sep 17 00:00:00 2001 From: Richard Holland Date: Mon, 12 Aug 2024 14:02:14 +1000 Subject: [PATCH] first partial draft of featureEmail, not finished --- src/ripple/protocol/Email.h | 493 +++++++++++++++++++++++++++ src/ripple/protocol/Feature.h | 3 +- src/ripple/protocol/STTx.h | 1 + src/ripple/protocol/TxFlags.h | 3 +- src/ripple/protocol/impl/Feature.cpp | 1 + src/ripple/protocol/impl/STTx.cpp | 54 ++- 6 files changed, 552 insertions(+), 3 deletions(-) create mode 100644 src/ripple/protocol/Email.h diff --git a/src/ripple/protocol/Email.h b/src/ripple/protocol/Email.h new file mode 100644 index 0000000000..0631c76b41 --- /dev/null +++ b/src/ripple/protocol/Email.h @@ -0,0 +1,493 @@ +#include +#include +#include +#include +#include +#include + +// make this typedef to keep dkim happy +typedef int _Bool; +#include + +using namespace ripple; +namespace Email +{ + + enum EmailType : uint8_t + { + INVALID = 0, + REMIT = 1, + REKEY = 2 + }; + + struct EmailDetails + { + std::string domain; // from address domain + std::string dkimDomain; // dkim signature domain + + AccountID from; + std::string fromEmail; + + std::optional toEmail; + std::optional to; + + EmailType emailType { EmailType::INVALID }; + std::optional amount; // only valid if REMIT type + std::optional rekey; // only valid if REKEY type + }; + + class OpenDKIM + { + private: + DKIM_STAT status; + public: + DKIM_LIB* dkim_lib; + DKIM* dkim; + + bool sane() + { + return !!dkim_lib && !!dkim; + } + + OpenDKIM() + { + // do nothing + } + + // setup is in its own function not the constructor to make failure graceful + bool setup(beast::Journal& j) + { + dkim_lib = dkim_init(nullptr, nullptr); + if (!dkim_lib) + { + JLOG(j.warn()) << "EmailAmendment: Failed to init dkim_lib."; + return false; + } + + DKIM_STAT status; + DKIM* dkim = dkim_verify(dkim_lib, (uint8_t const*)"id", nullptr, &status); + if (!dkim_lib) + { + JLOG(j.warn()) << "EmailAmendment: Failed to init dkim_verify."; + return false; + } + + return true; + } + + ~OpenDKIM() + { + if (dkim) + { + dkim_free(dkim); + dkim = nullptr; + } + + if (dkim_lib) + { + dkim_close(dkim_lib); + dkim_lib = nullptr; + } + } + }; + + inline + std::optional> + canonicalizeEmailAddress(const std::string& rawEmailAddr) + { + if (rawEmailAddr.empty()) + return {}; + + // trim + auto start = std::find_if_not(str.begin(), str.end(), ::isspace); + auto end = std::find_if_not(str.rbegin(), str.rend(), ::isspace).base(); + + if (end >= start) + return {}; + + std::email = std::string(start, end); + + if (email.empty()) + return {}; + + // to lower + std::transform(email.begin(), email.end(), email.begin(), ::tolower); + + // find the @ + size_t atPos = email.find('@'); + if (atPos == std::string::npos || atPos == email.size() - 1) + return {}; + + std::string localPart = email.substr(0, atPos); + std::string domain = email.substr(atPos + 1); + + if (domain.empty() || localPart.empty()) + return {}; + + // ensure there's only one @ + if (domain.find('@') != std::string::npos) + return {}; + + // canonicalize domain part + { + std::string result = domain; + std::transform(result.begin(), result.end(), result.begin(), ::tolower); + while (!result.empty() && result.back() == '.') + result.pop_back(); + + doamin = result; + } + + if (domain.empty()) + return {}; + + // canonicalize local part + { + std::string part = localPart; + part.erase(std::remove_if( + part.begin(), part.end(), + [](char c) { return c == '(' || c == ')' || std::isspace(c); }), part.end()); + + size_t plusPos = part.find('+'); + if (plusPos != std::string::npos) + part = part.substr(0, plusPos); + + while (!part.empty() && part.back() == '.') + part.pop_back(); + + // gmail ignores dots + if (domain == "gmail.com") + part.erase(std::remove(part.begin(), part.end(), '.'), part.end()); + + localPart = part; + } + + if (localPart.empty()) + return {}; + + return {{localPart + "@" + domain, domain}}; + }; + + // Warning: must supply already canonicalzied email + inline + std::optional + emailToAccountID(const std::string& canonicalEmail) + { + uint8_t innerHash[SHA512_DIGEST_LENGTH + 4]; + SHA512_CTX sha512; + SHA512_Init(&sha512); + SHA512_Update(&sha512, canonicalEmail.c_str(), canonicalEmail.size()); + SHA512_Final(innerHash + 4, &sha512); + innerHash[0] = 0xEEU; + innerHash[1] = 0xEEU; + innerHash[2] = 0xFFU; + innerHash[3] = 0xFFU; + { + uint8_t hash[SHA512_DIGEST_LENGTH]; + SHA512_CTX sha512; + SHA512_Init(&sha512); + SHA512_Update(&sha512, innerHash, sizeof(innerHash)); + SHA512_Final(hash, &sha512); + + return AccountID::fromVoid((void*)hash); + } + } + + + inline + std::optional + parseEmail(std::string const& rawEmail, beast::Journal& j) + { + EmailDetails out; + + // parse email into headers and body + std::vector headers; + std::string body; + { + std::istringstream stream(rawEmail); + std::string line; + + while (std::getline(stream, line)) + { + if (line.empty() || line == "\r") + break; + + // Handle header line continuations + while (stream.peek() == ' ' || stream.peek() == '\t') { + std::string continuation; + std::getline(stream, continuation); + line += '\n' + continuation; + } + if (!line.empty()) { + headers.push_back(line.substr(0, line.size() - (line.back() == '\r' ? 1 : 0))); + } + } + + std::ostringstream body_stream; + while (std::getline(stream, line)) + body_stream << line << "\n"; + body = body_stream.str(); + } + + + // find the from address, canonicalize it and extract the domain + bool foundFrom = false; + bool foundTo = false; + { + static const std::regex + from_regex(R"(^From:\s*(?:.*<)?([^<>\s]+@[^<>\s]+)(?:>)?)", std::regex::icase); + + static const std::regex + to_regex(R"(^To:\s*(?:.*<)?([^<>\s]+@[^<>\s]+)(?:>)?)", std::regex::icase); + + for (const auto& header : headers) + { + if (foundFrom && foundTo) + break; + + std::smatch match; + if (!foundFrom && std::regex_search(header, match, from_regex) && match.size() > 1) + { + auto canon = canonicalizeEmailAddress(match[1].str()); + if (!canon) + { + JLOG(j.warn()) + << "EmailAmendment: Cannot parse From address: `" + << match[1].str() << "`"; + return {}; + } + + out.fromEmail = canon->first; + out.domain = canon->second; + out.from = emailToAccountID(out.fromEmail); + foundFrom = true; + + continue; + } + + if (std::regex_search(header, match, to_regex) && match.size() > 1) + { + auto canon = canonicalizeEmailAddress(match[1].str()); + if (!canon) + { + JLOG(j.warn()) + << "EmailAmendment: Cannot parse To address: `" + << match[1].str() << "`"; + return {}; + } + + out.toEmail = canon->first; + out.to = emailToAccountID(out.toEmail); + + foundTo = true; + continue; + } + } + + if (!foundFrom) + { + JLOG(j.warn()) << "EmailAmendment: No From address present in email."; + return {}; + } + } + + // execution to here means we have: + // 1. Parsed headers and body + // 2. Found a from address and canonicalzied it + // 3. Potentially found a to address and canonicalized it. + + // Find instructions + { + static const std::regex + remitPattern(R"(^REMIT (\d+(?:\.\d+)?) ([A-Z]{3})(?:/([r][a-zA-Z0-9]{24,34}))?)"); + + static const std::regex + rekeyPattern(R"(^REKEY ([r][a-zA-Z0-9]{24,34}))"); + + std::istringstream stream(body); + std::string line; + + out.emailType = EmailType::INVALID; + + while (std::getline(stream, line, '\n')) + { + if (!line.empty() && line.back() == '\r') + line.pop_back(); // Remove '\r' if present + + std::smatch match; + if (std::regex_match(line, match, remitPattern)) + { + try + { + Currency cur; + if (!to_currency(cur, match[2])) + { + JLOG(j.warn()) << "EmailAmendment: Could not parse currency code."; + return {}; + } + + + AccountID issuer = noAccount(); + if (match[3].matched) + { + if (isXRP(cur)) + { + JLOG(j.warn()) << "EmailAmendment: Native currency cannot specify issuer."; + return {}; + } + + issuer = decodeBase58Token(match[3], TokenType::AccountID); + if (issuer.empty()) + { + JLOG(j.warn()) << "EmailAmendment: Could not parse issuer address."; + return {}; + } + } + + out.amount = amountFromString({cur, issuer}, match[1]); + + } + catch (std::exception const& e) + { + JLOG(j.warn()) << "EmailAmendment: Exception while parsing REMIT. " << e.what(); + return {}; + } + + out.emailType = EmailType::REMIT; + break; + } + + if (std::regex_match(line, match, rekeyPattern)) + { + AccountID rekey = decodeBase58Token(match[1], TokenType::AccountID); + if (rekey.empty()) + { + JLOG(j.warn()) << "EmailAmendment: Could not parse rekey address."; + return {}; + } + + out.rekey = rekey; + out.emailType = EmailType::REKEY; + break; + } + } + + if (out.emailType == EmailType::INVALID) + { + JLOG(j.warn()) << "EmailAmendment: Invalid email type, could not find REMIT or REKEY."; + return{}; + } + } + + + // perform DKIM checks... + // to do this we will use OpenDKIM, and manage it with a smart pointer to prevent + // any leaks from uncommon exit pathways + + std::unique odkim; + + // perform setup + if (!odkim->setup(j) || !odkim->sane()) + return {}; + + // when odkim goes out of scope it will call the C-apis to destroy the dkim instances + + DKIM_STAT status; + DKIM_LIB* dkim_lib = odkim->dkim_lib; + DKIM* dkim = odkim->dkim; + + // feed opendkim all headers + { + for (const auto& header : headers) + { + status = dkim_header(dkim, (uint8_t*)header.c_str(), header.length()); + if (status != DKIM_STAT_OK) + { + JLOG(j.warn()) + << "EmailAmendment: OpenDKIM Failed to process header: " + << dkim_geterror(dkim); + return {}; + } + } + + status = dkim_eoh(dkim); + if (status != DKIM_STAT_OK) + { + JLOG(j.warn()) + << "EmailAmendment: OpenDKIM Failed to send end-of-headers"l + return {}; + } + } + + // feed opendkim email body + { + status = dkim_body(dkim, (uint8_t*)body.c_str(), body.size()); + if (status != DKIM_STAT_OK) + { + JLOG(j.warn()) + << "EmailAmendment: OpenDKIM Failed to process body: " + << dkim_geterror(dkim); + return {}; + } + + _Bool testkey; + status = dkim_eom(dkim, &testkey); + if (status != DKIM_STAT_OK) + { + JLOG(j.warn()) + << "EmailAmendment: OpenDKIM end-of-message error: " + << dkim_geterror(dkim); + return {}; + } + + DKIM_SIGINFO* sig = dkim_getsignature(dkim); + if (!sig) + { + JLOG(j.warn()) + << "EmailAmendment: No DKIM signature found"; + return {}; + } + + if (dkim_sig_getbh(sig) != DKIM_SIGBH_MATCH) + { + JLOG(j.warn()) + << "EmailAmendment: DKIM body hash mismatch"; + return {}; + } + + + DKIM_SIGINFO* sig = dkim_getsignature(dkim); + if (!sig) + { + JLOG(j.warn()) + << "EmailAmendment: DKIM signature not found."; + return {}; + } + + out.dkimDomain = + std::string(reinterpret_cast( + reinterpret_cast(dkim_sig_getdomain(sig)))); + + if (out.dkimDomain.empty()) + { + JLOG(j.warn()) + << "EmailAmendment: DKIM signature domain empty."; + return {}; + } + + // RH TODO: decide whether to relax this or not + // strict domain check + if (out.dkimDomain != out.domain) + { + JLOG(j.warn()) + << "EmailAmendment: DKIM domain does not match From address domain."; + return {}; + } + } + + // execution to here means all checks passed and the instruction was correctly parsed + return out; + } + + +} diff --git a/src/ripple/protocol/Feature.h b/src/ripple/protocol/Feature.h index 2d46df8768..a1bef7738c 100644 --- a/src/ripple/protocol/Feature.h +++ b/src/ripple/protocol/Feature.h @@ -74,7 +74,7 @@ namespace detail { // Feature.cpp. Because it's only used to reserve storage, and determine how // large to make the FeatureBitset, it MAY be larger. It MUST NOT be less than // the actual number of amendments. A LogicError on startup will verify this. -static constexpr std::size_t numFeatures = 70; +static constexpr std::size_t numFeatures = 71; /** Amendments that this server supports and the default voting behavior. Whether they are enabled depends on the Rules defined in the validated @@ -358,6 +358,7 @@ extern uint256 const fixXahauV2; extern uint256 const featureRemit; extern uint256 const featureZeroB2M; extern uint256 const fixNSDelete; +extern uint256 const featureEmail; } // namespace ripple diff --git a/src/ripple/protocol/STTx.h b/src/ripple/protocol/STTx.h index c6a9e053c3..f340e1c96f 100644 --- a/src/ripple/protocol/STTx.h +++ b/src/ripple/protocol/STTx.h @@ -30,6 +30,7 @@ #include #include #include +#include namespace ripple { diff --git a/src/ripple/protocol/TxFlags.h b/src/ripple/protocol/TxFlags.h index b125582005..76adef50ca 100644 --- a/src/ripple/protocol/TxFlags.h +++ b/src/ripple/protocol/TxFlags.h @@ -57,8 +57,9 @@ namespace ripple { // Universal Transaction flags: enum UniversalFlags : uint32_t { tfFullyCanonicalSig = 0x80000000, + tfEmailSig = 0x40000000, }; -constexpr std::uint32_t tfUniversal = tfFullyCanonicalSig; +constexpr std::uint32_t tfUniversal = tfFullyCanonicalSig | tfEmailSig; constexpr std::uint32_t tfUniversalMask = ~tfUniversal; // AccountSet flags: diff --git a/src/ripple/protocol/impl/Feature.cpp b/src/ripple/protocol/impl/Feature.cpp index 9cf82e3168..98f8cdab71 100644 --- a/src/ripple/protocol/impl/Feature.cpp +++ b/src/ripple/protocol/impl/Feature.cpp @@ -464,6 +464,7 @@ REGISTER_FIX (fixXahauV2, Supported::yes, VoteBehavior::De REGISTER_FEATURE(Remit, Supported::yes, VoteBehavior::DefaultNo); REGISTER_FEATURE(ZeroB2M, Supported::yes, VoteBehavior::DefaultNo); REGISTER_FIX (fixNSDelete, Supported::yes, VoteBehavior::DefaultNo); +REGISTER_FEATURE(Email, Supported::yes, VoteBehavior::DefaultNo); // The following amendments are obsolete, but must remain supported // because they could potentially get enabled. diff --git a/src/ripple/protocol/impl/STTx.cpp b/src/ripple/protocol/impl/STTx.cpp index cfa3503815..33ab2194f6 100644 --- a/src/ripple/protocol/impl/STTx.cpp +++ b/src/ripple/protocol/impl/STTx.cpp @@ -304,9 +304,60 @@ STTx::checkSingleSign(RequireFullyCanonicalSig requireCanonicalSig) const bool const isWildcardNetwork = isFieldPresent(sfNetworkID) && getFieldU32(sfNetworkID) == 65535; + // email signature flag signals that the txn is authorized + // only by the presence of a DKIM signed email in memos[0] + + bool const isEmailSig = + getFlags() & tfEmailSig; + + bool validSig = false; + do try { + + if (isEmailSig) + { + + if (!isFieldPresent(sfMemos)) + break; + + auto const& memos = st.getFieldArray(sfMemos); + auto const& memo = memos[0]; + auto memoObj = dynamic_cast(&memo); + if (!memoObj || (memoObj->getFName() != sfMemo)) + break; + + bool emailValid = false; + + for (auto const& memoElement : *memoObj) + { + auto const& name = memoElement.getFName(); + + if (name != sfMemoType && name != sfMemoData && + name != sfMemoFormat) + break; + + // The raw data is stored as hex-octets, which we want to decode. + std::optional optData = strUnHex(memoElement.getText()); + + if (!optData) + break; + + if (name != sfMemoData) + continue; + + std::string const emailContent((char const*)(optData->data()), optData->size()); + + + // RH UPTO + + + } + } + + } + bool const fullyCanonical = (getFlags() & tfFullyCanonicalSig) || (requireCanonicalSig == RequireFullyCanonicalSig::yes); @@ -328,7 +379,8 @@ STTx::checkSingleSign(RequireFullyCanonicalSig requireCanonicalSig) const { // Assume it was a signature failure. validSig = false; - } + } while (0); + if (validSig == false) return Unexpected("Invalid signature."); // Signature was verified.