diff --git a/.clang-tidy b/.clang-tidy index 02e90d9148..8418b22af4 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -121,6 +121,6 @@ CheckOptions: readability-identifier-naming.PublicMemberSuffix: "" readability-identifier-naming.GlobalFunctionIgnoredRegexp: "^(to_string|hash_append|tuple_hash)$" -HeaderFilterRegex: '^.*/(tests?|xrpl|xrpld)/.*\.(h|hpp|ipp)$' +HeaderFilterRegex: '^.*/(tests?|tools|xrpl|xrpld)/.*\.(h|hpp|ipp)$' ExcludeHeaderFilterRegex: '^.*/protocol_autogen/.*\.(h|hpp)$' WarningsAsErrors: "*" diff --git a/.cspell.config.yaml b/.cspell.config.yaml index c1af739255..e3e95af4f7 100644 --- a/.cspell.config.yaml +++ b/.cspell.config.yaml @@ -67,9 +67,10 @@ words: - Britto - Btrfs - Buildx - - canonicality - canonicalised + - canonicality - cctools + - CGNAT - changespq - checkme - choco @@ -137,9 +138,11 @@ words: - gpgkey - Hinnant - hotwallet + - hvssbqmgz - hwaddress - hwrap - ifndef + - Iiwib - inequation - insuf - insuff @@ -152,6 +155,7 @@ words: - jemalloc - jlog - jtnofill + - keyfile - keylet - keylets - keyvadb @@ -177,11 +181,10 @@ words: - mathbunnyru - mcmodel - MEMORYSTATUSEX - - MPTAMM - - MPTDEX - Merkle - misprediction - missingok + - MPTAMM - mptbalance - MPTDEX - mptflags @@ -256,8 +259,8 @@ words: - replayer - repodata - repomd - - rerandomize - rerandomization + - rerandomize - rerandomized - rerandomizes - rerere @@ -280,8 +283,8 @@ words: - rustup - sahyadri - Satoshi - - scons - Schnorr + - scons - secp - sendq - seqit @@ -311,6 +314,7 @@ words: - stobject - stpath - stpathset + - STRINGIZE - sttx - stvar - stvector @@ -346,6 +350,7 @@ words: - unfindable - unflatten - unfund + - ungated - unimpair - unroutable - unscalable @@ -385,5 +390,3 @@ words: - xxhash - xxhasher - zstdio - - CGNAT - - ungated diff --git a/.github/scripts/rename/cmake.sh b/.github/scripts/rename/cmake.sh index 3539f563e0..91e9a288c3 100755 --- a/.github/scripts/rename/cmake.sh +++ b/.github/scripts/rename/cmake.sh @@ -61,9 +61,6 @@ ${SED_COMMAND} -i 's/ripple.pb.h/xrpl.pb.h/' include/xrpl/protocol/messages.h ${SED_COMMAND} -i 's/ripple.pb.h/xrpl.pb.h/' BUILD.md ${SED_COMMAND} -i 's/ripple.pb.h/xrpl.pb.h/' BUILD.md -# Restore the name of the validator keys repository. -${SED_COMMAND} -i 's@xrpl/validator-keys-tool@ripple/validator-keys-tool@' cmake/XrplValidatorKeys.cmake - # Ensure the name of the binary and config remain 'rippled' for now. ${SED_COMMAND} -i -E 's/xrpld(-example)?\.cfg/rippled\1.cfg/g' cmake/XrplInstall.cmake if grep -q '"xrpld"' cmake/XrplCore.cmake; then diff --git a/cmake/XrplValidatorKeys.cmake b/cmake/XrplValidatorKeys.cmake index 0acaed1a56..428a8f86b8 100644 --- a/cmake/XrplValidatorKeys.cmake +++ b/cmake/XrplValidatorKeys.cmake @@ -1,6 +1,6 @@ option( validator_keys - "Enables building of validator-keys tool as a separate target (imported via FetchContent)" + "Enables building of the validator-keys tool as a separate target" OFF ) @@ -9,33 +9,17 @@ if(validator_keys) # having pulled this in first. include(GNUInstallDirs) - # Pinned to an exact commit, not a branch: the tool ships inside our - # packages, so the same xrpld version must always package the same - # validator-keys. Bump this deliberately. - set(validator_keys_commit "4c0fb75eec9601c711645998c904507e87e910ae") - message(STATUS "Using ValidatorKeys commit: ${validator_keys_commit}") - - FetchContent_Declare( - validator_keys - GIT_REPOSITORY https://github.com/ripple/validator-keys-tool.git - GIT_TAG "${validator_keys_commit}" - ) - FetchContent_MakeAvailable(validator_keys) - # The tool's own CMakeLists excludes the target from 'all' when it is built - # as a subproject. Undo that, so validator_keys=ON really does build it. + add_subdirectory(src/tools/validator-keys) set_target_properties( validator-keys - PROPERTIES - RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}" - EXCLUDE_FROM_ALL OFF - EXCLUDE_FROM_DEFAULT_BUILD OFF + PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}" ) # We ship this binary, so like xrpld it must not keep the Nix store's ELF # loader, or it cannot run on the target distro at all. patch_nix_binary(validator-keys) configure_file( - "${validator_keys_SOURCE_DIR}/LICENSE" + "${CMAKE_SOURCE_DIR}/src/tools/validator-keys/LICENSE" "${CMAKE_BINARY_DIR}/validator-keys-LICENSE" COPYONLY ) diff --git a/package/README.md b/package/README.md index 6e88309ecd..c185cdca0c 100644 --- a/package/README.md +++ b/package/README.md @@ -73,9 +73,10 @@ artifacts (`xrpld_artifact_name` and `validator_keys_artifact_name`) after that same config, so a packaged config must keep `-Dvalidator_keys=ON`. Those configs are not `minimal`, so `on-pr.yml` only packages once a PR runs the full matrix. -`validator-keys` is fetched from an exact commit pinned in +`validator-keys` is built from the source in +[`src/tools/validator-keys`](../src/tools/validator-keys), enabled by [`cmake/XrplValidatorKeys.cmake`](../cmake/XrplValidatorKeys.cmake), so a given -`xrpld` version always packages the same tool; bump that commit deliberately. +`xrpld` version always packages the tool from the same tree. ### Locally (mirrors CI) diff --git a/package/build_pkg.py b/package/build_pkg.py index 1aaf53d5ff..7c430ec3ee 100755 --- a/package/build_pkg.py +++ b/package/build_pkg.py @@ -98,8 +98,8 @@ def check_binaries(build_dir: Path) -> None: # No package goes out without the attribution. notice = build_dir / "validator-keys-LICENSE" assert notice.is_file(), ( - f"missing {notice}. cmake/XrplValidatorKeys.cmake copies it out of the " - "fetched validator-keys-tool source, so reconfigure with -Dvalidator_keys=ON." + f"missing {notice}. cmake/XrplValidatorKeys.cmake copies it from " + "src/tools/validator-keys, so reconfigure with -Dvalidator_keys=ON." ) # Catches a binary still pointing at the Nix store's ELF loader, since diff --git a/src/tools/validator-keys/CMakeLists.txt b/src/tools/validator-keys/CMakeLists.txt new file mode 100644 index 0000000000..db9a66c6e0 --- /dev/null +++ b/src/tools/validator-keys/CMakeLists.txt @@ -0,0 +1,23 @@ +# The validator-keys tool: validator and publisher key files, manifests, +# tokens, revocations and validator-list signing. Built only when the +# validator_keys option is ON (see cmake/XrplValidatorKeys.cmake). +add_executable(validator-keys) +target_sources( + validator-keys + PRIVATE + ListSigning.cpp + SigningKeys.cpp + ValidatorKeysTool.cpp + # Unit tests run with `validator-keys --unittest`. + test/ListSigning_test.cpp + test/SigningKeys_test.cpp + test/ValidatorKeysTool_test.cpp +) +target_include_directories( + validator-keys + PRIVATE $ +) +target_link_libraries( + validator-keys + PRIVATE Xrpl::boost Xrpl::opts Xrpl::libs xrpl.libxrpl +) diff --git a/src/tools/validator-keys/LICENSE b/src/tools/validator-keys/LICENSE new file mode 100644 index 0000000000..a176dc8cfc --- /dev/null +++ b/src/tools/validator-keys/LICENSE @@ -0,0 +1,77 @@ +The accompanying files under various copyrights. + +Copyright (c) 2016 Ripple Labs Inc. + +Permission to use, copy, modify, and distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +The accompanying files incorporate work covered by the following copyright +and previous license notice: + +Copyright (c) 2011 Arthur Britto, David Schwartz, Jed McCaleb, +Vinnie Falco, Bob Way, Eric Lombrozo, Nikolaos D. Bougalis, Howard Hinnant + +Some code from Raw Material Software, Ltd., provided under the terms of the + ISC License. See the corresponding source files for more details. + Copyright (c) 2013 - Raw Material Software Ltd. + Please visit http://www.juce.com + +Some code from ASIO examples: +// Copyright (c) 2003-2011 Christopher M. Kohlhoff (chris at kohlhoff dot com) +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) + +Some code from Bitcoin: +// Copyright (c) 2009-2010 Satoshi Nakamoto +// Copyright (c) 2011 The Bitcoin developers +// Distributed under the MIT/X11 software license, see the accompanying +// file license.txt or http://www.opensource.org/licenses/mit-license.php. + +Some code from Tom Wu: +This software is covered under the following copyright: + +/* + * Copyright (c) 2003-2005 Tom Wu + * All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS-IS" AND WITHOUT WARRANTY OF ANY KIND, + * EXPRESS, IMPLIED OR OTHERWISE, INCLUDING WITHOUT LIMITATION, ANY + * WARRANTY OF MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. + * + * IN NO EVENT SHALL TOM WU BE LIABLE FOR ANY SPECIAL, INCIDENTAL, + * INDIRECT OR CONSEQUENTIAL DAMAGES OF ANY KIND, OR ANY DAMAGES WHATSOEVER + * RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER OR NOT ADVISED OF + * THE POSSIBILITY OF DAMAGE, AND ON ANY THEORY OF LIABILITY, ARISING OUT + * OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + * + * In addition, the following condition applies: + * + * All redistributions must retain an intact copy of this copyright notice + * and disclaimer. + */ + +Address all questions regarding this license to: + + Tom Wu + tjw@cs.Stanford.EDU diff --git a/src/tools/validator-keys/ListSigning.cpp b/src/tools/validator-keys/ListSigning.cpp new file mode 100644 index 0000000000..cc2f9afef0 --- /dev/null +++ b/src/tools/validator-keys/ListSigning.cpp @@ -0,0 +1,466 @@ +#include + +#include +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include + +namespace xrpl { + +namespace { + +// A version 2 list document holds at most this many blobs. +constexpr std::size_t kMaxBlobs = 5; + +[[nodiscard]] std::string +readFile(boost::filesystem::path const& file) +{ + std::ifstream in(file.c_str(), std::ios::in | std::ios::binary); + if (!in) + throw std::runtime_error("Failed to open file: " + file.string()); + return std::string(std::istreambuf_iterator(in), std::istreambuf_iterator()); +} + +// The base64 lines of a config-style block, without its section header or +// comment lines. +[[nodiscard]] std::vector +base64Lines(std::string const& text) +{ + std::vector lines; + std::vector raw; + boost::split(raw, text, boost::is_any_of("\n")); + for (auto line : raw) + { + boost::trim(line); + if (line.empty() || line.front() == '#' || line.front() == '[') + continue; + lines.push_back(line); + } + return lines; +} + +[[nodiscard]] std::optional +parseHexKey(std::string const& hex) +{ + auto const bytes = strUnHex(hex); + if (!bytes) + return std::nullopt; + auto const slice = makeSlice(*bytes); + if (!publicKeyType(slice)) + return std::nullopt; + return PublicKey(slice); +} + +[[nodiscard]] std::optional +parseManifest(std::string const& base64) +{ + auto m = deserializeManifest(base64Decode(base64)); + if (!m || !m->verify()) + return std::nullopt; + return m; +} + +[[nodiscard]] std::optional +uintField(json::Value const& obj, char const* name) +{ + if (!obj.isMember(name) || !obj[name].isIntegral() || obj[name].asInt() < 0) + return std::nullopt; + return obj[name].asUInt(); +} + +} // namespace + +ValidatorToken +loadTokenFile(boost::filesystem::path const& tokenFile) +{ + auto const token = loadValidatorToken(base64Lines(readFile(tokenFile))); + if (!token) + throw std::runtime_error("Not a validator token: " + tokenFile.string()); + return *token; +} + +Manifest +loadManifestFile(boost::filesystem::path const& manifestFile) +{ + auto const lines = base64Lines(readFile(manifestFile)); + std::string base64; + for (auto const& line : lines) + base64 += line; + auto m = parseManifest(base64); + if (!m) + throw std::runtime_error("Not a valid manifest: " + manifestFile.string()); + return std::move(*m); +} + +std::string +canonicalJson(std::string const& text) +{ + // Reject malformed text before whitespace is moved. + { + json::Reader reader; + json::Value parsed; + if (!reader.parse(text, parsed) || !parsed.isObject()) + throw std::runtime_error("Not a JSON object"); + } + + std::string out; + out.reserve(text.size()); + bool inString = false; + bool escaped = false; + for (char const c : text) + { + if (inString) + { + out += c; + if (escaped) + escaped = false; + else if (c == '\\') + escaped = true; + else if (c == '"') + inString = false; + continue; + } + switch (c) + { + case ' ': + case '\t': + case '\n': + case '\r': + break; + case '"': + inString = true; + out += c; + break; + case ',': + out += ", "; + break; + case ':': + out += ": "; + break; + default: + out += c; + } + } + return out; +} + +UnsignedList +parseUnsignedList(std::string const& text) +{ + UnsignedList list; + list.canonical = canonicalJson(text); + + json::Reader reader; + json::Value jv; + reader.parse(list.canonical, jv); + + auto const sequence = uintField(jv, jss::sequence); + if (!sequence || *sequence == 0) + throw std::runtime_error("\"sequence\" must be a positive integer"); + list.sequence = *sequence; + + auto const expiration = uintField(jv, jss::expiration); + if (!expiration) + throw std::runtime_error("\"expiration\" must be an unsigned integer"); + list.expiration = *expiration; + + if (jv.isMember(jss::effective)) + { + auto const effective = uintField(jv, jss::effective); + if (!effective) + throw std::runtime_error("\"effective\" must be an unsigned integer"); + if (*effective >= list.expiration) + throw std::runtime_error("\"effective\" must be earlier than \"expiration\""); + list.effective = effective; + } + + if (!jv.isMember(jss::validators) || !jv[jss::validators].isArray() || + jv[jss::validators].size() == 0) + throw std::runtime_error("\"validators\" must be a non-empty array"); + + for (auto const& entry : jv[jss::validators]) + { + if (!entry.isObject() || !entry.isMember(jss::validation_public_key) || + !entry[jss::validation_public_key].isString()) + throw std::runtime_error("every validator needs a \"validation_public_key\" string"); + + auto const key = parseHexKey(entry[jss::validation_public_key].asString()); + if (!key) + throw std::runtime_error( + "\"validation_public_key\" is not a hex public key: " + + entry[jss::validation_public_key].asString()); + + if (entry.isMember(jss::manifest)) + { + if (!entry[jss::manifest].isString()) + throw std::runtime_error( + "\"manifest\" must be a base64 string for " + + entry[jss::validation_public_key].asString()); + auto const m = parseManifest(entry[jss::manifest].asString()); + if (!m) + throw std::runtime_error( + "\"manifest\" does not verify for " + + entry[jss::validation_public_key].asString()); + if (m->masterKey != *key) + throw std::runtime_error( + "\"manifest\" belongs to another key than " + + entry[jss::validation_public_key].asString()); + } + + list.validators.push_back(*key); + } + + return list; +} + +UnsignedList +loadUnsignedList(boost::filesystem::path const& file) +{ + return parseUnsignedList(readFile(file)); +} + +std::string +signList(UnsignedList const& list, PublicKey const& signingKey, SecretKey const& signingSecret) +{ + return strHex(sign(signingKey, signingSecret, makeSlice(list.canonical))); +} + +json::Value +makeSignedList( + std::string const& manifestBase64, + PublicKey const& masterKey, + UnsignedList const& list, + std::string const& signatureHex, + unsigned version, + std::optional const& append) +{ + auto const blob = base64Encode(list.canonical); + + if (version == 1) + { + if (append) + throw std::runtime_error("A version 1 list holds one blob; use version 2 to append"); + json::Value jv(json::ValueType::Object); + jv[jss::blob] = blob; + jv[jss::manifest] = manifestBase64; + jv[jss::public_key] = strHex(masterKey); + jv[jss::signature] = signatureHex; + jv[jss::version] = 1; + return jv; + } + + if (version != 2) + throw std::runtime_error("Unsupported list version"); + + json::Value jv(json::ValueType::Object); + if (append) + { + auto const& existing = *append; + if (!existing.isObject() || !existing.isMember(jss::version) || + !existing[jss::version].isIntegral() || existing[jss::version].asUInt() != 2 || + !existing.isMember(jss::blobs_v2) || !existing[jss::blobs_v2].isArray()) + throw std::runtime_error("The list to append to is not a version 2 list"); + if (!existing.isMember(jss::public_key) || !existing[jss::public_key].isString() || + !boost::iequals(existing[jss::public_key].asString(), strHex(masterKey))) + throw std::runtime_error("The list to append to belongs to another master key"); + if (existing[jss::blobs_v2].size() >= kMaxBlobs) + throw std::runtime_error( + "The list to append to already holds " + std::to_string(kMaxBlobs) + " blobs"); + jv = existing; + } + else + { + jv[jss::blobs_v2] = json::Value(json::ValueType::Array); + } + + jv[jss::manifest] = manifestBase64; + jv[jss::public_key] = strHex(masterKey); + jv[jss::version] = 2; + + json::Value entry(json::ValueType::Object); + entry[jss::blob] = blob; + entry[jss::signature] = signatureHex; + jv[jss::blobs_v2].append(entry); + return jv; +} + +std::uint32_t +rippleEpochNow() +{ + using namespace std::chrono; + auto const since1970 = duration_cast(system_clock::now().time_since_epoch()); + return static_cast((since1970 - kEpochOffset).count()); +} + +ListVerification +verifyList( + json::Value const& list, + std::optional const& expectedRoster, + std::optional const& expectedKey, + std::uint32_t now) +{ + ListVerification result; + result.report = json::Value(json::ValueType::Object); + auto fail = [&result](std::string const& error) { + result.ok = false; + result.errors.push_back(error); + }; + + if (!list.isObject()) + { + fail("the list is not a JSON object"); + return result; + } + + auto const version = uintField(list, jss::version); + if (!version || (*version != 1 && *version != 2)) + { + fail("\"version\" must be 1 or 2"); + return result; + } + result.report[jss::version] = *version; + + if (!list.isMember(jss::public_key) || !list[jss::public_key].isString() || + !list.isMember(jss::manifest) || !list[jss::manifest].isString()) + { + fail("\"public_key\" and \"manifest\" must be strings"); + return result; + } + + auto const manifest = parseManifest(list[jss::manifest].asString()); + if (!manifest) + { + fail("\"manifest\" does not deserialize and verify"); + return result; + } + result.report[jss::public_key] = strHex(manifest->masterKey); + result.report["manifest_sequence"] = manifest->sequence; + + if (manifest->revoked() || !manifest->signingKey) + { + fail("the publisher's master key is revoked"); + return result; + } + result.report["signing_key"] = strHex(*manifest->signingKey); + + { + auto const declared = parseHexKey(list[jss::public_key].asString()); + if (!declared || *declared != manifest->masterKey) + fail("\"public_key\" is not the manifest's master key"); + } + + if (expectedKey && *expectedKey != manifest->masterKey) + fail("the master key is not the expected key"); + + // The blobs of either version as (blob, signature) pairs. + std::vector> blobs; + if (*version == 1) + { + if (!list.isMember(jss::blob) || !list[jss::blob].isString() || + !list.isMember(jss::signature) || !list[jss::signature].isString() || + list.isMember(jss::blobs_v2)) + { + fail("a version 1 list needs \"blob\" and \"signature\" and no \"blobs_v2\""); + return result; + } + blobs.emplace_back(list[jss::blob].asString(), list[jss::signature].asString()); + } + else + { + if (!list.isMember(jss::blobs_v2) || !list[jss::blobs_v2].isArray() || + list[jss::blobs_v2].size() == 0 || list[jss::blobs_v2].size() > kMaxBlobs || + list.isMember(jss::blob) || list.isMember(jss::signature)) + { + fail( + "a version 2 list needs 1 to " + std::to_string(kMaxBlobs) + + " \"blobs_v2\" entries and no top-level \"blob\""); + return result; + } + for (auto const& entry : list[jss::blobs_v2]) + { + if (!entry.isObject() || !entry.isMember(jss::blob) || !entry[jss::blob].isString() || + !entry.isMember(jss::signature) || !entry[jss::signature].isString()) + { + fail("every \"blobs_v2\" entry needs \"blob\" and \"signature\""); + return result; + } + if (entry.isMember(jss::manifest)) + { + if (!entry[jss::manifest].isString()) + { + fail("a \"blobs_v2\" entry's \"manifest\" must be a string"); + return result; + } + auto const m = parseManifest(entry[jss::manifest].asString()); + if (!m || m->masterKey != manifest->masterKey) + fail("a \"blobs_v2\" entry's \"manifest\" is not this publisher's"); + } + blobs.emplace_back(entry[jss::blob].asString(), entry[jss::signature].asString()); + } + } + + result.report["blobs"] = json::Value(json::ValueType::Array); + std::size_t index = 0; + for (auto const& [blob, signature] : blobs) + { + auto const where = "blob " + std::to_string(index++); + json::Value entry(json::ValueType::Object); + + auto const sig = strUnHex(signature); + auto const data = base64Decode(blob); + if (!sig || !verify(*manifest->signingKey, makeSlice(data), makeSlice(*sig))) + fail(where + ": the signature does not verify under the signing key"); + + std::optional parsed; + try + { + parsed = parseUnsignedList(data); + } + catch (std::runtime_error const& e) + { + fail(where + ": " + e.what()); + } + + if (parsed) + { + entry[jss::sequence] = parsed->sequence; + if (parsed->effective) + entry[jss::effective] = *parsed->effective; + entry[jss::expiration] = parsed->expiration; + entry[jss::validators] = json::UInt(parsed->validators.size()); + entry["expired"] = parsed->expiration <= now; + + if (parsed->expiration <= now) + fail(where + ": expired"); + + if (expectedRoster) + { + std::set const have( + parsed->validators.begin(), parsed->validators.end()); + std::set const want( + expectedRoster->validators.begin(), expectedRoster->validators.end()); + if (have != want) + fail(where + ": the validators differ from the expected list"); + } + } + + result.report["blobs"].append(entry); + } + + result.report["ok"] = result.ok; + result.report["errors"] = json::Value(json::ValueType::Array); + for (auto const& e : result.errors) + result.report["errors"].append(e); + return result; +} + +} // namespace xrpl diff --git a/src/tools/validator-keys/ListSigning.h b/src/tools/validator-keys/ListSigning.h new file mode 100644 index 0000000000..9d0d7da4e5 --- /dev/null +++ b/src/tools/validator-keys/ListSigning.h @@ -0,0 +1,149 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace boost { +namespace filesystem { +class path; +} +} // namespace boost + +namespace xrpl { + +/** + * Reads a token from a file holding the [validator_token] block as + * `create_token` prints it. The section header and `#` comment lines are + * ignored and the base64 lines are joined. + * + * @throws std::runtime_error if the file cannot be read or is not a token + */ +ValidatorToken +loadTokenFile(boost::filesystem::path const& tokenFile); + +/** + * Reads a base64 manifest from a file. Comment lines and line breaks are + * ignored. + * + * @throws std::runtime_error if the file cannot be read or the manifest does + * not deserialize and verify + */ +Manifest +loadManifestFile(boost::filesystem::path const& manifestFile); + +/** + * A validator list before it is signed: the canonical bytes the signing key + * signs, and the fields checked before signing. + */ +struct UnsignedList +{ + // Canonical JSON text: compact, `, ` and `: ` separators, key order as + // written. + std::string canonical; + std::uint32_t sequence = 0; + std::optional effective; + std::uint32_t expiration = 0; + // Master keys of the listed validators. + std::vector validators; +}; + +/** + * Returns the canonical form of a JSON document: whitespace outside strings + * removed, one space after each `,` and `:`, key order preserved. + * + * @throws std::runtime_error if the text is not a JSON document + */ +std::string +canonicalJson(std::string const& text); + +/** + * Parses an unsigned list and checks its fields: `sequence` and `expiration` + * are unsigned integers, `effective` if present is earlier than `expiration`, + * and every entry of `validators` has a `validation_public_key` that is a hex + * public key and, if present, a `manifest` for that key. + * + * @throws std::runtime_error naming the first failed check + */ +UnsignedList +parseUnsignedList(std::string const& text); + +/** + * Reads and parses an unsigned list file. + * + * @throws std::runtime_error if the file cannot be read or fails a check + */ +UnsignedList +loadUnsignedList(boost::filesystem::path const& file); + +/** + * Returns the hex signature of the list's canonical bytes. + */ +std::string +signList(UnsignedList const& list, PublicKey const& signingKey, SecretKey const& signingSecret); + +/** + * Builds the document a publisher serves. + * + * Version 1 is `{blob, manifest, public_key, signature, version}`. Version 2 + * carries the blob and signature inside `blobs_v2`; when @p append is given it + * must be a version 2 document for the same master key and the new blob is + * added to it. + * + * @throws std::runtime_error if @p append is not a version 2 document for + * @p masterKey or already holds the maximum number of blobs + */ +json::Value +makeSignedList( + std::string const& manifestBase64, + PublicKey const& masterKey, + UnsignedList const& list, + std::string const& signatureHex, + unsigned version, + std::optional const& append); + +/** + * Seconds since the XRP Ledger epoch, now. + */ +std::uint32_t +rippleEpochNow(); + +/** + * The outcome of checking a published list. + */ +struct ListVerification +{ + bool ok = true; + std::vector errors; + // What was found: master key, signing key, manifest sequence, version, + // and one entry per blob. + json::Value report; +}; + +/** + * Checks a published list the way a server does before trusting it: the + * manifest verifies and names the `public_key`, every blob's signature + * verifies under the manifest's signing key, every blob parses, and none has + * expired at @p now. Every listed validator with a manifest must own it. + * + * @param list The document as served + * @param expectedRoster When set, every blob must list exactly these master + * keys + * @param expectedKey When set, the manifest's master key must be this key + * @param now Seconds since the XRP Ledger epoch + */ +ListVerification +verifyList( + json::Value const& list, + std::optional const& expectedRoster, + std::optional const& expectedKey, + std::uint32_t now); + +} // namespace xrpl diff --git a/src/tools/validator-keys/README.md b/src/tools/validator-keys/README.md new file mode 100644 index 0000000000..c720ced810 --- /dev/null +++ b/src/tools/validator-keys/README.md @@ -0,0 +1,22 @@ +# validator-keys + +Command-line tool for the keys behind an XRP Ledger validator or a validator-list +publisher: the key file, the manifest that delegates from the master key to a +signing key, the `[validator_token]` for `xrpld.cfg`, key revocation, domain +attestation, and signing and verifying validator lists. + +It is built as a separate target of this repository and ships in the `xrpld` +packages as `/usr/bin/validator-keys`. + +## Build + +Configure with `-Dvalidator_keys=ON` and build the `validator-keys` target: + +``` +cmake --build . --target validator-keys +./validator-keys --unittest +``` + +## Guide + +[Validator Keys Tool Guide](doc/validator-keys-tool-guide.md) diff --git a/src/tools/validator-keys/SigningKeys.cpp b/src/tools/validator-keys/SigningKeys.cpp new file mode 100644 index 0000000000..df6f991b4e --- /dev/null +++ b/src/tools/validator-keys/SigningKeys.cpp @@ -0,0 +1,524 @@ +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include + +namespace xrpl { + +std::string +tokenToBase64(ValidatorToken const& token) +{ + json::Value jv; + jv["validation_secret_key"] = strHex(token.validationSecret); + jv["manifest"] = token.manifest; + + return xrpl::base64Encode(to_string(jv)); +} + +SigningKeys::SigningKeys(KeyType const& keyType) + : keyType_(keyType) + , keys_(generateKeyPair(keyType_, randomSeed())) + , tokenSequence_(0) + , revoked_(false) +{ +} + +SigningKeys::SigningKeys( + KeyType const& keyType, + SecretKey const& secretKey, + std::uint32_t tokenSequence, + bool revoked) + : keyType_(keyType) + , keys_({derivePublicKey(keyType_, secretKey), secretKey}) + , tokenSequence_(tokenSequence) + , revoked_(revoked) +{ +} + +SigningKeys::SigningKeys( + KeyType const& keyType, + PublicKey const& publicKey, + std::uint32_t tokenSequence, + bool revoked) + : keyType_(keyType), keys_(publicKey), tokenSequence_(tokenSequence), revoked_(revoked) +{ +} + +SigningKeys +SigningKeys::make_SigningKeys(boost::filesystem::path const& keyFile) +{ + std::ifstream ifsKeys(keyFile.c_str(), std::ios::in); + + if (!ifsKeys) + throw std::runtime_error("Failed to open key file: " + keyFile.string()); + + json::Reader reader; + json::Value jKeys; + if (!reader.parse(ifsKeys, jKeys)) + { + throw std::runtime_error("Unable to parse json key file: " + keyFile.string()); + } + + static std::array const requiredFields{ + {"key_type", "secret_key", "token_sequence", "revoked"}}; + + for (auto field : requiredFields) + { + if (!jKeys.isMember(field)) + { + throw std::runtime_error( + "Key file '" + keyFile.string() + "' is missing \"" + field + "\" field"); + } + } + + auto const invalidField = [&keyFile, &jKeys](std::string const& field) { + return std::runtime_error( + "Key file '" + keyFile.string() + "' contains invalid \"" + field + + "\" field: " + jKeys[field].toStyledString()); + }; + + auto const keyType = keyTypeFromString(jKeys["key_type"].asString()); + if (!keyType) + throw invalidField("key_type"); + + auto const secret = + parseBase58(TokenType::NodePrivate, jKeys["secret_key"].asString()); + + auto const pubKey = [&]() -> std::optional { + if (jKeys["secret_key"].asString() == "external") + { + if (!jKeys.isMember("public_key")) + { + throw std::runtime_error( + "Key file '" + keyFile.string() + "' is missing \"public_key\" field"); + } + auto const pubKey = + parseBase58(TokenType::NodePublic, jKeys["public_key"].asString()); + if (!pubKey) + throw invalidField("public_key"); + return pubKey; + } + if (!secret) + throw invalidField("secret_key"); + return std::nullopt; + }(); + + std::uint32_t tokenSequence; + try + { + if (!jKeys["token_sequence"].isIntegral()) + throw std::runtime_error(""); + + tokenSequence = jKeys["token_sequence"].asUInt(); + } + catch (std::runtime_error&) + { + throw invalidField("token_sequence"); + } + + if (!jKeys["revoked"].isBool()) + throw invalidField("revoked"); + + SigningKeys vk = [&]() { + if (secret) + return SigningKeys(*keyType, *secret, tokenSequence, jKeys["revoked"].asBool()); + + if (*keyType != *publicKeyType(*pubKey)) + throw std::runtime_error( + "Key file '" + keyFile.string() + + "' has a \"key_type\" that does not match \"public_key\""); + return SigningKeys(*keyType, *pubKey, tokenSequence, jKeys["revoked"].asBool()); + }(); + + if (jKeys.isMember("domain")) + { + if (!jKeys["domain"].isString()) + throw invalidField("domain"); + + vk.domain(jKeys["domain"].asString()); + } + + if (jKeys.isMember("manifest")) + { + if (!jKeys["manifest"].isString()) + throw invalidField("manifest"); + + auto ret = strUnHex(jKeys["manifest"].asString()); + + if (!ret || ret->size() == 0) + throw invalidField("manifest"); + + vk.manifest_.clear(); + vk.manifest_.reserve(ret->size()); + std::copy(ret->begin(), ret->end(), std::back_inserter(vk.manifest_)); + } + + if (jKeys.isMember("pending_token_secret")) + { + if (!jKeys["pending_token_secret"].isString()) + throw invalidField("pending_token_secret"); + + vk.pendingTokenSecret_ = parseBase58( + TokenType::NodePrivate, jKeys["pending_token_secret"].asString()); + + if (!vk.pendingTokenSecret_) + throw invalidField("pending_token_secret"); + } + + if (jKeys.isMember("pending_signing_key")) + { + if (!jKeys["pending_signing_key"].isString()) + throw invalidField("pending_signing_key"); + + vk.pendingSigningKey_ = + parseBase58(TokenType::NodePublic, jKeys["pending_signing_key"].asString()); + + if (!vk.pendingSigningKey_) + throw invalidField("pending_signing_key"); + } + + if (jKeys.isMember("pending_key_type")) + { + auto const pendingKeyType = keyTypeFromString(jKeys["pending_key_type"].asString()); + if (!pendingKeyType) + throw invalidField("pending_key_type"); + vk.pendingKeyType_ = pendingKeyType; + } + + return vk; +} + +void +SigningKeys::writeToFile(boost::filesystem::path const& keyFile) const +{ + using namespace boost::filesystem; + + json::Value jv; + jv["key_type"] = to_string(keyType_); + jv["public_key"] = toBase58(TokenType::NodePublic, keys_.publicKey); + jv["secret_key"] = + keys_.secretKey ? toBase58(TokenType::NodePrivate, *keys_.secretKey) : "external"; + jv["token_sequence"] = json::UInt(tokenSequence_); + jv["revoked"] = revoked_; + if (!domain_.empty()) + jv["domain"] = domain_; + if (!manifest_.empty()) + jv["manifest"] = strHex(makeSlice(manifest_)); + if (pendingTokenSecret_) + jv["pending_token_secret"] = toBase58(TokenType::NodePrivate, *pendingTokenSecret_); + if (pendingSigningKey_) + jv["pending_signing_key"] = toBase58(TokenType::NodePublic, *pendingSigningKey_); + if (pendingKeyType_) + jv["pending_key_type"] = to_string(*pendingKeyType_); + + if (!keyFile.parent_path().empty()) + { + boost::system::error_code ec; + if (!exists(keyFile.parent_path())) + boost::filesystem::create_directories(keyFile.parent_path(), ec); + + if (ec || !is_directory(keyFile.parent_path())) + throw std::runtime_error("Cannot create directory: " + keyFile.parent_path().string()); + } + + std::ofstream o(keyFile.string(), std::ios_base::trunc); + if (o.fail()) + throw std::runtime_error("Cannot open key file: " + keyFile.string()); + + o << jv.toStyledString(); +} + +void +SigningKeys::verifyManifest() const +{ + STObject st(sfGeneric); + SerialIter sit(manifest_.data(), manifest_.size()); + st.set(sit); + + auto fail = []() { throw std::runtime_error("Manifest is not properly signed"); }; + auto const tpk = get(st, sfSigningPubKey); + if (revoked() && tpk) + fail(); + + if (!revoked() && (!tpk || !verify(st, HashPrefix::Manifest, *tpk))) + fail(); + + auto const pk = get(st, sfPublicKey); + if (!pk || !verify(st, HashPrefix::Manifest, *pk, sfMasterSignature)) + fail(); +} + +namespace { + +[[nodiscard]] STObject +generatePartialManifest( + std::uint32_t sequence, + PublicKey const& masterPubKey, + PublicKey const& signingPubKey, + std::string const& domain) +{ + STObject st(sfGeneric); + st[sfSequence] = sequence; + st[sfPublicKey] = masterPubKey; + st[sfSigningPubKey] = signingPubKey; + + if (!domain.empty()) + st[sfDomain] = makeSlice(domain); + + return st; +} + +[[nodiscard]] STObject +generatePartialRevocation(PublicKey const& masterPubKey) +{ + STObject st(sfGeneric); + st[sfSequence] = std::numeric_limits::max(); + st[sfPublicKey] = masterPubKey; + + return st; +} + +// The bytes both the signing key and the master key sign. +[[nodiscard]] std::string +signingData(STObject const& st) +{ + Serializer s; + s.add32(HashPrefix::Manifest); + st.addWithoutSigningFields(s); + return strHex(s.peekData()); +} + +} // namespace + +std::optional +SigningKeys::createValidatorToken(KeyType const& keyType) +{ + if (revoked() || std::numeric_limits::max() - 1 <= tokenSequence_) + return std::nullopt; + + if (!keys_.secretKey) + throw std::runtime_error("This key file cannot be used to sign tokens."); + + ++tokenSequence_; + + auto const tokenSecret = generateSecretKey(keyType, randomSeed()); + auto const tokenPublic = derivePublicKey(keyType, tokenSecret); + + STObject st = generatePartialManifest(tokenSequence_, keys_.publicKey, tokenPublic, domain_); + + xrpl::sign(st, HashPrefix::Manifest, keyType, tokenSecret); + xrpl::sign(st, HashPrefix::Manifest, keyType_, *keys_.secretKey, sfMasterSignature); + + setManifest(st); + + return ValidatorToken{xrpl::base64Encode(manifest_.data(), manifest_.size()), tokenSecret}; +} + +std::optional +SigningKeys::startValidatorToken( + KeyType const& keyType, + std::optional const& externalSigningKey) const +{ + if (revoked() || std::numeric_limits::max() - 1 <= tokenSequence_) + return std::nullopt; + + clearPending(); + + // The next manifest carries the next sequence, but the sequence is not + // consumed until the signature comes back. + if (externalSigningKey) + { + pendingSigningKey_ = externalSigningKey; + pendingKeyType_ = publicKeyType(*externalSigningKey); + return signingData(generatePartialManifest( + tokenSequence_ + 1, keys_.publicKey, *externalSigningKey, domain_)); + } + + auto const tokenSecret = generateSecretKey(keyType, randomSeed()); + auto const tokenPublic = derivePublicKey(keyType, tokenSecret); + + pendingTokenSecret_ = tokenSecret; + pendingKeyType_ = keyType; + + return signingData( + generatePartialManifest(tokenSequence_ + 1, keys_.publicKey, tokenPublic, domain_)); +} + +std::optional +SigningKeys::finishToken(Blob const& masterSig) +{ + if (revoked()) + return std::nullopt; + + if (!pendingTokenSecret_ || !pendingKeyType_) + throw std::runtime_error("No pending token to finish"); + + ++tokenSequence_; + + auto const tokenSecret = *pendingTokenSecret_; + auto const tokenPublic = derivePublicKey(*pendingKeyType_, tokenSecret); + + STObject st = generatePartialManifest(tokenSequence_, keys_.publicKey, tokenPublic, domain_); + + xrpl::sign(st, HashPrefix::Manifest, *pendingKeyType_, tokenSecret); + st[sfMasterSignature] = makeSlice(masterSig); + + setManifest(st); + + return ValidatorToken{xrpl::base64Encode(manifest_.data(), manifest_.size()), tokenSecret}; +} + +std::optional +SigningKeys::finishExternalToken(Blob const& masterSig, Blob const& signingSig) +{ + if (revoked()) + return std::nullopt; + + if (!pendingSigningKey_) + throw std::runtime_error("No pending token with an external signing key to finish"); + + ++tokenSequence_; + + STObject st = + generatePartialManifest(tokenSequence_, keys_.publicKey, *pendingSigningKey_, domain_); + + st[sfSignature] = makeSlice(signingSig); + st[sfMasterSignature] = makeSlice(masterSig); + + setManifest(st); + + return xrpl::base64Encode(manifest_.data(), manifest_.size()); +} + +std::string +SigningKeys::revoke() +{ + if (!keys_.secretKey) + throw std::runtime_error("This key file cannot be used to sign tokens."); + + revoked_ = true; + + STObject st = generatePartialRevocation(keys_.publicKey); + + xrpl::sign(st, HashPrefix::Manifest, keyType_, *keys_.secretKey, sfMasterSignature); + + setManifest(st); + + return xrpl::base64Encode(manifest_.data(), manifest_.size()); +} + +std::string +SigningKeys::startRevoke() const +{ + clearPending(); + return signingData(generatePartialRevocation(keys_.publicKey)); +} + +std::string +SigningKeys::finishRevoke(Blob const& masterSig) +{ + revoked_ = true; + + STObject st = generatePartialRevocation(keys_.publicKey); + + st[sfMasterSignature] = makeSlice(masterSig); + + setManifest(st); + + return xrpl::base64Encode(manifest_.data(), manifest_.size()); +} + +void +SigningKeys::setManifest(STObject const& st) +{ + Serializer s; + st.add(s); + + manifest_.clear(); + manifest_.reserve(s.size()); + std::copy(s.begin(), s.end(), std::back_inserter(manifest_)); + + verifyManifest(); + + clearPending(); +} + +void +SigningKeys::clearPending() const +{ + pendingTokenSecret_.reset(); + pendingSigningKey_.reset(); + pendingKeyType_.reset(); +} + +std::string +SigningKeys::sign(std::string const& data) const +{ + if (!keys_.secretKey) + throw std::runtime_error("This key file cannot be used to sign."); + + return strHex(xrpl::sign(keys_.publicKey, *keys_.secretKey, makeSlice(data))); +} + +std::string +SigningKeys::signHex(std::string data) const +{ + if (!keys_.secretKey) + throw std::runtime_error("This key file cannot be used to sign."); + + boost::algorithm::trim(data); + auto const blob = strUnHex(data); + if (!blob) + throw std::runtime_error("Could not decode hex string: " + data); + return strHex(xrpl::sign(keys_.publicKey, *keys_.secretKey, makeSlice(*blob))); +} + +void +SigningKeys::domain(std::string d) +{ + if (!d.empty()) + { + // A valid domain for a validator must be at least 4 characters + // long, should contain at least one . and should not be longer + // that 128 characters. + if (d.size() < 4 || d.size() > 128) + throw std::runtime_error("The domain must be between 4 and 128 characters long."); + + // This regular expression should do a decent job of weeding out + // obviously wrong domain names but it isn't perfect. It does not + // really support IDNs. If this turns out to be an issue, a more + // thorough regex can be used or this check can just be removed. + static boost::regex const re( + "^" // Beginning of line + "(" // Hostname or domain name + "(?!-)" // - must not begin with '-' + "[a-zA-Z0-9-]{1,63}" // - only alphanumeric and '-' + "(? +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace boost { +namespace filesystem { +class path; +} +} // namespace boost + +namespace xrpl { + +/** + * Returns the token as the base64 JSON object written to [validator_token]. + */ +std::string +tokenToBase64(ValidatorToken const& token); + +/** + * The master key of a validator or a validator-list publisher, as stored in + * the key file, with the manifest, token and revocation operations that the + * master key signs. + * + * The secret key is optional. When it is absent the key file was created with + * `create_external` and every master signature comes from an external signer: + * the `start*` methods return the bytes to sign as hex and the `finish*` + * methods take the signature back. + */ +class SigningKeys +{ +private: + struct Keys + { + PublicKey publicKey; + // Unset when the master key is held by an external signer. + std::optional secretKey; + + Keys() = delete; + Keys(std::pair const& p) : publicKey(p.first), secretKey(p.second) + { + } + Keys(PublicKey const& pub) : publicKey(pub), secretKey(std::nullopt) + { + } + }; + + KeyType const keyType_; + Keys const keys_; + std::vector manifest_; + std::uint32_t tokenSequence_; + bool revoked_; + std::string domain_; + // A token started with `startValidatorToken` and not yet finished. Only + // one of the two is set: the software signing key generated for the + // token, or the external signing key the token will delegate to. + mutable std::optional pendingTokenSecret_; + mutable std::optional pendingSigningKey_; + mutable std::optional pendingKeyType_; + +public: + explicit SigningKeys(KeyType const& keyType); + + SigningKeys( + KeyType const& keyType, + SecretKey const& secretKey, + std::uint32_t tokenSequence, + bool revoked = false); + + /** + * Creates keys whose secret is held by an external signer. + * + * The key file is written with `"secret_key": "external"`. + */ + SigningKeys( + KeyType const& keyType, + PublicKey const& publicKey, + std::uint32_t tokenSequence = 0, + bool revoked = false); + + /** + * Returns SigningKeys constructed from a JSON key file. + * + * @param keyFile Path to JSON key file + * + * @throws std::runtime_error if file content is invalid + */ + static SigningKeys + make_SigningKeys(boost::filesystem::path const& keyFile); + + ~SigningKeys() = default; + SigningKeys(SigningKeys const&) = default; + SigningKeys& + operator=(SigningKeys const&) = delete; + + inline bool + operator==(SigningKeys const& rhs) const + { + return revoked_ == rhs.revoked_ && keyType_ == rhs.keyType_ && + tokenSequence_ == rhs.tokenSequence_ && keys_.publicKey == rhs.keys_.publicKey && + keys_.secretKey.has_value() == rhs.keys_.secretKey.has_value() && + (!keys_.secretKey || + std::equal( + keys_.secretKey->begin(), keys_.secretKey->end(), rhs.keys_.secretKey->begin())); + } + + /** + * Writes the keys to a JSON key file. + * + * @param keyFile Path to file to write + * + * @note Overwrites an existing key file + * + * @throws std::runtime_error if unable to create the parent directory + */ + void + writeToFile(boost::filesystem::path const& keyFile) const; + + /** + * Returns a validator token for the next sequence. + * + * @param keyType Key type of the token's signing key + * + * @return The token, or nullopt if the keys are revoked or the sequence is + * exhausted + * + * @throws std::runtime_error if the master key is external + */ + std::optional + createValidatorToken(KeyType const& keyType = KeyType::Secp256k1); + + /** + * Starts a token whose master signature comes from an external signer. + * + * When @p externalSigningKey is set, the token delegates to that key and + * its signature must also come from the external signer, so the returned + * bytes are signed twice: once by the signing key and once by the master + * key. Otherwise a software signing key is generated and kept pending in + * the key file until `finishToken`. + * + * @param keyType Key type of a generated signing key; ignored when + * @p externalSigningKey is set + * @param externalSigningKey Signing key held by the external signer + * + * @return The hex bytes to sign, or nullopt if the keys are revoked or + * the sequence is exhausted + */ + std::optional + startValidatorToken( + KeyType const& keyType = KeyType::Secp256k1, + std::optional const& externalSigningKey = std::nullopt) const; + + /** + * Finishes a token started with a generated signing key. + * + * @param masterSig Master signature over the bytes `startValidatorToken` + * returned + * + * @return The token, or nullopt if the keys are revoked + * + * @throws std::runtime_error if no such token is pending or the + * signature does not verify + */ + std::optional + finishToken(Blob const& masterSig); + + /** + * Finishes a token started with an external signing key. + * + * @param masterSig Master signature over the bytes `startValidatorToken` + * returned + * @param signingSig Signing-key signature over the same bytes + * + * @return The base64 manifest, or nullopt if the keys are revoked + * + * @throws std::runtime_error if no such token is pending or a signature + * does not verify + */ + std::optional + finishExternalToken(Blob const& masterSig, Blob const& signingSig); + + /** + * Revokes the keys. + * + * @return The base64 revocation manifest + * + * @throws std::runtime_error if the master key is external + */ + std::string + revoke(); + + /** + * Starts a revocation whose master signature comes from an external + * signer. + * + * @return The hex bytes to sign + */ + std::string + startRevoke() const; + + /** + * Finishes a revocation. + * + * @param masterSig Master signature over the bytes `startRevoke` returned + * + * @return The base64 revocation manifest + * + * @throws std::runtime_error if the signature does not verify + */ + std::string + finishRevoke(Blob const& masterSig); + + /** + * Signs a string with the master key. + * + * @param data String to sign + * + * @return The hex signature + * + * @throws std::runtime_error if the master key is external + */ + std::string + sign(std::string const& data) const; + + /** + * Signs hex-encoded bytes with the master key. + * + * @param data Hex string; decoded to raw bytes before signing + * + * @return The hex signature + * + * @throws std::runtime_error if the master key is external + */ + std::string + signHex(std::string data) const; + + /** + * Returns the public key. + */ + PublicKey const& + publicKey() const + { + return keys_.publicKey; + } + + /** + * Returns true if the keys are revoked. + */ + bool + revoked() const + { + return revoked_; + } + + /** + * Returns the domain associated with this key, if any. + */ + std::string const& + domain() const + { + return domain_; + } + + /** + * Sets the domain associated with this key. + */ + void + domain(std::string d); + + /** + * Checks the stored manifest. + * + * @throws std::runtime_error if the manifest is malformed or not signed + * correctly + */ + void + verifyManifest() const; + + /** + * Returns the last manifest generated, if available. + */ + std::vector + manifest() const + { + if (!manifest_.empty()) + verifyManifest(); + + return manifest_; + } + + /** + * Returns the sequence number of the last manifest generated. + */ + std::uint32_t + sequence() const + { + return tokenSequence_; + } + +private: + void + setManifest(STObject const& st); + + void + clearPending() const; +}; + +} // namespace xrpl diff --git a/src/tools/validator-keys/ValidatorKeysTool.cpp b/src/tools/validator-keys/ValidatorKeysTool.cpp new file mode 100644 index 0000000000..a035288eab --- /dev/null +++ b/src/tools/validator-keys/ValidatorKeysTool.cpp @@ -0,0 +1,912 @@ +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include +#include + +#include +#include + +//------------------------------------------------------------------------------ +// The build version number. You must edit this for each release +// and follow the format described at http://semver.org/ +//-------------------------------------------------------------------------- +char const* const versionString = + "0.4.0" + +#if defined(DEBUG) || defined(SANITIZER) + "+" +#ifdef DEBUG + "DEBUG" +#ifdef SANITIZER + "." +#endif +#endif + +#ifdef SANITIZER + BOOST_PP_STRINGIZE(SANITIZER) +#endif +#endif + + //-------------------------------------------------------------------------- + ; + +static int +runUnitTests() +{ + using namespace beast::unit_test; + // Report on stderr: the tool tests capture stdout to check command output, + // and a failure reported into that capture would never be seen. + reporter r(std::cerr); + bool const anyFailed = r.runEach(globalSuites()); + if (anyFailed) + return EXIT_FAILURE; // LCOV_EXCL_LINE + return EXIT_SUCCESS; +} + +namespace { + +/** + * Parses a public key given as base58, hex or base64. + * + * @throws std::runtime_error if none of the encodings yields a public key + */ +xrpl::PublicKey +parsePublicKey(std::string const& data) +{ + using namespace xrpl; + + if (auto const unBase58 = parseBase58(TokenType::NodePublic, data)) + return *unBase58; + + if (auto const unHex = strUnHex(data)) + { + auto const slice = makeSlice(*unHex); + if (publicKeyType(slice)) + return PublicKey(slice); + } + + { + auto const unBase64 = base64Decode(data); + auto const slice = makeSlice(unBase64); + if (publicKeyType(slice)) + return PublicKey(slice); + } + + throw std::runtime_error("Unable to parse public key: " + data); +} + +/** + * Decodes a signature given as hex or base64. There is no structural way to + * check it other than trying to use it, so if the decoding succeeds, proceed. + */ +xrpl::Blob +decodeSignature(std::string const& data) +{ + using namespace xrpl; + if (auto const unHex = strUnHex(data)) + { + return *unHex; + } + + // base64Decode decodes as far as it can and returns partial data for + // invalid input, so re-encode the result and require a round trip. + if (auto const unBase64 = base64Decode(data); base64Encode(unBase64) == data) + { + return Blob(unBase64.begin(), unBase64.end()); + } + + throw std::runtime_error("Invalid master signature"); +} + +// Writes a config block in 72-character lines, to the file when one is given +// (readable by the owner only, since a token holds a secret) or to stdout. +void +emitBlock( + std::string const& section, + std::string const& publicKey, + std::string const& body, + std::optional const& outFile) +{ + std::ostringstream block; + block << "# validator public key: " << publicKey << "\n\n"; + block << "[" << section << "]\n"; + auto const len = 72; + for (std::size_t i = 0; i < body.size(); i += len) + block << body.substr(i, len) << "\n"; + + if (!outFile) + { + std::cout << "Update xrpld.cfg file with these values and restart xrpld:\n\n"; + std::cout << block.str() << std::endl; + return; + } + + using namespace boost::filesystem; + std::ofstream o(outFile->string(), std::ios_base::trunc); + if (o.fail()) + throw std::runtime_error("Cannot open output file: " + outFile->string()); + o << block.str(); + o.close(); + permissions(*outFile, owner_read | owner_write); + std::cout << "[" << section << "] written to " << outFile->string() << "\n\n"; +} + +void +emitJson(json::Value const& jv, std::optional const& outFile) +{ + if (!outFile) + { + std::cout << jv.toStyledString() << std::endl; + return; + } + std::ofstream o(outFile->string(), std::ios_base::trunc); + if (o.fail()) + throw std::runtime_error("Cannot open output file: " + outFile->string()); + o << jv.toStyledString(); + std::cout << "Written to " << outFile->string() << "\n"; +} + +json::Value +readJsonFile(boost::filesystem::path const& file) +{ + std::ifstream in(file.c_str(), std::ios::in); + if (!in) + throw std::runtime_error("Failed to open file: " + file.string()); + json::Reader reader; + json::Value jv; + if (!reader.parse(in, jv)) + throw std::runtime_error("Not a JSON document: " + file.string()); + return jv; +} + +} // namespace + +void +createKeyFile(boost::filesystem::path const& keyFile) +{ + using namespace xrpl; + + if (exists(keyFile)) + throw std::runtime_error("Refusing to overwrite existing key file: " + keyFile.string()); + + SigningKeys const keys(KeyType::Ed25519); + keys.writeToFile(keyFile); + + std::cout << "Validator keys stored in " << keyFile.string() + << "\n\nThis file should be stored securely and not shared.\n\n"; +} + +void +createExternal(std::string const& data, boost::filesystem::path const& keyFile) +{ + using namespace xrpl; + + if (exists(keyFile)) + throw std::runtime_error("Refusing to overwrite existing key file: " + keyFile.string()); + + auto const publicKey = parsePublicKey(data); + + SigningKeys const keys(*publicKeyType(publicKey), publicKey); + keys.writeToFile(keyFile); + + std::cout << "Validator keys stored in " << keyFile.string() + << "\n\nThis file should be stored securely and not shared.\n\n"; +} + +void +createToken(ToolOptions const& options) +{ + using namespace xrpl; + + auto keys = SigningKeys::make_SigningKeys(options.keyFile); + + if (keys.revoked()) + throw std::runtime_error("Validator keys have been revoked."); + + auto const token = keys.createValidatorToken(options.tokenKeyType); + + if (!token) + throw std::runtime_error( + "Maximum number of tokens have already been generated.\n" + "Revoke validator keys if previous token has been compromised."); + + // Update key file with new token sequence + keys.writeToFile(options.keyFile); + + emitBlock( + "validator_token", + toBase58(TokenType::NodePublic, keys.publicKey()), + tokenToBase64(*token), + options.outFile); +} + +void +startToken(ToolOptions const& options) +{ + using namespace xrpl; + + auto keys = SigningKeys::make_SigningKeys(options.keyFile); + + if (keys.revoked()) + throw std::runtime_error("Validator keys have been revoked."); + + auto const token = keys.startValidatorToken(options.tokenKeyType, options.signingKey); + + if (!token) + throw std::runtime_error( + "Maximum number of tokens have already been generated.\n" + "Revoke validator keys if previous token has been compromised."); + + // Update key file with the pending token + keys.writeToFile(options.keyFile); + + std::cout << *token << std::endl; + + std::cout << std::endl; +} + +void +finishToken(std::vector const& signatures, ToolOptions const& options) +{ + using namespace xrpl; + + auto keys = SigningKeys::make_SigningKeys(options.keyFile); + + if (keys.revoked()) + throw std::runtime_error("Validator keys have been revoked."); + + auto const masterSig = decodeSignature(signatures.at(0)); + + if (signatures.size() == 2) + { + auto const signingSig = decodeSignature(signatures.at(1)); + auto const manifest = keys.finishExternalToken(masterSig, signingSig); + if (!manifest) + throw std::runtime_error("Validator keys have been revoked."); + + keys.writeToFile(options.keyFile); + + emitBlock( + "validator_manifest", + toBase58(TokenType::NodePublic, keys.publicKey()), + *manifest, + options.outFile); + return; + } + + auto const token = keys.finishToken(masterSig); + + if (!token) + throw std::runtime_error( + "Maximum number of tokens have already been generated.\n" + "Revoke validator keys if previous token has been compromised."); + + // Update key file with new token sequence + keys.writeToFile(options.keyFile); + + emitBlock( + "validator_token", + toBase58(TokenType::NodePublic, keys.publicKey()), + tokenToBase64(*token), + options.outFile); +} + +void +createRevocation(boost::filesystem::path const& keyFile) +{ + using namespace xrpl; + + auto keys = SigningKeys::make_SigningKeys(keyFile); + + if (keys.revoked()) + std::cout << "WARNING: Validator keys have already been revoked!\n\n"; + else + std::cout << "WARNING: This will revoke your validator keys!\n\n"; + + auto const revocation = keys.revoke(); + + // Update key file with new token sequence + keys.writeToFile(keyFile); + + emitBlock( + "validator_key_revocation", + toBase58(TokenType::NodePublic, keys.publicKey()), + revocation, + std::nullopt); +} + +void +startRevocation(boost::filesystem::path const& keyFile) +{ + using namespace xrpl; + + auto keys = SigningKeys::make_SigningKeys(keyFile); + + if (keys.revoked()) + std::cerr << "WARNING: Validator keys have already been revoked!\n\n"; + else + std::cerr << "WARNING: This will revoke your validator keys!\n\n"; + + auto const revocation = keys.startRevoke(); + + // Update key file with new token sequence + keys.writeToFile(keyFile); + + std::cout << revocation << std::endl; + + std::cout << std::endl; +} + +void +finishRevocation(std::string const& data, boost::filesystem::path const& keyFile) +{ + using namespace xrpl; + + auto keys = SigningKeys::make_SigningKeys(keyFile); + + if (keys.revoked()) + std::cout << "WARNING: Validator keys have already been revoked!\n\n"; + else + std::cout << "WARNING: This will revoke your validator keys!\n\n"; + + auto const masterSig = decodeSignature(data); + + auto const revocation = keys.finishRevoke(masterSig); + + // Update key file with new token sequence + keys.writeToFile(keyFile); + + emitBlock( + "validator_key_revocation", + toBase58(TokenType::NodePublic, keys.publicKey()), + revocation, + std::nullopt); +} + +void +attestDomain(xrpl::SigningKeys const& keys) +{ + using namespace xrpl; + + if (keys.domain().empty()) + { + std::cout << "No attestation is necessary if no domain is specified!\n"; + std::cout << "If you have an attestation in your xrpl-ledger.toml\n"; + std::cout << "you should remove it at this time.\n"; + return; + } + + std::cout << "The domain attestation for validator " + << toBase58(TokenType::NodePublic, keys.publicKey()) << " is:\n\n"; + + std::cout << "attestation=\"" + << keys.sign( + "[domain-attestation-blob:" + keys.domain() + ":" + + toBase58(TokenType::NodePublic, keys.publicKey()) + "]") + << "\"\n\n"; + + std::cout << "You should include it in your xrp-ledger.toml file in the\n"; + std::cout << "section for this validator.\n"; +} + +void +attestDomain(boost::filesystem::path const& keyFile) +{ + using namespace xrpl; + + auto keys = SigningKeys::make_SigningKeys(keyFile); + + if (keys.revoked()) + throw std::runtime_error("Operation error: The specified master key has been revoked!"); + + attestDomain(keys); +} + +void +setDomain(std::string const& domain, ToolOptions const& options) +{ + using namespace xrpl; + + auto keys = SigningKeys::make_SigningKeys(options.keyFile); + + if (keys.revoked()) + throw std::runtime_error("Operation error: The specified master key has been revoked!"); + + if (domain == keys.domain()) + { + if (domain.empty()) + std::cout << "The domain name was already cleared!\n"; + else + std::cout << "The domain name was already set.\n"; + return; + } + + // Set the domain and generate a new token + keys.domain(domain); + auto const token = keys.createValidatorToken(options.tokenKeyType); + if (!token) + throw std::runtime_error( + "Maximum number of tokens have already been generated.\n" + "Revoke validator keys if previous token has been compromised."); + + // Flush to disk + keys.writeToFile(options.keyFile); + + if (domain.empty()) + std::cout << "The domain name has been cleared.\n"; + else + std::cout << "The domain name has been set to: " << domain << "\n\n"; + attestDomain(keys); + + std::cout << "\n"; + std::cout << "You also need to update the xrpld.cfg file to add a new\n"; + std::cout << "validator token and restart xrpld:\n\n"; + emitBlock( + "validator_token", + toBase58(TokenType::NodePublic, keys.publicKey()), + tokenToBase64(*token), + options.outFile); +} + +void +signData(std::string const& data, boost::filesystem::path const& keyFile) +{ + using namespace xrpl; + + if (data.empty()) + throw std::runtime_error("Syntax error: Must specify data string to sign"); + + auto keys = SigningKeys::make_SigningKeys(keyFile); + + if (keys.revoked()) + std::cout << "WARNING: Validator keys have been revoked!\n\n"; + + std::cout << keys.sign(data) << std::endl; + std::cout << std::endl; +} + +void +signHexData(std::string const& data, boost::filesystem::path const& keyFile) +{ + using namespace xrpl; + + if (data.empty()) + throw std::runtime_error("Syntax error: Must specify data string to sign"); + + auto keys = SigningKeys::make_SigningKeys(keyFile); + + if (keys.revoked()) + std::cout << "WARNING: Validator keys have been revoked!\n\n"; + + std::cout << keys.signHex(data) << std::endl; + std::cout << std::endl; +} + +void +generateManifest(std::string const& type, boost::filesystem::path const& keyFile) +{ + using namespace xrpl; + + auto keys = SigningKeys::make_SigningKeys(keyFile); + + auto const m = keys.manifest(); + + if (m.empty()) + { + std::cout << "The last manifest generated is unavailable. You can\n"; + std::cout << "generate a new one.\n\n"; + return; + } + + if (type == "base64") + { + std::cout << "Manifest #" << keys.sequence() << " (Base64):\n"; + std::cout << base64Encode(m.data(), m.size()) << "\n\n"; + return; + } + + if (type == "hex") + { + std::cout << "Manifest #" << keys.sequence() << " (Hex):\n"; + std::cout << strHex(makeSlice(m)) << "\n\n"; + return; + } + + std::cout << "Unknown encoding '" << type << "'\n"; +} + +void +signListFile(boost::filesystem::path const& unsignedList, ToolOptions const& options) +{ + using namespace xrpl; + + if (!options.tokenFile) + throw std::runtime_error("sign_list needs --token-file"); + + auto const token = loadTokenFile(*options.tokenFile); + auto const manifest = deserializeManifest(base64Decode(token.manifest)); + if (!manifest || !manifest->verify() || manifest->revoked() || !manifest->signingKey) + throw std::runtime_error("The token's manifest is not valid"); + + auto const keyType = publicKeyType(*manifest->signingKey); + if (!keyType || derivePublicKey(*keyType, token.validationSecret) != *manifest->signingKey) + throw std::runtime_error("The token's secret does not match its manifest"); + + auto const list = loadUnsignedList(unsignedList); + auto const signature = signList(list, *manifest->signingKey, token.validationSecret); + + std::optional append; + if (options.appendFile) + append = readJsonFile(*options.appendFile); + + emitJson( + makeSignedList( + token.manifest, manifest->masterKey, list, signature, options.listVersion, append), + options.outFile); +} + +void +startSignList(boost::filesystem::path const& unsignedList, ToolOptions const& options) +{ + using namespace xrpl; + + if (!options.manifestFile) + throw std::runtime_error("start_sign_list needs --manifest-file"); + + // The manifest names the signing key; the bytes to sign are the list. + auto const manifest = loadManifestFile(*options.manifestFile); + if (manifest.revoked() || !manifest.signingKey) + throw std::runtime_error("The manifest is revoked"); + + auto const list = loadUnsignedList(unsignedList); + std::cout << strHex(makeSlice(list.canonical)) << std::endl; +} + +void +finishSignList( + std::string const& signature, + boost::filesystem::path const& unsignedList, + ToolOptions const& options) +{ + using namespace xrpl; + + if (!options.manifestFile) + throw std::runtime_error("finish_sign_list needs --manifest-file"); + + auto const manifest = loadManifestFile(*options.manifestFile); + if (manifest.revoked() || !manifest.signingKey) + throw std::runtime_error("The manifest is revoked"); + + auto const list = loadUnsignedList(unsignedList); + auto const sig = decodeSignature(signature); + if (!verify(*manifest.signingKey, makeSlice(list.canonical), makeSlice(sig))) + throw std::runtime_error("The signature does not verify under the manifest's signing key"); + + std::optional append; + if (options.appendFile) + append = readJsonFile(*options.appendFile); + + emitJson( + makeSignedList( + base64Encode(manifest.serialized), + manifest.masterKey, + list, + strHex(sig), + options.listVersion, + append), + options.outFile); +} + +int +verifyListFile(boost::filesystem::path const& list, ToolOptions const& options) +{ + using namespace xrpl; + + std::optional roster; + if (options.validatorsFile) + roster = loadUnsignedList(*options.validatorsFile); + + auto const result = + verifyList(readJsonFile(list), roster, options.expectedKey, rippleEpochNow()); + + std::cout << result.report.toStyledString() << std::endl; + return result.ok ? EXIT_SUCCESS : EXIT_FAILURE; +} + +int +runCommand( + std::string const& command, + std::vector const& args, + ToolOptions const& options) +{ + using namespace std; + + // Minimum and maximum number of positional arguments per command. + static map> const commandArgs = { + {"create_keys", {0, 0}}, + {"create_token", {0, 0}}, + {"revoke_keys", {0, 0}}, + {"set_domain", {1, 1}}, + {"clear_domain", {0, 0}}, + {"attest_domain", {0, 0}}, + {"show_manifest", {1, 1}}, + {"sign", {1, 1}}, + {"sign_hex", {1, 1}}, + {"create_external", {1, 1}}, + {"start_token", {0, 0}}, + {"finish_token", {1, 2}}, + {"start_revoke_keys", {0, 0}}, + {"finish_revoke_keys", {1, 1}}, + {"sign_list", {1, 1}}, + {"start_sign_list", {1, 1}}, + {"finish_sign_list", {2, 2}}, + {"verify_list", {1, 1}}, + }; + + auto const iArgs = commandArgs.find(command); + + if (iArgs == commandArgs.end()) + throw std::runtime_error("Unknown command: " + command); + + if (args.size() < iArgs->second.first || args.size() > iArgs->second.second) + throw std::runtime_error("Syntax error: Wrong number of arguments"); + + auto const& keyFile = options.keyFile; + + if (command == "create_keys") + createKeyFile(keyFile); + else if (command == "create_token") + createToken(options); + else if (command == "revoke_keys") + createRevocation(keyFile); + else if (command == "set_domain") + setDomain(args[0], options); + else if (command == "clear_domain") + setDomain("", options); + else if (command == "attest_domain") + attestDomain(keyFile); + else if (command == "sign") + signData(args[0], keyFile); + else if (command == "sign_hex") + signHexData(args[0], keyFile); + else if (command == "show_manifest") + generateManifest(args[0], keyFile); + else if (command == "create_external") + createExternal(args[0], keyFile); + else if (command == "start_token") + startToken(options); + else if (command == "finish_token") + finishToken(args, options); + else if (command == "start_revoke_keys") + startRevocation(keyFile); + else if (command == "finish_revoke_keys") + finishRevocation(args[0], keyFile); + else if (command == "sign_list") + signListFile(args[0], options); + else if (command == "start_sign_list") + startSignList(args[0], options); + else if (command == "finish_sign_list") + finishSignList(args[0], args[1], options); + else if (command == "verify_list") + return verifyListFile(args[0], options); + + return 0; +} + +// LCOV_EXCL_START +static std::string +getEnvVar(char const* name) +{ + std::string value; + + auto const v = getenv(name); + + if (v != nullptr) + value = v; + + return value; +} + +void +printHelp(boost::program_options::options_description const& desc) +{ + std::cerr << "validator-keys [options] [ ...]\n" + << desc << std::endl + << "Commands: \n" + " create_keys Generate validator keys.\n" + " create_token Generate validator token.\n" + " revoke_keys Revoke validator keys.\n" + " sign Sign string with validator " + "key.\n" + " sign_hex Decode and sign hex string with " + "validator key.\n" + " show_manifest [hex|base64] Displays the last generated " + "manifest\n" + " set_domain Associate a domain with the " + "validator key.\n" + " clear_domain Disassociate a domain from a " + "validator key.\n" + " attest_domain Produce the attestation string " + "for a domain.\n" + "Commands for signing externally: \n" + " create_external Generate validator keys without " + "a secret.\n" + " start_token Generate a partial token for " + "external signing; --signing-key delegates to an external key.\n" + " finish_token []\n" + " Finish generating token with " + "external signature(s).\n" + " start_revoke_keys Generate a partial revocation " + "for external signing.\n" + " finish_revoke_keys Finish generating revocation " + "with external signature.\n" + "Commands for validator lists: \n" + " sign_list Sign a list with --token-file.\n" + " start_sign_list \n" + " Print the bytes to sign with an " + "external signing key; needs --manifest-file.\n" + " finish_sign_list \n" + " Assemble the signed list from an " + "external signature; needs --manifest-file.\n" + " verify_list Check a published list; " + "--validators and --expected-key add checks.\n"; +} +// LCOV_EXCL_STOP + +std::string const& +getVersionString() +{ + static std::string const value = [] { + std::string const s = versionString; + beast::SemanticVersion v; + if (!v.parse(s) || v.print() != s) + throw std::logic_error(s + ": Bad version string"); // LCOV_EXCL_LINE + return s; + }(); + return value; +} + +int +main(int argc, char** argv) +{ + namespace po = boost::program_options; + + po::variables_map vm; + + // Set up option parsing. + // + po::options_description general("General Options"); + general.add_options()("help,h", "Display this message.")( + "keyfile", po::value(), "Specify the key file.")( + "token-key-type", + po::value(), + "Key type of a token's signing key: secp256k1 (default) or ed25519.")( + "signing-key", + po::value(), + "External signing key a token delegates to (start_token).")( + "token-file", po::value(), "File holding a [validator_token] block.")( + "manifest-file", po::value(), "File holding a base64 manifest.")( + "out", po::value(), "Write the token or signed list to this file.")( + "list-version", po::value(), "Signed list version: 1 (default) or 2.")( + "append", po::value(), "Version 2 list to add the new blob to.")( + "validators", + po::value(), + "Unsigned list whose validators a published list must carry (verify_list).")( + "expected-key", + po::value(), + "Master key a published list must be signed under (verify_list).")( + "unittest,u", "Perform unit tests.")("version", "Display the build version."); + + po::options_description hidden("Hidden options"); + hidden.add_options()("command", po::value(), "Command.")( + "arguments", + po::value>()->default_value(std::vector(), "empty"), + "Arguments."); + po::positional_options_description p; + p.add("command", 1).add("arguments", -1); + + po::options_description cmdline_options; + cmdline_options.add(general).add(hidden); + + // Parse options, if no error. + try + { + po::store( + po::command_line_parser(argc, argv) + .options(cmdline_options) // Parse options. + .positional(p) + .run(), + vm); + po::notify(vm); // Invoke option notify functions. + } + // LCOV_EXCL_START + catch (std::exception const&) + { + std::cerr << "validator-keys: Incorrect command line syntax." << std::endl; + std::cerr << "Use '--help' for a list of options." << std::endl; + return EXIT_FAILURE; + } + // LCOV_EXCL_STOP + + // Run the unit tests if requested. + // The unit tests will exit the application with an appropriate return code. + if (vm.count("unittest")) + return runUnitTests(); + + // LCOV_EXCL_START + if (vm.count("version")) + { + std::cout << "validator-keys version " << getVersionString() << std::endl; + return 0; + } + + if (vm.count("help") || !vm.count("command")) + { + printHelp(general); + return EXIT_SUCCESS; + } + + std::string const homeDir = getEnvVar("HOME"); + std::string const defaultKeyFile = + (homeDir.empty() ? boost::filesystem::current_path().string() : homeDir) + + "/.ripple/validator-keys.json"; + + try + { + using namespace boost::filesystem; + + ToolOptions options; + options.keyFile = vm.count("keyfile") ? vm["keyfile"].as() : defaultKeyFile; + + if (vm.count("token-key-type")) + { + auto const keyType = xrpl::keyTypeFromString(vm["token-key-type"].as()); + if (!keyType) + throw std::runtime_error( + "Unknown key type: " + vm["token-key-type"].as()); + options.tokenKeyType = *keyType; + } + if (vm.count("signing-key")) + options.signingKey = parsePublicKey(vm["signing-key"].as()); + if (vm.count("token-file")) + options.tokenFile = path(vm["token-file"].as()); + if (vm.count("manifest-file")) + options.manifestFile = path(vm["manifest-file"].as()); + if (vm.count("out")) + options.outFile = path(vm["out"].as()); + if (vm.count("list-version")) + options.listVersion = vm["list-version"].as(); + if (vm.count("append")) + options.appendFile = path(vm["append"].as()); + if (vm.count("validators")) + options.validatorsFile = path(vm["validators"].as()); + if (vm.count("expected-key")) + options.expectedKey = parsePublicKey(vm["expected-key"].as()); + + return runCommand( + vm["command"].as(), + vm["arguments"].as>(), + options); + } + catch (std::exception const& e) + { + std::cerr << e.what() << "\n"; + return EXIT_FAILURE; + } + + return EXIT_SUCCESS; + // LCOV_EXCL_STOP +} diff --git a/src/tools/validator-keys/ValidatorKeysTool.h b/src/tools/validator-keys/ValidatorKeysTool.h new file mode 100644 index 0000000000..cb8bd81260 --- /dev/null +++ b/src/tools/validator-keys/ValidatorKeysTool.h @@ -0,0 +1,97 @@ +#pragma once + +#include +#include + +#include + +#include +#include +#include + +std::string const& +getVersionString(); + +/** + * The command-line options every command may read. + */ +struct ToolOptions +{ + // The master key file. + boost::filesystem::path keyFile; + // Key type of a token's signing key. + xrpl::KeyType tokenKeyType = xrpl::KeyType::Secp256k1; + // External signing key a token delegates to. + std::optional signingKey; + // File holding a [validator_token] block. + std::optional tokenFile; + // File holding a base64 manifest. + std::optional manifestFile; + // File to write a token or a signed list to instead of stdout. + std::optional outFile; + // Version of the signed list document. + unsigned listVersion = 1; + // Version 2 list to add a blob to. + std::optional appendFile; + // Unsigned list whose validators a published list must carry. + std::optional validatorsFile; + // Master key a published list must be signed under. + std::optional expectedKey; +}; + +void +createKeyFile(boost::filesystem::path const& keyFile); + +void +createToken(ToolOptions const& options); + +void +createRevocation(boost::filesystem::path const& keyFile); + +/*****************************************/ +/* External signing support */ +void +createExternal(std::string const& data, boost::filesystem::path const& keyFile); + +void +startToken(ToolOptions const& options); + +void +finishToken(std::vector const& signatures, ToolOptions const& options); + +void +startRevocation(boost::filesystem::path const& keyFile); + +void +finishRevocation(std::string const& data, boost::filesystem::path const& keyFile); + +/*****************************************/ +/* Validator lists */ +void +signListFile(boost::filesystem::path const& unsignedList, ToolOptions const& options); + +void +startSignList(boost::filesystem::path const& unsignedList, ToolOptions const& options); + +void +finishSignList( + std::string const& signature, + boost::filesystem::path const& unsignedList, + ToolOptions const& options); + +int +verifyListFile(boost::filesystem::path const& list, ToolOptions const& options); + +/*****************************************/ + +void +signData(std::string const& data, boost::filesystem::path const& keyFile); + +void +signHexData(std::string const& data, boost::filesystem::path const& keyFile); + +int +runCommand( + std::string const& command, + std::vector const& args, + ToolOptions const& options); diff --git a/src/tools/validator-keys/doc/validator-keys-tool-guide.md b/src/tools/validator-keys/doc/validator-keys-tool-guide.md new file mode 100644 index 0000000000..1e133a2bbd --- /dev/null +++ b/src/tools/validator-keys/doc/validator-keys-tool-guide.md @@ -0,0 +1,226 @@ +# Validator Keys Tool Guide + +This guide explains how to set up a validator so its public key does not have to +change if the rippled config and/or server are compromised. + +A validator uses a public/private key pair. The validator is identified by the +public key. The private key should be tightly controlled. It is used to: + +- sign tokens authorizing a rippled server to run as the validator identified + by this public key. +- sign revocations indicating that the private key has been compromised and + the validator public key should no longer be trusted. + +Each new token invalidates all previous tokens for the validator public key. +The current token needs to be present in the rippled config file. + +Servers that trust the validator will adapt automatically when the token +changes. + +## Validator Keys + +When first setting up a validator, use the `validator-keys` tool to generate +its key pair: + +``` + $ validator-keys create_keys +``` + +Sample output: + +``` + Validator keys stored in /home/ubuntu/.ripple/validator-keys.json +``` + +Keep the key file in a secure but recoverable location, such as an encrypted +USB flash drive. Do not modify its contents. + +## Validator Token + +After first creating the [validator keys](#validator-keys) or if the previous +token has been compromised, use the `validator-keys` tool to create a new +validator token: + +``` + $ validator-keys create_token +``` + +Sample output: + +``` + Update xrpld.cfg file with these values: + + # validator public key: nHUtNnLVx7odrz5dnfb2xpIgbEeJPbzJWfdicSkGyVw1eE5GpjQr + + [validator_token] + eyJ2YWxpZGF0aW9uX3NlY3J|dF9rZXkiOiI5ZWQ0NWY4NjYyNDFjYzE4YTI3NDdiNT + QzODdjMDYyNTkwNzk3MmY0ZTcxOTAyMzFmYWE5Mzc0NTdmYT|kYWY2IiwibWFuaWZl + c3QiOiJKQUFBQUFGeEllMUZ0d21pbXZHdEgyaUNjTUpxQzlnVkZLaWxHZncxL3ZDeE + hYWExwbGMyR25NaEFrRTFhZ3FYeEJ3RHdEYklENk9NU1l1TTBGREFscEFnTms4U0tG + bjdNTzJmZGtjd1JRSWhBT25ndTlzQUtxWFlvdUorbDJWMFcrc0FPa1ZCK1pSUzZQU2 + hsSkFmVXNYZkFpQnNWSkdlc2FhZE9KYy9hQVpva1MxdnltR21WcmxIUEtXWDNZeXd1 + NmluOEhBU1FLUHVnQkQ2N2tNYVJGR3ZtcEFUSGxHS0pkdkRGbFdQWXk1QXFEZWRGdj + VUSmEydzBpMjFlcTNNWXl3TFZKWm5GT3I3QzBrdzJBaVR6U0NqSXpkaXRROD0ifQ== +``` + +For a new validator, add the [validator_token] value to the rippled config file. +For a pre-existing validator, replace the old [validator_token] value with the +newly generated one. A valid config file may only contain one [validator_token] +value. After the config is updated, restart xrpld. + +There is a hard limit of 4,294,967,293 tokens that can be generated for a given +validator key pair. + +## Key Revocation + +If a validator private key is compromised, the key must be revoked permanently. +To revoke the validator key, use the `validator-keys` tool to generate a +revocation, which indicates to other servers that the key is no longer valid: + +``` + $ validator-keys revoke_keys +``` + +Sample output: + +``` + WARNING: This will revoke your validator keys! + + Update xrpld.cfg file with these values and restart xrpld: + + # validator public key: nHUtNnLVx7odrz5dnfb2xpIgbEeJPbzJWfdicSkGyVw1eE5GpjQr + + [validator_key_revocation] + JP////9xIe0hvssbqmgzFH4/NDp1z|3ShkmCtFXuC5A0IUocppHopnASQN2MuMD1Puoyjvnr + jQ2KJSO/2tsjRhjO6q0QQHppslQsKNSXWxjGQNIEa6nPisBOKlDDcJVZAMP4QcIyNCadzgM= +``` + +Add the `[validator_key_revocation]` value to this validator's config and +restart xrpld. Rename the old key file and generate new [validator keys](#validator-keys) and +a corresponding [validator token](#validator-token). + +## Signing + +The `validator-keys` tool can be used to sign arbitrary data with the validator +key. + +``` + $ validator-keys sign "your data to sign" +``` + +Sample output: + +``` + B91B73536235BBA028D344B81DBCBECF19C1E0034AC21FB51C2351A138C9871162F3193D7C41A49FB7AABBC32BC2B116B1D5701807BE462D8800B5AEA4F0550D +``` + +## External Signing + +The master key can live in a hardware signer instead of the key file. The tool +then produces the bytes to sign, the hardware signs them, and the tool +assembles the result. + +One-time setup, with the hardware key's public key in base58 (`nHB...`), hex +(`ED...`) or base64: + +``` + $ validator-keys create_external +``` + +The key file is written with `"secret_key": "external"`. + +To create a token, print the bytes to sign, sign them externally, and pass the +hex or base64 signature back: + +``` + $ validator-keys start_token + $ validator-keys finish_token +``` + +The output is the same `[validator_token]` block `create_token` prints. A +revocation works the same way with `start_revoke_keys` and +`finish_revoke_keys `. + +When the signing key is also held by the hardware, name it when starting the +token. The bytes are then signed twice, once by each key, and the result is a +manifest without a secret: + +``` + $ validator-keys start_token --signing-key + $ validator-keys finish_token +``` + +For testing without a hardware signer, a second key file can stand in for it: + +``` + $ validator-keys --keyfile other-keys.json sign_hex +``` + +## Validator List Publishers + +A publisher's keys are a master key and a signing key bound to it by a +manifest, exactly like a validator's. The token `create_token` prints holds +that manifest and the signing key, so a publisher's setup is: + +``` + $ validator-keys create_keys + $ validator-keys create_token --token-key-type ed25519 --out publisher-token.txt +``` + +`--token-key-type ed25519` matches what hardware signers support. `--out` +writes the token to a file readable only by its owner instead of printing it. + +The manifest's sequence is `token_sequence` in the key file. A server keeps +the highest sequence it has seen for a master key, so a key migrated from +another tool must start `token_sequence` above the sequence of the manifest +currently published. + +### Signing a list + +The unsigned list is a JSON file with the fields a server reads: + +```json +{ + "sequence": 2026091301, + "expiration": 843955200, + "validators": [ + { "validation_public_key": "ED...", "manifest": "" } + ] +} +``` + +`sequence` must rise with every published list, `expiration` and the optional +`effective` are seconds since the XRP Ledger epoch, and each validator's +`manifest` must belong to its `validation_public_key`. Whitespace is removed +and one space is placed after each `,` and `:` before signing. + +``` + $ validator-keys sign_list unsigned.json --token-file publisher-token.txt --out vl.json +``` + +`vl.json` is the version 1 document a server fetches: `blob`, `manifest`, +`public_key`, `signature`, `version`. `--list-version 2` writes the blob into +`blobs_v2` instead, and `--append ` adds it to a version 2 +document that already holds up to four blobs, so a list can be published +alongside the one it will replace. + +When the signing key is held by a hardware signer, the list is signed in two +steps against the manifest from `finish_token`: + +``` + $ validator-keys start_sign_list unsigned.json --manifest-file manifest.txt + $ validator-keys finish_sign_list unsigned.json --manifest-file manifest.txt --out vl.json +``` + +### Checking a list + +``` + $ validator-keys verify_list vl.json --validators unsigned.json --expected-key +``` + +The checks are the ones a server makes before trusting the list: the manifest +verifies and names `public_key`, every blob's signature verifies under the +manifest's signing key, every blob parses, and none has expired. `--validators` +requires every blob to list exactly the keys in the unsigned list, and +`--expected-key` requires the master key to be the one given. The result is +printed as JSON and the exit code is 0 only if every check passed. diff --git a/src/tools/validator-keys/test/KeyFileGuard.h b/src/tools/validator-keys/test/KeyFileGuard.h new file mode 100644 index 0000000000..70eb9bbb8d --- /dev/null +++ b/src/tools/validator-keys/test/KeyFileGuard.h @@ -0,0 +1,60 @@ +#pragma once + +#include + +#include + +#include + +namespace xrpl { + +/** + * Write a key file dir and remove when done. + */ +class KeyFileGuard +{ +private: + using path = boost::filesystem::path; + path subDir_; + beast::unit_test::Suite& test_; + + auto + rmDir(path const& toRm) + { + if (is_directory(toRm)) + remove_all(toRm); + else + test_.log << "Expected " << toRm.string() << " to be an existing directory." + << std::endl; + }; + +public: + KeyFileGuard(beast::unit_test::Suite& test, std::string const& subDir) + : subDir_(subDir), test_(test) + { + using namespace boost::filesystem; + + if (!exists(subDir_)) + create_directory(subDir_); + else + // Cannot run the test. Someone created a file or directory + // where we want to put our directory + throw std::runtime_error("Cannot create directory: " + subDir_.string()); + } + ~KeyFileGuard() + { + try + { + using namespace boost::filesystem; + + rmDir(subDir_); + } + catch (std::exception& e) + { + // if we throw here, just let it die. + test_.log << "Error in ~KeyFileGuard: " << e.what() << std::endl; + }; + } +}; + +} // namespace xrpl diff --git a/src/tools/validator-keys/test/ListSigning_test.cpp b/src/tools/validator-keys/test/ListSigning_test.cpp new file mode 100644 index 0000000000..1d75ef9077 --- /dev/null +++ b/src/tools/validator-keys/test/ListSigning_test.cpp @@ -0,0 +1,509 @@ +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include + +namespace xrpl { + +namespace tests { + +class ListSigning_test : public beast::unit_test::Suite +{ +private: + // A publisher: master keys and the token carrying its signing key. + struct Publisher + { + SigningKeys keys{KeyType::Ed25519}; + ValidatorToken token; + Manifest manifest; + + Publisher() + : token(*keys.createValidatorToken(KeyType::Ed25519)) + , manifest(*deserializeManifest(base64Decode(token.manifest))) + { + } + }; + + // The unsigned list text a publisher's `prepare` step writes: one + // validator per token, each with its manifest. + static std::string + unsignedListText( + std::vector const& validators, + std::uint32_t sequence, + std::uint32_t expiration, + std::optional effective = std::nullopt) + { + std::string text = "{\n \"sequence\": " + std::to_string(sequence); + if (effective) + text += ",\n \"effective\": " + std::to_string(*effective); + text += ",\n \"expiration\": " + std::to_string(expiration) + ",\n \"validators\": ["; + bool first = true; + for (auto const& v : validators) + { + auto const m = deserializeManifest(base64Decode(v.manifest)); + text += first ? "\n" : ",\n"; + first = false; + text += " {\"validation_public_key\": \"" + strHex(m->masterKey) + + "\", \"manifest\": \"" + v.manifest + "\"}"; + } + text += "\n ]\n}\n"; + return text; + } + + static std::vector + makeValidators(std::size_t count) + { + std::vector validators; + for (std::size_t i = 0; i < count; ++i) + { + SigningKeys keys(KeyType::Ed25519); + validators.push_back(*keys.createValidatorToken(KeyType::Secp256k1)); + } + return validators; + } + + static json::Value + parse(std::string const& text) + { + json::Reader reader; + json::Value jv; + reader.parse(text, jv); + return jv; + } + + void + testCanonicalJson() + { + testcase("Canonical JSON"); + + // Whitespace outside strings goes, one space follows each comma and + // colon, key order and string contents stay. + BEAST_EXPECT( + canonicalJson("{ \"b\" :1,\n\t\"a\": [ 1 , 2 ] , \"s\":\"x, y: z\" }") == + "{\"b\": 1, \"a\": [1, 2], \"s\": \"x, y: z\"}"); + BEAST_EXPECT(canonicalJson("{\"e\": \"a\\\"b\"}") == "{\"e\": \"a\\\"b\"}"); + BEAST_EXPECT(canonicalJson("{\"sequence\": 1, \"x\": 2}") == "{\"sequence\": 1, \"x\": 2}"); + + try + { + canonicalJson("[1, 2]"); + fail(); + } + catch (std::runtime_error const& e) + { + BEAST_EXPECT(e.what() == std::string("Not a JSON object")); + } + try + { + canonicalJson("{\"a\": "); + fail(); + } + catch (std::runtime_error const& e) + { + BEAST_EXPECT(e.what() == std::string("Not a JSON object")); + } + } + + void + testParseUnsignedList() + { + testcase("Parse Unsigned List"); + + auto const validators = makeValidators(2); + + { + auto const list = parseUnsignedList(unsignedListText(validators, 5, 1000, 500)); + BEAST_EXPECT(list.sequence == 5); + BEAST_EXPECT(list.expiration == 1000); + BEAST_EXPECT(list.effective && *list.effective == 500); + BEAST_EXPECT(list.validators.size() == 2); + BEAST_EXPECT( + list.validators[0] == + deserializeManifest(base64Decode(validators[0].manifest))->masterKey); + } + { + auto const list = parseUnsignedList(unsignedListText(validators, 5, 1000)); + BEAST_EXPECT(!list.effective); + // The canonical text keeps the key order of the input. + BEAST_EXPECT(list.canonical.starts_with("{\"sequence\": 5, \"expiration\": 1000, ")); + } + + auto expectError = [this](std::string const& text, std::string const& expected) { + try + { + parseUnsignedList(text); + fail(expected); + } + catch (std::runtime_error const& e) + { + BEAST_EXPECTS(e.what() == expected, e.what()); + } + }; + + expectError( + "{\"expiration\": 1, \"validators\": []}", "\"sequence\" must be a positive integer"); + expectError( + "{\"sequence\": 0, \"expiration\": 1, \"validators\": []}", + "\"sequence\" must be a positive integer"); + expectError( + "{\"sequence\": 1, \"validators\": []}", "\"expiration\" must be an unsigned integer"); + expectError( + "{\"sequence\": 1, \"effective\": 1000, \"expiration\": 1000, \"validators\": []}", + "\"effective\" must be earlier than \"expiration\""); + expectError( + "{\"sequence\": 1, \"expiration\": 1000, \"validators\": []}", + "\"validators\" must be a non-empty array"); + expectError( + "{\"sequence\": 1, \"expiration\": 1000, \"validators\": [{}]}", + "every validator needs a \"validation_public_key\" string"); + expectError( + "{\"sequence\": 1, \"expiration\": 1000, \"validators\": [{\"validation_public_key\": " + "\"ED00\"}]}", + "\"validation_public_key\" is not a hex public key: ED00"); + { + // A manifest of another key. + auto const other = deserializeManifest(base64Decode(validators[1].manifest)); + auto const text = + "{\"sequence\": 1, \"expiration\": 1000, \"validators\": " + "[{\"validation_public_key\": \"" + + strHex(other->masterKey) + "\", \"manifest\": \"" + validators[0].manifest + + "\"}]}"; + expectError( + text, "\"manifest\" belongs to another key than " + strHex(other->masterKey)); + } + { + auto const key = deserializeManifest(base64Decode(validators[0].manifest))->masterKey; + auto const text = + "{\"sequence\": 1, \"expiration\": 1000, \"validators\": " + "[{\"validation_public_key\": \"" + + strHex(key) + "\", \"manifest\": \"AAAA\"}]}"; + expectError(text, "\"manifest\" does not verify for " + strHex(key)); + } + } + + void + testSignAndVerify() + { + testcase("Sign and Verify"); + + Publisher const publisher; + auto const validators = makeValidators(3); + std::uint32_t const now = 1000; + auto const list = parseUnsignedList(unsignedListText(validators, 7, now + 100)); + auto const signature = + signList(list, *publisher.manifest.signingKey, publisher.token.validationSecret); + + // Version 1 + auto const v1 = makeSignedList( + publisher.token.manifest, + publisher.manifest.masterKey, + list, + signature, + 1, + std::nullopt); + BEAST_EXPECT(v1[jss::version].asUInt() == 1); + BEAST_EXPECT(v1[jss::public_key].asString() == strHex(publisher.manifest.masterKey)); + BEAST_EXPECT(v1[jss::manifest].asString() == publisher.token.manifest); + BEAST_EXPECT(base64Decode(v1[jss::blob].asString()) == list.canonical); + BEAST_EXPECT(v1[jss::signature].asString() == signature); + { + auto const result = verifyList(v1, list, publisher.manifest.masterKey, now); + BEAST_EXPECTS(result.ok, to_string(result.report)); + BEAST_EXPECT(result.report["blobs"].size() == 1); + BEAST_EXPECT(result.report["blobs"][0u][jss::sequence].asUInt() == 7); + BEAST_EXPECT(result.report["blobs"][0u][jss::validators].asUInt() == 3); + BEAST_EXPECT(!result.report["blobs"][0u]["expired"].asBool()); + BEAST_EXPECT(result.report["manifest_sequence"].asUInt() == 1); + } + + // Version 2, then a second blob appended + auto const v2 = makeSignedList( + publisher.token.manifest, + publisher.manifest.masterKey, + list, + signature, + 2, + std::nullopt); + BEAST_EXPECT(v2[jss::version].asUInt() == 2); + BEAST_EXPECT(v2[jss::blobs_v2].size() == 1); + BEAST_EXPECT(!v2.isMember(jss::blob)); + BEAST_EXPECT(verifyList(v2, list, std::nullopt, now).ok); + + auto const later = parseUnsignedList(unsignedListText(validators, 8, now + 300, now + 200)); + auto const laterSig = + signList(later, *publisher.manifest.signingKey, publisher.token.validationSecret); + auto const v2b = makeSignedList( + publisher.token.manifest, publisher.manifest.masterKey, later, laterSig, 2, v2); + BEAST_EXPECT(v2b[jss::blobs_v2].size() == 2); + { + auto const result = verifyList(v2b, std::nullopt, std::nullopt, now); + BEAST_EXPECTS(result.ok, to_string(result.report)); + BEAST_EXPECT(result.report["blobs"][1u][jss::effective].asUInt() == now + 200); + } + + // Append refuses the wrong shape, another publisher, and a full list + try + { + makeSignedList( + publisher.token.manifest, publisher.manifest.masterKey, list, signature, 1, v2); + fail(); + } + catch (std::runtime_error const& e) + { + BEAST_EXPECT( + e.what() == + std::string("A version 1 list holds one blob; use version 2 to append")); + } + try + { + makeSignedList( + publisher.token.manifest, publisher.manifest.masterKey, list, signature, 2, v1); + fail(); + } + catch (std::runtime_error const& e) + { + BEAST_EXPECT(e.what() == std::string("The list to append to is not a version 2 list")); + } + { + Publisher const other; + try + { + makeSignedList( + other.token.manifest, other.manifest.masterKey, list, signature, 2, v2); + fail(); + } + catch (std::runtime_error const& e) + { + BEAST_EXPECT( + e.what() == std::string("The list to append to belongs to another master key")); + } + } + { + auto full = v2; + while (full[jss::blobs_v2].size() < 5) + full[jss::blobs_v2].append(full[jss::blobs_v2][0u]); + try + { + makeSignedList( + publisher.token.manifest, + publisher.manifest.masterKey, + list, + signature, + 2, + full); + fail(); + } + catch (std::runtime_error const& e) + { + BEAST_EXPECT( + e.what() == std::string("The list to append to already holds 5 blobs")); + } + } + } + + void + testVerifyRejects() + { + testcase("Verify Rejects"); + + Publisher const publisher; + auto const validators = makeValidators(2); + std::uint32_t const now = 1000; + auto const list = parseUnsignedList(unsignedListText(validators, 7, now + 100)); + auto const signature = + signList(list, *publisher.manifest.signingKey, publisher.token.validationSecret); + auto const good = makeSignedList( + publisher.token.manifest, + publisher.manifest.masterKey, + list, + signature, + 1, + std::nullopt); + + auto expectError = [this]( + json::Value const& doc, + std::optional const& roster, + std::optional const& key, + std::uint32_t at, + std::string const& expected) { + auto const result = verifyList(doc, roster, key, at); + BEAST_EXPECT(!result.ok); + bool found = false; + for (auto const& e : result.errors) + found = found || e == expected; + BEAST_EXPECTS(found, to_string(result.report)); + }; + + // Tampered blob: the signature no longer matches + { + auto tampered = good; + auto text = list.canonical; + text.replace(text.find("\"sequence\": 7"), 13, "\"sequence\": 9"); + tampered[jss::blob] = base64Encode(text); + expectError( + tampered, + std::nullopt, + std::nullopt, + now, + "blob 0: the signature does not verify under the signing key"); + } + // Expired + expectError(good, std::nullopt, std::nullopt, now + 100, "blob 0: expired"); + // Wrong manifest: another publisher's + { + Publisher const other; + auto wrong = good; + wrong[jss::manifest] = other.token.manifest; + expectError( + wrong, + std::nullopt, + std::nullopt, + now, + "\"public_key\" is not the manifest's master key"); + } + // Not the expected key + { + Publisher const other; + expectError( + good, + std::nullopt, + other.manifest.masterKey, + now, + "the master key is not the expected key"); + } + // Roster mismatch + { + auto const others = makeValidators(2); + auto const roster = parseUnsignedList(unsignedListText(others, 1, now + 100)); + expectError( + good, + roster, + std::nullopt, + now, + "blob 0: the validators differ from the expected list"); + } + // Structural + { + auto bad = good; + bad[jss::version] = 3; + expectError(bad, std::nullopt, std::nullopt, now, "\"version\" must be 1 or 2"); + } + { + auto bad = good; + bad[jss::blobs_v2] = json::Value(json::ValueType::Array); + expectError( + bad, + std::nullopt, + std::nullopt, + now, + "a version 1 list needs \"blob\" and \"signature\" and no \"blobs_v2\""); + } + { + auto bad = good; + bad[jss::manifest] = "AAAA"; + expectError( + bad, + std::nullopt, + std::nullopt, + now, + "\"manifest\" does not deserialize and verify"); + } + } + + void + testFiles() + { + testcase("Token and Manifest Files"); + + using namespace boost::filesystem; + + path const subdir = "test_key_file"; + KeyFileGuard const g(*this, subdir.string()); + + Publisher const publisher; + + // A token file as `create_token` writes it: header, comment, 72-char lines + path const tokenFile = subdir / "token.txt"; + { + std::ofstream o(tokenFile.string()); + o << "# validator public key: " + << toBase58(TokenType::NodePublic, publisher.keys.publicKey()) << "\n\n"; + o << "[validator_token]\n"; + auto const body = tokenToBase64(publisher.token); + for (std::size_t i = 0; i < body.size(); i += 72) + o << body.substr(i, 72) << "\n"; + } + { + auto const token = loadTokenFile(tokenFile); + BEAST_EXPECT(token.manifest == publisher.token.manifest); + BEAST_EXPECT( + std::equal( + token.validationSecret.begin(), + token.validationSecret.end(), + publisher.token.validationSecret.begin())); + } + + path const manifestFile = subdir / "manifest.txt"; + { + std::ofstream o(manifestFile.string()); + o << "# publisher manifest\n" << publisher.token.manifest << "\n"; + } + { + auto const manifest = loadManifestFile(manifestFile); + BEAST_EXPECT(manifest.masterKey == publisher.manifest.masterKey); + BEAST_EXPECT(manifest.signingKey == publisher.manifest.signingKey); + } + + try + { + loadTokenFile(manifestFile); + fail(); + } + catch (std::runtime_error const& e) + { + BEAST_EXPECT(e.what() == "Not a validator token: " + manifestFile.string()); + } + try + { + loadManifestFile(subdir / "missing.txt"); + fail(); + } + catch (std::runtime_error const& e) + { + BEAST_EXPECT(e.what() == "Failed to open file: " + (subdir / "missing.txt").string()); + } + + path const listFile = subdir / "unsigned.json"; + { + std::ofstream o(listFile.string()); + o << unsignedListText(makeValidators(1), 3, 5000); + } + auto const list = loadUnsignedList(listFile); + BEAST_EXPECT(list.sequence == 3 && list.validators.size() == 1); + } + +public: + void + run() override + { + testCanonicalJson(); + testParseUnsignedList(); + testSignAndVerify(); + testVerifyRejects(); + testFiles(); + } +}; + +BEAST_DEFINE_TESTSUITE(ListSigning, keys, xrpl); + +} // namespace tests + +} // namespace xrpl diff --git a/src/tools/validator-keys/test/SigningKeys_test.cpp b/src/tools/validator-keys/test/SigningKeys_test.cpp new file mode 100644 index 0000000000..a817b1cf32 --- /dev/null +++ b/src/tools/validator-keys/test/SigningKeys_test.cpp @@ -0,0 +1,742 @@ +#include +#include +#include +#include + +#include +#include + +namespace xrpl { + +namespace tests { + +class SigningKeys_test : public beast::unit_test::Suite +{ +private: + void + testKeyFile( + boost::filesystem::path const& keyFile, + json::Value const& jv, + std::string const& expectedError) + { + { + std::ofstream o(keyFile.string(), std::ios_base::trunc); + o << jv.toStyledString(); + o.close(); + } + + try + { + SigningKeys::make_SigningKeys(keyFile); + BEAST_EXPECT(expectedError.empty()); + } + catch (std::runtime_error& e) + { + BEAST_EXPECT(e.what() == expectedError); + } + } + + std::array const keyTypes{{KeyType::Ed25519, KeyType::Secp256k1}}; + + void + testMakeSigningKeys() + { + testcase("Make Validator Keys"); + + using namespace boost::filesystem; + + path const subdir = "test_key_file"; + path const keyFile = subdir / "validator_keys.json"; + + for (auto const keyType : keyTypes) + { + SigningKeys const keys(keyType); + + KeyFileGuard const g(*this, subdir.string()); + + keys.writeToFile(keyFile); + BEAST_EXPECT(exists(keyFile)); + + auto const keys2 = SigningKeys::make_SigningKeys(keyFile); + BEAST_EXPECT(keys == keys2); + } + { + // Require expected fields + KeyFileGuard g(*this, subdir.string()); + + auto expectedError = "Failed to open key file: " + keyFile.string(); + std::string error; + try + { + SigningKeys::make_SigningKeys(keyFile); + fail(); + } + catch (std::runtime_error& e) + { + error = e.what(); + } + BEAST_EXPECT(error == expectedError); + + expectedError = "Unable to parse json key file: " + keyFile.string(); + + { + std::ofstream o(keyFile.string(), std::ios_base::trunc); + o << "{{}"; + o.close(); + } + + try + { + SigningKeys::make_SigningKeys(keyFile); + fail(); + } + catch (std::runtime_error& e) + { + error = e.what(); + } + BEAST_EXPECT(error == expectedError); + + json::Value jv; + jv["dummy"] = "field"; + expectedError = "Key file '" + keyFile.string() + "' is missing \"key_type\" field"; + testKeyFile(keyFile, jv, expectedError); + + jv["key_type"] = "dummy keytype"; + expectedError = "Key file '" + keyFile.string() + "' is missing \"secret_key\" field"; + testKeyFile(keyFile, jv, expectedError); + + jv["secret_key"] = "dummy secret"; + expectedError = + "Key file '" + keyFile.string() + "' is missing \"token_sequence\" field"; + testKeyFile(keyFile, jv, expectedError); + + jv["token_sequence"] = "dummy sequence"; + expectedError = "Key file '" + keyFile.string() + "' is missing \"revoked\" field"; + testKeyFile(keyFile, jv, expectedError); + + jv["revoked"] = "dummy revoked"; + expectedError = "Key file '" + keyFile.string() + + "' contains invalid \"key_type\" field: " + jv["key_type"].toStyledString(); + testKeyFile(keyFile, jv, expectedError); + + auto const keyType = KeyType::Ed25519; + jv["key_type"] = to_string(keyType); + expectedError = "Key file '" + keyFile.string() + + "' contains invalid \"secret_key\" field: " + jv["secret_key"].toStyledString(); + testKeyFile(keyFile, jv, expectedError); + + SigningKeys const keys(keyType); + { + auto const kp = generateKeyPair(keyType, randomSeed()); + jv["secret_key"] = toBase58(TokenType::NodePrivate, kp.second); + } + expectedError = "Key file '" + keyFile.string() + + "' contains invalid \"token_sequence\" field: " + + jv["token_sequence"].toStyledString(); + testKeyFile(keyFile, jv, expectedError); + + jv["token_sequence"] = -1; + expectedError = "Key file '" + keyFile.string() + + "' contains invalid \"token_sequence\" field: " + + jv["token_sequence"].toStyledString(); + testKeyFile(keyFile, jv, expectedError); + + jv["token_sequence"] = json::UInt(std::numeric_limits::max()); + expectedError = "Key file '" + keyFile.string() + + "' contains invalid \"revoked\" field: " + jv["revoked"].toStyledString(); + testKeyFile(keyFile, jv, expectedError); + + jv["revoked"] = false; + expectedError = ""; + testKeyFile(keyFile, jv, expectedError); + + jv["revoked"] = true; + testKeyFile(keyFile, jv, expectedError); + } + } + + void + testCreateValidatorToken() + { + testcase("Create Validator Token"); + + for (auto const keyType : keyTypes) + { + SigningKeys keys(keyType); + std::uint32_t sequence = 0; + + for (auto const tokenKeyType : keyTypes) + { + auto const token = keys.createValidatorToken(tokenKeyType); + + if (!BEAST_EXPECT(token)) + continue; + + auto const tokenPublicKey = derivePublicKey(tokenKeyType, token->validationSecret); + + STObject st(sfGeneric); + auto const manifest = xrpl::base64Decode(token->manifest); + SerialIter sit(manifest.data(), manifest.size()); + st.set(sit); + + auto const seq = get(st, sfSequence); + BEAST_EXPECT(seq); + BEAST_EXPECT(*seq == ++sequence); + + auto const tpk = get(st, sfSigningPubKey); + BEAST_EXPECT(tpk); + BEAST_EXPECT(*tpk == tokenPublicKey); + BEAST_EXPECT(verify(st, HashPrefix::Manifest, tokenPublicKey)); + + auto const pk = get(st, sfPublicKey); + BEAST_EXPECT(pk); + BEAST_EXPECT(*pk == keys.publicKey()); + BEAST_EXPECT(verify(st, HashPrefix::Manifest, keys.publicKey(), sfMasterSignature)); + + try + { + keys.verifyManifest(); + } + catch (std::exception const& e) + { + fail(e.what()); + } + } + } + + auto const keyType = KeyType::Ed25519; + auto const kp = generateKeyPair(keyType, randomSeed()); + + auto keys = SigningKeys(keyType, kp.second, std::numeric_limits::max() - 1); + + BEAST_EXPECT(!keys.createValidatorToken(keyType)); + + keys.revoke(); + BEAST_EXPECT(!keys.createValidatorToken(keyType)); + } + + void + testRevoke() + { + testcase("Revoke"); + + for (auto const keyType : keyTypes) + { + SigningKeys keys(keyType); + + auto const revocation = keys.revoke(); + + STObject st(sfGeneric); + auto const manifest = xrpl::base64Decode(revocation); + SerialIter sit(manifest.data(), manifest.size()); + st.set(sit); + + auto const seq = get(st, sfSequence); + BEAST_EXPECT(seq); + BEAST_EXPECT(*seq == std::numeric_limits::max()); + + auto const pk = get(st, sfPublicKey); + BEAST_EXPECT(pk); + BEAST_EXPECT(*pk == keys.publicKey()); + BEAST_EXPECT(verify(st, HashPrefix::Manifest, keys.publicKey(), sfMasterSignature)); + + try + { + keys.verifyManifest(); + } + catch (std::exception const& e) + { + fail(e.what()); + } + } + } + + void + signWorker( + std::function modifyFunc, + std::function signFunc) + { + std::string const rawdata = "data to sign"; + std::string const data = modifyFunc(rawdata); + + std::map expected( + {{KeyType::Ed25519, + "2EE541D6825791BF5454C571D2B363EAB3F01C73159B1F" + "237AC6D38663A82B9D5EAD262D5F776B916E68247A1F082090F3BAE7ABC939" + "C8F29B0DC759FD712300"}, + {KeyType::Secp256k1, + "3045022100F142C27BF83D8D4541C7A4E759DE64A672" + "51A388A422DFDA6F4B470A2113ABC4022002DA56695F3A805F62B55E7CC8D5" + "55438D64A229CD0B4BA2AE33402443B20409"}}); + + for (auto const keyType : keyTypes) + { + auto const sk = generateSecretKey(keyType, generateSeed("test")); + SigningKeys keys(keyType, sk, 1); + + { + SigningKeys pkOnly(keyType, derivePublicKey(keyType, sk)); + try + { + signFunc(pkOnly, data); + fail(); + } + catch (std::exception const& e) + { + using namespace std::string_literals; + BEAST_EXPECT(e.what() == "This key file cannot be used to sign."s); + } + } + + auto const signature = signFunc(keys, data); + BEAST_EXPECT(expected[keyType] == signature); + + auto const ret = strUnHex(signature); + BEAST_EXPECT(ret); + BEAST_EXPECT(ret->size()); + BEAST_EXPECT(verify(keys.publicKey(), makeSlice(rawdata), makeSlice(*ret))); + } + } + + void + testSign() + { + testcase("Sign"); + + signWorker( + [](auto const& data) { return data; }, + [](auto const& keys, auto const& data) { return keys.sign(data); }); + } + + void + testSignHex() + { + testcase("Sign Hex"); + + signWorker( + [](auto const& data) { return strHex(data); }, + [](auto const& keys, auto const& data) { return keys.signHex(data); }); + } + + void + testWriteToFile() + { + testcase("Write to File"); + + using namespace boost::filesystem; + + auto const keyType = KeyType::Ed25519; + SigningKeys keys(keyType); + + { + path const subdir = "test_key_file"; + path const keyFile = subdir / "validator_keys.json"; + KeyFileGuard g(*this, subdir.string()); + + keys.writeToFile(keyFile); + BEAST_EXPECT(exists(keyFile)); + + { + auto fileKeys = SigningKeys::make_SigningKeys(keyFile); + BEAST_EXPECT(keys == fileKeys); + + // Overwrite file with new sequence + keys.createValidatorToken(KeyType::Secp256k1); + keys.writeToFile(keyFile); + } + + { + auto const fileKeys = SigningKeys::make_SigningKeys(keyFile); + BEAST_EXPECT(keys == fileKeys); + } + } + { + // Write to key file in current relative directory + path const keyFile = "test_validator_keys.json"; + if (!exists(keyFile)) + { + keys.writeToFile(keyFile); + remove(keyFile.string()); + } + else + { + // Cannot run the test. Someone created a file + // where we want to put our key file + Throw("Cannot create key file: " + keyFile.string()); + } + } + { + // Create key file directory + path const subdir = "test_key_file"; + path const keyFile = subdir / "directories/to/create/validator_keys.json"; + KeyFileGuard g(*this, subdir.string()); + + keys.writeToFile(keyFile); + BEAST_EXPECT(exists(keyFile)); + + auto const fileKeys = SigningKeys::make_SigningKeys(keyFile); + BEAST_EXPECT(keys == fileKeys); + } + { + // Fail if file cannot be opened for write + path const subdir = "test_key_file"; + KeyFileGuard g(*this, subdir.string()); + + path const badKeyFile = subdir / "."; + auto expectedError = "Cannot open key file: " + badKeyFile.string(); + std::string error; + try + { + keys.writeToFile(badKeyFile); + fail(); + } + catch (std::runtime_error& e) + { + error = e.what(); + } + BEAST_EXPECT(error == expectedError); + + // Fail if parent directory is existing file + path const keyFile = subdir / "validator_keys.json"; + keys.writeToFile(keyFile); + path const conflictingPath = keyFile / "validators_keys.json"; + expectedError = "Cannot create directory: " + conflictingPath.parent_path().string(); + try + { + keys.writeToFile(conflictingPath); + fail(); + } + catch (std::runtime_error& e) + { + error = e.what(); + } + BEAST_EXPECT(error == expectedError); + } + } + + //////////////////////////////////////////// + // Tests related to using external keys + // + // These tests will use two SigningKeys objects, + // one with a secret key representing the external + // signing mechanism, and one only containing the + // public key from the first representing the real + // worker. + //////////////////////////////////////////// + + void + testExternalMakeValidatorKeys() + { + testcase("Make External Validator Keys"); + + using namespace boost::filesystem; + + path const subdir = "test_key_file"; + path const externalKeyFile = subdir / "validator_keys_external.json"; + path const keyFile = subdir / "validator_keys.json"; + + for (auto const keyType : keyTypes) + { + SigningKeys const externalKeys(keyType); + + KeyFileGuard const g(*this, subdir.string()); + + externalKeys.writeToFile(externalKeyFile); + BEAST_EXPECT(exists(externalKeyFile)); + + SigningKeys const keys(keyType, externalKeys.publicKey()); + keys.writeToFile(keyFile); + BEAST_EXPECT(exists(keyFile)); + + auto const keys2 = SigningKeys::make_SigningKeys(keyFile); + BEAST_EXPECT(keys == keys2); + } + { + // Require expected fields + KeyFileGuard g(*this, subdir.string()); + + auto expectedError = "Failed to open key file: " + keyFile.string(); + std::string error; + + json::Value jv; + jv["key_type"] = "dummy keytype"; + + jv["secret_key"] = "external"; + expectedError = + "Key file '" + keyFile.string() + "' is missing \"token_sequence\" field"; + testKeyFile(keyFile, jv, expectedError); + + jv["token_sequence"] = "dummy sequence"; + expectedError = "Key file '" + keyFile.string() + "' is missing \"revoked\" field"; + testKeyFile(keyFile, jv, expectedError); + + jv["revoked"] = "dummy revoked"; + expectedError = "Key file '" + keyFile.string() + + "' contains invalid \"key_type\" field: " + jv["key_type"].toStyledString(); + testKeyFile(keyFile, jv, expectedError); + + auto const keyType = KeyType::Ed25519; + jv["key_type"] = to_string(keyType); + expectedError = "Key file '" + keyFile.string() + "' is missing \"public_key\" field"; + testKeyFile(keyFile, jv, expectedError); + + jv["public_key"] = "dummy public"; + expectedError = "Key file '" + keyFile.string() + + "' contains invalid \"public_key\" field: " + jv["public_key"].toStyledString(); + testKeyFile(keyFile, jv, expectedError); + + SigningKeys const keys(keyType); + { + auto const kp = generateKeyPair(keyType, randomSeed()); + jv["public_key"] = toBase58(TokenType::NodePublic, kp.first); + } + expectedError = "Key file '" + keyFile.string() + + "' contains invalid \"token_sequence\" field: " + + jv["token_sequence"].toStyledString(); + testKeyFile(keyFile, jv, expectedError); + + jv["token_sequence"] = -1; + expectedError = "Key file '" + keyFile.string() + + "' contains invalid \"token_sequence\" field: " + + jv["token_sequence"].toStyledString(); + testKeyFile(keyFile, jv, expectedError); + + jv["token_sequence"] = json::UInt(std::numeric_limits::max()); + expectedError = "Key file '" + keyFile.string() + + "' contains invalid \"revoked\" field: " + jv["revoked"].toStyledString(); + testKeyFile(keyFile, jv, expectedError); + + jv["revoked"] = false; + expectedError = ""; + testKeyFile(keyFile, jv, expectedError); + + jv["revoked"] = true; + testKeyFile(keyFile, jv, expectedError); + } + } + + void + testExternalCreateValidatorToken() + { + testcase("Create External Validator Token"); + + using namespace std::string_literals; + + for (auto const keyType : keyTypes) + { + SigningKeys const externalKeys(keyType); + SigningKeys keys(keyType, externalKeys.publicKey()); + std::uint32_t sequence = 0; + + for (auto const tokenKeyType : keyTypes) + { + try + { + auto const token = keys.createValidatorToken(tokenKeyType); + fail(); + } + catch (std::exception const& e) + { + BEAST_EXPECT(e.what() == "This key file cannot be used to sign tokens."s); + } + + auto const start = keys.startValidatorToken(tokenKeyType); + + if (!BEAST_EXPECT(start)) + continue; + + auto const sig = externalKeys.signHex(*start); + auto const sigBlob = strUnHex(sig); + if (!BEAST_EXPECT(sigBlob)) + continue; + auto const token = keys.finishToken(*sigBlob); + + if (!BEAST_EXPECT(token)) + continue; + + auto const tokenPublicKey = derivePublicKey(tokenKeyType, token->validationSecret); + + STObject st(sfGeneric); + auto const manifest = xrpl::base64Decode(token->manifest); + SerialIter sit(manifest.data(), manifest.size()); + st.set(sit); + + auto const seq = get(st, sfSequence); + BEAST_EXPECT(seq); + BEAST_EXPECT(*seq == ++sequence); + + auto const tpk = get(st, sfSigningPubKey); + BEAST_EXPECT(tpk); + BEAST_EXPECT(*tpk == tokenPublicKey); + BEAST_EXPECT(verify(st, HashPrefix::Manifest, tokenPublicKey)); + + auto const pk = get(st, sfPublicKey); + BEAST_EXPECT(pk); + BEAST_EXPECT(*pk == keys.publicKey()); + BEAST_EXPECT(verify(st, HashPrefix::Manifest, keys.publicKey(), sfMasterSignature)); + + try + { + keys.verifyManifest(); + } + catch (std::exception const& e) + { + fail(e.what()); + } + } + } + + auto const keyType = KeyType::Ed25519; + auto const kp = generateKeyPair(keyType, randomSeed()); + + { + // The next sequence is the special "revoked" value + auto keys = + SigningKeys(keyType, kp.first, std::numeric_limits::max() - 1); + + BEAST_EXPECT(!keys.startValidatorToken(keyType)); + } + + { + // Key is revoked + auto keys = SigningKeys(keyType, kp.first, std::numeric_limits::max()); + + BEAST_EXPECT(!keys.startValidatorToken(keyType)); + } + } + + void + testExternalRevoke() + { + testcase("External Revoke"); + + using namespace std::string_literals; + + for (auto const keyType : keyTypes) + { + SigningKeys const externalKeys(keyType); + SigningKeys keys(keyType, externalKeys.publicKey()); + + try + { + auto const revocation = keys.revoke(); + fail(); + } + catch (std::exception const& e) + { + BEAST_EXPECT(e.what() == "This key file cannot be used to sign tokens."s); + } + auto const start = keys.startRevoke(); + auto const sig = externalKeys.signHex(start); + auto const sigBlob = strUnHex(sig); + if (!BEAST_EXPECT(sigBlob)) + continue; + + auto const revocation = keys.finishRevoke(*sigBlob); + + STObject st(sfGeneric); + auto const manifest = xrpl::base64Decode(revocation); + SerialIter sit(manifest.data(), manifest.size()); + st.set(sit); + + auto const seq = get(st, sfSequence); + BEAST_EXPECT(seq); + BEAST_EXPECT(*seq == std::numeric_limits::max()); + + auto const pk = get(st, sfPublicKey); + BEAST_EXPECT(pk); + BEAST_EXPECT(*pk == keys.publicKey()); + BEAST_EXPECT(verify(st, HashPrefix::Manifest, keys.publicKey(), sfMasterSignature)); + + try + { + keys.verifyManifest(); + } + catch (std::exception const& e) + { + fail(e.what()); + } + } + } + + void + testExternalWriteToFile() + { + testcase("External Write to File"); + + using namespace boost::filesystem; + + auto const keyType = KeyType::Ed25519; + SigningKeys const externalKeys(keyType); + SigningKeys keys(keyType, externalKeys.publicKey()); + + { + path const subdir = "test_key_file"; + path const keyFile = subdir / "validator_keys.json"; + KeyFileGuard g(*this, subdir.string()); + + keys.writeToFile(keyFile); + BEAST_EXPECT(exists(keyFile)); + + { + auto const sigBlob = [&]() -> std::optional { + auto fileKeys = SigningKeys::make_SigningKeys(keyFile); + BEAST_EXPECT(keys == fileKeys); + + // Prepare to write new sequence + auto const start = keys.startValidatorToken(KeyType::Secp256k1); + if (!BEAST_EXPECT(start)) + return std::nullopt; + // keys looks the same as the original file (though + // the pending fields have changed) + BEAST_EXPECT(keys == fileKeys); + keys.writeToFile(keyFile); + + auto const sig = externalKeys.signHex(*start); + auto const sigBlob = strUnHex(sig); + return sigBlob; + }(); + auto fileKeys = SigningKeys::make_SigningKeys(keyFile); + BEAST_EXPECT(keys == fileKeys); + + if (!sigBlob) + return; + + // Overwrite file with new sequence + auto const token = keys.finishToken(*sigBlob); + if (!BEAST_EXPECT(token)) + return; + BEAST_EXPECT(keys != fileKeys); + keys.writeToFile(keyFile); + } + + { + auto const fileKeys = SigningKeys::make_SigningKeys(keyFile); + BEAST_EXPECT(keys == fileKeys); + } + } + } + +public: + void + run() override + { + testMakeSigningKeys(); + testCreateValidatorToken(); + testRevoke(); + testSign(); + testSignHex(); + testWriteToFile(); + // External + testExternalMakeValidatorKeys(); + testExternalCreateValidatorToken(); + testExternalRevoke(); + testExternalWriteToFile(); + } +}; + +BEAST_DEFINE_TESTSUITE(SigningKeys, keys, xrpl); + +} // namespace tests + +} // namespace xrpl diff --git a/src/tools/validator-keys/test/ValidatorKeysTool_test.cpp b/src/tools/validator-keys/test/ValidatorKeysTool_test.cpp new file mode 100644 index 0000000000..91c699124c --- /dev/null +++ b/src/tools/validator-keys/test/ValidatorKeysTool_test.cpp @@ -0,0 +1,923 @@ +#include +#include +#include + +#include + +#include +#include +#include +#include + +namespace xrpl { + +namespace tests { + +class ValidatorKeysTool_test : public beast::unit_test::Suite +{ +private: + static ToolOptions + toolOptions(boost::filesystem::path const& keyFile) + { + ToolOptions options; + options.keyFile = keyFile; + return options; + } + + // Allow a stream to be redirected. Destructor restores old streambuf. + class Redirect + { + public: + Redirect(std::ostream& stream, std::stringstream& sStream) + : stream_(stream), old_(stream_.rdbuf(sStream.rdbuf())) + { + } + + virtual ~Redirect() + { + stream_.rdbuf(old_); + } + + private: + std::ostream& stream_; + std::streambuf* const old_; + }; + + // Allow cout to be redirected. Destructor restores old cout streambuf. + class CoutRedirect : public Redirect + { + public: + CoutRedirect(std::stringstream& sStream) : Redirect(std::cout, sStream) + { + } + + ~CoutRedirect() + { + } + }; + + void + testCreateKeyFile() + { + testcase("Create Key File"); + + std::stringstream coutCapture; + CoutRedirect coutRedirect{coutCapture}; + + using namespace boost::filesystem; + + path const subdir = "test_key_file"; + KeyFileGuard const g(*this, subdir.string()); + path const keyFile = subdir / "validator_keys.json"; + + createKeyFile(keyFile); + BEAST_EXPECT(exists(keyFile)); + + std::string const expectedError = + "Refusing to overwrite existing key file: " + keyFile.string(); + std::string error; + try + { + createKeyFile(keyFile); + fail(); + } + catch (std::exception const& e) + { + error = e.what(); + } + BEAST_EXPECT(error == expectedError); + } + + void + testCreateToken() + { + testcase("Create Token"); + + std::stringstream coutCapture; + CoutRedirect coutRedirect{coutCapture}; + + using namespace boost::filesystem; + + path const subdir = "test_key_file"; + KeyFileGuard const g(*this, subdir.string()); + path const keyFile = subdir / "validator_keys.json"; + + auto testToken = [this](path const& keyFile, std::string const& expectedError) { + try + { + createToken(toolOptions(keyFile)); + BEAST_EXPECT(expectedError.empty()); + } + catch (std::exception const& e) + { + BEAST_EXPECT(e.what() == expectedError); + } + }; + + { + std::string const expectedError = "Failed to open key file: " + keyFile.string(); + testToken(keyFile, expectedError); + } + + createKeyFile(keyFile); + + { + std::string const expectedError = ""; + testToken(keyFile, expectedError); + } + { + auto const keyType = KeyType::Ed25519; + auto const kp = generateKeyPair(keyType, randomSeed()); + + auto keys = + SigningKeys(keyType, kp.second, std::numeric_limits::max() - 1); + + keys.writeToFile(keyFile); + std::string const expectedError = + "Maximum number of tokens have already been generated.\n" + "Revoke validator keys if previous token has been compromised."; + testToken(keyFile, expectedError); + } + { + createRevocation(keyFile); + std::string const expectedError = "Validator keys have been revoked."; + testToken(keyFile, expectedError); + } + } + + void + testCreateRevocation() + { + testcase("Create Revocation"); + + std::stringstream coutCapture; + CoutRedirect coutRedirect{coutCapture}; + + using namespace boost::filesystem; + + path const subdir = "test_key_file"; + KeyFileGuard const g(*this, subdir.string()); + path const keyFile = subdir / "validator_keys.json"; + + auto expectedError = "Failed to open key file: " + keyFile.string(); + std::string error; + try + { + createRevocation(keyFile); + fail(); + } + catch (std::runtime_error& e) + { + error = e.what(); + } + BEAST_EXPECT(error == expectedError); + + createKeyFile(keyFile); + BEAST_EXPECT(exists(keyFile)); + + createRevocation(keyFile); + createRevocation(keyFile); + } + + void + testCreateKeyFileExternal() + { + testcase("Create Key File External"); + + std::stringstream coutCapture; + CoutRedirect coutRedirect{coutCapture}; + + using namespace boost::filesystem; + + path const subdir = "test_key_file"; + path const keyFile = subdir / "validator_keys.json"; + + // The externalKey will contain a secret key, and be used + // to simulate the actions of an actual external signing device + // or process. Note that it is const and not written to disk. + SigningKeys const externalKey(KeyType::Ed25519); + + auto testCreate = [this, &subdir, &keyFile]( + std::string pubKey, std::string const& expectedError) { + KeyFileGuard const g(*this, subdir.string()); + + try + { + createExternal(pubKey, keyFile); + BEAST_EXPECT(expectedError.empty()); + } + catch (std::exception const& e) + { + BEAST_EXPECT(e.what() == expectedError); + } + }; + // Test a few different ways to create the file, and remove the file in + // between + { + std::string const pubKey(strHex(externalKey.publicKey())); + std::string const expectedError; + + testCreate(pubKey, expectedError); + } + { + auto const& key = externalKey.publicKey(); + std::string const pubKey(base64Encode(key.data(), key.size())); + std::string const expectedError; + + testCreate(pubKey, expectedError); + } + { + std::string badPubKey(strHex(externalKey.publicKey())); + badPubKey.insert(badPubKey.size() / 2, "n"); + std::string const expectedError = "Unable to parse public key: " + badPubKey; + + testCreate(badPubKey, expectedError); + } + { + std::string const badPubKey = "abcd"; + std::string const expectedError = "Unable to parse public key: " + badPubKey; + + testCreate(badPubKey, expectedError); + } + + // Use one file for the remainder of the tests + KeyFileGuard const g(*this, subdir.string()); + + std::string const pubKey(toBase58(TokenType::NodePublic, externalKey.publicKey())); + + createExternal(pubKey, keyFile); + + BEAST_EXPECT(exists(keyFile)); + + std::string const expectedError = + "Refusing to overwrite existing key file: " + keyFile.string(); + std::string error; + try + { + createExternal(pubKey, keyFile); + fail(); + } + catch (std::exception const& e) + { + error = e.what(); + } + BEAST_EXPECT(error == expectedError); + } + + void + testCreateTokenExternal() + { + testcase("Create Token External"); + + std::stringstream coutCapture; + CoutRedirect coutRedirect{coutCapture}; + + using namespace boost::filesystem; + + path const subdir = "test_key_file"; + KeyFileGuard const g(*this, subdir.string()); + path const keyFile = subdir / "validator_keys.json"; + + // The external key will contain a secret key, and be used + // to simulate the actions of an actual external signing device + // or process. Note that it is const. + KeyType const externalKeyType = KeyType::Ed25519; + SigningKeys const externalKey(externalKeyType); + std::string const pubKey(toBase58(TokenType::NodePublic, externalKey.publicKey())); + + auto testStart = [this](path const& keyFile, std::string const& expectedError) { + std::stringstream capture; + CoutRedirect coutRedirect{capture}; + try + { + startToken(toolOptions(keyFile)); + BEAST_EXPECT(expectedError.empty()); + return capture.str(); + } + catch (std::exception const& e) + { + BEAST_EXPECT(e.what() == expectedError); + } + return std::string(); + }; + + auto testFinish = + [this](std::string const& sig, path const& keyFile, std::string const& expectedError) { + try + { + finishToken({sig}, toolOptions(keyFile)); + BEAST_EXPECT(expectedError.empty()); + } + catch (std::exception const& e) + { + BEAST_EXPECT(e.what() == expectedError); + } + }; + + { + std::string const expectedError = "Failed to open key file: " + keyFile.string(); + BEAST_EXPECT(testStart(keyFile, expectedError).empty()); + } + + createExternal(pubKey, keyFile); + + std::string const noError = ""; + { + auto const start = testStart(keyFile, noError); + BEAST_EXPECT(!start.empty()); + auto const sig = externalKey.signHex(start); + testFinish(sig, keyFile, noError); + } + { + auto const start = testStart(keyFile, noError); + BEAST_EXPECT(!start.empty()); + auto const sig = [&]() { + auto sigBlob = strUnHex(externalKey.signHex(start)); + if (BEAST_EXPECT(sigBlob)) + return base64Encode(sigBlob->data(), sigBlob->size()); + return base64Encode("fail"); + }(); + + testFinish(sig, keyFile, noError); + } + { + std::string const expectedError = "Manifest is not properly signed"; + auto const start = testStart(keyFile, noError); + BEAST_EXPECT(!start.empty()); + auto const sig = externalKey.sign("foo"); + testFinish(sig, keyFile, expectedError); + } + { + std::string const expectedError = "Invalid master signature"; + auto const start = testStart(keyFile, noError); + BEAST_EXPECT(!start.empty()); + auto const sig = "bad signature"; + testFinish(sig, keyFile, expectedError); + } + { + { + // Need to ensure any pending token is gone. Best + // way to do that is to generate one successfully + auto const start = testStart(keyFile, noError); + BEAST_EXPECT(!start.empty()); + auto const sig = externalKey.signHex(start); + testFinish(sig, keyFile, noError); + } + + std::string const expectedError = "No pending token to finish"; + auto const sig = externalKey.sign("foo"); + testFinish(sig, keyFile, expectedError); + } + { + auto keys = SigningKeys( + externalKeyType, + externalKey.publicKey(), + std::numeric_limits::max() - 1); + + keys.writeToFile(keyFile); + std::string const expectedError = + "Maximum number of tokens have already been generated.\n" + "Revoke validator keys if previous token has been compromised."; + BEAST_EXPECT(testStart(keyFile, expectedError).empty()); + } + { + // Create the file revoked + auto keys = SigningKeys(externalKeyType, externalKey.publicKey(), 42, true); + + keys.writeToFile(keyFile); + std::string const expectedError = "Validator keys have been revoked."; + BEAST_EXPECT(testStart(keyFile, expectedError).empty()); + } + } + + void + testCreateRevocationExternal() + { + testcase("Create Revocation External"); + + std::stringstream coutCapture; + CoutRedirect coutRedirect{coutCapture}; + + using namespace boost::filesystem; + + path const subdir = "test_key_file"; + KeyFileGuard const g(*this, subdir.string()); + path const keyFile = subdir / "validator_keys.json"; + + // The external key will contain a secret key, and be used + // to simulate the actions of an actual external signing device + // or process. Note that it is const. + SigningKeys const externalKey(KeyType::Ed25519); + std::string const pubKey(toBase58(TokenType::NodePublic, externalKey.publicKey())); + + auto testStartRevoke = [this]( + path const& keyFile, + std::string const& expectedError, + bool expectRevoked = true) { + std::stringstream capture; + std::stringstream errCapture; + CoutRedirect coutRedirect{capture}; + Redirect cerrRedirect{std::cerr, errCapture}; + try + { + startRevocation(keyFile); + BEAST_EXPECT(expectedError.empty()); + if (expectRevoked) + BEAST_EXPECT( + errCapture.str() == + "WARNING: Validator keys have already been " + "revoked!\n\n"); + else + BEAST_EXPECT( + errCapture.str() == "WARNING: This will revoke your validator keys!\n\n"); + + return capture.str(); + } + catch (std::exception const& e) + { + BEAST_EXPECT(e.what() == expectedError); + } + return std::string(); + }; + + auto testFinishRevoke = + [this](std::string const& sig, path const& keyFile, std::string const& expectedError) { + try + { + finishRevocation(sig, keyFile); + BEAST_EXPECT(expectedError.empty()); + } + catch (std::exception const& e) + { + BEAST_EXPECT(e.what() == expectedError); + } + }; + + std::string const noError = ""; + { + auto const expectedError = "Failed to open key file: " + keyFile.string(); + testStartRevoke(keyFile, expectedError); + } + + createExternal(pubKey, keyFile); + BEAST_EXPECT(exists(keyFile)); + + { + auto const start = testStartRevoke(keyFile, noError, false); + BEAST_EXPECT(!start.empty()); + auto const sig = externalKey.signHex(start); + testFinishRevoke(sig, keyFile, noError); + } + { + auto const start = testStartRevoke(keyFile, noError); + BEAST_EXPECT(!start.empty()); + auto const sig = [&]() { + auto sigBlob = strUnHex(externalKey.signHex(start)); + if (BEAST_EXPECT(sigBlob)) + return base64Encode(sigBlob->data(), sigBlob->size()); + return base64Encode("fail"); + }(); + testFinishRevoke(sig, keyFile, noError); + } + + { + // keys can be revoked multiple times + auto const start = testStartRevoke(keyFile, noError); + BEAST_EXPECT(!start.empty()); + auto const sig = externalKey.signHex(start); + testFinishRevoke(sig, keyFile, noError); + } + { + std::string const expectedError = "Manifest is not properly signed"; + auto const start = testStartRevoke(keyFile, noError); + BEAST_EXPECT(!start.empty()); + auto const sig = externalKey.sign("foo"); + testFinishRevoke(sig, keyFile, expectedError); + } + { + std::string const expectedError = "Invalid master signature"; + auto const start = testStartRevoke(keyFile, noError); + BEAST_EXPECT(!start.empty()); + auto const sig = "bad signature"; + testFinishRevoke(sig, keyFile, expectedError); + } + { + // Unlike tokens, which have a random key and a changing sequence, + // revocations are fixed, so as long as a valid signature has been + // generated, it can be reused. Same idea as how a signed revocation + // can be stored and released at any time. + // Generate a revocation successfully + auto const start = testStartRevoke(keyFile, noError); + BEAST_EXPECT(!start.empty()); + auto const sig = externalKey.signHex(start); + testFinishRevoke(sig, keyFile, noError); + + // Reuse the signature. + testFinishRevoke(sig, keyFile, noError); + } + } + + void + testSign() + { + testcase("Sign"); + + std::stringstream coutCapture; + CoutRedirect coutRedirect{coutCapture}; + + using namespace boost::filesystem; + + auto testSign = + [this](std::string const& data, path const& keyFile, std::string const& expectedError) { + try + { + signData(data, keyFile); + BEAST_EXPECT(expectedError.empty()); + } + catch (std::exception const& e) + { + BEAST_EXPECT(e.what() == expectedError); + } + }; + + std::string const data = "data to sign"; + + path const subdir = "test_key_file"; + KeyFileGuard const g(*this, subdir.string()); + path const keyFile = subdir / "validator_keys.json"; + + { + std::string const expectedError = "Failed to open key file: " + keyFile.string(); + testSign(data, keyFile, expectedError); + } + + createKeyFile(keyFile); + BEAST_EXPECT(exists(keyFile)); + + { + std::string const emptyData = ""; + std::string const expectedError = "Syntax error: Must specify data string to sign"; + testSign(emptyData, keyFile, expectedError); + } + { + std::string const expectedError = ""; + testSign(data, keyFile, expectedError); + } + } + + void + testHexSign() + { + testcase("Sign Hex"); + + std::stringstream coutCapture; + CoutRedirect coutRedirect{coutCapture}; + + using namespace boost::filesystem; + + auto testSign = + [this](std::string const& data, path const& keyFile, std::string const& expectedError) { + try + { + signHexData(data, keyFile); + BEAST_EXPECT(expectedError.empty()); + } + catch (std::exception const& e) + { + BEAST_EXPECT(e.what() == expectedError); + } + }; + + std::string const rawdata = "data to sign"; + std::string const data = strHex(rawdata); + + path const subdir = "test_key_file"; + KeyFileGuard const g(*this, subdir.string()); + path const keyFile = subdir / "validator_keys.json"; + + { + std::string const expectedError = "Failed to open key file: " + keyFile.string(); + testSign(data, keyFile, expectedError); + } + + createKeyFile(keyFile); + BEAST_EXPECT(exists(keyFile)); + + { + std::string const emptyData = ""; + std::string const expectedError = "Syntax error: Must specify data string to sign"; + testSign(emptyData, keyFile, expectedError); + } + { + std::string const expectedError = ""; + testSign(data, keyFile, expectedError); + } + } + + void + testRunCommand() + { + testcase("Run Command"); + + std::stringstream coutCapture; + CoutRedirect coutRedirect{coutCapture}; + + using namespace boost::filesystem; + + path const subdir = "test_key_file"; + KeyFileGuard g(*this, subdir.string()); + path const keyFile = subdir / "validator_keys.json"; + + auto testCommand = [this]( + std::string const& command, + std::vector const& args, + path const& keyFile, + std::string const& expectedError) { + try + { + runCommand(command, args, toolOptions(keyFile)); + BEAST_EXPECT(expectedError.empty()); + } + catch (std::exception const& e) + { + BEAST_EXPECT(e.what() == expectedError); + } + }; + + std::vector const noArgs; + std::vector const oneArg = {"some data"}; + std::vector const oneHexArg = {strHex(oneArg[0])}; + std::vector const oneDomainArg = {"validator.example.com"}; + std::vector const twoArgs = {"data", "more data"}; + std::string const noError = ""; + std::string const argError = "Syntax error: Wrong number of arguments"; + { + std::string const command = "unknown"; + std::string const expectedError = "Unknown command: " + command; + testCommand(command, noArgs, keyFile, expectedError); + testCommand(command, oneArg, keyFile, expectedError); + testCommand(command, twoArgs, keyFile, expectedError); + } + { + std::string const command = "create_keys"; + testCommand(command, noArgs, keyFile, noError); + testCommand(command, oneArg, keyFile, argError); + testCommand(command, twoArgs, keyFile, argError); + } + { + std::string const command = "create_token"; + testCommand(command, noArgs, keyFile, noError); + testCommand(command, oneArg, keyFile, argError); + testCommand(command, twoArgs, keyFile, argError); + } + { + std::string const command = "set_domain"; + testCommand(command, noArgs, keyFile, argError); + testCommand(command, oneDomainArg, keyFile, noError); + testCommand(command, twoArgs, keyFile, argError); + } + { + std::string const command = "attest_domain"; + testCommand(command, noArgs, keyFile, noError); + testCommand(command, oneArg, keyFile, argError); + testCommand(command, twoArgs, keyFile, argError); + } + { + std::string const command = "clear_domain"; + testCommand(command, noArgs, keyFile, noError); + testCommand(command, oneArg, keyFile, argError); + testCommand(command, twoArgs, keyFile, argError); + } + { + std::string const command = "show_manifest"; + testCommand(command, noArgs, keyFile, argError); + testCommand(command, oneArg, keyFile, noError); + testCommand(command, twoArgs, keyFile, argError); + } + { + std::string const command = "revoke_keys"; + testCommand(command, noArgs, keyFile, noError); + testCommand(command, oneArg, keyFile, argError); + testCommand(command, twoArgs, keyFile, argError); + } + { + std::string const command = "sign"; + testCommand(command, noArgs, keyFile, argError); + testCommand(command, oneArg, keyFile, noError); + testCommand(command, twoArgs, keyFile, argError); + } + { + std::string const command = "sign_hex"; + testCommand(command, noArgs, keyFile, argError); + testCommand(command, oneHexArg, keyFile, noError); + testCommand(command, twoArgs, keyFile, argError); + } + + // External signing functionality. + std::string const pkArg = [&]() { + SigningKeys const keys = SigningKeys::make_SigningKeys(keyFile); + return toBase58(TokenType::NodePublic, keys.publicKey()); + }(); + { + // Purposely shadow "keyFile" from the outer context + // to prevent reuse + path const keyFile = subdir / "validator_keys_ext.json"; + // For the functions that expect a signature, don't pass in a + // valid signature. This is the error that is returned. + std::string const masterKeyError = "Invalid master signature"; + + { + std::string const command = "create_external"; + testCommand(command, noArgs, keyFile, argError); + testCommand(command, {pkArg}, keyFile, noError); + testCommand(command, twoArgs, keyFile, argError); + } + { + std::string const command = "start_token"; + testCommand(command, noArgs, keyFile, noError); + testCommand(command, oneArg, keyFile, argError); + testCommand(command, twoArgs, keyFile, argError); + } + { + // One signature finishes a software-signed token, two finish a + // token whose signing key is external. + std::string const command = "finish_token"; + std::vector const threeArgs = {"a", "b", "c"}; + testCommand(command, noArgs, keyFile, argError); + testCommand(command, oneArg, keyFile, masterKeyError); + testCommand(command, twoArgs, keyFile, masterKeyError); + testCommand(command, threeArgs, keyFile, argError); + } + { + std::stringstream ignore; + Redirect errRedirect(std::cerr, ignore); + std::string const command = "start_revoke_keys"; + testCommand(command, noArgs, keyFile, noError); + testCommand(command, oneArg, keyFile, argError); + testCommand(command, twoArgs, keyFile, argError); + } + { + std::string const command = "finish_revoke_keys"; + testCommand(command, noArgs, keyFile, argError); + testCommand(command, oneArg, keyFile, masterKeyError); + testCommand(command, twoArgs, keyFile, argError); + } + } + } + + void + testListCommands() + { + testcase("List Commands"); + + std::stringstream coutCapture; + CoutRedirect coutRedirect{coutCapture}; + + using namespace boost::filesystem; + + path const subdir = "test_key_file"; + KeyFileGuard const g(*this, subdir.string()); + + auto run = [this]( + std::string const& command, + std::vector const& args, + ToolOptions const& options, + std::string const& expectedError) -> int { + try + { + auto const rc = runCommand(command, args, options); + BEAST_EXPECTS(expectedError.empty(), "expected: " + expectedError); + return rc; + } + catch (std::exception const& e) + { + BEAST_EXPECTS(e.what() == expectedError, e.what()); + return -1; + } + }; + + // The publisher: a key file and a token carrying an ed25519 signing key + ToolOptions publisher; + publisher.keyFile = subdir / "publisher.json"; + publisher.tokenKeyType = KeyType::Ed25519; + publisher.tokenFile = subdir / "publisher-token.txt"; + publisher.outFile = publisher.tokenFile; + run("create_keys", {}, publisher, ""); + run("create_token", {}, publisher, ""); + BEAST_EXPECT(exists(*publisher.tokenFile)); + publisher.outFile.reset(); + + // Two validators, each with a token whose manifest goes in the list + std::string validators; + for (int i = 0; i < 2; ++i) + { + SigningKeys keys(KeyType::Ed25519); + auto const token = keys.createValidatorToken(KeyType::Secp256k1); + validators += (i ? ", " : "") + std::string("{\"validation_public_key\": \"") + + strHex(keys.publicKey()) + "\", \"manifest\": \"" + token->manifest + "\"}"; + } + auto const now = rippleEpochNow(); + path const unsignedList = subdir / "unsigned.json"; + { + std::ofstream o(unsignedList.string()); + o << "{\"sequence\": 2026091301, \"expiration\": " << now + 3600 + << ", \"validators\": [" << validators << "]}\n"; + } + + // sign_list needs a token + { + ToolOptions noToken = publisher; + noToken.tokenFile.reset(); + run("sign_list", {unsignedList.string()}, noToken, "sign_list needs --token-file"); + } + + // Sign, then verify with every check on + ToolOptions signer = publisher; + signer.outFile = subdir / "vl.json"; + run("sign_list", {unsignedList.string()}, signer, ""); + BEAST_EXPECT(exists(*signer.outFile)); + + ToolOptions verifier; + verifier.keyFile = publisher.keyFile; + verifier.validatorsFile = unsignedList; + verifier.expectedKey = SigningKeys::make_SigningKeys(publisher.keyFile).publicKey(); + BEAST_EXPECT(run("verify_list", {signer.outFile->string()}, verifier, "") == 0); + + // The wrong expected key fails verification + { + ToolOptions wrong = verifier; + wrong.expectedKey = SigningKeys(KeyType::Ed25519).publicKey(); + BEAST_EXPECT(run("verify_list", {signer.outFile->string()}, wrong, "") == 1); + } + + // Version 2, appended to itself once + ToolOptions v2 = signer; + v2.listVersion = 2; + v2.outFile = subdir / "vl2.json"; + run("sign_list", {unsignedList.string()}, v2, ""); + v2.appendFile = v2.outFile; + v2.outFile = subdir / "vl2b.json"; + run("sign_list", {unsignedList.string()}, v2, ""); + BEAST_EXPECT(run("verify_list", {v2.outFile->string()}, verifier, "") == 0); + + // External signing key: the master delegates to a key it never holds, + // then the list is signed in two steps with that key. + SigningKeys const external(KeyType::Ed25519); + auto master = SigningKeys::make_SigningKeys(publisher.keyFile); + auto const toSign = master.startValidatorToken(KeyType::Ed25519, external.publicKey()); + BEAST_EXPECT(toSign); + auto const manifest = master.finishExternalToken( + *strUnHex(master.signHex(*toSign)), *strUnHex(external.signHex(*toSign))); + BEAST_EXPECT(manifest); + master.writeToFile(publisher.keyFile); + + ToolOptions hardware; + hardware.keyFile = publisher.keyFile; + hardware.manifestFile = subdir / "manifest.txt"; + { + std::ofstream o(hardware.manifestFile->string()); + o << *manifest << "\n"; + } + run("start_sign_list", + {unsignedList.string()}, + publisher, + "start_sign_list needs --manifest-file"); + coutCapture.str(""); + run("start_sign_list", {unsignedList.string()}, hardware, ""); + auto bytes = coutCapture.str(); + boost::algorithm::trim(bytes); + BEAST_EXPECT(!bytes.empty()); + + hardware.outFile = subdir / "vl-external.json"; + run("finish_sign_list", + {master.signHex(bytes), unsignedList.string()}, + hardware, + "The signature does not verify under the manifest's signing key"); + run("finish_sign_list", {external.signHex(bytes), unsignedList.string()}, hardware, ""); + BEAST_EXPECT(run("verify_list", {hardware.outFile->string()}, verifier, "") == 0); + } + +public: + void + run() override + { + getVersionString(); + + testCreateKeyFile(); + testCreateToken(); + testCreateRevocation(); + testCreateKeyFileExternal(); + testCreateTokenExternal(); + testCreateRevocationExternal(); + testSign(); + testHexSign(); + testRunCommand(); + testListCommands(); + } +}; + +BEAST_DEFINE_TESTSUITE(ValidatorKeysTool, keys, xrpl); + +} // namespace tests + +} // namespace xrpl