feat(tls): mTLS + Register proof-of-possession for peer_id registration#1
Merged
Conversation
A Register could previously claim any peer_id with no proof the registrant
held the matching identity key -- the only protection was the anti-hijack
refusal of a peer_id already held by a live connection (registry.rs), which
does not stop a *fresh* claim of someone else's id.
Add an OPTIONAL mTLS listener (src/tls.rs, RelayServerConfig::tls_cert_path/
tls_key_path, CLI --tls-cert/--tls-key, env DIG_RELAY_TLS_CERT_PATH/
DIG_RELAY_TLS_KEY_PATH): when configured, the relay terminates TLS itself and
REQUIRES a client certificate (rustls, chosen over native-tls for reliable
client-cert capture on every OS). DIG peers are self-signed (no CA, the key
IS the identity per peer_id = SHA-256(TLS SPKI DER)), so the verifier accepts
any well-formed leaf and lets rustls/ring's handshake-signature check do the
real proof-of-possession work -- a client cannot complete the handshake
without the private key matching its certificate.
After the handshake, register_peer requires the Register message's claimed
peer_id to equal the one derived from the certificate actually used for that
connection (extract_client_peer_id); a mismatch is refused with a new
Error{code:6, IDENTITY_MISMATCH} before the registry is ever touched. A
connection with no client certificate never reaches the RelayMessage wire at
all -- mandatory client auth fails the TLS handshake itself.
This needs no dig-gossip wire coordination: the proof lives at the TLS
transport layer, not in an extra Register field, so the wire's existing
peer_id field is simply checked against the transport identity instead of
trusted blindly. The default plain ws:// listener (mTLS not configured,
matching the canonical relay.dig.net load-balancer-terminated deployment) is
unaffected -- the anti-hijack refusal remains its only protection, as before.
Tests: src/tls.rs unit tests cover the handshake/verifier/identity-derivation
plumbing over an in-memory duplex; tests/mtls.rs proves the full contract
end-to-end over real sockets (own-cert-id accepted, spoofed-id rejected with
IDENTITY_MISMATCH, no-cert connection never reaches the wire, /health
unaffected); src/server.rs adds register_peer-level regression tests for the
identity check (including that it runs before the capacity check).
Minor bump: a new backwards-compatible opt-in capability, default behavior
unchanged.
Closes DIG-Network/dig_ecosystem#5
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes a real gap: a
Registercould previously claim ANYpeer_idwith no proof the registrant held the matching identity key. The only existing protection (registry.rs'sRegisterOutcome::Occupied) refuses a duplicate register against a live peer, but does nothing to stop a fresh, spoofed claim of an unused/offline peer's id.src/tls.rs): configureRelayServerConfig::tls_cert_path/tls_key_path(CLI--tls-cert/--tls-key, envDIG_RELAY_TLS_CERT_PATH/DIG_RELAY_TLS_KEY_PATH) and the relay terminates TLS itself, requiring a client certificate on every connection.peer_id = SHA-256(TLS SPKI DER)), so the verifier (AnyClientCertVerifier) accepts any well-formed leaf and relies on rustls/ring's handshake-signature check for the actual proof-of-possession — a client cannot complete the TLS handshake without holding the private key matching its certificate.register_peerrequires theRegister's claimedpeer_idto equal the one derived from the certificate actually used for that connection. A mismatch is refused with a newError{code:6, IDENTITY_MISMATCH}before the registry is touched at all (verified to run even before the capacity check). A connection presenting no client certificate never reaches theRelayMessagewire — mandatory client auth fails the handshake itself.native-tlsspecifically because native-tls hides the client leaf certificate on Windows/macOS (seedig-gossip's inbound-listener docs) — rustls gives reliable cert capture on every OS.ws://listener (mTLS not configured — the canonicalrelay.dig.netload-balancer-terminated deployment) is unchanged: the anti-hijack refusal remains its only protection, exactly as before.No
dig-gossipwire coordination needed. The proof-of-possession lives entirely at the TLS transport layer (the handshake itself is the "signature"), not in an extraRegisterfield — the wire's pre-existingpeer_idfield is simply checked against the transport identity instead of trusted blindly.Errorcode6is a purely-additive value in the existing numeric taxonomy, not a shape change.Verified
src/tls.rsunit tests: PEM loading,peer_idderivation (matchesSHA-256(SPKI DER), cross-checked independently), a full real handshake over an in-memory duplex proving the server derives exactly the client's own cert-derived id, and that a connection with no client cert fails the handshake.tests/mtls.rs: full end-to-end over real TCP sockets — own-cert-id accepted, spoofed-id rejected withIDENTITY_MISMATCH, no-cert connection never reaches the wire,/healthstays plain HTTP regardless.src/server.rs:register_peer-level regression tests for the identity check, including that it runs before the capacity check.SPEC.md§3.2/§7/§8,DESIGN.md, andREADME.mdupdated in the same unit;registry.rs's doc comment updated (this repo's version of the "planned, coordinated" note is now "implemented, opt-in").Version bump: MINOR (0.3.0 → 0.4.0) — a new backwards-compatible opt-in capability (
feat:); default behavior is unchanged for every existing deployment.Test plan
cargo fmt --all --checkcargo clippy --all-targets -- -D warningscargo test --all(all suites green, including the newtests/mtls.rs)cargo build --releasecargo llvm-cov --all --ignore-filename-regex 'win_service\.rs' --fail-under-lines 80→ 92.08% lines (tls.rs itself 89.90%)cargo deny check→ advisories ok, bans ok, licenses ok, sources ok (addedISCto the license allowlist forring/rustls-webpki/untrusted; swapped the now-unmaintainedrustls-pemfile— RUSTSEC-2025-0134 — forrustls-pki-types' ownPemObjecttrait, already a transitive dep)Flagged follow-ups (out of scope for this PR)
relay.dig.netdeployment additionally requires the load balancer to pass TLS through rather than terminate it — an infra change in the superproject's privateinfra/dig-relay/, not this repo.docs.dig.net/docs/protocol/peer-network.md's:::note The relay link is authenticated differentlycallout (~line 164) states unconditionally "the node does not present one to the relay" — true by default, but no longer universal once an operator enables--tls-cert/--tls-key. Worth a follow-up docs.dig.net PR.Refs DIG-Network/dig_ecosystem#5