mirror of
https://github.com/XRPLF/rippled.git
synced 2026-10-12 14:48:10 +00:00
Compare commits
32 Commits
nb-baseuin
...
dangell7/v
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ead061fadb | ||
|
|
22334aa7c9 | ||
|
|
8cd344b56c | ||
|
|
d1498b1849 | ||
|
|
fa8be41369 | ||
|
|
b89ec84283 | ||
|
|
910b25db47 | ||
|
|
b093cb5912 | ||
|
|
40b299652e | ||
|
|
bcd5b64659 | ||
|
|
c11a71349e | ||
|
|
7a84f1c0d0 | ||
|
|
59cb0adb37 | ||
|
|
1103fefdf8 | ||
|
|
38a0899a71 | ||
|
|
51cf1b8fc8 | ||
|
|
610b6e6555 | ||
|
|
1c55353bda | ||
|
|
f616b1b763 | ||
|
|
fa789d95fd | ||
|
|
3dc27ca77d | ||
|
|
5cda41fad0 | ||
|
|
88a9bc76bc | ||
|
|
5e7da075fa | ||
|
|
b51d8c1140 | ||
|
|
82f95502ac | ||
|
|
0467fd7144 | ||
|
|
3ffba1678e | ||
|
|
9d2428967d | ||
|
|
7bd7ea79dd | ||
|
|
728db41016 | ||
|
|
ff5aa537e1 |
@@ -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: "*"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
3
.github/scripts/rename/cmake.sh
vendored
3
.github/scripts/rename/cmake.sh
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
1
.github/workflows/reusable-clang-tidy.yml
vendored
1
.github/workflows/reusable-clang-tidy.yml
vendored
@@ -85,6 +85,7 @@ jobs:
|
||||
-Dtests=ON \
|
||||
-Dwerr=ON \
|
||||
-Dxrpld=ON \
|
||||
-Dvalidator_keys=ON \
|
||||
-Dverify_headers=ON \
|
||||
-Drust=ON \
|
||||
..
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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) ==
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
25
src/tests/tools/validator-keys/CMakeLists.txt
Normal file
25
src/tests/tools/validator-keys/CMakeLists.txt
Normal 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)
|
||||
764
src/tests/tools/validator-keys/Commands.cpp
Normal file
764
src/tests/tools/validator-keys/Commands.cpp
Normal 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
|
||||
120
src/tests/tools/validator-keys/Fixtures.cpp
Normal file
120
src/tests/tools/validator-keys/Fixtures.cpp
Normal 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
|
||||
89
src/tests/tools/validator-keys/Fixtures.h
Normal file
89
src/tests/tools/validator-keys/Fixtures.h
Normal 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
|
||||
931
src/tests/tools/validator-keys/ListSigning.cpp
Normal file
931
src/tests/tools/validator-keys/ListSigning.cpp
Normal 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
|
||||
650
src/tests/tools/validator-keys/SigningKeys.cpp
Normal file
650
src/tests/tools/validator-keys/SigningKeys.cpp
Normal 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
|
||||
28
src/tools/validator-keys/CMakeLists.txt
Normal file
28
src/tools/validator-keys/CMakeLists.txt
Normal 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})
|
||||
736
src/tools/validator-keys/Commands.cpp
Normal file
736
src/tools/validator-keys/Commands.cpp
Normal 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
|
||||
83
src/tools/validator-keys/Commands.h
Normal file
83
src/tools/validator-keys/Commands.h
Normal 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
|
||||
77
src/tools/validator-keys/LICENSE
Normal file
77
src/tools/validator-keys/LICENSE
Normal 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
|
||||
702
src/tools/validator-keys/ListSigning.cpp
Normal file
702
src/tools/validator-keys/ListSigning.cpp
Normal 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
|
||||
179
src/tools/validator-keys/ListSigning.h
Normal file
179
src/tools/validator-keys/ListSigning.h
Normal 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
|
||||
195
src/tools/validator-keys/Main.cpp
Normal file
195
src/tools/validator-keys/Main.cpp
Normal 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
|
||||
159
src/tools/validator-keys/OwnerOnlyFile.cpp
Normal file
159
src/tools/validator-keys/OwnerOnlyFile.cpp
Normal 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
|
||||
87
src/tools/validator-keys/OwnerOnlyFile.h
Normal file
87
src/tools/validator-keys/OwnerOnlyFile.h
Normal 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
|
||||
24
src/tools/validator-keys/README.md
Normal file
24
src/tools/validator-keys/README.md
Normal 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)
|
||||
585
src/tools/validator-keys/SigningKeys.cpp
Normal file
585
src/tools/validator-keys/SigningKeys.cpp
Normal 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
|
||||
347
src/tools/validator-keys/SigningKeys.h
Normal file
347
src/tools/validator-keys/SigningKeys.h
Normal 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
|
||||
245
src/tools/validator-keys/doc/validator-keys-tool-guide.md
Normal file
245
src/tools/validator-keys/doc/validator-keys-tool-guide.md
Normal 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.
|
||||
Reference in New Issue
Block a user