Compare commits

...

32 Commits

Author SHA1 Message Date
Denis Angell
ead061fadb fix: refuse a key file on a filesystem without hard links and check appended blobs fully 2026-10-06 21:38:34 -04:00
Denis Angell
22334aa7c9 fix: make the show_manifest encoding optional 2026-10-06 21:09:28 -04:00
Denis Angell
8cd344b56c fix: sync the key file's directory best effort and end line comments at CR 2026-10-06 20:52:23 -04:00
Denis Angell
d1498b1849 fix: require the token's manifest to verify for the list's master key 2026-10-06 20:14:32 -04:00
Denis Angell
fa8be41369 fix: list the no-hard-link errors instead of comparing equal ones 2026-10-06 19:58:39 -04:00
Denis Angell
b89ec84283 fix: refuse create_token while a token is pending 2026-10-06 19:53:27 -04:00
Denis Angell
910b25db47 fix: address the validator-keys AI review on key files, list output and signing keys 2026-10-06 19:36:19 -04:00
Denis Angell
b093cb5912 fix: verify appended blobs, keep a pending token, and register tool tests with CTest 2026-10-06 15:07:36 -04:00
Denis Angell
40b299652e test: release the held directory when its lock fails 2026-10-06 13:21:26 -04:00
Denis Angell
bcd5b64659 fix: reject merged list values and serialize key file updates 2026-10-06 13:05:57 -04:00
Denis Angell
c11a71349e fix: make every directory created for a key file owner-only 2026-10-06 11:56:43 -04:00
Denis Angell
7a84f1c0d0 fix: reject text after the list object and honour --out for revocations 2026-10-06 11:31:51 -04:00
Denis Angell
59cb0adb37 fix: require canonical base64 for a public key argument 2026-10-06 10:59:31 -04:00
Denis Angell
1103fefdf8 fix: create key files without replacing one a concurrent run wrote 2026-10-06 10:21:27 -04:00
Denis Angell
38a0899a71 style: name the legacy config file without the old binary name 2026-10-05 20:07:32 -04:00
Denis Angell
51cf1b8fc8 fix: address the validator-keys review on output, key path, appends and packaging 2026-10-05 19:37:42 -04:00
Denis Angell
610b6e6555 fix: make STObject a complete type in Manifest.h 2026-10-03 13:08:42 +00:00
Denis Angell
1c55353bda fix: create OwnerOnlyFile's temporary atomically with mkstemp 2026-10-03 13:08:38 +00:00
Denis Angell
f616b1b763 Local merge develop into dangell7/validator-keys 2026-10-02 12:05:44 +00:00
Denis Angell
fa789d95fd style: drop includes the test publisher server no longer uses 2026-09-14 18:31:58 -04:00
Denis Angell
3dc27ca77d fix: replace output files whole and refuse an output that names an input 2026-09-14 17:32:39 -04:00
Denis Angell
5cda41fad0 refactor: sign manifests and revocations in libxrpl 2026-09-14 17:14:23 -04:00
Denis Angell
88a9bc76bc fix: name xrp-ledger.toml correctly in the attest_domain hint 2026-09-14 14:52:49 -04:00
Denis Angell
5e7da075fa fix: refuse symlinked key file temporaries and keep secrets out of errors 2026-09-14 14:35:34 -04:00
Denis Angell
b51d8c1140 refactor: satisfy clang-tidy in validator-keys 2026-09-14 13:46:16 -04:00
Denis Angell
82f95502ac docs: article the rename check expects in the validator-keys guide 2026-09-14 13:03:36 -04:00
Denis Angell
0467fd7144 fix: reject duplicate validators and a zero expiration; restrict output files before writing 2026-09-14 12:51:59 -04:00
Denis Angell
3ffba1678e refactor: one manifest pipeline, server-faithful list checks, gtest suites for validator-keys 2026-09-14 12:47:53 -04:00
Denis Angell
9d2428967d test: cover the tool's sources; appended blobs verify under their own manifest 2026-09-13 20:39:36 -04:00
Denis Angell
7bd7ea79dd ci: lint validator-keys in the clang-tidy job 2026-09-13 19:56:36 -04:00
Denis Angell
728db41016 test: manifest sequence continues from a migrated token_sequence 2026-09-13 19:56:34 -04:00
Denis Angell
ff5aa537e1 feat: vendor validator-keys with external signing and validator-list signing 2026-09-13 19:52:40 -04:00
38 changed files with 6302 additions and 317 deletions

View File

@@ -128,6 +128,6 @@ CheckOptions:
readability-identifier-naming.PublicMemberSuffix: ""
readability-identifier-naming.GlobalFunctionIgnoredRegexp: "^(to_string|hash_append|tuple_hash)$"
HeaderFilterRegex: '^.*/(tests?|xrpl|xrpld)/.*\.(h|hpp|ipp)$'
HeaderFilterRegex: '^.*/(tests?|tools|xrpl|xrpld)/.*\.(h|hpp|ipp)$'
ExcludeHeaderFilterRegex: '^.*/protocol_autogen/.*\.(h|hpp)$|^.*\.pb\.h$'
WarningsAsErrors: "*"

View File

@@ -68,9 +68,10 @@ words:
- Britto
- Btrfs
- Buildx
- canonicality
- canonicalised
- canonicality
- cctools
- CGNAT
- changespq
- checkme
- choco
@@ -138,9 +139,11 @@ words:
- gpgkey
- Hinnant
- hotwallet
- hvssbqmgz
- hwaddress
- hwrap
- ifndef
- Iiwib
- inequation
- Injectivity
- insuf
@@ -154,6 +157,7 @@ words:
- jemalloc
- jlog
- jtnofill
- keyfile
- keylet
- keylets
- keyvadb
@@ -179,11 +183,10 @@ words:
- mathbunnyru
- mcmodel
- MEMORYSTATUSEX
- MPTAMM
- MPTDEX
- Merkle
- misprediction
- missingok
- MPTAMM
- mptbalance
- MPTDEX
- mptflags
@@ -258,8 +261,8 @@ words:
- replayer
- repodata
- repomd
- rerandomize
- rerandomization
- rerandomize
- rerandomized
- rerandomizes
- rerere
@@ -282,8 +285,8 @@ words:
- rustup
- sahyadri
- Satoshi
- scons
- Schnorr
- scons
- secp
- sendq
- seqit
@@ -313,6 +316,7 @@ words:
- stobject
- stpath
- stpathset
- STRINGIZE
- sttx
- stvar
- stvector
@@ -348,6 +352,7 @@ words:
- unfindable
- unflatten
- unfund
- ungated
- unimpair
- unroutable
- unscalable
@@ -387,5 +392,3 @@ words:
- xxhash
- xxhasher
- zstdio
- CGNAT
- ungated

View File

@@ -194,9 +194,18 @@ 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
tests.xrpld > xrpld.rpc
tests.xrpld > xrpl.json
tests.xrpld > xrpl.protocol
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

View File

@@ -61,9 +61,6 @@ ${SED_COMMAND} -i 's/ripple.pb.h/xrpl.pb.h/' include/xrpl/protocol/messages.h
${SED_COMMAND} -i 's/ripple.pb.h/xrpl.pb.h/' BUILD.md
${SED_COMMAND} -i 's/ripple.pb.h/xrpl.pb.h/' BUILD.md
# Restore the name of the validator keys repository.
${SED_COMMAND} -i 's@xrpl/validator-keys-tool@ripple/validator-keys-tool@' cmake/XrplValidatorKeys.cmake
# Ensure the name of the binary and config remain 'rippled' for now.
${SED_COMMAND} -i -E 's/xrpld(-example)?\.cfg/rippled\1.cfg/g' cmake/XrplInstall.cmake
if grep -q '"xrpld"' cmake/XrplCore.cmake; then

View File

@@ -291,18 +291,16 @@ 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' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: validator-keys-${{ inputs.config_name }}
path: |
${{ env.BUILD_DIR }}/validator-keys
${{ env.BUILD_DIR }}/validator-keys-LICENSE
path: ${{ env.BUILD_DIR }}/validator-keys
retention-days: 3
if-no-files-found: error

View File

@@ -85,6 +85,7 @@ jobs:
-Dtests=ON \
-Dwerr=ON \
-Dxrpld=ON \
-Dvalidator_keys=ON \
-Dverify_headers=ON \
-Drust=ON \
..

View File

@@ -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).

View File

@@ -176,6 +176,9 @@ if(tests)
include(CTest)
add_subdirectory(src/tests/libxrpl)
add_subdirectory(src/tests/xrpld)
if(validator_keys)
add_subdirectory(src/tests/tools/validator-keys)
endif()
endif()
if(benchmark)

View File

