Skip to content

feat(tls): mTLS + Register proof-of-possession for peer_id registration#1

Merged
MichaelTaylor3d merged 1 commit into
mainfrom
feat/relay-mtls-pop-5
Jul 4, 2026
Merged

feat(tls): mTLS + Register proof-of-possession for peer_id registration#1
MichaelTaylor3d merged 1 commit into
mainfrom
feat/relay-mtls-pop-5

Conversation

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor

Summary

Closes a real gap: a Register could previously claim ANY peer_id with no proof the registrant held the matching identity key. The only existing protection (registry.rs's RegisterOutcome::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.

  • Adds an optional mTLS listener (src/tls.rs): configure RelayServerConfig::tls_cert_path/tls_key_path (CLI --tls-cert/--tls-key, env DIG_RELAY_TLS_CERT_PATH/DIG_RELAY_TLS_KEY_PATH) and the relay terminates TLS itself, requiring a client certificate on every connection.
  • DIG peers are self-signed (no CA — 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.
  • After the handshake, register_peer requires the Register's claimed peer_id to equal the one derived from the certificate actually used for that connection. A mismatch is refused with a new Error{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 the RelayMessage wire — mandatory client auth fails the handshake itself.
  • Chose rustls over native-tls specifically because native-tls hides the client leaf certificate on Windows/macOS (see dig-gossip's inbound-listener docs) — rustls gives reliable cert capture on every OS.
  • The default plain ws:// listener (mTLS not configured — the canonical relay.dig.net load-balancer-terminated deployment) is unchanged: the anti-hijack refusal remains its only protection, exactly as before.

No dig-gossip wire coordination needed. The proof-of-possession lives entirely at the TLS transport layer (the handshake itself is the "signature"), not in an extra Register field — the wire's pre-existing peer_id field is simply checked against the transport identity instead of trusted blindly. Error code 6 is a purely-additive value in the existing numeric taxonomy, not a shape change.

Verified

  • src/tls.rs unit tests: PEM loading, peer_id derivation (matches SHA-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 with IDENTITY_MISMATCH, no-cert connection never reaches the wire, /health stays 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, and README.md updated 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 --check
  • cargo clippy --all-targets -- -D warnings
  • cargo test --all (all suites green, including the new tests/mtls.rs)
  • cargo build --release
  • cargo 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 (added ISC to the license allowlist for ring/rustls-webpki/untrusted; swapped the now-unmaintained rustls-pemfile — RUSTSEC-2025-0134 — for rustls-pki-types' own PemObject trait, already a transitive dep)

Flagged follow-ups (out of scope for this PR)

  • Enabling mTLS end-to-end for the canonical relay.dig.net deployment additionally requires the load balancer to pass TLS through rather than terminate it — an infra change in the superproject's private infra/dig-relay/, not this repo.
  • docs.dig.net/docs/protocol/peer-network.md's :::note The relay link is authenticated differently callout (~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

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
@MichaelTaylor3d MichaelTaylor3d merged commit 993cc98 into main Jul 4, 2026
5 checks passed
@MichaelTaylor3d MichaelTaylor3d deleted the feat/relay-mtls-pop-5 branch July 4, 2026 15:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant