diff --git a/.github/scripts/levelization/results/ordering.txt b/.github/scripts/levelization/results/ordering.txt index 5577c363fd..a1f7a17b75 100644 --- a/.github/scripts/levelization/results/ordering.txt +++ b/.github/scripts/levelization/results/ordering.txt @@ -194,6 +194,15 @@ tests.libxrpl > xrpl.resource tests.libxrpl > xrpl.server tests.libxrpl > xrpl.shamap tests.libxrpl > xrpl.tx +tests.tools > tools.validator-keys +tests.tools > xrpl.basics +tests.tools > xrpl.json +tests.tools > xrpl.protocol +tests.tools > xrpl.server +tools.validator-keys > xrpl.basics +tools.validator-keys > xrpl.json +tools.validator-keys > xrpl.protocol +tools.validator-keys > xrpl.server xrpl.conditions > xrpl.basics xrpl.conditions > xrpl.protocol xrpl.config > xrpl.basics diff --git a/.github/workflows/reusable-build-test-config.yml b/.github/workflows/reusable-build-test-config.yml index a37ab386b8..14b32b7536 100644 --- a/.github/workflows/reusable-build-test-config.yml +++ b/.github/workflows/reusable-build-test-config.yml @@ -277,9 +277,9 @@ jobs: if-no-files-found: error - name: Run the validator-keys tests - if: ${{ env.VALIDATOR_KEYS_ENABLED == 'true' }} + if: ${{ env.VALIDATOR_KEYS_ENABLED == 'true' && !inputs.build_only }} working-directory: ${{ env.BUILD_DIR }} - run: ./validator-keys --unittest + run: ./validator_keys_tests - name: Upload the validator-keys binary if: ${{ env.PACKAGING_ARTIFACTS_ENABLED == 'true' && env.VALIDATOR_KEYS_ENABLED == 'true' }} diff --git a/AGENTS.md b/AGENTS.md index 85bf9befb2..0b07924eb0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,5 +38,6 @@ Paths below reflect the current layout; update this section if modularization mo - `include/xrpl/` + `src/libxrpl/` — the core protocol library: ledger, shamap, consensus, crypto, json, resource, nodestore, rdb, peerfinder, and `tx/` (transaction application: `Transactor.cpp`, `applySteps.cpp`, invariants, payment paths). `tx/transactors/` has one file per transaction type, grouped by subsystem: `escrow/`, `vault/`, `lending/`, `sponsor/`, `nft/`, `token/` (MPT), `payment_channel/`, `permissioned_domain/`, `dex/`, `oracle/`, `did/`, `credentials/`, `bridge/`, `check/`, `delegate/`, `account/`, `system/`. Any change to transaction-processing behavior must be gated behind an Amendment. - `src/xrpld/` — the server application built on top of `libxrpl`: `app`, `core`, `overlay` (P2P networking), `peerfinder`, `perflog`, `rpc`, `shamap`. `main` builds an `ApplicationImp` implementing `Application`; most components hold a reference to it (`app_`), giving broad cross-component access — expect to trace call chains through `Application&`. - `src/test/` — unit tests mirroring the subsystems above, plus `jtx/` (the transaction-building test DSL — e.g. `jtx/escrow.h`, `jtx/vault.h`, `jtx/sponsor.h`, `jtx/permissioned_dex.h`) and `unit_test/` (the custom test framework itself, derived from Beast). -- `src/tests/` — unit tests for `libxrpl` written in `gtest`, gradually replacing the `src/test` equivalents. +- `src/tests/` — unit tests for `libxrpl` written in `gtest`, gradually replacing the `src/test` equivalents; `src/tests/tools/` holds the `gtest` suites of the tools below. +- `src/tools/` — standalone binaries built on `libxrpl` behind their own CMake option: `validator-keys` (`-Dvalidator_keys=ON`), the validator and publisher key tool, whose library `xrpl.validator-keys` is what its tests link. - `crates/` — a Rust workspace (only built with `-Dxrpld -Drust=ON`) bridged into C++ via `cxxbridge`/the `cxx` crate; currently just a `hello_world` interop scaffold. Requires the Rust toolchain pinned in `rust-toolchain.toml` (the Nix devshell provides it automatically). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1035124f31..3bd219ccd3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -92,7 +92,8 @@ If you create new source files, they must be organized as follows: - If the files are in any of the `libxrpl` modules, the headers (`.h`) must go under `include/xrpl`, and source (`.cpp`) files must go under `src/libxrpl`. -- All other non-test files must go under `src/xrpld`. +- All other non-test files must go under `src/xrpld`, except a standalone tool + built as its own binary on top of `libxrpl`, which goes under `src/tools`. - New test source files should use `gtest` and go under `src/tests`, unless that isn't possible, in which case they should use our legacy test framework and go under `src/test`. - All benchmark source files must go under `src/benchmarks`. diff --git a/cmake/XrplValidatorKeys.cmake b/cmake/XrplValidatorKeys.cmake index 428a8f86b8..75590c001c 100644 --- a/cmake/XrplValidatorKeys.cmake +++ b/cmake/XrplValidatorKeys.cmake @@ -5,23 +5,8 @@ option( ) if(validator_keys) - # Own the install destination below rather than relying on another module - # having pulled this in first. - include(GNUInstallDirs) - add_subdirectory(src/tools/validator-keys) - set_target_properties( - validator-keys - 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( - "${CMAKE_SOURCE_DIR}/src/tools/validator-keys/LICENSE" - "${CMAKE_BINARY_DIR}/validator-keys-LICENSE" - COPYONLY - ) - install(TARGETS validator-keys RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}) + if(tests) + add_subdirectory(src/tests/tools/validator-keys) + endif() endif() diff --git a/package/debian/copyright b/package/debian/copyright index baaa12e13c..92213847d6 100644 --- a/package/debian/copyright +++ b/package/debian/copyright @@ -16,8 +16,7 @@ Copyright: 2016, Ripple Labs Inc. 2011, The Bitcoin developers 2003-2005, Tom Wu License: ISC and BSL-1.0 and MIT and Tom-Wu -Comment: Built from https://github.com/ripple/validator-keys-tool at the commit - pinned in cmake/XrplValidatorKeys.cmake. Besides ISC-licensed code it +Comment: Built from src/tools/validator-keys in this repository. Besides ISC-licensed code it incorporates work under the Boost Software License 1.0 (ASIO), the MIT/X11 license (Bitcoin) and Tom Wu's license, whose terms require its notice to be retained intact. The complete upstream notice is therefore shipped verbatim as diff --git a/src/tests/tools/validator-keys/CMakeLists.txt b/src/tests/tools/validator-keys/CMakeLists.txt new file mode 100644 index 0000000000..54887f6995 --- /dev/null +++ b/src/tests/tools/validator-keys/CMakeLists.txt @@ -0,0 +1,24 @@ +find_package(GTest REQUIRED) + +add_executable( + validator_keys_tests + Commands.cpp + ListSigning.cpp + SigningKeys.cpp + "${CMAKE_SOURCE_DIR}/src/tests/libxrpl/main.cpp" +) +patch_nix_binary(validator_keys_tests) +set_target_properties( + validator_keys_tests + PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}" +) +target_include_directories( + validator_keys_tests + PRIVATE ${CMAKE_CURRENT_SOURCE_DIR} +) +target_link_libraries( + validator_keys_tests + PRIVATE GTest::gtest GTest::gmock xrpl.validator-keys +) +include(GoogleTest) +gtest_discover_tests(validator_keys_tests) diff --git a/src/tests/tools/validator-keys/Commands.cpp b/src/tests/tools/validator-keys/Commands.cpp new file mode 100644 index 0000000000..f6cecd18a0 --- /dev/null +++ b/src/tests/tools/validator-keys/Commands.cpp @@ -0,0 +1,501 @@ +#include + +#include +#include +#include +#include + +#include +#include +#include + +#include + +#include + +namespace xrpl::tools::test { + +namespace { + +std::string const kArgError = "Syntax error: Wrong number of arguments"; +std::string const kRevokedOperation = "Operation error: The specified master key has been revoked!"; +std::string const kExhausted = + "Maximum number of tokens have already been generated.\n" + "Revoke validator keys if previous token has been compromised."; + +class CommandsTest : public ::testing::Test +{ +protected: + TempDir dir; + ToolOptions options = optionsFor(dir.file("validator_keys.json")); + + std::filesystem::path + file(std::string const& name) const + { + return dir.file(name); + } + + std::string + commandError( + std::string const& command, + std::vector const& args, + ToolOptions const& opts) const + { + return errorOf([&] { run(command, args, opts); }); + } + + SigningKeys + keys(ToolOptions const& opts) const + { + return SigningKeys::make_SigningKeys(opts.keyFile); + } + + // The token base64 inside a [validator_token] or [validator_manifest] block. + static std::string + blockBody(std::string const& text, std::string const& section) + { + auto pos = text.find("[" + section + "]\n"); + if (pos == std::string::npos) + { + ADD_FAILURE() << "no [" << section << "] block in: " << text; + return {}; + } + std::string body; + for (auto rest = text.substr(pos + section.size() + 3); !rest.empty();) + { + auto const eol = rest.find('\n'); + auto const line = rest.substr(0, eol); + if (line.empty()) + break; + body += line; + rest = eol == std::string::npos ? "" : rest.substr(eol + 1); + } + return body; + } +}; + +} // namespace + +TEST_F(CommandsTest, dispatch) +{ + EXPECT_EQ(commandError("unknown", {}, options), "Unknown command: unknown"); + EXPECT_EQ(commandError("create_keys", {"x"}, options), kArgError); + EXPECT_EQ(commandError("finish_token", {}, options), kArgError); + EXPECT_EQ(commandError("finish_token", {"a", "b", "c"}, options), kArgError); + EXPECT_EQ(commandError("finish_sign_list", {"a"}, options), kArgError); + EXPECT_FALSE(getVersionString().empty()); +} + +TEST_F(CommandsTest, create_keys) +{ + auto const r = run("create_keys", {}, options); + EXPECT_EQ(r.rc, EXIT_SUCCESS); + EXPECT_NE( + r.out.find("Validator keys stored in " + options.keyFile.string()), std::string::npos); + EXPECT_TRUE(keys(options).hasSecret()); + EXPECT_EQ( + commandError("create_keys", {}, options), + "Refusing to overwrite existing key file: " + options.keyFile.string()); +} + +TEST_F(CommandsTest, create_external) +{ + SigningKeys const external(KeyType::Ed25519); + auto const& key = external.publicKey(); + for (auto const& encoded : + {toBase58(TokenType::NodePublic, key), strHex(key), base64Encode(key.data(), key.size())}) + { + ToolOptions opts = optionsFor(file("external-" + std::to_string(encoded.size()) + ".json")); + EXPECT_EQ(run("create_external", {encoded}, opts).rc, EXIT_SUCCESS); + auto const loaded = keys(opts); + EXPECT_FALSE(loaded.hasSecret()); + EXPECT_EQ(loaded.publicKey(), key); + } + EXPECT_EQ( + commandError("create_external", {"abcd"}, options), "Unable to parse public key: abcd"); + auto badHex = strHex(key); + badHex.insert(badHex.size() / 2, "n"); + EXPECT_EQ( + commandError("create_external", {badHex}, options), + "Unable to parse public key: " + badHex); + run("create_external", {strHex(key)}, options); + EXPECT_EQ( + commandError("create_external", {strHex(key)}, options), + "Refusing to overwrite existing key file: " + options.keyFile.string()); +} + +TEST_F(CommandsTest, create_token) +{ + EXPECT_EQ( + commandError("create_token", {}, options), + "Failed to open key file: " + options.keyFile.string()); + run("create_keys", {}, options); + + auto const r = run("create_token", {}, options); + EXPECT_EQ(r.rc, EXIT_SUCCESS); + auto const token = loadValidatorToken({blockBody(r.out, "validator_token")}); + ASSERT_TRUE(token); + auto const m = deserializeManifest(base64Decode(token->manifest)); + ASSERT_TRUE(m); + EXPECT_EQ(m->sequence, 1u); + EXPECT_EQ(*m->signingKey, derivePublicKey(KeyType::Secp256k1, token->validationSecret)); + + // Written to a file readable by the owner only, as an ed25519 token + ToolOptions toFile = options; + toFile.tokenKeyType = KeyType::Ed25519; + toFile.outFile = file("token.txt"); + EXPECT_NE(run("create_token", {}, toFile).out.find("written to"), std::string::npos); + auto const written = loadTokenFile(*toFile.outFile); + EXPECT_EQ( + *deserializeManifest(base64Decode(written.manifest))->signingKey, + derivePublicKey(KeyType::Ed25519, written.validationSecret)); + EXPECT_EQ( + std::filesystem::status(*toFile.outFile).permissions() & + (std::filesystem::perms::group_all | std::filesystem::perms::others_all), + std::filesystem::perms::none); + EXPECT_EQ(keys(options).sequence(), 2u); + + // An unwritable output path fails before the sequence is consumed + ToolOptions unwritable = options; + unwritable.outFile = file("missing/token.txt"); + EXPECT_EQ( + commandError("create_token", {}, unwritable), + "Cannot open output file: " + unwritable.outFile->string()); + EXPECT_EQ(keys(options).sequence(), 2u); + + { + auto const kp = generateKeyPair(KeyType::Ed25519, randomSeed()); + SigningKeys(KeyType::Ed25519, kp.second, std::numeric_limits::max() - 1) + .writeToFile(options.keyFile); + EXPECT_EQ(commandError("create_token", {}, options), kExhausted); + } + run("revoke_keys", {}, options); + EXPECT_EQ(commandError("create_token", {}, options), "Validator keys have been revoked."); +} + +TEST_F(CommandsTest, external_token) +{ + // The signer stands in for the hardware holding the master key + SigningKeys const signer(KeyType::Ed25519); + run("create_external", {toBase58(TokenType::NodePublic, signer.publicKey())}, options); + + for (auto const encode : {0, 1}) + { + auto const start = run("start_token", {}, options); + EXPECT_EQ(start.rc, EXIT_SUCCESS); + auto const bytes = start.out.substr(0, start.out.find('\n')); + auto const sig = signer.signHex(bytes); + auto const sigBytes = *strUnHex(sig); + auto const encoded = encode ? base64Encode(sigBytes.data(), sigBytes.size()) : sig; + auto const finish = run("finish_token", {encoded}, options); + EXPECT_EQ(finish.rc, EXIT_SUCCESS); + auto const token = loadValidatorToken({blockBody(finish.out, "validator_token")}); + ASSERT_TRUE(token); + EXPECT_EQ(deserializeManifest(base64Decode(token->manifest))->sequence, encode ? 2u : 1u); + } + EXPECT_EQ( + commandError("finish_token", {"bad signature"}, options), "Invalid signature encoding"); + run("start_token", {}, options); + EXPECT_EQ( + commandError("finish_token", {signer.sign("foo")}, options), + "Manifest is not properly signed"); + EXPECT_EQ(keys(options).sequence(), 2u); + + // An external signing key too: two signatures, a manifest without a secret + SigningKeys const signingKey(KeyType::Ed25519); + ToolOptions both = options; + both.signingKey = signingKey.publicKey(); + both.outFile = file("manifest.txt"); + auto const start = run("start_token", {}, both); + auto const bytes = start.out.substr(0, start.out.find('\n')); + EXPECT_EQ( + commandError("finish_token", {signer.signHex(bytes)}, both), + "The pending token's signing key is external; pass its signature too"); + auto const finish = + run("finish_token", {signer.signHex(bytes), signingKey.signHex(bytes)}, both); + EXPECT_EQ(finish.rc, EXIT_SUCCESS); + auto const manifest = loadManifestFile(*both.outFile); + EXPECT_EQ(*manifest.signingKey, signingKey.publicKey()); + EXPECT_EQ(manifest.sequence, 3u); + + // The domain is stored for the next token; the attestation bytes are printed + // for the external signer + EXPECT_EQ(run("attest_domain", {}, options).out.find("No attestation is necessary"), 0u); + auto const domain = run("set_domain", {"validator.example.com"}, options); + EXPECT_NE(domain.out.find("run start_token and finish_token"), std::string::npos); + EXPECT_EQ(keys(options).domain(), "validator.example.com"); + auto const attest = run("attest_domain", {}, options); + EXPECT_EQ( + attest.out.substr(0, attest.out.find('\n')), + strHex(makeSlice(keys(options).attestationData()))); + EXPECT_NE(attest.err.find("Sign these bytes"), std::string::npos); + auto const next = run("start_token", {}, options); + auto const nextBytes = next.out.substr(0, next.out.find('\n')); + auto const finished = run("finish_token", {signer.signHex(nextBytes)}, options); + auto const token = loadValidatorToken({blockBody(finished.out, "validator_token")}); + EXPECT_EQ(deserializeManifest(base64Decode(token->manifest))->domain, "validator.example.com"); + + // Revocation in two steps + auto const startRevoke = run("start_revoke_keys", {}, options); + EXPECT_NE(startRevoke.err.find("This will revoke"), std::string::npos); + auto const revokeBytes = startRevoke.out.substr(0, startRevoke.out.find('\n')); + EXPECT_EQ( + commandError("finish_revoke_keys", {signer.sign("foo")}, options), + "Manifest is not properly signed"); + EXPECT_FALSE(keys(options).revoked()); + auto const revoked = run("finish_revoke_keys", {signer.signHex(revokeBytes)}, options); + EXPECT_NE(revoked.out.find("[validator_key_revocation]"), std::string::npos); + EXPECT_TRUE(keys(options).revoked()); + EXPECT_NE( + run("start_revoke_keys", {}, options).err.find("already been revoked"), std::string::npos); + EXPECT_EQ(commandError("start_token", {}, options), "Validator keys have been revoked."); + EXPECT_EQ(commandError("finish_token", {"00"}, options), "Validator keys have been revoked."); +} + +TEST_F(CommandsTest, revoke_keys) +{ + run("create_keys", {}, options); + auto const first = run("revoke_keys", {}, options); + EXPECT_NE(first.err.find("This will revoke"), std::string::npos); + EXPECT_NE(first.out.find("[validator_key_revocation]"), std::string::npos); + auto const again = run("revoke_keys", {}, options); + EXPECT_NE(again.err.find("already been revoked"), std::string::npos); + EXPECT_EQ(commandError("set_domain", {"validator.example.com"}, options), kRevokedOperation); + EXPECT_EQ(commandError("attest_domain", {}, options), kRevokedOperation); + EXPECT_NE(run("sign", {"data"}, options).err.find("have been revoked"), std::string::npos); +} + +TEST_F(CommandsTest, domain) +{ + run("create_keys", {}, options); + EXPECT_NE(run("show_manifest", {"hex"}, options).out.find("unavailable"), std::string::npos); + EXPECT_NE(run("clear_domain", {}, options).out.find("already cleared"), std::string::npos); + + auto const set = run("set_domain", {"validator.example.com"}, options); + EXPECT_NE(set.out.find("has been set to: validator.example.com"), std::string::npos); + EXPECT_NE(set.out.find("attestation=\""), std::string::npos); + EXPECT_NE(set.out.find("[validator_token]"), std::string::npos); + EXPECT_NE( + run("set_domain", {"validator.example.com"}, options).out.find("already set"), + std::string::npos); + EXPECT_NE(run("attest_domain", {}, options).out.find("attestation=\""), std::string::npos); + EXPECT_NE(run("show_manifest", {"base64"}, options).out.find("(Base64)"), std::string::npos); + EXPECT_NE(run("show_manifest", {"hex"}, options).out.find("(Hex)"), std::string::npos); + EXPECT_EQ(commandError("show_manifest", {"other"}, options), "Unknown encoding 'other'"); + EXPECT_NE(run("clear_domain", {}, options).out.find("has been cleared"), std::string::npos); + EXPECT_EQ( + commandError("set_domain", {"-bad.example"}, options), + "The domain field must use the '[host.][subdomain.]domain.tld' format"); + + auto const kp = generateKeyPair(KeyType::Ed25519, randomSeed()); + SigningKeys(KeyType::Ed25519, kp.second, std::numeric_limits::max() - 1) + .writeToFile(options.keyFile); + EXPECT_EQ(commandError("set_domain", {"other.example.com"}, options), kExhausted); +} + +TEST_F(CommandsTest, sign) +{ + run("create_keys", {}, options); + EXPECT_EQ( + commandError("sign", {""}, options), "Syntax error: Must specify data string to sign"); + auto const loaded = keys(options); + EXPECT_EQ(run("sign", {"data"}, options).out, loaded.sign("data") + "\n"); + EXPECT_EQ(run("sign_hex", {"00FF"}, options).out, loaded.signHex("00FF") + "\n"); +} + +TEST_F(CommandsTest, list_commands) +{ + // The publisher: a key file and a token carrying an ed25519 signing key + ToolOptions publisher = optionsFor(file("publisher.json")); + publisher.tokenKeyType = KeyType::Ed25519; + publisher.tokenFile = file("publisher-token.txt"); + publisher.outFile = publisher.tokenFile; + run("create_keys", {}, publisher); + run("create_token", {}, publisher); + publisher.outFile.reset(); + auto const master = keys(publisher).publicKey(); + + auto const unsignedList = file("unsigned.json"); + auto const now = netClockNow(); + writeFile(unsignedList, unsignedListText(makeValidators(2), 2026091301, now + 3600)); + + ToolOptions noToken = publisher; + noToken.tokenFile.reset(); + EXPECT_EQ( + commandError("sign_list", {unsignedList.string()}, noToken), + "sign_list needs --token-file"); + + // Sign to a file, then verify with every check on + ToolOptions signer = publisher; + signer.outFile = file("vl.json"); + EXPECT_NE( + run("sign_list", {unsignedList.string()}, signer).out.find("written to"), + std::string::npos); + + ToolOptions verifier = optionsFor(publisher.keyFile); + verifier.validatorsFile = unsignedList; + verifier.expectedKey = master; + auto const verified = run("verify_list", {signer.outFile->string()}, verifier); + EXPECT_EQ(verified.rc, EXIT_SUCCESS) << verified.out; + { + ToolOptions wrong = verifier; + wrong.expectedKey = SigningKeys(KeyType::Ed25519).publicKey(); + EXPECT_EQ(run("verify_list", {signer.outFile->string()}, wrong).rc, EXIT_FAILURE); + } + + // To stdout without --out + { + ToolOptions stdoutSigner = publisher; + auto const r = run("sign_list", {unsignedList.string()}, stdoutSigner); + json::Reader reader; + json::Value jv; + EXPECT_TRUE(reader.parse(r.out, jv)) << r.out; + EXPECT_EQ(jv[jss::public_key].asString(), strHex(master)); + } + + // Version 2, appended to itself, then appended again after a key rotation + ToolOptions v2 = signer; + v2.listVersion = 2; + v2.outFile = file("vl2.json"); + run("sign_list", {unsignedList.string()}, v2); + v2.appendFile = v2.outFile; + v2.outFile = file("vl2b.json"); + run("sign_list", {unsignedList.string()}, v2); + EXPECT_EQ(run("verify_list", {v2.outFile->string()}, verifier).rc, EXIT_SUCCESS); + { + ToolOptions rotated = v2; + rotated.outFile = file("publisher-token-2.txt"); + run("create_token", {}, rotated); + rotated.tokenFile = rotated.outFile; + rotated.appendFile = v2.outFile; + rotated.outFile = file("vl2c.json"); + run("sign_list", {unsignedList.string()}, rotated); + auto const r = run("verify_list", {rotated.outFile->string()}, verifier); + EXPECT_EQ(r.rc, EXIT_SUCCESS) << r.out; + json::Reader reader; + json::Value report; + reader.parse(r.out, report); + EXPECT_EQ(report["manifest_sequence"].asUInt(), 2u); + EXPECT_EQ(report["blobs"].size(), 3u); + } + + // Output paths that cannot be opened, inputs that cannot be read + { + ToolOptions bad = signer; + bad.outFile = file("missing/vl.json"); + EXPECT_EQ( + commandError("sign_list", {unsignedList.string()}, bad), + "Cannot open output file: " + bad.outFile->string()); + } + EXPECT_EQ( + commandError("verify_list", {file("missing.json").string()}, verifier), + "Failed to open file: " + file("missing.json").string()); + writeFile(file("not.json"), "nope\n"); + EXPECT_EQ( + commandError("verify_list", {file("not.json").string()}, verifier), + "Not a JSON document: " + file("not.json").string()); + + // Tokens that cannot sign a list: an invalid manifest, a secret of another key + auto const writeToken = [&](std::filesystem::path const& path, ValidatorToken const& token) { + writeFile(path, "[validator_token]\n" + tokenToBase64(token) + "\n"); + }; + auto const secret = generateSecretKey(KeyType::Ed25519, randomSeed()); + { + ToolOptions badManifest = signer; + badManifest.tokenFile = file("bad-manifest-token.txt"); + writeToken(*badManifest.tokenFile, ValidatorToken{"AAAA", secret}); + EXPECT_EQ( + commandError("sign_list", {unsignedList.string()}, badManifest), + "The token's manifest is not valid"); + ToolOptions wrongSecret = signer; + wrongSecret.tokenFile = file("wrong-secret-token.txt"); + writeToken( + *wrongSecret.tokenFile, + ValidatorToken{loadTokenFile(*publisher.tokenFile).manifest, secret}); + EXPECT_EQ( + commandError("sign_list", {unsignedList.string()}, wrongSecret), + "The token's secret does not match its manifest"); + } +} + +TEST_F(CommandsTest, external_list_signing) +{ + // The master key delegates to a signing key it never holds; the list is then + // signed in two steps with that key. + ToolOptions publisher = optionsFor(file("publisher.json")); + run("create_keys", {}, publisher); + SigningKeys const external(KeyType::Ed25519); + ToolOptions delegate = publisher; + delegate.signingKey = external.publicKey(); + delegate.outFile = file("manifest.txt"); + auto const start = run("start_token", {}, delegate); + auto const bytes = start.out.substr(0, start.out.find('\n')); + run("finish_token", {keys(publisher).signHex(bytes), external.signHex(bytes)}, delegate); + + auto const unsignedList = file("unsigned.json"); + writeFile(unsignedList, unsignedListText(makeValidators(2), 2026091301, netClockNow() + 3600)); + + ToolOptions hardware = publisher; + EXPECT_EQ( + commandError("start_sign_list", {unsignedList.string()}, hardware), + "start_sign_list needs --manifest-file"); + EXPECT_EQ( + commandError("finish_sign_list", {"00", unsignedList.string()}, hardware), + "finish_sign_list needs --manifest-file"); + hardware.manifestFile = delegate.outFile; + + auto const toSign = run("start_sign_list", {unsignedList.string()}, hardware); + auto const listBytes = toSign.out.substr(0, toSign.out.find('\n')); + EXPECT_EQ(listBytes, strHex(makeSlice(loadUnsignedList(unsignedList).canonical))); + + hardware.outFile = file("vl.json"); + EXPECT_EQ( + commandError( + "finish_sign_list", + {keys(publisher).signHex(listBytes), unsignedList.string()}, + hardware), + "The signature does not verify under the manifest's signing key"); + EXPECT_EQ( + run("finish_sign_list", {external.signHex(listBytes), unsignedList.string()}, hardware).rc, + EXIT_SUCCESS); + ToolOptions verifier = optionsFor(publisher.keyFile); + verifier.validatorsFile = unsignedList; + EXPECT_EQ(run("verify_list", {hardware.outFile->string()}, verifier).rc, EXIT_SUCCESS); + + // A version 2 append under the same manifest works; after a rotation it + // cannot re-sign the earlier blobs and says so + ToolOptions v2 = hardware; + v2.listVersion = 2; + v2.outFile = file("vl2.json"); + run("finish_sign_list", {external.signHex(listBytes), unsignedList.string()}, v2); + v2.appendFile = v2.outFile; + v2.outFile = file("vl2b.json"); + run("finish_sign_list", {external.signHex(listBytes), unsignedList.string()}, v2); + EXPECT_EQ(run("verify_list", {v2.outFile->string()}, verifier).rc, EXIT_SUCCESS); + { + ToolOptions rotated = publisher; + rotated.outFile = file("token.txt"); + run("create_token", {}, rotated); + ToolOptions append = v2; + append.manifestFile.reset(); + append.tokenFile = rotated.outFile; + append.appendFile = v2.outFile; + append.outFile = file("vl2c.json"); + EXPECT_EQ(run("sign_list", {unsignedList.string()}, append).rc, EXIT_SUCCESS); + EXPECT_EQ(run("verify_list", {append.outFile->string()}, verifier).rc, EXIT_SUCCESS); + } + + // A revoked manifest signs nothing + SigningKeys revokedKeys(KeyType::Ed25519); + ToolOptions revoked = hardware; + revoked.manifestFile = file("revoked-manifest.txt"); + writeFile(*revoked.manifestFile, revokedKeys.revoke() + "\n"); + EXPECT_EQ( + commandError("start_sign_list", {unsignedList.string()}, revoked), + "The manifest is revoked"); + EXPECT_EQ( + commandError("finish_sign_list", {"00", unsignedList.string()}, revoked), + "The manifest is revoked"); +} + +} // namespace xrpl::tools::test diff --git a/src/tests/tools/validator-keys/Fixtures.h b/src/tests/tools/validator-keys/Fixtures.h new file mode 100644 index 0000000000..02d63b0b76 --- /dev/null +++ b/src/tests/tools/validator-keys/Fixtures.h @@ -0,0 +1,145 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace xrpl::tools::test { + +/** + * What one command run produced. + */ +struct Run +{ + int rc; + std::string out; + std::string err; +}; + +inline Run +run(std::string const& command, std::vector const& args, ToolOptions const& options) +{ + std::ostringstream out; + std::ostringstream err; + int const rc = runCommand(command, args, options, out, err); + return {rc, out.str(), err.str()}; +} + +/** + * The message of the std::runtime_error @p f throws, or an empty string. + */ +inline std::string +errorOf(std::function const& f) +{ + try + { + f(); + } + catch (std::runtime_error const& e) + { + return e.what(); + } + return {}; +} + +inline ToolOptions +optionsFor(std::filesystem::path const& keyFile) +{ + ToolOptions options; + options.keyFile = keyFile; + return options; +} + +inline void +writeFile(std::filesystem::path const& file, std::string const& text) +{ + std::error_code ec; + writeFileContents(ec, file, text); + ASSERT_FALSE(ec) << file; +} + +inline std::string +readFile(std::filesystem::path const& file) +{ + std::error_code ec; + auto const text = getFileContents(ec, file); + EXPECT_FALSE(ec) << file; + return text; +} + +inline bool +sameSecret(SecretKey const& a, SecretKey const& b) +{ + return std::equal(a.begin(), a.end(), b.begin()); +} + +/** + * A publisher: master keys and the token carrying its ed25519 signing key. + */ +struct Publisher +{ + SigningKeys keys{KeyType::Ed25519}; + ValidatorToken token; + Manifest manifest; + + Publisher() + : token(keys.createToken(KeyType::Ed25519)) + , manifest(*deserializeManifest(base64Decode(token.manifest))) + { + } +}; + +inline 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.createToken(KeyType::Secp256k1)); + } + return validators; +} + +/** + * The unsigned list text a publisher's prepare step writes: one validator per + * token, each with its manifest. + */ +inline 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; +} + +} // namespace xrpl::tools::test diff --git a/src/tests/tools/validator-keys/ListSigning.cpp b/src/tests/tools/validator-keys/ListSigning.cpp new file mode 100644 index 0000000000..fee181380b --- /dev/null +++ b/src/tests/tools/validator-keys/ListSigning.cpp @@ -0,0 +1,485 @@ +#include + +#include +#include +#include +#include +#include + +#include +#include + +#include + +namespace xrpl::tools::test { + +namespace { + +std::string const kNotObject = "Not a JSON object"; + +class ListSigningTest : public ::testing::Test +{ +protected: + Publisher const publisher; + std::vector const validators = makeValidators(3); + std::uint32_t const now = 1000; + UnsignedList const list = parseUnsignedList(unsignedListText(validators, 7, now + 100)); + std::string const signature = + signList(list, *publisher.manifest.signingKey, publisher.token.validationSecret); + + json::Value + signed1() const + { + return makeSignedList( + publisher.token.manifest, + publisher.manifest.masterKey, + list, + signature, + 1, + std::nullopt, + {}); + } + + json::Value + signed2() const + { + return makeSignedList( + publisher.token.manifest, + publisher.manifest.masterKey, + list, + signature, + 2, + std::nullopt, + {}); + } + + static std::string + errorOfParse(std::string const& text) + { + return errorOf([&] { parseUnsignedList(text); }); + } + + // Verifies and expects @p error among the report's errors. + void + expectError( + json::Value const& doc, + std::string const& error, + std::optional const& roster = std::nullopt, + std::optional const& key = std::nullopt, + std::optional at = std::nullopt) const + { + auto const report = verifyList(doc, roster, key, at.value_or(now)); + EXPECT_FALSE(report["ok"].asBool()); + bool found = false; + for (auto const& e : report["errors"]) + found = found || e.asString() == error; + EXPECT_TRUE(found) << to_string(report); + } + + void + expectOk(json::Value const& doc) const + { + auto const report = verifyList(doc, std::nullopt, std::nullopt, now); + EXPECT_TRUE(report["ok"].asBool()) << to_string(report); + } +}; + +} // namespace + +TEST_F(ListSigningTest, canonical_json) +{ + // Whitespace and comments outside strings go, one space follows each comma + // and colon, key order and string contents stay. + EXPECT_EQ( + canonicalJson("{ \"b\" :1,\n\t\"a\": [ 1 , 2 ] , \"s\":\"x, y: z\" }"), + "{\"b\": 1, \"a\": [1, 2], \"s\": \"x, y: z\"}"); + EXPECT_EQ(canonicalJson("{\"e\": \"a\\\"b\"}"), "{\"e\": \"a\\\"b\"}"); + EXPECT_EQ( + canonicalJson("{\"sequence\": 1, // reviewed\n \"x\": /* two */ 2}"), + "{\"sequence\": 1, \"x\": 2}"); + EXPECT_EQ(canonicalJson("{\"u\": \"http://x\"}"), "{\"u\": \"http://x\"}"); + + EXPECT_EQ(errorOf([] { canonicalJson("[1, 2]"); }), kNotObject); + EXPECT_EQ(errorOf([] { canonicalJson("{\"a\": "); }), kNotObject); + EXPECT_EQ(errorOf([] { canonicalJson("{\"a\": 1} // open"); }), ""); + EXPECT_EQ(errorOf([] { canonicalJson("{\"a\": 1 /* open"); }), kNotObject); +} + +TEST_F(ListSigningTest, parse_unsigned_list) +{ + { + auto const l = parseUnsignedList(unsignedListText(validators, 5, 1000, 500)); + EXPECT_EQ(l.sequence, 5u); + EXPECT_EQ(l.expiration, 1000u); + ASSERT_TRUE(l.effective); + EXPECT_EQ(*l.effective, 500u); + EXPECT_EQ(l.validators.size(), 3u); + EXPECT_EQ( + l.validators[0], deserializeManifest(base64Decode(validators[0].manifest))->masterKey); + } + { + auto const l = parseUnsignedList(unsignedListText(validators, 5, 1000)); + EXPECT_FALSE(l.effective); + EXPECT_TRUE(l.canonical.starts_with("{\"sequence\": 5, \"expiration\": 1000, ")); + } + + std::string const sequenceError = "\"sequence\" must be an integer from 1 to 2147483647"; + std::string const expirationError = "\"expiration\" must be an integer from 0 to 2147483647"; + EXPECT_EQ(errorOfParse("{\"expiration\": 1, \"validators\": []}"), sequenceError); + EXPECT_EQ( + errorOfParse("{\"sequence\": 0, \"expiration\": 1, \"validators\": []}"), sequenceError); + EXPECT_EQ( + errorOfParse("{\"sequence\": true, \"expiration\": 1, \"validators\": []}"), sequenceError); + EXPECT_EQ( + errorOfParse("{\"sequence\": 2147483648, \"expiration\": 1, \"validators\": []}"), + sequenceError); + EXPECT_EQ(errorOfParse("{\"sequence\": 1, \"validators\": []}"), expirationError); + EXPECT_EQ( + errorOfParse("{\"sequence\": 1, \"expiration\": -1, \"validators\": []}"), expirationError); + EXPECT_EQ( + errorOfParse( + "{\"sequence\": 1, \"effective\": 1000, \"expiration\": 1000, \"validators\": []}"), + "\"effective\" must be earlier than \"expiration\""); + EXPECT_EQ( + errorOfParse( + "{\"sequence\": 1, \"effective\": \"x\", \"expiration\": 1000, \"validators\": []}"), + "\"effective\" must be an integer from 0 to 2147483647"); + EXPECT_EQ( + errorOfParse("{\"sequence\": 1, \"expiration\": 1000, \"validators\": []}"), + "\"validators\" must be a non-empty array"); + EXPECT_EQ( + errorOfParse("{\"sequence\": 1, \"expiration\": 1000, \"validators\": [{}]}"), + "every validator needs a \"validation_public_key\" string"); + for (auto const* bad : + {"zz", "ED00", "FF00000000000000000000000000000000000000000000000000000000000000"}) + { + EXPECT_EQ( + errorOfParse( + std::string( + "{\"sequence\": 1, \"expiration\": 1000, \"validators\": " + "[{\"validation_public_key\": \"") + + bad + "\"}]}"), + std::string("\"validation_public_key\" is not a hex public key: ") + bad); + } + { + auto const key = + strHex(deserializeManifest(base64Decode(validators[0].manifest))->masterKey); + auto const other = + strHex(deserializeManifest(base64Decode(validators[1].manifest))->masterKey); + auto const entry = [](std::string const& key, std::string const& manifest) { + return "{\"sequence\": 1, \"expiration\": 1000, \"validators\": " + "[{\"validation_public_key\": \"" + + key + "\", \"manifest\": " + manifest + "}]}"; + }; + EXPECT_EQ( + errorOfParse(entry(other, "\"" + validators[0].manifest + "\"")), + "\"manifest\" belongs to another key than " + other); + EXPECT_EQ(errorOfParse(entry(key, "\"AAAA\"")), "\"manifest\" does not verify for " + key); + EXPECT_EQ(errorOfParse(entry(key, "5")), "\"manifest\" must be a base64 string for " + key); + } +} + +TEST_F(ListSigningTest, files) +{ + TempDir dir; + + // A token file as create_token writes it: header, comment, 72-character lines + auto const tokenFile = std::filesystem::path(dir.file("token.txt")); + { + std::string text = "# validator public key: " + + toBase58(TokenType::NodePublic, publisher.keys.publicKey()) + "\n\n[validator_token]\n"; + auto const body = tokenToBase64(publisher.token); + for (std::size_t i = 0; i < body.size(); i += 72) + text += body.substr(i, 72) + "\n"; + writeFile(tokenFile, text); + } + auto const token = loadTokenFile(tokenFile); + EXPECT_EQ(token.manifest, publisher.token.manifest); + EXPECT_TRUE(sameSecret(token.validationSecret, publisher.token.validationSecret)); + + auto const manifestFile = std::filesystem::path(dir.file("manifest.txt")); + writeFile(manifestFile, "# publisher manifest\n" + publisher.token.manifest + "\n"); + auto const manifest = loadManifestFile(manifestFile); + EXPECT_EQ(manifest.masterKey, publisher.manifest.masterKey); + EXPECT_EQ(manifest.signingKey, publisher.manifest.signingKey); + + EXPECT_EQ( + errorOf([&] { loadTokenFile(manifestFile); }), + "Not a validator token: " + manifestFile.string()); + auto const bad = std::filesystem::path(dir.file("bad-manifest.txt")); + writeFile(bad, "AAAA\n"); + EXPECT_EQ(errorOf([&] { loadManifestFile(bad); }), "Not a valid manifest: " + bad.string()); + auto const missing = std::filesystem::path(dir.file("missing.txt")); + EXPECT_EQ( + errorOf([&] { loadManifestFile(missing); }), "Failed to open file: " + missing.string()); + + auto const listFile = std::filesystem::path(dir.file("unsigned.json")); + writeFile(listFile, unsignedListText(validators, 3, 5000)); + EXPECT_EQ(loadUnsignedList(listFile).sequence, 3u); +} + +TEST_F(ListSigningTest, sign_and_verify_version_1) +{ + auto const v1 = signed1(); + EXPECT_EQ(v1[jss::version].asUInt(), 1u); + EXPECT_EQ(v1[jss::public_key].asString(), strHex(publisher.manifest.masterKey)); + EXPECT_EQ(v1[jss::manifest].asString(), publisher.token.manifest); + EXPECT_EQ(base64Decode(v1[jss::blob].asString()), list.canonical); + EXPECT_EQ(v1[jss::signature].asString(), signature); + + auto const report = verifyList(v1, list, publisher.manifest.masterKey, now); + EXPECT_TRUE(report["ok"].asBool()) << to_string(report); + EXPECT_EQ(report["blobs"].size(), 1u); + EXPECT_EQ(report["blobs"][0u][jss::sequence].asUInt(), 7u); + EXPECT_EQ(report["blobs"][0u][jss::validators].asUInt(), 3u); + EXPECT_FALSE(report["blobs"][0u]["expired"].asBool()); + EXPECT_EQ(report["manifest_sequence"].asUInt(), 1u); + EXPECT_EQ(report["signing_key"].asString(), strHex(*publisher.manifest.signingKey)); + + EXPECT_EQ( + errorOf([&] { + makeSignedList( + publisher.token.manifest, + publisher.manifest.masterKey, + list, + signature, + 3, + std::nullopt, + {}); + }), + "Unsupported list version"); +} + +TEST_F(ListSigningTest, sign_and_verify_version_2) +{ + auto const v2 = signed2(); + EXPECT_EQ(v2[jss::version].asUInt(), 2u); + EXPECT_EQ(v2[jss::blobs_v2].size(), 1u); + EXPECT_FALSE(v2.isMember(jss::blob)); + expectOk(v2); + + // A second blob appended under the same manifest + 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, {}); + EXPECT_EQ(v2b[jss::blobs_v2].size(), 2u); + EXPECT_EQ(v2b[jss::blobs_v2][0u][jss::signature].asString(), signature); + { + auto const report = verifyList(v2b, std::nullopt, std::nullopt, now); + EXPECT_TRUE(report["ok"].asBool()) << to_string(report); + EXPECT_EQ(report["blobs"][1u][jss::effective].asUInt(), now + 200); + } + + // After the signing key rotates the earlier blobs are signed again with the + // new key, because a server verifies every blob under the newest manifest. + SigningKeys rotated = publisher.keys; + auto const token2 = rotated.createToken(KeyType::Ed25519); + auto const manifest2 = *deserializeManifest(base64Decode(token2.manifest)); + auto const resign = [&](std::string const& bytes) { + return strHex(sign(*manifest2.signingKey, token2.validationSecret, makeSlice(bytes))); + }; + auto const third = parseUnsignedList(unsignedListText(validators, 9, now + 400, now + 350)); + EXPECT_EQ( + errorOf([&] { + makeSignedList( + token2.manifest, manifest2.masterKey, third, resign(third.canonical), 2, v2b, {}); + }), + "The list to append to was signed under another manifest and its blobs need signing " + "again"); + auto const v2c = makeSignedList( + token2.manifest, manifest2.masterKey, third, resign(third.canonical), 2, v2b, resign); + EXPECT_EQ(v2c[jss::blobs_v2].size(), 3u); + EXPECT_EQ(v2c[jss::manifest].asString(), token2.manifest); + EXPECT_NE(v2c[jss::blobs_v2][0u][jss::signature].asString(), signature); + EXPECT_EQ( + v2c[jss::blobs_v2][0u][jss::blob].asString(), v2b[jss::blobs_v2][0u][jss::blob].asString()); + EXPECT_FALSE(v2c[jss::blobs_v2][0u].isMember(jss::manifest)); + { + auto const report = verifyList(v2c, std::nullopt, std::nullopt, now); + EXPECT_TRUE(report["ok"].asBool()) << to_string(report); + EXPECT_EQ(report["manifest_sequence"].asUInt(), 2u); + EXPECT_EQ(report["signing_key"].asString(), strHex(*manifest2.signingKey)); + } + + // Append refuses the wrong shape, another publisher, a broken blob and a full list + EXPECT_EQ( + errorOf([&] { + makeSignedList( + publisher.token.manifest, publisher.manifest.masterKey, list, signature, 1, v2, {}); + }), + "A version 1 list holds one blob; use version 2 to append"); + EXPECT_EQ( + errorOf([&] { + makeSignedList( + publisher.token.manifest, + publisher.manifest.masterKey, + list, + signature, + 2, + signed1(), + {}); + }), + "The list to append to is not a version 2 list"); + { + Publisher const other; + EXPECT_EQ( + errorOf([&] { + makeSignedList( + other.token.manifest, other.manifest.masterKey, list, signature, 2, v2, {}); + }), + "The list to append to belongs to another master key"); + } + { + auto broken = v2; + broken[jss::blobs_v2][0u][jss::blob] = 5; + EXPECT_EQ( + errorOf([&] { + makeSignedList( + token2.manifest, manifest2.masterKey, third, "00", 2, broken, resign); + }), + "The list to append to holds an invalid blob"); + } + { + auto full = v2; + while (full[jss::blobs_v2].size() < 5) + full[jss::blobs_v2].append(full[jss::blobs_v2][0u]); + EXPECT_EQ( + errorOf([&] { + makeSignedList( + publisher.token.manifest, + publisher.manifest.masterKey, + list, + signature, + 2, + full, + {}); + }), + "The list to append to already holds 5 blobs"); + } +} + +TEST_F(ListSigningTest, verify_rejects) +{ + auto const good = signed1(); + + { + auto tampered = good; + auto text = list.canonical; + text.replace(text.find("\"sequence\": 7"), 13, "\"sequence\": 9"); + tampered[jss::blob] = base64Encode(text); + expectError(tampered, "blob 0: the signature does not verify under the signing key"); + } + expectError(good, "blob 0: expired", std::nullopt, std::nullopt, now + 100); + { + Publisher const other; + auto wrong = good; + wrong[jss::manifest] = other.token.manifest; + expectError(wrong, "\"public_key\" is not the manifest's master key"); + expectError( + good, "the master key is not the expected key", std::nullopt, other.manifest.masterKey); + } + { + auto const roster = parseUnsignedList(unsignedListText(makeValidators(2), 1, now + 100)); + expectError(good, "blob 0: the validators differ from the expected list", roster); + } + expectError(json::Value(json::ValueType::Array), "the list is not a JSON object"); + for (auto const version : {0, 3}) + { + auto bad = good; + bad[jss::version] = version; + expectError(bad, "\"version\" must be 1 or 2"); + } + { + auto bad = good; + bad[jss::public_key] = 1; + expectError(bad, "\"public_key\" and \"manifest\" must be strings"); + } + { + auto bad = good; + bad[jss::manifest] = "AAAA"; + expectError(bad, "\"manifest\" does not deserialize and verify"); + } + { + SigningKeys revoked(KeyType::Ed25519); + auto bad = good; + bad[jss::manifest] = revoked.revoke(); + bad[jss::public_key] = strHex(revoked.publicKey()); + expectError(bad, "the publisher's master key is revoked"); + } + { + auto bad = good; + bad[jss::blob] = base64Encode("{}"); + expectError(bad, "blob 0: \"sequence\" must be an integer from 1 to 2147483647"); + } + { + auto bad = good; + bad[jss::blobs_v2] = json::Value(json::ValueType::Array); + expectError(bad, "a version 1 list needs \"blob\" and \"signature\" and no \"blobs_v2\""); + } + + auto const v2 = signed2(); + std::string const v2Shape = + "a version 2 list needs 1 to 5 \"blobs_v2\" entries and no top-level \"blob\""; + { + auto bad = v2; + bad[jss::blobs_v2] = json::Value(json::ValueType::Array); + expectError(bad, v2Shape); + bad = v2; + bad[jss::blob] = "x"; + expectError(bad, v2Shape); + } + std::string const entryShape = + "every \"blobs_v2\" entry needs \"blob\" and \"signature\" strings and an optional " + "\"manifest\" string"; + { + auto bad = v2; + bad[jss::blobs_v2][0u].removeMember(jss::signature); + expectError(bad, entryShape); + bad = v2; + bad[jss::blobs_v2][0u][jss::manifest] = 5; + expectError(bad, entryShape); + } + { + Publisher const other; + auto bad = v2; + bad[jss::blobs_v2][0u][jss::manifest] = other.token.manifest; + expectError(bad, "blob 0: its \"manifest\" is not this publisher's"); + + SigningKeys revoked = publisher.keys; + bad[jss::blobs_v2][0u][jss::manifest] = revoked.revoke(); + expectError(bad, "blob 0: its \"manifest\" revokes the publisher's master key"); + + // An entry carrying the publisher's own manifest changes nothing + auto fine = v2; + fine[jss::blobs_v2][0u][jss::manifest] = publisher.token.manifest; + expectOk(fine); + } + { + // An entry with a newer manifest moves the signing key for that blob and + // the ones after it, as a server would. + SigningKeys rotated = publisher.keys; + auto const token2 = rotated.createToken(KeyType::Ed25519); + auto const manifest2 = *deserializeManifest(base64Decode(token2.manifest)); + auto const later = parseUnsignedList(unsignedListText(validators, 8, now + 300)); + json::Value entry(json::ValueType::Object); + entry[jss::blob] = base64Encode(later.canonical); + entry[jss::signature] = signList(later, *manifest2.signingKey, token2.validationSecret); + entry[jss::manifest] = token2.manifest; + auto newer = v2; + newer[jss::blobs_v2].append(entry); + auto const report = verifyList(newer, std::nullopt, std::nullopt, now); + EXPECT_TRUE(report["ok"].asBool()) << to_string(report); + EXPECT_EQ(report["manifest_sequence"].asUInt(), 2u); + + // Placed first, it invalidates the blob signed under the older key + auto reordered = v2; + reordered[jss::blobs_v2] = json::Value(json::ValueType::Array); + reordered[jss::blobs_v2].append(entry); + reordered[jss::blobs_v2].append(v2[jss::blobs_v2][0u]); + expectError(reordered, "blob 1: the signature does not verify under the signing key"); + } +} + +} // namespace xrpl::tools::test diff --git a/src/tests/tools/validator-keys/SigningKeys.cpp b/src/tests/tools/validator-keys/SigningKeys.cpp new file mode 100644 index 0000000000..eb2cb1ce0f --- /dev/null +++ b/src/tests/tools/validator-keys/SigningKeys.cpp @@ -0,0 +1,501 @@ +#include + +#include +#include +#include +#include +#include +#include + +#include + +#include + +#include + +namespace xrpl::tools::test { + +namespace { + +constexpr std::array kKeyTypes{{KeyType::Ed25519, KeyType::Secp256k1}}; +constexpr std::uint32_t kMaxSequence = std::numeric_limits::max(); + +std::string const kBadManifest = "Manifest is not properly signed"; +std::string const kRevoked = "Validator keys have been revoked."; +std::string const kExhausted = + "Maximum number of tokens have already been generated.\n" + "Revoke validator keys if previous token has been compromised."; + +class SigningKeysTest : public ::testing::Test +{ +protected: + TempDir dir; + std::filesystem::path keyFile{dir.file("validator_keys.json")}; + + void + writeKeyFile(json::Value const& jv) + { + writeFile(keyFile, jv.toStyledString()); + } + + std::string + loadError(json::Value const& jv) + { + writeKeyFile(jv); + return errorOf([&] { SigningKeys::make_SigningKeys(keyFile); }); + } + + json::Value + keyFileJson() + { + json::Reader reader; + json::Value jv; + reader.parse(readFile(keyFile), jv); + return jv; + } + + std::string + invalidField(json::Value const& jv, std::string const& field) const + { + return "Key file '" + keyFile.string() + "' contains invalid \"" + field + + "\" field: " + jv[field].toStyledString(); + } + + static json::Value + baseKeyFile(SecretKey const& secret) + { + json::Value jv; + jv["key_type"] = "ed25519"; + jv["secret_key"] = toBase58(TokenType::NodePrivate, secret); + jv["token_sequence"] = 1; + jv["revoked"] = false; + return jv; + } +}; + +// The manifest of a token or a revocation, parsed and checked against the +// master key that made it. +Manifest +manifestOf(std::string const& base64, SigningKeys const& keys) +{ + auto m = deserializeManifest(base64Decode(base64)); + if (!m) + { + ADD_FAILURE() << "not a manifest: " << base64; + throw std::runtime_error("not a manifest"); + } + EXPECT_TRUE(m->verify()); + EXPECT_EQ(m->masterKey, keys.publicKey()); + return std::move(*m); +} + +} // namespace + +TEST_F(SigningKeysTest, key_file_round_trip) +{ + for (auto const keyType : kKeyTypes) + { + SigningKeys const keys(keyType); + keys.writeToFile(keyFile); + EXPECT_TRUE(std::filesystem::exists(keyFile)); + EXPECT_FALSE(std::filesystem::exists(keyFile.string() + ".tmp")); + auto const perms = std::filesystem::status(keyFile).permissions(); + EXPECT_EQ( + perms & (std::filesystem::perms::group_all | std::filesystem::perms::others_all), + std::filesystem::perms::none); + EXPECT_TRUE(keys == SigningKeys::make_SigningKeys(keyFile)); + } + + // A token, a domain and a pending external token all survive the round trip + SigningKeys keys(KeyType::Ed25519); + keys.domain("validator.example.com"); + keys.createToken(); + SigningKeys const signer(KeyType::Ed25519); + keys.startToken(KeyType::Ed25519, signer.publicKey()); + keys.writeToFile(keyFile); + EXPECT_TRUE(keys == SigningKeys::make_SigningKeys(keyFile)); + + // So does a pending token with a generated signing key + SigningKeys other(KeyType::Secp256k1); + SigningKeys const before = other; + other.startToken(KeyType::Secp256k1); + other.writeToFile(keyFile); + EXPECT_TRUE(other == SigningKeys::make_SigningKeys(keyFile)); + EXPECT_FALSE(other == keys); + EXPECT_FALSE(other == before); + + // The same master key with a pending token of the other kind + SigningKeys external = before; + external.startToken(KeyType::Secp256k1, signer.publicKey()); + EXPECT_FALSE(other == external); +} + +TEST_F(SigningKeysTest, write_to_file_errors) +{ + SigningKeys const keys(KeyType::Ed25519); + + auto const nested = dir.file("a/b/c/validator_keys.json"); + keys.writeToFile(nested); + EXPECT_TRUE(keys == SigningKeys::make_SigningKeys(nested)); + + // The parent path is a file + auto const blocked = std::filesystem::path(keyFile.string() + "/keys.json"); + keys.writeToFile(keyFile); + EXPECT_EQ( + errorOf([&] { keys.writeToFile(blocked); }), + "Cannot create directory: " + blocked.parent_path().string()); + + // The target is a directory + auto const directory = std::filesystem::path(dir.file("dir")); + std::filesystem::create_directory(directory); + EXPECT_EQ( + errorOf([&] { keys.writeToFile(directory); }), + "Cannot write key file: " + directory.string()); + + // The directory cannot be written to + auto const sealed = std::filesystem::path(dir.file("sealed")); + std::filesystem::create_directory(sealed); + std::filesystem::permissions( + sealed, std::filesystem::perms::owner_read | std::filesystem::perms::owner_exec); + auto const inSealed = sealed / "keys.json"; + EXPECT_EQ( + errorOf([&] { keys.writeToFile(inSealed); }), + "Cannot write key file: " + inSealed.string()); + std::filesystem::permissions(sealed, std::filesystem::perms::owner_all); +} + +TEST_F(SigningKeysTest, key_file_fields) +{ + EXPECT_EQ( + errorOf([&] { SigningKeys::make_SigningKeys(keyFile); }), + "Failed to open key file: " + keyFile.string()); + + writeFile(keyFile, "{{}"); + EXPECT_EQ( + errorOf([&] { SigningKeys::make_SigningKeys(keyFile); }), + "Unable to parse json key file: " + keyFile.string()); + + json::Value jv; + jv["dummy"] = "field"; + for (auto const* field : {"key_type", "secret_key", "token_sequence", "revoked"}) + { + EXPECT_EQ( + loadError(jv), + "Key file '" + keyFile.string() + "' is missing \"" + field + "\" field"); + jv[field] = "dummy"; + } + EXPECT_EQ(loadError(jv), invalidField(jv, "key_type")); + + auto const kp = generateKeyPair(KeyType::Ed25519, randomSeed()); + jv["key_type"] = "ed25519"; + EXPECT_EQ(loadError(jv), invalidField(jv, "token_sequence")); + jv["token_sequence"] = -1; + EXPECT_EQ(loadError(jv), invalidField(jv, "token_sequence")); + jv["token_sequence"] = true; + EXPECT_EQ(loadError(jv), invalidField(jv, "token_sequence")); + jv["token_sequence"] = json::UInt(kMaxSequence); + EXPECT_EQ(loadError(jv), invalidField(jv, "revoked")); + jv["revoked"] = false; + EXPECT_EQ(loadError(jv), invalidField(jv, "secret_key")); + jv["secret_key"] = toBase58(TokenType::NodePrivate, kp.second); + EXPECT_EQ(loadError(jv), ""); + + // Optional fields with the wrong type or value + for (auto const* field : + {"domain", "manifest", "pending_token_secret", "pending_signing_key", "pending_key_type"}) + { + auto bad = baseKeyFile(kp.second); + bad[field] = 1; + if (field == std::string("pending_key_type")) + bad["pending_token_secret"] = toBase58(TokenType::NodePrivate, kp.second); + else if (std::string(field).starts_with("pending_")) + bad["pending_key_type"] = "ed25519"; + EXPECT_EQ(loadError(bad), invalidField(bad, field)) << field; + } + for (auto const* field : {"manifest", "pending_token_secret", "pending_signing_key"}) + { + auto bad = baseKeyFile(kp.second); + bad[field] = "not valid"; + bad["pending_key_type"] = "ed25519"; + EXPECT_EQ(loadError(bad), invalidField(bad, field)) << field; + } + { + auto bad = baseKeyFile(kp.second); + bad["manifest"] = ""; + EXPECT_EQ(loadError(bad), invalidField(bad, "manifest")); + } + { + auto bad = baseKeyFile(kp.second); + bad["domain"] = "-bad.example"; + EXPECT_EQ( + loadError(bad), "The domain field must use the '[host.][subdomain.]domain.tld' format"); + } + { + // Pending fields need a key type and exclude each other + auto bad = baseKeyFile(kp.second); + bad["pending_token_secret"] = toBase58(TokenType::NodePrivate, kp.second); + EXPECT_EQ( + loadError(bad), + "Key file '" + keyFile.string() + "' is missing \"pending_key_type\" field"); + bad["pending_key_type"] = "dummy"; + EXPECT_EQ(loadError(bad), invalidField(bad, "pending_key_type")); + bad["pending_key_type"] = "ed25519"; + bad["pending_signing_key"] = toBase58(TokenType::NodePublic, kp.first); + EXPECT_EQ( + loadError(bad), + "Key file '" + keyFile.string() + + "' has both \"pending_token_secret\" and \"pending_signing_key\""); + } +} + +TEST_F(SigningKeysTest, external_key_file_fields) +{ + auto const kp = generateKeyPair(KeyType::Ed25519, randomSeed()); + json::Value jv; + jv["key_type"] = "ed25519"; + jv["secret_key"] = "external"; + jv["token_sequence"] = 0; + jv["revoked"] = false; + EXPECT_EQ(loadError(jv), "Key file '" + keyFile.string() + "' is missing \"public_key\" field"); + jv["public_key"] = "dummy public"; + EXPECT_EQ(loadError(jv), invalidField(jv, "public_key")); + jv["public_key"] = toBase58(TokenType::NodePublic, kp.first); + jv["key_type"] = "secp256k1"; + EXPECT_EQ( + loadError(jv), + "Key file '" + keyFile.string() + + "' has a \"key_type\" that does not match \"public_key\""); + jv["key_type"] = "ed25519"; + EXPECT_EQ(loadError(jv), ""); + + auto const keys = SigningKeys::make_SigningKeys(keyFile); + EXPECT_FALSE(keys.hasSecret()); + EXPECT_EQ(keys.publicKey(), kp.first); + EXPECT_TRUE(keys == SigningKeys(KeyType::Ed25519, kp.first)); +} + +TEST_F(SigningKeysTest, create_token) +{ + for (auto const keyType : kKeyTypes) + { + SigningKeys keys(keyType); + std::uint32_t sequence = 0; + for (auto const tokenKeyType : kKeyTypes) + { + auto const token = keys.createToken(tokenKeyType); + auto const m = manifestOf(token.manifest, keys); + EXPECT_EQ(m.sequence, ++sequence); + EXPECT_EQ(keys.sequence(), sequence); + ASSERT_TRUE(m.signingKey); + EXPECT_EQ(*m.signingKey, derivePublicKey(tokenKeyType, token.validationSecret)); + EXPECT_EQ(base64Encode(keys.manifest().data(), keys.manifest().size()), token.manifest); + } + } + + auto const kp = generateKeyPair(KeyType::Ed25519, randomSeed()); + { + SigningKeys keys(KeyType::Ed25519, kp.second, kMaxSequence - 1); + EXPECT_EQ(errorOf([&] { keys.createToken(); }), kExhausted); + } + { + // A key migrated from a publisher whose manifests carried the list + // sequence continues from that sequence. + SigningKeys keys(KeyType::Ed25519, kp.second, 2026091301); + auto const m = manifestOf(keys.createToken(KeyType::Ed25519).manifest, keys); + EXPECT_EQ(m.sequence, 2026091302u); + } + { + SigningKeys keys(KeyType::Ed25519); + keys.revoke(); + EXPECT_EQ(errorOf([&] { keys.createToken(); }), kRevoked); + } + { + SigningKeys keys(KeyType::Ed25519, kp.first); + EXPECT_EQ( + errorOf([&] { keys.createToken(); }), "This key file cannot be used to sign tokens."); + EXPECT_EQ(errorOf([&] { keys.revoke(); }), "This key file cannot be used to sign tokens."); + } +} + +TEST_F(SigningKeysTest, token_with_domain) +{ + SigningKeys keys(KeyType::Ed25519); + keys.domain("validator.example.com"); + auto const m = manifestOf(keys.createToken().manifest, keys); + EXPECT_EQ(m.domain, "validator.example.com"); + EXPECT_EQ( + keys.attestationData(), + "[domain-attestation-blob:validator.example.com:" + + toBase58(TokenType::NodePublic, keys.publicKey()) + "]"); + + for (auto const* bad : {"a.b", "-bad.example", "nodots"}) + EXPECT_EQ( + errorOf([&] { keys.domain(bad); }), + "The domain field must use the '[host.][subdomain.]domain.tld' format") + << bad; + keys.domain(""); + EXPECT_TRUE(keys.domain().empty()); +} + +TEST_F(SigningKeysTest, revoke) +{ + for (auto const keyType : kKeyTypes) + { + SigningKeys keys(keyType); + auto const m = manifestOf(keys.revoke(), keys); + EXPECT_TRUE(m.revoked()); + EXPECT_FALSE(m.signingKey); + EXPECT_TRUE(keys.revoked()); + // Revoking again is allowed + manifestOf(keys.revoke(), keys); + } +} + +TEST_F(SigningKeysTest, sign) +{ + std::map const expected{ + {KeyType::Ed25519, + "2EE541D6825791BF5454C571D2B363EAB3F01C73159B1F" + "237AC6D38663A82B9D5EAD262D5F776B916E68247A1F082090F3BAE7ABC939" + "C8F29B0DC759FD712300"}, + {KeyType::Secp256k1, + "3045022100F142C27BF83D8D4541C7A4E759DE64A672" + "51A388A422DFDA6F4B470A2113ABC4022002DA56695F3A805F62B55E7CC8D5" + "55438D64A229CD0B4BA2AE33402443B20409"}}; + + std::string const data = "data to sign"; + for (auto const keyType : kKeyTypes) + { + auto const sk = generateSecretKey(keyType, generateSeed("test")); + SigningKeys const keys(keyType, sk, 1); + EXPECT_EQ(keys.sign(data), expected.at(keyType)); + EXPECT_EQ(keys.signHex(strHex(data)), expected.at(keyType)); + auto const sig = strUnHex(keys.sign(data)); + ASSERT_TRUE(sig); + EXPECT_TRUE(verify(keys.publicKey(), makeSlice(data), makeSlice(*sig))); + + SigningKeys const external(keyType, derivePublicKey(keyType, sk)); + EXPECT_EQ(errorOf([&] { external.sign(data); }), "This key file cannot be used to sign."); + EXPECT_EQ( + errorOf([&] { external.signHex(strHex(data)); }), + "This key file cannot be used to sign."); + } + SigningKeys const keys(KeyType::Ed25519); + EXPECT_EQ(errorOf([&] { keys.signHex("zz"); }), "Could not decode hex string: zz"); +} + +TEST_F(SigningKeysTest, external_master) +{ + for (auto const keyType : kKeyTypes) + { + // The signer stands in for the hardware holding the master key + SigningKeys const signer(keyType); + SigningKeys keys(keyType, signer.publicKey()); + std::uint32_t sequence = 0; + + for (auto const tokenKeyType : kKeyTypes) + { + auto const data = keys.startToken(tokenKeyType); + keys.writeToFile(keyFile); + auto fileKeys = SigningKeys::make_SigningKeys(keyFile); + EXPECT_TRUE(keys == fileKeys); + + auto const finished = fileKeys.finishToken(*strUnHex(signer.signHex(data))); + ASSERT_TRUE(finished.secret); + auto const m = manifestOf(finished.manifest, keys); + EXPECT_EQ(m.sequence, ++sequence); + EXPECT_EQ(fileKeys.sequence(), sequence); + EXPECT_EQ(*m.signingKey, derivePublicKey(tokenKeyType, *finished.secret)); + + // A signature over other bytes does not finish the token + EXPECT_EQ( + errorOf([&] { keys.finishToken(*strUnHex(signer.sign("foo"))); }), kBadManifest); + EXPECT_EQ( + errorOf([&] { keys.finishToken(*strUnHex(signer.signHex(data)), Blob{}); }), + "The pending token's signing key is in this key file; pass one signature"); + keys.finishToken(*strUnHex(signer.signHex(data))); + } + + // Nothing pending + EXPECT_EQ(errorOf([&] { keys.finishToken(Blob{}); }), "No pending token to finish"); + + // Revocation: the same bytes each time, so a signature can be kept and reused + auto const revocation = keys.startRevoke(); + EXPECT_EQ(revocation, keys.startRevoke()); + EXPECT_EQ(errorOf([&] { keys.finishRevoke(*strUnHex(signer.sign("foo"))); }), kBadManifest); + EXPECT_FALSE(keys.revoked()); + auto const sig = *strUnHex(signer.signHex(revocation)); + manifestOf(keys.finishRevoke(sig), keys); + EXPECT_TRUE(keys.revoked()); + manifestOf(keys.finishRevoke(sig), keys); + + EXPECT_EQ(errorOf([&] { keys.startToken(); }), kRevoked); + EXPECT_EQ(errorOf([&] { keys.finishToken(sig); }), kRevoked); + } + + SigningKeys exhausted( + KeyType::Ed25519, SigningKeys(KeyType::Ed25519).publicKey(), kMaxSequence - 1); + EXPECT_EQ(errorOf([&] { exhausted.startToken(); }), kExhausted); +} + +TEST_F(SigningKeysTest, external_signing_key) +{ + // The master key is in software here; the signing key is held elsewhere. + SigningKeys const signer(KeyType::Ed25519); + SigningKeys keys(KeyType::Ed25519); + + EXPECT_EQ( + errorOf([&] { keys.startToken(KeyType::Ed25519, keys.publicKey()); }), + "The signing key must differ from the master key"); + + auto const data = keys.startToken(KeyType::Ed25519, signer.publicKey()); + keys.writeToFile(keyFile); + auto fileKeys = SigningKeys::make_SigningKeys(keyFile); + EXPECT_TRUE(keys == fileKeys); + + auto const masterSig = *strUnHex(keys.signHex(data)); + auto const signingSig = *strUnHex(signer.signHex(data)); + EXPECT_EQ( + errorOf([&] { fileKeys.finishToken(masterSig); }), + "The pending token's signing key is external; pass its signature too"); + EXPECT_EQ(errorOf([&] { fileKeys.finishToken(masterSig, masterSig); }), kBadManifest); + + auto const finished = fileKeys.finishToken(masterSig, signingSig); + EXPECT_FALSE(finished.secret); + auto const m = manifestOf(finished.manifest, keys); + EXPECT_EQ(m.sequence, 1u); + EXPECT_EQ(*m.signingKey, signer.publicKey()); + EXPECT_EQ(fileKeys.sequence(), 1u); + EXPECT_EQ( + errorOf([&] { fileKeys.finishToken(masterSig, signingSig); }), + "No pending token to finish"); +} + +TEST_F(SigningKeysTest, stored_manifest_is_checked) +{ + SigningKeys keys(KeyType::Ed25519); + keys.createToken(KeyType::Ed25519); + keys.writeToFile(keyFile); + auto const good = keyFileJson(); + EXPECT_EQ(loadError(good), ""); + + // A token manifest on revoked keys + auto jv = good; + jv["revoked"] = true; + EXPECT_EQ(loadError(jv), kBadManifest); + + // A revocation manifest on keys that are not revoked + SigningKeys revoked(KeyType::Ed25519); + revoked.revoke(); + jv = good; + jv["manifest"] = strHex(makeSlice(revoked.manifest())); + EXPECT_EQ(loadError(jv), kBadManifest); + + // A manifest of another key + jv = good; + jv["secret_key"] = + toBase58(TokenType::NodePrivate, generateKeyPair(KeyType::Ed25519, randomSeed()).second); + EXPECT_EQ(loadError(jv), kBadManifest); +} + +} // namespace xrpl::tools::test diff --git a/src/tools/validator-keys/CMakeLists.txt b/src/tools/validator-keys/CMakeLists.txt index db9a66c6e0..d3f52260d7 100644 --- a/src/tools/validator-keys/CMakeLists.txt +++ b/src/tools/validator-keys/CMakeLists.txt @@ -1,23 +1,33 @@ # 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) +include(GNUInstallDirs) + +add_library(xrpl.validator-keys STATIC) 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 + xrpl.validator-keys + PRIVATE Commands.cpp ListSigning.cpp SigningKeys.cpp ) target_include_directories( - validator-keys - PRIVATE $ + xrpl.validator-keys + PUBLIC $ ) target_link_libraries( - validator-keys - PRIVATE Xrpl::boost Xrpl::opts Xrpl::libs xrpl.libxrpl + xrpl.validator-keys + PUBLIC Xrpl::boost Xrpl::opts Xrpl::libs xrpl.libxrpl ) + +add_executable(validator-keys Main.cpp) +target_link_libraries(validator-keys PRIVATE xrpl.validator-keys) +patch_nix_binary(validator-keys) +set_target_properties( + validator-keys + PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}" +) + +configure_file( + "${CMAKE_CURRENT_SOURCE_DIR}/LICENSE" + "${CMAKE_BINARY_DIR}/validator-keys-LICENSE" + COPYONLY +) +install(TARGETS validator-keys RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}) diff --git a/src/tools/validator-keys/Commands.cpp b/src/tools/validator-keys/Commands.cpp new file mode 100644 index 0000000000..fbd839a78d --- /dev/null +++ b/src/tools/validator-keys/Commands.cpp @@ -0,0 +1,603 @@ +#include + +#include +#include +#include +#include +#include + +#include + +#include +#include + +#include +#include + +namespace xrpl::tools { + +namespace { + +// The build version number: edit for each release, in semantic version form. +char const* const kVersionString = + "0.4.0" + +#if defined(DEBUG) || defined(SANITIZER) + "+" +#ifdef DEBUG + "DEBUG" +#ifdef SANITIZER + "." +#endif +#endif + +#ifdef SANITIZER + BOOST_PP_STRINGIZE(SANITIZER) +#endif +#endif + ; + +constexpr std::size_t kMaxDocumentBytes = 4 * 1024 * 1024; +constexpr std::size_t kBlockLineLength = 72; + +using Args = std::vector; + +struct Context +{ + ToolOptions const& options; + std::ostream& out; + std::ostream& err; +}; + +/** + * Where a command's result goes: the output file named by `--out`, opened + * before the command changes any state so an unwritable path fails first, or + * the output stream. A file that receives nothing is removed again. + */ +class Output +{ + std::optional file_; + std::ofstream stream_; + std::ostream& out_; + bool written_ = false; + +public: + Output(std::optional const& file, std::ostream& out) + : file_(file), out_(out) + { + if (file_) + { + stream_.open(*file_, std::ios_base::trunc); + if (stream_.fail()) + throw std::runtime_error("Cannot open output file: " + file_->string()); + } + } + + ~Output() + { + if (file_ && !written_) + { + stream_.close(); + std::error_code ec; + std::filesystem::remove(*file_, ec); + } + } + + Output(Output const&) = delete; + Output& + operator=(Output const&) = delete; + + // A config block in 72-character lines; a file gets owner-only permissions + // because a token holds a secret. + void + block(std::string const& section, std::string const& publicKey, std::string const& body) + { + std::string text = "# validator public key: " + publicKey + "\n\n[" + section + "]\n"; + for (std::size_t i = 0; i < body.size(); i += kBlockLineLength) + text.append(body, i, kBlockLineLength).push_back('\n'); + + if (!file_) + { + out_ << "Update xrpld.cfg file with these values and restart xrpld:\n\n" + << text << std::endl; + return; + } + write(text, "[" + section + "]"); + std::error_code ec; + std::filesystem::permissions( + *file_, std::filesystem::perms::owner_read | std::filesystem::perms::owner_write, ec); + } + + void + json(json::Value const& jv) + { + if (!file_) + { + out_ << jv.toStyledString() << std::endl; + return; + } + write(jv.toStyledString(), "The list"); + } + +private: + void + write(std::string const& text, std::string const& what) + { + stream_ << text; + stream_.close(); + if (stream_.fail()) + throw std::runtime_error( // LCOV_EXCL_LINE + "Cannot write output file: " + file_->string()); // LCOV_EXCL_LINE + written_ = true; + out_ << what << " written to " << file_->string() << "\n"; + } +}; + +/** + * Parses a public key given as base58, hex or base64. + * + * @throws std::runtime_error if none of the encodings yields a public key + */ +PublicKey +parsePublicKey(std::string const& data) +{ + if (auto const key = parseBase58(TokenType::NodePublic, data)) + return *key; + if (auto const key = parseHexKey(data)) + return *key; + if (auto const bytes = base64Decode(data); publicKeyType(makeSlice(bytes))) + return PublicKey(makeSlice(bytes)); + throw std::runtime_error("Unable to parse public key: " + data); +} + +/** + * Decodes a signature given as hex or base64. Only using it shows whether it + * is right, so any string that decodes is accepted. + */ +Blob +decodeSignature(std::string const& data) +{ + if (auto const bytes = strUnHex(data)) + return *bytes; + // base64Decode returns partial data for invalid input, so require a round trip. + if (auto const bytes = base64Decode(data); base64Encode(bytes) == data) + return Blob(bytes.begin(), bytes.end()); + throw std::runtime_error("Invalid signature encoding"); +} + +json::Value +readJsonFile(std::filesystem::path const& file) +{ + std::error_code ec; + auto const text = getFileContents(ec, file, kMaxDocumentBytes); + if (ec) + throw std::runtime_error("Failed to open file: " + file.string()); + json::Reader reader; + json::Value jv; + if (!reader.parse(text, jv)) + throw std::runtime_error("Not a JSON document: " + file.string()); + return jv; +} + +std::string +nodePublic(SigningKeys const& keys) +{ + return toBase58(TokenType::NodePublic, keys.publicKey()); +} + +void +refuseExisting(std::filesystem::path const& keyFile) +{ + if (std::filesystem::exists(keyFile)) + throw std::runtime_error("Refusing to overwrite existing key file: " + keyFile.string()); +} + +void +storedNotice(std::filesystem::path const& keyFile, std::ostream& out) +{ + out << "Validator keys stored in " << keyFile.string() + << "\n\nThis file should be stored securely and not shared.\n\n"; +} + +SigningKeys +loadUnrevoked(std::filesystem::path const& keyFile) +{ + auto keys = SigningKeys::make_SigningKeys(keyFile); + if (keys.revoked()) + throw std::runtime_error("Operation error: The specified master key has been revoked!"); + return keys; +} + +void +warnRevocation(SigningKeys const& keys, std::ostream& err) +{ + if (keys.revoked()) + err << "WARNING: Validator keys have already been revoked!\n\n"; + else + err << "WARNING: This will revoke your validator keys!\n\n"; +} + +void +emitFinished(SigningKeys const& keys, SigningKeys::Finished const& finished, Output& output) +{ + if (finished.secret) + output.block( + "validator_token", + nodePublic(keys), + tokenToBase64(ValidatorToken{finished.manifest, *finished.secret})); + else + output.block("validator_manifest", nodePublic(keys), finished.manifest); +} + +void +emitAttestation(SigningKeys const& keys, Context& ctx) +{ + if (keys.domain().empty()) + { + ctx.out << "No attestation is necessary if no domain is specified!\n" + "If you have an attestation in your xrpl-ledger.toml\n" + "you should remove it at this time.\n"; + return; + } + if (!keys.hasSecret()) + { + ctx.err << "Sign these bytes with the master key; the hex signature is the\n" + "attestation for xrp-ledger.toml.\n\n"; + ctx.out << strHex(makeSlice(keys.attestationData())) << std::endl; + return; + } + ctx.out << "The domain attestation for validator " << nodePublic(keys) << " is:\n\n" + << "attestation=\"" << keys.sign(keys.attestationData()) << "\"\n\n" + << "You should include it in your xrp-ledger.toml file in the\n" + "section for this validator.\n"; +} + +int +cmdCreateKeys(Args const&, Context& ctx) +{ + refuseExisting(ctx.options.keyFile); + SigningKeys const keys(KeyType::Ed25519); + keys.writeToFile(ctx.options.keyFile); + storedNotice(ctx.options.keyFile, ctx.out); + return EXIT_SUCCESS; +} + +int +cmdCreateExternal(Args const& args, Context& ctx) +{ + refuseExisting(ctx.options.keyFile); + auto const publicKey = parsePublicKey(args[0]); + SigningKeys const keys(*publicKeyType(publicKey), publicKey); + keys.writeToFile(ctx.options.keyFile); + storedNotice(ctx.options.keyFile, ctx.out); + return EXIT_SUCCESS; +} + +int +cmdCreateToken(Args const&, Context& ctx) +{ + Output output(ctx.options.outFile, ctx.out); + auto keys = SigningKeys::make_SigningKeys(ctx.options.keyFile); + auto const token = keys.createToken(ctx.options.tokenKeyType); + keys.writeToFile(ctx.options.keyFile); + output.block("validator_token", nodePublic(keys), tokenToBase64(token)); + return EXIT_SUCCESS; +} + +int +cmdStartToken(Args const&, Context& ctx) +{ + auto keys = SigningKeys::make_SigningKeys(ctx.options.keyFile); + auto const data = keys.startToken(ctx.options.tokenKeyType, ctx.options.signingKey); + keys.writeToFile(ctx.options.keyFile); + ctx.out << data << std::endl; + return EXIT_SUCCESS; +} + +int +cmdFinishToken(Args const& args, Context& ctx) +{ + Output output(ctx.options.outFile, ctx.out); + auto keys = SigningKeys::make_SigningKeys(ctx.options.keyFile); + std::optional signingSig; + if (args.size() == 2) + signingSig = decodeSignature(args[1]); + auto const finished = keys.finishToken(decodeSignature(args[0]), signingSig); + keys.writeToFile(ctx.options.keyFile); + emitFinished(keys, finished, output); + return EXIT_SUCCESS; +} + +int +cmdRevokeKeys(Args const&, Context& ctx) +{ + auto keys = SigningKeys::make_SigningKeys(ctx.options.keyFile); + warnRevocation(keys, ctx.err); + auto const revocation = keys.revoke(); + keys.writeToFile(ctx.options.keyFile); + Output(std::nullopt, ctx.out).block("validator_key_revocation", nodePublic(keys), revocation); + return EXIT_SUCCESS; +} + +int +cmdStartRevokeKeys(Args const&, Context& ctx) +{ + auto const keys = SigningKeys::make_SigningKeys(ctx.options.keyFile); + warnRevocation(keys, ctx.err); + ctx.out << keys.startRevoke() << std::endl; + return EXIT_SUCCESS; +} + +int +cmdFinishRevokeKeys(Args const& args, Context& ctx) +{ + auto keys = SigningKeys::make_SigningKeys(ctx.options.keyFile); + warnRevocation(keys, ctx.err); + auto const revocation = keys.finishRevoke(decodeSignature(args[0])); + keys.writeToFile(ctx.options.keyFile); + Output(std::nullopt, ctx.out).block("validator_key_revocation", nodePublic(keys), revocation); + return EXIT_SUCCESS; +} + +int +setDomain(std::string const& domain, Context& ctx) +{ + Output output(ctx.options.outFile, ctx.out); + auto keys = loadUnrevoked(ctx.options.keyFile); + + if (domain == keys.domain()) + { + ctx.out + << (domain.empty() ? "The domain name was already cleared!\n" + : "The domain name was already set.\n"); + return EXIT_SUCCESS; + } + + keys.domain(domain); + if (!keys.hasSecret()) + { + keys.writeToFile(ctx.options.keyFile); + ctx.out << (domain.empty() ? "The domain name has been cleared.\n" + : "The domain name has been set to: " + domain + "\n") + << "The next token carries it: run start_token and finish_token.\n"; + return EXIT_SUCCESS; + } + + auto const token = keys.createToken(ctx.options.tokenKeyType); + keys.writeToFile(ctx.options.keyFile); + + ctx.out + << (domain.empty() ? "The domain name has been cleared.\n" + : "The domain name has been set to: " + domain + "\n\n"); + emitAttestation(keys, ctx); + ctx.out << "\nYou also need to update the xrpld.cfg file to add a new\n" + "validator token and restart xrpld:\n\n"; + output.block("validator_token", nodePublic(keys), tokenToBase64(token)); + return EXIT_SUCCESS; +} + +int +cmdSetDomain(Args const& args, Context& ctx) +{ + return setDomain(args[0], ctx); +} + +int +cmdClearDomain(Args const&, Context& ctx) +{ + return setDomain("", ctx); +} + +int +cmdAttestDomain(Args const&, Context& ctx) +{ + emitAttestation(loadUnrevoked(ctx.options.keyFile), ctx); + return EXIT_SUCCESS; +} + +int +sign(std::string const& data, bool hex, Context& ctx) +{ + if (data.empty()) + throw std::runtime_error("Syntax error: Must specify data string to sign"); + auto const keys = SigningKeys::make_SigningKeys(ctx.options.keyFile); + if (keys.revoked()) + ctx.err << "WARNING: Validator keys have been revoked!\n\n"; + ctx.out << (hex ? keys.signHex(data) : keys.sign(data)) << std::endl; + return EXIT_SUCCESS; +} + +int +cmdSign(Args const& args, Context& ctx) +{ + return sign(args[0], false, ctx); +} + +int +cmdSignHex(Args const& args, Context& ctx) +{ + return sign(args[0], true, ctx); +} + +int +cmdShowManifest(Args const& args, Context& ctx) +{ + auto const keys = SigningKeys::make_SigningKeys(ctx.options.keyFile); + auto const& m = keys.manifest(); + if (m.empty()) + { + ctx.out << "The last manifest generated is unavailable. You can\n" + "generate a new one.\n\n"; + return EXIT_SUCCESS; + } + if (args[0] == "base64") + { + ctx.out << "Manifest #" << keys.sequence() << " (Base64):\n" + << base64Encode(m.data(), m.size()) << "\n\n"; + return EXIT_SUCCESS; + } + if (args[0] == "hex") + { + ctx.out << "Manifest #" << keys.sequence() << " (Hex):\n" << strHex(makeSlice(m)) << "\n\n"; + return EXIT_SUCCESS; + } + throw std::runtime_error("Unknown encoding '" + args[0] + "'"); +} + +// The manifest a list is signed under when the signing key is external. +Manifest +loadSigningManifest(Context const& ctx, char const* command) +{ + if (!ctx.options.manifestFile) + throw std::runtime_error(std::string(command) + " needs --manifest-file"); + auto manifest = loadManifestFile(*ctx.options.manifestFile); + if (manifest.revoked() || !manifest.signingKey) + throw std::runtime_error("The manifest is revoked"); + return manifest; +} + +void +emitSignedList( + std::string const& manifestBase64, + PublicKey const& masterKey, + UnsignedList const& list, + std::string const& signatureHex, + Resigner const& resign, + Context& ctx, + Output& output) +{ + std::optional append; + if (ctx.options.appendFile) + append = readJsonFile(*ctx.options.appendFile); + output.json(makeSignedList( + manifestBase64, masterKey, list, signatureHex, ctx.options.listVersion, append, resign)); +} + +int +cmdSignList(Args const& args, Context& ctx) +{ + if (!ctx.options.tokenFile) + throw std::runtime_error("sign_list needs --token-file"); + Output output(ctx.options.outFile, ctx.out); + + auto const token = loadTokenFile(*ctx.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 signingKey = *manifest->signingKey; + auto const keyType = publicKeyType(signingKey); + if (!keyType || derivePublicKey(*keyType, token.validationSecret) != signingKey) + throw std::runtime_error("The token's secret does not match its manifest"); + + auto const list = loadUnsignedList(args[0]); + auto const resign = [&](std::string const& blobBytes) { + return strHex(xrpl::sign(signingKey, token.validationSecret, makeSlice(blobBytes))); + }; + emitSignedList( + token.manifest, manifest->masterKey, list, resign(list.canonical), resign, ctx, output); + return EXIT_SUCCESS; +} + +int +cmdStartSignList(Args const& args, Context& ctx) +{ + loadSigningManifest(ctx, "start_sign_list"); + auto const list = loadUnsignedList(args[0]); + ctx.out << strHex(makeSlice(list.canonical)) << std::endl; + return EXIT_SUCCESS; +} + +int +cmdFinishSignList(Args const& args, Context& ctx) +{ + auto const manifest = loadSigningManifest(ctx, "finish_sign_list"); + Output output(ctx.options.outFile, ctx.out); + + auto const list = loadUnsignedList(args[1]); + auto const sig = decodeSignature(args[0]); + if (!verify(*manifest.signingKey, makeSlice(list.canonical), makeSlice(sig))) + throw std::runtime_error("The signature does not verify under the manifest's signing key"); + + emitSignedList( + base64Encode(manifest.serialized), manifest.masterKey, list, strHex(sig), {}, ctx, output); + return EXIT_SUCCESS; +} + +int +cmdVerifyList(Args const& args, Context& ctx) +{ + std::optional roster; + if (ctx.options.validatorsFile) + roster = loadUnsignedList(*ctx.options.validatorsFile); + + auto const report = + verifyList(readJsonFile(args[0]), roster, ctx.options.expectedKey, netClockNow()); + ctx.out << report.toStyledString() << std::endl; + return report["ok"].asBool() ? EXIT_SUCCESS : EXIT_FAILURE; +} + +struct Command +{ + char const* name; + std::size_t minArgs; + std::size_t maxArgs; + int (*run)(Args const&, Context&); +}; + +constexpr std::array kCommands{{ + {"create_keys", 0, 0, cmdCreateKeys}, + {"create_external", 1, 1, cmdCreateExternal}, + {"create_token", 0, 0, cmdCreateToken}, + {"start_token", 0, 0, cmdStartToken}, + {"finish_token", 1, 2, cmdFinishToken}, + {"revoke_keys", 0, 0, cmdRevokeKeys}, + {"start_revoke_keys", 0, 0, cmdStartRevokeKeys}, + {"finish_revoke_keys", 1, 1, cmdFinishRevokeKeys}, + {"set_domain", 1, 1, cmdSetDomain}, + {"clear_domain", 0, 0, cmdClearDomain}, + {"attest_domain", 0, 0, cmdAttestDomain}, + {"sign", 1, 1, cmdSign}, + {"sign_hex", 1, 1, cmdSignHex}, + {"show_manifest", 1, 1, cmdShowManifest}, + {"sign_list", 1, 1, cmdSignList}, + {"start_sign_list", 1, 1, cmdStartSignList}, + {"finish_sign_list", 2, 2, cmdFinishSignList}, + {"verify_list", 1, 1, cmdVerifyList}, +}}; + +} // namespace + +std::string const& +getVersionString() +{ + static std::string const kValue = [] { + std::string const s = kVersionString; + beast::SemanticVersion v; + if (!v.parse(s) || v.print() != s) + throw std::logic_error(s + ": Bad version string"); // LCOV_EXCL_LINE + return s; + }(); + return kValue; +} + +int +runCommand( + std::string const& command, + std::vector const& args, + ToolOptions const& options, + std::ostream& out, + std::ostream& err) +{ + auto const it = std::find_if( + kCommands.begin(), kCommands.end(), [&](Command const& c) { return command == c.name; }); + if (it == kCommands.end()) + throw std::runtime_error("Unknown command: " + command); + if (args.size() < it->minArgs || args.size() > it->maxArgs) + throw std::runtime_error("Syntax error: Wrong number of arguments"); + + Context ctx{options, out, err}; + return it->run(args, ctx); +} + +} // namespace xrpl::tools diff --git a/src/tools/validator-keys/Commands.h b/src/tools/validator-keys/Commands.h new file mode 100644 index 0000000000..061398b3e6 --- /dev/null +++ b/src/tools/validator-keys/Commands.h @@ -0,0 +1,64 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include +#include + +namespace xrpl::tools { + +/** + * The command-line options every command may read. + */ +struct ToolOptions +{ + // The master key file. + std::filesystem::path keyFile; + // Key type of a token's signing key. xrpld loads only secp256k1 validator + // tokens; ed25519 is for a publisher's signing key. + KeyType tokenKeyType = 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; +}; + +/** + * The tool's version, checked to be a semantic version. + */ +std::string const& +getVersionString(); + +/** + * Runs one command. Results go to @p out, warnings and notes to @p err. + * + * @return The process exit code + * + * @throws std::runtime_error naming what went wrong; nothing has been written + * to a key file or an output file when it throws before that point + */ +int +runCommand( + std::string const& command, + std::vector const& args, + ToolOptions const& options, + std::ostream& out, + std::ostream& err); + +} // namespace xrpl::tools diff --git a/src/tools/validator-keys/ListSigning.cpp b/src/tools/validator-keys/ListSigning.cpp index 5a9e892689..854e97b668 100644 --- a/src/tools/validator-keys/ListSigning.cpp +++ b/src/tools/validator-keys/ListSigning.cpp @@ -1,5 +1,6 @@ #include +#include #include #include #include @@ -7,48 +8,144 @@ #include #include -#include -#include #include -#include #include namespace xrpl { namespace { -// A version 2 list document holds at most this many blobs. +// A version 2 document holds at most this many blobs. constexpr std::size_t kMaxBlobs = 5; +// The largest value a server reads for sequence, expiration and effective. +constexpr std::int64_t kMaxListInteger = 2147483647; +constexpr std::size_t kMaxListBytes = 4 * 1024 * 1024; +constexpr std::size_t kMaxKeyMaterialBytes = 64 * 1024; -[[nodiscard]] std::string -readFile(boost::filesystem::path const& file) +std::string +readFile(std::filesystem::path const& file, std::size_t maxSize) { - std::ifstream in(file.c_str(), std::ios::in | std::ios::binary); - if (!in) + std::error_code ec; + auto text = getFileContents(ec, file, maxSize); + if (ec) throw std::runtime_error("Failed to open file: " + file.string()); - return std::string(std::istreambuf_iterator(in), std::istreambuf_iterator()); + return text; } // The base64 lines of a config-style block, without its section header or // comment lines. -[[nodiscard]] std::vector +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::split(lines, text, boost::is_any_of("\n")); + for (auto& line : lines) boost::trim(line); - if (line.empty() || line.front() == '#' || line.front() == '[') - continue; - lines.push_back(line); - } + std::erase_if(lines, [](std::string const& line) { + return line.empty() || line.front() == '#' || line.front() == '['; + }); return lines; } -[[nodiscard]] std::optional +std::optional +parseManifest(std::string const& base64) +{ + auto m = deserializeManifest(base64Decode(base64)); + if (!m || !m->verify()) + return std::nullopt; + return m; +} + +// An integer field as a server reads it: type Int, so at most 2147483647. +std::optional +listInteger(json::Value const& obj, char const* name) +{ + if (!obj.isMember(name) || !obj[name].isInt() || obj[name].asInt() < 0) + return std::nullopt; + return obj[name].asUInt(); +} + +std::string +integerError(char const* name, std::int64_t least) +{ + return std::string("\"") + name + "\" must be an integer from " + std::to_string(least) + + " to " + std::to_string(kMaxListInteger); +} + +// Field checks over parsed text whose canonical form is already known. +UnsignedList +checkedList(std::string canonical, json::Value const& jv) +{ + UnsignedList list; + list.canonical = std::move(canonical); + + auto const sequence = listInteger(jv, jss::sequence); + if (!sequence || *sequence == 0) + throw std::runtime_error(integerError(jss::sequence, 1)); + list.sequence = *sequence; + + auto const expiration = listInteger(jv, jss::expiration); + if (!expiration) + throw std::runtime_error(integerError(jss::expiration, 0)); + list.expiration = *expiration; + + if (jv.isMember(jss::effective)) + { + auto const effective = listInteger(jv, jss::effective); + if (!effective) + throw std::runtime_error(integerError(jss::effective, 0)); + 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 keyText = entry[jss::validation_public_key].asString(); + auto const key = parseHexKey(keyText); + if (!key) + throw std::runtime_error( + "\"validation_public_key\" is not a hex public key: " + keyText); + + if (entry.isMember(jss::manifest)) + { + if (!entry[jss::manifest].isString()) + throw std::runtime_error("\"manifest\" must be a base64 string for " + keyText); + auto const m = parseManifest(entry[jss::manifest].asString()); + if (!m) + throw std::runtime_error("\"manifest\" does not verify for " + keyText); + if (m->masterKey != *key) + throw std::runtime_error("\"manifest\" belongs to another key than " + keyText); + } + + list.validators.push_back(*key); + } + + return list; +} + +json::Value +parseObject(std::string const& text) +{ + json::Reader reader; + json::Value jv; + if (!reader.parse(text, jv) || !jv.isObject()) + throw std::runtime_error("Not a JSON object"); + return jv; +} + +} // namespace + +std::optional parseHexKey(std::string const& hex) { auto const bytes = strUnHex(hex); @@ -60,42 +157,20 @@ parseHexKey(std::string const& hex) 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) +loadTokenFile(std::filesystem::path const& tokenFile) { - auto const token = loadValidatorToken(base64Lines(readFile(tokenFile))); + auto const token = loadValidatorToken(base64Lines(readFile(tokenFile, kMaxKeyMaterialBytes))); if (!token) throw std::runtime_error("Not a validator token: " + tokenFile.string()); return *token; } Manifest -loadManifestFile(boost::filesystem::path const& manifestFile) +loadManifestFile(std::filesystem::path const& manifestFile) { - auto const lines = base64Lines(readFile(manifestFile)); - std::string base64; - for (auto const& line : lines) - base64 += line; - auto m = parseManifest(base64); + auto const lines = base64Lines(readFile(manifestFile, kMaxKeyMaterialBytes)); + auto m = parseManifest(boost::join(lines, "")); if (!m) throw std::runtime_error("Not a valid manifest: " + manifestFile.string()); return std::move(*m); @@ -104,20 +179,13 @@ loadManifestFile(boost::filesystem::path const& manifestFile) 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) + for (std::size_t i = 0; i < text.size(); ++i) { + char const c = text[i]; if (inString) { out += c; @@ -129,6 +197,21 @@ canonicalJson(std::string const& text) inString = false; continue; } + if (c == '/' && i + 1 < text.size() && text[i + 1] == '/') + { + i = text.find('\n', i); + if (i == std::string::npos) + break; + continue; + } + if (c == '/' && i + 1 < text.size() && text[i + 1] == '*') + { + i = text.find("*/", i + 2); + if (i == std::string::npos) + break; + ++i; + continue; + } switch (c) { case ' ': @@ -150,82 +233,22 @@ canonicalJson(std::string const& text) out += c; } } + parseObject(out); 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; + auto canonical = canonicalJson(text); + auto const jv = parseObject(canonical); + return checkedList(std::move(canonical), jv); } UnsignedList -loadUnsignedList(boost::filesystem::path const& file) +loadUnsignedList(std::filesystem::path const& file) { - return parseUnsignedList(readFile(file)); + return parseUnsignedList(readFile(file, kMaxListBytes)); } std::string @@ -241,33 +264,22 @@ makeSignedList( UnsignedList const& list, std::string const& signatureHex, unsigned version, - std::optional const& append) + std::optional const& append, + Resigner const& resign) { - 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) + if (version != 1 && version != 2) throw std::runtime_error("Unsupported list version"); + if (version == 1 && append) + throw std::runtime_error("A version 1 list holds one blob; use version 2 to append"); 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()) + !existing[jss::version].isInt() || existing[jss::version].asInt() != 2 || + !existing.isMember(jss::blobs_v2) || !existing[jss::blobs_v2].isArray() || + !existing.isMember(jss::manifest) || !existing[jss::manifest].isString()) 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))) @@ -275,25 +287,39 @@ makeSignedList( 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; - // Blobs signed under an earlier manifest keep it, so they still verify - // once the top-level manifest names the current signing key. - auto const previous = existing.isMember(jss::manifest) && existing[jss::manifest].isString() - ? existing[jss::manifest].asString() - : std::string(); - if (!previous.empty() && previous != manifestBase64) + + jv[jss::blobs_v2] = existing[jss::blobs_v2]; + if (existing[jss::manifest].asString() != manifestBase64) + { + if (!resign) + throw std::runtime_error( + "The list to append to was signed under another manifest and its blobs " + "need signing again"); for (auto& entry : jv[jss::blobs_v2]) - if (!entry.isMember(jss::manifest)) - entry[jss::manifest] = previous; + { + if (!entry.isObject() || !entry.isMember(jss::blob) || !entry[jss::blob].isString()) + throw std::runtime_error("The list to append to holds an invalid blob"); + entry[jss::signature] = resign(base64Decode(entry[jss::blob].asString())); + entry.removeMember(jss::manifest); + } + } } - else + else if (version == 2) { jv[jss::blobs_v2] = json::Value(json::ValueType::Array); } jv[jss::manifest] = manifestBase64; jv[jss::public_key] = strHex(masterKey); - jv[jss::version] = 2; + jv[jss::version] = static_cast(version); + + auto const blob = base64Encode(list.canonical); + if (version == 1) + { + jv[jss::blob] = blob; + jv[jss::signature] = signatureHex; + return jv; + } json::Value entry(json::ValueType::Object); entry[jss::blob] = blob; @@ -303,81 +329,80 @@ makeSignedList( } std::uint32_t -rippleEpochNow() +netClockNow() { using namespace std::chrono; auto const since1970 = duration_cast(system_clock::now().time_since_epoch()); return static_cast((since1970 - kEpochOffset).count()); } -ListVerification +json::Value 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); + json::Value report(json::ValueType::Object); + report["ok"] = true; + report["errors"] = json::Value(json::ValueType::Array); + auto fail = [&report](std::string const& error) { + report["ok"] = false; + report["errors"].append(error); }; if (!list.isObject()) { fail("the list is not a JSON object"); - return result; + return report; } - auto const version = uintField(list, jss::version); + auto const version = listInteger(list, jss::version); if (!version || (*version != 1 && *version != 2)) { fail("\"version\" must be 1 or 2"); - return result; + return report; } - result.report[jss::version] = *version; + 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; + return report; } - auto const manifest = parseManifest(list[jss::manifest].asString()); + auto manifest = parseManifest(list[jss::manifest].asString()); if (!manifest) { fail("\"manifest\" does not deserialize and verify"); - return result; + return report; } - result.report[jss::public_key] = strHex(manifest->masterKey); - result.report["manifest_sequence"] = manifest->sequence; + report[jss::public_key] = strHex(manifest->masterKey); + report["manifest_sequence"] = manifest->sequence; if (manifest->revoked() || !manifest->signingKey) { fail("the publisher's master key is revoked"); - return result; + return report; } - result.report["signing_key"] = strHex(*manifest->signingKey); + 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, each with the signing key it was signed under. - struct BlobEntry + struct Entry { std::string blob; std::string signature; - PublicKey signingKey; + std::optional manifest; }; - std::vector blobs; + std::vector entries; if (*version == 1) { if (!list.isMember(jss::blob) || !list[jss::blob].isString() || @@ -385,10 +410,9 @@ verifyList( list.isMember(jss::blobs_v2)) { fail("a version 1 list needs \"blob\" and \"signature\" and no \"blobs_v2\""); - return result; + return report; } - blobs.push_back( - {list[jss::blob].asString(), list[jss::signature].asString(), *manifest->signingKey}); + entries.push_back({list[jss::blob].asString(), list[jss::signature].asString(), {}}); } else { @@ -399,45 +423,54 @@ verifyList( fail( "a version 2 list needs 1 to " + std::to_string(kMaxBlobs) + " \"blobs_v2\" entries and no top-level \"blob\""); - return result; + return report; } 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()) + !entry.isMember(jss::signature) || !entry[jss::signature].isString() || + (entry.isMember(jss::manifest) && !entry[jss::manifest].isString())) { - fail("every \"blobs_v2\" entry needs \"blob\" and \"signature\""); - return result; + fail( + "every \"blobs_v2\" entry needs \"blob\" and \"signature\" strings and " + "an optional \"manifest\" string"); + return report; } - auto signingKey = *manifest->signingKey; + std::optional entryManifest; 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 || !m->signingKey) - fail("a \"blobs_v2\" entry's \"manifest\" is not this publisher's"); - else - signingKey = *m->signingKey; - } - blobs.push_back( - {entry[jss::blob].asString(), entry[jss::signature].asString(), signingKey}); + entryManifest = entry[jss::manifest].asString(); + entries.push_back( + {entry[jss::blob].asString(), entry[jss::signature].asString(), entryManifest}); } } - result.report["blobs"] = json::Value(json::ValueType::Array); + std::optional> want; + if (expectedRoster) + want.emplace(expectedRoster->validators.begin(), expectedRoster->validators.end()); + + report["blobs"] = json::Value(json::ValueType::Array); std::size_t index = 0; - for (auto const& [blob, signature, signingKey] : blobs) + for (auto const& entry : entries) { auto const where = "blob " + std::to_string(index++); - json::Value entry(json::ValueType::Object); + json::Value found(json::ValueType::Object); - auto const sig = strUnHex(signature); - auto const data = base64Decode(blob); - if (!sig || !verify(signingKey, makeSlice(data), makeSlice(*sig))) + // A server applies an entry's manifest before checking the blob and + // keeps the newest manifest it has seen for the publisher. + if (entry.manifest) + { + auto m = parseManifest(*entry.manifest); + if (!m || m->masterKey != manifest->masterKey) + fail(where + ": its \"manifest\" is not this publisher's"); + else if (m->revoked() || !m->signingKey) + fail(where + ": its \"manifest\" revokes the publisher's master key"); + else if (m->sequence > manifest->sequence) + manifest = std::move(m); + } + + auto const sig = strUnHex(entry.signature); + auto const data = base64Decode(entry.blob); + if (!sig || !verify(*manifest->signingKey, makeSlice(data), makeSlice(*sig))) fail(where + ": the signature does not verify under the signing key"); std::optional parsed; @@ -452,35 +485,31 @@ verifyList( if (parsed) { - entry[jss::sequence] = parsed->sequence; + found[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; + found[jss::effective] = *parsed->effective; + found[jss::expiration] = parsed->expiration; + found[jss::validators] = json::UInt(parsed->validators.size()); + found["expired"] = parsed->expiration <= now; if (parsed->expiration <= now) fail(where + ": expired"); - if (expectedRoster) + if (want) { std::set const have( parsed->validators.begin(), parsed->validators.end()); - std::set const want( - expectedRoster->validators.begin(), expectedRoster->validators.end()); - if (have != want) + if (have != *want) fail(where + ": the validators differ from the expected list"); } } - result.report["blobs"].append(entry); + report["blobs"].append(found); } - 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; + report["signing_key"] = strHex(*manifest->signingKey); + report["manifest_sequence"] = manifest->sequence; + return report; } } // namespace xrpl diff --git a/src/tools/validator-keys/ListSigning.h b/src/tools/validator-keys/ListSigning.h index 28dd86249d..2d0b07636c 100644 --- a/src/tools/validator-keys/ListSigning.h +++ b/src/tools/validator-keys/ListSigning.h @@ -1,24 +1,26 @@ #pragma once #include -#include #include #include #include #include +#include +#include #include #include #include -namespace boost { -namespace filesystem { -class path; -} -} // namespace boost - namespace xrpl { +/** + * Parses a public key given as hex: 33 bytes with a type byte a server + * accepts. + */ +std::optional +parseHexKey(std::string const& hex); + /** * Reads a token from a file holding the [validator_token] block as * `create_token` prints it. The section header and `#` comment lines are @@ -27,7 +29,7 @@ namespace xrpl { * @throws std::runtime_error if the file cannot be read or is not a token */ ValidatorToken -loadTokenFile(boost::filesystem::path const& tokenFile); +loadTokenFile(std::filesystem::path const& tokenFile); /** * Reads a base64 manifest from a file. Comment lines and line breaks are @@ -37,7 +39,7 @@ loadTokenFile(boost::filesystem::path const& tokenFile); * not deserialize and verify */ Manifest -loadManifestFile(boost::filesystem::path const& manifestFile); +loadManifestFile(std::filesystem::path const& manifestFile); /** * A validator list before it is signed: the canonical bytes the signing key @@ -56,18 +58,20 @@ struct UnsignedList }; /** - * Returns the canonical form of a JSON document: whitespace outside strings - * removed, one space after each `,` and `:`, key order preserved. + * Returns the canonical form of a JSON document: comments and whitespace + * outside strings removed, one space after each `,` and `:`, key order + * preserved. * - * @throws std::runtime_error if the text is not a JSON document + * @throws std::runtime_error if the text is not a JSON object */ 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 + * Parses an unsigned list and checks its fields the way a server does: + * `sequence`, `expiration` and an optional `effective` are integers no + * greater than 2147483647, `effective` 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 @@ -81,7 +85,7 @@ parseUnsignedList(std::string const& text); * @throws std::runtime_error if the file cannot be read or fails a check */ UnsignedList -loadUnsignedList(boost::filesystem::path const& file); +loadUnsignedList(std::filesystem::path const& file); /** * Returns the hex signature of the list's canonical bytes. @@ -89,17 +93,24 @@ loadUnsignedList(boost::filesystem::path const& file); std::string signList(UnsignedList const& list, PublicKey const& signingKey, SecretKey const& signingSecret); +/** + * Signs the bytes of an already published blob; returns the hex signature. + */ +using Resigner = std::function; + /** * 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. Blobs signed under an earlier manifest keep that manifest in - * their entry. + * added to it. A server verifies every blob under the newest manifest it has + * seen for the publisher, so when @p append was signed under another manifest + * its blobs are signed again with @p resign. * * @throws std::runtime_error if @p append is not a version 2 document for - * @p masterKey or already holds the maximum number of blobs + * @p masterKey, already holds the maximum number of blobs, or needs + * re-signing and @p resign is empty */ json::Value makeSignedList( @@ -108,39 +119,31 @@ makeSignedList( UnsignedList const& list, std::string const& signatureHex, unsigned version, - std::optional const& append); + std::optional const& append, + Resigner const& resign); /** * 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; -}; +netClockNow(); /** * 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. + * verifies under the newest signing key seen for the publisher, every blob + * parses, and none has expired at @p now. * * @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 + * + * @return A report with `ok`, `errors`, the keys and sequences found, and one + * entry per blob */ -ListVerification +json::Value verifyList( json::Value const& list, std::optional const& expectedRoster, diff --git a/src/tools/validator-keys/Main.cpp b/src/tools/validator-keys/Main.cpp new file mode 100644 index 0000000000..fc12a27e38 --- /dev/null +++ b/src/tools/validator-keys/Main.cpp @@ -0,0 +1,193 @@ +#include + +#include + +#include + +#include +#include +#include + +// LCOV_EXCL_START +namespace { + +std::string +getEnvVar(char const* name) +{ + auto const v = std::getenv(name); + return v == nullptr ? std::string() : std::string(v); +} + +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 Print the bytes a token's " + "signatures cover; --signing-key delegates to an external key.\n" + " finish_token []\n" + " Finish the token with the " + "external signature(s).\n" + " start_revoke_keys Print the bytes a revocation's " + "signature covers.\n" + " finish_revoke_keys Finish the revocation with the " + "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"; +} + +xrpl::PublicKey +publicKeyOption(std::string const& value) +{ + if (auto const key = xrpl::parseBase58(xrpl::TokenType::NodePublic, value)) + return *key; + if (auto const bytes = xrpl::strUnHex(value); + bytes && xrpl::publicKeyType(xrpl::makeSlice(*bytes))) + return xrpl::PublicKey(xrpl::makeSlice(*bytes)); + throw std::runtime_error("Unable to parse public key: " + value); +} + +} // namespace + +int +main(int argc, char** argv) +{ + namespace po = boost::program_options; + using namespace xrpl::tools; + + 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; the only type xrpld loads " + "from [validator_token]) or ed25519 (for a publisher's signing key).")( + "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).")( + "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 positional; + positional.add("command", 1).add("arguments", -1); + + po::options_description all; + all.add(general).add(hidden); + + po::variables_map vm; + try + { + po::store( + po::command_line_parser(argc, argv).options(all).positional(positional).run(), vm); + po::notify(vm); + } + 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; + } + + if (vm.count("version")) + { + std::cout << "validator-keys version " << getVersionString() << std::endl; + return EXIT_SUCCESS; + } + + if (vm.count("help") || !vm.count("command")) + { + printHelp(general); + return EXIT_SUCCESS; + } + + std::string const homeDir = getEnvVar("HOME"); + std::string const defaultKeyFile = + (homeDir.empty() ? std::filesystem::current_path().string() : homeDir) + + "/.ripple/validator-keys.json"; + + try + { + 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 = publicKeyOption(vm["signing-key"].as()); + if (vm.count("token-file")) + options.tokenFile = vm["token-file"].as(); + if (vm.count("manifest-file")) + options.manifestFile = vm["manifest-file"].as(); + if (vm.count("out")) + options.outFile = vm["out"].as(); + if (vm.count("list-version")) + options.listVersion = vm["list-version"].as(); + if (vm.count("append")) + options.appendFile = vm["append"].as(); + if (vm.count("validators")) + options.validatorsFile = vm["validators"].as(); + if (vm.count("expected-key")) + options.expectedKey = publicKeyOption(vm["expected-key"].as()); + + return runCommand( + vm["command"].as(), + vm["arguments"].as>(), + options, + std::cout, + std::cerr); + } + catch (std::exception const& e) + { + std::cerr << e.what() << "\n"; + return EXIT_FAILURE; + } +} +// LCOV_EXCL_STOP diff --git a/src/tools/validator-keys/README.md b/src/tools/validator-keys/README.md index c720ced810..46951ad699 100644 --- a/src/tools/validator-keys/README.md +++ b/src/tools/validator-keys/README.md @@ -10,11 +10,13 @@ packages as `/usr/bin/validator-keys`. ## Build -Configure with `-Dvalidator_keys=ON` and build the `validator-keys` target: +Configure with `-Dvalidator_keys=ON` and build the `validator-keys` target. With +`-Dtests=ON` the gtest suites under `src/tests/tools/validator-keys` build as +`validator_keys_tests`: ``` -cmake --build . --target validator-keys -./validator-keys --unittest +cmake --build . --target validator-keys validator_keys_tests +./validator_keys_tests ``` ## Guide diff --git a/src/tools/validator-keys/SigningKeys.cpp b/src/tools/validator-keys/SigningKeys.cpp index 1ad90503a0..a765c1880d 100644 --- a/src/tools/validator-keys/SigningKeys.cpp +++ b/src/tools/validator-keys/SigningKeys.cpp @@ -1,5 +1,6 @@ #include +#include #include #include #include @@ -9,13 +10,43 @@ #include #include -#include -#include +#include #include +#include namespace xrpl { +namespace { + +// Key files are small; anything larger is not one. +constexpr std::size_t kMaxKeyFileBytes = 64 * 1024; + +char const* const kRevokedError = "Validator keys have been revoked."; +char const* const kExhaustedError = + "Maximum number of tokens have already been generated.\n" + "Revoke validator keys if previous token has been compromised."; +char const* const kNoSecretError = "This key file cannot be used to sign."; +char const* const kBadManifestError = "Manifest is not properly signed"; + +bool +sameSecret(SecretKey const& a, SecretKey const& b) +{ + return std::equal(a.begin(), a.end(), b.begin()); +} + +// The bytes both the signing key and the master key sign. +std::string +signingData(STObject const& st) +{ + Serializer s; + s.add32(HashPrefix::Manifest); + st.addWithoutSigningFields(s); + return strHex(s.peekData()); +} + +} // namespace + std::string tokenToBase64(ValidatorToken const& token) { @@ -23,7 +54,7 @@ tokenToBase64(ValidatorToken const& token) jv["validation_secret_key"] = strHex(token.validationSecret); jv["manifest"] = token.manifest; - return xrpl::base64Encode(to_string(jv)); + return base64Encode(to_string(jv)); } SigningKeys::SigningKeys(KeyType const& keyType) @@ -55,31 +86,48 @@ SigningKeys::SigningKeys( { } -SigningKeys -SigningKeys::make_SigningKeys(boost::filesystem::path const& keyFile) +bool +SigningKeys::operator==(SigningKeys const& rhs) const { - std::ifstream ifsKeys(keyFile.c_str(), std::ios::in); + if (keyType_ != rhs.keyType_ || keys_.publicKey != rhs.keys_.publicKey || + keys_.secretKey.has_value() != rhs.keys_.secretKey.has_value() || + (keys_.secretKey && !sameSecret(*keys_.secretKey, *rhs.keys_.secretKey))) + return false; + if (tokenSequence_ != rhs.tokenSequence_ || revoked_ != rhs.revoked_ || + domain_ != rhs.domain_ || manifest_ != rhs.manifest_ || + pending_.has_value() != rhs.pending_.has_value()) + return false; + if (!pending_) + return true; + if (pending_->keyType != rhs.pending_->keyType || + pending_->signer.index() != rhs.pending_->signer.index()) + return false; + if (auto const* secret = std::get_if(&pending_->signer)) + return sameSecret(*secret, std::get(rhs.pending_->signer)); + return std::get(pending_->signer) == std::get(rhs.pending_->signer); +} - if (!ifsKeys) +SigningKeys +SigningKeys::make_SigningKeys(std::filesystem::path const& keyFile) +{ + std::error_code ec; + auto const text = getFileContents(ec, keyFile, kMaxKeyFileBytes); + if (ec) throw std::runtime_error("Failed to open key file: " + keyFile.string()); json::Reader reader; json::Value jKeys; - if (!reader.parse(ifsKeys, jKeys)) - { + if (!reader.parse(text, jKeys) || !jKeys.isObject()) throw std::runtime_error("Unable to parse json key file: " + keyFile.string()); - } - static std::array const requiredFields{ + static constexpr std::array kRequiredFields{ {"key_type", "secret_key", "token_sequence", "revoked"}}; - for (auto field : requiredFields) + for (auto const* field : kRequiredFields) { 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) { @@ -92,117 +140,100 @@ SigningKeys::make_SigningKeys(boost::filesystem::path const& keyFile) if (!keyType) throw invalidField("key_type"); - auto const secret = - parseBase58(TokenType::NodePrivate, jKeys["secret_key"].asString()); + if (!jKeys["token_sequence"].isIntegral() || jKeys["token_sequence"].isBool() || + (jKeys["token_sequence"].isInt() && jKeys["token_sequence"].asInt() < 0)) + throw invalidField("token_sequence"); + auto const tokenSequence = jKeys["token_sequence"].asUInt(); - auto const pubKey = [&]() -> std::optional { + if (!jKeys["revoked"].isBool()) + throw invalidField("revoked"); + auto const revoked = jKeys["revoked"].asBool(); + + auto keys = [&]() { 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 (*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, revoked); } + auto const secret = + parseBase58(TokenType::NodePrivate, jKeys["secret_key"].asString()); 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()); + return SigningKeys(*keyType, *secret, tokenSequence, revoked); }(); if (jKeys.isMember("domain")) { if (!jKeys["domain"].isString()) throw invalidField("domain"); - - vk.domain(jKeys["domain"].asString()); + keys.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) + auto bytes = strUnHex(jKeys["manifest"].asString()); + if (!bytes || bytes->empty()) throw invalidField("manifest"); - - vk.manifest_.clear(); - vk.manifest_.reserve(ret->size()); - std::copy(ret->begin(), ret->end(), std::back_inserter(vk.manifest_)); + keys.manifest_ = std::move(*bytes); + keys.checkManifest(); } - 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")) + bool const hasSecret = jKeys.isMember("pending_token_secret"); + bool const hasSigningKey = jKeys.isMember("pending_signing_key"); + if (hasSecret || hasSigningKey) { + if (hasSecret && hasSigningKey) + throw std::runtime_error( + "Key file '" + keyFile.string() + + "' has both \"pending_token_secret\" and \"pending_signing_key\""); + if (!jKeys.isMember("pending_key_type")) + throw std::runtime_error( + "Key file '" + keyFile.string() + "' is missing \"pending_key_type\" field"); auto const pendingKeyType = keyTypeFromString(jKeys["pending_key_type"].asString()); if (!pendingKeyType) throw invalidField("pending_key_type"); - vk.pendingKeyType_ = pendingKeyType; + + if (hasSecret) + { + if (!jKeys["pending_token_secret"].isString()) + throw invalidField("pending_token_secret"); + auto const secret = parseBase58( + TokenType::NodePrivate, jKeys["pending_token_secret"].asString()); + if (!secret) + throw invalidField("pending_token_secret"); + keys.pending_ = Pending{*pendingKeyType, *secret}; + } + else + { + if (!jKeys["pending_signing_key"].isString()) + throw invalidField("pending_signing_key"); + auto const signingKey = parseBase58( + TokenType::NodePublic, jKeys["pending_signing_key"].asString()); + if (!signingKey) + throw invalidField("pending_signing_key"); + keys.pending_ = Pending{*pendingKeyType, *signingKey}; + } } - return vk; + return keys; } void -SigningKeys::writeToFile(boost::filesystem::path const& keyFile) const +SigningKeys::writeToFile(std::filesystem::path const& keyFile) const { - using namespace boost::filesystem; + namespace fs = std::filesystem; json::Value jv; jv["key_type"] = to_string(keyType_); @@ -215,190 +246,193 @@ SigningKeys::writeToFile(boost::filesystem::path const& keyFile) const 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()) + if (pending_) { - 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()); + jv["pending_key_type"] = to_string(pending_->keyType); + if (auto const* secret = std::get_if(&pending_->signer)) + jv["pending_token_secret"] = toBase58(TokenType::NodePrivate, *secret); + else + jv["pending_signing_key"] = + toBase58(TokenType::NodePublic, std::get(pending_->signer)); } - std::ofstream o(keyFile.string(), std::ios_base::trunc); - if (o.fail()) - throw std::runtime_error("Cannot open key file: " + keyFile.string()); + std::error_code ec; + if (auto const parent = keyFile.parent_path(); !parent.empty()) + { + fs::create_directories(parent, ec); + if (ec || !fs::is_directory(parent)) + throw std::runtime_error("Cannot create directory: " + parent.string()); + } - o << jv.toStyledString(); + // Write beside the key file, restrict it to the owner, then replace the + // key file in one step. + auto const temp = fs::path(keyFile.string() + ".tmp"); + { + std::ofstream o(temp, std::ios_base::trunc); + o << jv.toStyledString(); + o.close(); + if (o.fail()) + { + fs::remove(temp, ec); + throw std::runtime_error("Cannot write key file: " + keyFile.string()); + } + } + fs::permissions(temp, fs::perms::owner_read | fs::perms::owner_write, ec); + if (!ec) + fs::rename(temp, keyFile, ec); + if (ec) + { + fs::remove(temp, ec); + throw std::runtime_error("Cannot write key file: " + keyFile.string()); + } } -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 || *pk != keys_.publicKey || !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 +SigningKeys::partialManifest(std::uint32_t sequence, PublicKey const& signingKey) const { STObject st(sfGeneric); st[sfSequence] = sequence; - st[sfPublicKey] = masterPubKey; - st[sfSigningPubKey] = signingPubKey; - - if (!domain.empty()) - st[sfDomain] = makeSlice(domain); - + st[sfPublicKey] = keys_.publicKey; + st[sfSigningPubKey] = signingKey; + if (!domain_.empty()) + st[sfDomain] = makeSlice(domain_); return st; } -[[nodiscard]] STObject -generatePartialRevocation(PublicKey const& masterPubKey) +STObject +SigningKeys::partialRevocation() const { STObject st(sfGeneric); st[sfSequence] = std::numeric_limits::max(); - st[sfPublicKey] = masterPubKey; - + st[sfPublicKey] = keys_.publicKey; return st; } -// The bytes both the signing key and the master key sign. -[[nodiscard]] std::string -signingData(STObject const& st) +void +SigningKeys::checkManifest() const +{ + auto const m = deserializeManifest(manifest_); + if (!m || !m->verify() || m->masterKey != keys_.publicKey || m->revoked() != revoked_) + throw std::runtime_error(kBadManifestError); +} + +void +SigningKeys::storeManifest(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) + st.add(s); + auto const previous = std::exchange(manifest_, std::vector(s.begin(), s.end())); + try { - pendingSigningKey_ = externalSigningKey; - pendingKeyType_ = publicKeyType(*externalSigningKey); - return signingData(generatePartialManifest( - tokenSequence_ + 1, keys_.publicKey, *externalSigningKey, domain_)); + checkManifest(); + } + catch (std::runtime_error const&) + { + manifest_ = previous; + throw; } - - 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_)); -} - -ValidatorToken -SigningKeys::finishToken(Blob const& masterSig) -{ - if (revoked()) - throw std::runtime_error("Validator keys have been revoked."); - - 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::string -SigningKeys::finishExternalToken(Blob const& masterSig, Blob const& signingSig) +SigningKeys::startToken(KeyType const& keyType, std::optional const& externalSigningKey) { - if (revoked()) - throw std::runtime_error("Validator keys have been revoked."); + if (revoked_) + throw std::runtime_error(kRevokedError); + if (tokenSequence_ >= std::numeric_limits::max() - 1) + throw std::runtime_error(kExhaustedError); - if (!pendingSigningKey_) - throw std::runtime_error("No pending token with an external signing key to finish"); + if (externalSigningKey) + { + if (*externalSigningKey == keys_.publicKey) + throw std::runtime_error("The signing key must differ from the master key"); + pending_ = Pending{*publicKeyType(*externalSigningKey), *externalSigningKey}; + return signingData(partialManifest(tokenSequence_ + 1, *externalSigningKey)); + } - ++tokenSequence_; + auto const secret = generateSecretKey(keyType, randomSeed()); + pending_ = Pending{keyType, secret}; + return signingData(partialManifest(tokenSequence_ + 1, derivePublicKey(keyType, secret))); +} - STObject st = - generatePartialManifest(tokenSequence_, keys_.publicKey, *pendingSigningKey_, domain_); +SigningKeys::Finished +SigningKeys::finishToken(Blob const& masterSig, std::optional const& signingSig) +{ + if (revoked_) + throw std::runtime_error(kRevokedError); + if (!pending_) + throw std::runtime_error("No pending token to finish"); - st[sfSignature] = makeSlice(signingSig); + auto const pending = *pending_; + std::optional secret; + PublicKey signingKey = [&] { + if (auto const* s = std::get_if(&pending.signer)) + { + secret = *s; + return derivePublicKey(pending.keyType, *s); + } + return std::get(pending.signer); + }(); + + STObject st = partialManifest(tokenSequence_ + 1, signingKey); + if (secret) + { + if (signingSig) + throw std::runtime_error( + "The pending token's signing key is in this key file; pass one signature"); + xrpl::sign(st, HashPrefix::Manifest, pending.keyType, *secret); + } + else + { + if (!signingSig) + throw std::runtime_error( + "The pending token's signing key is external; pass its signature too"); + st[sfSignature] = makeSlice(*signingSig); + } st[sfMasterSignature] = makeSlice(masterSig); - setManifest(st); + storeManifest(st); + ++tokenSequence_; + pending_.reset(); - return xrpl::base64Encode(manifest_.data(), manifest_.size()); + return Finished{base64Encode(manifest_.data(), manifest_.size()), secret}; +} + +ValidatorToken +SigningKeys::createToken(KeyType const& keyType) +{ + if (!keys_.secretKey) + throw std::runtime_error("This key file cannot be used to sign tokens."); + + auto const data = startToken(keyType); + auto const finished = finishToken(*strUnHex(signHex(data))); + return ValidatorToken{finished.manifest, *finished.secret}; +} + +std::string +SigningKeys::startRevoke() const +{ + return signingData(partialRevocation()); +} + +std::string +SigningKeys::finishRevoke(Blob const& masterSig) +{ + STObject st = partialRevocation(); + st[sfMasterSignature] = makeSlice(masterSig); + + auto const wasRevoked = std::exchange(revoked_, true); + try + { + storeManifest(st); + } + catch (std::runtime_error const&) + { + revoked_ = wasRevoked; + throw; + } + pending_.reset(); + + return base64Encode(manifest_.data(), manifest_.size()); } std::string @@ -407,66 +441,14 @@ 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(); + return finishRevoke(*strUnHex(signHex(startRevoke()))); } std::string SigningKeys::sign(std::string const& data) const { if (!keys_.secretKey) - throw std::runtime_error("This key file cannot be used to sign."); + throw std::runtime_error(kNoSecretError); return strHex(xrpl::sign(keys_.publicKey, *keys_.secretKey, makeSlice(data))); } @@ -475,7 +457,7 @@ std::string SigningKeys::signHex(std::string data) const { if (!keys_.secretKey) - throw std::runtime_error("This key file cannot be used to sign."); + throw std::runtime_error(kNoSecretError); boost::algorithm::trim(data); auto const blob = strUnHex(data); @@ -484,39 +466,19 @@ SigningKeys::signHex(std::string data) const return strHex(xrpl::sign(keys_.publicKey, *keys_.secretKey, makeSlice(*blob))); } +std::string +SigningKeys::attestationData() const +{ + return "[domain-attestation-blob:" + domain_ + ":" + + toBase58(TokenType::NodePublic, keys_.publicKey) + "]"; +} + 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 -namespace boost { -namespace filesystem { -class path; -} -} // namespace boost - namespace xrpl { /** @@ -29,13 +24,17 @@ 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 key file, with the manifests, tokens and revocations the master key + * signs. + * + * A manifest is made in two steps so the master signature can come from a + * signer outside this process: `startToken` fixes the manifest's contents and + * returns the bytes to sign, `finishToken` takes the signature back. When the + * master secret is in the key file, `createToken` does both. A revocation + * follows the same two steps. * * 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. + * `create_external` and the master key never signs inside this process. */ class SigningKeys { @@ -55,20 +54,34 @@ private: } }; + // A token started and not yet finished: the key type of its signing key + // and either that key's secret, generated here, or its public key when an + // external signer holds it. + struct Pending + { + KeyType keyType; + std::variant signer; + }; + 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_; + std::optional pending_; public: + /** + * The result of finishing a token: the manifest and, when the signing key + * was generated here, its secret. + */ + struct Finished + { + std::string manifest; + std::optional secret; + }; + explicit SigningKeys(KeyType const& keyType); SigningKeys( @@ -93,127 +106,77 @@ public: * * @param keyFile Path to JSON key file * - * @throws std::runtime_error if file content is invalid + * @throws std::runtime_error if file content is invalid or the stored + * manifest is not a valid manifest for this key */ static SigningKeys - make_SigningKeys(boost::filesystem::path const& keyFile); + make_SigningKeys(std::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())); - } + bool + operator==(SigningKeys const& rhs) const; /** - * Writes the keys to a JSON key file. + * Writes the keys to a JSON key file readable by its owner only. The file + * is replaced whole, so a failed write leaves the previous content. * * @param keyFile Path to file to write * - * @note Overwrites an existing key file - * - * @throws std::runtime_error if unable to create the parent directory + * @throws std::runtime_error if the file cannot be written */ void - writeToFile(boost::filesystem::path const& keyFile) const; + writeToFile(std::filesystem::path const& keyFile) const; /** - * Returns a validator token for the next sequence. + * Starts a token: fixes the next manifest's contents and returns the + * bytes both its signatures cover, as hex. + * + * With @p externalSigningKey the token delegates to that key and its + * signature must come from outside too; otherwise a signing key of + * @p keyType is generated and kept pending until `finishToken`. + * + * @throws std::runtime_error if the keys are revoked, the sequence is + * exhausted, or the external signing key is the master key + */ + std::string + startToken( + KeyType const& keyType = KeyType::Secp256k1, + std::optional const& externalSigningKey = std::nullopt); + + /** + * Finishes the pending token with the master signature and, when the + * signing key is external, the signing key's signature over the same + * bytes. + * + * @throws std::runtime_error if no token is pending, a needed signature is + * missing, or the manifest does not verify + */ + Finished + finishToken(Blob const& masterSig, std::optional const& signingSig = std::nullopt); + + /** + * Makes a token signed with the master secret in this key file. * * @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 - * - * @throws std::runtime_error if the keys are revoked, no such token is - * pending, or the signature does not verify + * @throws std::runtime_error if the master key is external, the keys are + * revoked, or the sequence is exhausted */ ValidatorToken - finishToken(Blob const& masterSig); + createToken(KeyType const& keyType = KeyType::Secp256k1); /** - * 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 - * - * @throws std::runtime_error if the keys are revoked, no such token is - * pending, or a signature does not verify - */ - std::string - 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 + * Returns the bytes a master signature over a revocation covers, as hex. */ std::string startRevoke() const; /** - * Finishes a revocation. - * - * @param masterSig Master signature over the bytes `startRevoke` returned - * - * @return The base64 revocation manifest + * Records the revocation and returns the base64 revocation manifest. * * @throws std::runtime_error if the signature does not verify */ @@ -221,9 +184,15 @@ public: finishRevoke(Blob const& masterSig); /** - * Signs a string with the master key. + * Revokes the keys with the master secret in this key file. * - * @param data String to sign + * @throws std::runtime_error if the master key is external + */ + std::string + revoke(); + + /** + * Signs a string with the master key. * * @return The hex signature * @@ -235,18 +204,20 @@ public: /** * 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 + * @throws std::runtime_error if the data is not hex or the master key is + * external */ std::string signHex(std::string data) const; /** - * Returns the public key. + * The string a domain attestation signs, for the domain of this key. */ + std::string + attestationData() const; + PublicKey const& publicKey() const { @@ -254,17 +225,20 @@ public: } /** - * Returns true if the keys are revoked. + * True when the master secret is in the key file. */ + bool + hasSecret() const + { + return keys_.secretKey.has_value(); + } + bool revoked() const { return revoked_; } - /** - * Returns the domain associated with this key, if any. - */ std::string const& domain() const { @@ -272,35 +246,22 @@ public: } /** - * Sets the domain associated with this key. + * Sets the domain the next manifest carries. + * + * @throws std::runtime_error if the domain is not well formed */ void domain(std::string d); /** - * Checks the stored manifest. - * - * @throws std::runtime_error if the manifest is malformed or not signed - * correctly + * The last manifest generated, serialized; empty if none. */ - void - verifyManifest() const; - - /** - * Returns the last manifest generated, if available. - */ - std::vector + std::vector const& manifest() const { - if (!manifest_.empty()) - verifyManifest(); - return manifest_; } - /** - * Returns the sequence number of the last manifest generated. - */ std::uint32_t sequence() const { @@ -308,11 +269,17 @@ public: } private: - void - setManifest(STObject const& st); + STObject + partialManifest(std::uint32_t sequence, PublicKey const& signingKey) const; + + STObject + partialRevocation() const; void - clearPending() const; + storeManifest(STObject const& st); + + void + checkManifest() const; }; } // namespace xrpl diff --git a/src/tools/validator-keys/ValidatorKeysTool.cpp b/src/tools/validator-keys/ValidatorKeysTool.cpp deleted file mode 100644 index 8d3c2fb94b..0000000000 --- a/src/tools/validator-keys/ValidatorKeysTool.cpp +++ /dev/null @@ -1,897 +0,0 @@ -#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); - - auto const masterSig = decodeSignature(signatures.at(0)); - - if (signatures.size() == 2) - { - auto const manifest = - keys.finishExternalToken(masterSig, decodeSignature(signatures.at(1))); - keys.writeToFile(options.keyFile); - emitBlock( - "validator_manifest", - toBase58(TokenType::NodePublic, keys.publicKey()), - manifest, - options.outFile); - return; - } - - auto const token = keys.finishToken(masterSig); - 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 deleted file mode 100644 index cb8bd81260..0000000000 --- a/src/tools/validator-keys/ValidatorKeysTool.h +++ /dev/null @@ -1,97 +0,0 @@ -#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 index 1e133a2bbd..506f6e2aaa 100644 --- a/src/tools/validator-keys/doc/validator-keys-tool-guide.md +++ b/src/tools/validator-keys/doc/validator-keys-tool-guide.md @@ -1,18 +1,18 @@ # 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. +change if the xrpld 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 +- sign tokens authorizing a xrpld 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. +The current token needs to be present in the xrpld config file. Servers that trust the validator will adapt automatically when the token changes. @@ -63,12 +63,12 @@ Sample output: VUSmEydzBpMjFlcTNNWXl3TFZKWm5GT3I3QzBrdzJBaVR6U0NqSXpkaXRROD0ifQ== ``` -For a new validator, add the [validator_token] value to the rippled config file. +For a new validator, add the [validator_token] value to the xrpld 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 +There is a hard limit of 4,294,967,294 tokens that can be generated for a given validator key pair. ## Key Revocation @@ -150,6 +150,15 @@ manifest without a secret: $ validator-keys finish_token ``` +A secp256k1 signature from an external signer must be in the fully canonical +form a server accepts (a low `S` value in the DER encoding); the tool rejects +any other form. An ed25519 signature has one form. + +`set_domain` on an external master key stores the domain for the next token +made with `start_token` and `finish_token`, and `attest_domain` then prints the +bytes of the attestation for the external signer; the hex signature is the +attestation. + For testing without a hardware signer, a second key file can stand in for it: ``` @@ -167,8 +176,10 @@ that manifest and the signing key, so a publisher's setup is: $ 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. +`--token-key-type ed25519` matches what hardware signers support; it is for a +publisher's signing key only, since xrpld loads secp256k1 tokens from +`[validator_token]` and no other. `--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 diff --git a/src/tools/validator-keys/test/KeyFileGuard.h b/src/tools/validator-keys/test/KeyFileGuard.h deleted file mode 100644 index ece7bb0eec..0000000000 --- a/src/tools/validator-keys/test/KeyFileGuard.h +++ /dev/null @@ -1,51 +0,0 @@ -#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_; - - void - rmDir(path const& toRm) - { - boost::system::error_code ec; - if (is_directory(toRm, ec)) - remove_all(toRm, ec); - 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() - { - rmDir(subDir_); - } -}; - -} // namespace xrpl diff --git a/src/tools/validator-keys/test/ListSigning_test.cpp b/src/tools/validator-keys/test/ListSigning_test.cpp deleted file mode 100644 index ad4259ace3..0000000000 --- a/src/tools/validator-keys/test/ListSigning_test.cpp +++ /dev/null @@ -1,663 +0,0 @@ -#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, \"effective\": \"x\", \"expiration\": 1000, \"validators\": []}", - "\"effective\" must be an unsigned integer"); - expectError( - "{\"sequence\": 1, \"expiration\": 1000, \"validators\": []}", - "\"validators\" must be a non-empty array"); - expectError( - "{\"sequence\": 1, \"expiration\": 1000, \"validators\": [{\"validation_public_key\": " - "\"zz\"}]}", - "\"validation_public_key\" is not a hex public key: zz"); - 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)); - auto const notString = - "{\"sequence\": 1, \"expiration\": 1000, \"validators\": " - "[{\"validation_public_key\": \"" + - strHex(key) + "\", \"manifest\": 5}]}"; - expectError(notString, "\"manifest\" must be a base64 string 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); - } - - try - { - makeSignedList( - publisher.token.manifest, - publisher.manifest.masterKey, - list, - signature, - 3, - std::nullopt); - fail(); - } - catch (std::runtime_error const& e) - { - BEAST_EXPECT(e.what() == std::string("Unsupported list version")); - } - - // Append after the signing key rotated: the earlier blobs keep their manifest - { - SigningKeys rotated = publisher.keys; - auto const token2 = *rotated.createValidatorToken(KeyType::Ed25519); - auto const manifest2 = *deserializeManifest(base64Decode(token2.manifest)); - auto const sig2 = signList(later, *manifest2.signingKey, token2.validationSecret); - auto const v2c = - makeSignedList(token2.manifest, manifest2.masterKey, later, sig2, 2, v2b); - BEAST_EXPECT(v2c[jss::blobs_v2].size() == 3); - BEAST_EXPECT(v2c[jss::manifest].asString() == token2.manifest); - BEAST_EXPECT( - v2c[jss::blobs_v2][0u][jss::manifest].asString() == publisher.token.manifest); - BEAST_EXPECT(!v2c[jss::blobs_v2][2u].isMember(jss::manifest)); - auto const result = verifyList(v2c, std::nullopt, std::nullopt, now); - BEAST_EXPECTS(result.ok, to_string(result.report)); - BEAST_EXPECT(result.report["manifest_sequence"].asUInt() == 2); - } - - // 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"); - - expectError( - json::Value(json::ValueType::Array), - std::nullopt, - std::nullopt, - now, - "the list is not a JSON object"); - { - auto bad = good; - bad[jss::public_key] = 1; - expectError( - bad, - std::nullopt, - std::nullopt, - now, - "\"public_key\" and \"manifest\" must be strings"); - } - { - // A revoked publisher - SigningKeys revoked(KeyType::Ed25519); - auto bad = good; - bad[jss::manifest] = revoked.revoke(); - bad[jss::public_key] = strHex(revoked.publicKey()); - expectError( - bad, std::nullopt, std::nullopt, now, "the publisher's master key is revoked"); - } - { - // A blob that parses as JSON but is not a list; the signature fails too - auto bad = good; - bad[jss::blob] = base64Encode("{}"); - expectError( - bad, - std::nullopt, - std::nullopt, - now, - "blob 0: \"sequence\" must be a positive integer"); - } - - // Version 2 structure - auto const v2 = makeSignedList( - publisher.token.manifest, - publisher.manifest.masterKey, - list, - signature, - 2, - std::nullopt); - std::string const v2Shape = - "a version 2 list needs 1 to 5 \"blobs_v2\" entries and no top-level \"blob\""; - { - auto bad = v2; - bad[jss::blobs_v2] = json::Value(json::ValueType::Array); - expectError(bad, std::nullopt, std::nullopt, now, v2Shape); - } - { - auto bad = v2; - bad[jss::blob] = "x"; - expectError(bad, std::nullopt, std::nullopt, now, v2Shape); - } - { - auto bad = v2; - bad[jss::blobs_v2][0u].removeMember(jss::signature); - expectError( - bad, - std::nullopt, - std::nullopt, - now, - "every \"blobs_v2\" entry needs \"blob\" and \"signature\""); - } - { - auto bad = v2; - bad[jss::blobs_v2][0u][jss::manifest] = 5; - expectError( - bad, - std::nullopt, - std::nullopt, - now, - "a \"blobs_v2\" entry's \"manifest\" must be a string"); - } - { - Publisher const other; - auto bad = v2; - bad[jss::blobs_v2][0u][jss::manifest] = other.token.manifest; - expectError( - bad, - std::nullopt, - std::nullopt, - now, - "a \"blobs_v2\" entry's \"manifest\" is not this publisher's"); - // The publisher's own manifest in an entry is accepted - auto fine = v2; - fine[jss::blobs_v2][0u][jss::manifest] = publisher.token.manifest; - BEAST_EXPECT(verifyList(fine, std::nullopt, std::nullopt, now).ok); - } - } - } - - 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()); - } - { - path const bad = subdir / "bad-manifest.txt"; - std::ofstream o(bad.string()); - o << "AAAA\n"; - o.close(); - try - { - loadManifestFile(bad); - fail(); - } - catch (std::runtime_error const& e) - { - BEAST_EXPECT(e.what() == "Not a valid manifest: " + bad.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 deleted file mode 100644 index 9b17c031b2..0000000000 --- a/src/tools/validator-keys/test/SigningKeys_test.cpp +++ /dev/null @@ -1,1011 +0,0 @@ -#include -#include -#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()); - } - } - } - - { - // A key migrated from a publisher whose manifests carried the list - // sequence continues from that sequence. - auto const kp2 = generateKeyPair(KeyType::Ed25519, randomSeed()); - auto keys = SigningKeys(KeyType::Ed25519, kp2.second, 2026091301); - auto const token = keys.createValidatorToken(KeyType::Ed25519); - if (BEAST_EXPECT(token)) - { - auto const m = deserializeManifest(base64Decode(token->manifest)); - BEAST_EXPECT(m && m->sequence == 2026091302); - BEAST_EXPECT(keys.sequence() == 2026091302); - } - } - - 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); - - 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 - keys.finishToken(*sigBlob); - BEAST_EXPECT(keys != fileKeys); - keys.writeToFile(keyFile); - } - - { - auto const fileKeys = SigningKeys::make_SigningKeys(keyFile); - BEAST_EXPECT(keys == fileKeys); - } - } - } - - void - testKeyFileFields() - { - testcase("Key File Fields"); - - using namespace boost::filesystem; - - path const subdir = "test_key_file"; - KeyFileGuard const g(*this, subdir.string()); - path const keyFile = subdir / "validator_keys.json"; - - auto const kp = generateKeyPair(KeyType::Ed25519, randomSeed()); - auto const base = [&kp]() { - json::Value jv; - jv["key_type"] = "ed25519"; - jv["secret_key"] = toBase58(TokenType::NodePrivate, kp.second); - jv["token_sequence"] = 1; - jv["revoked"] = false; - return jv; - }; - auto const invalid = [&keyFile](json::Value const& jv, std::string const& field) { - return "Key file '" + keyFile.string() + "' contains invalid \"" + field + - "\" field: " + jv[field].toStyledString(); - }; - - for (auto const field : - {"domain", - "manifest", - "pending_token_secret", - "pending_signing_key", - "pending_key_type"}) - { - auto jv = base(); - jv[field] = 1; - testKeyFile(keyFile, jv, invalid(jv, field)); - } - for (auto const field : - {"manifest", "pending_token_secret", "pending_signing_key", "pending_key_type"}) - { - auto jv = base(); - jv[field] = "not valid"; - testKeyFile(keyFile, jv, invalid(jv, field)); - } - { - auto jv = base(); - jv["manifest"] = ""; - testKeyFile(keyFile, jv, invalid(jv, "manifest")); - } - { - // An external key whose key_type disagrees with its public key - auto jv = base(); - jv["secret_key"] = "external"; - jv["public_key"] = toBase58(TokenType::NodePublic, kp.first); - jv["key_type"] = "secp256k1"; - testKeyFile( - keyFile, - jv, - "Key file '" + keyFile.string() + - "' has a \"key_type\" that does not match \"public_key\""); - } - } - - void - testVerifyManifest() - { - testcase("Verify Manifest"); - - using namespace boost::filesystem; - - path const subdir = "test_key_file"; - KeyFileGuard const g(*this, subdir.string()); - path const keyFile = subdir / "validator_keys.json"; - - auto expectBadManifest = [this, &keyFile](json::Value const& jv) { - std::ofstream o(keyFile.string(), std::ios_base::trunc); - o << jv.toStyledString(); - o.close(); - try - { - SigningKeys::make_SigningKeys(keyFile).manifest(); - fail(); - } - catch (std::runtime_error const& e) - { - BEAST_EXPECT(e.what() == std::string("Manifest is not properly signed")); - } - }; - - SigningKeys keys(KeyType::Ed25519); - keys.createValidatorToken(KeyType::Ed25519); - auto const tokenManifest = strHex(makeSlice(keys.manifest())); - keys.writeToFile(keyFile); - json::Value jv; - { - std::ifstream in(keyFile.string()); - json::Reader reader; - reader.parse(in, jv); - } - - // A token manifest on revoked keys - jv["revoked"] = true; - expectBadManifest(jv); - - // A revocation manifest on keys that are not revoked - SigningKeys revoked(KeyType::Ed25519); - revoked.revoke(); - jv["revoked"] = false; - jv["manifest"] = strHex(makeSlice(revoked.manifest())); - expectBadManifest(jv); - - // A token manifest of another key - jv["manifest"] = tokenManifest; - jv["secret_key"] = toBase58( - TokenType::NodePrivate, generateKeyPair(KeyType::Ed25519, randomSeed()).second); - expectBadManifest(jv); - } - - void - testExternalSigningKey() - { - testcase("External Signing Key"); - - using namespace boost::filesystem; - - path const subdir = "test_key_file"; - KeyFileGuard const g(*this, subdir.string()); - path const keyFile = subdir / "validator_keys.json"; - - // The master key is in software here; the signing key is held elsewhere. - SigningKeys const signer(KeyType::Ed25519); - SigningKeys keys(KeyType::Ed25519); - - auto const start = keys.startValidatorToken(KeyType::Ed25519, signer.publicKey()); - BEAST_EXPECT(start); - // The pending signing key survives a round trip through the key file - keys.writeToFile(keyFile); - auto fileKeys = SigningKeys::make_SigningKeys(keyFile); - BEAST_EXPECT(keys == fileKeys); - - auto const masterSig = *strUnHex(keys.signHex(*start)); - auto const signingSig = *strUnHex(signer.signHex(*start)); - auto const manifest = - deserializeManifest(base64Decode(fileKeys.finishExternalToken(masterSig, signingSig))); - BEAST_EXPECT(manifest && manifest->verify()); - BEAST_EXPECT(manifest && manifest->signingKey == signer.publicKey()); - BEAST_EXPECT(manifest && manifest->sequence == 1); - BEAST_EXPECT(fileKeys.sequence() == 1); - - // Both signatures must be right - try - { - keys.finishExternalToken(masterSig, masterSig); - fail(); - } - catch (std::runtime_error const& e) - { - BEAST_EXPECT(e.what() == std::string("Manifest is not properly signed")); - } - // Nothing pending - try - { - SigningKeys(KeyType::Ed25519).finishExternalToken(masterSig, signingSig); - fail(); - } - catch (std::runtime_error const& e) - { - BEAST_EXPECT( - e.what() == std::string("No pending token with an external signing key to finish")); - } - // Revoked keys finish nothing - keys.revoke(); - BEAST_EXPECT(!keys.startValidatorToken(KeyType::Ed25519, signer.publicKey())); - for (auto const external : {false, true}) - { - try - { - if (external) - keys.finishExternalToken(masterSig, signingSig); - else - keys.finishToken(masterSig); - fail(); - } - catch (std::runtime_error const& e) - { - BEAST_EXPECT(e.what() == std::string("Validator keys have been revoked.")); - } - } - } - - void - testDomainAndHex() - { - testcase("Domain and Hex"); - - SigningKeys keys(KeyType::Ed25519); - auto expectError = [this, &keys](std::string const& domain, std::string const& expected) { - try - { - keys.domain(domain); - fail(expected); - } - catch (std::runtime_error const& e) - { - BEAST_EXPECT(e.what() == expected); - } - }; - expectError("a.b", "The domain must be between 4 and 128 characters long."); - expectError( - std::string(126, 'a') + ".com", - "The domain must be between 4 and 128 characters long."); - expectError( - "-bad.example", "The domain field must use the '[host.][subdomain.]domain.tld' format"); - keys.domain("good.example"); - BEAST_EXPECT(keys.domain() == "good.example"); - - try - { - keys.signHex("zz"); - fail(); - } - catch (std::runtime_error const& e) - { - BEAST_EXPECT(e.what() == std::string("Could not decode hex string: zz")); - } - } - - void - testKeyFileGuard() - { - testcase("Key File Guard"); - - using namespace boost::filesystem; - - path const subdir = "test_key_file"; - { - KeyFileGuard const g(*this, subdir.string()); - // A second guard for the same directory cannot set up - try - { - KeyFileGuard const again(*this, subdir.string()); - fail(); - } - catch (std::runtime_error const& e) - { - BEAST_EXPECT(e.what() == "Cannot create directory: " + subdir.string()); - } - // The directory disappearing early is only logged at teardown - remove_all(subdir); - } - BEAST_EXPECT(!exists(subdir)); - } - -public: - void - run() override - { - testMakeSigningKeys(); - testCreateValidatorToken(); - testRevoke(); - testSign(); - testSignHex(); - testWriteToFile(); - // External - testExternalMakeValidatorKeys(); - testExternalCreateValidatorToken(); - testExternalRevoke(); - testExternalWriteToFile(); - testKeyFileFields(); - testVerifyManifest(); - testExternalSigningKey(); - testDomainAndHex(); - testKeyFileGuard(); - } -}; - -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 deleted file mode 100644 index 37e865b1ff..0000000000 --- a/src/tools/validator-keys/test/ValidatorKeysTool_test.cpp +++ /dev/null @@ -1,1091 +0,0 @@ -#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))); - 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); - - // finish_sign_list appends to a version 2 list - { - ToolOptions append = hardware; - append.listVersion = 2; - append.appendFile = v2.outFile; - append.outFile = subdir / "vl2c.json"; - run("finish_sign_list", {external.signHex(bytes), unsignedList.string()}, append, ""); - BEAST_EXPECT(run("verify_list", {append.outFile->string()}, verifier, "") == 0); - } - run("finish_sign_list", - {external.signHex(bytes), unsignedList.string()}, - publisher, - "finish_sign_list needs --manifest-file"); - - // The signed list goes to stdout without --out - { - coutCapture.str(""); - ToolOptions stdoutSigner = signer; - stdoutSigner.outFile.reset(); - run("sign_list", {unsignedList.string()}, stdoutSigner, ""); - BEAST_EXPECT(coutCapture.str().find("\"public_key\"") != std::string::npos); - } - // Output paths that cannot be opened - { - ToolOptions bad = signer; - bad.outFile = subdir / "missing" / "vl.json"; - run("sign_list", - {unsignedList.string()}, - bad, - "Cannot open output file: " + bad.outFile->string()); - ToolOptions badToken = publisher; - badToken.outFile = subdir / "missing" / "token.txt"; - run("create_token", - {}, - badToken, - "Cannot open output file: " + badToken.outFile->string()); - } - // verify_list input problems - run("verify_list", - {(subdir / "missing.json").string()}, - verifier, - "Failed to open file: " + (subdir / "missing.json").string()); - { - path const notJson = subdir / "not.json"; - std::ofstream o(notJson.string()); - o << "nope\n"; - o.close(); - run("verify_list", - {notJson.string()}, - verifier, - "Not a JSON document: " + notJson.string()); - } - // Tokens that cannot sign a list: an invalid manifest, a secret of another key - auto writeToken = [](path const& file, ValidatorToken const& token) { - std::ofstream o(file.string()); - o << "[validator_token]\n" << tokenToBase64(token) << "\n"; - }; - { - ToolOptions badManifest = signer; - badManifest.tokenFile = subdir / "bad-manifest-token.txt"; - writeToken( - *badManifest.tokenFile, - ValidatorToken{"AAAA", generateSecretKey(KeyType::Ed25519, randomSeed())}); - run("sign_list", - {unsignedList.string()}, - badManifest, - "The token's manifest is not valid"); - - ToolOptions wrongSecret = signer; - wrongSecret.tokenFile = subdir / "wrong-secret-token.txt"; - writeToken( - *wrongSecret.tokenFile, - ValidatorToken{manifest, generateSecretKey(KeyType::Ed25519, randomSeed())}); - run("sign_list", - {unsignedList.string()}, - wrongSecret, - "The token's secret does not match its manifest"); - } - // A revoked manifest signs nothing - { - SigningKeys revokedKeys(KeyType::Ed25519); - ToolOptions revoked = hardware; - revoked.manifestFile = subdir / "revoked-manifest.txt"; - std::ofstream o(revoked.manifestFile->string()); - o << revokedKeys.revoke() << "\n"; - o.close(); - run("start_sign_list", {unsignedList.string()}, revoked, "The manifest is revoked"); - run("finish_sign_list", - {external.signHex(bytes), unsignedList.string()}, - revoked, - "The manifest is revoked"); - } - } - - void - testDomainCommands() - { - testcase("Domain Commands"); - - std::stringstream coutCapture; - CoutRedirect coutRedirect{coutCapture}; - - using namespace boost::filesystem; - - path const subdir = "test_key_file"; - KeyFileGuard const g(*this, subdir.string()); - ToolOptions options = toolOptions(subdir / "validator_keys.json"); - - auto run = [this]( - std::string const& command, - std::vector const& args, - ToolOptions const& options, - std::string const& expectedError) { - try - { - runCommand(command, args, options); - BEAST_EXPECTS(expectedError.empty(), "expected: " + expectedError); - } - catch (std::exception const& e) - { - BEAST_EXPECTS(e.what() == expectedError, e.what()); - } - }; - - run("create_keys", {}, options, ""); - // No manifest yet - coutCapture.str(""); - run("show_manifest", {"hex"}, options, ""); - BEAST_EXPECT(coutCapture.str().find("unavailable") != std::string::npos); - - run("clear_domain", {}, options, ""); // already clear - run("set_domain", {"validator.example.com"}, options, ""); - run("set_domain", {"validator.example.com"}, options, ""); // already set - run("attest_domain", {}, options, ""); - coutCapture.str(""); - run("show_manifest", {"base64"}, options, ""); - BEAST_EXPECT(coutCapture.str().find("(Base64)") != std::string::npos); - coutCapture.str(""); - run("show_manifest", {"hex"}, options, ""); - BEAST_EXPECT(coutCapture.str().find("(Hex)") != std::string::npos); - - // The token sequence is exhausted - { - auto const kp = generateKeyPair(KeyType::Ed25519, randomSeed()); - SigningKeys(KeyType::Ed25519, kp.second, std::numeric_limits::max() - 1) - .writeToFile(options.keyFile); - run("set_domain", - {"other.example.com"}, - options, - "Maximum number of tokens have already been generated.\n" - "Revoke validator keys if previous token has been compromised."); - } - - // Revoked keys refuse domain work and tokens - ToolOptions revoked = toolOptions(subdir / "revoked.json"); - run("create_keys", {}, revoked, ""); - run("revoke_keys", {}, revoked, ""); - std::string const revokedError = - "Operation error: The specified master key has been revoked!"; - run("set_domain", {"validator.example.com"}, revoked, revokedError); - run("attest_domain", {}, revoked, revokedError); - run("finish_token", {"00"}, revoked, "Validator keys have been revoked."); - run("finish_token", {"00", "00"}, revoked, "Validator keys have been revoked."); - run("finish_token", - {"00", "00"}, - options, - "No pending token with an external signing key to finish"); - } - -public: - void - run() override - { - getVersionString(); - - testCreateKeyFile(); - testCreateToken(); - testCreateRevocation(); - testCreateKeyFileExternal(); - testCreateTokenExternal(); - testCreateRevocationExternal(); - testSign(); - testHexSign(); - testRunCommand(); - testListCommands(); - testDomainCommands(); - } -}; - -BEAST_DEFINE_TESTSUITE(ValidatorKeysTool, keys, xrpl); - -} // namespace tests - -} // namespace xrpl