@@ -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`.

View File

@@ -1,43 +1,15 @@
option(
validator_keys
"Enables building of validator-keys tool as a separate target (imported via FetchContent)"
"Enables building of the validator-keys tool as a separate target"
OFF
)
if(validator_keys)
# Own the install destination below rather than relying on another module
# having pulled this in first.
include(GNUInstallDirs)
# Pinned to an exact commit, not a branch: the tool ships inside our
# packages, so the same xrpld version must always package the same
# validator-keys. Bump this deliberately.
set(validator_keys_commit "4c0fb75eec9601c711645998c904507e87e910ae")
message(STATUS "Using ValidatorKeys commit: ${validator_keys_commit}")
FetchContent_Declare(
validator_keys
GIT_REPOSITORY https://github.com/ripple/validator-keys-tool.git
GIT_TAG "${validator_keys_commit}"
)
FetchContent_MakeAvailable(validator_keys)
# The tool's own CMakeLists excludes the target from 'all' when it is built
# as a subproject. Undo that, so validator_keys=ON really does build it.
set_target_properties(
validator-keys
PROPERTIES
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}"
EXCLUDE_FROM_ALL OFF
EXCLUDE_FROM_DEFAULT_BUILD OFF
)
# We ship this binary, so like xrpld it must not keep the Nix store's ELF
# loader, or it cannot run on the target distro at all.
patch_nix_binary(validator-keys)
configure_file(
"${validator_keys_SOURCE_DIR}/LICENSE"
"${CMAKE_BINARY_DIR}/validator-keys-LICENSE"
COPYONLY
)
install(TARGETS validator-keys RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR})
if(WIN32)
message(
FATAL_ERROR
"validator_keys is not supported on Windows: key files are written with POSIX mkstemp and rename"
)
endif()
add_subdirectory(src/tools/validator-keys)
endif()

View File

@@ -7,6 +7,7 @@
#include <xrpl/basics/base_uint.h>
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/protocol/PublicKey.h>
#include <xrpl/protocol/STObject.h>
#include <xrpl/protocol/SecretKey.h>
#include <atomic>
@@ -296,6 +297,80 @@ deserializeManifest(
}
/** @} */
/**
* The fields of a manifest before it is signed: @p masterKey delegates to
* @p signingKey at @p sequence, with an optional domain.
*
* Both signatures cover @ref manifestSigningData of the returned object. Set
* them as sfSignature and sfMasterSignature, then serialize.
*
* @param masterKey The validator's or publisher's master public key
* @param signingKey The ephemeral key the manifest delegates to
* @param sequence The manifest sequence; a newer manifest has a larger one
* @param domain The domain to carry, or empty for none
* @return sfPublicKey, sfSigningPubKey, sfSequence and, when not empty,
* sfDomain, without any signature
*/
STObject
makeManifestFields(
PublicKey const& masterKey,
PublicKey const& signingKey,
std::uint32_t sequence,
std::string const& domain = {});
/**
* The fields of a revocation before it is signed: @p masterKey at the
* largest sequence, with no signing key.
*
* @param masterKey The master public key being revoked
* @return sfPublicKey and sfSequence set to the largest value, without the
* master signature
*/
STObject
makeRevocationFields(PublicKey const& masterKey);
/**
* The bytes a manifest's signatures cover: HashPrefix::Manifest followed by
* the fields without the signatures.
*
* @param fields An unsigned manifest or revocation, as
* @ref makeManifestFields or @ref makeRevocationFields returns it
* @return The bytes sfMasterSignature covers, and sfSignature when the
* manifest has a signing key
*/
Blob
manifestSigningData(STObject const& fields);
/**
* A manifest signed by both keys.
*
* @param masterKey The master public key
* @param masterSecret The secret key of @p masterKey; signs sfMasterSignature
* @param signingKey The ephemeral key the manifest delegates to
* @param signingSecret The secret key of @p signingKey; signs sfSignature
* @param sequence The manifest sequence; a newer manifest has a larger one
* @param domain The domain to carry, or empty for none
* @return The serialized manifest, as `Manifest::serialized` holds it
*/
std::string
makeManifest(
PublicKey const& masterKey,
SecretKey const& masterSecret,
PublicKey const& signingKey,
SecretKey const& signingSecret,
std::uint32_t sequence,
std::string const& domain = {});
/**
* A revocation of @p masterKey signed by it.
*
* @param masterKey The master public key being revoked
* @param masterSecret The secret key of @p masterKey
* @return The serialized manifest, as `Manifest::serialized` holds it
*/
std::string
makeRevocation(PublicKey const& masterKey, SecretKey const& masterSecret);
inline bool
operator==(Manifest const& lhs, Manifest const& rhs)
{

View File

@@ -135,9 +135,10 @@ artifacts (`xrpld_artifact_name` and `validator_keys_artifact_name`) after that
same config, so a packaged config must keep `-Dvalidator_keys=ON`. Those configs
are not `minimal`, so `on-pr.yml` only packages once a PR runs the full matrix.
`validator-keys` is fetched from an exact commit pinned in
`validator-keys` is built from the source in
[`src/tools/validator-keys`](../src/tools/validator-keys), enabled by
[`cmake/XrplValidatorKeys.cmake`](../cmake/XrplValidatorKeys.cmake), so a given
`xrpld` version always packages the same tool; bump that commit deliberately.
`xrpld` version always packages the tool from the same tree.
### Locally (mirrors CI)

View File

@@ -39,11 +39,12 @@ BASE_NAME = "xrpld"
VARIANTS = ("", "assert")
# Files both packaging systems consume, staged under the same names.
STAGED_FROM_BUILD = ("xrpld", "validator-keys", "validator-keys-LICENSE")
STAGED_FROM_BUILD = ("xrpld", "validator-keys")
STAGED_FROM_SRC = {
"cfg/xrpld-example.cfg": "xrpld.cfg",
"cfg/validators-example.txt": "validators.txt",
"LICENSE.md": "LICENSE.md",
"src/tools/validator-keys/LICENSE": "validator-keys-LICENSE",
"README.md": "README.md",
}
STAGED_UNITS = ("xrpld.service", "xrpld.sysusers", "xrpld.tmpfiles", "xrpld.logrotate")
@@ -153,7 +154,7 @@ def read_version(xrpld: Path) -> str:
def check_binaries(build_dir: Path) -> None:
"""Fail unless the binaries and their notices are present and runnable."""
"""Fail unless the binaries are present and runnable."""
missing = [
name
for name in ("xrpld", "validator-keys")
@@ -165,13 +166,6 @@ def check_binaries(build_dir: Path) -> None:
"-Dxrpld=ON -Dvalidator_keys=ON."
)
# No package goes out without the attribution.
notice = build_dir / "validator-keys-LICENSE"
assert notice.is_file(), (
f"missing {notice}. cmake/XrplValidatorKeys.cmake copies it out of the "
"fetched validator-keys-tool source, so reconfigure with -Dvalidator_keys=ON."
)
# Catches a binary still pointing at the Nix store's ELF loader, since
# packaging runs in a vanilla distro container.
capture(build_dir / "validator-keys", "--version")

View File

@@ -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

View File

@@ -17,6 +17,7 @@
#include <xrpl/protocol/SOTemplate.h>
#include <xrpl/protocol/STExchange.h>
#include <xrpl/protocol/STObject.h>
#include <xrpl/protocol/SecretKey.h>
#include <xrpl/protocol/Serializer.h>
#include <xrpl/protocol/Sign.h>
#include <xrpl/protocol/tokens.h>
@@ -260,6 +261,76 @@ Manifest::getMasterSignature() const
return st.getFieldVL(sfMasterSignature);
}
STObject
makeManifestFields(
PublicKey const& masterKey,
PublicKey const& signingKey,
std::uint32_t sequence,
std::string const& domain)
{
STObject st(sfGeneric);
st[sfSequence] = sequence;
st[sfPublicKey] = masterKey;
st[sfSigningPubKey] = signingKey;
if (!domain.empty())
st[sfDomain] = makeSlice(domain);
return st;
}
STObject
makeRevocationFields(PublicKey const& masterKey)
{
STObject st(sfGeneric);
st[sfSequence] = std::numeric_limits<std::uint32_t>::max();
st[sfPublicKey] = masterKey;
return st;
}
Blob
manifestSigningData(STObject const& fields)
{
Serializer s;
s.add32(HashPrefix::Manifest);
fields.addWithoutSigningFields(s);
return s.peekData();
}
namespace {
std::string
serializeManifest(STObject const& st)
{
Serializer s;
st.add(s);
return std::string(static_cast<char const*>(s.data()), s.size());
}
} // namespace
std::string
makeManifest(
PublicKey const& masterKey,
SecretKey const& masterSecret,
PublicKey const& signingKey,
SecretKey const& signingSecret,
std::uint32_t sequence,
std::string const& domain)
{
auto st = makeManifestFields(masterKey, signingKey, sequence, domain);
auto const data = manifestSigningData(st);
set(st, sfSignature, sign(signingKey, signingSecret, makeSlice(data)));
set(st, sfMasterSignature, sign(masterKey, masterSecret, makeSlice(data)));
return serializeManifest(st);
}
std::string
makeRevocation(PublicKey const& masterKey, SecretKey const& masterSecret)
{
auto st = makeRevocationFields(masterKey);
set(st, sfMasterSignature, sign(masterKey, masterSecret, makeSlice(manifestSigningData(st))));
return serializeManifest(st);
}
std::optional<ValidatorToken>
loadValidatorToken(std::vector<std::string> const& blob, beast::Journal journal)
{

View File

@@ -106,75 +106,26 @@ public:
}
}
static std::string
makeManifestString(
PublicKey const& pk,
SecretKey const& sk,
PublicKey const& spk,
SecretKey const& ssk,
int seq)
{
STObject st(sfGeneric);
st[sfSequence] = seq;
st[sfPublicKey] = pk;
st[sfSigningPubKey] = spk;
// NOLINTNEXTLINE(bugprone-unchecked-optional-access)
sign(st, HashPrefix::Manifest, *publicKeyType(spk), ssk);
// NOLINTNEXTLINE(bugprone-unchecked-optional-access)
sign(st, HashPrefix::Manifest, *publicKeyType(pk), sk, sfMasterSignature);
Serializer s;
st.add(s);
return base64Encode(std::string(static_cast<char const*>(s.data()), s.size()));
}
std::string
makeRevocationString(SecretKey const& sk, KeyType type, bool invalidSig = false)
{
auto const pk = derivePublicKey(type, sk);
STObject st(sfGeneric);
st[sfSequence] = std::numeric_limits<std::uint32_t>::max();
st[sfPublicKey] = pk;
sign(
st, HashPrefix::Manifest, type, invalidSig ? randomSecretKey() : sk, sfMasterSignature);
BEAST_EXPECT(invalidSig ^ verify(st, HashPrefix::Manifest, pk, sfMasterSignature));
Serializer s;
st.add(s);
return base64Encode(std::string(static_cast<char const*>(s.data()), s.size()));
return base64Encode(buildRevocation(sk, type, invalidSig).serialized);
}
Manifest
makeRevocation(SecretKey const& sk, KeyType type, bool invalidSig = false)
buildRevocation(SecretKey const& sk, KeyType type, bool invalidSig = false)
{
auto const pk = derivePublicKey(type, sk);
STObject st(sfGeneric);
st[sfSequence] = std::numeric_limits<std::uint32_t>::max();
st[sfPublicKey] = pk;
sign(
st, HashPrefix::Manifest, type, invalidSig ? randomSecretKey() : sk, sfMasterSignature);
BEAST_EXPECT(invalidSig ^ verify(st, HashPrefix::Manifest, pk, sfMasterSignature));
Serializer s;
st.add(s);
std::string const m(static_cast<char const*>(s.data()), s.size());
if (auto r = deserializeManifest(m))
return std::move(*r);
Throw<std::runtime_error>("Could not create a revocation manifest");
return *deserializeManifest(std::string{}); // Silence compiler warning.
auto manifest =
deserializeManifest(makeRevocation(pk, invalidSig ? randomSecretKey() : sk));
if (!manifest)
Throw<std::runtime_error>("Could not create a revocation manifest");
BEAST_EXPECT(invalidSig ^ manifest->verify());
return std::move(*manifest);
}
Manifest
makeManifest(
buildManifest(
SecretKey const& sk,
KeyType type,
SecretKey const& ssk,
@@ -184,27 +135,12 @@ public:
{
auto const pk = derivePublicKey(type, sk);
auto const spk = derivePublicKey(stype, ssk);
STObject st(sfGeneric);
st[sfSequence] = seq;
st[sfPublicKey] = pk;
st[sfSigningPubKey] = spk;
sign(st, HashPrefix::Manifest, stype, ssk);
BEAST_EXPECT(verify(st, HashPrefix::Manifest, spk));
sign(
st, HashPrefix::Manifest, type, invalidSig ? randomSecretKey() : sk, sfMasterSignature);
BEAST_EXPECT(invalidSig ^ verify(st, HashPrefix::Manifest, pk, sfMasterSignature));
Serializer s;
st.add(s);
std::string const m(static_cast<char const*>(s.data()), s.size());
if (auto r = deserializeManifest(m))
return std::move(*r);
Throw<std::runtime_error>("Could not create a manifest");
return *deserializeManifest(std::string{}); // Silence compiler warning.
auto manifest = deserializeManifest(
makeManifest(pk, invalidSig ? randomSecretKey() : sk, spk, ssk, seq));
if (!manifest)
Throw<std::runtime_error>("Could not create a manifest");
BEAST_EXPECT(invalidSig ^ manifest->verify());
return std::move(*manifest);
}
static Manifest
@@ -311,7 +247,8 @@ public:
auto const pk = derivePublicKey(KeyType::Ed25519, sk);
auto const kp = randomKeyPair(KeyType::Secp256k1);
std::string const cfgManifest = makeManifestString(pk, sk, kp.first, kp.second, 0);
std::string const cfgManifest =
base64Encode(makeManifest(pk, sk, kp.first, kp.second, 0));
BEAST_EXPECT(
loaded.load(*dbCon, "ValidatorManifests", cfgManifest, emptyRevocation));
@@ -330,7 +267,7 @@ public:
auto const pk = derivePublicKey(keyType, sk);
auto const kp = randomKeyPair(KeyType::Secp256k1);
std::vector<std::string> const nonRevocation = {
makeManifestString(pk, sk, kp.first, kp.second, 0)};
base64Encode(makeManifest(pk, sk, kp.first, kp.second, 0))};
BEAST_EXPECT(
!loaded.load(*dbCon, "ValidatorManifests", emptyManifest, nonRevocation));
@@ -359,7 +296,7 @@ public:
auto const sk = randomSecretKey();
auto const pk = derivePublicKey(KeyType::Ed25519, sk);
auto const kp = randomKeyPair(KeyType::Secp256k1);
auto const m = makeManifest(sk, KeyType::Ed25519, kp.second, KeyType::Secp256k1, 0);
auto const m = buildManifest(sk, KeyType::Ed25519, kp.second, KeyType::Secp256k1, 0);
STObject st(sfGeneric);
st[sfSequence] = 0;
@@ -397,7 +334,7 @@ public:
BEAST_EXPECT(
ManifestDisposition::Accepted ==
cache.applyManifest(
makeManifest(sk, KeyType::Ed25519, kp0.second, KeyType::Secp256k1, 0),
buildManifest(sk, KeyType::Ed25519, kp0.second, KeyType::Secp256k1, 0),
ManifestRateLimitCapPolicy::Capped));
BEAST_EXPECT(cache.getSigningKey(pk) == kp0.first);
BEAST_EXPECT(cache.getMasterKey(kp0.first) == pk);
@@ -410,7 +347,7 @@ public:
BEAST_EXPECT(
ManifestDisposition::Accepted ==
cache.applyManifest(
makeManifest(sk, KeyType::Ed25519, kp1.second, KeyType::Secp256k1, 1),
buildManifest(sk, KeyType::Ed25519, kp1.second, KeyType::Secp256k1, 1),
ManifestRateLimitCapPolicy::Capped));
BEAST_EXPECT(cache.getSigningKey(pk) == kp1.first);
BEAST_EXPECT(cache.getMasterKey(kp1.first) == pk);
@@ -421,7 +358,7 @@ public:
BEAST_EXPECT(
ManifestDisposition::BadEphemeralKey ==
cache.applyManifest(
makeManifest(sk, KeyType::Ed25519, kp1.second, KeyType::Secp256k1, 2),
buildManifest(sk, KeyType::Ed25519, kp1.second, KeyType::Secp256k1, 2),
ManifestRateLimitCapPolicy::Capped));
BEAST_EXPECT(cache.getSigningKey(pk) == kp1.first);
BEAST_EXPECT(cache.getMasterKey(kp1.first) == pk);
@@ -433,7 +370,7 @@ public:
BEAST_EXPECT(
ManifestDisposition::Accepted ==
cache.applyManifest(
makeRevocation(sk, KeyType::Ed25519), ManifestRateLimitCapPolicy::Capped));
buildRevocation(sk, KeyType::Ed25519), ManifestRateLimitCapPolicy::Capped));
BEAST_EXPECT(cache.revoked(pk));
BEAST_EXPECT(cache.getSigningKey(pk) == pk);
BEAST_EXPECT(cache.getMasterKey(kp0.first) == kp0.first);
@@ -877,27 +814,27 @@ public:
auto const kpA0 = randomKeyPair(KeyType::Secp256k1);
auto const kpA1 = randomKeyPair(KeyType::Secp256k1);
auto const sA0 =
makeManifest(skA, KeyType::Ed25519, kpA0.second, KeyType::Secp256k1, 0);
buildManifest(skA, KeyType::Ed25519, kpA0.second, KeyType::Secp256k1, 0);
auto const sA1 =
makeManifest(skA, KeyType::Ed25519, kpA1.second, KeyType::Secp256k1, 1);
buildManifest(skA, KeyType::Ed25519, kpA1.second, KeyType::Secp256k1, 1);
auto const sA2 =
makeManifest(skA, KeyType::Ed25519, kpA1.second, KeyType::Secp256k1, 2);
auto const sAMax = makeRevocation(skA, KeyType::Ed25519);
buildManifest(skA, KeyType::Ed25519, kpA1.second, KeyType::Secp256k1, 2);
auto const sAMax = buildRevocation(skA, KeyType::Ed25519);
auto const skB = randomSecretKey();
auto const kpB0 = randomKeyPair(KeyType::Secp256k1);
auto const kpB1 = randomKeyPair(KeyType::Secp256k1);
auto const kpB2 = randomKeyPair(KeyType::Secp256k1);
auto const sB0 =
makeManifest(skB, KeyType::Ed25519, kpB0.second, KeyType::Secp256k1, 0);
auto const sB1 = makeManifest(
buildManifest(skB, KeyType::Ed25519, kpB0.second, KeyType::Secp256k1, 0);
auto const sB1 = buildManifest(
skB,
KeyType::Ed25519,
kpB1.second,
KeyType::Secp256k1,
1,
true); // invalidSig
auto const sB2 = makeManifest(skB, KeyType::Ed25519, kpB2.second, KeyType::Ed25519, 2);
auto const sB2 = buildManifest(skB, KeyType::Ed25519, kpB2.second, KeyType::Ed25519, 2);
auto const fake = sB2.serialized + '\0';
@@ -962,7 +899,7 @@ public:
cache.applyManifest(clone(sB2), ManifestRateLimitCapPolicy::Capped) ==
ManifestDisposition::Accepted);
auto const sC0 = makeManifest(
auto const sC0 = buildManifest(
kpB2.second, KeyType::Ed25519, randomSecretKey(), KeyType::Ed25519, 47);
BEAST_EXPECT(
cache.applyManifest(clone(sC0), ManifestRateLimitCapPolicy::Capped) ==

View File

@@ -14,14 +14,9 @@
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/config/Constants.h>
#include <xrpl/protocol/HashPrefix.h>
#include <xrpl/protocol/KeyType.h>
#include <xrpl/protocol/PublicKey.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STObject.h>
#include <xrpl/protocol/SecretKey.h>
#include <xrpl/protocol/Serializer.h>
#include <xrpl/protocol/Sign.h>
#include <xrpl/protocol/UintTypes.h>
#include <xrpl/protocol/digest.h>
#include <xrpl/protocol/jss.h>
@@ -74,50 +69,6 @@ private:
return derivePublicKey(KeyType::Ed25519, randomSecretKey());
}
static std::string
makeManifestString(
PublicKey const& pk,
SecretKey const& sk,
PublicKey const& spk,
SecretKey const& ssk,
int seq)
{
STObject st(sfGeneric);
st[sfSequence] = seq;
st[sfPublicKey] = pk;
if (seq != std::numeric_limits<std::uint32_t>::max())
{
st[sfSigningPubKey] = spk;
// NOLINTNEXTLINE(bugprone-unchecked-optional-access)
sign(st, HashPrefix::Manifest, *publicKeyType(spk), ssk);
}
// NOLINTNEXTLINE(bugprone-unchecked-optional-access)
sign(st, HashPrefix::Manifest, *publicKeyType(pk), sk, sfMasterSignature);
Serializer s;
st.add(s);
return std::string(static_cast<char const*>(s.data()), s.size());
}
static std::string
makeRevocationString(PublicKey const& pk, SecretKey const& sk)
{
STObject st(sfGeneric);
st[sfSequence] = std::numeric_limits<std::uint32_t>::max();
st[sfPublicKey] = pk;
// NOLINTNEXTLINE(bugprone-unchecked-optional-access)
sign(st, HashPrefix::Manifest, *publicKeyType(pk), sk, sfMasterSignature);
Serializer s;
st.add(s);
return std::string(static_cast<char const*>(s.data()), s.size());
}
static Validator
randomValidator()
{
@@ -127,8 +78,8 @@ private:
return {
.masterPublic = masterPublic,
.signingPublic = signingKeys.first,
.manifest = base64Encode(makeManifestString(
masterPublic, secret, signingKeys.first, signingKeys.second, 1))};
.manifest = base64Encode(
makeManifest(masterPublic, secret, signingKeys.first, signingKeys.second, 1))};
}
static std::string
@@ -232,7 +183,7 @@ private:
auto const localMasterSecret = randomSecretKey();
auto const localMasterPublic = derivePublicKey(KeyType::Ed25519, localMasterSecret);
std::string const cfgManifest(makeManifestString(
std::string const cfgManifest(makeManifest(
localMasterPublic, localMasterSecret, localSigningPublicOuter, localSigningSecret, 1));
auto format = [](PublicKey const& publicKey, char const* comment = nullptr) {
@@ -461,12 +412,7 @@ private:
// -- thus should not be loaded
// NOLINTBEGIN(bugprone-unchecked-optional-access)
pubManifests.applyManifest(
*deserializeManifest(makeManifestString(
pubRevokedPublic,
pubRevokedSecret,
pubRevokedSigning.first,
pubRevokedSigning.second,
std::numeric_limits<std::uint32_t>::max())),
*deserializeManifest(makeRevocation(pubRevokedPublic, pubRevokedSecret)),
ManifestRateLimitCapPolicy::Capped);
// NOLINTEND(bugprone-unchecked-optional-access)
@@ -503,12 +449,7 @@ private:
// -- thus should not be loaded
// NOLINTBEGIN(bugprone-unchecked-optional-access)
pubManifests.applyManifest(
*deserializeManifest(makeManifestString(
pubRevokedPublic,
pubRevokedSecret,
pubRevokedSigning.first,
pubRevokedSigning.second,
std::numeric_limits<std::uint32_t>::max())),
*deserializeManifest(makeRevocation(pubRevokedPublic, pubRevokedSecret)),
ManifestRateLimitCapPolicy::Capped);
// NOLINTEND(bugprone-unchecked-optional-access)
@@ -605,7 +546,7 @@ private:
auto const publisherPublic = derivePublicKey(KeyType::Ed25519, publisherSecret);
auto const hexPublic = strHex(publisherPublic.begin(), publisherPublic.end());
auto const pubSigningKeys1 = randomKeyPair(KeyType::Secp256k1);
auto const manifest1 = base64Encode(makeManifestString(
auto const manifest1 = base64Encode(makeManifest(
publisherPublic, publisherSecret, pubSigningKeys1.first, pubSigningKeys1.second, 1));
std::vector<std::string> const cfgPublisherKeys({strHex(publisherPublic)});
@@ -769,7 +710,7 @@ private:
ListDisposition::Invalid);
// do not use list from untrusted publisher
auto const untrustedManifest = base64Encode(makeManifestString(
auto const untrustedManifest = base64Encode(makeManifest(
randomMasterKey(), publisherSecret, pubSigningKeys1.first, pubSigningKeys1.second, 1));
checkResult(
@@ -834,7 +775,7 @@ private:
// apply list with new publisher key updated by manifest. Also send some
// old lists along with the old manifest
auto const pubSigningKeys2 = randomKeyPair(KeyType::Secp256k1);
auto manifest2 = base64Encode(makeManifestString(
auto manifest2 = base64Encode(makeManifest(
publisherPublic, publisherSecret, pubSigningKeys2.first, pubSigningKeys2.second, 2));
auto const sequence4 = 4;
@@ -956,7 +897,7 @@ private:
// do not apply list with revoked publisher key
// applied list is removed due to revoked publisher key
auto const signingKeysMax = randomKeyPair(KeyType::Secp256k1);
auto maxManifest = base64Encode(makeRevocationString(publisherPublic, publisherSecret));
auto maxManifest = base64Encode(makeRevocation(publisherPublic, publisherSecret));
auto const sequence9 = 9;
auto const blob9 = makeList(lists.at(9), sequence9, validUntil.time_since_epoch().count());
@@ -1004,7 +945,7 @@ private:
auto const publisherPublic = derivePublicKey(KeyType::Ed25519, publisherSecret);
auto const hexPublic = strHex(publisherPublic.begin(), publisherPublic.end());
auto const pubSigningKeys1 = randomKeyPair(KeyType::Secp256k1);
auto const manifest = base64Encode(makeManifestString(
auto const manifest = base64Encode(makeManifest(
publisherPublic, publisherSecret, pubSigningKeys1.first, pubSigningKeys1.second, 1));
std::vector<std::string> const cfgPublisherKeys({strHex(publisherPublic)});
@@ -1223,8 +1164,8 @@ private:
BEAST_EXPECT(!trustedKeysOuter->trusted(signingPublic1));
// Should trust the ephemeral signing key from the applied manifest
auto m1 = deserializeManifest(makeManifestString(
masterPublic, masterPrivate, signingPublic1, signingKeys1.second, 1));
auto m1 = deserializeManifest(
makeManifest(masterPublic, masterPrivate, signingPublic1, signingKeys1.second, 1));
BEAST_EXPECT(
// NOLINTNEXTLINE(bugprone-unchecked-optional-access)
@@ -1239,8 +1180,8 @@ private:
// from the newest applied manifest
auto const signingKeys2 = randomKeyPair(KeyType::Secp256k1);
auto const signingPublic2 = signingKeys2.first;
auto m2 = deserializeManifest(makeManifestString(
masterPublic, masterPrivate, signingPublic2, signingKeys2.second, 2));
auto m2 = deserializeManifest(
makeManifest(masterPublic, masterPrivate, signingPublic2, signingKeys2.second, 2));
BEAST_EXPECT(
// NOLINTNEXTLINE(bugprone-unchecked-optional-access)
manifestsOuter.applyManifest(std::move(*m2), ManifestRateLimitCapPolicy::Capped) ==
@@ -1256,7 +1197,7 @@ private:
auto const signingKeysMax = randomKeyPair(KeyType::Secp256k1);
auto const signingPublicMax = signingKeysMax.first;
activeValidatorsOuter.emplace(calcNodeID(signingPublicMax));
auto max = deserializeManifest(makeRevocationString(masterPublic, masterPrivate));
auto max = deserializeManifest(makeRevocation(masterPublic, masterPrivate));
// NOLINTBEGIN(bugprone-unchecked-optional-access)
BEAST_EXPECT(max->revoked());
@@ -1420,7 +1361,7 @@ private:
std::vector<std::string> const emptyCfgKeys;
auto const publisherKeys = randomKeyPair(KeyType::Secp256k1);
auto const pubSigningKeys = randomKeyPair(KeyType::Secp256k1);
auto const manifest = base64Encode(makeManifestString(
auto const manifest = base64Encode(makeManifest(
publisherKeys.first,
publisherKeys.second,
pubSigningKeys.first,
@@ -1626,7 +1567,7 @@ private:
auto const publisherSecret = randomSecretKey();
auto const publisherPublic = derivePublicKey(KeyType::Ed25519, publisherSecret);
auto const pubSigningKeys = randomKeyPair(KeyType::Secp256k1);
auto const manifest = base64Encode(makeManifestString(
auto const manifest = base64Encode(makeManifest(
publisherPublic,
publisherSecret,
pubSigningKeys.first,
@@ -1718,7 +1659,7 @@ private:
auto const publisherSecret = randomSecretKey();
auto const publisherPublic = derivePublicKey(KeyType::Ed25519, publisherSecret);
auto const pubSigningKeys = randomKeyPair(KeyType::Secp256k1);
auto const manifest = base64Encode(makeManifestString(
auto const manifest = base64Encode(makeManifest(
publisherPublic,
publisherSecret,
pubSigningKeys.first,
@@ -1935,7 +1876,7 @@ private:
auto const publisherSecret = randomSecretKey();
auto const publisherPublic = derivePublicKey(KeyType::Ed25519, publisherSecret);
auto const pubSigningKeys = randomKeyPair(KeyType::Secp256k1);
auto const manifest = base64Encode(makeManifestString(
auto const manifest = base64Encode(makeManifest(
publisherPublic,
publisherSecret,
pubSigningKeys.first,
@@ -2533,16 +2474,18 @@ private:
auto const pubSigningKeys = randomKeyPair(KeyType::Secp256k1);
cfgPublishers.push_back(strHex(publisherPublic));
constexpr auto kRevoked = std::numeric_limits<std::uint32_t>::max();
auto const manifest = base64Encode(makeManifestString(
publisherPublic,
publisherSecret,
pubSigningKeys.first,
pubSigningKeys.second,
i < countRevoked ? kRevoked : 1));
bool const revoked = i < countRevoked;
auto const manifest = base64Encode(
revoked ? makeRevocation(publisherPublic, publisherSecret)
: makeManifest(
publisherPublic,
publisherSecret,
pubSigningKeys.first,
pubSigningKeys.second,
1));
publishers.push_back(
Publisher{
.revoked = i < countRevoked,
.revoked = revoked,
.pubKey = publisherPublic,
.signingKeys = pubSigningKeys,
.manifest = manifest,

View File

@@ -7,14 +7,10 @@
#include <xrpl/basics/chrono.h>
#include <xrpl/basics/random.h>
#include <xrpl/basics/strHex.h>
#include <xrpl/protocol/HashPrefix.h>
#include <xrpl/protocol/KeyType.h>
#include <xrpl/protocol/PublicKey.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STObject.h>
#include <xrpl/protocol/SecretKey.h>
#include <xrpl/protocol/Serializer.h>
#include <xrpl/protocol/Sign.h>
#include <xrpl/server/Manifest.h>
#include <boost/asio.hpp>
#include <boost/asio/ip/tcp.hpp>
@@ -111,31 +107,6 @@ public:
std::string manifest;
};
static std::string
makeManifestString(
PublicKey const& pk,
SecretKey const& sk,
PublicKey const& spk,
SecretKey const& ssk,
int seq)
{
STObject st(sfGeneric);
st[sfSequence] = seq;
st[sfPublicKey] = pk;
st[sfSigningPubKey] = spk;
// NOLINTBEGIN(bugprone-unchecked-optional-access) publicKeyType returns value for valid
// keys
sign(st, HashPrefix::Manifest, *publicKeyType(spk), ssk);
sign(st, HashPrefix::Manifest, *publicKeyType(pk), sk, sfMasterSignature);
// NOLINTEND(bugprone-unchecked-optional-access)
Serializer s;
st.add(s);
return base64Encode(std::string(static_cast<char const*>(s.data()), s.size()));
}
static Validator
randomValidator()
{
@@ -145,8 +116,8 @@ public:
return {
.masterPublic = masterPublic,
.signingPublic = signingKeys.first,
.manifest =
makeManifestString(masterPublic, secret, signingKeys.first, signingKeys.second, 1)};
.manifest = base64Encode(
makeManifest(masterPublic, secret, signingKeys.first, signingKeys.second, 1))};
}
// TrustedPublisherServer must be accessed through a shared_ptr.
@@ -174,8 +145,8 @@ public:
, publisherPublic_{derivePublicKey(KeyType::Ed25519, publisherSecret_)}
{
auto const keys = randomKeyPair(KeyType::Secp256k1);
auto const manifest =
makeManifestString(publisherPublic_, publisherSecret_, keys.first, keys.second, 1);
auto const manifest = base64Encode(
makeManifest(publisherPublic_, publisherSecret_, keys.first, keys.second, 1));
std::vector<BlobInfo> blobInfo;
blobInfo.reserve(futures.size() + 1);

View File

@@ -26,14 +26,12 @@
#include <xrpl/protocol/HashPrefix.h>
#include <xrpl/protocol/KeyType.h>
#include <xrpl/protocol/LedgerHeader.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STObject.h>
#include <xrpl/protocol/SecretKey.h>
#include <xrpl/protocol/Seed.h>
#include <xrpl/protocol/Serializer.h>
#include <xrpl/protocol/Sign.h>
#include <xrpl/protocol/digest.h>
#include <xrpl/protocol/jss.h>
#include <xrpl/server/Manifest.h>
#include <xrpl/shamap/SHAMapNodeID.h>
#include <boost/asio/buffer.hpp>
@@ -140,21 +138,16 @@ public:
manifests->mutable_list()->Reserve(n);
for (int i = 0; i < n; i++)
{
auto master = randomKeyPair(KeyType::Ed25519);
auto signing = randomKeyPair(KeyType::Ed25519);
STObject st(sfGeneric);
st[sfSequence] = i;
st[sfPublicKey] = std::get<0>(master);
st[sfSigningPubKey] = std::get<0>(signing);
st[sfDomain] =
makeSlice(std::string("example") + std::to_string(i) + std::string(".com"));
sign(
st, HashPrefix::Manifest, KeyType::Ed25519, std::get<1>(master), sfMasterSignature);
sign(st, HashPrefix::Manifest, KeyType::Ed25519, std::get<1>(signing));
Serializer s;
st.add(s);
auto* manifest = manifests->add_list();
manifest->set_stobject(s.data(), s.size());
auto const master = randomKeyPair(KeyType::Ed25519);
auto const signing = randomKeyPair(KeyType::Ed25519);
auto const manifest = makeManifest(
master.first,
master.second,
signing.first,
signing.second,
i,
"example" + std::to_string(i) + ".com");
manifests->add_list()->set_stobject(manifest.data(), manifest.size());
}
return manifests;
}
@@ -297,26 +290,16 @@ public:
{
auto list = std::make_shared<protocol::TMValidatorListCollection>();
auto master = randomKeyPair(KeyType::Ed25519);
auto signing = randomKeyPair(KeyType::Ed25519);
STObject st(sfGeneric);
st[sfSequence] = 0;
st[sfPublicKey] = std::get<0>(master);
st[sfSigningPubKey] = std::get<0>(signing);
st[sfDomain] = makeSlice(std::string("example.com"));
sign(st, HashPrefix::Manifest, KeyType::Ed25519, std::get<1>(master), sfMasterSignature);
sign(st, HashPrefix::Manifest, KeyType::Ed25519, std::get<1>(signing));
Serializer s;
st.add(s);
list->set_manifest(s.data(), s.size());
auto const master = randomKeyPair(KeyType::Ed25519);
auto const signing = randomKeyPair(KeyType::Ed25519);
auto const manifest = makeManifest(
master.first, master.second, signing.first, signing.second, 0, "example.com");
list->set_manifest(manifest.data(), manifest.size());
list->set_version(4);
STObject const signature(sfSignature);
xrpl::sign(st, HashPrefix::Manifest, KeyType::Ed25519, std::get<1>(signing));
Serializer s1;
st.add(s1);
// Bytes to compress, not a list a server would verify.
auto& blob = *list->add_blobs();
blob.set_signature(s1.data(), s1.size());
blob.set_blob(strHex(s.slice()));
blob.set_signature(manifest.data(), manifest.size());
blob.set_blob(strHex(makeSlice(manifest)));
return list;
}

View File

@@ -0,0 +1,25 @@
find_package(GTest REQUIRED)
add_executable(
validator_keys_tests
Commands.cpp
Fixtures.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)

View File

@@ -0,0 +1,764 @@
#include <tools/validator-keys/Commands.h>
#include <xrpl/basics/FileUtilities.h>
#include <xrpl/basics/Slice.h>
#include <xrpl/basics/StringUtilities.h>
#include <xrpl/basics/base64.h>
#include <xrpl/basics/strHex.h>
#include <xrpl/json/json_reader.h>
#include <xrpl/json/json_value.h>
#include <xrpl/protocol/KeyType.h>
#include <xrpl/protocol/PublicKey.h>
#include <xrpl/protocol/SecretKey.h>
#include <xrpl/protocol/Seed.h>
#include <xrpl/protocol/jss.h>
#include <xrpl/protocol/tokens.h>
#include <xrpl/server/Manifest.h>
#include <gtest/gtest.h>
#include <sys/file.h>
#include <tools/validator-keys/ListSigning.h>
#include <tools/validator-keys/SigningKeys.h>
#include <Fixtures.h>
#include <dirent.h>
#include <chrono>
#include <cstdint>
#include <cstdlib>
#include <exception>
#include <filesystem>
#include <future>
#include <ios>
#include <limits>
#include <sstream>
#include <stdexcept>
#include <string>
#include <string_view>
#include <thread>
#include <vector>
namespace xrpl::tools::test {
namespace {
constexpr std::string_view kArgError = "Syntax error: Wrong number of arguments";
constexpr std::string_view kRevokedOperation =
"Operation error: The specified master key has been revoked!";
constexpr std::string_view 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"));
[[nodiscard]] std::filesystem::path
file(std::string const& name) const
{
return dir_.file(name);
}
[[nodiscard]] static std::string
commandError(
std::string const& command,
std::vector<std::string> const& args,
ToolOptions const& opts)
{
return errorOf([&] { run(command, args, opts); });
}
[[nodiscard]] static SigningKeys
keys(ToolOptions const& opts)
{
return SigningKeys::makeSigningKeys(opts.keyFile);
}
// The base64 inside a [validator_token] or [validator_key_revocation] 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;
}
};
// An exclusive flock on a directory, the lock a command updating a key file
// in that directory waits for; closing the directory releases it.
class HeldDirectory
{
DIR* dir_;
public:
explicit HeldDirectory(std::filesystem::path const& dir) : dir_(::opendir(dir.c_str()))
{
if (dir_ == nullptr || ::flock(::dirfd(dir_), LOCK_EX) != 0)
{
// The destructor does not run when the constructor throws.
release();
throw std::runtime_error("cannot lock " + dir.string());
}
}
~HeldDirectory()
{
release();
}
HeldDirectory(HeldDirectory const&) = delete;
HeldDirectory&
operator=(HeldDirectory const&) = delete;
void
release()
{
if (dir_ != nullptr)
::closedir(dir_);
dir_ = nullptr;
}
};
} // 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);
}
TEST_F(CommandsTest, parse_public_key)
{
SigningKeys const external(KeyType::Ed25519);
auto const& key = external.publicKey();
EXPECT_EQ(parsePublicKey(toBase58(TokenType::NodePublic, key)), key);
EXPECT_EQ(parsePublicKey(strHex(key)), key);
EXPECT_EQ(parsePublicKey(base64Encode(key.data(), key.size())), key);
EXPECT_EQ(errorOf([] { parsePublicKey("abcd"); }), "Unable to parse public key: abcd");
}
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 const 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);
auto const trailing = base64Encode(key.data(), key.size()) + "!";
EXPECT_EQ(
commandError("create_external", {trailing}, options_),
"Unable to parse public key: " + trailing);
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());
// A missing directory is reported as the missing key file, not as a lock failure
auto const inMissingDir = file("missing") / "k.json";
EXPECT_EQ(
commandError("create_token", {}, optionsFor(inMissingDir)),
"Failed to open key file: " + inMissingDir.string());
run("create_keys", {}, options_);
auto const r = run("create_token", {}, options_);
EXPECT_EQ(r.rc, EXIT_SUCCESS);
auto const token = required(loadValidatorToken({blockBody(r.out, "validator_token")}));
auto const m = required(deserializeManifest(base64Decode(token.manifest)));
EXPECT_EQ(m.sequence, 1u);
EXPECT_EQ(required(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);
auto const writtenManifest = required(deserializeManifest(base64Decode(written.manifest)));
EXPECT_EQ(
required(writtenManifest.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);
// A symlink is not written through
ToolOptions linked = options_;
linked.outFile = file("link.txt");
std::filesystem::create_symlink(file("elsewhere.txt"), *linked.outFile);
EXPECT_EQ(
commandError("create_token", {}, linked),
"Refusing to write through a symlink: " + linked.outFile->string());
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 write output file: " + unwritable.outFile->string());
EXPECT_EQ(keys(options_).sequence(), 2u);
// The output may not replace the key file
ToolOptions aliased = options_;
aliased.outFile = options_.keyFile;
EXPECT_EQ(
commandError("create_token", {}, aliased),
"--out names an input file: " + options_.keyFile.string());
EXPECT_EQ(keys(options_).sequence(), 2u);
{
auto const kp = generateKeyPair(KeyType::Ed25519, randomSeed());
SigningKeys(KeyType::Ed25519, kp.second, std::numeric_limits<std::uint32_t>::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 = required(strUnHex(sig));
auto const encoded = (encode != 0) ? base64Encode(sigBytes.data(), sigBytes.size()) : sig;
auto const finish = run("finish_token", {encoded}, options_);
EXPECT_EQ(finish.rc, EXIT_SUCCESS);
auto const token = required(loadValidatorToken({blockBody(finish.out, "validator_token")}));
auto const m = required(deserializeManifest(base64Decode(token.manifest)));
EXPECT_EQ(m.sequence, encode ? 2u : 1u);
}
EXPECT_EQ(
commandError("finish_token", {"bad signature"}, options_), "Invalid signature encoding");
auto const pending = run("start_token", {}, options_);
EXPECT_EQ(
commandError("finish_token", {signer.sign("foo")}, options_),
"Manifest is not properly signed");
EXPECT_EQ(keys(options_).sequence(), 2u);
// The token stays pending: start_token prints the same bytes again, and a
// start with another signing key is refused until it is finished
std::string const pendingError =
"A token is pending: finish it with finish_token before starting another";
EXPECT_EQ(run("start_token", {}, options_).out, pending.out);
SigningKeys const signingKey(KeyType::Ed25519);
ToolOptions both = options_;
both.signingKey = signingKey.publicKey();
both.outFile = file("manifest.txt");
EXPECT_EQ(commandError("start_token", {}, both), pendingError);
auto const pendingBytes = pending.out.substr(0, pending.out.find('\n'));
EXPECT_EQ(run("finish_token", {signer.signHex(pendingBytes)}, options_).rc, EXIT_SUCCESS);
EXPECT_EQ(keys(options_).sequence(), 3u);
// An external signing key too: two signatures, and a manifest without a
// secret printed as base64 for a list publisher's --manifest-file
ToolOptions toStdout = both;
toStdout.outFile.reset();
auto const start = run("start_token", {}, toStdout);
EXPECT_EQ(run("start_token", {}, toStdout).out, start.out);
EXPECT_EQ(commandError("start_token", {}, options_), pendingError);
auto const bytes = start.out.substr(0, start.out.find('\n'));
EXPECT_EQ(
commandError("finish_token", {signer.signHex(bytes)}, toStdout),
"The pending token's signing key is external; pass its signature too");
auto const finish =
run("finish_token", {signer.signHex(bytes), signingKey.signHex(bytes)}, toStdout);
EXPECT_EQ(finish.rc, EXIT_SUCCESS);
EXPECT_NE(finish.out.find("--manifest-file"), std::string::npos);
EXPECT_EQ(finish.out.find('['), std::string::npos);
{
auto const lastLine = finish.out.substr(finish.out.rfind('\n', finish.out.size() - 2) + 1);
auto const printed =
required(deserializeManifest(base64Decode(lastLine.substr(0, lastLine.find('\n')))));
EXPECT_EQ(required(printed.signingKey), signingKey.publicKey());
EXPECT_EQ(printed.sequence, 4u);
}
// The current manifest's signing key is refused for the next token; with
// another key the manifest goes to --out as the base64 alone
EXPECT_EQ(
commandError("start_token", {}, both),
"The signing key is the current manifest's; a server rejects a manifest that reuses it");
SigningKeys const signingKey2(KeyType::Ed25519);
both.signingKey = signingKey2.publicKey();
auto const start2 = run("start_token", {}, both);
auto const bytes2 = start2.out.substr(0, start2.out.find('\n'));
EXPECT_NE(
run("finish_token", {signer.signHex(bytes2), signingKey2.signHex(bytes2)}, both)
.out.find("The manifest written to"),
std::string::npos);
auto const manifest = loadManifestFile(*both.outFile);
EXPECT_EQ(required(manifest.signingKey), signingKey2.publicKey());
EXPECT_EQ(manifest.sequence, 5u);
EXPECT_EQ(readFile(*both.outFile), base64Encode(manifest.serialized) + "\n");
// 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'));
// The pending manifest is fixed, so its domain cannot change before finish_token
EXPECT_EQ(
commandError("set_domain", {"other.example.com"}, options_),
"A token is pending: finish it with finish_token before changing the domain");
EXPECT_EQ(keys(options_).domain(), "validator.example.com");
auto const finished = run("finish_token", {signer.signHex(nextBytes)}, options_);
auto const token = required(loadValidatorToken({blockBody(finished.out, "validator_token")}));
auto const m = required(deserializeManifest(base64Decode(token.manifest)));
EXPECT_EQ(m.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());
ToolOptions toFile = options_;
toFile.outFile = file("revocation.txt");
auto const revoked = run("finish_revoke_keys", {signer.signHex(revokeBytes)}, toFile);
EXPECT_NE(revoked.out.find("written to"), std::string::npos);
EXPECT_NE(readFile(*toFile.outFile).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, output_failure)
{
run("create_keys", {}, options_);
std::ostringstream out;
out.setstate(std::ios::badbit);
std::ostringstream err;
EXPECT_EQ(
errorOf([&] { runCommand("create_token", {}, options_, out, err); }),
"Cannot write the output");
}
TEST_F(CommandsTest, key_file_lock)
{
run("create_keys", {}, options_);
std::promise<int> promise;
auto result = promise.get_future();
// Declared before the lock so unwinding on a failed expectation releases
// the lock before the thread is joined.
std::jthread worker;
HeldDirectory held(options_.keyFile.parent_path());
worker = std::jthread([&] {
try
{
promise.set_value(run("create_token", {}, options_).rc);
}
catch (...)
{
promise.set_exception(std::current_exception());
}
});
// Blocked before loading the key file while the directory lock is held
EXPECT_EQ(result.wait_for(std::chrono::milliseconds(300)), std::future_status::timeout);
EXPECT_EQ(keys(options_).sequence(), 0u);
held.release();
worker.join();
EXPECT_EQ(result.get(), EXIT_SUCCESS);
EXPECT_EQ(keys(options_).sequence(), 1u);
}
TEST_F(CommandsTest, default_key_file)
{
std::filesystem::path const home = dir_.path();
auto const keyFile = home / ".xrpld" / "validator-keys.json";
auto const legacy = home / ".ripple" / "validator-keys.json";
EXPECT_EQ(defaultKeyFile(home), keyFile);
std::filesystem::create_directories(legacy.parent_path());
writeFile(legacy, "{}");
EXPECT_EQ(defaultKeyFile(home), legacy);
std::filesystem::create_directories(keyFile.parent_path());
writeFile(keyFile, "{}");
EXPECT_EQ(defaultKeyFile(home), keyFile);
}
TEST_F(CommandsTest, revoke_keys)
{
run("create_keys", {}, options_);
// An unwritable output path fails before the keys are revoked
ToolOptions unwritable = options_;
unwritable.outFile = file("missing/revocation.txt");
EXPECT_EQ(
commandError("revoke_keys", {}, unwritable),
"Cannot write output file: " + unwritable.outFile->string());
EXPECT_FALSE(keys(options_).revoked());
// The revocation goes to the output file
ToolOptions toFile = options_;
toFile.outFile = file("revocation.txt");
auto const first = run("revoke_keys", {}, toFile);
EXPECT_NE(first.err.find("This will revoke"), std::string::npos);
EXPECT_NE(first.out.find("written to"), std::string::npos);
EXPECT_NE(readFile(*toFile.outFile).find("[validator_key_revocation]"), std::string::npos);
EXPECT_TRUE(keys(options_).revoked());
auto const again = run("revoke_keys", {}, options_);
EXPECT_NE(again.err.find("already been revoked"), std::string::npos);
EXPECT_NE(again.out.find("[validator_key_revocation]"), 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", {}, 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<std::uint32_t>::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();
// Three lists with rising sequences, over the same validators
auto const validators = makeValidators(2);
auto const now = netClockNow();
auto const unsignedList = file("unsigned.json");
auto const unsignedList2 = file("unsigned2.json");
auto const unsignedList3 = file("unsigned3.json");
writeFile(unsignedList, unsignedListText(validators, 2026091301, now + 3600));
writeFile(unsignedList2, unsignedListText(validators, 2026091302, now + 3600));
writeFile(unsignedList3, unsignedListText(validators, 2026091303, 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 readable by everyone, 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);
EXPECT_EQ(
std::filesystem::status(*signer.outFile).permissions() &
std::filesystem::perms::others_read,
std::filesystem::perms::others_read);
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 const 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, the next list appended in place, then another appended 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;
EXPECT_EQ(run("sign_list", {unsignedList2.string()}, v2).rc, EXIT_SUCCESS);
{
auto const r = run("verify_list", {v2.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["blobs"].size(), 2u);
}
{
ToolOptions rotated = v2;
rotated.appendFile.reset();
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", {unsignedList3.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 write output file: " + bad.outFile->string());
// The output may not replace the list it signs
bad.outFile = unsignedList;
EXPECT_EQ(
commandError("sign_list", {unsignedList.string()}, bad),
"--out names an input file: " + unsignedList.string());
// A failed command leaves an existing output as it was
bad.outFile = file("kept.json");
writeFile(*bad.outFile, "previous");
EXPECT_EQ(
commandError("sign_list", {file("missing.json").string()}, bad),
"Failed to open file: " + file("missing.json").string());
EXPECT_EQ(readFile(*bad.outFile), "previous");
EXPECT_FALSE(std::filesystem::exists(file("kept.json.tmp")));
}
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{.manifest = "AAAA", .validationSecret = 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{
.manifest = loadTokenFile(*publisher.tokenFile).manifest,
.validationSecret = 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 const 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 validators = makeValidators(2);
auto const now = netClockNow();
auto const unsignedList = file("unsigned.json");
auto const unsignedList2 = file("unsigned2.json");
auto const unsignedList3 = file("unsigned3.json");
writeFile(unsignedList, unsignedListText(validators, 2026091301, now + 3600));
writeFile(unsignedList2, unsignedListText(validators, 2026091302, now + 3600));
writeFile(unsignedList3, unsignedListText(validators, 2026091303, now + 3600));
auto const signExternally = [&](std::filesystem::path const& list) {
return external.signHex(strHex(makeSlice(loadUnsignedList(list).canonical)));
};
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", {signExternally(unsignedList2), unsignedList2.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", {unsignedList3.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

View File

@@ -0,0 +1,120 @@
#include <Fixtures.h>
#include <xrpl/basics/FileUtilities.h>
#include <xrpl/basics/base64.h>
#include <xrpl/basics/strHex.h>
#include <xrpl/protocol/KeyType.h>
#include <xrpl/server/Manifest.h>
#include <gtest/gtest.h>
#include <tools/validator-keys/Commands.h>
#include <tools/validator-keys/SigningKeys.h>
#include <cstddef>
#include <cstdint>
#include <filesystem>
#include <format>
#include <functional>
#include <optional>
#include <sstream>
#include <stdexcept>
#include <string>
#include <system_error>
#include <vector>
namespace xrpl::tools::test {
Run
run(std::string const& command, std::vector<std::string> const& args, ToolOptions const& options)
{
std::ostringstream out;
std::ostringstream err;
int const rc = runCommand(command, args, options, out, err);
return {.rc = rc, .out = out.str(), .err = err.str()};
}
std::string
errorOf(std::function<void()> const& f)
{
try
{
f();
}
catch (std::runtime_error const& e)
{
return e.what();
}
return {};
}
ToolOptions
optionsFor(std::filesystem::path const& keyFile)
{
ToolOptions options;
options.keyFile = keyFile;
return options;
}
void
writeFile(std::filesystem::path const& file, std::string const& text)
{
std::error_code ec;
writeFileContents(ec, file, text);
ASSERT_FALSE(ec) << file;
}
std::string
readFile(std::filesystem::path const& file)
{
std::error_code ec;
auto const text = getFileContents(ec, file);
EXPECT_FALSE(ec) << file;
return text;
}
Publisher::Publisher()
: token(keys.createToken(KeyType::Ed25519))
, manifest(required(deserializeManifest(base64Decode(token.manifest))))
, signingKey(required(manifest.signingKey))
{
}
std::vector<ValidatorToken>
makeValidators(std::size_t count)
{
std::vector<ValidatorToken> validators;
for (std::size_t i = 0; i < count; ++i)
{
SigningKeys keys(KeyType::Ed25519);
validators.push_back(keys.createToken(KeyType::Secp256k1));
}
return validators;
}
std::string
unsignedListText(
std::vector<ValidatorToken> const& validators,
std::uint32_t sequence,
std::uint32_t expiration,
std::optional<std::uint32_t> effective)
{
std::string text = std::format("{{\n \"sequence\": {}", sequence);
if (effective)
text += std::format(",\n \"effective\": {}", *effective);
text += std::format(",\n \"expiration\": {},\n \"validators\": [", expiration);
char const* separator = "\n";
for (auto const& v : validators)
{
auto const m = required(deserializeManifest(base64Decode(v.manifest)));
text += std::format(
R"({} {{"validation_public_key": "{}", "manifest": "{}"}})",
separator,
strHex(m.masterKey),
v.manifest);
separator = ",\n";
}
text += "\n ]\n}\n";
return text;
}
} // namespace xrpl::tools::test

View File

@@ -0,0 +1,89 @@
#pragma once
#include <xrpl/protocol/PublicKey.h>
#include <xrpl/server/Manifest.h>
#include <tools/validator-keys/Commands.h>
#include <tools/validator-keys/SigningKeys.h>
#include <cstddef>
#include <cstdint>
#include <filesystem>
#include <functional>
#include <optional>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>
namespace xrpl::tools::test {
/**
* What one command run produced.
*/
struct Run
{
int rc;
std::string out;
std::string err;
};
Run
run(std::string const& command, std::vector<std::string> const& args, ToolOptions const& options);
/**
* The message of the std::runtime_error @p f throws, or an empty string.
*/
std::string
errorOf(std::function<void()> const& f);
ToolOptions
optionsFor(std::filesystem::path const& keyFile);
void
writeFile(std::filesystem::path const& file, std::string const& text);
std::string
readFile(std::filesystem::path const& file);
/**
* The value a test relies on being present; an empty optional fails the test
* with an exception at the line that expected it.
*/
template <class T>
T
required(std::optional<T> value)
{
if (!value)
throw std::runtime_error("required value is missing");
return std::move(*value);
}
/**
* A publisher: master keys and the token carrying its ed25519 signing key.
*/
struct Publisher
{
SigningKeys keys{KeyType::Ed25519};
ValidatorToken token;
Manifest manifest;
PublicKey signingKey;
Publisher();
};
std::vector<ValidatorToken>
makeValidators(std::size_t count);
/**
* The unsigned list text a publisher's prepare step writes: one validator per
* token, each with its manifest.
*/
std::string
unsignedListText(
std::vector<ValidatorToken> const& validators,
std::uint32_t sequence,
std::uint32_t expiration,
std::optional<std::uint32_t> effective = std::nullopt);
} // namespace xrpl::tools::test

View File

@@ -0,0 +1,931 @@
#include <tools/validator-keys/ListSigning.h>
#include <xrpl/basics/FileUtilities.h>
#include <xrpl/basics/Slice.h>
#include <xrpl/basics/base64.h>
#include <xrpl/basics/strHex.h>
#include <xrpl/json/json_value.h>
#include <xrpl/json/to_string.h>
#include <xrpl/protocol/KeyType.h>
#include <xrpl/protocol/PublicKey.h>
#include <xrpl/protocol/SecretKey.h>
#include <xrpl/protocol/jss.h>
#include <xrpl/protocol/tokens.h>
#include <xrpl/server/Manifest.h>
#include <gtest/gtest.h>
#include <tools/validator-keys/SigningKeys.h>
#include <Fixtures.h>
#include <cstddef>
#include <cstdint>
#include <filesystem>
#include <optional>
#include <string>
#include <utility>
#include <vector>
namespace xrpl::tools::test {
namespace {
std::string const kNotObject = "Not a JSON object";
class ListSigningTest : public ::testing::Test
{
protected:
Publisher const publisher_;
std::vector<ValidatorToken> 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_.signingKey, publisher_.token.validationSecret);
[[nodiscard]] json::Value
signed1() const
{
return makeSignedList(
publisher_.token.manifest,
publisher_.manifest.masterKey,
list_,
signature_,
1,
std::nullopt,
{});
}
[[nodiscard]] 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<UnsignedList> const& roster = std::nullopt,
std::optional<PublicKey> const& key = std::nullopt,
std::optional<std::uint32_t> 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\"}");
// Whitespace or a comment inside a value is not dropped into one value
std::string const runTogether = "Values run together";
EXPECT_EQ(canonicalJson("{\"a\": 1 /*c*/ , \"b\": 2}"), "{\"a\": 1, \"b\": 2}");
EXPECT_EQ(errorOf([] { canonicalJson("{\"a\": 1/*c*/2}"); }), runTogether);
EXPECT_EQ(errorOf([] { canonicalJson("{\"a\": 1 2}"); }), runTogether);
EXPECT_EQ(errorOf([] { canonicalJson("{\"a\": tr ue}"); }), runTogether);
EXPECT_EQ(errorOf([] { canonicalJson("{\"a\": 1 .5}"); }), runTogether);
EXPECT_EQ(errorOf([] { canonicalJson("{\"a\": 1 // c\n2}"); }), runTogether);
EXPECT_EQ(errorOf([] { canonicalJson("[1, 2]"); }), kNotObject);
EXPECT_EQ(errorOf([] { canonicalJson("{\"a\": "); }), kNotObject);
EXPECT_EQ(errorOf([] { canonicalJson("{\"a\": 1 /* open"); }), "Unterminated comment");
EXPECT_EQ(errorOf([] { canonicalJson("{\"a\": 1} /* open"); }), "Unterminated comment");
// Only whitespace and comments may follow the object
std::string const trailing = "Content after the JSON object";
EXPECT_EQ(errorOf([] { canonicalJson("{\"a\": 1} // note"); }), "");
// A line comment ends at CR as well as LF
EXPECT_EQ(canonicalJson("{\"a\": 1 // note\r}"), "{\"a\": 1}");
EXPECT_EQ(errorOf([] { canonicalJson("{\"a\": 1} // note\rx"); }), trailing);
EXPECT_EQ(errorOf([] { canonicalJson("{\"a\": 1} /* note */"); }), "");
EXPECT_EQ(errorOf([] { canonicalJson("{\"a\": 1} \n"); }), "");
EXPECT_EQ(errorOf([] { canonicalJson("{\"a\": 1} x"); }), trailing);
EXPECT_EQ(errorOf([] { canonicalJson("{\"a\": 1} \"x"); }), trailing);
EXPECT_EQ(errorOf([] { canonicalJson("{\"a\": 1} /"); }), trailing);
EXPECT_EQ(errorOf([] { canonicalJson("{\"a\": 1} {\"b\": 2}"); }), trailing);
EXPECT_EQ(errorOf([] { canonicalJson("{\"a\": 1} ]"); }), trailing);
EXPECT_EQ(errorOf([] { canonicalJson("{\"a\": [1]} 2"); }), trailing);
}
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);
EXPECT_EQ(l.effective, 500u);
EXPECT_EQ(l.validators.size(), 3u);
EXPECT_EQ(
l.validators[0],
required(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 1 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, \"expiration\": 0, \"validators\": []}"), expirationError);
{
auto twice = validators_;
twice.push_back(validators_[0]);
auto const key =
strHex(required(deserializeManifest(base64Decode(validators_[0].manifest))).masterKey);
EXPECT_EQ(
errorOfParse(unsignedListText(twice, 1, 1000)),
"\"validators\" lists " + key + " more than once");
}
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(required(deserializeManifest(base64Decode(validators_[0].manifest))).masterKey);
auto const other =
strHex(required(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 + R"(", "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 const 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_.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_.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 = required(deserializeManifest(base64Decode(token2.manifest)));
auto const signingKey2 = required(manifest2.signingKey);
auto const resign = [&](std::string const& bytes) {
return strHex(sign(signingKey2, 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(signingKey2));
}
// Appending under the older token would downgrade the published manifest
auto const resignOld = [&](std::string const& bytes) {
return strHex(
sign(publisher_.signingKey, publisher_.token.validationSecret, makeSlice(bytes)));
};
EXPECT_EQ(
errorOf([&] {
makeSignedList(
publisher_.token.manifest,
publisher_.manifest.masterKey,
third,
resignOld(third.canonical),
2,
v2c,
resignOld);
}),
"The list to append to was signed under manifest sequence 2; append with a token whose "
"manifest sequence is higher");
// 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, append_checks_entries)
{
auto const v2 = signed2();
std::string const invalidBlob = "The list to append to holds an invalid blob";
std::string const invalidManifest = "The list to append to holds an invalid manifest";
std::string const badSignature =
"The list to append to holds a blob whose signature does not verify";
auto const later = parseUnsignedList(unsignedListText(validators_, 8, now_ + 300));
auto const laterSig = signList(later, publisher_.signingKey, publisher_.token.validationSecret);
auto const append = [&](json::Value const& existing) {
return makeSignedList(
publisher_.token.manifest,
publisher_.manifest.masterKey,
later,
laterSig,
2,
existing,
{});
};
auto const appendError = [&](json::Value const& existing) {
return errorOf([&] { append(existing); });
};
// Malformed entries
{
auto doc = v2;
doc[jss::blobs_v2][0u].removeMember(jss::signature);
EXPECT_EQ(appendError(doc), invalidBlob);
}
{
auto doc = v2;
doc[jss::blobs_v2][0u][jss::blob] = "not base64!";
EXPECT_EQ(appendError(doc), invalidBlob);
}
{
// Not canonical: the decoder drops the extra padding
auto doc = v2;
doc[jss::blobs_v2][0u][jss::blob] = v2[jss::blobs_v2][0u][jss::blob].asString() + "=";
EXPECT_EQ(appendError(doc), invalidBlob);
}
{
auto doc = v2;
doc[jss::blobs_v2][0u][jss::signature] = "zz";
EXPECT_EQ(appendError(doc), invalidBlob);
}
{
auto doc = v2;
doc[jss::blobs_v2][0u][jss::manifest] = 5;
EXPECT_EQ(appendError(doc), invalidManifest);
}
{
auto doc = v2;
doc[jss::blobs_v2][0u][jss::manifest] = "AAAA";
EXPECT_EQ(appendError(doc), invalidManifest);
}
{
auto doc = v2;
doc[jss::manifest] = "AAAA";
EXPECT_EQ(appendError(doc), invalidManifest);
}
// A signature or a blob changed under the same manifest
{
auto doc = v2;
doc[jss::blobs_v2][0u][jss::signature] = laterSig;
EXPECT_EQ(appendError(doc), badSignature);
}
{
auto doc = v2;
doc[jss::blobs_v2][0u][jss::blob] = base64Encode(later.canonical);
EXPECT_EQ(appendError(doc), badSignature);
}
// An entry carrying the token's manifest is verified and copied as it is
{
auto doc = v2;
doc[jss::blobs_v2][0u][jss::manifest] = publisher_.token.manifest;
auto const out = append(doc);
EXPECT_EQ(out[jss::blobs_v2].size(), 2u);
EXPECT_EQ(out[jss::blobs_v2][0u][jss::signature].asString(), signature_);
EXPECT_EQ(out[jss::blobs_v2][0u][jss::manifest].asString(), publisher_.token.manifest);
expectOk(out);
}
}
TEST_F(ListSigningTest, append_requires_higher_sequence)
{
// A server ignores a blob whose sequence is not above the one it holds,
// so a list at or below any existing blob's sequence is refused.
auto const appendError = [&](UnsignedList const& list, json::Value const& existing) {
return errorOf([&] {
makeSignedList(
publisher_.token.manifest,
publisher_.manifest.masterKey,
list,
signList(list, publisher_.signingKey, publisher_.token.validationSecret),
2,
existing,
{});
});
};
auto const v2 = signed2();
EXPECT_EQ(
appendError(list_, v2),
"The list to append has sequence 7; the document already holds sequence 7");
auto const earlier = parseUnsignedList(unsignedListText(validators_, 6, now_ + 100));
EXPECT_EQ(
appendError(earlier, v2),
"The list to append has sequence 6; the document already holds sequence 7");
// The highest sequence counts, wherever it is in the document
auto const later = parseUnsignedList(unsignedListText(validators_, 9, now_ + 300));
auto const laterSig = signList(later, publisher_.signingKey, publisher_.token.validationSecret);
auto doc = makeSignedList(
publisher_.token.manifest, publisher_.manifest.masterKey, later, laterSig, 2, v2, {});
std::swap(doc[jss::blobs_v2][0u], doc[jss::blobs_v2][1u]);
auto const between = parseUnsignedList(unsignedListText(validators_, 8, now_ + 200));
EXPECT_EQ(
appendError(between, doc),
"The list to append has sequence 8; the document already holds sequence 9");
auto const tenth = parseUnsignedList(unsignedListText(validators_, 10, now_ + 400));
EXPECT_EQ(appendError(tenth, doc), "");
// A blob that is not a list has no sequence to compare
std::string const notAList = "[]";
auto broken = v2;
broken[jss::blobs_v2][0u][jss::blob] = base64Encode(notAList);
broken[jss::blobs_v2][0u][jss::signature] =
strHex(sign(publisher_.signingKey, publisher_.token.validationSecret, makeSlice(notAList)));
EXPECT_EQ(
appendError(tenth, broken),
"The list to append to holds an invalid blob: Not a JSON object");
// A signed blob with a sequence but no expiration or validators fails the
// checks an unsigned list gets, so its sequence never counts
std::string const bareSequence = R"({"sequence": 999})";
auto bare = v2;
bare[jss::blobs_v2][0u][jss::blob] = base64Encode(bareSequence);
bare[jss::blobs_v2][0u][jss::signature] = strHex(
sign(publisher_.signingKey, publisher_.token.validationSecret, makeSlice(bareSequence)));
EXPECT_EQ(
appendError(tenth, bare),
"The list to append to holds an invalid blob: \"expiration\" must be an integer from 1 to "
"2147483647");
}
TEST_F(ListSigningTest, append_reads_entry_manifests)
{
auto const v2 = signed2();
SigningKeys rotated = publisher_.keys;
auto const token2 = rotated.createToken(KeyType::Ed25519);
auto const manifest2 = required(deserializeManifest(base64Decode(token2.manifest)));
auto const signingKey2 = required(manifest2.signingKey);
auto const resign2 = [&](std::string const& bytes) {
return strHex(sign(signingKey2, token2.validationSecret, makeSlice(bytes)));
};
auto const later = parseUnsignedList(unsignedListText(validators_, 8, now_ + 300));
auto const third = parseUnsignedList(unsignedListText(validators_, 9, now_ + 400));
std::string const needsResign =
"The list to append to was signed under another manifest and its blobs need signing "
"again";
// A server emitted this document under the first manifest and added a blob
// carrying the second: the first token is refused, the second re-signs the
// blob whose effective manifest is the older top-level one.
{
auto doc = v2;
json::Value entry(json::ValueType::Object);
entry[jss::blob] = base64Encode(later.canonical);
entry[jss::signature] = resign2(later.canonical);
entry[jss::manifest] = token2.manifest;
doc[jss::blobs_v2].append(entry);
EXPECT_EQ(
errorOf([&] {
makeSignedList(
publisher_.token.manifest,
publisher_.manifest.masterKey,
third,
signList(third, publisher_.signingKey, publisher_.token.validationSecret),
2,
doc,
{});
}),
"The list to append to was signed under manifest sequence 2; append with a token "
"whose manifest sequence is higher");
EXPECT_EQ(
errorOf([&] {
makeSignedList(
token2.manifest,
manifest2.masterKey,
third,
resign2(third.canonical),
2,
doc,
{});
}),
needsResign);
auto const out = makeSignedList(
token2.manifest, manifest2.masterKey, third, resign2(third.canonical), 2, doc, resign2);
EXPECT_EQ(out[jss::blobs_v2].size(), 3u);
EXPECT_NE(out[jss::blobs_v2][0u][jss::signature].asString(), signature_);
EXPECT_FALSE(out[jss::blobs_v2][1u].isMember(jss::manifest));
auto const report = verifyList(out, std::nullopt, std::nullopt, now_);
EXPECT_TRUE(report["ok"].asBool()) << to_string(report);
EXPECT_EQ(report["manifest_sequence"].asUInt(), 2u);
}
// The top-level manifest is the token's but one blob still carries the
// older manifest it was signed under: every blob is signed again.
{
json::Value doc(json::ValueType::Object);
doc[jss::version] = 2;
doc[jss::public_key] = strHex(manifest2.masterKey);
doc[jss::manifest] = token2.manifest;
doc[jss::blobs_v2] = json::Value(json::ValueType::Array);
auto first = v2[jss::blobs_v2][0u];
first[jss::manifest] = publisher_.token.manifest;
doc[jss::blobs_v2].append(first);
json::Value second(json::ValueType::Object);
second[jss::blob] = base64Encode(later.canonical);
second[jss::signature] = resign2(later.canonical);
doc[jss::blobs_v2].append(second);
EXPECT_EQ(
errorOf([&] {
makeSignedList(
token2.manifest,
manifest2.masterKey,
third,
resign2(third.canonical),
2,
doc,
{});
}),
needsResign);
auto const out = makeSignedList(
token2.manifest, manifest2.masterKey, third, resign2(third.canonical), 2, doc, resign2);
EXPECT_EQ(out[jss::blobs_v2].size(), 3u);
EXPECT_NE(out[jss::blobs_v2][0u][jss::signature].asString(), signature_);
EXPECT_FALSE(out[jss::blobs_v2][0u].isMember(jss::manifest));
auto const report = verifyList(out, std::nullopt, std::nullopt, now_);
EXPECT_TRUE(report["ok"].asBool()) << to_string(report);
EXPECT_EQ(report["manifest_sequence"].asUInt(), 2u);
}
}
TEST_F(ListSigningTest, append_verifies_blobs_before_resigning)
{
// Two blobs signed under the first manifest, the second one's signature
// swapped for the first's: every server refuses it today, and the rotated
// token must not sign it again under the new key.
auto const later = parseUnsignedList(unsignedListText(validators_, 8, now_ + 300));
auto const laterSig = signList(later, publisher_.signingKey, publisher_.token.validationSecret);
auto doc = makeSignedList(
publisher_.token.manifest,
publisher_.manifest.masterKey,
later,
laterSig,
2,
signed2(),
{});
doc[jss::blobs_v2][1u][jss::signature] = signature_;
SigningKeys rotated = publisher_.keys;
auto const token2 = rotated.createToken(KeyType::Ed25519);
auto const manifest2 = required(deserializeManifest(base64Decode(token2.manifest)));
auto const signingKey2 = required(manifest2.signingKey);
auto const sign2 = [&](std::string const& bytes) {
return strHex(sign(signingKey2, token2.validationSecret, makeSlice(bytes)));
};
bool resigned = false;
auto const resign2 = [&](std::string const& bytes) {
resigned = true;
return sign2(bytes);
};
auto const third = parseUnsignedList(unsignedListText(validators_, 9, now_ + 400));
EXPECT_EQ(
errorOf([&] {
makeSignedList(
token2.manifest,
manifest2.masterKey,
third,
sign2(third.canonical),
2,
doc,
resign2);
}),
"The list to append to holds a blob whose signature does not verify");
EXPECT_FALSE(resigned);
}
TEST_F(ListSigningTest, append_refuses_manifest_of_another_master_key)
{
std::string const anotherMaster =
"The list to append to holds a manifest for another master key";
SigningKeys rotated = publisher_.keys;
auto const token2 = rotated.createToken(KeyType::Ed25519);
auto const manifest2 = required(deserializeManifest(base64Decode(token2.manifest)));
auto const signingKey2 = required(manifest2.signingKey);
auto const resign2 = [&](std::string const& bytes) {
return strHex(sign(signingKey2, token2.validationSecret, makeSlice(bytes)));
};
auto const later = parseUnsignedList(unsignedListText(validators_, 8, now_ + 300));
auto const appendError = [&](json::Value const& existing) {
return errorOf([&] {
makeSignedList(
token2.manifest,
manifest2.masterKey,
later,
resign2(later.canonical),
2,
existing,
resign2);
});
};
// Another publisher's manifest at sequence 1, below the token's 2, so
// the sequence check alone would take it for an older manifest of ours
Publisher const other;
{
auto doc = signed2();
doc[jss::blobs_v2][0u][jss::manifest] = other.token.manifest;
EXPECT_EQ(appendError(doc), anotherMaster);
}
{
auto doc = signed2();
doc[jss::manifest] = other.token.manifest;
EXPECT_EQ(appendError(doc), anotherMaster);
}
// A token whose manifest belongs to another master key than the list's,
// appending and starting a new document of either version
std::string const invalidToken = "The token holds an invalid manifest";
EXPECT_EQ(
errorOf([&] {
makeSignedList(
other.token.manifest,
publisher_.manifest.masterKey,
later,
resign2(later.canonical),
2,
signed2(),
resign2);
}),
invalidToken);
for (auto const version : {1u, 2u})
{
EXPECT_EQ(
errorOf([&] {
makeSignedList(
other.token.manifest,
publisher_.manifest.masterKey,
later,
resign2(later.canonical),
version,
std::nullopt,
{});
}),
invalidToken);
}
// A revocation of the list's master key verifies but names no signing key
{
SigningKeys revoking = publisher_.keys;
auto doc = signed2();
doc[jss::blobs_v2][0u][jss::manifest] = revoking.revoke();
EXPECT_EQ(appendError(doc), "The list to append to holds an invalid manifest");
}
}
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, R"("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, R"(a version 1 list needs "blob" and "signature" and no "blobs_v2")");
}
auto const v2 = signed2();
std::string const v2Shape =
R"(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 = required(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, required(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

View File

@@ -0,0 +1,650 @@
#include <tools/validator-keys/SigningKeys.h>
#include <xrpl/basics/FileUtilities.h>
#include <xrpl/basics/Slice.h>
#include <xrpl/basics/StringUtilities.h>
#include <xrpl/basics/base64.h>
#include <xrpl/basics/strHex.h>
#include <xrpl/json/json_forwards.h>
#include <xrpl/json/json_reader.h>
#include <xrpl/json/json_value.h>
#include <xrpl/protocol/KeyType.h>
#include <xrpl/protocol/PublicKey.h>
#include <xrpl/protocol/SecretKey.h>
#include <xrpl/protocol/Seed.h>
#include <xrpl/protocol/tokens.h>
#include <xrpl/server/Manifest.h>
#include <gtest/gtest.h>
#include <tools/validator-keys/OwnerOnlyFile.h>
#include <Fixtures.h>
#include <array>
#include <cstddef>
#include <cstdint>
#include <filesystem>
#include <limits>
#include <map>
#include <string>
#include <utility>
namespace xrpl::tools::test {
namespace {
constexpr std::array<KeyType, 2> kKeyTypes{{KeyType::Ed25519, KeyType::Secp256k1}};
constexpr std::uint32_t kMaxSequence = std::numeric_limits<std::uint32_t>::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::makeSigningKeys(keyFile_); });
}
json::Value
keyFileJson()
{
json::Reader reader;
json::Value jv;
reader.parse(readFile(keyFile_), jv);
return jv;
}
[[nodiscard]] std::string
invalidField(std::string const& field) const
{
return "Key file '" + keyFile_.string() + "' contains invalid \"" + field + "\" field";
}
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 = required(deserializeManifest(base64Decode(base64)));
EXPECT_TRUE(m.verify());
EXPECT_EQ(m.masterKey, keys.publicKey());
return 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::makeSigningKeys(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::makeSigningKeys(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::makeSigningKeys(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::makeSigningKeys(nested));
// Every directory made on the way is owner-only, not just the last one
for (auto const* made : {"a", "a/b", "a/b/c"})
{
EXPECT_EQ(
std::filesystem::status(dir_.file(made)).permissions() &
(std::filesystem::perms::group_all | std::filesystem::perms::others_all),
std::filesystem::perms::none)
<< made;
}
// 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);
// A stale file at the old fixed ".tmp" sibling name is neither followed
// nor removed: the temporary now gets a random suffix in the same
// directory, so that name plays no part in the write.
auto const linked = std::filesystem::path(dir_.file("linked.json"));
auto const stale = linked.string() + ".tmp";
std::filesystem::create_symlink(dir_.file("elsewhere.json"), stale);
keys.writeToFile(linked);
EXPECT_TRUE(keys == SigningKeys::makeSigningKeys(linked));
EXPECT_TRUE(std::filesystem::is_symlink(stale));
}
TEST_F(SigningKeysTest, create_refuses_existing)
{
SigningKeys const first(KeyType::Ed25519);
SigningKeys const second(KeyType::Ed25519);
// A second creator that passed the same missing-file state commits after the first
OwnerOnlyFile late(keyFile_, "key file", OwnerOnlyFile::Existing::Refuse);
late.write("{}");
first.writeToFile(keyFile_, OwnerOnlyFile::Existing::Refuse);
EXPECT_EQ(
errorOf([&] { late.commit(); }),
"Refusing to overwrite existing key file: " + keyFile_.string());
EXPECT_TRUE(first == SigningKeys::makeSigningKeys(keyFile_));
EXPECT_EQ(
errorOf([&] { second.writeToFile(keyFile_, OwnerOnlyFile::Existing::Refuse); }),
"Refusing to overwrite existing key file: " + keyFile_.string());
EXPECT_TRUE(first == SigningKeys::makeSigningKeys(keyFile_));
// Only the target is left in the directory: no temporary survives either path
std::size_t entries = 0;
for ([[maybe_unused]] auto const& entry :
std::filesystem::directory_iterator(keyFile_.parent_path()))
++entries;
EXPECT_EQ(entries, 1u);
}
TEST_F(SigningKeysTest, key_file_fields)
{
EXPECT_EQ(
errorOf([&] { SigningKeys::makeSigningKeys(keyFile_); }),
"Failed to open key file: " + keyFile_.string());
writeFile(keyFile_, "{{}");
EXPECT_EQ(
errorOf([&] { SigningKeys::makeSigningKeys(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("key_type"));
auto const kp = generateKeyPair(KeyType::Ed25519, randomSeed());
jv["key_type"] = "ed25519";
EXPECT_EQ(loadError(jv), invalidField("token_sequence"));
jv["token_sequence"] = -1;
EXPECT_EQ(loadError(jv), invalidField("token_sequence"));
jv["token_sequence"] = true;
EXPECT_EQ(loadError(jv), invalidField("token_sequence"));
jv["token_sequence"] = json::UInt(kMaxSequence);
EXPECT_EQ(loadError(jv), invalidField("revoked"));
jv["revoked"] = false;
EXPECT_EQ(loadError(jv), invalidField("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(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(field)) << field;
}
{
auto bad = baseKeyFile(kp.second);
bad["manifest"] = "";
EXPECT_EQ(loadError(bad), invalidField("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("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("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::makeSigningKeys(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(
required(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 sign a revocation: the master key is external; use "
"start_revoke_keys and finish_revoke_keys.");
}
}
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<KeyType, std::string> 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 = required(strUnHex(keys.sign(data)));
EXPECT_TRUE(verify(keys.publicKey(), makeSlice(data), makeSlice(sig)));
SigningKeys const external(keyType, derivePublicKey(keyType, sk));
EXPECT_EQ(
errorOf([&] { (void)external.sign(data); }), "This key file cannot be used to sign.");
EXPECT_EQ(
errorOf([&] { (void)external.signHex(strHex(data)); }),
"This key file cannot be used to sign.");
}
SigningKeys const keys(KeyType::Ed25519);
EXPECT_EQ(errorOf([&] { (void)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::makeSigningKeys(keyFile_);
EXPECT_TRUE(keys == fileKeys);
auto const finished = fileKeys.finishToken(required(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(
required(m.signingKey), derivePublicKey(tokenKeyType, required(finished.secret)));
// A signature over other bytes does not finish the token
EXPECT_EQ(
errorOf([&] { keys.finishToken(required(strUnHex(signer.sign("foo")))); }),
kBadManifest);
EXPECT_EQ(
errorOf(
[&] { keys.finishToken(required(strUnHex(signer.signHex(data))), Blob{}); }),
"The pending token's signing key is in this key file; pass one signature");
keys.finishToken(required(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(required(strUnHex(signer.sign("foo")))); }),
kBadManifest);
EXPECT_FALSE(keys.revoked());
auto const sig = required(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::makeSigningKeys(keyFile_);
EXPECT_TRUE(keys == fileKeys);
auto const masterSig = required(strUnHex(keys.signHex(data)));
auto const signingSig = required(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(required(m.signingKey), signer.publicKey());
EXPECT_EQ(fileKeys.sequence(), 1u);
EXPECT_EQ(
errorOf([&] { fileKeys.finishToken(masterSig, signingSig); }),
"No pending token to finish");
// The signing key the current manifest names is refused for the next
// token: ManifestCache::applyManifest returns BadEphemeralKey for it
EXPECT_EQ(
errorOf([&] { fileKeys.startToken(KeyType::Ed25519, signer.publicKey()); }),
"The signing key is the current manifest's; a server rejects a manifest that reuses it");
EXPECT_EQ(fileKeys.sequence(), 1u);
SigningKeys const next(KeyType::Ed25519);
EXPECT_EQ(errorOf([&] { fileKeys.startToken(KeyType::Ed25519, next.publicKey()); }), "");
}
TEST_F(SigningKeysTest, pending_token_is_kept)
{
SigningKeys const signer(KeyType::Ed25519);
SigningKeys keys(KeyType::Ed25519);
std::string const pendingError =
"A token is pending: finish it with finish_token before starting another";
// A generated signing key: the same key type gets the same bytes, through
// the key file too, and another request is refused
auto const data = keys.startToken(KeyType::Secp256k1);
EXPECT_EQ(keys.startToken(KeyType::Secp256k1), data);
keys.writeToFile(keyFile_);
auto fileKeys = SigningKeys::makeSigningKeys(keyFile_);
EXPECT_EQ(fileKeys.startToken(KeyType::Secp256k1), data);
EXPECT_TRUE(keys == fileKeys);
EXPECT_EQ(errorOf([&] { keys.startToken(KeyType::Ed25519); }), pendingError);
EXPECT_EQ(
errorOf([&] { keys.startToken(KeyType::Secp256k1, signer.publicKey()); }), pendingError);
EXPECT_TRUE(keys == fileKeys);
auto const finished = keys.finishToken(required(strUnHex(keys.signHex(data))));
auto const m = manifestOf(finished.manifest, keys);
EXPECT_EQ(m.sequence, 1u);
EXPECT_EQ(
required(m.signingKey), derivePublicKey(KeyType::Secp256k1, required(finished.secret)));
// An external signing key: the same key gets the same bytes, whatever key
// type the call names; another key or a generated key is refused
auto const external = keys.startToken(KeyType::Ed25519, signer.publicKey());
EXPECT_EQ(keys.startToken(KeyType::Secp256k1, signer.publicKey()), external);
EXPECT_EQ(errorOf([&] { keys.startToken(KeyType::Ed25519); }), pendingError);
EXPECT_EQ(
errorOf(
[&] { keys.startToken(KeyType::Ed25519, SigningKeys(KeyType::Ed25519).publicKey()); }),
pendingError);
// createToken would replace the pending token too, so it is refused
EXPECT_EQ(errorOf([&] { keys.createToken(); }), pendingError);
EXPECT_EQ(keys.startToken(KeyType::Ed25519, signer.publicKey()), external);
auto const masterSig = required(strUnHex(keys.signHex(external)));
auto const signingSig = required(strUnHex(signer.signHex(external)));
EXPECT_EQ(manifestOf(keys.finishToken(masterSig, signingSig).manifest, keys).sequence, 2u);
}
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);
// A manifest above token_sequence loads, so the keys can still be revoked,
// but makes no token: the next one would not be newer than it
jv = good;
jv["token_sequence"] = 0;
EXPECT_EQ(loadError(jv), "");
{
auto loaded = SigningKeys::makeSigningKeys(keyFile_);
std::string const stale =
"The stored manifest has sequence 1, above token_sequence 0; a server would reject "
"the next token as stale";
EXPECT_EQ(errorOf([&] { loaded.createToken(); }), stale);
EXPECT_EQ(errorOf([&] { loaded.startToken(); }), stale);
EXPECT_EQ(loaded.sequence(), 0u);
EXPECT_TRUE(manifestOf(loaded.revoke(), loaded).revoked());
}
// A token_sequence above the manifest, as a migrated key file sets it,
// continues from there
jv = good;
jv["token_sequence"] = 5;
EXPECT_EQ(loadError(jv), "");
{
auto loaded = SigningKeys::makeSigningKeys(keyFile_);
EXPECT_EQ(manifestOf(loaded.createToken().manifest, loaded).sequence, 6u);
}
}
} // namespace xrpl::tools::test

View File

@@ -0,0 +1,28 @@
# 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).
include(GNUInstallDirs)
add_library(xrpl.validator-keys STATIC)
target_sources(
xrpl.validator-keys
PRIVATE Commands.cpp ListSigning.cpp OwnerOnlyFile.cpp SigningKeys.cpp
)
target_include_directories(
xrpl.validator-keys
PUBLIC $<BUILD_INTERFACE:${CMAKE_SOURCE_DIR}/src>
)
target_link_libraries(
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}"
)
install(TARGETS validator-keys RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR})

View File

@@ -0,0 +1,736 @@
#include <tools/validator-keys/Commands.h>
#include <xrpl/basics/Blob.h>
#include <xrpl/basics/FileUtilities.h>
#include <xrpl/basics/Slice.h>
#include <xrpl/basics/StringUtilities.h>
#include <xrpl/basics/base64.h>
#include <xrpl/basics/contract.h>
#include <xrpl/basics/strHex.h>
#include <xrpl/json/json_reader.h>
#include <xrpl/json/json_value.h>
#include <xrpl/protocol/KeyType.h>
#include <xrpl/protocol/PublicKey.h>
#include <xrpl/protocol/SecretKey.h>
#include <xrpl/protocol/tokens.h>
#include <xrpl/server/Manifest.h>
#include <sys/file.h>
#include <tools/validator-keys/ListSigning.h>
#include <tools/validator-keys/OwnerOnlyFile.h>
#include <tools/validator-keys/SigningKeys.h>
#include <dirent.h>
#include <algorithm>
#include <array>
#include <cerrno>
#include <cstddef>
#include <cstdlib>
#include <filesystem>
#include <memory>
#include <optional>
#include <ostream>
#include <stdexcept>
#include <string>
#include <system_error>
#include <utility>
#include <vector>
namespace xrpl::tools {
namespace {
constexpr std::size_t kMaxDocumentBytes = 4 * 1024 * 1024;
constexpr std::size_t kBlockLineLength = 72;
using Args = std::vector<std::string>;
struct Context
{
ToolOptions const& options;
std::ostream& out;
std::ostream& err;
};
// An exclusive flock on the key file's directory, held while a command loads,
// changes and rewrites the key file; OwnerOnlyFile::commit renames a new inode
// over the key file, so a lock on the file itself would not serialize.
class KeyFileLock
{
// opendir gives a read-only, close-on-exec descriptor of the directory;
// null when the directory does not exist.
DIR* dir_ = nullptr;
public:
explicit KeyFileLock(std::filesystem::path const& keyFile)
{
auto dir = keyFile.parent_path();
if (dir.empty())
dir = ".";
dir_ = ::opendir(dir.c_str());
if (dir_ == nullptr)
{
// No directory means no key file to protect; the command reports
// the missing key file itself.
if (errno == ENOENT || errno == ENOTDIR)
return;
throw std::runtime_error("Cannot lock key file: " + keyFile.string());
}
int rc = 0;
do
{
rc = ::flock(::dirfd(dir_), LOCK_EX);
} while (rc != 0 && errno == EINTR);
if (rc != 0)
{
::closedir(dir_);
throw std::runtime_error("Cannot lock key file: " + keyFile.string());
}
}
~KeyFileLock()
{
if (dir_ != nullptr)
::closedir(dir_);
}
KeyFileLock(KeyFileLock const&) = delete;
KeyFileLock&
operator=(KeyFileLock const&) = delete;
};
/**
* Where a command's result goes: the file named by `--out`, replaced whole
* once the command has succeeded, or the output stream. The file is prepared
* before the command changes any state so an unwritable path fails first.
*/
class Output
{
// Null when the result goes to the output stream.
std::unique_ptr<OwnerOnlyFile> file_;
std::ostream& out_;
public:
/**
* @param inputs Files the command reads besides those in the options;
* `--out` may not name any input except `--append`, whose document
* the output replaces once it is complete.
* @param mode The file's mode: `OwnerOnlyFile::kPublished` for a list
*/
Output(
Context const& ctx,
std::vector<std::filesystem::path> const& inputs = {},
int mode = OwnerOnlyFile::kOwnerOnly)
: out_(ctx.out)
{
auto const& options = ctx.options;
if (!options.outFile)
return;
std::vector<std::filesystem::path> read(inputs);
read.push_back(options.keyFile);
for (auto const& file : {options.tokenFile, options.manifestFile, options.validatorsFile})
{
if (file)
read.push_back(*file);
}
for (auto const& file : read)
{
std::error_code ec;
if (std::filesystem::equivalent(file, *options.outFile, ec))
throw std::runtime_error("--out names an input file: " + options.outFile->string());
}
file_ = std::make_unique<OwnerOnlyFile>(
*options.outFile, "output file", OwnerOnlyFile::Existing::Replace, mode);
}
// A config block in 72-character lines.
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 + "]");
}
// A base64 manifest alone: the file a list publisher names with --manifest-file.
void
manifest(std::string const& base64)
{
if (!file_)
{
out_ << "The manifest for a validator-list publisher; save it and pass it to\n"
"start_sign_list and finish_sign_list with --manifest-file:\n\n"
<< base64 << std::endl;
return;
}
write(base64 + "\n", "The manifest");
}
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)
{
file_->write(text);
file_->commit();
out_ << what << " written to " << file_->target().string() << "\n";
}
};
/**
* 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;
if (auto const bytes = decodeBase64Exact(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
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::makeSigningKeys(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{
.manifest = finished.manifest, .validationSecret = *finished.secret}));
}
else
{
output.manifest(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 xrp-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)
{
SigningKeys const keys(KeyType::Ed25519);
keys.writeToFile(ctx.options.keyFile, OwnerOnlyFile::Existing::Refuse);
storedNotice(ctx.options.keyFile, ctx.out);
return EXIT_SUCCESS;
}
int
cmdCreateExternal(Args const& args, Context& ctx)
{
auto const publicKey = parsePublicKey(args[0]);
auto const keyType = publicKeyType(publicKey);
if (!keyType)
logicError("create_external: public key without a key type"); // LCOV_EXCL_LINE
SigningKeys const keys(*keyType, publicKey);
keys.writeToFile(ctx.options.keyFile, OwnerOnlyFile::Existing::Refuse);
storedNotice(ctx.options.keyFile, ctx.out);
return EXIT_SUCCESS;
}
int
cmdCreateToken(Args const&, Context& ctx)
{
Output output(ctx);
auto keys = SigningKeys::makeSigningKeys(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::makeSigningKeys(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);
auto keys = SigningKeys::makeSigningKeys(ctx.options.keyFile);
std::optional<Blob> 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)
{
Output output(ctx);
auto keys = SigningKeys::makeSigningKeys(ctx.options.keyFile);
warnRevocation(keys, ctx.err);
auto const revocation = keys.revoke();
keys.writeToFile(ctx.options.keyFile);
output.block("validator_key_revocation", nodePublic(keys), revocation);
return EXIT_SUCCESS;
}
int
cmdStartRevokeKeys(Args const&, Context& ctx)
{
auto const keys = SigningKeys::makeSigningKeys(ctx.options.keyFile);
warnRevocation(keys, ctx.err);
ctx.out << keys.startRevoke() << std::endl;
return EXIT_SUCCESS;
}
int
cmdFinishRevokeKeys(Args const& args, Context& ctx)
{
Output output(ctx);
auto keys = SigningKeys::makeSigningKeys(ctx.options.keyFile);
warnRevocation(keys, ctx.err);
auto const revocation = keys.finishRevoke(decodeSignature(args[0]));
keys.writeToFile(ctx.options.keyFile);
output.block("validator_key_revocation", nodePublic(keys), revocation);
return EXIT_SUCCESS;
}
int
setDomain(std::string const& domain, Context& ctx)
{
Output output(ctx);
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::makeSigningKeys(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::makeSigningKeys(ctx.options.keyFile);
auto const& m = keys.manifest();
// The encoding is optional; base64 is what a list's "manifest" field and
// --manifest-file take.
std::string const encoding = args.empty() ? "base64" : args[0];
if (m.empty())
{
ctx.out << "The last manifest generated is unavailable. You can\n"
"generate a new one.\n\n";
return EXIT_SUCCESS;
}
if (encoding == "base64")
{
ctx.out << "Manifest #" << keys.sequence() << " (Base64):\n"
<< base64Encode(m.data(), m.size()) << "\n\n";
return EXIT_SUCCESS;
}
if (encoding == "hex")
{
ctx.out << "Manifest #" << keys.sequence() << " (Hex):\n" << strHex(makeSlice(m)) << "\n\n";
return EXIT_SUCCESS;
}
throw std::runtime_error("Unknown encoding '" + encoding + "'");
}
// The manifest a list is signed under when the signing key is external, and
// the signing key it delegates to.
std::pair<Manifest, PublicKey>
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");
auto const signingKey = *manifest.signingKey;
return {std::move(manifest), signingKey};
}
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<json::Value> 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, {args[0]}, OwnerOnlyFile::kPublished);
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, signingKey] = loadSigningManifest(ctx, "finish_sign_list");
Output output(ctx, {args[1]}, OwnerOnlyFile::kPublished);
auto const list = loadUnsignedList(args[1]);
auto const sig = decodeSignature(args[0]);
if (!verify(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<UnsignedList> 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;
// Loads, changes and rewrites the key file, so runs under KeyFileLock.
bool updatesKeyFile;
int (*run)(Args const&, Context&);
};
constexpr std::array<Command, 18> kCommands{{
{.name = "create_keys",
.minArgs = 0,
.maxArgs = 0,
.updatesKeyFile = false,
.run = cmdCreateKeys},
{.name = "create_external",
.minArgs = 1,
.maxArgs = 1,
.updatesKeyFile = false,
.run = cmdCreateExternal},
{.name = "create_token",
.minArgs = 0,
.maxArgs = 0,
.updatesKeyFile = true,
.run = cmdCreateToken},
{.name = "start_token",
.minArgs = 0,
.maxArgs = 0,
.updatesKeyFile = true,
.run = cmdStartToken},
{.name = "finish_token",
.minArgs = 1,
.maxArgs = 2,
.updatesKeyFile = true,
.run = cmdFinishToken},
{.name = "revoke_keys",
.minArgs = 0,
.maxArgs = 0,
.updatesKeyFile = true,
.run = cmdRevokeKeys},
{.name = "start_revoke_keys",
.minArgs = 0,
.maxArgs = 0,
.updatesKeyFile = false,
.run = cmdStartRevokeKeys},
{.name = "finish_revoke_keys",
.minArgs = 1,
.maxArgs = 1,
.updatesKeyFile = true,
.run = cmdFinishRevokeKeys},
{.name = "set_domain", .minArgs = 1, .maxArgs = 1, .updatesKeyFile = true, .run = cmdSetDomain},
{.name = "clear_domain",
.minArgs = 0,
.maxArgs = 0,
.updatesKeyFile = true,
.run = cmdClearDomain},
{.name = "attest_domain",
.minArgs = 0,
.maxArgs = 0,
.updatesKeyFile = false,
.run = cmdAttestDomain},
{.name = "sign", .minArgs = 1, .maxArgs = 1, .updatesKeyFile = false, .run = cmdSign},
{.name = "sign_hex", .minArgs = 1, .maxArgs = 1, .updatesKeyFile = false, .run = cmdSignHex},
{.name = "show_manifest",
.minArgs = 0,
.maxArgs = 1,
.updatesKeyFile = false,
.run = cmdShowManifest},
{.name = "sign_list", .minArgs = 1, .maxArgs = 1, .updatesKeyFile = false, .run = cmdSignList},
{.name = "start_sign_list",
.minArgs = 1,
.maxArgs = 1,
.updatesKeyFile = false,
.run = cmdStartSignList},
{.name = "finish_sign_list",
.minArgs = 2,
.maxArgs = 2,
.updatesKeyFile = false,
.run = cmdFinishSignList},
{.name = "verify_list",
.minArgs = 1,
.maxArgs = 1,
.updatesKeyFile = false,
.run = cmdVerifyList},
}};
} // namespace
PublicKey
parsePublicKey(std::string const& data)
{
if (auto const key = parseBase58<PublicKey>(TokenType::NodePublic, data))
return *key;
if (auto const key = parseHexKey(data))
return *key;
if (auto const bytes = decodeBase64Exact(data); bytes && publicKeyType(makeSlice(*bytes)))
return PublicKey(makeSlice(*bytes));
throw std::runtime_error("Unable to parse public key: " + data);
}
std::filesystem::path
defaultKeyFile(std::filesystem::path const& home)
{
auto keyFile = home / ".xrpld" / "validator-keys.json";
auto legacy = home / ".ripple" / "validator-keys.json";
if (!std::filesystem::exists(keyFile) && std::filesystem::exists(legacy))
return legacy;
return keyFile;
}
int
runCommand(
std::string const& command,
std::vector<std::string> const& args,
ToolOptions const& options,
std::ostream& out,
std::ostream& err)
{
auto const it =
std::ranges::find_if(kCommands, [&](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");
std::optional<KeyFileLock> lock;
if (it->updatesKeyFile)
lock.emplace(options.keyFile);
Context ctx{.options = options, .out = out, .err = err};
int const rc = it->run(args, ctx);
// A result that never reached the caller, such as a token, is a failure.
out.flush();
if (!out)
throw std::runtime_error("Cannot write the output");
return rc;
}
} // namespace xrpl::tools

View File

@@ -0,0 +1,83 @@
#pragma once
#include <xrpl/protocol/KeyType.h>
#include <xrpl/protocol/PublicKey.h>
#include <filesystem>
#include <optional>
#include <ostream>
#include <string>
#include <vector>
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<PublicKey> signingKey;
// File holding a [validator_token] block.
std::optional<std::filesystem::path> tokenFile;
// File holding a base64 manifest.
std::optional<std::filesystem::path> manifestFile;
// File to write a token, a manifest, a revocation or a signed list to
// instead of stdout; a signed list is readable by everyone, the rest by
// the owner only.
std::optional<std::filesystem::path> outFile;
// Version of the signed list document.
unsigned listVersion = 1;
// Version 2 list to add a blob to.
std::optional<std::filesystem::path> appendFile;
// Unsigned list whose validators a published list must carry.
std::optional<std::filesystem::path> validatorsFile;
// Master key a published list must be signed under.
std::optional<PublicKey> expectedKey;
};
/**
* Parses a public key given as base58 (`nHB...`), hex or base64: the
* encodings `create_external`, `--signing-key` and `--expected-key` take.
*
* @throws std::runtime_error if none of the encodings yields a public key
*/
PublicKey
parsePublicKey(std::string const& data);
/**
* The key file used when `--keyfile` is not given:
* `<home>/.xrpld/validator-keys.json`, or the legacy
* `<home>/.ripple/validator-keys.json` when only that one exists, the same
* order xrpld reads its config file under its current and legacy names.
*/
std::filesystem::path
defaultKeyFile(std::filesystem::path const& home);
/**
* Runs one command. Results go to @p out, warnings and notes to @p err.
* A command that updates the key file holds an exclusive lock on the key
* file's directory for its whole run, so concurrent runs are serialized.
*
* @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 write. A failed write to @p out is thrown after the key
* file was updated: the undelivered token is not recoverable and
* the next token takes the next sequence.
*/
int
runCommand(
std::string const& command,
std::vector<std::string> const& args,
ToolOptions const& options,
std::ostream& out,
std::ostream& err);
} // namespace xrpl::tools

View File

@@ -0,0 +1,77 @@
The accompanying files under various copyrights.
Copyright (c) 2016 Ripple Labs Inc.
Permission to use, copy, modify, and distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
The accompanying files incorporate work covered by the following copyright
and previous license notice:
Copyright (c) 2011 Arthur Britto, David Schwartz, Jed McCaleb,
Vinnie Falco, Bob Way, Eric Lombrozo, Nikolaos D. Bougalis, Howard Hinnant
Some code from Raw Material Software, Ltd., provided under the terms of the
ISC License. See the corresponding source files for more details.
Copyright (c) 2013 - Raw Material Software Ltd.
Please visit http://www.juce.com
Some code from ASIO examples:
// Copyright (c) 2003-2011 Christopher M. Kohlhoff (chris at kohlhoff dot com)
//
// Distributed under the Boost Software License, Version 1.0. (See accompanying
// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt)
Some code from Bitcoin:
// Copyright (c) 2009-2010 Satoshi Nakamoto
// Copyright (c) 2011 The Bitcoin developers
// Distributed under the MIT/X11 software license, see the accompanying
// file license.txt or http://www.opensource.org/licenses/mit-license.php.
Some code from Tom Wu:
This software is covered under the following copyright:
/*
* Copyright (c) 2003-2005 Tom Wu
* All Rights Reserved.
*
* Permission is hereby granted, free of charge, to any person obtaining
* a copy of this software and associated documentation files (the
* "Software"), to deal in the Software without restriction, including
* without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to
* permit persons to whom the Software is furnished to do so, subject to
* the following conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS-IS" AND WITHOUT WARRANTY OF ANY KIND,
* EXPRESS, IMPLIED OR OTHERWISE, INCLUDING WITHOUT LIMITATION, ANY
* WARRANTY OF MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE.
*
* IN NO EVENT SHALL TOM WU BE LIABLE FOR ANY SPECIAL, INCIDENTAL,
* INDIRECT OR CONSEQUENTIAL DAMAGES OF ANY KIND, OR ANY DAMAGES WHATSOEVER
* RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER OR NOT ADVISED OF
* THE POSSIBILITY OF DAMAGE, AND ON ANY THEORY OF LIABILITY, ARISING OUT
* OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*
* In addition, the following condition applies:
*
* All redistributions must retain an intact copy of this copyright notice
* and disclaimer.
*/
Address all questions regarding this license to:
Tom Wu
tjw@cs.Stanford.EDU

View File

@@ -0,0 +1,702 @@
#include <tools/validator-keys/ListSigning.h>
#include <xrpl/basics/FileUtilities.h>
#include <xrpl/basics/Slice.h>
#include <xrpl/basics/StringUtilities.h>
#include <xrpl/basics/base64.h>
#include <xrpl/basics/chrono.h>
#include <xrpl/basics/strHex.h>
#include <xrpl/json/json_forwards.h>
#include <xrpl/json/json_reader.h>
#include <xrpl/json/json_value.h>
#include <xrpl/protocol/PublicKey.h>
#include <xrpl/protocol/SecretKey.h>
#include <xrpl/protocol/jss.h>
#include <xrpl/server/Manifest.h>
#include <boost/algorithm/string.hpp>
#include <algorithm>
#include <chrono>
#include <cstddef>
#include <cstdint>
#include <filesystem>
#include <optional>
#include <set>
#include <stdexcept>
#include <string>
#include <system_error>
#include <utility>
#include <vector>
namespace xrpl {
namespace {
// 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;
std::string
readFile(std::filesystem::path const& file, std::size_t maxSize)
{
std::error_code ec;
auto text = getFileContents(ec, file, maxSize);
if (ec)
throw std::runtime_error("Failed to open file: " + file.string());
return text;
}
// A character of a number, `true`, `false` or `null` outside a string.
bool
scalarChar(char c)
{
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '.' ||
c == '+' || c == '-';
}
// The base64 lines of a config-style block, without its section header or
// comment lines.
std::vector<std::string>
base64Lines(std::string const& text)
{
std::vector<std::string> lines;
boost::split(lines, text, boost::is_any_of("\n"));
for (auto& line : lines)
boost::trim(line);
std::erase_if(lines, [](std::string const& line) {
return line.empty() || line.front() == '#' || line.front() == '[';
});
return lines;
}
std::optional<Manifest>
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<std::uint32_t>
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;
// A server takes a missing effective time as 0 and needs expiration after it.
auto const expiration = listInteger(jv, jss::expiration);
if (!expiration || *expiration == 0)
throw std::runtime_error(integerError(jss::expiration, 1));
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(R"("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);
}
if (std::ranges::find(list.validators, *key) != list.validators.end())
throw std::runtime_error("\"validators\" lists " + keyText + " more than once");
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;
}
// A published blob's list, checked as an unsigned list is: the tool copies or
// signs again only a blob it would sign itself.
UnsignedList
blobList(std::string const& blobBytes)
{
try
{
return checkedList(blobBytes, parseObject(blobBytes));
}
catch (std::runtime_error const& e)
{
throw std::runtime_error(
"The list to append to holds an invalid blob: " + std::string(e.what()));
}
}
} // namespace
std::optional<PublicKey>
parseHexKey(std::string const& hex)
{
auto const bytes = strUnHex(hex);
if (!bytes)
return std::nullopt;
auto const slice = makeSlice(*bytes);
if (!publicKeyType(slice))
return std::nullopt;
return PublicKey(slice);
}
std::optional<std::string>
decodeBase64Exact(std::string const& data)
{
auto bytes = base64Decode(data);
if (base64Encode(bytes) != data)
return std::nullopt;
return bytes;
}
ValidatorToken
loadTokenFile(std::filesystem::path const& 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(std::filesystem::path const& manifestFile)
{
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);
}
std::string
canonicalJson(std::string const& text)
{
std::string out;
out.reserve(text.size());
bool inString = false;
bool escaped = false;
// Bracket depth outside strings and comments; json::Reader::parse ignores
// text after the root value, so once the depth returns to 0 only
// whitespace and comments may follow.
std::size_t depth = 0;
bool closed = false;
// Whitespace or a comment was skipped since the last emitted character;
// dropping it between two scalar characters would merge two tokens.
bool separated = false;
for (std::size_t i = 0; i < text.size(); ++i)
{
char const c = text[i];
if (inString)
{
out += c;
if (escaped)
{
escaped = false;
}
else if (c == '\\')
{
escaped = true;
}
else if (c == '"')
{
inString = false;
}
continue;
}
if (c == '/' && i + 1 < text.size() && text[i + 1] == '/')
{
separated = true;
// json::Reader ends a line comment at CR or LF.
i = text.find_first_of("\r\n", i);
if (i == std::string::npos)
break;
continue;
}
if (c == '/' && i + 1 < text.size() && text[i + 1] == '*')
{
separated = true;
i = text.find("*/", i + 2);
if (i == std::string::npos)
throw std::runtime_error("Unterminated comment");
++i;
continue;
}
if (c == ' ' || c == '\t' || c == '\n' || c == '\r')
{
separated = true;
continue;
}
if (closed)
throw std::runtime_error("Content after the JSON object");
if (separated && scalarChar(c) && !out.empty() && scalarChar(out.back()))
throw std::runtime_error("Values run together");
separated = false;
switch (c)
{
case '"':
inString = true;
out += c;
break;
case ',':
out += ", ";
break;
case ':':
out += ": ";
break;
case '{':
case '[':
++depth;
out += c;
break;
case '}':
case ']':
if (depth > 0)
closed = --depth == 0;
out += c;
break;
default:
out += c;
}
}
parseObject(out);
return out;
}
UnsignedList
parseUnsignedList(std::string const& text)
{
auto canonical = canonicalJson(text);
auto const jv = parseObject(canonical);
return checkedList(std::move(canonical), jv);
}
UnsignedList
loadUnsignedList(std::filesystem::path const& file)
{
return parseUnsignedList(readFile(file, kMaxListBytes));
}
std::string
signList(UnsignedList const& list, PublicKey const& signingKey, SecretKey const& signingSecret)
{
return strHex(sign(signingKey, signingSecret, makeSlice(list.canonical)));
}
json::Value
makeSignedList(
std::string const& manifestBase64,
PublicKey const& masterKey,
UnsignedList const& list,
std::string const& signatureHex,
unsigned version,
std::optional<json::Value> const& append,
Resigner const& resign)
{
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");
// The token's manifest must verify and belong to the list's master key.
auto const current = parseManifest(manifestBase64);
if (!current || current->masterKey != masterKey || !current->signingKey)
throw std::runtime_error("The token holds an invalid manifest");
json::Value jv(json::ValueType::Object);
if (append)
{
auto const& existing = *append;
if (!existing.isObject() || !existing.isMember(jss::version) ||
!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)))
throw std::runtime_error("The list to append to belongs to another master key");
if (existing[jss::blobs_v2].size() >= kMaxBlobs)
{
throw std::runtime_error(
"The list to append to already holds " + std::to_string(kMaxBlobs) + " blobs");
}
// The signing key a manifest in the document delegates to, and whether
// the manifest is the token's. A server that applied a manifest
// rejects an older or equal one, and with it every blob signed under
// it, so any other manifest must be older; it must also verify for
// the list's master key, or its blobs were never the publisher's.
struct Signer
{
PublicKey key;
bool current;
};
auto const signerOf = [&](std::string const& base64) -> Signer {
if (base64 == manifestBase64)
return {.key = *current->signingKey, .current = true};
auto const published = parseManifest(base64);
if (!published)
throw std::runtime_error("The list to append to holds an invalid manifest");
if (published->masterKey != masterKey)
{
throw std::runtime_error(
"The list to append to holds a manifest for another master key");
}
if (!published->signingKey)
throw std::runtime_error("The list to append to holds an invalid manifest");
if (published->sequence >= current->sequence)
{
throw std::runtime_error(
"The list to append to was signed under manifest sequence " +
std::to_string(published->sequence) +
"; append with a token whose manifest sequence is higher");
}
return {.key = *published->signingKey, .current = false};
};
auto const top = signerOf(existing[jss::manifest].asString());
struct Entry
{
std::string blob;
bool current;
};
std::vector<Entry> entries;
std::uint32_t highest = 0;
for (auto const& entry : existing[jss::blobs_v2])
{
if (!entry.isObject() || !entry.isMember(jss::blob) || !entry[jss::blob].isString() ||
!entry.isMember(jss::signature) || !entry[jss::signature].isString())
throw std::runtime_error("The list to append to holds an invalid blob");
auto const bytes = decodeBase64Exact(entry[jss::blob].asString());
auto const signature = strUnHex(entry[jss::signature].asString());
if (!bytes || !signature)
throw std::runtime_error("The list to append to holds an invalid blob");
auto signer = top;
if (entry.isMember(jss::manifest))
{
if (!entry[jss::manifest].isString())
throw std::runtime_error("The list to append to holds an invalid manifest");
signer = signerOf(entry[jss::manifest].asString());
}
// Signed under the key its own manifest names: a tampered blob
// must not be signed again under the token's key.
if (!verify(signer.key, makeSlice(*bytes), makeSlice(*signature)))
{
throw std::runtime_error(
"The list to append to holds a blob whose signature does not verify");
}
entries.push_back({.blob = *bytes, .current = signer.current});
highest = std::max(highest, blobList(*bytes).sequence);
}
// A server ignores a blob whose sequence is not above the one it holds.
if (list.sequence <= highest)
{
throw std::runtime_error(
"The list to append has sequence " + std::to_string(list.sequence) +
"; the document already holds sequence " + std::to_string(highest));
}
if (std::ranges::all_of(entries, [](Entry const& e) { return e.current; }))
{
jv[jss::blobs_v2] = existing[jss::blobs_v2];
}
else
{
if (!resign)
{
throw std::runtime_error(
"The list to append to was signed under another manifest and its blobs "
"need signing again");
}
jv[jss::blobs_v2] = json::Value(json::ValueType::Array);
for (auto const& entry : entries)
{
json::Value resigned(json::ValueType::Object);
resigned[jss::blob] = base64Encode(entry.blob);
resigned[jss::signature] = resign(entry.blob);
jv[jss::blobs_v2].append(resigned);
}
}
}
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] = static_cast<int>(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;
entry[jss::signature] = signatureHex;
jv[jss::blobs_v2].append(entry);
return jv;
}
std::uint32_t
netClockNow()
{
using namespace std::chrono;
auto const since1970 = duration_cast<seconds>(system_clock::now().time_since_epoch());
return static_cast<std::uint32_t>((since1970 - kEpochOffset).count());
}
json::Value
verifyList(
json::Value const& list,
std::optional<UnsignedList> const& expectedRoster,
std::optional<PublicKey> const& expectedKey,
std::uint32_t now)
{
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 report;
}
auto const version = listInteger(list, jss::version);
if (!version || (*version != 1 && *version != 2))
{
fail("\"version\" must be 1 or 2");
return report;
}
report[jss::version] = *version;
if (!list.isMember(jss::public_key) || !list[jss::public_key].isString() ||
!list.isMember(jss::manifest) || !list[jss::manifest].isString())
{
fail(R"("public_key" and "manifest" must be strings)");
return report;
}
auto manifest = parseManifest(list[jss::manifest].asString());
if (!manifest)
{
fail("\"manifest\" does not deserialize and verify");
return report;
}
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 report;
}
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");
struct Entry
{
std::string blob;
std::string signature;
std::optional<std::string> manifest;
};
std::vector<Entry> entries;
if (*version == 1)
{
if (!list.isMember(jss::blob) || !list[jss::blob].isString() ||
!list.isMember(jss::signature) || !list[jss::signature].isString() ||
list.isMember(jss::blobs_v2))
{
fail(R"(a version 1 list needs "blob" and "signature" and no "blobs_v2")");
return report;
}
entries.push_back(
{.blob = list[jss::blob].asString(),
.signature = list[jss::signature].asString(),
.manifest = {}});
}
else
{
if (!list.isMember(jss::blobs_v2) || !list[jss::blobs_v2].isArray() ||
list[jss::blobs_v2].size() == 0 || list[jss::blobs_v2].size() > kMaxBlobs ||
list.isMember(jss::blob) || list.isMember(jss::signature))
{
fail(
"a version 2 list needs 1 to " + std::to_string(kMaxBlobs) +
R"( "blobs_v2" entries and no top-level "blob")");
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::manifest) && !entry[jss::manifest].isString()))
{
fail(
"every \"blobs_v2\" entry needs \"blob\" and \"signature\" strings and "
"an optional \"manifest\" string");
return report;
}
std::optional<std::string> entryManifest;
if (entry.isMember(jss::manifest))
entryManifest = entry[jss::manifest].asString();
entries.push_back(
{.blob = entry[jss::blob].asString(),
.signature = entry[jss::signature].asString(),
.manifest = entryManifest});
}
}
std::optional<std::set<PublicKey>> 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& entry : entries)
{
auto const where = "blob " + std::to_string(index++);
json::Value found(json::ValueType::Object);
// 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<UnsignedList> parsed;
try
{
parsed = parseUnsignedList(data);
}
catch (std::runtime_error const& e)
{
fail(where + ": " + e.what());
}
if (parsed)
{
found[jss::sequence] = parsed->sequence;
if (parsed->effective)
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 (want)
{
std::set<PublicKey> const have(
parsed->validators.begin(), parsed->validators.end());
if (have != *want)
fail(where + ": the validators differ from the expected list");
}
}
report["blobs"].append(found);
}
report["signing_key"] = strHex(*manifest->signingKey);
report["manifest_sequence"] = manifest->sequence;
return report;
}
} // namespace xrpl

View File

@@ -0,0 +1,179 @@
#pragma once
#include <xrpl/json/json_value.h>
#include <xrpl/protocol/PublicKey.h>
#include <xrpl/protocol/SecretKey.h>
#include <xrpl/server/Manifest.h>
#include <cstdint>
#include <filesystem>
#include <functional>
#include <optional>
#include <string>
#include <vector>
namespace xrpl {
/**
* Parses a public key given as hex: 33 bytes with a type byte a server
* accepts.
*/
std::optional<PublicKey>
parseHexKey(std::string const& hex);
/**
* Decodes canonical base64, or nothing. base64Decode returns partial data for
* invalid input, so the decoded bytes must encode back to @p data.
*/
std::optional<std::string>
decodeBase64Exact(std::string const& data);
/**
* Reads a token from a file holding the [validator_token] block as
* `create_token` prints it. The section header and `#` comment lines are
* ignored and the base64 lines are joined.
*
* @throws std::runtime_error if the file cannot be read or is not a token
*/
ValidatorToken
loadTokenFile(std::filesystem::path const& tokenFile);
/**
* Reads a base64 manifest from a file. Comment lines and line breaks are
* ignored.
*
* @throws std::runtime_error if the file cannot be read or the manifest does
* not deserialize and verify
*/
Manifest
loadManifestFile(std::filesystem::path const& manifestFile);
/**
* A validator list before it is signed: the canonical bytes the signing key
* signs, and the fields checked before signing.
*/
struct UnsignedList
{
// Canonical JSON text: compact, `, ` and `: ` separators, key order as
// written.
std::string canonical;
std::uint32_t sequence = 0;
std::optional<std::uint32_t> effective;
std::uint32_t expiration = 0;
// Master keys of the listed validators.
std::vector<PublicKey> validators;
};
/**
* 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 object, has a block
* comment without its closing `*` `/`, has anything but whitespace
* and comments after the object, or has whitespace or a comment
* inside a number or a literal (`1 2`, `tr ue`, a block comment
* between the digits of `12`), which removing it would merge into
* one value
*/
std::string
canonicalJson(std::string const& text);
/**
* 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
*/
UnsignedList
parseUnsignedList(std::string const& text);
/**
* Reads and parses an unsigned list file.
*
* @throws std::runtime_error if the file cannot be read or fails a check
*/
UnsignedList
loadUnsignedList(std::filesystem::path const& file);
/**
* Returns the hex signature of the list's canonical bytes.
*/
std::string
signList(UnsignedList const& list, PublicKey const& signingKey, SecretKey const& signingSecret);
/**
* Signs the bytes of an already published blob; returns the hex signature.
*/
using Resigner = std::function<std::string(std::string const& blobBytes)>;
/**
* 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. A server verifies every blob under the newest manifest it has
* seen for the publisher, so a manifest in @p append, top-level or per entry,
* whose sequence is not below @p manifestBase64's is refused; any other
* manifest must verify for @p masterKey and name a signing key. Every entry's
* signature must verify under the signing key of its effective manifest (its
* own, else the top-level one). When that manifest is @p manifestBase64 for
* every entry the entries are copied, otherwise every blob is signed again
* with @p resign and the per-entry manifests are dropped. A server ignores a
* blob whose `sequence` is not above the one it holds, so @p list's must be
* above every existing blob's.
*
* @throws std::runtime_error if @p manifestBase64 does not deserialize and
* verify, names no signing key or belongs to another master key than
* @p masterKey; or if @p append is not a version 2 document for
* @p masterKey, already holds the maximum number of blobs, holds a
* malformed entry, holds a manifest that does not deserialize and
* verify or has no signing key, holds a manifest for another master
* key, holds a manifest whose sequence is not below the token's,
* holds a blob whose signature does not verify under its effective
* manifest's signing key, holds a blob whose `sequence` is not below
* @p list's, or needs re-signing and @p resign is empty
*/
json::Value
makeSignedList(
std::string const& manifestBase64,
PublicKey const& masterKey,
UnsignedList const& list,
std::string const& signatureHex,
unsigned version,
std::optional<json::Value> const& append,
Resigner const& resign);
/**
* Seconds since the XRP Ledger epoch, now.
*/
std::uint32_t
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 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
*/
json::Value
verifyList(
json::Value const& list,
std::optional<UnsignedList> const& expectedRoster,
std::optional<PublicKey> const& expectedKey,
std::uint32_t now);
} // namespace xrpl

