mirror of
https://github.com/savoirfairelinux/opendht.git
synced 2026-11-09 04:49:14 +00:00
Compare commits
75
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ccb0b4340e | ||
|
|
fa78704062 | ||
|
|
625625a186 | ||
|
|
f0d039b4a0 | ||
|
|
d464214d73 | ||
|
|
b69c66feed | ||
|
|
3d9ee6a51a | ||
|
|
f71697c81b | ||
|
|
b071022283 | ||
|
|
487bc2fce2 | ||
|
|
758f280651 | ||
|
|
20464e6f16 | ||
|
|
d211de73e0 | ||
|
|
bde661a2d4 | ||
|
|
49ef8ee84d | ||
|
|
390528a1af | ||
|
|
97c9d58a5c | ||
|
|
e5903d5c94 | ||
|
|
65bbbead34 | ||
|
|
ffaf1e2ca3 | ||
|
|
af7d5c2770 | ||
|
|
c14858a82a | ||
|
|
1010a4c90f | ||
|
|
ea6bacf0a1 | ||
|
|
36f67747d7 | ||
|
|
b093dc0907 | ||
|
|
a06695734d | ||
|
|
35aa47029e | ||
|
|
4af12a4c2c | ||
|
|
3f77b6b820 | ||
|
|
858867dc56 | ||
|
|
fcb0632a41 | ||
|
|
3ba61e7e4e | ||
|
|
4ef2c21ce0 | ||
|
|
d98c66b2c0 | ||
|
|
dc09983b6b | ||
|
|
da00246fe8 | ||
|
|
e092e4d89f | ||
|
|
ed8131a3c6 | ||
|
|
f29c77cadf | ||
|
|
d90988cb6a | ||
|
|
3cf24c89d4 | ||
|
|
5e7bcd1189 | ||
|
|
8ac6baee22 | ||
|
|
f9bb4bcb40 | ||
|
|
35f486e035 | ||
|
|
0d5e4c761a | ||
|
|
efc372dc3f | ||
|
|
ddfbca3106 | ||
|
|
353c3d4e11 | ||
|
|
51324683e8 | ||
|
|
4633483e06 | ||
|
|
6aee7ceb5c | ||
|
|
32709230c6 | ||
|
|
7b526410dd | ||
|
|
fbff1eced4 | ||
|
|
d23cb42b27 | ||
|
|
e78e54b8c5 | ||
|
|
40560eec4e | ||
|
|
853bb1eeed | ||
|
|
22d87a0f0e | ||
|
|
dfb66c5a23 | ||
|
|
56fcc5b918 | ||
|
|
26b30d4162 | ||
|
|
791a667fe2 | ||
|
|
b693e3028b | ||
|
|
3e975d530a | ||
|
|
a32acbfa08 | ||
|
|
43147b8791 | ||
|
|
c1762af7e9 | ||
|
|
68b5996ba9 | ||
|
|
7bc287fe2f | ||
|
|
523dda8b3d | ||
|
|
1c32dd0efb | ||
|
|
1c914d8977 |
No files matched your search
@@ -130,36 +130,8 @@ the assembled npm archive on Node 22, 24, and 26 before release attachment.
|
||||
|
||||
## C and Rust Bindings
|
||||
|
||||
Enable `OPENDHT_C=ON` to build the C ABI. With `BUILD_TESTING=ON`, CTest runs
|
||||
`test_c`; Meson registers the same test as `CBindings` when `c` and `tests` are
|
||||
enabled. Rust regression and callback-lifetime tests run with Cargo; see
|
||||
[the Rust build guide](rust/BUILD.md) for build and library-path settings.
|
||||
|
||||
`dht_infohash` and `dht_pkid` have `uint32_t` alignment, matching the native hash
|
||||
types, while retaining their 20-byte and 32-byte sizes. Rebuild consumers against
|
||||
the updated header; foreign-language declarations must match this alignment.
|
||||
|
||||
C handles returned by constructors, imports, and reference functions are owned
|
||||
and require their matching delete/unref function. Data views, string getters,
|
||||
query fields, and callback arguments are borrowed: copy them or take a reference
|
||||
before returning from the callback. `dht_sockaddr_array_delete` releases both the
|
||||
public-address array and its elements.
|
||||
|
||||
Operation completion callbacks run once, including failure when pending work is
|
||||
discarded on runner teardown. Listener release callbacks run after the native
|
||||
registration is destroyed; deleting an operation token alone does not cancel its
|
||||
listener. Cancel first, then delete the consumed token. Startup-context release
|
||||
callbacks run after all native status/store callback captures are released, also
|
||||
on startup failure. Certificate-store callbacks must invoke the supplied emitter
|
||||
synchronously with borrowed certificates. Query-result views are callback-scoped.
|
||||
|
||||
`dht_runner_loop` drives non-threaded nodes. `dht_runner_join` waits for callbacks
|
||||
and allows restart; never call it from a DHT callback. For persisted shutdown,
|
||||
wait for the shutdown callback before joining. Push tokens can be polled without
|
||||
blocking and must be deleted separately. `dht_push_token_poll` returns `EAGAIN`
|
||||
while pending, 0 with the native outcome when ready, and an error code otherwise;
|
||||
consume a successful result only once. Native push outcomes are 0 through 3 for
|
||||
refresh/value events, 128 through 131 for ignored events, and 132 for errors.
|
||||
Enable `OPENDHT_C=ON` to build the C ABI.
|
||||
See [the Rust build guide](rust/BUILD.md) for build instructions.
|
||||
|
||||
## Windows/MSVC
|
||||
|
||||
@@ -181,19 +153,3 @@ cd opendht
|
||||
cmake --preset=vcpkg_manifest .
|
||||
cmake --build .
|
||||
```
|
||||
|
||||
### CI
|
||||
|
||||
Windows CI uses [lukka/run-vcpkg](https://github.com/lukka/run-vcpkg) to acquire
|
||||
and bootstrap the revision pinned by `default-registry.baseline` in
|
||||
`vcpkg-configuration.json`. CMake uses the action's `VCPKG_ROOT` and installs
|
||||
manifest dependencies on every run. Dependency-preparation jobs warm the binary
|
||||
cache before the Windows build matrix starts.
|
||||
|
||||
Only ABI-keyed binary package archives are cached, not installed trees. The
|
||||
supported [files provider](https://learn.microsoft.com/en-us/vcpkg/reference/binarycaching#files-provider)
|
||||
is persisted with `actions/cache`; it does not use the removed `x-gha` provider
|
||||
or require package-feed credentials. Cache keys include the target triplet,
|
||||
manifests, overlay ports, run ID, and attempt, with restore prefixes for reuse.
|
||||
Each run can save newly built archives without overwriting an immutable cache;
|
||||
vcpkg validates package ABIs before reuse.
|
||||
@@ -117,6 +117,7 @@ if (NOT MSVC)
|
||||
find_package (PkgConfig REQUIRED)
|
||||
pkg_search_module (GnuTLS REQUIRED IMPORTED_TARGET gnutls)
|
||||
pkg_search_module (Nettle REQUIRED IMPORTED_TARGET nettle)
|
||||
pkg_search_module (Hogweed REQUIRED IMPORTED_TARGET hogweed>=3.1)
|
||||
pkg_search_module (argon2 REQUIRED IMPORTED_TARGET libargon2)
|
||||
set(argon2_lib ", libargon2")
|
||||
pkg_search_module (Jsoncpp IMPORTED_TARGET jsoncpp)
|
||||
@@ -467,6 +468,7 @@ list (APPEND opendht_SOURCES
|
||||
src/routing_table.cpp
|
||||
src/node_cache.cpp
|
||||
src/network_engine.cpp
|
||||
src/transport.cpp
|
||||
src/securedht.cpp
|
||||
src/dhtrunner.cpp
|
||||
src/log.cpp
|
||||
@@ -491,6 +493,7 @@ list (APPEND opendht_HEADERS
|
||||
include/opendht/routing_table.h
|
||||
include/opendht/node_cache.h
|
||||
include/opendht/network_engine.h
|
||||
include/opendht/transport.h
|
||||
include/opendht/scheduler.h
|
||||
include/opendht/rate_limiter.h
|
||||
include/opendht/securedht.h
|
||||
@@ -613,6 +616,7 @@ else()
|
||||
PRIVATE
|
||||
PkgConfig::argon2
|
||||
PkgConfig::Nettle
|
||||
PkgConfig::Hogweed
|
||||
${SIMDUTF_LIBRARIES}
|
||||
PUBLIC
|
||||
${CMAKE_THREAD_LIBS_INIT}
|
||||
@@ -637,6 +641,7 @@ else()
|
||||
PRIVATE
|
||||
argon2
|
||||
nettle
|
||||
hogweed
|
||||
PUBLIC
|
||||
${CMAKE_THREAD_LIBS_INIT}
|
||||
${GNUTLS_LIBRARIES}
|
||||
@@ -869,6 +874,8 @@ if (BUILD_TESTING)
|
||||
tests/test_value.cpp
|
||||
tests/test_crypto.h
|
||||
tests/test_crypto.cpp
|
||||
tests/test_transport.h
|
||||
tests/test_transport.cpp
|
||||
tests/test_dhtrunner.h
|
||||
tests/test_dhtrunner.cpp
|
||||
tests/test_storage.h
|
||||
|
||||
+59
-1
@@ -27,6 +27,19 @@
|
||||
[\fB\-y\fR \fIendpoint\fR]
|
||||
[\fB\-z\fR \fIkey\fR]
|
||||
[\fB\-u\fR \fIbundleid\fR]
|
||||
[\fB\-\-no\-encryption\fR]
|
||||
[\fB\-\-transport\-key\fR \fIfile\fR]
|
||||
[\fB\-\-network\-authority\fR \fIkey\fR]
|
||||
[\fB\-\-network\-credential\fR \fIfile\fR]
|
||||
.br
|
||||
.B dhtnode
|
||||
\fB\-\-gen\-network\-authority\fR \fIprefix\fR
|
||||
.br
|
||||
.B dhtnode
|
||||
\fB\-\-issue\-credential\fR \fIfile\fR
|
||||
\fB\-\-authority\-key\fR \fIfile\fR
|
||||
\fB\-\-transport\-key\fR \fIfile\fR
|
||||
[\fB\-\-validity\fR \fIdays\fR]
|
||||
.SH DESCRIPTION
|
||||
Runs an OpenDHT node, with an interactive CLI (default) or as a daemon (with \fB'-d'\fP or \fB'-s'\fP).
|
||||
Commands available in the interactive shell are:
|
||||
@@ -122,7 +135,9 @@ Disable rate limiting (allows unlimited requests and storage).
|
||||
Enable public stable mode.
|
||||
.TP
|
||||
\fB\-f\fP \fIpath\fP, \fB\-\-persist\fP \fIpath\fP
|
||||
Specify a file path to persist/load the node state (routing table and values).
|
||||
Specify a file path to persist/load the node state (routing table, values and
|
||||
generated transport key). State is written to a temporary file and renamed
|
||||
over the destination only after the write succeeds. Save failures are logged.
|
||||
.TP
|
||||
\fB\-n\fP \fInetwork_id\fP, \fB\-\-net\fP \fInetwork_id\fP
|
||||
Specify the network id. This lets you connect to distinct networks and prevents
|
||||
@@ -164,6 +179,40 @@ Device key for push notifications.
|
||||
.TP
|
||||
\fB\-u\fP \fIbundleid\fP, \fB\-\-bundleid\fP \fIbundleid\fP
|
||||
Bundle ID for push notifications.
|
||||
.SH TRANSPORT ENCRYPTION
|
||||
The DHT protocol is encrypted with nodes supporting it, and stays in plaintext
|
||||
with older nodes. The node ID derives from the transport key.
|
||||
.TP
|
||||
\fB\-\-no\-encryption\fP
|
||||
Disable protocol encryption, using a random node ID like older versions.
|
||||
.TP
|
||||
\fB\-\-transport\-key\fP \fIfile\fP
|
||||
Load the transport key from \fIfile\fP, or create it if it doesn't exist.
|
||||
Keeps the same node ID across restarts. Otherwise, a key is generated and kept
|
||||
in the node state if \fB\-\-persist\fP is used, or generated on each start.
|
||||
.TP
|
||||
\fB\-\-network\-authority\fP \fIkey\fP
|
||||
Join a private network: only accept nodes with a credential issued by the
|
||||
authority with public key \fIkey\fP (hexadecimal). Can be repeated.
|
||||
Requires \fB\-\-network\-credential\fP and \fB\-\-transport\-key\fP.
|
||||
.TP
|
||||
\fB\-\-network\-credential\fP \fIfile\fP
|
||||
Credential of our transport key, issued by a network authority.
|
||||
.TP
|
||||
\fB\-\-gen\-network\-authority\fP \fIprefix\fP
|
||||
Generate a network authority, save its secret key to \fIprefix\fP.key and its
|
||||
public key to \fIprefix\fP.pub, and exit. Refuses to overwrite either existing
|
||||
output path, including a dangling symlink.
|
||||
.TP
|
||||
\fB\-\-issue\-credential\fP \fIfile\fP
|
||||
Issue a credential for the key in \fB\-\-transport\-key\fP (created if needed),
|
||||
signed with \fB\-\-authority\-key\fP, save it to \fIfile\fP and exit.
|
||||
.TP
|
||||
\fB\-\-authority\-key\fP \fIfile\fP
|
||||
Network authority secret key used to issue credentials.
|
||||
.TP
|
||||
\fB\-\-validity\fP \fIdays\fP
|
||||
Validity of issued credentials (default: 365 days).
|
||||
.SH EXAMPLES
|
||||
Run a node using bootstrap.jami.net as a bootstrap:
|
||||
.PP
|
||||
@@ -188,6 +237,15 @@ Use a proxy client to connect to a proxy server:
|
||||
.EX
|
||||
dhtnode --proxyclient dhtproxy.jami.net:80
|
||||
.EE
|
||||
.PP
|
||||
Create a private network and run one of its nodes:
|
||||
.PP
|
||||
.EX
|
||||
dhtnode --gen-network-authority net
|
||||
dhtnode --issue-credential node1.cred --authority-key net.key --transport-key node1.key
|
||||
dhtnode --transport-key node1.key --network-credential node1.cred \\
|
||||
--network-authority $(cat net.pub) -b 192.0.2.1:4222
|
||||
.EE
|
||||
.SH AUTHORS
|
||||
.TP
|
||||
Program written by
|
||||
|
||||
@@ -0,0 +1,564 @@
|
||||
# Transport encryption: cryptographic design
|
||||
|
||||
This document specifies how OpenDHT encrypts and authenticates the UDP DHT protocol between nodes (the "transport"), and how private networks restrict participation with a small public-key infrastructure.
|
||||
|
||||
Implementation: [include/opendht/transport.h](../include/opendht/transport.h), [src/transport.cpp](../src/transport.cpp), and the send/receive paths in [src/network_engine.cpp](../src/network_engine.cpp).
|
||||
|
||||
## 1. Scope
|
||||
|
||||
### Goals
|
||||
|
||||
- **Confidentiality and integrity** of DHT protocol messages exchanged between two nodes that both support encryption.
|
||||
- **Authenticated node IDs**: a node can only send encrypted messages under the node ID that derives from its own key.
|
||||
- **Backward compatibility**: nodes without this feature keep working with new nodes, in plaintext.
|
||||
- **Optional private networks**: when configured, a node only talks to nodes holding a credential signed by a trusted authority.
|
||||
- **Permanent membership**: grants do not expire. Without a compromise or an intentional membership change, no grant renewal, authority rotation, or operator maintenance is required.
|
||||
- **Forward secrecy**: recorded traffic stays confidential after a node's long-term key is compromised, once the ephemeral keys used for it are erased (at most one hour later). The first packet to a peer has weaker guarantees ([§5.2](#52-key-schedule)).
|
||||
- **Address validation**: a Session or Data envelope proves that its sender receives packets at its source address. It replaces the legacy write tokens between encrypted nodes ([§6.6](#66-write-authorization)).
|
||||
- **Low cost**: routing discovery also establishes the session, with a few X25519 operations per peer every 30 minutes. Once a session is established, 30 bytes of overhead per packet, minus the 40-byte token in messages that carried one.
|
||||
|
||||
### Non-goals
|
||||
|
||||
- **End-to-end protection of values.** Encryption is hop-by-hop. Nodes storing a value see it in clear. Use `SecureDht` (signed or encrypted values) for end-to-end protection. `SecureDht` identities are unrelated to transport keys.
|
||||
- **Hiding metadata**: packet sizes, timing and addresses are visible, and so are the sender's public keys until a session is established.
|
||||
- The REST proxy (`DhtProxyServer`/`DhtProxyClient`) and local peer discovery are not covered.
|
||||
|
||||
## 2. Notation
|
||||
|
||||
| Notation | Meaning |
|
||||
|---|---|
|
||||
| `a ‖ b` | Concatenation of byte strings |
|
||||
| `"text"` | ASCII bytes, without terminator |
|
||||
| `u32be(x)` | Unsigned integer, big-endian, 4 bytes |
|
||||
| `sk_A`, `pk_A` | Static transport secret and public key of node A |
|
||||
| `esk_A`, `epk_A` | Ephemeral secret and public key of node A for the address of its peer |
|
||||
| `DH(x, Y)` | `X25519(x, Y)` |
|
||||
| `N` | Network ID (`Config::network`, 32-bit, `0` by default) |
|
||||
| `K_A→B` | Key encrypting envelopes from A to B |
|
||||
|
||||
All keys are 32 bytes. Integers on the wire are big-endian.
|
||||
|
||||
## 3. Primitives
|
||||
|
||||
| Purpose | Primitive | Reference |
|
||||
|---|---|---|
|
||||
| Key agreement | X25519 | RFC 7748 |
|
||||
| Key derivation | HKDF-SHA256 | RFC 5869 |
|
||||
| Packet encryption | ChaCha20-Poly1305 AEAD, 96-bit nonce, 128-bit tag | RFC 8439 |
|
||||
| Node ID | SHA-256, truncated to 160 bits | FIPS 180-4 |
|
||||
| Credential signature | Ed25519 | RFC 8032 |
|
||||
| Randomness | `gnutls_rnd` (`GNUTLS_RND_KEY` for keys, `GNUTLS_RND_NONCE` for nonces) | |
|
||||
|
||||
Primitives come from Nettle (libnettle and libhogweed) and GnuTLS, which OpenDHT already depends on. All primitives are used through their standard interfaces with no modification. The authentication tag is compared in constant time. Secret keys are erased with `gnutls_memset`.
|
||||
|
||||
## 4. Keys and identities
|
||||
|
||||
### 4.1 Transport key
|
||||
|
||||
Each node has a static X25519 key pair, its long-term identity:
|
||||
|
||||
```
|
||||
sk = 32 random bytes
|
||||
pk = X25519(sk, 9)
|
||||
```
|
||||
|
||||
The key is either given in `Config::transport.key`, or generated on start. A generated key is saved in the node state when `Config::persist_path` is set (see [§8](#8-key-persistence)). Otherwise, it changes on every restart, and so does the node ID.
|
||||
|
||||
The transport key is used only for the transport. It is independent of the `SecureDht` identity (an RSA certificate).
|
||||
|
||||
### 4.2 Node ID
|
||||
|
||||
When transport encryption is enabled, the node ID is derived from the public key:
|
||||
|
||||
```
|
||||
nodeId(pk) = SHA-256("opendht-node-id-v1" ‖ pk)[0..20)
|
||||
```
|
||||
|
||||
`Config::node_id` is ignored in that case. This binding is what authenticates node IDs: a receiver checks that the ID claimed inside a decrypted message equals `nodeId(pk)` of the static key the envelope was authenticated with (see [§6.5](#65-receiving)). Holding the ID therefore requires holding `sk`.
|
||||
|
||||
When encryption is disabled (`TransportConfig::enabled = false`), the node uses the legacy behavior: a random ID, or `Config::node_id`.
|
||||
|
||||
### 4.3 Network authority (private networks)
|
||||
|
||||
An authority is an Ed25519 key pair. Its secret is the 32-byte Ed25519 seed. Its public key identifies the private network, and nodes are configured with a list of trusted authority public keys (`TransportConfig::authorities`). A network is private if and only if this list is non-empty.
|
||||
|
||||
Any one trusted authority can grant membership. A node can trust multiple authorities simultaneously, allowing old and replacement grants to coexist during an explicit transition. There is no scheduled authority rotation.
|
||||
|
||||
### 4.4 Credential (private networks)
|
||||
|
||||
A membership grant, called a credential in the API and CLI, is an authority's signature over a transport public key. It never expires and contains no timestamp. It is 97 bytes:
|
||||
|
||||
```
|
||||
offset size field
|
||||
0 1 version = 0x02
|
||||
1 32 pk transport public key of the node
|
||||
33 64 signature Ed25519 signature by the authority
|
||||
```
|
||||
|
||||
The signature covers:
|
||||
|
||||
```
|
||||
"opendht-credential-v2" ‖ 0x02 ‖ pk
|
||||
```
|
||||
|
||||
A credential is **valid** when its signature verifies under at least one trusted authority. Admission does not depend on a system clock. The same grant can be reused across restarts and session-key rotations without an update.
|
||||
|
||||
Version 1 expiring credentials are rejected, not reinterpreted as permanent grants. Migrating from that format requires issuing a version 2 grant once.
|
||||
|
||||
Credentials are not bound to a network ID. An authority key therefore defines a network: use distinct authorities for distinct networks.
|
||||
|
||||
## 5. Key agreement
|
||||
|
||||
### 5.1 Ephemeral keys
|
||||
|
||||
Each node has an epoch secret `S`: 32 random bytes, replaced every 30 minutes (an *epoch*). Its ephemeral key for a peer at address `addr` derives from it:
|
||||
|
||||
```
|
||||
addr = IP (4 bytes for IPv4, including IPv4-mapped IPv6, else 16 bytes) ‖ port (2 bytes, network order)
|
||||
esk(addr) = HMAC-SHA256(key = S, "opendht-address-key-v1" ‖ addr)
|
||||
epk(addr) = X25519(esk(addr), 9)
|
||||
```
|
||||
|
||||
- The node always sends to `addr` with `epk(addr)` from its **current** epoch.
|
||||
- It still accepts envelopes addressed to its key from the **previous** epoch, so peers have one epoch to learn the new key.
|
||||
- On rotation, the previous epoch secret and every key derived from it are erased.
|
||||
|
||||
An ephemeral secret therefore lives at most one hour. Epoch secrets are never persisted, and a restart starts with a new one.
|
||||
|
||||
Nodes learn each other's ephemeral key from the header of every envelope ([§6.1](#61-envelope)). A node records a peer's ephemeral key only from an envelope it authenticated.
|
||||
|
||||
`epk(addr)` is only ever sent to `addr`. Learning it therefore requires receiving packets at `addr`, or observing the path to it. This is what makes a Session envelope a proof of address ([§6.6](#66-write-authorization)). Deriving keys per address costs one more X25519 base-point multiplication per new address, and nothing per packet.
|
||||
|
||||
### 5.2 Key schedule
|
||||
|
||||
Each envelope from a sender A to a receiver B is encrypted with a key `K_A→B`, derived with HKDF-SHA256 (RFC 5869):
|
||||
|
||||
```
|
||||
prk = HMAC-SHA256(key = "opendht-transport-v2", ikm)
|
||||
K_A→B = HMAC-SHA256(key = prk, info ‖ 0x01)
|
||||
```
|
||||
|
||||
The input keying material `ikm` and `info` depend on what A knows about B.
|
||||
|
||||
**Session**: A knows B's ephemeral key `epk_B`:
|
||||
|
||||
```
|
||||
ikm = DH(esk_A, epk_B) ‖ DH(esk_A, pk_B) ‖ DH(sk_A, epk_B) ‖ DH(sk_A, pk_B)
|
||||
info = u32be(N) ‖ 0x02 ‖ pk_A ‖ epk_A ‖ pk_B ‖ epk_B
|
||||
```
|
||||
|
||||
**Initial**: A doesn't know B's ephemeral key, typically on first contact:
|
||||
|
||||
```
|
||||
ikm = DH(esk_A, pk_B) ‖ DH(sk_A, pk_B)
|
||||
info = u32be(N) ‖ 0x01 ‖ pk_A ‖ epk_A ‖ pk_B
|
||||
```
|
||||
|
||||
B computes the same values with its own secrets, since `DH(x_A, Y_B) = DH(y_B, X_A)`. Any DH result equal to zero (a low-order public key) is rejected.
|
||||
|
||||
Properties:
|
||||
|
||||
- **Directional keys.** `info` orders the keys as sender, then receiver, so `K_A→B ≠ K_B→A`. Each node derives both from the same four DH values.
|
||||
- **Authentication.** Every key includes `DH(sk_A, pk_B)`, so only the holders of `sk_A` or `sk_B` can produce or read the envelope. Every envelope thus proves possession of the sender's static key.
|
||||
- **Forward secrecy.** A Session key includes `DH(esk_A, epk_B)`. Once both ephemeral secrets are erased, the key cannot be recomputed, even with both static secrets.
|
||||
- **Key-compromise impersonation resistance.** With `sk_B`, an attacker still cannot impersonate A to B in a Session envelope: it cannot compute `DH(sk_A, epk_B)` without `sk_A` or `esk_B`.
|
||||
- **Initial keys are weaker**, as for the first message of Noise IK. Without `epk_B`, an Initial key is only forward-secret against a later compromise of `sk_A`, not of `sk_B`. It also allows impersonating A to B with `sk_B`. Initial envelopes are only sent until A learns `epk_B`, usually for a single packet.
|
||||
- **Binding.** `info` includes `N`, the envelope type and all public keys involved. Nodes with different network IDs cannot decrypt each other's packets.
|
||||
- **Cost.** A Session key costs four X25519 operations, plus one for our ephemeral key. It is cached in the epoch, so it is derived once per peer address and ephemeral key pair, once the peer has proven to receive at that address ([§6.5](#65-receiving)). An Initial key costs two X25519 operations, plus one for our ephemeral key, and is not cached. Each epoch caches at most 16384 sessions, evicting the least recently used; beyond that, keys are derived for each packet.
|
||||
|
||||
## 6. Wire protocol
|
||||
|
||||
### 6.1 Envelope
|
||||
|
||||
Initial and Session envelopes carry the sender keys:
|
||||
|
||||
```
|
||||
offset size field
|
||||
0 1 marker = 0xC1
|
||||
1 1 type: 1 = Initial, 2 = Session, 3 = Hello; | 0x80 if a credential is included
|
||||
2 32 pk_sender
|
||||
34 32 epk_sender
|
||||
66 12 nonce (random)
|
||||
78 len ciphertext
|
||||
78+len 16 Poly1305 tag
|
||||
```
|
||||
|
||||
Once the session is confirmed ([§6.4](#64-sending)), Data envelopes leave them out. The receiver finds the session keys from the source address:
|
||||
|
||||
```
|
||||
offset size field
|
||||
0 1 marker = 0xC1
|
||||
1 1 type = 4 (Data), never with a credential
|
||||
2 12 nonce (random)
|
||||
14 len ciphertext
|
||||
14+len 16 Poly1305 tag
|
||||
```
|
||||
|
||||
- The ciphertext is `ChaCha20-Poly1305(K_sender→receiver, nonce, plaintext, aad = header)`.
|
||||
- The header is bytes `[0, 66)`, or `[0, 2)` for Data: marker, type, and the sender keys. Because it is the associated data, an attacker cannot change the type, the credential flag or the sender keys without the tag check failing.
|
||||
- The plaintext is the msgpack-encoded DHT message. With the credential flag, it is preceded by the sender's 97-byte credential.
|
||||
- The overhead is 30 bytes for Data, 94 bytes for Initial and Session, or 191 bytes with a credential.
|
||||
- Nonces are 96 bits from the system CSPRNG. For `n` packets under one key, the probability of a nonce collision is about $n^2 / 2^{97}$. Keys change at least every epoch, so this is negligible.
|
||||
|
||||
`0xC1` is the one byte value that msgpack never uses. Every legacy OpenDHT message is a msgpack map, which starts with `0x80`–`0x8F` or `0xDE`–`0xDF`. This marker therefore unambiguously separates envelopes from plaintext messages:
|
||||
- legacy nodes fail to parse an envelope and drop it, without blacklisting the sender;
|
||||
- new nodes still parse plaintext messages normally.
|
||||
|
||||
### 6.2 Hello (private networks only)
|
||||
|
||||
`Hello` introduces a node to a peer whose key is unknown. This happens when pinging a bare address, typically a bootstrap node. A Hello is **not encrypted**:
|
||||
|
||||
```
|
||||
0xC1 ‖ 0x03 ‖ pk_sender ‖ epk_sender ‖ credential (97 bytes) ‖ msgpack ping
|
||||
```
|
||||
|
||||
The sender cannot encrypt to a key it doesn't know, and X25519 keys cannot sign, so **a Hello proves nothing about its sender**. Its credential travels in clear and can be copied. The receiver therefore:
|
||||
|
||||
- answers only a ping, and only if the credential is valid for `pk_sender`;
|
||||
- answers with a Session envelope to `pk_sender` and `epk_sender`, so only the holder of `sk_sender` can read the answer;
|
||||
- keeps no state from the Hello: it does not add, move or authorize the claimed node.
|
||||
|
||||
The sender proves possession of its key with its next envelope.
|
||||
|
||||
### 6.3 Key advertisement in plaintext messages
|
||||
|
||||
New nodes add fields to plaintext msgpack messages. Legacy nodes ignore unknown fields.
|
||||
|
||||
| Field | Location | Content |
|
||||
|---|---|---|
|
||||
| `k` | top level | Sender's transport public key (bin, 32 bytes) |
|
||||
| `k4` | reply body, next to `n4` | Concatenated public keys of the listed IPv4 nodes, 32 bytes per node, all-zero if unknown |
|
||||
| `k6` | reply body, next to `n6` | Same for IPv6 |
|
||||
|
||||
Rules for using these fields:
|
||||
|
||||
- **`k` is sent only when it is needed**: in public mode, in plaintext messages to nodes whose key is not yet known. Encrypted messages do not need it, because the sender key is in the envelope header.
|
||||
- **Listed keys are checked before use.** A key from `k`, `k4` or `k6` is attached to a node only if `nodeId(key)` equals the node's ID. Otherwise it is ignored.
|
||||
- **The binding limits what a dishonest node can do with a node list.** It cannot attach a forged key to an existing node ID. It can still list a wrong address, which at worst causes encrypted packets to be sent to the wrong host. That host cannot read them.
|
||||
- `k4` and `k6` are omitted if no listed node has a known key. They are ignored if their length is not exactly 32 × the number of listed nodes.
|
||||
|
||||
### 6.4 Sending
|
||||
|
||||
To send a message to node B:
|
||||
|
||||
1. If transport encryption is disabled, send plaintext.
|
||||
2. If B's static key is known (B has a peer record), send an envelope with our ephemeral key for B's address:
|
||||
- a Data envelope if our session with B's address is **confirmed**;
|
||||
- otherwise a Session envelope if B's ephemeral key is known, or else an Initial envelope. In a private network, both include our credential.
|
||||
3. Else, in a private network:
|
||||
- if B's ID is unknown (a ping to a bare address), send a `Hello`;
|
||||
- otherwise refuse to send (`EPERM`).
|
||||
4. Else (public network, key unknown), send plaintext, including `k`.
|
||||
|
||||
Once a node's key is known, nothing is ever sent to it in plaintext. Failed key agreement, including a low-order public key, refuses the send (`EPERM`); it never falls back to plaintext.
|
||||
|
||||
**Confirmation.** A session with an address is confirmed when B shows that it holds the session keys and accepted us:
|
||||
- B answers, in a Session or Data envelope, a request we sent to its node ID **in a Session or Data envelope**. B cached the session when it opened our envelope ([§6.5](#65-receiving)), and answered with keys of our current epoch. Requests sent in an Initial envelope or a Hello are excluded: B answered them without caching a session, so our next request is a Session envelope, and its answer confirms;
|
||||
- or we receive a Data envelope under our current epoch. B only sends one after we confirmed it the same way.
|
||||
|
||||
The confirmation is lost when the session keys change: on epoch rotation, or when B's ephemeral key changes through accepted traffic. An Initial request leaves existing state untouched and is answered without reusing a cached session or its confirmation.
|
||||
|
||||
**Establishment.** Only `ping` and `find_node` requests may precede address validation. Application requests wait until B's ephemeral key is known, or a successful control reply identifies a genuinely keyless legacy peer. Learning the ephemeral from an accepted Initial reply is enough to send a Session: neither address-indexed cache admission nor Data confirmation requires another handshake. Direct NetworkEngine application requests share one engine-owned ping; canceling a caller's ping cannot strand them. Private bare-address bootstrap remains a ping-only Hello.
|
||||
|
||||
**Recovery.** At the first response timeout, a request forgets B's ephemeral key and starts recovering the session, for example after B restarts. Application requests to B are withheld, including new requests, while one internal ping retries in Initial envelopes (with our credential in a private network). `find_node` remains available and its successful reply can also release the waiters. An accepted reply completes its own request before held work is released, so a delayed original reply is not retransmitted during attachment. Released requests receive fresh response timers; withholding consumes no transmission attempts. The internal probe's first timeout notifies waiting operations so searches can replace unresponsive peers promptly. Probe exhaustion or rejection terminates the waiters. Their payloads never enter Initial envelopes, and no `401` challenge/retry exchange is used.
|
||||
|
||||
**Value searches.** A cold peer first receives `find_node(search_target)`. Its reply supplies its ephemeral and closer candidates in the same exchange. The existing distance-ordered search dispatch contacts closer candidates immediately, within the existing four-active-peer limit; it does not wait for a SELECT reply from the farther responder. This routing probe has separate state: it cannot satisfy a GET, pagination, or an empty-result callback. Responders remain eligible for subsequent value retrieval. A cold holder typically needs three exchanges (`find_node`, SELECT IDs, GET by ID), while a session-ready holder retains its usual two. A routing probe's first timeout frees its active search slot while protocol retries continue, rather than waiting for final expiration.
|
||||
|
||||
Value data is fragmented so that each packet fits in the 1280-byte IPv6 minimum MTU: `1280 − 40 − 8 = 1232` bytes of UDP payload, less 48 bytes reserved for the msgpack framing of a fragment, less the overhead of the envelope the fragments are sent in. Fragments carry `1184` bytes of value data in plaintext, `1154` in Data envelopes, `1090` in Initial or Session envelopes, and `993` with a credential.
|
||||
|
||||
This sizes value fragments only. Complete replies and node lists are not yet subject to a whole-datagram MTU budget; node-list trimming and packet-size enforcement remain separate work.
|
||||
|
||||
### 6.5 Receiving
|
||||
|
||||
For each received packet, a node applies the following checks in order:
|
||||
|
||||
1. **Address filters.** Drop the packet if it comes from a martian or blacklisted address. This is unchanged.
|
||||
|
||||
2. **Envelopes.** If the packet starts with `0xC1`:
|
||||
1. If encryption is disabled, drop it.
|
||||
2. Drop it if its type is unknown, or if `pk_sender` is our own key. This rejects reflected packets.
|
||||
3. **Hello:**
|
||||
- Drop it outside private networks, or if it carries no message.
|
||||
- Apply the public-key limit, then verify the credential: its key must equal `pk_sender` and it must be valid. Drop the packet on failure.
|
||||
- Continue with steps 4 and 5, then answer as described in [§6.2](#62-hello-private-networks-only). The Hello is not processed any further.
|
||||
4. **Initial:** apply the public-key limit, derive the Initial key from `pk_sender` and `epk_sender`, and decrypt.
|
||||
5. **Session:** first try the keys cached in each epoch for (source address, `pk_sender`, `epk_sender`). Otherwise, apply the public-key limit, then for our current epoch then the previous one: derive the keys with our ephemeral key for the source address, and decrypt. Keep newly derived keys pending until the packet passes authorization and message validation.
|
||||
6. **Data:** decrypt with the session cached for the source address in our current epoch, then in the previous one. Its `pk_sender` and `epk_sender` are those of the session. This needs no key derivation and no public-key limit.
|
||||
7. Drop the packet if it was not decrypted.
|
||||
8. **Credential flag:** strip the 97-byte credential. In a private network, verify it as for Hello, unless the peer is already authorized; on success, record the peer as authorized, without an expiry.
|
||||
**No credential:** in a private network, drop the packet unless the peer is already authorized.
|
||||
9. Record `epk_sender` on a temporary peer record. It is **validated for the source address** if the envelope was a Session or Data envelope: opening it required our ephemeral key for that address, which was only ever sent there. Existing peer state is updated only after parsing, ID binding and the processing checks below.
|
||||
|
||||
**Session cache.** The epoch caches one session per source address, including its verified admission in private networks. Authorization therefore survives the release of a client-mode peer's node record. Newly derived Session keys enter the cache only after credential authorization, parsing, network and node ID checks, and basic fragment validation. Replies must match a pending request or listener. Admission is per packet, so subsequent Data fragments can decrypt before the whole message is assembled. Sending to the exact address for which the peer's ephemeral key is validated can also cache a session. Answers to Initial and Hello envelopes never use or change cached sessions: their source address may be spoofed. If both the peer record and cached admission are lost, the same permanent credential is verified again; no replacement grant is needed.
|
||||
|
||||
3. **Plaintext in a private network.** Drop any packet that is not an envelope.
|
||||
|
||||
4. **Parsing.** Parse the msgpack message and apply the network ID check. This is unchanged.
|
||||
|
||||
5. **Envelope ID binding.** For an envelope, the message's node ID must equal `nodeId(pk_sender)`, otherwise drop the message. Value data fragments are exempt because they carry no node ID. Fragment buffers are keyed by source address, transaction ID and sender key, with a separate keyless buffer for plaintext. Only parts from the same sender can join a header, and a response header must match a pending request or listener. Foreign-key or plaintext parts cannot reserve the expected sender's buffer, even when they arrive first. Initial envelopes carrying value fragments or fragmented-message headers are dropped: encrypted value data requires a Session or Data envelope.
|
||||
|
||||
6. **Plaintext with `k`.** For a plaintext message carrying `k`, attach the key to the sender if it matches the sender's ID.
|
||||
|
||||
7. **Processing.**
|
||||
- A Session or Data envelope can move the sender's node record to the packet's source address.
|
||||
- Initial requests use transient node records. Only `ping` and `find_node` receive successful replies; GET (including SELECT) and writes receive a short `401` error, and value updates are dropped. Rejected GETs never invoke storage or serialize values. These requests do not add or move an established node, count as activity, or change its ephemeral key or session confirmation.
|
||||
- Initial replies and errors are accepted only for a pending request at the expected source address, never for a listener. They cannot move a known keyed node to a different address.
|
||||
- Replies and Data envelopes can confirm the session ([§6.4](#64-sending)).
|
||||
|
||||
A **plaintext** message claiming the ID of a node whose key is known is untrusted, even from that node's recorded address:
|
||||
- it does not move the node record and does not count as activity from that node;
|
||||
- replies, errors, value updates and writes are dropped;
|
||||
- `ping` and `find_node` receive control replies using a transient node record and an Initial envelope to the known key; GET receives a short `401`, even when the plaintext packet is the first advertisement of a matching `k`. Truly keyless legacy GETs remain unchanged.
|
||||
|
||||
**Rate limits.** A node keeps separate budgets, each with a global quota and a quota per source address (per /64 prefix for IPv6):
|
||||
- **Session requests:** requests in Session or Data envelopes, whose source address is validated. `Config::max_req_per_sec`, by default 8192 per second, and 1024 per address.
|
||||
- **Other requests:** plaintext requests and requests in Initial or Hello envelopes, whose source address can be spoofed. `Config::max_unauth_req_per_sec`, with the same defaults. A flood of them does not affect requests from established peers.
|
||||
- **Public-key work:** one unit per packet needing a key agreement or a signature verification, charged before that work. `Config::max_crypto_per_sec`, by default 512 per second, and 64 per address. A unit covers the packet and its answer, including credential verification:
|
||||
- each Initial envelope (two X25519 operations, and five more to answer a request);
|
||||
- each Session envelope from an uncached (address, `pk_sender`, `epk_sender`) triple (five X25519 operations per epoch tried);
|
||||
- each cached Session envelope whose peer has not yet been authorized and needs credential verification (one charge, not an additional charge for packets already admitted for key agreement);
|
||||
- each Hello (one Ed25519 verification, and the key derivation of its answer);
|
||||
- each plaintext request from a sender whose key is known or advertised, answered in an Initial envelope (three X25519 operations).
|
||||
|
||||
Initial and uncached Session envelopes from an address where we await an answer, from the sender's node record or from a request sent by address, use a separate budget of the same size. Our own key exchanges thus complete during a flood.
|
||||
|
||||
Packets from peers with cached session keys cost one AEAD operation, except when a private-network credential needs verification. Each epoch caches at most 16384 sessions, evicting the least recently used one to store a new one. An evicted peer recovers after one retransmission. Only a peer that received a packet from us at its address can take a cache slot, so a slot costs the attacker a reachable address and a round trip.
|
||||
|
||||
### 6.6 Write authorization
|
||||
|
||||
`put`, `refresh` and `listen` create state on the receiver, and `listen` makes it send value updates to the requester's address. The receiver therefore requires proof that the requester receives packets at its source address. Without it, an attacker could spoof a victim's address to fill its storage quota, or direct update traffic to it.
|
||||
|
||||
- **Encrypted requests** must arrive in a **Session** or **Data** envelope. Our ephemeral key for the source address is only sent to that address ([§5.1](#51-ephemeral-keys)), so opening the envelope proves the sender received one of our packets there. Requests in Initial envelopes are refused with error `401`; normal senders establish the session through `ping` or `find_node` before sending application requests.
|
||||
- **Plaintext requests** must carry a token, as in the legacy protocol. The token is sent in answers to `find`, `get` and `listen`, and in value updates, to the address of the node record:
|
||||
|
||||
```
|
||||
token(addr) = HMAC-SHA256(key = S, "opendht-address-token-v1" ‖ addr)
|
||||
```
|
||||
|
||||
A token is accepted in the epoch it was issued and the next one. Legacy nodes treat tokens as opaque, so changing their derivation keeps compatibility.
|
||||
|
||||
Messages to and from encrypted nodes carry no token. This saves 40 bytes in each answer to `find`, `get` and `listen`, and in each `put`, `refresh`, `listen` and value update. A node that receives an encrypted answer may write to its sender.
|
||||
|
||||
## 7. Message flows
|
||||
|
||||
### 7.1 Public network
|
||||
|
||||
Encryption is opportunistic, and keys travel with node IDs.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant A as A (new)
|
||||
participant B as B (new)
|
||||
participant L as L (legacy)
|
||||
A->>B: plaintext ping + k = pk_A (B's key unknown)
|
||||
Note over B: attaches pk_A to A (nodeId check)
|
||||
B->>A: Initial envelope (pong)
|
||||
Note over A: decrypts, checks ID = nodeId(pk_B), learns epk_B
|
||||
A->>B: Session envelope (request)
|
||||
B->>A: Session envelope (reply)
|
||||
Note over A: session confirmed
|
||||
A->>B: Data envelope (request)
|
||||
Note over B: session confirmed
|
||||
B->>A: Data envelope (all further traffic)
|
||||
A->>L: plaintext + k (ignored by L)
|
||||
L->>A: plaintext
|
||||
```
|
||||
|
||||
- The first request to a node with an unknown key, typically a bootstrap ping, is in plaintext. Its reply is an Initial envelope. The next request and its reply use Session envelopes, and all later traffic uses Data envelopes.
|
||||
- Nodes learned through `k4`/`k6` get an Initial envelope as the first packet. Its answer is a Session envelope, but the answerer caches nothing yet: the next request is a Session envelope, and its answer confirms the session. Data envelopes follow.
|
||||
- Legacy nodes see plaintext from new nodes and are unaffected.
|
||||
|
||||
### 7.2 Private network
|
||||
|
||||
Only nodes with a credential participate. Nodes never send or accept plaintext.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant A
|
||||
participant B as B (bootstrap)
|
||||
A->>B: Hello(pk_A, epk_A, cred_A, ping)
|
||||
Note over B: verifies cred_A, keeps no state
|
||||
B->>A: Session(cred_B, pong)
|
||||
Note over A: verifies cred_B, authorizes B
|
||||
A->>B: Session(cred_A, request)
|
||||
Note over B: verifies cred_A, authorizes A, caches the session
|
||||
B->>A: Session(cred_B, reply)
|
||||
Note over A: session confirmed
|
||||
A->>B: Data (without credential)
|
||||
```
|
||||
|
||||
- **Credential exchange.** Each side attaches its credential to its Initial and Session envelopes. Data envelopes, sent once the peer has accepted us, never carry it.
|
||||
- **Authorization lifetime.** Admission does not expire. Session keys still rotate for forward secrecy, independently of membership. A lost admission record is recovered using the same grant, without operator action.
|
||||
- **Node lists.** Listed nodes without a valid matching key are discarded.
|
||||
- **Startup checks.** The node refuses to start (`DhtException`) if:
|
||||
- encryption is disabled while authorities are configured;
|
||||
- the configured credential does not match the transport key;
|
||||
- the credential is not valid for the configured authorities.
|
||||
|
||||
The credential and trusted authorities are read on start. Restart with the updated configuration when explicitly changing a grant or the trust set; this also discards cached admissions.
|
||||
|
||||
## 8. Key persistence
|
||||
|
||||
If the following conditions all hold, the static secret key is saved in the state file as the msgpack field `transport_key` (32 bytes):
|
||||
- encryption is enabled;
|
||||
- no key was configured;
|
||||
- `Config::persist_path` is set.
|
||||
|
||||
When the key is saved:
|
||||
- the state is written to a randomly named temporary file in the destination directory;
|
||||
- owner-only read/write permissions are requested before any secret data is written;
|
||||
- the temporary file is closed successfully before it is renamed over the state file;
|
||||
- a save failure is logged, the temporary file is removed, and the previous state file is left intact;
|
||||
- on start, the key is restored before any packet is exchanged, so the node keeps its ID across restarts.
|
||||
|
||||
The node also stores its ID in the state file, so older versions reading this state keep the same ID. A configured key is never written to the state file. Ephemeral keys and session keys are never persisted.
|
||||
|
||||
`dhtnode --transport-key <file>` loads the raw 32-byte secret from `<file>`. If the file does not exist, `dhtnode` creates it using the same temporary-file and permission handling. Unreadable or invalid existing keys are not replaced.
|
||||
|
||||
File operations use portable C++ filesystem APIs. Permission semantics depend on the platform; exclusive file creation and power-loss durability are not guaranteed. Use an owner-controlled directory and a single writer for each path.
|
||||
|
||||
## 9. Security analysis
|
||||
|
||||
### 9.1 Properties
|
||||
|
||||
Against an attacker who controls the network but holds no node's secret key:
|
||||
|
||||
- **Confidentiality and integrity.** Traffic between two nodes that know each other's keys is confidential and integrity-protected. The protection is IND-CCA2 and INT-CTXT, from ChaCha20-Poly1305 under keys derived from X25519.
|
||||
- **ID authentication.** An encrypted message under node ID `X` can only be produced by the holder of the static secret key behind `X`.
|
||||
- Producing a key for a given ID requires a SHA-256 preimage on 160 bits.
|
||||
- Choosing the first `b` bits of one's own ID costs about $2^b$ key generations. Legacy nodes choose their ID freely.
|
||||
- **Forward secrecy.** Session envelopes recorded by the attacker stay confidential if static keys are compromised later, once both ephemeral secrets are erased (at most one hour after use). Initial envelopes stay confidential if the sender's static key is compromised, but not the receiver's.
|
||||
- **Key-compromise impersonation.** With a node's static secret, an attacker can decrypt the Initial envelopes sent to it and impersonate other nodes to it in Initial envelopes, but not in Session envelopes.
|
||||
- **Address stickiness.** Spoofed plaintext, Hellos and replayed Initial envelopes cannot redirect a node record whose key is known. Moving it requires a Session or Data envelope from the new address (see [§6.2](#62-hello-private-networks-only) and [§6.5](#65-receiving)).
|
||||
- **Fragment integrity.** Once a fragmented message arrives in envelopes, only parts from the same sender key are merged. Spoofed plaintext or foreign-key parts cannot alter or block it ([§6.5](#65-receiving), step 5).
|
||||
- **Address validation.** Session and Data envelopes only open from the address our ephemeral key was sent to. An off-path attacker cannot send one from a spoofed address, even with its own valid static key, so it cannot `put`, `refresh` or `listen` on behalf of another address.
|
||||
- **Private-network admission.** Without a valid credential and its matching secret key, a node:
|
||||
- cannot read any traffic;
|
||||
- cannot get answers it can decrypt;
|
||||
- is never listed by honest nodes.
|
||||
- **Network separation.** Different network IDs derive different keys.
|
||||
- **DoS cost.** An unauthenticated Data envelope costs a lookup and at most two AEAD operations. Other envelopes pass the public-key limit before any key derivation or signature verification.
|
||||
|
||||
### 9.2 Limitations
|
||||
|
||||
Each item states who it applies to, what that party can do, and what it cannot do.
|
||||
|
||||
- **Encryption is hop-by-hop.** Nodes that store values read them. Only `SecureDht` protects values end to end ([§1](#1-scope)).
|
||||
|
||||
- **Public mode is trust on first use.** Applies in public networks, to an attacker who modifies traffic on the path between two nodes A and B before A knows B's key.
|
||||
- A node learns a peer's key from the first plaintext message that carries it with the peer's ID: `k` in a message from the peer, or `k4`/`k6` in a reply from any node. An attacker that removes these fields from every such message keeps A and B in plaintext, and reads and modifies their traffic. The downgrade ends with the first message carrying a key that the attacker misses: the node that receives it then sends only envelopes to that peer, which the attacker cannot alter, and treats plaintext claiming the peer's ID as untrusted ([§6.5](#65-receiving), step 7). The attacker can still drop their traffic.
|
||||
- The attacker cannot bind its own key to B's ID. It can rewrite B's plaintext messages under an ID and key of its own, so that A talks to the attacker's node at B's address, and the attacker relays to B. A then knows the attacker's node at that address, not B.
|
||||
- Until A knows a key for an ID, any plaintext sender can claim that ID from any address. The claim lasts until A learns the key: plaintext claiming the ID is untrusted from then on, and the first Session or Data envelope under it moves the node record to its sender's address.
|
||||
- A key received in an encrypted reply cannot be stripped. One received in a plaintext reply can, by an attacker on that reply's path.
|
||||
- The first request to a node with an unknown key, for example a bootstrap ping, is in plaintext.
|
||||
- Private networks are not affected: they never send or accept plaintext, and credentials authenticate keys.
|
||||
|
||||
- **Compromise of a static secret key.** Applies to whoever obtains `sk_B`, for example from B's key file or state file, for as long as B keeps that key.
|
||||
- It can act as B from any address, and talk to any node under B's ID.
|
||||
- It can read every Initial envelope ever sent to B, including recorded ones. Initial keys depend only on `sk_B` and the sender's public keys.
|
||||
- It can send Initial envelopes to B under any other node's ID. B answers `ping` and `find_node` in them, under keys the attacker cannot derive, and changes no state ([§6.5](#65-receiving), step 7). GET and writes are refused with `401`.
|
||||
- It cannot read Session or Data envelopes between B and other nodes, recorded or live, nor impersonate another node to B in them. That requires B's ephemeral secret, or the other node's static secret.
|
||||
- In a private network, it stays admitted for as long as B's granting authority is trusted. The credential is not secret: Hellos carry it in clear.
|
||||
- Compromise of a running node also exposes its epoch secrets, and with them all sessions of its current and previous epoch.
|
||||
|
||||
Recover by changing the key. This changes the node ID, and in a private network needs a new credential. Changing B's key alone does not exclude the compromised key: its grant remains valid until its authority is distrusted, as described below. The old key cannot be revoked in a public network.
|
||||
|
||||
- **Initial envelopes are not forward-secret against the receiver.** An Initial envelope stays confidential if the sender's static key is compromised later, but not if the receiver's is. Initial envelopes carry:
|
||||
- `ping` or `find_node` to a node whose ephemeral key is unknown, including their retransmissions;
|
||||
- control replies to plaintext requests advertising a key. Application requests wait for a Session ([§6.4](#64-sending)).
|
||||
|
||||
Routing targets and control traffic remain exposed to a later compromise of the receiver's static key. Application GET/SELECT and write payloads do not enter Initial envelopes. Answers to Initial envelopes are Session envelopes, and are forward-secret.
|
||||
|
||||
- **No replay protection.** Nodes keep no record of received nonces. A captured envelope is accepted again as long as the receiver holds the keys that open it:
|
||||
- **Initial envelopes** open from any address, for as long as the receiver's static key is unchanged. A replayed `ping` or `find_node` is answered again, to the replayer's source address, under keys the replayer cannot derive. GET and writes are refused with `401`, value updates are dropped, and replies and errors are accepted only for a request pending toward that address ([§6.5](#65-receiving), step 7). Reflection remains for control replies: anyone holding a captured Initial request, or a Hello, can make the receiver send an encrypted answer to an address of its choice. In a private network this includes outsiders, because the captured packet carries its sender's credential. Rate limits bound request frequency, not reply size; node-list packet-size limits remain separate work.
|
||||
- **Session and Data envelopes** open only from their original source address, which the replayer must spoof, and only until the epoch that opened them is erased: at most one hour. A replayed request is processed again as its sender, including a write the sender did make: a repeated `put`, `refresh` or `listen` extends the life of a value or of a listener. A replayed value update is accepted while its listen is open, and repeats it: the values it lists are added or refreshed again, and the expirations it announces are signaled again. A replayed reply or error is accepted only if its transaction ID is pending. Transaction IDs are 32-bit counters per peer with a random start, so an old ID is pending again only after $2^{32}$ requests to that peer.
|
||||
|
||||
- **On-path attackers get write authorization for the addresses they observe.** Applies to an attacker that reads the packets we send to an address `addr`. Our ephemeral key `epk(addr)` is in the clear header of every Initial and Session envelope we send there: at each first contact, retransmission and epoch rotation. Data envelopes do not carry it.
|
||||
- With its own key pair, the attacker can send us Session envelopes with source address `addr`, and they open. It then has what the owner of `addr` has: write authorization ([§6.6](#66-write-authorization)). Its `put` and `refresh` consume the storage quota of `addr`, and its `listen` directs value updates to `addr`, all under the attacker's own node ID. Each such envelope also replaces the session cached for `addr`, so Data envelopes from the real owner are dropped until its next retransmission establishes a new session ([§6.4](#64-sending), recovery).
|
||||
- It cannot impersonate the node at `addr`, nor read or alter its traffic. That requires the static secret of that node.
|
||||
- A captured `epk(addr)` is usable until the epoch that produced it is erased: at most one hour.
|
||||
- In a private network, the attacker's key must also hold a valid credential.
|
||||
|
||||
This is the same exposure as legacy tokens, which are sent in clear to the address they authorize. Address validation defends against off-path spoofing only.
|
||||
|
||||
- **Address changes.** Our ephemeral key for a peer is derived from its address. When a peer's address or port changes, for example after a NAT rebinding, its Session and Data envelopes to us no longer open, and ours no longer reach it. Its next retransmission is a ping in an Initial envelope, which starts a new session for the new address, and the request follows in a Session envelope ([§6.4](#64-sending), recovery).
|
||||
|
||||
- **No Sybil resistance.** An identity costs one key generation. Encryption prevents choosing a node ID ([§9.1](#91-properties)), not creating many of them. In public networks, legacy nodes still choose their IDs freely.
|
||||
|
||||
- **Denial of service.** Public-key operations on unauthenticated packets are bounded by the public-key limit ([§6.5](#65-receiving)): by default 512 packets per second globally, and 64 per source address or IPv6 /64 prefix.
|
||||
- An attacker spending that budget from many or spoofed addresses drops Initial, Hello and uncached Session envelopes from new peers, and plaintext requests from keyed senders. Answers to our own requests have a separate budget, which only an attacker spoofing the addresses of the peers we contact can spend. Data envelopes, cached sessions and requests from established peers are unaffected.
|
||||
- Likewise, a flood of plaintext or Initial requests drops other requests without a session, as in the legacy protocol, but not those of established peers.
|
||||
- A unit stands for three to ten X25519 operations, so the default allows about 5000 per second. `Config::max_crypto_per_sec` adapts it to the CPU available.
|
||||
- An attacker reachable at many addresses, for example an IPv6 prefix, can fill the session cache of an epoch: one slot costs it one round trip. Evicted peers have their Data envelopes dropped until their next retransmission, which starts a new session.
|
||||
|
||||
- **No per-grant revocation.** Applies to private networks. A grant is permanent: a compromised or excluded node can participate for as long as any authority that signed a grant for its key remains trusted. Compromise of an authority allows issuing arbitrary new grants. Exclusion requires every remaining node to remove the affected authority from its trust set and restart, discarding cached admissions. This also excludes every member relying only on that authority; retained members need grants from another trusted authority. Multiple accepted authorities provide a transition path, but do not neutralize a compromised authority while it is still trusted. There is no clock-based fallback, revocation list, or automatic expiry.
|
||||
|
||||
- **Metadata.** Anyone observing the traffic sees:
|
||||
- in Initial and Session envelopes, the sender's static key, hence its node ID, and its ephemeral key: who talks to whom, at each first contact, retransmission and epoch rotation;
|
||||
- in Data envelopes, only the type; the sender is still identified by its address, from the Session envelopes that preceded;
|
||||
- in every envelope, the type, the credential flag, the size and the timing, which can reveal the kind of message, for example fragmented value data;
|
||||
- Hello messages entirely, including the credential, which reveals the node's key. A copied credential is useless without the matching secret key;
|
||||
- in public networks, plaintext messages entirely, including node lists.
|
||||
|
||||
Node addresses are not hidden: they are in node lists, and on the packets themselves.
|
||||
|
||||
- **Node state.** The saved node list holds IDs and addresses, but no keys. In a public network, saved nodes are contacted again in plaintext, with the first-contact exposure above. In a private network, a saved node with an ID but no key cannot be contacted ([§6.4](#64-sending), step 3): a restarted node needs a bootstrap address to rejoin, and reuses saved nodes once it learns their keys from node lists.
|
||||
|
||||
## 10. Operating a private network
|
||||
|
||||
With `dhtnode`:
|
||||
|
||||
```sh
|
||||
# Once: create the authority (net.key = secret seed, net.pub = hex public key)
|
||||
dhtnode --gen-network-authority net
|
||||
|
||||
# Once per node: create its transport key and permanent membership grant
|
||||
dhtnode --transport-key node1.key --issue-credential node1.cred --authority-key net.key
|
||||
|
||||
# Run a node
|
||||
dhtnode --transport-key node1.key --network-credential node1.cred --network-authority $(cat net.pub)
|
||||
```
|
||||
|
||||
Authority generation refuses to run if either output path already exists, including a dangling symlink.
|
||||
|
||||
Keep the authority secret offline. Distribute only `net.pub` and the per-node credentials. Without a compromise or an intentional membership change, keep these keys, grants, and trust sets unchanged: there is no scheduled renewal or maintenance.
|
||||
|
||||
`--network-authority` can be repeated to trust several authorities during an explicit transition:
|
||||
|
||||
```sh
|
||||
dhtnode --transport-key node1.key --network-credential node1.cred \
|
||||
--network-authority $(cat net.pub) --network-authority $(cat replacement.pub)
|
||||
```
|
||||
|
||||
To move away from an authority:
|
||||
|
||||
1. Create a replacement authority and configure retained members to trust both old and replacement public keys. Restart them with this overlapping trust set.
|
||||
2. Issue replacement grants only to retained, uncompromised transport keys and restart those nodes with their new grants. Old and replacement grants interoperate while both authorities are trusted. A compromised transport key must be replaced as well.
|
||||
3. Remove the old authority from every retained node's configuration and restart them, clearing prior admissions. Only grants from the remaining trusted authorities are then accepted.
|
||||
|
||||
A compromised authority or a compromised key's old grant remains usable wherever the old authority is still trusted. The overlap is a transition mechanism, not immediate exclusion; it ends only when all retained nodes stop trusting the affected authority. Uncompromised grants from other authorities do not need updating.
|
||||
|
||||
From the library, set `Config::transport` before starting:
|
||||
- `authorities`;
|
||||
- `key`, which must be the key the credential was issued for;
|
||||
- `credential`.
|
||||
|
||||
## 11. Constants
|
||||
|
||||
| Name | Value |
|
||||
|---|---|
|
||||
| Envelope marker | `0xC1` |
|
||||
| Envelope types | `Initial = 1`, `Session = 2`, `Hello = 3`, `Data = 4` |
|
||||
| Credential flag | `0x80` |
|
||||
| Key, nonce, tag sizes | 32, 12, 16 bytes |
|
||||
| Envelope header | 66 bytes, 2 for Data |
|
||||
| Envelope overhead | 30 bytes for Data, 94 for Initial and Session (191 with credential) |
|
||||
| Epoch (ephemeral keys and tokens) | 30 minutes, previous epoch accepted for one more period |
|
||||
| Cached sessions per epoch | 16384, least recently used evicted |
|
||||
| Credential size, version | 97 bytes, `0x02` |
|
||||
| Node ID context | `"opendht-node-id-v1"` |
|
||||
| HKDF salt | `"opendht-transport-v2"` |
|
||||
| Credential signature context | `"opendht-credential-v2"` |
|
||||
| Ephemeral key, token contexts | `"opendht-address-key-v1"`, `"opendht-address-token-v1"` |
|
||||
| Token size | 32 bytes |
|
||||
| Value fragment size | 1184 bytes, less the envelope overhead (1154 to 993) |
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
#include "infohash.h"
|
||||
#include "value.h"
|
||||
#include "transport.h"
|
||||
|
||||
#include <vector>
|
||||
#include <memory>
|
||||
@@ -97,7 +98,7 @@ struct OPENDHT_PUBLIC NodeInfo
|
||||
*/
|
||||
struct OPENDHT_PUBLIC Config
|
||||
{
|
||||
/** DHT node ID */
|
||||
/** DHT node ID. Ignored if transport encryption is enabled: the node ID derives from the transport key. */
|
||||
InfoHash node_id {};
|
||||
|
||||
/**
|
||||
@@ -122,6 +123,20 @@ struct OPENDHT_PUBLIC Config
|
||||
/** If non-0, overrides the default per-IP address rate-limit. -1 means no limit. */
|
||||
ssize_t max_peer_req_per_sec {0};
|
||||
|
||||
/**
|
||||
* If non-0, override the rate-limits of requests from senders without an encrypted session
|
||||
* (default: same as above). -1 means no limit.
|
||||
*/
|
||||
ssize_t max_unauth_req_per_sec {0};
|
||||
ssize_t max_peer_unauth_req_per_sec {0};
|
||||
|
||||
/**
|
||||
* If non-0, override the rate-limits of packets needing a transport key agreement
|
||||
* or credential check. -1 means no limit.
|
||||
*/
|
||||
ssize_t max_crypto_per_sec {0};
|
||||
ssize_t max_peer_crypto_per_sec {0};
|
||||
|
||||
/* If non-0, overrides the default maximum number of searches. -1 means no limit. */
|
||||
ssize_t max_searches {0};
|
||||
|
||||
@@ -143,6 +158,9 @@ struct OPENDHT_PUBLIC Config
|
||||
|
||||
/* Client mode, node will not be used by other nodes to store data. */
|
||||
bool client_mode {false};
|
||||
|
||||
/** Encryption of the DHT protocol, and optional private network */
|
||||
TransportConfig transport {};
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
+10
-16
@@ -277,9 +277,6 @@ public:
|
||||
std::vector<ValuesExport> exportValues() const override;
|
||||
void importValues(const std::vector<ValuesExport>&) override;
|
||||
|
||||
void saveState(const std::string& path) const;
|
||||
void loadState(const std::string& path);
|
||||
|
||||
NodeStats getNodesStats(sa_family_t af) const override;
|
||||
|
||||
std::string getStorageLog() const override;
|
||||
@@ -367,8 +364,6 @@ private:
|
||||
|
||||
static constexpr std::chrono::seconds BOOTSTRAP_PERIOD {10};
|
||||
|
||||
static constexpr size_t TOKEN_SIZE {32};
|
||||
|
||||
// internal structures
|
||||
struct SearchNode;
|
||||
struct Get;
|
||||
@@ -379,13 +374,14 @@ private:
|
||||
Dht(const Dht&) = delete;
|
||||
Dht& operator=(const Dht&) = delete;
|
||||
|
||||
void saveState(const std::string& path) const;
|
||||
/* Only called on construction: may replace the transport key */
|
||||
void loadState(const std::string& path);
|
||||
|
||||
std::mt19937_64 rd;
|
||||
|
||||
InfoHash myid {};
|
||||
|
||||
uint64_t secret {};
|
||||
uint64_t oldsecret {};
|
||||
|
||||
// registred types
|
||||
TypeStore types;
|
||||
|
||||
@@ -438,6 +434,8 @@ private:
|
||||
net::NetworkEngine network_engine;
|
||||
|
||||
std::string persistPath;
|
||||
/* The transport key was generated: keep it in the persisted state */
|
||||
bool persistTransportKey {false};
|
||||
|
||||
// are we a bootstrap node ?
|
||||
// note: Any running node can be used as a bootstrap node.
|
||||
@@ -452,11 +450,6 @@ private:
|
||||
return public_stable ? LISTEN_EXPIRE_TIME_PUBLIC : LISTEN_EXPIRE_TIME;
|
||||
}
|
||||
|
||||
void rotateSecrets();
|
||||
|
||||
Blob makeToken(const SockAddr&, bool old) const;
|
||||
bool tokenMatch(const Blob& token, const SockAddr&) const;
|
||||
|
||||
void reportedAddr(const SockAddr&);
|
||||
|
||||
// Storage
|
||||
@@ -576,6 +569,8 @@ private:
|
||||
* @param query The query sent to the node.
|
||||
*/
|
||||
void searchNodeGetExpired(const net::Request& status, bool over, std::weak_ptr<Search> ws, Sp<Query> query);
|
||||
void searchNodeRoutingDone(const net::Request& req, net::RequestAnswer&& answer, std::weak_ptr<Search> ws);
|
||||
void searchNodeRoutingExpired(const net::Request& req, bool over, std::weak_ptr<Search> ws);
|
||||
|
||||
/**
|
||||
* This method recovers sends individual request for values per id.
|
||||
@@ -627,15 +622,14 @@ private:
|
||||
void onGetValuesDone(const Sp<Node>& status, net::RequestAnswer& a, Sp<Search>& sr, const Sp<Query>& orig_query);
|
||||
/* when we receive a listen request */
|
||||
net::RequestAnswer onListen(
|
||||
Sp<Node> node, const InfoHash& hash, const Blob& token, size_t socket_id, const Query& query, int version = 0);
|
||||
Sp<Node> node, const InfoHash& hash, size_t socket_id, const Query& query, int version = 0);
|
||||
void onListenDone(const Sp<Node>& node, net::RequestAnswer& a, Sp<Search>& sr);
|
||||
/* when we receive an announce request */
|
||||
net::RequestAnswer onAnnounce(Sp<Node> node,
|
||||
const InfoHash& hash,
|
||||
const Blob& token,
|
||||
const std::vector<Sp<Value>>& v,
|
||||
const time_point& created);
|
||||
net::RequestAnswer onRefresh(Sp<Node> node, const InfoHash& hash, const Blob& token, const Value::Id& vid);
|
||||
net::RequestAnswer onRefresh(Sp<Node> node, const InfoHash& hash, const Value::Id& vid);
|
||||
void onAnnounceDone(const Sp<Node>& status, net::RequestAnswer& a, Sp<Search>& sr);
|
||||
};
|
||||
|
||||
|
||||
@@ -12,13 +12,16 @@
|
||||
#include "rate_limiter.h"
|
||||
#include "logger.h"
|
||||
#include "network_utils.h"
|
||||
#include "transport.h"
|
||||
|
||||
#include <vector>
|
||||
#include <string>
|
||||
#include <functional>
|
||||
#include <algorithm>
|
||||
#include <list>
|
||||
#include <memory>
|
||||
#include <queue>
|
||||
#include <tuple>
|
||||
|
||||
namespace dht {
|
||||
namespace net {
|
||||
@@ -34,9 +37,18 @@ struct TransId;
|
||||
struct NetworkConfig
|
||||
{
|
||||
NetId network {0};
|
||||
/* Requests in Session or Data envelopes, from a validated address */
|
||||
ssize_t max_req_per_sec {0};
|
||||
ssize_t max_peer_req_per_sec {0};
|
||||
/* Other requests. 0: same as above */
|
||||
ssize_t max_unauth_req_per_sec {0};
|
||||
ssize_t max_peer_unauth_req_per_sec {0};
|
||||
/* Packets needing a key agreement or signature check. 0: same as max_req_per_sec */
|
||||
ssize_t max_crypto_per_sec {0};
|
||||
ssize_t max_peer_crypto_per_sec {0};
|
||||
bool is_client {false};
|
||||
/** Transport encryption is used only if enabled and a key is set */
|
||||
TransportConfig transport {};
|
||||
};
|
||||
|
||||
class DhtProtocolException : public DhtException
|
||||
@@ -83,7 +95,8 @@ struct ParsedMessage;
|
||||
*/
|
||||
struct RequestAnswer
|
||||
{
|
||||
Blob ntoken {};
|
||||
/* Write token of the node; empty if it needs none (encrypted), nullopt if not granted. */
|
||||
std::optional<Blob> ntoken {};
|
||||
Value::Id vid {};
|
||||
std::vector<Sp<Value>> values {};
|
||||
std::vector<Value::Id> refreshed_values {};
|
||||
@@ -160,34 +173,31 @@ private:
|
||||
*/
|
||||
std::function<RequestAnswer(Sp<Node>, const InfoHash&, want_t, const Query&)> onGetValues {};
|
||||
/**
|
||||
* Called on listen request.
|
||||
* Called on listen request, from a node proven to receive at its address.
|
||||
*
|
||||
* @param node (type: Sp<Node>) the requesting node.
|
||||
* @param h (type: InfoHash) hash of the value of interest.
|
||||
* @param token (type: Blob) security token.
|
||||
* @param rid (type: uint16_t) request id.
|
||||
*/
|
||||
std::function<RequestAnswer(Sp<Node>, const InfoHash&, const Blob&, Tid, const Query&, int)> onListen {};
|
||||
std::function<RequestAnswer(Sp<Node>, const InfoHash&, Tid, const Query&, int)> onListen {};
|
||||
/**
|
||||
* Called on announce request.
|
||||
* Called on announce request, from a node proven to receive at its address.
|
||||
*
|
||||
* @param node (type: Sp<Node>) the requesting node.
|
||||
* @param h (type: InfoHash) hash of the value of interest.
|
||||
* @param token (type: Blob) security token.
|
||||
* @param values (type: std::vector<Sp<Value>>) values to store.
|
||||
* @param created (type: time_point) time when the value was created.
|
||||
*/
|
||||
std::function<RequestAnswer(Sp<Node>, const InfoHash&, const Blob&, const std::vector<Sp<Value>>&, const time_point&)>
|
||||
std::function<RequestAnswer(Sp<Node>, const InfoHash&, const std::vector<Sp<Value>>&, const time_point&)>
|
||||
onAnnounce {};
|
||||
/**
|
||||
* Called on refresh request.
|
||||
* Called on refresh request, from a node proven to receive at its address.
|
||||
*
|
||||
* @param node (type: Sp<Node>) the requesting node.
|
||||
* @param h (type: InfoHash) hash of the value of interest.
|
||||
* @param token (type: Blob) security token.
|
||||
* @param vid (type: Value::id) the value id.
|
||||
*/
|
||||
std::function<RequestAnswer(Sp<Node>, const InfoHash&, const Blob&, const Value::Id&)> onRefresh {};
|
||||
std::function<RequestAnswer(Sp<Node>, const InfoHash&, const Value::Id&)> onRefresh {};
|
||||
|
||||
public:
|
||||
using RequestCb = std::function<void(const Request&, RequestAnswer&&)>;
|
||||
@@ -214,6 +224,15 @@ public:
|
||||
|
||||
net::DatagramSocket* getSocket() const { return dht_socket.get(); };
|
||||
|
||||
/** Our transport key, or nullptr if transport encryption is disabled */
|
||||
const transport::KeyPair* getTransportKey() const { return transport_ ? &config.transport.key : nullptr; }
|
||||
/** Replaces our transport key. Only valid before any message is exchanged. */
|
||||
void setTransportKey(const transport::KeyPair& key)
|
||||
{
|
||||
if (transport_)
|
||||
config.transport.key = key;
|
||||
}
|
||||
|
||||
void clear();
|
||||
|
||||
/**
|
||||
@@ -225,7 +244,6 @@ public:
|
||||
* @param socket_id The tid to use to write to the request socket.
|
||||
* @param hash The hash key of the value.
|
||||
* @param want Wether to send ipv4 and/or ipv6 nodes.
|
||||
* @param ntoken Listen security token.
|
||||
* @param nodes The ipv4 closest nodes.
|
||||
* @param nodes6 The ipv6 closest nodes.
|
||||
* @param values The values to send.
|
||||
@@ -235,7 +253,6 @@ public:
|
||||
Tid socket_id,
|
||||
const InfoHash& hash,
|
||||
want_t want,
|
||||
const Blob& ntoken,
|
||||
std::vector<Sp<Node>>&& nodes,
|
||||
std::vector<Sp<Node>>&& nodes6,
|
||||
std::vector<Sp<Value>>&& values,
|
||||
@@ -245,13 +262,11 @@ public:
|
||||
void tellListenerRefreshed(const Sp<Node>& n,
|
||||
Tid socket_id,
|
||||
const InfoHash& hash,
|
||||
const Blob& ntoken,
|
||||
const std::vector<Value::Id>& values,
|
||||
int version);
|
||||
void tellListenerExpired(const Sp<Node>& n,
|
||||
Tid socket_id,
|
||||
const InfoHash& hash,
|
||||
const Blob& ntoken,
|
||||
const std::vector<Value::Id>& values,
|
||||
int version);
|
||||
|
||||
@@ -264,6 +279,8 @@ public:
|
||||
* Requests *
|
||||
**************/
|
||||
|
||||
bool needsSession(const Node& node) const;
|
||||
|
||||
/**
|
||||
* Send a "ping" request to a given node.
|
||||
*
|
||||
@@ -461,7 +478,10 @@ public:
|
||||
size_t getNodeCacheSize() const { return cache.size(); }
|
||||
size_t getNodeCacheSize(sa_family_t af) const { return cache.size(af); }
|
||||
|
||||
size_t getRateLimiterSize() const { return address_rate_limiter.size(); }
|
||||
size_t getRateLimiterSize() const
|
||||
{
|
||||
return requestLimiter_.size() + unauthLimiter_.size() + cryptoLimiter_.size() + replyCryptoLimiter_.size();
|
||||
}
|
||||
|
||||
size_t getPartialCount() const { return partial_messages.size(); }
|
||||
|
||||
@@ -486,22 +506,136 @@ private:
|
||||
reason to increase this value. */
|
||||
static constexpr unsigned BLACKLISTED_MAX {10};
|
||||
|
||||
static constexpr size_t MTU {1280};
|
||||
/* IPv6 minimum MTU, less the IPv6 and UDP headers */
|
||||
static constexpr size_t MAX_PACKET_SIZE {1280 - 40 - 8};
|
||||
/* Upper bound of the msgpack framing around a value fragment */
|
||||
static constexpr size_t FRAGMENT_HEADER_SIZE {48};
|
||||
/* Value fragment sizes keeping each packet within the minimum MTU, less the envelope */
|
||||
static constexpr size_t FRAGMENT_SIZE {MAX_PACKET_SIZE - FRAGMENT_HEADER_SIZE};
|
||||
static constexpr size_t ENCRYPTED_FRAGMENT_SIZE {FRAGMENT_SIZE - transport::MAX_ENVELOPE_OVERHEAD};
|
||||
static constexpr size_t MAX_PACKET_VALUE_SIZE {600};
|
||||
static constexpr size_t MAX_MESSAGE_VALUE_SIZE {56 * 1024};
|
||||
static constexpr size_t RX_MAX_EARLY_BYTES {MAX_MESSAGE_VALUE_SIZE + MAX_VALUE_SIZE + 32};
|
||||
static constexpr size_t RX_MAX_EARLY_PARTS {RX_MAX_EARLY_BYTES / MTU + 2};
|
||||
static constexpr size_t RX_MAX_EARLY_PARTS {RX_MAX_EARLY_BYTES / ENCRYPTED_FRAGMENT_SIZE + 2};
|
||||
/* Lifetime of ephemeral transport keys and write tokens: in use for one period, then accepted for another */
|
||||
static constexpr std::chrono::minutes EPOCH_DURATION {30};
|
||||
/* Cached session keys per epoch */
|
||||
static constexpr size_t MAX_SESSIONS {16 * 1024};
|
||||
static constexpr size_t TOKEN_SIZE {transport::KEY_SIZE};
|
||||
|
||||
struct Session
|
||||
{
|
||||
/* our ephemeral key for the peer address */
|
||||
transport::Key ephemeral;
|
||||
transport::Key peer;
|
||||
transport::Key peerEphemeral;
|
||||
transport::SessionKeys keys;
|
||||
/* the peer has these keys and accepted us: send Data envelopes */
|
||||
bool confirmed {false};
|
||||
bool authorized {false};
|
||||
};
|
||||
/* The secret from which ephemeral keys and tokens derive, and session keys, all erased together.
|
||||
* A session is cached for an address only once its peer proved to receive there: it opened an envelope
|
||||
* encrypted with our key for that address. Replies to Initial and Hello envelopes never cache one,
|
||||
* since their source address can be spoofed. */
|
||||
struct Epoch
|
||||
{
|
||||
struct Entry
|
||||
{
|
||||
Session session;
|
||||
std::list<SockAddr>::iterator used;
|
||||
};
|
||||
transport::Key secret {};
|
||||
std::map<SockAddr, Entry> sessions {};
|
||||
/* most recently used first */
|
||||
std::list<SockAddr> used {};
|
||||
|
||||
explicit operator bool() const { return secret != transport::Key {}; }
|
||||
transport::KeyPair ephemeral(const SockAddr& addr) const;
|
||||
Blob token(const SockAddr& addr) const;
|
||||
/* Marks the session as used */
|
||||
Session* find(const SockAddr& addr);
|
||||
Session* find(const SockAddr& addr, const transport::Key& peer, const transport::Key& peerEphemeral);
|
||||
/* Evicts the least recently used session when full */
|
||||
void store(const SockAddr& addr, const Session& session);
|
||||
void wipe();
|
||||
};
|
||||
struct PendingSession
|
||||
{
|
||||
Epoch* epoch;
|
||||
Session session;
|
||||
};
|
||||
void rotateEpoch();
|
||||
/* Session with the peer at `addr`, or nullopt for an invalid peer key. Cached if the peer ephemeral key is validated. */
|
||||
std::optional<Session> session(const transport::Peer& peer, const SockAddr& addr);
|
||||
std::optional<Blob> openSession(const transport::Header& header,
|
||||
const uint8_t* buf,
|
||||
size_t buflen,
|
||||
const SockAddr& from,
|
||||
std::optional<PendingSession>& pending,
|
||||
Session*& cached);
|
||||
/* Fills the sender keys of `header` from the session */
|
||||
std::optional<Blob> openData(
|
||||
transport::Header& header, const uint8_t* buf, size_t buflen, const SockAddr& from, Session*& cached);
|
||||
|
||||
struct OpenedEnvelope
|
||||
{
|
||||
Sp<transport::Peer> peer;
|
||||
Blob payload;
|
||||
/* unauthenticated */
|
||||
bool hello {false};
|
||||
/* Session or Data envelope: the sender got our ephemeral key for its address */
|
||||
bool session {false};
|
||||
std::optional<PendingSession> pending {};
|
||||
Session* cached {};
|
||||
};
|
||||
std::optional<OpenedEnvelope> openEnvelope(const uint8_t* buf, size_t buflen, const SockAddr& from);
|
||||
bool authorize(transport::Peer& peer, const uint8_t* credential, size_t size) const;
|
||||
void attachPeer(Node& node, const Sp<transport::Peer>& peer);
|
||||
Sp<transport::Peer> makePeer(const InfoHash& id, const transport::Key& key) const;
|
||||
void processHello(const ParsedMessage& msg, const Sp<transport::Peer>& peer, const SockAddr& from);
|
||||
|
||||
/* Advertise our transport key in plaintext messages to nodes not known to support encryption */
|
||||
bool advertiseKey(const Node& node) const
|
||||
{
|
||||
return transport_ and not config.transport.isPrivate() and not node.getTransport();
|
||||
}
|
||||
/* Messages to the node are encrypted */
|
||||
bool encrypts(const Node& node) const { return transport_ and node.getTransport(); }
|
||||
/* Token granting writes to a plaintext node. Encrypted nodes need none. */
|
||||
Blob writeToken(const Node& node) const { return encrypts(node) ? Blob {} : epoch_.token(node.getAddr()); }
|
||||
bool tokenMatch(const Blob& token, const SockAddr& addr) const;
|
||||
/* Writes need proof that the sender receives at its address: a Session envelope, or else a token */
|
||||
void authorizeWrite(const ParsedMessage& msg, const Node& node, const std::string& error) const;
|
||||
/* Number of optional top-level fields packed by packOptionalFields */
|
||||
unsigned optionalFields(bool key) const
|
||||
{
|
||||
return (config.network ? 1 : 0) + (config.is_client ? 1 : 0) + (key ? 1 : 0);
|
||||
}
|
||||
void packOptionalFields(msgpack::packer<msgpack::sbuffer>& pk, bool key) const;
|
||||
|
||||
void process(std::unique_ptr<ParsedMessage>&&, const SockAddr& from);
|
||||
bool isExpectedReply(const ParsedMessage&, const SockAddr& from);
|
||||
bool tryCompleteMessage(PartialMessage&, const SockAddr& from);
|
||||
|
||||
bool rateLimit(const SockAddr& addr);
|
||||
/* Admits a request, unless `charged` already: Session and Data senders have their own budget.
|
||||
* A plaintext request whose answer takes an Initial envelope also needs crypto budget. */
|
||||
bool limitRequest(const ParsedMessage& msg, const SockAddr& from, bool charged = false);
|
||||
/* Admits a packet needing a key agreement, from a separate budget if we await a reply from there */
|
||||
bool limitCrypto(const transport::Key& sender, const SockAddr& from);
|
||||
bool expectsReply(const transport::Key& sender, const SockAddr& from);
|
||||
|
||||
static bool isMartian(const SockAddr& addr);
|
||||
bool isNodeBlacklisted(const SockAddr& addr) const;
|
||||
|
||||
void requestStep(Sp<Request> req);
|
||||
void transmitRequest(const Sp<Request>& req);
|
||||
void notifyRequestTimeout(Request& req);
|
||||
void completeSession(Node& node);
|
||||
/* Re-establishes the session with a ping in an Initial envelope, unless one is pending */
|
||||
void recoverSession(const Sp<Node>& node);
|
||||
/* Sends the requests withheld during the recovery, under the new session */
|
||||
void resumeRequests(Node& node);
|
||||
|
||||
/**
|
||||
* Sends a request to a node. Request::MAX_ATTEMPT_COUNT attempts will
|
||||
@@ -521,9 +655,13 @@ private:
|
||||
};
|
||||
|
||||
// basic wrapper for socket sendto function
|
||||
int send(const SockAddr& addr, const char* buf, size_t len, bool confirmed = false);
|
||||
int sendRaw(const SockAddr& addr, const uint8_t* buf, size_t len, bool confirmed = false);
|
||||
// send to a node, encrypted if supported
|
||||
int send(const Node& node, const SockAddr& addr, const char* buf, size_t len, bool confirmed = false);
|
||||
/* Bytes added by the envelope that send() uses for the node at `addr` */
|
||||
size_t envelopeOverhead(const Node& node, const SockAddr& addr);
|
||||
|
||||
void sendValueParts(Tid tid, const std::vector<Blob>& svals, const SockAddr& addr);
|
||||
void sendValueParts(Tid tid, const std::vector<Blob>& svals, const Node& node, const SockAddr& addr);
|
||||
std::vector<Blob> packValueHeader(msgpack::sbuffer&,
|
||||
std::vector<Sp<Value>>::const_iterator,
|
||||
std::vector<Sp<Value>>::const_iterator) const;
|
||||
@@ -531,50 +669,65 @@ private:
|
||||
{
|
||||
return packValueHeader(buf, values.begin(), values.end());
|
||||
}
|
||||
using PartialMessageKey = std::pair<SockAddr, Tid>;
|
||||
using PartialMessageKey = std::tuple<SockAddr, Tid, transport::Key>;
|
||||
void maintainRxBuffer(const PartialMessageKey& key);
|
||||
|
||||
/*************
|
||||
* Answers *
|
||||
*************/
|
||||
/* answer to a ping request */
|
||||
void sendPong(const SockAddr& addr, Tid tid);
|
||||
void sendPong(const Node& node, const SockAddr& addr, Tid tid);
|
||||
struct BufferedNodes
|
||||
{
|
||||
Blob nodes4, nodes6;
|
||||
/* transport keys, aligned with nodes, zeros if unknown. Empty if none is known. */
|
||||
Blob keys4, keys6;
|
||||
};
|
||||
/* answer to findnodes/getvalues request */
|
||||
void sendNodesValues(const SockAddr& addr,
|
||||
void sendNodesValues(const Node& node,
|
||||
const SockAddr& addr,
|
||||
Tid tid,
|
||||
const Blob& nodes,
|
||||
const Blob& nodes6,
|
||||
const BufferedNodes& nodes,
|
||||
const std::vector<Sp<Value>>& st,
|
||||
const Query& query,
|
||||
const Blob& token);
|
||||
Blob bufferNodes(sa_family_t af, const InfoHash& id, std::vector<Sp<Node>>& nodes);
|
||||
std::pair<Blob, Blob> bufferNodes(sa_family_t af, const InfoHash& id, std::vector<Sp<Node>>& nodes);
|
||||
|
||||
std::pair<Blob, Blob> bufferNodes(
|
||||
BufferedNodes bufferNodes(
|
||||
sa_family_t af, const InfoHash& id, want_t want, std::vector<Sp<Node>>& nodes, std::vector<Sp<Node>>& nodes6);
|
||||
/* answer to a listen request */
|
||||
void sendListenConfirmation(const SockAddr& addr, Tid tid, const Blob& token = {});
|
||||
void sendListenConfirmation(const Node& node, const SockAddr& addr, Tid tid, const Blob& token = {});
|
||||
/* answer to put request */
|
||||
void sendValueAnnounced(const SockAddr& addr, Tid, Value::Id);
|
||||
void sendValueAnnounced(const Node& node, const SockAddr& addr, Tid, Value::Id);
|
||||
/* answer in case of error */
|
||||
void sendError(const SockAddr& addr, Tid tid, uint16_t code, const std::string& message, bool include_id = false);
|
||||
void sendError(const Node& node,
|
||||
const SockAddr& addr,
|
||||
Tid tid,
|
||||
uint16_t code,
|
||||
const std::string& message,
|
||||
bool include_id = false);
|
||||
|
||||
void deserializeNodes(ParsedMessage& msg, const SockAddr& from);
|
||||
|
||||
/* DHT info */
|
||||
const InfoHash& myid;
|
||||
const NetworkConfig config {};
|
||||
NetworkConfig config {};
|
||||
const bool transport_ {false};
|
||||
const Blob credential_ {};
|
||||
Epoch epoch_ {};
|
||||
Epoch previousEpoch_ {};
|
||||
Sp<Scheduler::Job> epochRotation_ {};
|
||||
const std::unique_ptr<DatagramSocket> dht_socket;
|
||||
Sp<Logger> logger_;
|
||||
std::mt19937_64& rd;
|
||||
|
||||
NodeCache cache;
|
||||
|
||||
// global limiting should be triggered by at least 8 different IPs
|
||||
using IpLimiter = RateLimiter;
|
||||
using IpLimiterMap = std::map<SockAddr, IpLimiter, SockAddr::ipCmp>;
|
||||
IpLimiterMap address_rate_limiter;
|
||||
RateLimiter rate_limiter;
|
||||
ssize_t limiter_maintenance {0};
|
||||
/* Requests from Session/Data senders, other requests, public-key work for unexpected and expected senders */
|
||||
AddressRateLimiter requestLimiter_;
|
||||
AddressRateLimiter unauthLimiter_;
|
||||
AddressRateLimiter cryptoLimiter_;
|
||||
AddressRateLimiter replyCryptoLimiter_;
|
||||
|
||||
// requests handling
|
||||
std::map<Tid, Sp<Request>> requests {};
|
||||
|
||||
@@ -18,6 +18,9 @@ struct Request;
|
||||
struct Socket;
|
||||
struct RequestAnswer;
|
||||
} /* namespace net */
|
||||
namespace transport {
|
||||
struct Peer;
|
||||
}
|
||||
|
||||
using Tid = uint32_t;
|
||||
using SocketCb = std::function<void(const Sp<Node>&, net::RequestAnswer&&)>;
|
||||
@@ -46,6 +49,10 @@ struct Node
|
||||
bool isClient() const { return is_client; }
|
||||
bool isIncoming() { return time > reply_time; }
|
||||
|
||||
/** Transport encryption state, if the node is known to support it */
|
||||
const Sp<transport::Peer>& getTransport() const { return transport_; }
|
||||
void setTransport(const Sp<transport::Peer>& peer) { transport_ = peer; }
|
||||
|
||||
const time_point& getTime() const { return time; }
|
||||
const time_point& getReplyTime() const { return reply_time; }
|
||||
void setTime(const time_point& t) { time = t; }
|
||||
@@ -84,6 +91,7 @@ struct Node
|
||||
void requested(const Sp<net::Request>& req);
|
||||
void received(time_point now, const Sp<net::Request>& req);
|
||||
Sp<net::Request> getRequest(Tid tid);
|
||||
const std::map<Tid, Sp<net::Request>>& getRequests() const { return requests_; }
|
||||
void cancelRequest(const Sp<net::Request>& req);
|
||||
|
||||
void setExpired();
|
||||
@@ -155,6 +163,7 @@ private:
|
||||
|
||||
std::map<Tid, Sp<net::Request>> requests_ {};
|
||||
std::map<Tid, Sp<Socket>> sockets_;
|
||||
Sp<transport::Peer> transport_ {};
|
||||
};
|
||||
|
||||
} // namespace dht
|
||||
@@ -24,6 +24,7 @@ struct NodeCache
|
||||
* To use in case of connectivity change etc.
|
||||
*/
|
||||
void clearBadNodes(sa_family_t family = 0);
|
||||
void clear();
|
||||
|
||||
NodeCache(std::mt19937_64& r)
|
||||
: rd(r) {};
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
#pragma once
|
||||
|
||||
#include "utils.h"
|
||||
#include "sockaddr.h"
|
||||
|
||||
#include <map>
|
||||
#include <queue>
|
||||
|
||||
namespace dht {
|
||||
@@ -23,6 +26,11 @@ public:
|
||||
records.pop();
|
||||
return records.size();
|
||||
}
|
||||
/** Whether quota remains, without using it */
|
||||
bool available(const time_point& now)
|
||||
{
|
||||
return quota_ == std::numeric_limits<size_t>::max() or maintain(now) < quota_;
|
||||
}
|
||||
/** Return false if quota is reached, insert record and return true otherwise. */
|
||||
bool limit(const time_point& now)
|
||||
{
|
||||
@@ -41,4 +49,52 @@ private:
|
||||
std::queue<time_point> records {};
|
||||
};
|
||||
|
||||
/**
|
||||
* A global quota and a quota per address. IPv6 addresses share the quota of their /64 prefix.
|
||||
* Negative quotas are unlimited.
|
||||
*/
|
||||
class AddressRateLimiter
|
||||
{
|
||||
public:
|
||||
AddressRateLimiter(ssize_t quota, ssize_t peerQuota, const duration& period = std::chrono::seconds(1))
|
||||
: global_(quota, period)
|
||||
, peerQuota_(peerQuota)
|
||||
, period_(period)
|
||||
{}
|
||||
|
||||
bool limit(const SockAddr& addr, const time_point& now)
|
||||
{
|
||||
// Checked first so that dropped packets create no per-address record
|
||||
if (not global_.available(now))
|
||||
return false;
|
||||
if (peerQuota_ >= 0) {
|
||||
maintain(now);
|
||||
auto it = peers_.find(addr);
|
||||
if (it == peers_.end())
|
||||
it = peers_.emplace(addr, RateLimiter(peerQuota_, period_)).first;
|
||||
if (not it->second.limit(now))
|
||||
return false;
|
||||
}
|
||||
return global_.limit(now);
|
||||
}
|
||||
/** Number of addresses with a record */
|
||||
size_t size() const { return peers_.size(); }
|
||||
|
||||
private:
|
||||
void maintain(const time_point& now)
|
||||
{
|
||||
if (now < nextMaintenance_)
|
||||
return;
|
||||
for (auto it = peers_.begin(); it != peers_.end();)
|
||||
it = it->second.maintain(now) ? std::next(it) : peers_.erase(it);
|
||||
nextMaintenance_ = now + period_;
|
||||
}
|
||||
|
||||
RateLimiter global_;
|
||||
const ssize_t peerQuota_;
|
||||
const duration period_;
|
||||
std::map<SockAddr, RateLimiter, SockAddr::ipCmp> peers_ {};
|
||||
time_point nextMaintenance_ {time_point::min()};
|
||||
};
|
||||
|
||||
} // namespace dht
|
||||
@@ -0,0 +1,219 @@
|
||||
// Copyright (c) 2014-2026 Savoir-faire Linux Inc.
|
||||
// SPDX-License-Identifier: MIT
|
||||
#pragma once
|
||||
|
||||
#include "def.h"
|
||||
#include "infohash.h"
|
||||
#include "sockaddr.h"
|
||||
#include "utils.h"
|
||||
|
||||
#include <array>
|
||||
#include <optional>
|
||||
#include <vector>
|
||||
|
||||
namespace dht {
|
||||
namespace transport {
|
||||
|
||||
constexpr size_t KEY_SIZE {32};
|
||||
constexpr size_t SIGNATURE_SIZE {64};
|
||||
using Key = std::array<uint8_t, KEY_SIZE>;
|
||||
using Signature = std::array<uint8_t, SIGNATURE_SIZE>;
|
||||
|
||||
/** Erases secret key material. */
|
||||
OPENDHT_PUBLIC void wipe(Key& key);
|
||||
|
||||
/**
|
||||
* X25519 key pair used to encrypt the DHT protocol: the static key of a node, from which its node ID derives,
|
||||
* or a periodically rotated ephemeral key.
|
||||
*/
|
||||
struct OPENDHT_PUBLIC KeyPair
|
||||
{
|
||||
Key secret {};
|
||||
Key pub {};
|
||||
|
||||
static KeyPair generate();
|
||||
static KeyPair fromSecret(const Key& secret);
|
||||
explicit operator bool() const { return pub != Key {}; }
|
||||
};
|
||||
|
||||
/** Node ID of a node using the transport public key `pub`. */
|
||||
OPENDHT_PUBLIC InfoHash nodeId(const Key& pub);
|
||||
|
||||
/** Secret from the system CSPRNG. */
|
||||
OPENDHT_PUBLIC Key randomSecret();
|
||||
|
||||
/** Ephemeral key pair used with the peer at `address` (IP and port), derived from an epoch secret. */
|
||||
OPENDHT_PUBLIC KeyPair addressKey(const Key& epochSecret, const uint8_t* address, size_t size);
|
||||
|
||||
/** Write token of the plaintext peer at `address` (IP and port), derived from an epoch secret. */
|
||||
OPENDHT_PUBLIC Key addressToken(const Key& epochSecret, const uint8_t* address, size_t size);
|
||||
|
||||
/** Non-expiring authority-signed grant binding a transport public key to a private network. */
|
||||
struct OPENDHT_PUBLIC Credential
|
||||
{
|
||||
static constexpr size_t PACKED_SIZE {1 + KEY_SIZE + SIGNATURE_SIZE};
|
||||
|
||||
Key key {};
|
||||
Signature signature {};
|
||||
|
||||
Blob pack() const;
|
||||
/** @throws std::invalid_argument if the data is not a packed credential */
|
||||
static Credential unpack(const uint8_t* data, size_t size);
|
||||
static Credential unpack(const Blob& b) { return unpack(b.data(), b.size()); }
|
||||
|
||||
/** True if signed by one of the trusted authorities. */
|
||||
bool verify(const std::vector<Key>& authorities) const;
|
||||
explicit operator bool() const { return key != Key {}; }
|
||||
};
|
||||
|
||||
/** Ed25519 key of a private network authority, issuing node credentials. */
|
||||
struct OPENDHT_PUBLIC Authority
|
||||
{
|
||||
Key secret {};
|
||||
Key pub {};
|
||||
|
||||
static Authority generate();
|
||||
static Authority fromSecret(const Key& secret);
|
||||
Credential issue(const Key& transportKey) const;
|
||||
};
|
||||
|
||||
/** Transport state of a remote node, attached to its Node. */
|
||||
struct Peer
|
||||
{
|
||||
explicit Peer(const Key& k)
|
||||
: key(k)
|
||||
{}
|
||||
const Key key;
|
||||
/** Last authenticated ephemeral key of the peer */
|
||||
std::optional<Key> ephemeral {};
|
||||
/** Address proven by the Session or Data envelope carrying the ephemeral key */
|
||||
SockAddr validatedAddress {};
|
||||
/** Private networks: the peer presented a grant from a trusted authority */
|
||||
bool authorized {false};
|
||||
/** Our session was lost: requests other than pings wait for a new ephemeral key */
|
||||
bool recovering {false};
|
||||
|
||||
void setEphemeral(const Key& k, const SockAddr& address = {})
|
||||
{
|
||||
ephemeral = k;
|
||||
validatedAddress = address;
|
||||
}
|
||||
void resetEphemeral()
|
||||
{
|
||||
ephemeral.reset();
|
||||
validatedAddress = {};
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
* Envelope: marker | type | sender static key | sender ephemeral key | nonce | ChaCha20-Poly1305(payload) | tag
|
||||
* Data envelope: marker | type | nonce | ChaCha20-Poly1305(payload) | tag, keys found from the source address.
|
||||
* The marker is never a valid msgpack first byte, so legacy nodes drop envelopes.
|
||||
* Hello (private networks only, plaintext): marker | type | sender static key | sender ephemeral key | credential | message
|
||||
*/
|
||||
constexpr uint8_t ENVELOPE_MARKER {0xC1};
|
||||
/** Initial: the sender doesn't know the receiver ephemeral key. Session: it does. Data: the receiver has the session. */
|
||||
enum class EnvelopeType : uint8_t { Initial = 1, Session = 2, Hello = 3, Data = 4 };
|
||||
/** Type flag: the payload starts with the sender credential */
|
||||
constexpr uint8_t CREDENTIAL_FLAG {0x80};
|
||||
constexpr size_t NONCE_SIZE {12};
|
||||
constexpr size_t TAG_SIZE {16};
|
||||
constexpr size_t HEADER_SIZE {2 + 2 * KEY_SIZE};
|
||||
constexpr size_t DATA_HEADER_SIZE {2};
|
||||
constexpr size_t ENVELOPE_OVERHEAD {HEADER_SIZE + NONCE_SIZE + TAG_SIZE};
|
||||
constexpr size_t DATA_ENVELOPE_OVERHEAD {DATA_HEADER_SIZE + NONCE_SIZE + TAG_SIZE};
|
||||
constexpr size_t MAX_ENVELOPE_OVERHEAD {ENVELOPE_OVERHEAD + Credential::PACKED_SIZE};
|
||||
|
||||
inline bool
|
||||
isEnvelope(const uint8_t* data, size_t size)
|
||||
{
|
||||
return size >= DATA_HEADER_SIZE and data[0] == ENVELOPE_MARKER;
|
||||
}
|
||||
|
||||
struct Header
|
||||
{
|
||||
EnvelopeType type;
|
||||
bool credential;
|
||||
/* not in Data envelopes */
|
||||
Key sender;
|
||||
Key ephemeral;
|
||||
};
|
||||
|
||||
/** Envelope header, or nullopt if not an envelope of a known type. */
|
||||
OPENDHT_PUBLIC std::optional<Header> parseHeader(const uint8_t* data, size_t size);
|
||||
|
||||
/** Keys to send to and receive from a peer. */
|
||||
struct SessionKeys
|
||||
{
|
||||
Key tx;
|
||||
Key rx;
|
||||
|
||||
/** Erases secret key material. */
|
||||
void wipe()
|
||||
{
|
||||
transport::wipe(tx);
|
||||
transport::wipe(rx);
|
||||
}
|
||||
|
||||
/** Destructor that wipes secret key material. */
|
||||
~SessionKeys()
|
||||
{
|
||||
wipe();
|
||||
}
|
||||
};
|
||||
|
||||
/** Session keys with a peer whose ephemeral key is known, or nullopt for an invalid peer key. */
|
||||
OPENDHT_PUBLIC std::optional<SessionKeys> sessionKeys(
|
||||
const KeyPair& own, const KeyPair& ephemeral, const Key& peer, const Key& peerEphemeral, NetId network);
|
||||
|
||||
/** Key of an Initial envelope to `peer`, or nullopt for an invalid peer key. */
|
||||
OPENDHT_PUBLIC std::optional<Key> initialSendKey(const KeyPair& own,
|
||||
const KeyPair& ephemeral,
|
||||
const Key& peer,
|
||||
NetId network);
|
||||
|
||||
/** Key of an Initial envelope from `peer`, or nullopt for an invalid peer key. */
|
||||
OPENDHT_PUBLIC std::optional<Key> initialReceiveKey(const KeyPair& own,
|
||||
const Key& peer,
|
||||
const Key& peerEphemeral,
|
||||
NetId network);
|
||||
|
||||
/** Encrypts `credential || data`, authenticating the envelope header. */
|
||||
OPENDHT_PUBLIC Blob seal(const Key& key,
|
||||
EnvelopeType type,
|
||||
const Key& sender,
|
||||
const Key& ephemeral,
|
||||
const uint8_t* data,
|
||||
size_t size,
|
||||
const Blob& credential = {});
|
||||
|
||||
/** Encrypts `data` in a Data envelope. */
|
||||
OPENDHT_PUBLIC Blob sealData(const Key& key, const uint8_t* data, size_t size);
|
||||
|
||||
/** Decrypted payload, or nullopt if the envelope is not authentic. */
|
||||
OPENDHT_PUBLIC std::optional<Blob> open(const Key& key, const uint8_t* data, size_t size);
|
||||
|
||||
OPENDHT_PUBLIC Blob
|
||||
hello(const Key& sender, const Key& ephemeral, const Blob& credential, const uint8_t* data, size_t size);
|
||||
|
||||
} // namespace transport
|
||||
|
||||
/**
|
||||
* Encryption of the DHT protocol. Peers that don't support it keep talking in plaintext,
|
||||
* unless the node is part of a private network.
|
||||
*/
|
||||
struct TransportConfig
|
||||
{
|
||||
/** Advertise our key and encrypt traffic with nodes supporting it. Disabling uses a legacy random node ID. */
|
||||
bool enabled {true};
|
||||
/** Transport key. If empty, generated on start, and kept in the persisted state if Config::persist_path is set. */
|
||||
transport::KeyPair key {};
|
||||
/** Network authorities. If set, only nodes with a credential issued by one of them are accepted. */
|
||||
std::vector<transport::Key> authorities {};
|
||||
/** Credential of `key`, required for private networks. */
|
||||
transport::Credential credential {};
|
||||
|
||||
bool isPrivate() const { return not authorities.empty(); }
|
||||
};
|
||||
|
||||
} // namespace dht
|
||||
+53
@@ -12,6 +12,7 @@ project(
|
||||
|
||||
gnutls = dependency('gnutls')
|
||||
nettle = dependency('nettle')
|
||||
hogweed = dependency('hogweed', version: '>=3.1')
|
||||
msgpack = dependency('msgpack-cxx', required: false)
|
||||
argon2 = dependency('libargon2')
|
||||
fmt = dependency('fmt')
|
||||
@@ -38,6 +39,7 @@ deps = [
|
||||
fmt,
|
||||
gnutls,
|
||||
nettle,
|
||||
hogweed,
|
||||
msgpack,
|
||||
argon2,
|
||||
openssl,
|
||||
@@ -84,6 +86,7 @@ opendht_src = [
|
||||
'src/routing_table.cpp',
|
||||
'src/node_cache.cpp',
|
||||
'src/network_engine.cpp',
|
||||
'src/transport.cpp',
|
||||
'src/securedht.cpp',
|
||||
'src/dhtrunner.cpp',
|
||||
'src/log.cpp',
|
||||
@@ -205,6 +208,48 @@ if get_option('c').enabled()
|
||||
endif
|
||||
endif
|
||||
|
||||
rust_option = get_option('rust').require(
|
||||
get_option('c').enabled(),
|
||||
error_message: 'rust requires c',
|
||||
).require(
|
||||
get_option('default_library') != 'static',
|
||||
error_message: 'rust requires shared libraries',
|
||||
)
|
||||
if rust_option.allowed()
|
||||
cargo = find_program('cargo', required: rust_option)
|
||||
if cargo.found()
|
||||
rust_env = environment()
|
||||
rust_env.prepend('LIBRARY_PATH', meson.current_build_dir())
|
||||
rust_env.prepend('LD_LIBRARY_PATH', meson.current_build_dir())
|
||||
rust_env.prepend('DYLD_LIBRARY_PATH', meson.current_build_dir())
|
||||
rust_env.prepend('PATH', meson.current_build_dir())
|
||||
cargo_args = [
|
||||
'--manifest-path', meson.current_source_dir() / 'rust/Cargo.toml',
|
||||
'--target-dir', meson.current_build_dir() / 'rust',
|
||||
]
|
||||
rust_build = custom_target(
|
||||
'rust-bindings',
|
||||
output: 'rust-build.json',
|
||||
command: [cargo, 'build', '--lib', '--examples', '--message-format=json-render-diagnostics'] + cargo_args,
|
||||
env: rust_env,
|
||||
depends: opendht_c,
|
||||
capture: true,
|
||||
build_by_default: true,
|
||||
build_always_stale: true,
|
||||
)
|
||||
if get_option('tests').enabled()
|
||||
test(
|
||||
'Rust',
|
||||
cargo,
|
||||
args: ['test'] + cargo_args,
|
||||
env: rust_env,
|
||||
depends: rust_build,
|
||||
timeout: 120,
|
||||
)
|
||||
endif
|
||||
endif
|
||||
endif
|
||||
|
||||
if get_option('python').require(
|
||||
get_option('indexation').enabled(),
|
||||
error_message: 'python requires indexation',
|
||||
@@ -335,6 +380,14 @@ if get_option('tests').enabled()
|
||||
)
|
||||
test('Crypto', test_crypto, timeout: 60)
|
||||
|
||||
test_transport = executable(
|
||||
'test_transport',
|
||||
'tests/test_transport.cpp',
|
||||
'tests/tests_runner.cpp',
|
||||
dependencies: [opendht_dep, cppunit, jsoncpp, fmt, openssl, msgpack],
|
||||
)
|
||||
test('Transport', test_transport)
|
||||
|
||||
test_dhtrunner = executable(
|
||||
'test_dhtrunner',
|
||||
'tests/test_dhtrunner.cpp',
|
||||
|
||||
@@ -4,6 +4,7 @@ option('push_notifications', type : 'feature', value : 'disabled')
|
||||
option('peer_discovery', type : 'feature', value : 'enabled')
|
||||
option('tools', type : 'feature', value : 'enabled')
|
||||
option('c', type : 'feature', value : 'enabled')
|
||||
option('rust', type : 'feature', value : 'disabled')
|
||||
option('indexation', type : 'feature', value : 'enabled')
|
||||
option('python', type : 'feature', value : 'enabled')
|
||||
option('tests', type : 'feature', value : 'enabled')
|
||||
|
||||
+1
-1
@@ -8,5 +8,5 @@ Version: @VERSION@
|
||||
Libs: -L${libdir} -lopendht -lfmt
|
||||
Libs.private: @http_lib@ -pthread
|
||||
Requires: gnutls >= 3.3@jsoncpp_lib@@openssl_lib@
|
||||
Requires.private: nettle >= 2.4@argon2_lib@@llhttp_lib@@iouring_lib@@simdutf_lib@
|
||||
Requires.private: nettle >= 2.4, hogweed >= 3.1@argon2_lib@@llhttp_lib@@iouring_lib@@simdutf_lib@
|
||||
Cflags: -I${includedir}@opendht_public_cflags@
|
||||
@@ -866,6 +866,9 @@ cdef class DhtConfig(object):
|
||||
def setRateLimit(self, ssize_t max_req_per_sec, ssize_t max_peer_req_per_sec):
|
||||
self._config.dht_config.node_config.max_req_per_sec = max_req_per_sec
|
||||
self._config.dht_config.node_config.max_peer_req_per_sec = max_peer_req_per_sec
|
||||
def setCryptoRateLimit(self, ssize_t max_crypto_per_sec, ssize_t max_peer_crypto_per_sec):
|
||||
self._config.dht_config.node_config.max_crypto_per_sec = max_crypto_per_sec
|
||||
self._config.dht_config.node_config.max_peer_crypto_per_sec = max_peer_crypto_per_sec
|
||||
def setProxyInfo(self, str proxy_server, str push_node_id="", str push_token="", str push_topic="", str push_platform="", str user_agent="", Certificate server_ca = Certificate()):
|
||||
self._config.proxy_server = proxy_server.encode()
|
||||
self._config.push_node_id = push_node_id.encode()
|
||||
|
||||
@@ -283,6 +283,8 @@ cdef extern from "opendht/callbacks.h" namespace "dht":
|
||||
string persist_path
|
||||
size_t max_req_per_sec
|
||||
size_t max_peer_req_per_sec
|
||||
ssize_t max_crypto_per_sec
|
||||
ssize_t max_peer_crypto_per_sec
|
||||
cppclass SecureDhtConfig:
|
||||
Config node_config
|
||||
Identity id
|
||||
|
||||
@@ -11,6 +11,7 @@ from opendht import aio as dht
|
||||
async def main():
|
||||
config = dht.DhtConfig()
|
||||
config.setRateLimit(-1, -1)
|
||||
config.setCryptoRateLimit(-1, -1)
|
||||
|
||||
ping_node = dht.DhtRunner()
|
||||
pong_node = dht.DhtRunner()
|
||||
|
||||
+123
-113
@@ -10,6 +10,7 @@
|
||||
#include <msgpack.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <filesystem>
|
||||
#include <random>
|
||||
#include <sstream>
|
||||
#include <fstream>
|
||||
@@ -27,6 +28,7 @@ constexpr duration Dht::LISTEN_EXPIRE_TIME;
|
||||
constexpr duration Dht::LISTEN_EXPIRE_TIME_PUBLIC;
|
||||
constexpr duration Dht::REANNOUNCE_MARGIN;
|
||||
static constexpr size_t MAX_REQUESTS_PER_SEC {8 * 1024};
|
||||
static constexpr size_t MAX_CRYPTO_PER_SEC {512};
|
||||
static constexpr duration BOOTSTRAP_PERIOD_MAX {std::chrono::hours(24)};
|
||||
|
||||
NodeStatus
|
||||
@@ -333,6 +335,30 @@ Dht::searchNodeGetExpired(const net::Request& status, bool over, std::weak_ptr<S
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
Dht::searchNodeRoutingDone(const net::Request& req, net::RequestAnswer&& answer, std::weak_ptr<Search> ws)
|
||||
{
|
||||
if (auto sr = ws.lock()) {
|
||||
if (auto sn = sr->getNode(req.node); sn and sn->routingProbe.get() == &req) {
|
||||
sr->insertNode(req.node, scheduler.time(), answer.ntoken);
|
||||
scheduler.edit(sr->nextSearchStep, scheduler.time());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
Dht::searchNodeRoutingExpired(const net::Request& req, bool over, std::weak_ptr<Search> ws)
|
||||
{
|
||||
if (auto sr = ws.lock()) {
|
||||
if (auto sn = sr->getNode(req.node); sn and sn->routingProbe.get() == &req) {
|
||||
sn->candidate = not over;
|
||||
if (over)
|
||||
sn->routingProbe.reset();
|
||||
scheduler.edit(sr->nextSearchStep, scheduler.time());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
Dht::paginate(std::weak_ptr<Search> ws, Sp<Query> query, SearchNode* n)
|
||||
{
|
||||
@@ -438,6 +464,16 @@ Dht::searchSendGetValues(Sp<Search> sr, SearchNode* pn, bool update)
|
||||
if (not n)
|
||||
continue;
|
||||
|
||||
if (network_engine.needsSession(*n->node)) {
|
||||
n->routingProbe = network_engine
|
||||
.sendFindNode(n->node,
|
||||
sr->id,
|
||||
-1,
|
||||
std::bind(&Dht::searchNodeRoutingDone, this, _1, _2, ws),
|
||||
std::bind(&Dht::searchNodeRoutingExpired, this, _1, _2, ws));
|
||||
return n;
|
||||
}
|
||||
|
||||
if (query and not query->select.empty()) {
|
||||
/* The request contains a select. No need to paginate… */
|
||||
/* if (logger_)
|
||||
@@ -530,8 +566,8 @@ Dht::searchSendAnnounceValue(const Sp<Search>& sr, unsigned syncLevel)
|
||||
sn->node->toString(),
|
||||
a.value->id);
|
||||
auto created = a.permanent ? time_point::max() : a.created;
|
||||
acked = {network_engine
|
||||
.sendAnnounceValue(sn->node, sr->id, a.value, created, sn->token, onDone, onExpired),
|
||||
acked = {network_engine.sendAnnounceValue(
|
||||
sn->node, sr->id, a.value, created, sn->token.value_or(Blob {}), onDone, onExpired),
|
||||
next_refresh_time};
|
||||
} else if (hasValue and a.permanent) {
|
||||
if (logger_)
|
||||
@@ -543,7 +579,7 @@ Dht::searchSendAnnounceValue(const Sp<Search>& sr, unsigned syncLevel)
|
||||
sn->node,
|
||||
sr->id,
|
||||
a.value->id,
|
||||
sn->token,
|
||||
sn->token.value_or(Blob {}),
|
||||
onDone,
|
||||
[this,
|
||||
ws,
|
||||
@@ -563,7 +599,8 @@ Dht::searchSendAnnounceValue(const Sp<Search>& sr, unsigned syncLevel)
|
||||
sr->id,
|
||||
v,
|
||||
created,
|
||||
sn->token,
|
||||
sn->token.value_or(
|
||||
Blob {}),
|
||||
onDone,
|
||||
onExpired),
|
||||
next_refresh_time};
|
||||
@@ -622,8 +659,13 @@ Dht::searchSendAnnounceValue(const Sp<Search>& sr, unsigned syncLevel)
|
||||
sr->id.toString(),
|
||||
n.node->toString(),
|
||||
a.value->id);
|
||||
n.acked[a.value->id] = {network_engine.sendAnnounceValue(
|
||||
n.node, sr->id, a.value, a.created, n.token, onDone, onExpired),
|
||||
n.acked[a.value->id] = {network_engine.sendAnnounceValue(n.node,
|
||||
sr->id,
|
||||
a.value,
|
||||
a.created,
|
||||
n.token.value_or(Blob {}),
|
||||
onDone,
|
||||
onExpired),
|
||||
now + getType(a.value->type).expiration};
|
||||
}
|
||||
}
|
||||
@@ -663,8 +705,8 @@ Dht::searchSynchedNodeListen(const Sp<Search>& sr, SearchNode& n)
|
||||
if (auto sr = ws.lock()) {
|
||||
scheduler.edit(sr->nextSearchStep, scheduler.time());
|
||||
if (auto sn = sr->getNode(node)) {
|
||||
if (not answer.ntoken.empty())
|
||||
sn->refreshToken(answer.ntoken);
|
||||
if (answer.ntoken)
|
||||
sn->refreshToken(*answer.ntoken);
|
||||
sn->onValues(query, std::move(answer), types, scheduler);
|
||||
}
|
||||
}
|
||||
@@ -698,7 +740,7 @@ Dht::searchSynchedNodeListen(const Sp<Search>& sr, SearchNode& n)
|
||||
n.node,
|
||||
sr->id,
|
||||
*query,
|
||||
n.token,
|
||||
n.token.value_or(Blob {}),
|
||||
r->second.socketId,
|
||||
[this, ws, query](const net::Request& req, net::RequestAnswer&& answer) mutable {
|
||||
/* on done */
|
||||
@@ -1405,12 +1447,10 @@ Dht::storageChanged(const InfoHash& id, Storage& st, const Sp<Value>& v, bool ne
|
||||
node_listeners.first->toString());
|
||||
std::vector<Sp<Value>> vals;
|
||||
vals.push_back(v);
|
||||
Blob ntoken = makeToken(node_listeners.first->getAddr(), false);
|
||||
network_engine.tellListener(node_listeners.first,
|
||||
l.first,
|
||||
id,
|
||||
0,
|
||||
ntoken,
|
||||
{},
|
||||
{},
|
||||
std::move(vals),
|
||||
@@ -1507,7 +1547,6 @@ Dht::storageAddListener(const InfoHash& id, const Sp<Node>& node, size_t socket_
|
||||
socket_id,
|
||||
id,
|
||||
WANT4 | WANT6,
|
||||
makeToken(node->getAddr(), false),
|
||||
dht4.buckets.findClosestNodes(id, now, TARGET_NODES),
|
||||
dht6.buckets.findClosestNodes(id, now, TARGET_NODES),
|
||||
std::move(vals),
|
||||
@@ -1562,8 +1601,7 @@ Dht::storageRemoved(const InfoHash& id, Storage& st, const std::vector<Sp<Value>
|
||||
logger_->warn("[store {}] [node {}] Sending expired",
|
||||
id.to_view(),
|
||||
node_listeners.first->toString());
|
||||
Blob ntoken = makeToken(node_listeners.first->getAddr(), false);
|
||||
network_engine.tellListenerExpired(node_listeners.first, l.first, id, ntoken, ids, l.second.version);
|
||||
network_engine.tellListenerExpired(node_listeners.first, l.first, id, ids, l.second.version);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1645,59 +1683,6 @@ Dht::connectivityChanged(sa_family_t af)
|
||||
startBootstrap(); // will only happen if disconnected
|
||||
}
|
||||
|
||||
void
|
||||
Dht::rotateSecrets()
|
||||
{
|
||||
oldsecret = secret;
|
||||
secret = std::uniform_int_distribution<uint64_t> {}(rd);
|
||||
uniform_duration_distribution<> time_dist(std::chrono::minutes(15), std::chrono::minutes(45));
|
||||
auto rotate_secrets_time = scheduler.time() + time_dist(rd);
|
||||
scheduler.add(rotate_secrets_time, std::bind(&Dht::rotateSecrets, this));
|
||||
}
|
||||
|
||||
Blob
|
||||
Dht::makeToken(const SockAddr& addr, bool old) const
|
||||
{
|
||||
const void* ip;
|
||||
size_t iplen;
|
||||
in_port_t port;
|
||||
|
||||
auto family = addr.getFamily();
|
||||
if (family == AF_INET) {
|
||||
const auto& sin = addr.getIPv4();
|
||||
ip = &sin.sin_addr;
|
||||
iplen = 4;
|
||||
port = sin.sin_port;
|
||||
} else if (family == AF_INET6) {
|
||||
const auto& sin6 = addr.getIPv6();
|
||||
ip = &sin6.sin6_addr;
|
||||
iplen = 16;
|
||||
port = sin6.sin6_port;
|
||||
} else {
|
||||
return {};
|
||||
}
|
||||
|
||||
const auto& c1 = old ? oldsecret : secret;
|
||||
Blob data;
|
||||
data.reserve(sizeof(secret) + sizeof(in_port_t) + iplen);
|
||||
data.insert(data.end(), (uint8_t*) &c1, ((uint8_t*) &c1) + sizeof(c1));
|
||||
data.insert(data.end(), (uint8_t*) ip, (uint8_t*) ip + iplen);
|
||||
data.insert(data.end(), (uint8_t*) &port, ((uint8_t*) &port) + sizeof(in_port_t));
|
||||
return crypto::hash(data, TOKEN_SIZE);
|
||||
}
|
||||
|
||||
bool
|
||||
Dht::tokenMatch(const Blob& token, const SockAddr& addr) const
|
||||
{
|
||||
if (not addr or token.size() != TOKEN_SIZE)
|
||||
return false;
|
||||
if (token == makeToken(addr, false))
|
||||
return true;
|
||||
if (token == makeToken(addr, true))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
NodeStats
|
||||
Dht::getNodesStats(sa_family_t af) const
|
||||
{
|
||||
@@ -1983,10 +1968,33 @@ fromDhtConfig(const Config& config)
|
||||
{
|
||||
net::NetworkConfig netConf;
|
||||
netConf.network = config.network;
|
||||
// global limiting should be triggered by at least 8 different IPs
|
||||
auto peerLimit = [](ssize_t global, ssize_t peer) -> ssize_t {
|
||||
return peer ? peer : (global < 0 ? -1 : global / 8);
|
||||
};
|
||||
netConf.max_req_per_sec = config.max_req_per_sec ? config.max_req_per_sec : MAX_REQUESTS_PER_SEC;
|
||||
netConf.max_peer_req_per_sec = config.max_peer_req_per_sec ? config.max_peer_req_per_sec
|
||||
: netConf.max_req_per_sec / 8;
|
||||
netConf.max_peer_req_per_sec = peerLimit(netConf.max_req_per_sec, config.max_peer_req_per_sec);
|
||||
// left at 0, the engine uses the limits above
|
||||
netConf.max_unauth_req_per_sec = config.max_unauth_req_per_sec;
|
||||
netConf.max_peer_unauth_req_per_sec = config.max_unauth_req_per_sec
|
||||
? peerLimit(config.max_unauth_req_per_sec,
|
||||
config.max_peer_unauth_req_per_sec)
|
||||
: config.max_peer_unauth_req_per_sec;
|
||||
netConf.max_crypto_per_sec = config.max_crypto_per_sec ? config.max_crypto_per_sec : MAX_CRYPTO_PER_SEC;
|
||||
netConf.max_peer_crypto_per_sec = peerLimit(netConf.max_crypto_per_sec, config.max_peer_crypto_per_sec);
|
||||
netConf.is_client = config.client_mode;
|
||||
netConf.transport = config.transport;
|
||||
auto& transport = netConf.transport;
|
||||
if (transport.isPrivate() and not transport.enabled)
|
||||
throw DhtException("Private networks require transport encryption");
|
||||
if (transport.enabled and not transport.key)
|
||||
transport.key = transport::KeyPair::generate();
|
||||
if (transport.isPrivate()) {
|
||||
if (transport.credential.key != transport.key.pub)
|
||||
throw DhtException("Transport credential doesn't match the transport key");
|
||||
if (not transport.credential.verify(transport.authorities))
|
||||
throw DhtException("Invalid or expired transport credential");
|
||||
}
|
||||
return netConf;
|
||||
}
|
||||
|
||||
@@ -2016,14 +2024,17 @@ Dht::Dht(std::unique_ptr<net::DatagramSocket>&& sock,
|
||||
std::bind(&Dht::onPing, this, _1),
|
||||
std::bind(&Dht::onFindNode, this, _1, _2, _3),
|
||||
std::bind(&Dht::onGetValues, this, _1, _2, _3, _4),
|
||||
std::bind(&Dht::onListen, this, _1, _2, _3, _4, _5, _6),
|
||||
std::bind(&Dht::onAnnounce, this, _1, _2, _3, _4, _5),
|
||||
std::bind(&Dht::onRefresh, this, _1, _2, _3, _4))
|
||||
std::bind(&Dht::onListen, this, _1, _2, _3, _4, _5),
|
||||
std::bind(&Dht::onAnnounce, this, _1, _2, _3, _4),
|
||||
std::bind(&Dht::onRefresh, this, _1, _2, _3))
|
||||
, persistPath(config.persist_path)
|
||||
, is_bootstrap(config.is_bootstrap)
|
||||
, maintain_storage(config.maintain_storage)
|
||||
, public_stable(config.public_stable)
|
||||
{
|
||||
if (auto key = network_engine.getTransportKey())
|
||||
myid = transport::nodeId(key->pub);
|
||||
persistTransportKey = config.transport.enabled and not config.transport.key and not persistPath.empty();
|
||||
scheduler.syncTime();
|
||||
auto s = network_engine.getSocket();
|
||||
if (not s or (not s->hasIPv4() and not s->hasIPv6()))
|
||||
@@ -2042,10 +2053,6 @@ Dht::Dht(std::unique_ptr<net::DatagramSocket>&& sock,
|
||||
uniform_duration_distribution<> time_dis {std::chrono::seconds(3), std::chrono::seconds(5)};
|
||||
nextNodesConfirmation = scheduler.add(scheduler.time() + time_dis(rd), std::bind(&Dht::confirmNodes, this));
|
||||
|
||||
// Fill old secret
|
||||
secret = std::uniform_int_distribution<uint64_t> {}(rd);
|
||||
rotateSecrets();
|
||||
|
||||
if (not persistPath.empty())
|
||||
loadState(persistPath);
|
||||
|
||||
@@ -2562,7 +2569,7 @@ Dht::onError(Sp<net::Request> req, net::DhtProtocolException e)
|
||||
for (auto& n : sr->nodes) {
|
||||
if (n->node != node)
|
||||
continue;
|
||||
n->token.clear();
|
||||
n->token.reset();
|
||||
n->last_get_reply = time_point::min();
|
||||
for (auto& listen : n->listenStatus) {
|
||||
if (listen.second.req and listen.second.req->pending())
|
||||
@@ -2601,11 +2608,10 @@ Dht::onPing(Sp<Node>)
|
||||
}
|
||||
|
||||
net::RequestAnswer
|
||||
Dht::onFindNode(Sp<Node> node, const InfoHash& target, want_t want)
|
||||
Dht::onFindNode(Sp<Node>, const InfoHash& target, want_t want)
|
||||
{
|
||||
const auto& now = scheduler.time();
|
||||
net::RequestAnswer answer;
|
||||
answer.ntoken = makeToken(node->getAddr(), false);
|
||||
if (want & WANT4)
|
||||
answer.nodes4 = dht4.buckets.findClosestNodes(target, now, TARGET_NODES);
|
||||
if (want & WANT6)
|
||||
@@ -2625,7 +2631,6 @@ Dht::onGetValues(Sp<Node> node, const InfoHash& hash, want_t, const Query& query
|
||||
const auto& now = scheduler.time();
|
||||
net::RequestAnswer answer {};
|
||||
auto st = store.find(hash);
|
||||
answer.ntoken = makeToken(node->getAddr(), false);
|
||||
answer.nodes4 = dht4.buckets.findClosestNodes(hash, now, TARGET_NODES);
|
||||
answer.nodes6 = dht6.buckets.findClosestNodes(hash, now, TARGET_NODES);
|
||||
if (st != store.end() && not st->second.empty()) {
|
||||
@@ -2649,7 +2654,7 @@ Dht::onGetValuesDone(const Sp<Node>& node, net::RequestAnswer& a, Sp<Search>& sr
|
||||
logger_->d(sr->id, "[search %s] [node %s] Got reply to 'get' with %u nodes",
|
||||
sr->id.toString().c_str(), node->toString().c_str(), a.nodes4.size()+a.nodes6.size());*/
|
||||
|
||||
if (not a.ntoken.empty()) {
|
||||
if (a.ntoken) {
|
||||
if (not a.values.empty() or not a.fields.empty()) {
|
||||
if (logger_)
|
||||
logger_->debug("[search {}] [node {}] Found {} values",
|
||||
@@ -2715,7 +2720,7 @@ Dht::onGetValuesDone(const Sp<Node>& node, net::RequestAnswer& a, Sp<Search>& sr
|
||||
}
|
||||
|
||||
net::RequestAnswer
|
||||
Dht::onListen(Sp<Node> node, const InfoHash& hash, const Blob& token, size_t socket_id, const Query& query, int version)
|
||||
Dht::onListen(Sp<Node> node, const InfoHash& hash, size_t socket_id, const Query& query, int version)
|
||||
{
|
||||
if (not hash) {
|
||||
if (logger_)
|
||||
@@ -2723,17 +2728,9 @@ Dht::onListen(Sp<Node> node, const InfoHash& hash, const Blob& token, size_t soc
|
||||
throw net::DhtProtocolException {net::DhtProtocolException::NON_AUTHORITATIVE_INFORMATION,
|
||||
net::DhtProtocolException::LISTEN_NO_INFOHASH};
|
||||
}
|
||||
if (not tokenMatch(token, node->getAddr())) {
|
||||
if (logger_)
|
||||
logger_->warn("[node {}] Incorrect token {} for 'listen'", node->toString(), hash.toString());
|
||||
throw net::DhtProtocolException {net::DhtProtocolException::UNAUTHORIZED,
|
||||
net::DhtProtocolException::LISTEN_WRONG_TOKEN};
|
||||
}
|
||||
Query q = query;
|
||||
storageAddListener(hash, node, socket_id, std::move(q), version);
|
||||
net::RequestAnswer answer {};
|
||||
answer.ntoken = makeToken(node->getAddr(), false);
|
||||
return answer;
|
||||
return {};
|
||||
}
|
||||
|
||||
void
|
||||
@@ -2745,9 +2742,9 @@ Dht::onListenDone(const Sp<Node>& node, net::RequestAnswer& answer, Sp<Search>&
|
||||
|
||||
if (not sr->done) {
|
||||
const auto& now = scheduler.time();
|
||||
if (not answer.ntoken.empty())
|
||||
if (answer.ntoken)
|
||||
if (auto sn = sr->getNode(node))
|
||||
sn->refreshToken(answer.ntoken);
|
||||
sn->refreshToken(*answer.ntoken);
|
||||
searchSendGetValues(sr);
|
||||
scheduler.edit(sr->nextSearchStep, now);
|
||||
}
|
||||
@@ -2756,7 +2753,6 @@ Dht::onListenDone(const Sp<Node>& node, net::RequestAnswer& answer, Sp<Search>&
|
||||
net::RequestAnswer
|
||||
Dht::onAnnounce(Sp<Node> n,
|
||||
const InfoHash& hash,
|
||||
const Blob& token,
|
||||
const std::vector<Sp<Value>>& values,
|
||||
const time_point& creation_date)
|
||||
{
|
||||
@@ -2767,12 +2763,6 @@ Dht::onAnnounce(Sp<Node> n,
|
||||
throw net::DhtProtocolException {net::DhtProtocolException::NON_AUTHORITATIVE_INFORMATION,
|
||||
net::DhtProtocolException::PUT_NO_INFOHASH};
|
||||
}
|
||||
if (!tokenMatch(token, node.getAddr())) {
|
||||
if (logger_)
|
||||
logger_->warn("[node {}] Incorrect token {} for 'put'", node.toString(), hash.toString());
|
||||
throw net::DhtProtocolException {net::DhtProtocolException::UNAUTHORIZED,
|
||||
net::DhtProtocolException::PUT_WRONG_TOKEN};
|
||||
}
|
||||
{
|
||||
// We store a value only if we think we're part of the
|
||||
// SEARCH_NODES nodes around the target id.
|
||||
@@ -2833,15 +2823,10 @@ Dht::onAnnounce(Sp<Node> n,
|
||||
}
|
||||
|
||||
net::RequestAnswer
|
||||
Dht::onRefresh(Sp<Node> node, const InfoHash& hash, const Blob& token, const Value::Id& vid)
|
||||
Dht::onRefresh(Sp<Node> node, const InfoHash& hash, const Value::Id& vid)
|
||||
{
|
||||
using namespace net;
|
||||
|
||||
if (not tokenMatch(token, node->getAddr())) {
|
||||
if (logger_)
|
||||
logger_->warn("[node {}] Incorrect token {} for 'put'", node->toString(), hash.toString());
|
||||
throw DhtProtocolException {DhtProtocolException::UNAUTHORIZED, DhtProtocolException::PUT_WRONG_TOKEN};
|
||||
}
|
||||
if (storageRefresh(hash, vid)) {
|
||||
if (logger_)
|
||||
logger_->debug("[store {}] [node {}] Refreshed value {:016x}", hash.toString(), node->toString(), vid);
|
||||
@@ -2872,9 +2857,7 @@ Dht::storageRefresh(const InfoHash& id, Value::Id vid)
|
||||
logger_->warn("[store {}] [node {}] Sending refresh",
|
||||
id.toString(),
|
||||
node_listeners.first->toString());
|
||||
Blob ntoken = makeToken(node_listeners.first->getAddr(), false);
|
||||
network_engine
|
||||
.tellListenerRefreshed(node_listeners.first, l.first, id, ntoken, ids, l.second.version);
|
||||
network_engine.tellListenerRefreshed(node_listeners.first, l.first, id, ids, l.second.version);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2907,8 +2890,10 @@ struct DhtState
|
||||
InfoHash id;
|
||||
std::vector<NodeExport> nodes;
|
||||
std::vector<ValuesExport> values;
|
||||
/* secret transport key, if generated by the node */
|
||||
Blob transport_key;
|
||||
|
||||
MSGPACK_DEFINE_MAP(v, id, nodes, values)
|
||||
MSGPACK_DEFINE_MAP(v, id, nodes, values, transport_key)
|
||||
};
|
||||
|
||||
void
|
||||
@@ -2918,8 +2903,26 @@ Dht::saveState(const std::string& path) const
|
||||
state.id = myid;
|
||||
state.nodes = exportNodes();
|
||||
state.values = exportValues();
|
||||
std::ofstream file(path);
|
||||
msgpack::pack(file, state);
|
||||
auto key = persistTransportKey ? network_engine.getTransportKey() : nullptr;
|
||||
if (key)
|
||||
state.transport_key.assign(key->secret.begin(), key->secret.end());
|
||||
auto temporary = path + "." + InfoHash::getRandom().toString() + ".tmp";
|
||||
try {
|
||||
std::ofstream file;
|
||||
file.exceptions(std::ios::failbit | std::ios::badbit);
|
||||
file.open(temporary, std::ios::binary | std::ios::trunc);
|
||||
if (key)
|
||||
std::filesystem::permissions(temporary,
|
||||
std::filesystem::perms::owner_read | std::filesystem::perms::owner_write);
|
||||
msgpack::pack(file, state);
|
||||
file.close();
|
||||
std::filesystem::rename(temporary, path);
|
||||
} catch (const std::exception& e) {
|
||||
std::error_code ec;
|
||||
std::filesystem::remove(temporary, ec);
|
||||
if (logger_)
|
||||
logger_->warn("Error saving state to {}: {}", path, e.what());
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
@@ -2948,8 +2951,15 @@ Dht::loadState(const std::string& path)
|
||||
auto state = oh.get().as<DhtState>();
|
||||
if (logger_)
|
||||
logger_->debug("Importing {} nodes", state.nodes.size());
|
||||
if (state.id)
|
||||
if (persistTransportKey and state.transport_key.size() == transport::KEY_SIZE) {
|
||||
transport::Key secret;
|
||||
std::copy(state.transport_key.begin(), state.transport_key.end(), secret.begin());
|
||||
auto key = transport::KeyPair::fromSecret(secret);
|
||||
network_engine.setTransportKey(key);
|
||||
myid = transport::nodeId(key.pub);
|
||||
} else if (state.id and not network_engine.getTransportKey()) {
|
||||
myid = state.id;
|
||||
}
|
||||
std::vector<Sp<Node>> tmpNodes;
|
||||
tmpNodes.reserve(state.nodes.size());
|
||||
for (const auto& node : state.nodes)
|
||||
|
||||
+881
-264
File diff suppressed because it is too large.
Load diff
+10
-3
@@ -4,6 +4,7 @@
|
||||
#include "node.h"
|
||||
#include "request.h"
|
||||
#include "rng.h"
|
||||
#include "transport.h"
|
||||
|
||||
#include <sstream>
|
||||
|
||||
@@ -147,14 +148,20 @@ Node::closeSocket(Tid id)
|
||||
std::string
|
||||
Node::toString() const
|
||||
{
|
||||
return fmt::format("{} {:<21}", id, addr.toString());
|
||||
auto state = 'P';
|
||||
auto authorization = '-';
|
||||
if (transport_) {
|
||||
state = transport_->ephemeral && transport_->validatedAddress == addr ? 'A' : 'K';
|
||||
if (transport_->authorized)
|
||||
authorization = '+';
|
||||
}
|
||||
return fmt::format("{} {:<21} [{}{}]", id, addr.toString(), state, authorization);
|
||||
}
|
||||
|
||||
std::ostream&
|
||||
operator<<(std::ostream& s, const Node& h)
|
||||
{
|
||||
s << h.id << " " << h.addr.toString();
|
||||
return s;
|
||||
return s << h.toString();
|
||||
}
|
||||
|
||||
void
|
||||
|
||||
@@ -9,6 +9,12 @@ constexpr size_t CLEANUP_MAX_NODES {4 * 1024};
|
||||
constexpr size_t CLEANUP_FREQ {4 * 1024};
|
||||
|
||||
NodeCache::~NodeCache()
|
||||
{
|
||||
clear();
|
||||
}
|
||||
|
||||
void
|
||||
NodeCache::clear()
|
||||
{
|
||||
cache_4.setExpired();
|
||||
cache_6.setExpired();
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
#include "infohash.h"
|
||||
#include "sockaddr.h"
|
||||
#include "transport.h"
|
||||
#include "net.h"
|
||||
|
||||
#include <algorithm>
|
||||
@@ -26,6 +27,7 @@ static constexpr auto KEY_NETID = "n"sv;
|
||||
static constexpr auto KEY_ISCLIENT = "s"sv;
|
||||
static constexpr auto KEY_Q = "q"sv;
|
||||
static constexpr auto KEY_A = "a"sv;
|
||||
static constexpr auto KEY_TRANSPORT = "k"sv;
|
||||
|
||||
static constexpr auto KEY_REQ_SID = "sid"sv;
|
||||
static constexpr auto KEY_REQ_ID = "id"sv;
|
||||
@@ -36,6 +38,8 @@ static constexpr auto KEY_REQ_TOKEN = "token"sv;
|
||||
static constexpr auto KEY_REQ_VALUE_ID = "vid"sv;
|
||||
static constexpr auto KEY_REQ_NODES4 = "n4"sv;
|
||||
static constexpr auto KEY_REQ_NODES6 = "n6"sv;
|
||||
static constexpr auto KEY_REQ_KEYS4 = "k4"sv;
|
||||
static constexpr auto KEY_REQ_KEYS6 = "k6"sv;
|
||||
static constexpr auto KEY_REQ_CREATION = "c"sv;
|
||||
static constexpr auto KEY_REQ_ADDRESS = "sa"sv;
|
||||
static constexpr auto KEY_REQ_VALUES = "values"sv;
|
||||
@@ -93,6 +97,8 @@ struct ParsedMessage
|
||||
time_point created {time_point::max()};
|
||||
/* IPv4 nodes in response to a 'find' request */
|
||||
Blob nodes4_raw, nodes6_raw;
|
||||
/* transport keys of the nodes, or zeros if unknown */
|
||||
Blob nodes4_keys, nodes6_keys;
|
||||
std::vector<Sp<Node>> nodes4, nodes6;
|
||||
/* values to store or retreive request */
|
||||
std::vector<Sp<Value>> values;
|
||||
@@ -129,6 +135,13 @@ struct ParsedMessage
|
||||
std::string ua;
|
||||
int version {0};
|
||||
SockAddr addr;
|
||||
/* transport key advertised by the sender in plaintext */
|
||||
std::optional<transport::Key> transport_key;
|
||||
/* transport state of the sender, if authenticated */
|
||||
Sp<transport::Peer> peer;
|
||||
bool encrypted {false};
|
||||
/* received in a Session envelope: the sender receives at its address */
|
||||
bool session {false};
|
||||
void msgpack_unpack(const msgpack::object& o);
|
||||
|
||||
bool append(const ParsedMessage& block);
|
||||
@@ -229,6 +242,11 @@ ParsedMessage::msgpack_unpack(const msgpack::object& msg)
|
||||
parsed.q = o.val.as<std::string_view>();
|
||||
else if (key == KEY_A)
|
||||
parsed.a = &o.val;
|
||||
else if (key == KEY_TRANSPORT and o.val.type == msgpack::type::BIN
|
||||
and o.val.via.bin.size == transport::KEY_SIZE) {
|
||||
transport_key.emplace();
|
||||
std::copy_n((const uint8_t*) o.val.via.bin.ptr, transport::KEY_SIZE, transport_key->begin());
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.e)
|
||||
@@ -321,6 +339,10 @@ ParsedMessage::msgpack_unpack(const msgpack::object& msg)
|
||||
nodes4_raw = unpackBlob(o.val);
|
||||
else if (key == KEY_REQ_NODES6)
|
||||
nodes6_raw = unpackBlob(o.val);
|
||||
else if (key == KEY_REQ_KEYS4)
|
||||
nodes4_keys = unpackBlob(o.val);
|
||||
else if (key == KEY_REQ_KEYS6)
|
||||
nodes6_keys = unpackBlob(o.val);
|
||||
else if (key == KEY_REQ_ADDRESS)
|
||||
parsedReq.sa = &o.val;
|
||||
else if (key == KEY_REQ_CREATION)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#pragma once
|
||||
|
||||
#include "net.h"
|
||||
#include "scheduler.h"
|
||||
#include "value.h"
|
||||
|
||||
namespace dht {
|
||||
@@ -133,6 +134,9 @@ private:
|
||||
|
||||
void clear()
|
||||
{
|
||||
if (retryJob)
|
||||
retryJob->cancel();
|
||||
retryJob.reset();
|
||||
on_done = {};
|
||||
on_error = {};
|
||||
on_expired = {};
|
||||
@@ -155,6 +159,13 @@ private:
|
||||
|
||||
Blob msg {}; /* the serialized message. */
|
||||
std::vector<Blob> parts;
|
||||
/* the last attempt was an Initial envelope, answered without caching our session */
|
||||
bool initial {false};
|
||||
/* waiting for session establishment or recovery, without an application retry timer */
|
||||
bool withheld {false};
|
||||
bool sessionProbe {false};
|
||||
bool timeoutNotified {false};
|
||||
Sp<Scheduler::Job> retryJob;
|
||||
};
|
||||
|
||||
} /* namespace net */
|
||||
|
||||
+18
-11
@@ -104,10 +104,12 @@ struct Dht::SearchNode
|
||||
std::map<Sp<Query>, std::vector<Sp<Query>>> pagination_queries {};
|
||||
|
||||
SyncStatus getStatus {}; /* get/sync status */
|
||||
Sp<net::Request> routingProbe;
|
||||
NodeListenerStatus listenStatus {}; /* listen status */
|
||||
AnnounceStatusMap acked {}; /* announcement status for a given value id */
|
||||
|
||||
Blob token {}; /* last token the node sent to us after a get request */
|
||||
/* write token the node sent us after a get request: empty if it needs none (encrypted) */
|
||||
std::optional<Blob> token {};
|
||||
time_point last_get_reply {time_point::min()}; /* last time received valid token */
|
||||
Sp<Scheduler::Job> syncJob {};
|
||||
bool candidate {false}; /* A search node is candidate if the search is/was synced and this
|
||||
@@ -138,12 +140,12 @@ struct Dht::SearchNode
|
||||
*/
|
||||
bool isSynced(const time_point& now) const
|
||||
{
|
||||
return not node->isExpired() and not token.empty() and last_get_reply >= now - Node::NODE_EXPIRE_TIME;
|
||||
return not node->isExpired() and token and last_get_reply >= now - Node::NODE_EXPIRE_TIME;
|
||||
}
|
||||
|
||||
time_point getSyncTime(const time_point& now) const
|
||||
{
|
||||
if (node->isExpired() or token.empty())
|
||||
if (node->isExpired() or not token)
|
||||
return now;
|
||||
return last_get_reply + Node::NODE_EXPIRE_TIME;
|
||||
}
|
||||
@@ -154,7 +156,7 @@ struct Dht::SearchNode
|
||||
*/
|
||||
void refreshToken(const Blob& tok)
|
||||
{
|
||||
if (not tok.empty() and tok.size() <= 64)
|
||||
if (tok.size() <= 64)
|
||||
token = tok;
|
||||
}
|
||||
|
||||
@@ -186,7 +188,7 @@ struct Dht::SearchNode
|
||||
*/
|
||||
bool canGet(time_point now, time_point update, const Get& get) const
|
||||
{
|
||||
if (node->isExpired())
|
||||
if (node->isExpired() or (routingProbe and routingProbe->pending()))
|
||||
return false;
|
||||
|
||||
bool is_pending {false}, completed_sq_status {false}, pending_sq_status {false};
|
||||
@@ -241,6 +243,8 @@ struct Dht::SearchNode
|
||||
*/
|
||||
bool isDone(const Get& get) const
|
||||
{
|
||||
if (routingProbe and routingProbe->pending())
|
||||
return false;
|
||||
if (hasStartedPagination(get.query)) {
|
||||
const auto& pqs = pagination_queries.find(get.query);
|
||||
auto paginationPending = std::find_if(pqs->second.cbegin(),
|
||||
@@ -260,6 +264,9 @@ struct Dht::SearchNode
|
||||
|
||||
void cancelGet()
|
||||
{
|
||||
if (routingProbe)
|
||||
node->cancelRequest(routingProbe);
|
||||
routingProbe.reset();
|
||||
for (const auto& status : getStatus) {
|
||||
if (status.second->pending()) {
|
||||
node->cancelRequest(status.second);
|
||||
@@ -514,7 +521,7 @@ struct Dht::Search
|
||||
/**
|
||||
* @returns true if the node was not present and added to the search
|
||||
*/
|
||||
bool insertNode(const Sp<Node>& n, time_point now, const Blob& token = {});
|
||||
bool insertNode(const Sp<Node>& n, time_point now, const std::optional<Blob>& token = {});
|
||||
|
||||
SearchNode* getNode(const Sp<Node>& n)
|
||||
{
|
||||
@@ -529,7 +536,7 @@ struct Dht::Search
|
||||
{
|
||||
unsigned count = 0;
|
||||
for (const auto& n : nodes)
|
||||
if (not n->isBad() and n->pendingGet())
|
||||
if (not n->isBad() and (n->pendingGet() or (n->routingProbe and n->routingProbe->pending())))
|
||||
count++;
|
||||
return count;
|
||||
}
|
||||
@@ -585,7 +592,7 @@ struct Dht::Search
|
||||
void setDone()
|
||||
{
|
||||
for (auto& n : nodes) {
|
||||
n->getStatus.clear();
|
||||
n->cancelGet();
|
||||
n->listenStatus.clear();
|
||||
n->acked.clear();
|
||||
}
|
||||
@@ -869,7 +876,7 @@ struct Dht::Search
|
||||
target. We just got a new candidate, insert it at the right spot or
|
||||
discard it. */
|
||||
bool
|
||||
Dht::Search::insertNode(const Sp<Node>& snode, time_point now, const Blob& token)
|
||||
Dht::Search::insertNode(const Sp<Node>& snode, time_point now, const std::optional<Blob>& token)
|
||||
{
|
||||
auto& node = *snode;
|
||||
const auto& nid = node.id;
|
||||
@@ -948,10 +955,10 @@ Dht::Search::insertNode(const Sp<Node>& snode, time_point now, const Blob& token
|
||||
}
|
||||
if (n == nodes.end() or not *n)
|
||||
return false;
|
||||
if (not token.empty()) {
|
||||
if (token) {
|
||||
(*n)->candidate = false;
|
||||
(*n)->last_get_reply = now;
|
||||
if (token.size() <= 64)
|
||||
if (token->size() <= 64)
|
||||
(*n)->token = token;
|
||||
expired = false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,454 @@
|
||||
// Copyright (c) 2014-2026 Savoir-faire Linux Inc.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
#include "transport.h"
|
||||
|
||||
#include <gnutls/crypto.h>
|
||||
#include <nettle/chacha-poly1305.h>
|
||||
#include <nettle/curve25519.h>
|
||||
#include <nettle/eddsa.h>
|
||||
#include <nettle/hmac.h>
|
||||
#include <nettle/memops.h>
|
||||
#include <nettle/sha2.h>
|
||||
#include <nettle/version.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <stdexcept>
|
||||
#include <string_view>
|
||||
|
||||
namespace dht {
|
||||
namespace transport {
|
||||
|
||||
using namespace std::literals;
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr auto NODE_ID_CONTEXT = "opendht-node-id-v1"sv;
|
||||
constexpr auto KDF_SALT = "opendht-transport-v2"sv;
|
||||
constexpr auto CREDENTIAL_CONTEXT = "opendht-credential-v2"sv;
|
||||
constexpr auto ADDRESS_KEY_CONTEXT = "opendht-address-key-v1"sv;
|
||||
constexpr auto ADDRESS_TOKEN_CONTEXT = "opendht-address-token-v1"sv;
|
||||
constexpr uint8_t CREDENTIAL_VERSION {2};
|
||||
|
||||
void
|
||||
randomBytes(uint8_t* data, size_t size, gnutls_rnd_level_t level)
|
||||
{
|
||||
if (gnutls_rnd(level, data, size) != 0)
|
||||
throw std::runtime_error("Unable to generate random data");
|
||||
}
|
||||
|
||||
Key
|
||||
hmacSha256(const uint8_t* key, size_t keyLen, const uint8_t* data, size_t dataLen)
|
||||
{
|
||||
struct hmac_sha256_ctx ctx;
|
||||
hmac_sha256_set_key(&ctx, keyLen, key);
|
||||
hmac_sha256_update(&ctx, dataLen, data);
|
||||
Key out;
|
||||
#if NETTLE_VERSION_MAJOR >= 4
|
||||
hmac_sha256_digest(&ctx, out.data());
|
||||
#else
|
||||
hmac_sha256_digest(&ctx, out.size(), out.data());
|
||||
#endif
|
||||
gnutls_memset(&ctx, 0, sizeof(ctx));
|
||||
return out;
|
||||
}
|
||||
|
||||
/* HMAC-SHA256(secret, context || address) */
|
||||
Key
|
||||
addressPrf(const Key& secret, std::string_view context, const uint8_t* address, size_t size)
|
||||
{
|
||||
struct hmac_sha256_ctx ctx;
|
||||
hmac_sha256_set_key(&ctx, secret.size(), secret.data());
|
||||
hmac_sha256_update(&ctx, context.size(), (const uint8_t*) context.data());
|
||||
hmac_sha256_update(&ctx, size, address);
|
||||
Key out;
|
||||
#if NETTLE_VERSION_MAJOR >= 4
|
||||
hmac_sha256_digest(&ctx, out.data());
|
||||
#else
|
||||
hmac_sha256_digest(&ctx, out.size(), out.data());
|
||||
#endif
|
||||
gnutls_memset(&ctx, 0, sizeof(ctx));
|
||||
return out;
|
||||
}
|
||||
|
||||
void
|
||||
chachaDigest(struct chacha_poly1305_ctx* ctx, uint8_t* tag)
|
||||
{
|
||||
#if NETTLE_VERSION_MAJOR >= 4
|
||||
chacha_poly1305_digest(ctx, tag);
|
||||
#else
|
||||
chacha_poly1305_digest(ctx, TAG_SIZE, tag);
|
||||
#endif
|
||||
}
|
||||
|
||||
constexpr size_t SIGNED_SIZE {CREDENTIAL_CONTEXT.size() + 1 + KEY_SIZE};
|
||||
|
||||
std::array<uint8_t, SIGNED_SIZE>
|
||||
signedData(const Key& key)
|
||||
{
|
||||
std::array<uint8_t, SIGNED_SIZE> data;
|
||||
auto position = std::copy(CREDENTIAL_CONTEXT.begin(), CREDENTIAL_CONTEXT.end(), data.begin());
|
||||
*position++ = CREDENTIAL_VERSION;
|
||||
std::copy(key.begin(), key.end(), position);
|
||||
return data;
|
||||
}
|
||||
|
||||
std::optional<Key>
|
||||
dh(const Key& secret, const Key& pub)
|
||||
{
|
||||
Key out;
|
||||
curve25519_mul(out.data(), secret.data(), pub.data());
|
||||
if (out == Key {})
|
||||
return std::nullopt;
|
||||
return out;
|
||||
}
|
||||
|
||||
/* Key derivation input, wiped on destruction */
|
||||
struct KdfBuffer
|
||||
{
|
||||
std::array<uint8_t, 4 + 1 + 4 * KEY_SIZE + 1> data;
|
||||
size_t size {0};
|
||||
|
||||
KdfBuffer& add(const uint8_t* d, size_t len)
|
||||
{
|
||||
std::memcpy(data.data() + size, d, len);
|
||||
size += len;
|
||||
return *this;
|
||||
}
|
||||
KdfBuffer& add(const Key& k) { return add(k.data(), k.size()); }
|
||||
KdfBuffer& add(uint8_t b) { return add(&b, 1); }
|
||||
KdfBuffer& add(NetId network)
|
||||
{
|
||||
for (int i = 0; i < 4; ++i)
|
||||
add(static_cast<uint8_t>((network >> (24 - 8 * i)) & 0xFF));
|
||||
return *this;
|
||||
}
|
||||
~KdfBuffer() { gnutls_memset(data.data(), 0, data.size()); }
|
||||
};
|
||||
|
||||
/* HKDF-SHA256 with a single output block */
|
||||
Key
|
||||
hkdf(const KdfBuffer& ikm, KdfBuffer info)
|
||||
{
|
||||
auto prk = hmacSha256((const uint8_t*) KDF_SALT.data(), KDF_SALT.size(), ikm.data.data(), ikm.size);
|
||||
info.add(uint8_t {1});
|
||||
auto out = hmacSha256(prk.data(), prk.size(), info.data.data(), info.size);
|
||||
wipe(prk);
|
||||
return out;
|
||||
}
|
||||
|
||||
KdfBuffer
|
||||
kdfInfo(NetId network, EnvelopeType type, const Key& sender, const Key& senderEphemeral, const Key& receiver)
|
||||
{
|
||||
KdfBuffer info;
|
||||
info.add(network).add(static_cast<uint8_t>(type)).add(sender).add(senderEphemeral).add(receiver);
|
||||
return info;
|
||||
}
|
||||
|
||||
void
|
||||
wipe(std::optional<Key>& k)
|
||||
{
|
||||
if (k)
|
||||
transport::wipe(*k);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
KeyPair
|
||||
KeyPair::generate()
|
||||
{
|
||||
Key secret = randomSecret();
|
||||
auto kp = fromSecret(secret);
|
||||
wipe(secret);
|
||||
return kp;
|
||||
}
|
||||
|
||||
KeyPair
|
||||
KeyPair::fromSecret(const Key& secret)
|
||||
{
|
||||
KeyPair kp;
|
||||
kp.secret = secret;
|
||||
curve25519_mul_g(kp.pub.data(), secret.data());
|
||||
return kp;
|
||||
}
|
||||
|
||||
InfoHash
|
||||
nodeId(const Key& pub)
|
||||
{
|
||||
struct sha256_ctx ctx;
|
||||
sha256_init(&ctx);
|
||||
sha256_update(&ctx, NODE_ID_CONTEXT.size(), (const uint8_t*) NODE_ID_CONTEXT.data());
|
||||
sha256_update(&ctx, pub.size(), pub.data());
|
||||
std::array<uint8_t, SHA256_DIGEST_SIZE> digest;
|
||||
#if NETTLE_VERSION_MAJOR >= 4
|
||||
sha256_digest(&ctx, digest.data());
|
||||
#else
|
||||
sha256_digest(&ctx, digest.size(), digest.data());
|
||||
#endif
|
||||
return InfoHash(digest.data(), HASH_LEN);
|
||||
}
|
||||
|
||||
Key
|
||||
randomSecret()
|
||||
{
|
||||
Key secret;
|
||||
randomBytes(secret.data(), secret.size(), GNUTLS_RND_KEY);
|
||||
return secret;
|
||||
}
|
||||
|
||||
KeyPair
|
||||
addressKey(const Key& epochSecret, const uint8_t* address, size_t size)
|
||||
{
|
||||
auto secret = addressPrf(epochSecret, ADDRESS_KEY_CONTEXT, address, size);
|
||||
auto kp = KeyPair::fromSecret(secret);
|
||||
wipe(secret);
|
||||
return kp;
|
||||
}
|
||||
|
||||
Key
|
||||
addressToken(const Key& epochSecret, const uint8_t* address, size_t size)
|
||||
{
|
||||
return addressPrf(epochSecret, ADDRESS_TOKEN_CONTEXT, address, size);
|
||||
}
|
||||
|
||||
Blob
|
||||
Credential::pack() const
|
||||
{
|
||||
Blob b(PACKED_SIZE);
|
||||
b[0] = CREDENTIAL_VERSION;
|
||||
std::copy(key.begin(), key.end(), b.begin() + 1);
|
||||
std::copy(signature.begin(), signature.end(), b.begin() + 1 + KEY_SIZE);
|
||||
return b;
|
||||
}
|
||||
|
||||
Credential
|
||||
Credential::unpack(const uint8_t* data, size_t size)
|
||||
{
|
||||
if (size != PACKED_SIZE or data[0] != CREDENTIAL_VERSION)
|
||||
throw std::invalid_argument("Invalid transport credential");
|
||||
Credential c;
|
||||
std::copy_n(data + 1, KEY_SIZE, c.key.begin());
|
||||
std::copy_n(data + 1 + KEY_SIZE, SIGNATURE_SIZE, c.signature.begin());
|
||||
return c;
|
||||
}
|
||||
|
||||
bool
|
||||
Credential::verify(const std::vector<Key>& authorities) const
|
||||
{
|
||||
auto data = signedData(key);
|
||||
return std::any_of(authorities.begin(), authorities.end(), [&](const Key& authority) {
|
||||
return ed25519_sha512_verify(authority.data(), data.size(), data.data(), signature.data()) == 1;
|
||||
});
|
||||
}
|
||||
|
||||
Authority
|
||||
Authority::generate()
|
||||
{
|
||||
Key secret;
|
||||
randomBytes(secret.data(), secret.size(), GNUTLS_RND_KEY);
|
||||
auto a = fromSecret(secret);
|
||||
wipe(secret);
|
||||
return a;
|
||||
}
|
||||
|
||||
Authority
|
||||
Authority::fromSecret(const Key& secret)
|
||||
{
|
||||
Authority a;
|
||||
a.secret = secret;
|
||||
ed25519_sha512_public_key(a.pub.data(), secret.data());
|
||||
return a;
|
||||
}
|
||||
|
||||
Credential
|
||||
Authority::issue(const Key& transportKey) const
|
||||
{
|
||||
Credential c;
|
||||
c.key = transportKey;
|
||||
auto data = signedData(c.key);
|
||||
ed25519_sha512_sign(pub.data(), secret.data(), data.size(), data.data(), c.signature.data());
|
||||
return c;
|
||||
}
|
||||
|
||||
std::optional<SessionKeys>
|
||||
sessionKeys(const KeyPair& own, const KeyPair& ephemeral, const Key& peer, const Key& peerEphemeral, NetId network)
|
||||
{
|
||||
auto ee = dh(ephemeral.secret, peerEphemeral);
|
||||
auto es = dh(ephemeral.secret, peer);
|
||||
auto se = dh(own.secret, peerEphemeral);
|
||||
auto ss = dh(own.secret, peer);
|
||||
std::optional<SessionKeys> keys;
|
||||
if (ee and es and se and ss) {
|
||||
// The DH(sender, receiver) terms in order ee, es, se, ss, seen from each side
|
||||
KdfBuffer tx, rx;
|
||||
tx.add(*ee).add(*es).add(*se).add(*ss);
|
||||
rx.add(*ee).add(*se).add(*es).add(*ss);
|
||||
keys = SessionKeys {
|
||||
hkdf(tx, kdfInfo(network, EnvelopeType::Session, own.pub, ephemeral.pub, peer).add(peerEphemeral)),
|
||||
hkdf(rx, kdfInfo(network, EnvelopeType::Session, peer, peerEphemeral, own.pub).add(ephemeral.pub))};
|
||||
}
|
||||
for (auto* k : {&ee, &es, &se, &ss})
|
||||
wipe(*k);
|
||||
return keys;
|
||||
}
|
||||
|
||||
std::optional<Key>
|
||||
initialSendKey(const KeyPair& own, const KeyPair& ephemeral, const Key& peer, NetId network)
|
||||
{
|
||||
auto es = dh(ephemeral.secret, peer);
|
||||
auto ss = dh(own.secret, peer);
|
||||
std::optional<Key> key;
|
||||
if (es and ss) {
|
||||
KdfBuffer ikm;
|
||||
ikm.add(*es).add(*ss);
|
||||
key = hkdf(ikm, kdfInfo(network, EnvelopeType::Initial, own.pub, ephemeral.pub, peer));
|
||||
}
|
||||
wipe(es);
|
||||
wipe(ss);
|
||||
return key;
|
||||
}
|
||||
|
||||
std::optional<Key>
|
||||
initialReceiveKey(const KeyPair& own, const Key& peer, const Key& peerEphemeral, NetId network)
|
||||
{
|
||||
auto es = dh(own.secret, peerEphemeral);
|
||||
auto ss = dh(own.secret, peer);
|
||||
std::optional<Key> key;
|
||||
if (es and ss) {
|
||||
KdfBuffer ikm;
|
||||
ikm.add(*es).add(*ss);
|
||||
key = hkdf(ikm, kdfInfo(network, EnvelopeType::Initial, peer, peerEphemeral, own.pub));
|
||||
}
|
||||
wipe(es);
|
||||
wipe(ss);
|
||||
return key;
|
||||
}
|
||||
|
||||
std::optional<Header>
|
||||
parseHeader(const uint8_t* data, size_t size)
|
||||
{
|
||||
if (not isEnvelope(data, size))
|
||||
return std::nullopt;
|
||||
const uint8_t type = data[1] & ~CREDENTIAL_FLAG;
|
||||
Header header {};
|
||||
header.credential = data[1] & CREDENTIAL_FLAG;
|
||||
if (type == static_cast<uint8_t>(EnvelopeType::Data)) {
|
||||
if (header.credential)
|
||||
return std::nullopt;
|
||||
header.type = EnvelopeType::Data;
|
||||
return header;
|
||||
}
|
||||
if (type < static_cast<uint8_t>(EnvelopeType::Initial) or type > static_cast<uint8_t>(EnvelopeType::Hello)
|
||||
or size < HEADER_SIZE)
|
||||
return std::nullopt;
|
||||
header.type = static_cast<EnvelopeType>(type);
|
||||
std::copy_n(data + 2, KEY_SIZE, header.sender.begin());
|
||||
std::copy_n(data + 2 + KEY_SIZE, KEY_SIZE, header.ephemeral.begin());
|
||||
return header;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
/* Appends the nonce and the encryption of `credential || data` to the authenticated header in `out` */
|
||||
Blob
|
||||
sealWith(const Key& key, Blob out, const Blob& credential, const uint8_t* data, size_t size)
|
||||
{
|
||||
const size_t headerSize = out.size();
|
||||
const size_t len = credential.size() + size;
|
||||
out.resize(headerSize + NONCE_SIZE + len + TAG_SIZE);
|
||||
auto nonce = out.data() + headerSize;
|
||||
randomBytes(nonce, NONCE_SIZE, GNUTLS_RND_NONCE);
|
||||
auto payload = nonce + NONCE_SIZE;
|
||||
std::copy(credential.begin(), credential.end(), payload);
|
||||
if (size)
|
||||
std::memcpy(payload + credential.size(), data, size);
|
||||
|
||||
struct chacha_poly1305_ctx ctx;
|
||||
chacha_poly1305_set_key(&ctx, key.data());
|
||||
chacha_poly1305_set_nonce(&ctx, nonce);
|
||||
chacha_poly1305_update(&ctx, headerSize, out.data());
|
||||
chacha_poly1305_encrypt(&ctx, len, payload, payload);
|
||||
chachaDigest(&ctx, payload + len);
|
||||
gnutls_memset(&ctx, 0, sizeof(ctx));
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Blob
|
||||
seal(const Key& key,
|
||||
EnvelopeType type,
|
||||
const Key& sender,
|
||||
const Key& ephemeral,
|
||||
const uint8_t* data,
|
||||
size_t size,
|
||||
const Blob& credential)
|
||||
{
|
||||
Blob header;
|
||||
header.reserve(MAX_ENVELOPE_OVERHEAD + size);
|
||||
header.push_back(ENVELOPE_MARKER);
|
||||
header.push_back(static_cast<uint8_t>(type) | (credential.empty() ? 0 : CREDENTIAL_FLAG));
|
||||
header.insert(header.end(), sender.begin(), sender.end());
|
||||
header.insert(header.end(), ephemeral.begin(), ephemeral.end());
|
||||
return sealWith(key, std::move(header), credential, data, size);
|
||||
}
|
||||
|
||||
Blob
|
||||
sealData(const Key& key, const uint8_t* data, size_t size)
|
||||
{
|
||||
Blob header;
|
||||
header.reserve(DATA_ENVELOPE_OVERHEAD + size);
|
||||
header.push_back(ENVELOPE_MARKER);
|
||||
header.push_back(static_cast<uint8_t>(EnvelopeType::Data));
|
||||
return sealWith(key, std::move(header), {}, data, size);
|
||||
}
|
||||
|
||||
std::optional<Blob>
|
||||
open(const Key& key, const uint8_t* data, size_t size)
|
||||
{
|
||||
auto header = parseHeader(data, size);
|
||||
if (not header or header->type == EnvelopeType::Hello)
|
||||
return std::nullopt;
|
||||
const size_t headerSize = header->type == EnvelopeType::Data ? DATA_HEADER_SIZE : HEADER_SIZE;
|
||||
if (size < headerSize + NONCE_SIZE + TAG_SIZE)
|
||||
return std::nullopt;
|
||||
const size_t len = size - headerSize - NONCE_SIZE - TAG_SIZE;
|
||||
const auto nonce = data + headerSize;
|
||||
|
||||
struct chacha_poly1305_ctx ctx;
|
||||
chacha_poly1305_set_key(&ctx, key.data());
|
||||
chacha_poly1305_set_nonce(&ctx, nonce);
|
||||
chacha_poly1305_update(&ctx, headerSize, data);
|
||||
Blob out(len);
|
||||
chacha_poly1305_decrypt(&ctx, len, out.data(), nonce + NONCE_SIZE);
|
||||
std::array<uint8_t, TAG_SIZE> tag;
|
||||
chachaDigest(&ctx, tag.data());
|
||||
gnutls_memset(&ctx, 0, sizeof(ctx));
|
||||
if (not memeql_sec(tag.data(), data + size - TAG_SIZE, TAG_SIZE))
|
||||
return std::nullopt;
|
||||
return out;
|
||||
}
|
||||
|
||||
Blob
|
||||
hello(const Key& sender, const Key& ephemeral, const Blob& credential, const uint8_t* data, size_t size)
|
||||
{
|
||||
Blob out;
|
||||
out.reserve(HEADER_SIZE + credential.size() + size);
|
||||
out.push_back(ENVELOPE_MARKER);
|
||||
out.push_back(static_cast<uint8_t>(EnvelopeType::Hello));
|
||||
out.insert(out.end(), sender.begin(), sender.end());
|
||||
out.insert(out.end(), ephemeral.begin(), ephemeral.end());
|
||||
out.insert(out.end(), credential.begin(), credential.end());
|
||||
out.insert(out.end(), data, data + size);
|
||||
return out;
|
||||
}
|
||||
|
||||
void
|
||||
wipe(Key& key)
|
||||
{
|
||||
gnutls_memset(key.data(), 0, key.size());
|
||||
}
|
||||
|
||||
} // namespace transport
|
||||
} // namespace dht
|
||||
@@ -10,6 +10,7 @@
|
||||
#include <mutex>
|
||||
#include <condition_variable>
|
||||
#include <fstream>
|
||||
#include <filesystem>
|
||||
#include <cstdio>
|
||||
using namespace std::chrono_literals;
|
||||
using namespace std::literals;
|
||||
@@ -17,6 +18,7 @@ using namespace std::literals;
|
||||
namespace test {
|
||||
CPPUNIT_TEST_SUITE_REGISTRATION(DhtRunnerTester);
|
||||
CPPUNIT_TEST_SUITE_REGISTRATION(DhtRunnerPushTester);
|
||||
CPPUNIT_TEST_SUITE_REGISTRATION(DhtRunnerTransportTester);
|
||||
|
||||
namespace {
|
||||
#if defined(OPENDHT_PROXY_CLIENT) && defined(OPENDHT_PUSH_NOTIFICATIONS)
|
||||
@@ -1579,4 +1581,281 @@ DhtRunnerTester::testListenMultiValuePartialExpire()
|
||||
node1.cancelListen(key, ftoken.get());
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
/* UDP socket recording sent packets */
|
||||
class RecordingSocket : public dht::net::DatagramSocket
|
||||
{
|
||||
public:
|
||||
RecordingSocket()
|
||||
: inner_(std::make_unique<dht::net::UdpSocket>(0))
|
||||
{
|
||||
inner_->setOnReceive([this](dht::net::PacketList&& packets) {
|
||||
onReceived(std::move(packets));
|
||||
return dht::net::PacketList {};
|
||||
});
|
||||
}
|
||||
~RecordingSocket() { inner_.reset(); }
|
||||
|
||||
int sendTo(const dht::SockAddr& dest, const uint8_t* data, size_t size, bool replied) override
|
||||
{
|
||||
{
|
||||
std::lock_guard lk(sentLock_);
|
||||
sent_.emplace_back(data, data + size);
|
||||
}
|
||||
return inner_->sendTo(dest, data, size, replied);
|
||||
}
|
||||
bool hasIPv4() const override { return inner_->hasIPv4(); }
|
||||
bool hasIPv6() const override { return inner_->hasIPv6(); }
|
||||
const dht::SockAddr& getBoundRef(sa_family_t family = AF_UNSPEC) const override
|
||||
{
|
||||
return inner_->getBoundRef(family);
|
||||
}
|
||||
void stop() override { inner_->stop(); }
|
||||
|
||||
std::vector<dht::Blob> sent() const
|
||||
{
|
||||
std::lock_guard lk(sentLock_);
|
||||
return sent_;
|
||||
}
|
||||
|
||||
private:
|
||||
std::unique_ptr<dht::net::UdpSocket> inner_;
|
||||
mutable std::mutex sentLock_;
|
||||
std::vector<dht::Blob> sent_;
|
||||
};
|
||||
|
||||
dht::DhtRunner::Config
|
||||
transportTestConfig(dht::TransportConfig transport = {})
|
||||
{
|
||||
dht::DhtRunner::Config config;
|
||||
config.dht_config.node_config.max_peer_req_per_sec = -1;
|
||||
config.dht_config.node_config.max_req_per_sec = -1;
|
||||
config.dht_config.node_config.transport = std::move(transport);
|
||||
return config;
|
||||
}
|
||||
|
||||
RecordingSocket*
|
||||
runRecorded(dht::DhtRunner& node, const dht::DhtRunner::Config& config)
|
||||
{
|
||||
auto socket = std::make_unique<RecordingSocket>();
|
||||
auto ptr = socket.get();
|
||||
dht::DhtRunner::Context context;
|
||||
context.sock = std::move(socket);
|
||||
node.run(config, std::move(context));
|
||||
return ptr;
|
||||
}
|
||||
|
||||
dht::SockAddr
|
||||
localBound(const dht::DhtRunner& node)
|
||||
{
|
||||
auto bound = node.getBound();
|
||||
if (bound.isUnspecified())
|
||||
bound.setLoopback();
|
||||
return bound;
|
||||
}
|
||||
|
||||
bool
|
||||
isEnvelope(const dht::Blob& packet)
|
||||
{
|
||||
return dht::transport::isEnvelope(packet.data(), packet.size());
|
||||
}
|
||||
|
||||
void
|
||||
putGet(dht::DhtRunner& from, dht::DhtRunner& to, const std::string& keyName)
|
||||
{
|
||||
auto key = dht::InfoHash::get(keyName);
|
||||
dht::Value val {keyName};
|
||||
auto data = val.data;
|
||||
std::promise<bool> p;
|
||||
auto future = p.get_future();
|
||||
from.put(key, std::move(val), [&](bool ok) { p.set_value(ok); });
|
||||
CPPUNIT_ASSERT(getFutureValue(std::move(future)));
|
||||
auto vals = getFutureValue(to.get(key));
|
||||
CPPUNIT_ASSERT(not vals.empty());
|
||||
CPPUNIT_ASSERT(vals.front()->data == data);
|
||||
}
|
||||
|
||||
dht::TransportConfig
|
||||
privateTransport(const dht::transport::Authority& authority)
|
||||
{
|
||||
dht::TransportConfig transport;
|
||||
transport.key = dht::transport::KeyPair::generate();
|
||||
transport.authorities = {authority.pub};
|
||||
transport.credential = authority.issue(transport.key.pub);
|
||||
return transport;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void
|
||||
DhtRunnerTransportTester::testEncryptedByDefault()
|
||||
{
|
||||
dht::DhtRunner a, b;
|
||||
auto socketA = runRecorded(a, transportTestConfig());
|
||||
auto socketB = runRecorded(b, transportTestConfig());
|
||||
b.bootstrap(localBound(a));
|
||||
putGet(b, a, "encrypted");
|
||||
|
||||
// a learns b's key from its first request: everything a sends is encrypted
|
||||
auto sentA = socketA->sent();
|
||||
CPPUNIT_ASSERT(not sentA.empty());
|
||||
CPPUNIT_ASSERT(std::all_of(sentA.begin(), sentA.end(), isEnvelope));
|
||||
|
||||
// b sends plaintext only until it learns a's key
|
||||
auto sentB = socketB->sent();
|
||||
auto firstEnvelope = std::find_if(sentB.begin(), sentB.end(), isEnvelope);
|
||||
CPPUNIT_ASSERT(firstEnvelope != sentB.end());
|
||||
CPPUNIT_ASSERT(std::all_of(firstEnvelope, sentB.end(), isEnvelope));
|
||||
}
|
||||
|
||||
void
|
||||
DhtRunnerTransportTester::testLegacyInterop()
|
||||
{
|
||||
dht::TransportConfig legacyTransport;
|
||||
legacyTransport.enabled = false;
|
||||
dht::DhtRunner node, legacy;
|
||||
auto socket = runRecorded(node, transportTestConfig());
|
||||
auto legacyConfig = transportTestConfig(legacyTransport);
|
||||
legacy.run(0, legacyConfig);
|
||||
legacy.bootstrap(localBound(node));
|
||||
|
||||
putGet(legacy, node, "legacy to new");
|
||||
putGet(node, legacy, "new to legacy");
|
||||
|
||||
auto sent = socket->sent();
|
||||
CPPUNIT_ASSERT(not sent.empty());
|
||||
CPPUNIT_ASSERT(std::none_of(sent.begin(), sent.end(), isEnvelope));
|
||||
}
|
||||
|
||||
void
|
||||
DhtRunnerTransportTester::testNodeIdFromTransportKey()
|
||||
{
|
||||
std::mt19937_64 rd(42);
|
||||
auto configuredId = dht::InfoHash::getRandom(rd);
|
||||
|
||||
dht::TransportConfig transport;
|
||||
transport.key = dht::transport::KeyPair::generate();
|
||||
auto config = transportTestConfig(transport);
|
||||
config.dht_config.node_config.node_id = configuredId;
|
||||
dht::DhtRunner node;
|
||||
node.run(0, config);
|
||||
CPPUNIT_ASSERT_EQUAL(dht::transport::nodeId(transport.key.pub), node.getNodeId());
|
||||
|
||||
transport.enabled = false;
|
||||
config = transportTestConfig(transport);
|
||||
config.dht_config.node_config.node_id = configuredId;
|
||||
dht::DhtRunner legacy;
|
||||
legacy.run(0, config);
|
||||
CPPUNIT_ASSERT_EQUAL(configuredId, legacy.getNodeId());
|
||||
}
|
||||
|
||||
void
|
||||
DhtRunnerTransportTester::testPersistedTransportKey()
|
||||
{
|
||||
const auto path = std::filesystem::temp_directory_path()
|
||||
/ ("opendht_test_transport_state_" + dht::InfoHash::getRandom().toString());
|
||||
auto previous = path;
|
||||
previous += ".previous";
|
||||
std::filesystem::remove(path);
|
||||
std::filesystem::remove(previous);
|
||||
auto runNode = [&](const dht::TransportConfig& transport) {
|
||||
auto config = transportTestConfig(transport);
|
||||
config.dht_config.node_config.persist_path = path.string();
|
||||
dht::DhtRunner node;
|
||||
node.run(0, config);
|
||||
auto id = node.getNodeId();
|
||||
std::promise<void> stopped;
|
||||
node.shutdown([&] { stopped.set_value(); });
|
||||
stopped.get_future().wait();
|
||||
node.join();
|
||||
return id;
|
||||
};
|
||||
|
||||
// A generated key is persisted, keeping the node ID
|
||||
auto generatedId = runNode({});
|
||||
CPPUNIT_ASSERT(std::filesystem::exists(path));
|
||||
CPPUNIT_ASSERT_EQUAL(generatedId, runNode({}));
|
||||
#ifndef _WIN32
|
||||
auto perms = std::filesystem::status(path).permissions();
|
||||
CPPUNIT_ASSERT((perms & (std::filesystem::perms::group_all | std::filesystem::perms::others_all))
|
||||
== std::filesystem::perms::none);
|
||||
#endif
|
||||
|
||||
// A configured key takes precedence and isn't persisted
|
||||
dht::TransportConfig configured;
|
||||
configured.key = dht::transport::KeyPair::generate();
|
||||
std::filesystem::create_hard_link(path, previous);
|
||||
auto previousSize = std::filesystem::file_size(previous);
|
||||
CPPUNIT_ASSERT_EQUAL(dht::transport::nodeId(configured.key.pub), runNode(configured));
|
||||
CPPUNIT_ASSERT(not std::filesystem::equivalent(path, previous));
|
||||
CPPUNIT_ASSERT_EQUAL(previousSize, std::filesystem::file_size(previous));
|
||||
auto newId = runNode({});
|
||||
CPPUNIT_ASSERT(newId != generatedId);
|
||||
CPPUNIT_ASSERT(newId != dht::transport::nodeId(configured.key.pub));
|
||||
CPPUNIT_ASSERT_EQUAL(newId, runNode({}));
|
||||
|
||||
#ifndef _WIN32
|
||||
std::filesystem::remove(path);
|
||||
std::filesystem::create_symlink(previous, path);
|
||||
CPPUNIT_ASSERT_EQUAL(generatedId, runNode({}));
|
||||
CPPUNIT_ASSERT(not std::filesystem::is_symlink(path));
|
||||
CPPUNIT_ASSERT_EQUAL(previousSize, std::filesystem::file_size(previous));
|
||||
#endif
|
||||
std::filesystem::remove(path);
|
||||
std::filesystem::remove(previous);
|
||||
}
|
||||
|
||||
void
|
||||
DhtRunnerTransportTester::testPrivateNetwork()
|
||||
{
|
||||
auto authority = dht::transport::Authority::generate();
|
||||
auto nextAuthority = dht::transport::Authority::generate();
|
||||
dht::DhtRunner member1, member2, outsider;
|
||||
auto transport1 = privateTransport(authority);
|
||||
auto transport2 = privateTransport(nextAuthority);
|
||||
transport1.authorities = transport2.authorities = {authority.pub, nextAuthority.pub};
|
||||
auto socket1 = runRecorded(member1, transportTestConfig(transport1));
|
||||
auto config2 = transportTestConfig(transport2);
|
||||
member2.run(0, config2);
|
||||
auto outsiderConfig = transportTestConfig(privateTransport(dht::transport::Authority::generate()));
|
||||
outsider.run(0, outsiderConfig);
|
||||
|
||||
member2.bootstrap(localBound(member1));
|
||||
putGet(member2, member1, "private");
|
||||
|
||||
outsider.bootstrap(localBound(member1));
|
||||
std::this_thread::sleep_for(2s);
|
||||
CPPUNIT_ASSERT_EQUAL(0u, outsider.getNodesStats(AF_INET).getKnownNodes());
|
||||
CPPUNIT_ASSERT_EQUAL(1u, member1.getNodesStats(AF_INET).getKnownNodes());
|
||||
|
||||
auto sent = socket1->sent();
|
||||
CPPUNIT_ASSERT(not sent.empty());
|
||||
CPPUNIT_ASSERT(std::all_of(sent.begin(), sent.end(), isEnvelope));
|
||||
}
|
||||
|
||||
void
|
||||
DhtRunnerTransportTester::testInvalidPrivateConfig()
|
||||
{
|
||||
auto authority = dht::transport::Authority::generate();
|
||||
|
||||
auto otherKey = privateTransport(authority);
|
||||
otherKey.key = dht::transport::KeyPair::generate();
|
||||
dht::DhtRunner node1;
|
||||
auto config = transportTestConfig(otherKey);
|
||||
CPPUNIT_ASSERT_THROW(node1.run(0, config), dht::DhtException);
|
||||
|
||||
auto untrusted = privateTransport(authority);
|
||||
untrusted.authorities = {dht::transport::Authority::generate().pub};
|
||||
dht::DhtRunner node2;
|
||||
config = transportTestConfig(untrusted);
|
||||
CPPUNIT_ASSERT_THROW(node2.run(0, config), dht::DhtException);
|
||||
|
||||
auto disabled = privateTransport(authority);
|
||||
disabled.enabled = false;
|
||||
dht::DhtRunner node3;
|
||||
config = transportTestConfig(disabled);
|
||||
CPPUNIT_ASSERT_THROW(node3.run(0, config), dht::DhtException);
|
||||
}
|
||||
|
||||
} // namespace test
|
||||
@@ -26,6 +26,26 @@ public:
|
||||
void testPushWhileRunning();
|
||||
};
|
||||
|
||||
class DhtRunnerTransportTester : public CppUnit::TestFixture
|
||||
{
|
||||
CPPUNIT_TEST_SUITE(DhtRunnerTransportTester);
|
||||
CPPUNIT_TEST(testEncryptedByDefault);
|
||||
CPPUNIT_TEST(testLegacyInterop);
|
||||
CPPUNIT_TEST(testNodeIdFromTransportKey);
|
||||
CPPUNIT_TEST(testPersistedTransportKey);
|
||||
CPPUNIT_TEST(testPrivateNetwork);
|
||||
CPPUNIT_TEST(testInvalidPrivateConfig);
|
||||
CPPUNIT_TEST_SUITE_END();
|
||||
|
||||
public:
|
||||
void testEncryptedByDefault();
|
||||
void testLegacyInterop();
|
||||
void testNodeIdFromTransportKey();
|
||||
void testPersistedTransportKey();
|
||||
void testPrivateNetwork();
|
||||
void testInvalidPrivateConfig();
|
||||
};
|
||||
|
||||
class DhtRunnerTester : public CppUnit::TestFixture
|
||||
{
|
||||
CPPUNIT_TEST_SUITE(DhtRunnerTester);
|
||||
|
||||
+2343
-27
File diff suppressed because it is too large.
Load diff
+108
-2
@@ -20,13 +20,66 @@ class NetworkEngineTester : public CppUnit::TestFixture
|
||||
CPPUNIT_TEST(testDropsMalformedCompletedPartialMessage);
|
||||
CPPUNIT_TEST(testCompletesPartialSessionFromSameSource);
|
||||
CPPUNIT_TEST(testSeparatesPartialSessionsBySource);
|
||||
CPPUNIT_TEST(testListenConfirmationCarriesToken);
|
||||
CPPUNIT_TEST(testPlaintextListenRequiresToken);
|
||||
CPPUNIT_TEST(testListenConfirmationUpdatesSearchNodeToken);
|
||||
CPPUNIT_TEST(testListenReopensSocketAfterNodeExpiration);
|
||||
CPPUNIT_TEST(testUnauthorizedListenFlushClearsListenState);
|
||||
CPPUNIT_TEST(testGetRequestReuse);
|
||||
CPPUNIT_TEST(testFilteredProjectionDoesNotWaitForUnrelatedPeer);
|
||||
CPPUNIT_TEST(testFindNodeRequestReuse);
|
||||
CPPUNIT_TEST(testNodeTransportState);
|
||||
CPPUNIT_TEST(testTransportDisabledKeepsLegacyWire);
|
||||
CPPUNIT_TEST(testTransportEncryptsAfterKeyExchange);
|
||||
CPPUNIT_TEST(testTransportLegacyPeerStaysPlaintext);
|
||||
CPPUNIT_TEST(testTransportRejectsMismatchedNodeId);
|
||||
CPPUNIT_TEST(testTransportIgnoresForgedNodeListKey);
|
||||
CPPUNIT_TEST(testTransportPlaintextDoesNotMoveKeyedNode);
|
||||
CPPUNIT_TEST(testTransportPrivateNetwork);
|
||||
CPPUNIT_TEST(testTransportPrivateClientKeepsAuthorization);
|
||||
CPPUNIT_TEST(testTransportPrivateNetworkRejectsOutsiders);
|
||||
CPPUNIT_TEST(testTransportEpochRotation);
|
||||
CPPUNIT_TEST(testTransportPeerRestart);
|
||||
CPPUNIT_TEST(testTransportHelloIsUnauthenticated);
|
||||
CPPUNIT_TEST(testTransportSessionAuthorizesWrites);
|
||||
CPPUNIT_TEST(testTransportInitialWriteIsUnauthorized);
|
||||
CPPUNIT_TEST(testTransportUnvalidatedGetIsUnauthorized);
|
||||
CPPUNIT_TEST(testTransportEphemeralBoundToAddress);
|
||||
CPPUNIT_TEST(testTransportRestartedPeerReconnects);
|
||||
CPPUNIT_TEST(testTransportSpoofedInitialKeepsSession);
|
||||
CPPUNIT_TEST(testTransportSpoofedHelloKeepsSession);
|
||||
CPPUNIT_TEST(testTransportSpoofedInitialsDoNotFillSessionCache);
|
||||
CPPUNIT_TEST(testTransportSessionCacheEvictsLeastRecentlyUsed);
|
||||
CPPUNIT_TEST(testTransportRejectedSessionsKeepCache);
|
||||
CPPUNIT_TEST(testTransportInitialSenderConfirmsAfterSessionEnvelope);
|
||||
CPPUNIT_TEST(testTransportEncryptedFragmentsIgnorePlaintextParts);
|
||||
CPPUNIT_TEST(testTransportEncryptedFragmentsIgnorePlaintextHeader);
|
||||
CPPUNIT_TEST(testTransportEncryptedFragmentsRequireSenderKey);
|
||||
CPPUNIT_TEST(testValueFragmentsFitMinimumMtu);
|
||||
CPPUNIT_TEST(testTransportRetryRecoversSessionBeforePayload);
|
||||
CPPUNIT_TEST(testTransportRetryNeverResendsPayloadInInitial);
|
||||
CPPUNIT_TEST(testTransportRecoveryWithholdsAllRequests);
|
||||
CPPUNIT_TEST(testTransportColdRequestsShareProbe);
|
||||
CPPUNIT_TEST(testTransportDelayedReplyDoesNotResend);
|
||||
CPPUNIT_TEST(testTransportAuthenticatedReplyCanMoveNode);
|
||||
CPPUNIT_TEST(testTransportRecoveryRefreshesDeadline);
|
||||
CPPUNIT_TEST(testTransportSearchRoutesBeforeFetching);
|
||||
CPPUNIT_TEST(testTransportSearchProbeSlots);
|
||||
CPPUNIT_TEST(testTransportSearchHalfUnreachable);
|
||||
CPPUNIT_TEST(testTransportProbeFailureNotifiesWaiters);
|
||||
CPPUNIT_TEST(testTransportProbeErrorExpiresWaiters);
|
||||
CPPUNIT_TEST(testTransportFindReleasesWaiters);
|
||||
CPPUNIT_TEST(testTransportColdLegacyGet);
|
||||
CPPUNIT_TEST(testTransportPlaintextCannotPoisonSessionCache);
|
||||
CPPUNIT_TEST(testTransportPlaintextCannotUpdateKeyedNode);
|
||||
CPPUNIT_TEST(testTransportPlaintextCannotCompleteEncryptedRequest);
|
||||
CPPUNIT_TEST(testTransportInitialReplayKeepsPeerState);
|
||||
CPPUNIT_TEST(testTransportInitialAddressChangeNeedsSession);
|
||||
CPPUNIT_TEST(testTransportEncryptionFailureDoesNotSendPlaintext);
|
||||
CPPUNIT_TEST(testRateLimitSeparatesSessionRequests);
|
||||
CPPUNIT_TEST(testRateLimitCryptoAdmission);
|
||||
CPPUNIT_TEST(testRateLimitCachedSessionCredentials);
|
||||
CPPUNIT_TEST(testRateLimitCryptoPerPrefix);
|
||||
CPPUNIT_TEST(testRateLimitReservesExpectedReplies);
|
||||
#endif
|
||||
CPPUNIT_TEST_SUITE_END();
|
||||
|
||||
@@ -44,13 +97,66 @@ public:
|
||||
void testDropsMalformedCompletedPartialMessage();
|
||||
void testCompletesPartialSessionFromSameSource();
|
||||
void testSeparatesPartialSessionsBySource();
|
||||
void testListenConfirmationCarriesToken();
|
||||
void testPlaintextListenRequiresToken();
|
||||
void testListenConfirmationUpdatesSearchNodeToken();
|
||||
void testListenReopensSocketAfterNodeExpiration();
|
||||
void testUnauthorizedListenFlushClearsListenState();
|
||||
void testGetRequestReuse();
|
||||
void testFilteredProjectionDoesNotWaitForUnrelatedPeer();
|
||||
void testFindNodeRequestReuse();
|
||||
void testNodeTransportState();
|
||||
void testTransportDisabledKeepsLegacyWire();
|
||||
void testTransportEncryptsAfterKeyExchange();
|
||||
void testTransportLegacyPeerStaysPlaintext();
|
||||
void testTransportRejectsMismatchedNodeId();
|
||||
void testTransportIgnoresForgedNodeListKey();
|
||||
void testTransportPlaintextDoesNotMoveKeyedNode();
|
||||
void testTransportPrivateNetwork();
|
||||
void testTransportPrivateClientKeepsAuthorization();
|
||||
void testTransportPrivateNetworkRejectsOutsiders();
|
||||
void testTransportEpochRotation();
|
||||
void testTransportPeerRestart();
|
||||
void testTransportHelloIsUnauthenticated();
|
||||
void testTransportSessionAuthorizesWrites();
|
||||
void testTransportInitialWriteIsUnauthorized();
|
||||
void testTransportUnvalidatedGetIsUnauthorized();
|
||||
void testTransportEphemeralBoundToAddress();
|
||||
void testTransportRestartedPeerReconnects();
|
||||
void testTransportSpoofedInitialKeepsSession();
|
||||
void testTransportSpoofedHelloKeepsSession();
|
||||
void testTransportSpoofedInitialsDoNotFillSessionCache();
|
||||
void testTransportSessionCacheEvictsLeastRecentlyUsed();
|
||||
void testTransportRejectedSessionsKeepCache();
|
||||
void testTransportInitialSenderConfirmsAfterSessionEnvelope();
|
||||
void testTransportEncryptedFragmentsIgnorePlaintextParts();
|
||||
void testTransportEncryptedFragmentsIgnorePlaintextHeader();
|
||||
void testTransportEncryptedFragmentsRequireSenderKey();
|
||||
void testValueFragmentsFitMinimumMtu();
|
||||
void testTransportRetryRecoversSessionBeforePayload();
|
||||
void testTransportRetryNeverResendsPayloadInInitial();
|
||||
void testTransportRecoveryWithholdsAllRequests();
|
||||
void testTransportColdRequestsShareProbe();
|
||||
void testTransportDelayedReplyDoesNotResend();
|
||||
void testTransportAuthenticatedReplyCanMoveNode();
|
||||
void testTransportRecoveryRefreshesDeadline();
|
||||
void testTransportSearchRoutesBeforeFetching();
|
||||
void testTransportSearchProbeSlots();
|
||||
void testTransportSearchHalfUnreachable();
|
||||
void testTransportProbeFailureNotifiesWaiters();
|
||||
void testTransportProbeErrorExpiresWaiters();
|
||||
void testTransportFindReleasesWaiters();
|
||||
void testTransportColdLegacyGet();
|
||||
void testTransportPlaintextCannotPoisonSessionCache();
|
||||
void testTransportPlaintextCannotUpdateKeyedNode();
|
||||
void testTransportPlaintextCannotCompleteEncryptedRequest();
|
||||
void testTransportInitialReplayKeepsPeerState();
|
||||
void testTransportInitialAddressChangeNeedsSession();
|
||||
void testTransportEncryptionFailureDoesNotSendPlaintext();
|
||||
void testRateLimitSeparatesSessionRequests();
|
||||
void testRateLimitCryptoAdmission();
|
||||
void testRateLimitCachedSessionCredentials();
|
||||
void testRateLimitCryptoPerPrefix();
|
||||
void testRateLimitReservesExpectedReplies();
|
||||
#endif
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
// Copyright (c) 2014-2026 Savoir-faire Linux Inc.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
#include "test_transport.h"
|
||||
|
||||
#include <opendht/transport.h>
|
||||
#include <opendht/value.h>
|
||||
|
||||
#include <msgpack.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <new>
|
||||
|
||||
using namespace std::chrono_literals;
|
||||
|
||||
namespace test {
|
||||
CPPUNIT_TEST_SUITE_REGISTRATION(TransportTester);
|
||||
|
||||
using namespace dht;
|
||||
using namespace dht::transport;
|
||||
|
||||
static Key
|
||||
keyFromHex(std::string_view hex)
|
||||
{
|
||||
Key k;
|
||||
for (size_t i = 0; i < k.size(); i++)
|
||||
k[i] = std::stoi(std::string(hex.substr(2 * i, 2)), nullptr, 16);
|
||||
return k;
|
||||
}
|
||||
|
||||
static Blob
|
||||
message()
|
||||
{
|
||||
msgpack::sbuffer buffer;
|
||||
msgpack::packer<msgpack::sbuffer> pk(&buffer);
|
||||
pk.pack_map(2);
|
||||
pk.pack("y");
|
||||
pk.pack("q");
|
||||
pk.pack("q");
|
||||
pk.pack("ping");
|
||||
return {buffer.data(), buffer.data() + buffer.size()};
|
||||
}
|
||||
|
||||
void
|
||||
TransportTester::testX25519Rfc7748()
|
||||
{
|
||||
auto alice = KeyPair::fromSecret(keyFromHex("77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a"));
|
||||
auto bob = KeyPair::fromSecret(keyFromHex("5dab087e624a8a4b79e17f8b83800ee66f3bb1292618b6fd1c2f8b27ff88e0eb"));
|
||||
CPPUNIT_ASSERT(alice.pub == keyFromHex("8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a"));
|
||||
CPPUNIT_ASSERT(bob.pub == keyFromHex("de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f"));
|
||||
}
|
||||
|
||||
void
|
||||
TransportTester::testSessionKeys()
|
||||
{
|
||||
auto a = KeyPair::generate(), ea = KeyPair::generate();
|
||||
auto b = KeyPair::generate(), eb = KeyPair::generate();
|
||||
CPPUNIT_ASSERT(a);
|
||||
CPPUNIT_ASSERT(a.pub != b.pub);
|
||||
|
||||
auto ab = sessionKeys(a, ea, b.pub, eb.pub, 0);
|
||||
auto ba = sessionKeys(b, eb, a.pub, ea.pub, 0);
|
||||
CPPUNIT_ASSERT(ab and ba);
|
||||
CPPUNIT_ASSERT(ab->tx == ba->rx);
|
||||
CPPUNIT_ASSERT(ab->rx == ba->tx);
|
||||
CPPUNIT_ASSERT(ab->tx != ab->rx);
|
||||
|
||||
auto c = KeyPair::generate();
|
||||
for (const auto& other : {sessionKeys(a, ea, b.pub, eb.pub, 42),
|
||||
sessionKeys(a, c, b.pub, eb.pub, 0),
|
||||
sessionKeys(a, ea, b.pub, c.pub, 0),
|
||||
sessionKeys(a, ea, c.pub, eb.pub, 0),
|
||||
sessionKeys(c, ea, b.pub, eb.pub, 0)}) {
|
||||
CPPUNIT_ASSERT(other);
|
||||
CPPUNIT_ASSERT(other->tx != ab->tx and other->rx != ab->rx);
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
TransportTester::testSessionKeysErasure()
|
||||
{
|
||||
alignas(SessionKeys) std::array<uint8_t, sizeof(SessionKeys)> storage {};
|
||||
Key tx, rx;
|
||||
tx.fill(0xA5);
|
||||
rx.fill(0x5A);
|
||||
auto keys = new (storage.data()) SessionKeys {tx, rx};
|
||||
SessionKeys copied = *keys;
|
||||
SessionKeys assigned {};
|
||||
assigned = *keys;
|
||||
|
||||
keys->~SessionKeys();
|
||||
|
||||
CPPUNIT_ASSERT(std::all_of(storage.begin(), storage.end(), [](uint8_t byte) { return byte == 0; }));
|
||||
CPPUNIT_ASSERT(copied.tx == tx and copied.rx == rx);
|
||||
CPPUNIT_ASSERT(assigned.tx == tx and assigned.rx == rx);
|
||||
}
|
||||
|
||||
void
|
||||
TransportTester::testInitialKey()
|
||||
{
|
||||
auto a = KeyPair::generate(), ea = KeyPair::generate();
|
||||
auto b = KeyPair::generate(), eb = KeyPair::generate();
|
||||
|
||||
auto sent = initialSendKey(a, ea, b.pub, 0);
|
||||
auto received = initialReceiveKey(b, a.pub, ea.pub, 0);
|
||||
CPPUNIT_ASSERT(sent and received);
|
||||
CPPUNIT_ASSERT(*sent == *received);
|
||||
|
||||
auto session = sessionKeys(a, ea, b.pub, eb.pub, 0);
|
||||
CPPUNIT_ASSERT(*sent != session->tx and *sent != session->rx);
|
||||
CPPUNIT_ASSERT(*initialSendKey(a, ea, b.pub, 42) != *sent);
|
||||
CPPUNIT_ASSERT(*initialSendKey(a, eb, b.pub, 0) != *sent);
|
||||
// directional
|
||||
CPPUNIT_ASSERT(*initialSendKey(b, ea, a.pub, 0) != *sent);
|
||||
}
|
||||
|
||||
void
|
||||
TransportTester::testRejectsLowOrderKey()
|
||||
{
|
||||
auto a = KeyPair::generate(), ea = KeyPair::generate();
|
||||
auto b = KeyPair::generate();
|
||||
Key one {};
|
||||
one[0] = 1;
|
||||
for (const auto& bad : {Key {}, one}) {
|
||||
CPPUNIT_ASSERT(not sessionKeys(a, ea, bad, b.pub, 0));
|
||||
CPPUNIT_ASSERT(not sessionKeys(a, ea, b.pub, bad, 0));
|
||||
CPPUNIT_ASSERT(not initialSendKey(a, ea, bad, 0));
|
||||
CPPUNIT_ASSERT(not initialReceiveKey(a, bad, b.pub, 0));
|
||||
CPPUNIT_ASSERT(not initialReceiveKey(a, b.pub, bad, 0));
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
TransportTester::testNodeId()
|
||||
{
|
||||
auto a = KeyPair::generate();
|
||||
auto b = KeyPair::generate();
|
||||
CPPUNIT_ASSERT(nodeId(a.pub));
|
||||
CPPUNIT_ASSERT_EQUAL(nodeId(a.pub), nodeId(KeyPair::fromSecret(a.secret).pub));
|
||||
CPPUNIT_ASSERT(nodeId(a.pub) != nodeId(b.pub));
|
||||
}
|
||||
|
||||
void
|
||||
TransportTester::testAddressKey()
|
||||
{
|
||||
auto secret = randomSecret(), other = randomSecret();
|
||||
CPPUNIT_ASSERT(secret != other);
|
||||
const uint8_t x[] = {127, 0, 0, 2, 0x10, 0x7e};
|
||||
const uint8_t y[] = {127, 0, 0, 2, 0x10, 0x7f};
|
||||
|
||||
auto key = addressKey(secret, x, sizeof(x));
|
||||
CPPUNIT_ASSERT(key);
|
||||
CPPUNIT_ASSERT(key.pub == KeyPair::fromSecret(key.secret).pub);
|
||||
CPPUNIT_ASSERT(key.secret == addressKey(secret, x, sizeof(x)).secret);
|
||||
CPPUNIT_ASSERT(key.secret != addressKey(secret, y, sizeof(y)).secret);
|
||||
CPPUNIT_ASSERT(key.secret != addressKey(other, x, sizeof(x)).secret);
|
||||
|
||||
auto token = addressToken(secret, x, sizeof(x));
|
||||
CPPUNIT_ASSERT(token == addressToken(secret, x, sizeof(x)));
|
||||
CPPUNIT_ASSERT(token != addressToken(secret, y, sizeof(y)));
|
||||
CPPUNIT_ASSERT(token != addressToken(other, x, sizeof(x)));
|
||||
CPPUNIT_ASSERT(token != key.secret and token != key.pub);
|
||||
}
|
||||
|
||||
void
|
||||
TransportTester::testSealOpen()
|
||||
{
|
||||
auto a = KeyPair::generate(), ea = KeyPair::generate();
|
||||
auto b = KeyPair::generate(), eb = KeyPair::generate();
|
||||
auto keys = *sessionKeys(a, ea, b.pub, eb.pub, 0);
|
||||
auto msg = message();
|
||||
|
||||
auto envelope = seal(keys.tx, EnvelopeType::Session, a.pub, ea.pub, msg.data(), msg.size());
|
||||
CPPUNIT_ASSERT_EQUAL(msg.size() + ENVELOPE_OVERHEAD, envelope.size());
|
||||
CPPUNIT_ASSERT(isEnvelope(envelope.data(), envelope.size()));
|
||||
auto header = parseHeader(envelope.data(), envelope.size());
|
||||
CPPUNIT_ASSERT(header);
|
||||
CPPUNIT_ASSERT(header->type == EnvelopeType::Session);
|
||||
CPPUNIT_ASSERT(not header->credential);
|
||||
CPPUNIT_ASSERT(header->sender == a.pub);
|
||||
CPPUNIT_ASSERT(header->ephemeral == ea.pub);
|
||||
|
||||
auto opened = open(sessionKeys(b, eb, a.pub, ea.pub, 0)->rx, envelope.data(), envelope.size());
|
||||
CPPUNIT_ASSERT(opened);
|
||||
CPPUNIT_ASSERT(*opened == msg);
|
||||
|
||||
// Random nonces: sealing twice gives different envelopes
|
||||
CPPUNIT_ASSERT(seal(keys.tx, EnvelopeType::Session, a.pub, ea.pub, msg.data(), msg.size()) != envelope);
|
||||
|
||||
Blob credential {1, 2, 3};
|
||||
auto withCredential = seal(keys.tx, EnvelopeType::Initial, a.pub, ea.pub, msg.data(), msg.size(), credential);
|
||||
header = parseHeader(withCredential.data(), withCredential.size());
|
||||
CPPUNIT_ASSERT(header and header->type == EnvelopeType::Initial and header->credential);
|
||||
auto openedCredential = open(keys.tx, withCredential.data(), withCredential.size());
|
||||
CPPUNIT_ASSERT(openedCredential);
|
||||
CPPUNIT_ASSERT(std::equal(credential.begin(), credential.end(), openedCredential->begin()));
|
||||
CPPUNIT_ASSERT(std::equal(msg.begin(), msg.end(), openedCredential->begin() + credential.size()));
|
||||
}
|
||||
|
||||
void
|
||||
TransportTester::testOpenRejectsTampering()
|
||||
{
|
||||
auto a = KeyPair::generate(), ea = KeyPair::generate();
|
||||
auto b = KeyPair::generate(), eb = KeyPair::generate();
|
||||
auto keys = *sessionKeys(a, ea, b.pub, eb.pub, 0);
|
||||
auto msg = message();
|
||||
auto envelope = seal(keys.tx, EnvelopeType::Session, a.pub, ea.pub, msg.data(), msg.size());
|
||||
|
||||
for (size_t i : {(size_t) 1, (size_t) 2, 2 + KEY_SIZE, HEADER_SIZE, HEADER_SIZE + NONCE_SIZE, envelope.size() - 1}) {
|
||||
auto tampered = envelope;
|
||||
tampered[i] ^= 0x01;
|
||||
CPPUNIT_ASSERT(not open(keys.tx, tampered.data(), tampered.size()));
|
||||
}
|
||||
CPPUNIT_ASSERT(not open(keys.tx, envelope.data(), envelope.size() - 1));
|
||||
CPPUNIT_ASSERT(not open(keys.tx, envelope.data(), ENVELOPE_OVERHEAD - 1));
|
||||
CPPUNIT_ASSERT(not open(keys.rx, envelope.data(), envelope.size()));
|
||||
CPPUNIT_ASSERT(not open(sessionKeys(b, eb, a.pub, ea.pub, 1)->rx, envelope.data(), envelope.size()));
|
||||
}
|
||||
|
||||
void
|
||||
TransportTester::testDataEnvelope()
|
||||
{
|
||||
auto a = KeyPair::generate(), ea = KeyPair::generate();
|
||||
auto b = KeyPair::generate(), eb = KeyPair::generate();
|
||||
auto keys = *sessionKeys(a, ea, b.pub, eb.pub, 0);
|
||||
auto msg = message();
|
||||
|
||||
auto envelope = sealData(keys.tx, msg.data(), msg.size());
|
||||
CPPUNIT_ASSERT_EQUAL(msg.size() + DATA_ENVELOPE_OVERHEAD, envelope.size());
|
||||
CPPUNIT_ASSERT_EQUAL((size_t) 30, DATA_ENVELOPE_OVERHEAD);
|
||||
CPPUNIT_ASSERT(isEnvelope(envelope.data(), envelope.size()));
|
||||
CPPUNIT_ASSERT_THROW(msgpack::unpack((const char*) envelope.data(), envelope.size()), msgpack::parse_error);
|
||||
auto header = parseHeader(envelope.data(), envelope.size());
|
||||
CPPUNIT_ASSERT(header and header->type == EnvelopeType::Data and not header->credential);
|
||||
|
||||
auto opened = open(sessionKeys(b, eb, a.pub, ea.pub, 0)->rx, envelope.data(), envelope.size());
|
||||
CPPUNIT_ASSERT(opened and *opened == msg);
|
||||
|
||||
for (size_t i : {(size_t) 1, DATA_HEADER_SIZE, DATA_HEADER_SIZE + NONCE_SIZE, envelope.size() - 1}) {
|
||||
auto tampered = envelope;
|
||||
tampered[i] ^= 0x01;
|
||||
CPPUNIT_ASSERT(not open(keys.tx, tampered.data(), tampered.size()));
|
||||
}
|
||||
CPPUNIT_ASSERT(not open(keys.tx, envelope.data(), DATA_ENVELOPE_OVERHEAD - 1));
|
||||
CPPUNIT_ASSERT(not open(keys.rx, envelope.data(), envelope.size()));
|
||||
|
||||
// Same key, other type: the type is authenticated
|
||||
auto session = seal(keys.tx, EnvelopeType::Session, a.pub, ea.pub, msg.data(), msg.size());
|
||||
Blob relabeled {ENVELOPE_MARKER, static_cast<uint8_t>(EnvelopeType::Data)};
|
||||
relabeled.insert(relabeled.end(), session.begin() + HEADER_SIZE, session.end());
|
||||
CPPUNIT_ASSERT(not open(keys.tx, relabeled.data(), relabeled.size()));
|
||||
|
||||
// Data never carries a credential
|
||||
auto flagged = envelope;
|
||||
flagged[1] |= CREDENTIAL_FLAG;
|
||||
CPPUNIT_ASSERT(not parseHeader(flagged.data(), flagged.size()));
|
||||
CPPUNIT_ASSERT(not open(keys.tx, flagged.data(), flagged.size()));
|
||||
}
|
||||
|
||||
void
|
||||
TransportTester::testParseHeader()
|
||||
{
|
||||
auto a = KeyPair::generate(), ea = KeyPair::generate();
|
||||
auto msg = message();
|
||||
auto envelope = seal(Key {}, EnvelopeType::Session, a.pub, ea.pub, msg.data(), msg.size());
|
||||
CPPUNIT_ASSERT(parseHeader(envelope.data(), HEADER_SIZE));
|
||||
CPPUNIT_ASSERT(not parseHeader(envelope.data(), HEADER_SIZE - 1));
|
||||
for (uint8_t type : {0, 5, 0x80, 0x84, 0x85}) {
|
||||
auto unknown = envelope;
|
||||
unknown[1] = type;
|
||||
CPPUNIT_ASSERT(not parseHeader(unknown.data(), unknown.size()));
|
||||
}
|
||||
CPPUNIT_ASSERT(not parseHeader(msg.data(), msg.size()));
|
||||
}
|
||||
|
||||
void
|
||||
TransportTester::testEnvelopeIsNotMsgpack()
|
||||
{
|
||||
auto a = KeyPair::generate(), ea = KeyPair::generate();
|
||||
auto msg = message();
|
||||
auto envelope = seal(Key {}, EnvelopeType::Session, a.pub, ea.pub, msg.data(), msg.size());
|
||||
CPPUNIT_ASSERT_THROW(msgpack::unpack((const char*) envelope.data(), envelope.size()), msgpack::parse_error);
|
||||
auto h = hello(a.pub, ea.pub, Blob(Credential::PACKED_SIZE), msg.data(), msg.size());
|
||||
CPPUNIT_ASSERT_THROW(msgpack::unpack((const char*) h.data(), h.size()), msgpack::parse_error);
|
||||
}
|
||||
|
||||
void
|
||||
TransportTester::testHello()
|
||||
{
|
||||
auto a = KeyPair::generate(), ea = KeyPair::generate();
|
||||
auto credential = Authority::generate().issue(a.pub).pack();
|
||||
auto msg = message();
|
||||
auto h = hello(a.pub, ea.pub, credential, msg.data(), msg.size());
|
||||
CPPUNIT_ASSERT_EQUAL(HEADER_SIZE + Credential::PACKED_SIZE + msg.size(), h.size());
|
||||
auto header = parseHeader(h.data(), h.size());
|
||||
CPPUNIT_ASSERT(header);
|
||||
CPPUNIT_ASSERT(header->type == EnvelopeType::Hello);
|
||||
CPPUNIT_ASSERT(header->sender == a.pub);
|
||||
CPPUNIT_ASSERT(header->ephemeral == ea.pub);
|
||||
CPPUNIT_ASSERT(std::equal(credential.begin(), credential.end(), h.begin() + HEADER_SIZE));
|
||||
CPPUNIT_ASSERT(std::equal(msg.begin(), msg.end(), h.begin() + HEADER_SIZE + credential.size()));
|
||||
}
|
||||
|
||||
void
|
||||
TransportTester::testCredential()
|
||||
{
|
||||
auto authority = Authority::generate();
|
||||
CPPUNIT_ASSERT(Authority::fromSecret(authority.secret).pub == authority.pub);
|
||||
auto key = KeyPair::generate();
|
||||
|
||||
auto credential = authority.issue(key.pub);
|
||||
CPPUNIT_ASSERT(credential);
|
||||
CPPUNIT_ASSERT(credential.key == key.pub);
|
||||
CPPUNIT_ASSERT(credential.verify({authority.pub}));
|
||||
auto nextAuthority = Authority::generate();
|
||||
auto nextCredential = nextAuthority.issue(key.pub);
|
||||
CPPUNIT_ASSERT(credential.verify({authority.pub, nextAuthority.pub}));
|
||||
CPPUNIT_ASSERT(nextCredential.verify({authority.pub, nextAuthority.pub}));
|
||||
CPPUNIT_ASSERT(not credential.verify({nextAuthority.pub}));
|
||||
CPPUNIT_ASSERT(nextCredential.verify({nextAuthority.pub}));
|
||||
|
||||
auto packed = credential.pack();
|
||||
CPPUNIT_ASSERT_EQUAL(size_t {97}, packed.size());
|
||||
CPPUNIT_ASSERT_EQUAL(uint8_t {2}, packed[0]);
|
||||
CPPUNIT_ASSERT_EQUAL(size_t {Credential::PACKED_SIZE}, packed.size());
|
||||
auto unpacked = Credential::unpack(packed);
|
||||
CPPUNIT_ASSERT(unpacked.key == credential.key);
|
||||
CPPUNIT_ASSERT(unpacked.signature == credential.signature);
|
||||
CPPUNIT_ASSERT(unpacked.verify({authority.pub}));
|
||||
}
|
||||
|
||||
void
|
||||
TransportTester::testCredentialRejections()
|
||||
{
|
||||
auto authority = Authority::generate();
|
||||
auto key = KeyPair::generate();
|
||||
auto credential = authority.issue(key.pub);
|
||||
|
||||
CPPUNIT_ASSERT(not credential.verify({}));
|
||||
CPPUNIT_ASSERT(not credential.verify({Authority::generate().pub}));
|
||||
|
||||
auto otherKey = credential;
|
||||
otherKey.key = KeyPair::generate().pub;
|
||||
CPPUNIT_ASSERT(not otherKey.verify({authority.pub}));
|
||||
|
||||
auto tampered = credential;
|
||||
tampered.signature[0] ^= 1;
|
||||
CPPUNIT_ASSERT(not tampered.verify({authority.pub}));
|
||||
|
||||
auto packed = credential.pack();
|
||||
CPPUNIT_ASSERT_THROW(Credential::unpack(packed.data(), packed.size() - 1), std::invalid_argument);
|
||||
packed[0] = 1;
|
||||
CPPUNIT_ASSERT_THROW(Credential::unpack(packed), std::invalid_argument);
|
||||
}
|
||||
|
||||
} // namespace test
|
||||
@@ -0,0 +1,51 @@
|
||||
// Copyright (c) 2014-2026 Savoir-faire Linux Inc.
|
||||
// SPDX-License-Identifier: MIT
|
||||
#pragma once
|
||||
|
||||
#include <cppunit/TestFixture.h>
|
||||
#include <cppunit/extensions/HelperMacros.h>
|
||||
|
||||
namespace test {
|
||||
|
||||
class TransportTester : public CppUnit::TestFixture
|
||||
{
|
||||
CPPUNIT_TEST_SUITE(TransportTester);
|
||||
CPPUNIT_TEST(testX25519Rfc7748);
|
||||
CPPUNIT_TEST(testSessionKeys);
|
||||
CPPUNIT_TEST(testSessionKeysErasure);
|
||||
CPPUNIT_TEST(testInitialKey);
|
||||
CPPUNIT_TEST(testRejectsLowOrderKey);
|
||||
CPPUNIT_TEST(testNodeId);
|
||||
CPPUNIT_TEST(testAddressKey);
|
||||
CPPUNIT_TEST(testSealOpen);
|
||||
CPPUNIT_TEST(testOpenRejectsTampering);
|
||||
CPPUNIT_TEST(testDataEnvelope);
|
||||
CPPUNIT_TEST(testParseHeader);
|
||||
CPPUNIT_TEST(testEnvelopeIsNotMsgpack);
|
||||
CPPUNIT_TEST(testHello);
|
||||
CPPUNIT_TEST(testCredential);
|
||||
CPPUNIT_TEST(testCredentialRejections);
|
||||
CPPUNIT_TEST_SUITE_END();
|
||||
|
||||
public:
|
||||
void setUp() {}
|
||||
void tearDown() {}
|
||||
|
||||
void testX25519Rfc7748();
|
||||
void testSessionKeys();
|
||||
void testSessionKeysErasure();
|
||||
void testInitialKey();
|
||||
void testRejectsLowOrderKey();
|
||||
void testNodeId();
|
||||
void testAddressKey();
|
||||
void testSealOpen();
|
||||
void testOpenRejectsTampering();
|
||||
void testDataEnvelope();
|
||||
void testParseHeader();
|
||||
void testEnvelopeIsNotMsgpack();
|
||||
void testHello();
|
||||
void testCredential();
|
||||
void testCredentialRejections();
|
||||
};
|
||||
|
||||
} // namespace test
|
||||
+12
-1
@@ -38,7 +38,11 @@ print_usage()
|
||||
{
|
||||
std::cout
|
||||
<< "Usage: dhtnode [-v [-l logfile]] [-i] [-d] [-n network_id] [-p local_port] [-b bootstrap_host[:port]] "
|
||||
"[--proxyserver local_port] [--proxyserverssl local_port] [--bundleid bundleid] [--pushserver endpoint]"
|
||||
"[--proxyserver local_port] [--proxyserverssl local_port] [--bundleid bundleid] [--pushserver endpoint] "
|
||||
"[--no-encryption] [--transport-key file] [--network-authority key] [--network-credential file]"
|
||||
<< std::endl
|
||||
<< " dhtnode --gen-network-authority prefix" << std::endl
|
||||
<< " dhtnode --issue-credential file --authority-key file --transport-key file"
|
||||
<< std::endl
|
||||
<< std::endl;
|
||||
print_info();
|
||||
@@ -576,6 +580,13 @@ main(int argc, char** argv)
|
||||
print_version();
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
if (runNetworkAuthorityCommands(params))
|
||||
return 0;
|
||||
} catch (const std::exception& e) {
|
||||
std::cerr << e.what() << std::endl;
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
|
||||
if (params.daemonize) {
|
||||
daemonize();
|
||||
|
||||
@@ -35,6 +35,8 @@ benchPingPong(unsigned netSize, unsigned n_parallel)
|
||||
DhtRunner::Config config {};
|
||||
config.dht_config.node_config.max_peer_req_per_sec = -1;
|
||||
config.dht_config.node_config.max_req_per_sec = -1;
|
||||
config.dht_config.node_config.max_crypto_per_sec = -1;
|
||||
config.dht_config.node_config.max_peer_crypto_per_sec = -1;
|
||||
|
||||
DhtRunner ping_node, pong_node;
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
#include <iostream>
|
||||
#include <sstream>
|
||||
#include <fstream>
|
||||
#include <filesystem>
|
||||
|
||||
namespace dht {
|
||||
namespace tools {
|
||||
@@ -122,6 +123,60 @@ loadFile(const std::string& path)
|
||||
return buffer;
|
||||
}
|
||||
|
||||
void
|
||||
saveFile(const std::string& path, const uint8_t* data, size_t size, bool secret)
|
||||
{
|
||||
auto temporary = path + "." + dht::InfoHash::getRandom().toString() + ".tmp";
|
||||
try {
|
||||
std::ofstream file;
|
||||
file.exceptions(std::ios::failbit | std::ios::badbit);
|
||||
file.open(temporary, std::ios::binary | std::ios::trunc);
|
||||
if (secret)
|
||||
std::filesystem::permissions(temporary,
|
||||
std::filesystem::perms::owner_read | std::filesystem::perms::owner_write);
|
||||
file.write((const char*) data, size);
|
||||
file.close();
|
||||
std::filesystem::rename(temporary, path);
|
||||
} catch (...) {
|
||||
std::error_code ec;
|
||||
std::filesystem::remove(temporary, ec);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
dht::transport::Key
|
||||
loadTransportKey(const std::string& path)
|
||||
{
|
||||
auto data = loadFile(path);
|
||||
dht::transport::Key key;
|
||||
if (data.size() != key.size())
|
||||
throw std::runtime_error("Invalid key file: " + path);
|
||||
std::copy(data.begin(), data.end(), key.begin());
|
||||
return key;
|
||||
}
|
||||
|
||||
/* Loads the transport key from path, or generates and saves it if the file doesn't exist */
|
||||
dht::transport::KeyPair
|
||||
loadOrCreateTransportKey(const std::string& path)
|
||||
{
|
||||
if (std::filesystem::exists(std::filesystem::symlink_status(path)))
|
||||
return dht::transport::KeyPair::fromSecret(loadTransportKey(path));
|
||||
auto key = dht::transport::KeyPair::generate();
|
||||
saveFile(path, key.secret.data(), key.secret.size(), true);
|
||||
return key;
|
||||
}
|
||||
|
||||
dht::transport::Key
|
||||
keyFromHex(const std::string& hex)
|
||||
{
|
||||
dht::transport::Key key;
|
||||
if (hex.size() != 2 * key.size())
|
||||
throw std::runtime_error("Invalid key: " + hex);
|
||||
for (size_t i = 0; i < key.size(); i++)
|
||||
key[i] = std::stoul(hex.substr(2 * i, 2), nullptr, 16);
|
||||
return key;
|
||||
}
|
||||
|
||||
struct dht_params
|
||||
{
|
||||
bool help {false}; // print help and exit
|
||||
@@ -151,8 +206,47 @@ struct dht_params
|
||||
std::string save_identity {};
|
||||
bool no_rate_limit {false};
|
||||
bool public_stable {false};
|
||||
bool no_encryption {false};
|
||||
std::string transport_key {};
|
||||
std::vector<dht::transport::Key> authorities {};
|
||||
std::string credential {};
|
||||
std::string gen_authority {};
|
||||
std::string issue_credential {};
|
||||
std::string authority_key {};
|
||||
};
|
||||
|
||||
/* Runs private network management commands. Returns true if one was run. */
|
||||
bool
|
||||
runNetworkAuthorityCommands(const dht_params& params)
|
||||
{
|
||||
if (not params.gen_authority.empty()) {
|
||||
for (const auto& suffix : {".key", ".pub"}) {
|
||||
auto path = params.gen_authority + suffix;
|
||||
if (std::filesystem::exists(std::filesystem::symlink_status(path)))
|
||||
throw std::runtime_error("Refusing to overwrite network authority file: " + path);
|
||||
}
|
||||
auto authority = dht::transport::Authority::generate();
|
||||
auto pub = dht::toHex(authority.pub.data(), authority.pub.size());
|
||||
saveFile(params.gen_authority + ".key", authority.secret.data(), authority.secret.size(), true);
|
||||
saveFile(params.gen_authority + ".pub", (const uint8_t*) pub.data(), pub.size(), false);
|
||||
std::cout << "Network authority saved to " << params.gen_authority << ".key" << std::endl
|
||||
<< "Public key (--network-authority): " << pub << std::endl;
|
||||
return true;
|
||||
}
|
||||
if (not params.issue_credential.empty()) {
|
||||
if (params.authority_key.empty() or params.transport_key.empty())
|
||||
throw std::runtime_error("--issue-credential requires --authority-key and --transport-key");
|
||||
auto authority = dht::transport::Authority::fromSecret(loadTransportKey(params.authority_key));
|
||||
auto key = loadOrCreateTransportKey(params.transport_key);
|
||||
auto credential = authority.issue(key.pub).pack();
|
||||
saveFile(params.issue_credential, credential.data(), credential.size(), false);
|
||||
std::cout << "Credential for node " << dht::transport::nodeId(key.pub) << " saved to "
|
||||
<< params.issue_credential << ", does not expire" << std::endl;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
std::pair<dht::DhtRunner::Config, dht::DhtRunner::Context>
|
||||
getDhtConfig(dht_params& params)
|
||||
{
|
||||
@@ -181,9 +275,18 @@ getDhtConfig(dht_params& params)
|
||||
if (params.no_rate_limit) {
|
||||
config.dht_config.node_config.max_req_per_sec = -1;
|
||||
config.dht_config.node_config.max_peer_req_per_sec = -1;
|
||||
config.dht_config.node_config.max_crypto_per_sec = -1;
|
||||
config.dht_config.node_config.max_peer_crypto_per_sec = -1;
|
||||
config.dht_config.node_config.max_searches = -1;
|
||||
config.dht_config.node_config.max_store_size = -1;
|
||||
}
|
||||
auto& transport = config.dht_config.node_config.transport;
|
||||
transport.enabled = not params.no_encryption;
|
||||
if (not params.transport_key.empty())
|
||||
transport.key = loadOrCreateTransportKey(params.transport_key);
|
||||
transport.authorities = params.authorities;
|
||||
if (not params.credential.empty())
|
||||
transport.credential = dht::transport::Credential::unpack(loadFile(params.credential));
|
||||
|
||||
dht::DhtRunner::Context context {};
|
||||
if (params.log) {
|
||||
@@ -253,6 +356,13 @@ static const constexpr struct option long_options[] = {
|
||||
{"devicekey", required_argument, nullptr, 'z'},
|
||||
{"bundleid", required_argument, nullptr, 'u'},
|
||||
{"version", no_argument, nullptr, 'V'},
|
||||
{"no-encryption", no_argument, nullptr, 'N'},
|
||||
{"transport-key", required_argument, nullptr, 'T'},
|
||||
{"network-authority", required_argument, nullptr, 'A'},
|
||||
{"network-credential", required_argument, nullptr, 'R'},
|
||||
{"gen-network-authority", required_argument, nullptr, 'G'},
|
||||
{"issue-credential", required_argument, nullptr, 'Y'},
|
||||
{"authority-key", required_argument, nullptr, 'X'},
|
||||
{nullptr, 0, nullptr, 0 }
|
||||
};
|
||||
|
||||
@@ -375,6 +485,27 @@ parseArgs(int argc, char** argv)
|
||||
case 'I':
|
||||
params.save_identity = optarg;
|
||||
break;
|
||||
case 'N':
|
||||
params.no_encryption = true;
|
||||
break;
|
||||
case 'T':
|
||||
params.transport_key = optarg;
|
||||
break;
|
||||
case 'A':
|
||||
params.authorities.emplace_back(keyFromHex(optarg));
|
||||
break;
|
||||
case 'R':
|
||||
params.credential = optarg;
|
||||
break;
|
||||
case 'G':
|
||||
params.gen_authority = optarg;
|
||||
break;
|
||||
case 'Y':
|
||||
params.issue_credential = optarg;
|
||||
break;
|
||||
case 'X':
|
||||
params.authority_key = optarg;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user