View File

@@ -0,0 +1,195 @@
#include <xrpl/protocol/BuildInfo.h>
#include <xrpl/protocol/KeyType.h>
#include <boost/program_options.hpp>
#include <tools/validator-keys/Commands.h>
#include <cstdlib>
#include <exception>
#include <filesystem>
#include <iostream>
#include <stdexcept>
#include <string>
#include <vector>
// 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] <command> [<argument> ...]\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 <data> Sign string with validator "
"key.\n"
" sign_hex <data> Decode and sign hex string with "
"validator key.\n"
" show_manifest [hex|base64] Displays the last generated "
"manifest\n"
" set_domain <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 <public key> 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 <master sig> [<signing sig>]\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 <sig> Finish the revocation with the "
"external signature.\n"
"Commands for validator lists: \n"
" sign_list <unsigned list> Sign a list with --token-file.\n"
" start_sign_list <unsigned list>\n"
" Print the bytes to sign with an "
"external signing key; needs --manifest-file.\n"
" finish_sign_list <sig> <unsigned list>\n"
" Assemble the signed list from an "
"external signature; needs --manifest-file.\n"
" verify_list <list> Check a published list; "
"--validators and --expected-key add checks.\n";
}
} // 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<std::string>(),
"Specify the key file (default: ~/.xrpld/validator-keys.json, or "
"~/.ripple/validator-keys.json when only that one exists).")(
"token-key-type",
po::value<std::string>(),
"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<std::string>(),
"External signing key a token delegates to, as base58, hex or base64 (start_token).")(
"token-file", po::value<std::string>(), "File holding a [validator_token] block.")(
"manifest-file", po::value<std::string>(), "File holding a base64 manifest.")(
"out",
po::value<std::string>(),
"Write the token, manifest, revocation or signed list to this file; a signed list "
"is readable by everyone, the rest by the owner only.")(
"list-version", po::value<unsigned>(), "Signed list version: 1 (default) or 2.")(
"append", po::value<std::string>(), "Version 2 list to add the new blob to.")(
"validators",
po::value<std::string>(),
"Unsigned list whose validators a published list must carry (verify_list).")(
"expected-key",
po::value<std::string>(),
"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<std::string>(), "Command.")(
"arguments",
po::value<std::vector<std::string>>()->default_value(std::vector<std::string>(), "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.contains("version"))
{
std::cout << "validator-keys version " << xrpl::build_info::getVersionString() << std::endl;
return EXIT_SUCCESS;
}
if (vm.contains("help") || !vm.contains("command"))
{
printHelp(general);
return EXIT_SUCCESS;
}
try
{
std::string const homeDir = getEnvVar("HOME");
ToolOptions options;
options.keyFile = vm.contains("keyfile")
? std::filesystem::path(vm["keyfile"].as<std::string>())
: defaultKeyFile(
homeDir.empty() ? std::filesystem::current_path()
: std::filesystem::path(homeDir));
if (vm.contains("token-key-type"))
{
auto const keyType = xrpl::keyTypeFromString(vm["token-key-type"].as<std::string>());
if (!keyType)
{
throw std::runtime_error(
"Unknown key type: " + vm["token-key-type"].as<std::string>());
}
options.tokenKeyType = *keyType;
}
if (vm.contains("signing-key"))
options.signingKey = parsePublicKey(vm["signing-key"].as<std::string>());
if (vm.contains("token-file"))
options.tokenFile = vm["token-file"].as<std::string>();
if (vm.contains("manifest-file"))
options.manifestFile = vm["manifest-file"].as<std::string>();
if (vm.contains("out"))
options.outFile = vm["out"].as<std::string>();
if (vm.contains("list-version"))
options.listVersion = vm["list-version"].as<unsigned>();
if (vm.contains("append"))
options.appendFile = vm["append"].as<std::string>();
if (vm.contains("validators"))
options.validatorsFile = vm["validators"].as<std::string>();
if (vm.contains("expected-key"))
options.expectedKey = parsePublicKey(vm["expected-key"].as<std::string>());
return runCommand(
vm["command"].as<std::string>(),
vm["arguments"].as<std::vector<std::string>>(),
options,
std::cout,
std::cerr);
}
catch (std::exception const& e)
{
std::cerr << e.what() << "\n";
return EXIT_FAILURE;
}
}
// LCOV_EXCL_STOP

View File

@@ -0,0 +1,159 @@
#include <tools/validator-keys/OwnerOnlyFile.h>
#include <sys/stat.h>
#include <dirent.h>
#include <unistd.h>
#include <algorithm>
#include <array>
#include <cerrno>
#include <cstddef>
#include <cstdlib>
#include <filesystem>
#include <stdexcept>
#include <string>
#include <system_error>
#include <utility>
namespace xrpl {
namespace fs = std::filesystem;
namespace {
// Syncs the directory holding @p file so the entry a rename or link made
// reaches the disk sooner. Best effort: the file's data is already synced,
// the entry is in place, and some filesystems refuse fsync on a directory.
// opendir gives the descriptor without a variadic open.
void
syncDirectory(fs::path const& file)
{
auto dir = file.parent_path();
if (dir.empty())
dir = ".";
DIR* const d = ::opendir(dir.c_str());
if (d == nullptr)
return;
::fsync(::dirfd(d));
::closedir(d);
}
// A filesystem without hard links (FAT, exFAT, SMB and some FUSE mounts)
// refuses link with one of these. ENOTSUP and EOPNOTSUPP are one value on
// Linux and two on macOS, so they are listed rather than compared.
constexpr std::array kNoHardLinkErrors{EPERM, ENOTSUP, EOPNOTSUPP};
bool
noHardLinks(int error)
{
return std::ranges::find(kNoHardLinkErrors, error) != kNoHardLinkErrors.end();
}
} // namespace
OwnerOnlyFile::OwnerOnlyFile(fs::path target, std::string what, Existing existing, int mode)
: target_(std::move(target)), what_(std::move(what)), existing_(existing), mode_(mode)
{
if (fs::is_symlink(target_))
throw std::runtime_error("Refusing to write through a symlink: " + target_.string());
// mkstemp creates the file itself, exclusively and mode 0600, so there is
// no separate check-then-open step for a symlink to race.
std::string templ = target_.string() + ".XXXXXX";
fd_ = ::mkstemp(templ.data());
if (fd_ < 0)
throw std::runtime_error("Cannot write " + what_ + ": " + target_.string());
temp_ = templ;
// A published mode is set before the first byte, on the descriptor.
if (mode_ != kOwnerOnly && ::fchmod(fd_, mode_) != 0)
{
::close(fd_);
fd_ = -1;
std::error_code ec;
fs::remove(temp_, ec);
throw std::runtime_error("Cannot write " + what_ + ": " + target_.string());
}
}
OwnerOnlyFile::~OwnerOnlyFile()
{
if (!committed_)
{
if (fd_ >= 0)
::close(fd_);
std::error_code ec;
fs::remove(temp_, ec);
}
}
void
OwnerOnlyFile::write(std::string const& text)
{
std::size_t offset = 0;
while (offset < text.size())
{
auto const n = ::write(fd_, text.data() + offset, text.size() - offset);
if (n <= 0)
{
failed_ = true;
return;
}
offset += static_cast<std::size_t>(n);
}
}
void
OwnerOnlyFile::commit()
{
// Synced before the rename: a file renamed into place with its data still
// in the page cache can survive a crash empty.
bool written = !failed_ && ::fsync(fd_) == 0;
written = ::close(fd_) == 0 && written;
fd_ = -1;
bool exists = false;
bool linked = false;
bool unlinkable = false;
if (written && existing_ == Existing::Refuse)
{
// link fails with EEXIST instead of replacing, so a concurrent creator is never lost.
linked = ::link(temp_.c_str(), target_.c_str()) == 0;
if (!linked)
{
written = false;
exists = errno == EEXIST;
unlinkable = noHardLinks(errno);
}
}
if (written && !linked)
{
std::error_code ec;
fs::rename(temp_, target_, ec);
written = !ec;
}
// A successful link leaves the temporary as a second name for the target.
if (!written || linked)
{
std::error_code ec;
fs::remove(temp_, ec);
}
if (exists)
{
throw std::runtime_error(
"Refusing to overwrite existing " + what_ + ": " + target_.string());
}
// A filesystem without hard links cannot hold an owner-only file either.
if (unlinkable)
{
throw std::runtime_error(
"Cannot create " + what_ + " on a filesystem without hard links: " + target_.string() +
"; create it on a local filesystem and copy it");
}
if (!written)
throw std::runtime_error("Cannot write " + what_ + ": " + target_.string());
syncDirectory(target_);
committed_ = true;
}
} // namespace xrpl

View File

@@ -0,0 +1,87 @@
#pragma once
#include <filesystem>
#include <string>
namespace xrpl {
/**
* A file written whole and replaced in one step.
*
* The content goes to a temporary created beside the target under a name
* the system chooses, exclusively and restricted to the owner from its
* first byte, and the target is replaced in one step by `commit`, which
* also syncs the content to disk and, where the filesystem allows, the
* directory entry. Without `commit` the temporary is removed, so a failed
* command leaves the previous target as it was. The target is readable by
* its owner only unless `kPublished` is given, for a document that is meant
* to be served. A new target with `Existing::Refuse` needs a filesystem
* with hard links.
*/
class OwnerOnlyFile
{
public:
// Whether `commit` may replace a target that already exists.
enum class Existing { Replace, Refuse };
// Modes of the target: readable by its owner only, or by everyone.
static constexpr int kOwnerOnly = 0600;
static constexpr int kPublished = 0644;
private:
std::filesystem::path target_;
std::filesystem::path temp_;
// Names the file in errors: "key file", "output file".
std::string what_;
Existing existing_;
int mode_;
int fd_ = -1;
bool failed_ = false;
bool committed_ = false;
public:
/**
* Creates the temporary, owner-only before the first byte is written.
*
* @param existing `Refuse` makes `commit` fail when the target exists,
* checked in the same step that creates it
* @param mode The target's mode, `kOwnerOnly` or `kPublished`
*
* @throws std::runtime_error if the target is a symlink, or the
* temporary could not be created
*/
OwnerOnlyFile(
std::filesystem::path target,
std::string what,
Existing existing = Existing::Replace,
int mode = kOwnerOnly);
~OwnerOnlyFile();
OwnerOnlyFile(OwnerOnlyFile const&) = delete;
OwnerOnlyFile&
operator=(OwnerOnlyFile const&) = delete;
void
write(std::string const& text);
/**
* Replaces the target with what was written, or with `Existing::Refuse`
* creates it as a hard link to the temporary, which fails when the
* target exists. A create that fails leaves no target.
*
* @throws std::runtime_error if the content could not be written or
* synced, the target could not be replaced, or with
* `Existing::Refuse` the target exists or the filesystem has no
* hard links
*/
void
commit();
[[nodiscard]] std::filesystem::path const&
target() const
{
return target_;
}
};
} // namespace xrpl

View File

@@ -0,0 +1,24 @@
# validator-keys
Command-line tool for the keys behind an XRP Ledger validator or a validator-list
publisher: the key file, the manifest that delegates from the master key to a
signing key, the `[validator_token]` for `xrpld.cfg`, key revocation, domain
attestation, and signing and verifying validator lists.
It is built as a separate target of this repository and ships in the `xrpld`
packages as `/usr/bin/validator-keys`.
## Build
Configure with `-Dvalidator_keys=ON` and build the `validator-keys` target. With
`-Dtests=ON` the gtest suites under `src/tests/tools/validator-keys` build as
`validator_keys_tests`:
```
cmake --build . --target validator-keys validator_keys_tests
./validator_keys_tests
```
## Guide
[Validator Keys Tool Guide](doc/validator-keys-tool-guide.md)

View File

@@ -0,0 +1,585 @@
#include <tools/validator-keys/SigningKeys.h>
#include <xrpl/basics/Blob.h>
#include <xrpl/basics/FileUtilities.h>
#include <xrpl/basics/Slice.h>
#include <xrpl/basics/StringUtilities.h>
#include <xrpl/basics/base64.h>
#include <xrpl/basics/strHex.h>
#include <xrpl/json/json_forwards.h>
#include <xrpl/json/json_reader.h>
#include <xrpl/json/json_value.h>
#include <xrpl/json/to_string.h>
#include <xrpl/protocol/HashPrefix.h>
#include <xrpl/protocol/KeyType.h>
#include <xrpl/protocol/PublicKey.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STObject.h>
#include <xrpl/protocol/SecretKey.h>
#include <xrpl/protocol/Seed.h>
#include <xrpl/protocol/Serializer.h>
#include <xrpl/protocol/Sign.h>
#include <xrpl/protocol/tokens.h>
#include <xrpl/server/Manifest.h>
#include <boost/algorithm/string.hpp>
#include <sys/stat.h>
#include <tools/validator-keys/OwnerOnlyFile.h>
#include <algorithm>
#include <array>
#include <cerrno>
#include <cstddef>
#include <cstdint>
#include <filesystem>
#include <limits>
#include <optional>
#include <stdexcept>
#include <string>
#include <system_error>
#include <utility>
#include <vector>
namespace xrpl {
namespace {
// Key files are small; anything larger is not one.
constexpr std::size_t kMaxKeyFileBytes = 64 * 1024;
// The mode of each directory created for a key file: rwx for the owner only.
constexpr int kOwnerOnlyDirectory = 0700;
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";
char const* const kPendingError =
"A token is pending: finish it with finish_token before starting another";
} // namespace
bool
sameSecret(SecretKey const& a, SecretKey const& b)
{
return std::equal(a.begin(), a.end(), b.begin());
}
std::string
tokenToBase64(ValidatorToken const& token)
{
json::Value jv;
jv["validation_secret_key"] = strHex(token.validationSecret);
jv["manifest"] = token.manifest;
return base64Encode(to_string(jv));
}
SigningKeys::SigningKeys(KeyType const& keyType)
: keyType_(keyType)
, keys_(generateKeyPair(keyType_, randomSeed()))
, tokenSequence_(0)
, revoked_(false)
{
}
SigningKeys::SigningKeys(
KeyType const& keyType,
SecretKey const& secretKey,
std::uint32_t tokenSequence,
bool revoked)
: keyType_(keyType)
, keys_({derivePublicKey(keyType_, secretKey), secretKey})
, tokenSequence_(tokenSequence)
, revoked_(revoked)
{
}
SigningKeys::SigningKeys(
KeyType const& keyType,
PublicKey const& publicKey,
std::uint32_t tokenSequence,
bool revoked)
: keyType_(keyType), keys_(publicKey), tokenSequence_(tokenSequence), revoked_(revoked)
{
}
bool
SigningKeys::operator==(SigningKeys const& rhs) const
{
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;
auto const& pending = *pending_;
auto const& other = *rhs.pending_;
if (pending.signingKey != other.signingKey ||
pending.generated.has_value() != other.generated.has_value())
return false;
if (!pending.generated || !other.generated)
return true;
return pending.generated->keyType == other.generated->keyType &&
sameSecret(pending.generated->secretKey, other.generated->secretKey);
}
SigningKeys
SigningKeys::makeSigningKeys(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(text, jKeys) || !jKeys.isObject())
throw std::runtime_error("Unable to parse json key file: " + keyFile.string());
static constexpr std::array<char const*, 4> kRequiredFields{
{"key_type", "secret_key", "token_sequence", "revoked"}};
for (auto const* field : kRequiredFields)
{
if (!jKeys.isMember(field))
{
throw std::runtime_error(
"Key file '" + keyFile.string() + "' is missing \"" + field + "\" field");
}
}
// The value is not repeated: it may be a secret.
auto const invalidField = [&keyFile](std::string const& field) {
return std::runtime_error(
"Key file '" + keyFile.string() + "' contains invalid \"" + field + "\" field");
};
auto const keyType = keyTypeFromString(jKeys["key_type"].asString());
if (!keyType)
throw invalidField("key_type");
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();
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<PublicKey>(TokenType::NodePublic, jKeys["public_key"].asString());
if (!pubKey)
throw invalidField("public_key");
if (publicKeyType(*pubKey) != keyType)
{
throw std::runtime_error(
"Key file '" + keyFile.string() +
R"(' has a "key_type" that does not match "public_key")");
}
return SigningKeys(*keyType, *pubKey, tokenSequence, revoked);
}
auto const secret =
parseBase58<SecretKey>(TokenType::NodePrivate, jKeys["secret_key"].asString());
if (!secret)
throw invalidField("secret_key");
return SigningKeys(*keyType, *secret, tokenSequence, revoked);
}();
if (jKeys.isMember("domain"))
{
if (!jKeys["domain"].isString())
throw invalidField("domain");
keys.domain(jKeys["domain"].asString());
}
if (jKeys.isMember("manifest"))
{
if (!jKeys["manifest"].isString())
throw invalidField("manifest");
auto bytes = strUnHex(jKeys["manifest"].asString());
if (!bytes || bytes->empty())
throw invalidField("manifest");
keys.manifest_ = std::move(*bytes);
keys.checkManifest();
}
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() +
R"(' has both "pending_token_secret" and "pending_signing_key")");
}
if (hasSecret)
{
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");
if (!jKeys["pending_token_secret"].isString())
throw invalidField("pending_token_secret");
auto const secret = parseBase58<SecretKey>(
TokenType::NodePrivate, jKeys["pending_token_secret"].asString());
if (!secret)
throw invalidField("pending_token_secret");
keys.pending_ = Pending{
.signingKey = derivePublicKey(*pendingKeyType, *secret),
.generated = GeneratedKey{.keyType = *pendingKeyType, .secretKey = *secret}};
}
else
{
if (!jKeys["pending_signing_key"].isString())
throw invalidField("pending_signing_key");
auto const signingKey = parseBase58<PublicKey>(
TokenType::NodePublic, jKeys["pending_signing_key"].asString());
if (!signingKey)
throw invalidField("pending_signing_key");
keys.pending_ = Pending{.signingKey = *signingKey, .generated = std::nullopt};
}
}
return keys;
}
void
SigningKeys::writeToFile(std::filesystem::path const& keyFile, OwnerOnlyFile::Existing existing)
const
{
namespace fs = std::filesystem;
json::Value jv;
jv["key_type"] = to_string(keyType_);
jv["public_key"] = toBase58(TokenType::NodePublic, keys_.publicKey);
jv["secret_key"] =
keys_.secretKey ? toBase58(TokenType::NodePrivate, *keys_.secretKey) : "external";
jv["token_sequence"] = json::UInt(tokenSequence_);
jv["revoked"] = revoked_;
if (!domain_.empty())
jv["domain"] = domain_;
if (!manifest_.empty())
jv["manifest"] = strHex(makeSlice(manifest_));
if (pending_)
{
if (auto const& generated = pending_->generated)
{
jv["pending_key_type"] = to_string(generated->keyType);
jv["pending_token_secret"] = toBase58(TokenType::NodePrivate, generated->secretKey);
}
else
{
jv["pending_signing_key"] = toBase58(TokenType::NodePublic, pending_->signingKey);
}
}
if (auto const parent = keyFile.parent_path(); !parent.empty())
{
// Each missing directory is made owner-only by mkdir itself; existing ones stay as
// they are.
fs::path made;
for (auto const& component : parent)
{
made /= component;
if (::mkdir(made.c_str(), kOwnerOnlyDirectory) != 0 && errno != EEXIST)
throw std::runtime_error("Cannot create directory: " + parent.string());
}
std::error_code ec;
if (!fs::is_directory(parent, ec))
throw std::runtime_error("Cannot create directory: " + parent.string());
}
OwnerOnlyFile file(keyFile, "key file", existing);
file.write(jv.toStyledString());
file.commit();
}
STObject
SigningKeys::partialManifest(std::uint32_t sequence, PublicKey const& signingKey) const
{
return makeManifestFields(keys_.publicKey, signingKey, sequence, domain_);
}
STObject
SigningKeys::partialRevocation() const
{
return makeRevocationFields(keys_.publicKey);
}
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;
st.add(s);
auto const previous = std::exchange(manifest_, std::vector<std::uint8_t>(s.begin(), s.end()));
try
{
checkManifest();
}
catch (std::runtime_error const&)
{
manifest_ = previous;
throw;
}
}
std::optional<Manifest>
SigningKeys::storedManifest() const
{
if (manifest_.empty())
return std::nullopt;
return deserializeManifest(manifest_);
}
std::string
SigningKeys::startToken(KeyType const& keyType, std::optional<PublicKey> const& externalSigningKey)
{
if (externalSigningKey && *externalSigningKey == keys_.publicKey)
throw std::runtime_error("The signing key must differ from the master key");
// ManifestCache::applyManifest looks the signing key up before it erases
// the previous manifest's entry, so a reused one is BadEphemeralKey.
if (auto const stored = storedManifest();
externalSigningKey && stored && stored->signingKey == *externalSigningKey)
{
throw std::runtime_error(
"The signing key is the current manifest's; a server rejects a manifest that "
"reuses it");
}
if (pending_)
{
// The pending manifest is fixed: the same request gets its bytes again so a
// lost signature can be made over them, and another request would discard
// the generated secret or the bytes an external signer already signed.
bool const same = externalSigningKey
? !pending_->generated && pending_->signingKey == *externalSigningKey
: pending_->generated && pending_->generated->keyType == keyType;
if (!same)
throw std::runtime_error(kPendingError);
auto const pending = *pending_;
return strHex(startPending(pending));
}
if (externalSigningKey)
{
return strHex(
startPending(Pending{.signingKey = *externalSigningKey, .generated = std::nullopt}));
}
auto const secret = generateSecretKey(keyType, randomSeed());
return strHex(startPending(
Pending{
.signingKey = derivePublicKey(keyType, secret),
.generated = GeneratedKey{.keyType = keyType, .secretKey = secret}}));
}
Blob
SigningKeys::startPending(Pending const& pending)
{
if (revoked_)
throw std::runtime_error(kRevokedError);
if (tokenSequence_ >= std::numeric_limits<std::uint32_t>::max() - 1)
throw std::runtime_error(kExhaustedError);
// A server keeps the highest sequence it has seen, so a token below the
// stored manifest would be stale.
if (auto const stored = storedManifest(); stored && stored->sequence > tokenSequence_)
{
throw std::runtime_error(
"The stored manifest has sequence " + std::to_string(stored->sequence) +
", above token_sequence " + std::to_string(tokenSequence_) +
"; a server would reject the next token as stale");
}
pending_ = pending;
return manifestSigningData(partialManifest(tokenSequence_ + 1, pending.signingKey));
}
SigningKeys::Finished
SigningKeys::finishToken(Blob const& masterSig, std::optional<Blob> const& signingSig)
{
if (revoked_)
throw std::runtime_error(kRevokedError);
if (!pending_)
throw std::runtime_error("No pending token to finish");
auto const pending = *pending_;
std::optional<SecretKey> secret;
if (pending.generated)
secret = pending.generated->secretKey;
return Finished{.manifest = finishPending(pending, masterSig, signingSig), .secret = secret};
}
std::string
SigningKeys::finishPending(
Pending const& pending,
Blob const& masterSig,
std::optional<Blob> const& signingSig)
{
STObject st = partialManifest(tokenSequence_ + 1, pending.signingKey);
if (auto const& generated = pending.generated)
{
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, generated->keyType, generated->secretKey);
}
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);
storeManifest(st);
++tokenSequence_;
pending_.reset();
return base64Encode(manifest_.data(), manifest_.size());
}
ValidatorToken
SigningKeys::createToken(KeyType const& keyType)
{
if (!keys_.secretKey)
throw std::runtime_error("This key file cannot be used to sign tokens.");
// Making a token would replace the pending one, discarding what its signer holds.
if (pending_)
throw std::runtime_error(kPendingError);
auto const secret = generateSecretKey(keyType, randomSeed());
Pending const pending{
.signingKey = derivePublicKey(keyType, secret),
.generated = GeneratedKey{.keyType = keyType, .secretKey = secret}};
auto const data = startPending(pending);
return ValidatorToken{
.manifest = finishPending(pending, masterSign(makeSlice(data)), std::nullopt),
.validationSecret = secret};
}
std::string
SigningKeys::startRevoke() const
{
return strHex(manifestSigningData(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
SigningKeys::revoke()
{
if (!keys_.secretKey)
{
throw std::runtime_error(
"This key file cannot sign a revocation: the master key is external; use "
"start_revoke_keys and finish_revoke_keys.");
}
return finishRevoke(masterSign(makeSlice(manifestSigningData(partialRevocation()))));
}
Blob
SigningKeys::masterSign(Slice const& data) const
{
if (!keys_.secretKey)
throw std::runtime_error(kNoSecretError);
auto const sig = xrpl::sign(keys_.publicKey, *keys_.secretKey, data);
return Blob(sig.begin(), sig.end());
}
std::string
SigningKeys::sign(std::string const& data) const
{
return strHex(masterSign(makeSlice(data)));
}
std::string
SigningKeys::signHex(std::string data) const
{
boost::algorithm::trim(data);
auto const blob = strUnHex(data);
if (!blob)
throw std::runtime_error("Could not decode hex string: " + data);
return strHex(masterSign(makeSlice(*blob)));
}
std::string
SigningKeys::attestationData() const
{
return "[domain-attestation-blob:" + domain_ + ":" +
toBase58(TokenType::NodePublic, keys_.publicKey) + "]";
}
void
SigningKeys::domain(std::string d)
{
if (pending_)
{
throw std::runtime_error(
"A token is pending: finish it with finish_token before changing the domain");
}
if (!d.empty() && !isProperlyFormedTomlDomain(d))
{
throw std::runtime_error(
"The domain field must use the '[host.][subdomain.]domain.tld' format");
}
domain_ = std::move(d);
}
} // namespace xrpl

View File

@@ -0,0 +1,347 @@
#pragma once
#include <xrpl/basics/Blob.h>
#include <xrpl/basics/Slice.h>
#include <xrpl/protocol/KeyType.h>
#include <xrpl/protocol/PublicKey.h>
#include <xrpl/protocol/STObject.h>
#include <xrpl/protocol/SecretKey.h>
#include <xrpl/server/Manifest.h>
#include <tools/validator-keys/OwnerOnlyFile.h>
#include <cstdint>
#include <filesystem>
#include <optional>
#include <string>
#include <vector>
namespace xrpl {
/**
* Returns the token as the base64 JSON object written to [validator_token].
*/
std::string
tokenToBase64(ValidatorToken const& token);
/**
* Whether two secret keys hold the same bytes. SecretKey deletes its
* comparison operators so secrets are compared only where it is deliberate.
*/
bool
sameSecret(SecretKey const& a, SecretKey const& b);
/**
* The master key of a validator or a validator-list publisher, as stored in
* 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 the master key never signs inside this process.
*/
class SigningKeys
{
private:
struct Keys
{
PublicKey publicKey;
// Unset when the master key is held by an external signer.
std::optional<SecretKey> secretKey;
Keys() = delete;
Keys(std::pair<PublicKey, SecretKey> const& p) : publicKey(p.first), secretKey(p.second)
{
}
Keys(PublicKey const& pub) : publicKey(pub), secretKey(std::nullopt)
{
}
};
// A signing key generated here, kept until its token is finished.
struct GeneratedKey
{
KeyType keyType;
SecretKey secretKey;
};
// A token started and not yet finished: its signing key and, unless an
// external signer holds that key, the key generated here.
struct Pending
{
PublicKey signingKey;
std::optional<GeneratedKey> generated;
};
KeyType const keyType_;
Keys const keys_;
std::vector<std::uint8_t> manifest_;
std::uint32_t tokenSequence_;
bool revoked_;
std::string domain_;
std::optional<Pending> 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<SecretKey> secret;
};
explicit SigningKeys(KeyType const& keyType);
SigningKeys(
KeyType const& keyType,
SecretKey const& secretKey,
std::uint32_t tokenSequence,
bool revoked = false);
/**
* Creates keys whose secret is held by an external signer.
*
* The key file is written with `"secret_key": "external"`.
*/
SigningKeys(
KeyType const& keyType,
PublicKey const& publicKey,
std::uint32_t tokenSequence = 0,
bool revoked = false);
/**
* Returns SigningKeys constructed from a JSON key file.
*
* @param keyFile Path to JSON key file
*
* @throws std::runtime_error if file content is invalid or the stored
* manifest is not a valid manifest for this key
*/
static SigningKeys
makeSigningKeys(std::filesystem::path const& keyFile);
~SigningKeys() = default;
SigningKeys(SigningKeys const&) = default;
SigningKeys&
operator=(SigningKeys const&) = delete;
bool
operator==(SigningKeys const& rhs) const;
/**
* 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
* @param existing `Refuse` to create a new key file only
*
* @throws std::runtime_error if the file cannot be written, or with
* `Existing::Refuse` it exists
*/
void
writeToFile(
std::filesystem::path const& keyFile,
OwnerOnlyFile::Existing existing = OwnerOnlyFile::Existing::Replace) const;
/**
* 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`.
*
* While a token is pending, the same call returns the pending token's
* bytes again, so a lost signature can be made over the same bytes.
*
* @throws std::runtime_error if the keys are revoked, the sequence is
* exhausted, the stored manifest's sequence is above the token
* sequence, the external signing key is the master key or the
* stored manifest's signing key, or a token with another signing
* key or key type is pending
*/
std::string
startToken(
KeyType const& keyType = KeyType::Secp256k1,
std::optional<PublicKey> 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<Blob> 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
*
* @throws std::runtime_error if the master key is external, a token is
* pending, the keys are revoked, the sequence is exhausted, or the
* stored manifest's sequence is above the token sequence
*/
ValidatorToken
createToken(KeyType const& keyType = KeyType::Secp256k1);
/**
* Returns the bytes a master signature over a revocation covers, as hex.
*/
[[nodiscard]] std::string
startRevoke() const;
/**
* Records the revocation and returns the base64 revocation manifest.
*
* @throws std::runtime_error if the signature does not verify
*/
std::string
finishRevoke(Blob const& masterSig);
/**
* Revokes the keys with the master secret in this key file.
*
* @throws std::runtime_error if the master key is external
*/
std::string
revoke();
/**
* Signs a string with the master key.
*
* @return The hex signature
*
* @throws std::runtime_error if the master key is external
*/
[[nodiscard]] std::string
sign(std::string const& data) const;
/**
* Signs hex-encoded bytes with the master key.
*
* @return The hex signature
*
* @throws std::runtime_error if the data is not hex or the master key is
* external
*/
[[nodiscard]] std::string
signHex(std::string data) const;
/**
* The string a domain attestation signs, for the domain of this key.
*/
[[nodiscard]] std::string
attestationData() const;
[[nodiscard]] PublicKey const&
publicKey() const
{
return keys_.publicKey;
}
/**
* True when the master secret is in the key file.
*/
[[nodiscard]] bool
hasSecret() const
{
return keys_.secretKey.has_value();
}
[[nodiscard]] bool
revoked() const
{
return revoked_;
}
[[nodiscard]] std::string const&
domain() const
{
return domain_;
}
/**
* Sets the domain the next manifest carries.
*
* @throws std::runtime_error if the domain is not well formed, or a token
* is pending, since its manifest is already fixed for signing
*/
void
domain(std::string d);
/**
* The last manifest generated, serialized; empty if none.
*/
[[nodiscard]] std::vector<std::uint8_t> const&
manifest() const
{
return manifest_;
}
[[nodiscard]] std::uint32_t
sequence() const
{
return tokenSequence_;
}
private:
[[nodiscard]] STObject
partialManifest(std::uint32_t sequence, PublicKey const& signingKey) const;
[[nodiscard]] STObject
partialRevocation() const;
/**
* The last manifest generated, deserialized; empty if none.
*/
[[nodiscard]] std::optional<Manifest>
storedManifest() const;
/**
* Fixes the next manifest for @p pending and returns the bytes both its
* signatures cover.
*
* @throws std::runtime_error if the keys are revoked, the sequence is
* exhausted, or the stored manifest's sequence is above the
* token sequence, which the next token would not exceed
*/
Blob
startPending(Pending const& pending);
/**
* Assembles and stores the manifest of @p pending from its signatures and
* returns it as base64.
*/
std::string
finishPending(
Pending const& pending,
Blob const& masterSig,
std::optional<Blob> const& signingSig);
/**
* Signs bytes with the master key.
*
* @throws std::runtime_error if the master key is external
*/
[[nodiscard]] Blob
masterSign(Slice const& data) const;
void
storeManifest(STObject const& st);
void
checkManifest() const;
};
} // namespace xrpl

View File

@@ -0,0 +1,245 @@
# Validator Keys Tool Guide
This guide explains how to set up a validator so its public key does not have to
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 an 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 xrpld config file.
Servers that trust the validator will adapt automatically when the token
changes.
## Validator Keys
When first setting up a validator, use the `validator-keys` tool to generate
its key pair:
```
$ validator-keys create_keys
```
Sample output:
```
Validator keys stored in /home/ubuntu/.xrpld/validator-keys.json
```
Without `--keyfile` the tool uses `~/.xrpld/validator-keys.json`. A key file
left at `~/.ripple/validator-keys.json` by an earlier version is still used
when `~/.xrpld/validator-keys.json` does not exist.
Create the key file on a local filesystem: it is written readable by its
owner only, and the tool refuses to create it on a filesystem without hard
links, such as FAT, exFAT or an SMB share without POSIX extensions.
Then copy it to a secure but recoverable location, such as an encrypted USB
flash drive. Do not modify its contents.
## Validator Token
After first creating the [validator keys](#validator-keys) or if the previous
token has been compromised, use the `validator-keys` tool to create a new
validator token:
```
$ validator-keys create_token
```
Sample output:
```
Update xrpld.cfg file with these values:
# validator public key: nHUtNnLVx7odrz5dnfb2xpIgbEeJPbzJWfdicSkGyVw1eE5GpjQr
[validator_token]
<base64 token, in 72-character lines>
```
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,294 tokens that can be generated for a given
validator key pair.
## Key Revocation
If a validator private key is compromised, the key must be revoked permanently.
To revoke the validator key, use the `validator-keys` tool to generate a
revocation, which indicates to other servers that the key is no longer valid:
```
$ validator-keys revoke_keys
```
Sample output:
```
WARNING: This will revoke your validator keys!
Update xrpld.cfg file with these values and restart xrpld:
# validator public key: nHUtNnLVx7odrz5dnfb2xpIgbEeJPbzJWfdicSkGyVw1eE5GpjQr
[validator_key_revocation]
<base64 revocation, in 72-character lines>
```
Add the `[validator_key_revocation]` value to this validator's config and
restart xrpld. Rename the old key file and generate new [validator keys](#validator-keys) and
a corresponding [validator token](#validator-token).
## Signing
The `validator-keys` tool can be used to sign arbitrary data with the validator
key.
```
$ validator-keys sign "your data to sign"
```
Sample output:
```
B91B73536235BBA028D344B81DBCBECF19C1E0034AC21FB51C2351A138C9871162F3193D7C41A49FB7AABBC32BC2B116B1D5701807BE462D8800B5AEA4F0550D
```
## External Signing
The master key can live in a hardware signer instead of the key file. The tool
then produces the bytes to sign, the hardware signs them, and the tool
assembles the result.
One-time setup, with the hardware key's public key in base58 (`nHB...`), hex
(`ED...`) or base64:
```
$ validator-keys create_external <public key>
```
The key file is written with `"secret_key": "external"`.
To create a token, print the bytes to sign, sign them externally, and pass the
hex or base64 signature back:
```
$ validator-keys start_token
$ validator-keys finish_token <signature>
```
The output is the same `[validator_token]` block `create_token` prints. A
revocation works the same way with `start_revoke_keys` and
`finish_revoke_keys <signature>`.
When the signing key is also held by the hardware, name it when starting the
token, in base58, hex or base64. The bytes are then signed twice, once by each
key, and the result is a manifest without a secret, printed as plain base64:
it is the manifest a [list publisher](#validator-list-publishers) passes to
`start_sign_list` and `finish_sign_list` with `--manifest-file`, so save it
with `--out manifest.txt`. It is not a `[validator_token]` and nothing in
`xrpld.cfg` takes it. Each token needs a new signing key: a server rejects a
manifest that names the signing key of the one it already holds.
```
$ validator-keys start_token --signing-key <signing public key>
$ validator-keys finish_token <master signature> <signing signature> --out manifest.txt
```
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:
```
$ validator-keys --keyfile other-keys.json sign_hex <bytes>
```
## Validator List Publishers
A publisher's keys are a master key and a signing key bound to it by a
manifest, exactly like a validator's. The token `create_token` prints holds
that manifest and the signing key, so a publisher's setup is:
```
$ validator-keys create_keys
$ validator-keys create_token --token-key-type ed25519 --out publisher-token.txt
```
`--token-key-type ed25519` matches what hardware signers support; 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
another tool must start `token_sequence` above the sequence of the manifest
currently published.
### Signing a list
The unsigned list is a JSON file with the fields a server reads:
```json
{
"sequence": 2026091301,
"expiration": 843955200,
"validators": [
{ "validation_public_key": "ED...", "manifest": "<base64 manifest>" }
]
}
```
`sequence` must rise with every published list, `expiration` and the optional
`effective` are seconds since the XRP Ledger epoch, and each validator's
`manifest` must belong to its `validation_public_key`. Whitespace is removed
and one space is placed after each `,` and `:` before signing.
```
$ validator-keys sign_list unsigned.json --token-file publisher-token.txt --out vl.json
```
`vl.json` is the version 1 document a server fetches: `blob`, `manifest`,
`public_key`, `signature`, `version`. Unlike a token, it is written readable
by everyone, since it is meant to be served. `--list-version 2` writes the
blob into `blobs_v2` instead, and `--append <existing.json>` adds it to a
version 2 document, which carries at most five blobs, so a list can be
published alongside the one it will replace. The appended list's `sequence`
must be above every blob's in the document, as a server ignores the rest.
`--out` may name the `--append` file: the document is replaced in one step
once the new one is complete.
When the signing key is held by a hardware signer, the list is signed in two
steps against the manifest from `finish_token`:
```
$ validator-keys start_sign_list unsigned.json --manifest-file manifest.txt
$ validator-keys finish_sign_list <signature> unsigned.json --manifest-file manifest.txt --out vl.json
```
### Checking a list
```
$ validator-keys verify_list vl.json --validators unsigned.json --expected-key <master public key>
```
The checks are the ones a server makes before trusting the list: the manifest
verifies and names `public_key`, every blob's signature verifies under the
manifest's signing key, every blob parses, and none has expired. `--validators`
requires every blob to list exactly the keys in the unsigned list, and
`--expected-key` requires the master key to be the one given. The result is
printed as JSON and the exit code is 0 only if every check passed.