Skip to main content

dig_nat/
relay_descriptor.rs

1//! Relay descriptor verification (#1199) — a self-authenticating (peer_id, addrs, BLS_pub) record.
2//!
3//! On a DIRECT connection the [`crate::cert_binding`] cert extension is the authoritative,
4//! tamper-proof `peer_id ↔ BLS_pub` binding. But a node also learns of relays/peers BEFORE dialing —
5//! from PEX/DHT/relay registration records — and #1199's relay store-and-forward routes past a relay
6//! with no direct handshake. Those discovery records must be self-authenticating so a MITM cannot
7//! swap the advertised BLS key and read the seal.
8//!
9//! A [`RelayDescriptor`] carries the relay's `peer_id` (as the SHA-256 of its TLS SPKI DER), its BLS
10//! G1 pubkey, dialable addresses, its network id, and an optional DID, all covered by a **BLS G2
11//! signature** made with the relay's own BLS key. [`verify_relay_descriptor`] proves the record was
12//! authored by the holder of that BLS key, that the key is a valid G1 point, that (on a live dial)
13//! the `peer_id_spki_hash` matches the presented cert's SPKI, and — where a DID + a resolver are
14//! available — that the DID resolves to the same BLS key on chain.
15
16use std::net::SocketAddr;
17
18use sha2::{Digest, Sha256};
19
20use dig_identity::{g1_subgroup_check, verify_signature};
21
22/// Domain-separation context for the descriptor signature (distinct from the cert-binding context so
23/// a signature over one can never be replayed as the other).
24const DESCRIPTOR_SIG_CONTEXT: &[u8] = b"dig-nat/relay-descriptor/v1";
25
26/// Resolves a DID string to its on-chain BLS G1 identity pubkey (dig-identity's
27/// `resolve_bls_public_key`), injected by the caller so dig-nat needs no chain access. Returns `None`
28/// when the DID cannot be resolved (tolerated — best-effort).
29pub type DidResolver<'a> = dyn Fn(&str) -> Option<[u8; 48]> + 'a;
30
31/// A self-authenticating relay/peer discovery record: the advertised identity + reachability, signed
32/// by the relay's own BLS G1 key.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct RelayDescriptor {
35    /// The relay's `peer_id` as `SHA-256(TLS SPKI DER)` — must match the cert presented on a dial.
36    pub peer_id_spki_hash: [u8; 32],
37    /// The relay's 48-byte compressed BLS G1 identity public key (the seal target).
38    pub bls_pub: [u8; 48],
39    /// Dialable candidate addresses, IPv6-first (§5.2). Advisory; the cert binding is authoritative.
40    pub addresses: Vec<SocketAddr>,
41    /// The network id the relay serves (e.g. `DIG_MAINNET`).
42    pub network_id: String,
43    /// An optional DID the relay claims. When present AND a resolver is supplied, the DID MUST
44    /// resolve to [`Self::bls_pub`] (nodes/relays are normally DID-less — this is best-effort).
45    pub did: Option<String>,
46    /// The 96-byte BLS G2 signature over [`RelayDescriptor::signing_bytes`], made with the BLS key
47    /// whose public half is [`Self::bls_pub`].
48    pub signature: [u8; 96],
49}
50
51/// Why a [`RelayDescriptor`] failed verification.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
53pub enum RelayDescriptorError {
54    /// The advertised BLS pubkey is not a canonical, non-identity G1 subgroup point.
55    #[error("relay descriptor BLS pubkey failed the G1 subgroup check")]
56    BadBlsPubkey,
57    /// The BLS signature did not verify under the advertised pubkey (forged / tampered record).
58    #[error("relay descriptor signature did not verify")]
59    BadSignature,
60    /// The descriptor's `peer_id_spki_hash` does not match the SPKI presented on the live handshake.
61    #[error("relay descriptor peer_id does not match the presented certificate")]
62    PeerIdMismatch,
63    /// A DID was present and resolvable, but it resolves to a DIFFERENT BLS key (substitution).
64    #[error("relay descriptor DID resolves to a different BLS key")]
65    DidMismatch,
66}
67
68impl RelayDescriptor {
69    /// The canonical, length-prefixed byte string the [`RelayDescriptor::signature`] covers.
70    ///
71    /// Built by hand (not serde) so the signed bytes are deterministic and independent of any
72    /// serialization framework — every field except the signature, each length-prefixed, in a fixed
73    /// order, behind [`DESCRIPTOR_SIG_CONTEXT`].
74    pub fn signing_bytes(&self) -> Vec<u8> {
75        let mut out = Vec::new();
76        out.extend_from_slice(DESCRIPTOR_SIG_CONTEXT);
77        out.extend_from_slice(&self.peer_id_spki_hash);
78        out.extend_from_slice(&self.bls_pub);
79
80        out.extend_from_slice(&(self.addresses.len() as u32).to_le_bytes());
81        for addr in &self.addresses {
82            let s = addr.to_string();
83            out.extend_from_slice(&(s.len() as u32).to_le_bytes());
84            out.extend_from_slice(s.as_bytes());
85        }
86
87        out.extend_from_slice(&(self.network_id.len() as u32).to_le_bytes());
88        out.extend_from_slice(self.network_id.as_bytes());
89
90        match &self.did {
91            None => out.push(0),
92            Some(did) => {
93                out.push(1);
94                out.extend_from_slice(&(did.len() as u32).to_le_bytes());
95                out.extend_from_slice(did.as_bytes());
96            }
97        }
98        out
99    }
100}
101
102/// Verify a [`RelayDescriptor`] is authentic and (optionally) bound to a live cert + a resolvable DID.
103///
104/// - `presented_spki_der`: `Some(spki)` on a live dial — the descriptor's `peer_id_spki_hash` MUST
105///   equal `SHA-256(spki)`. `None` for a pre-dial / store-and-forward check with no handshake yet.
106/// - `did_resolver`: `Some(f)` to resolve a claimed DID to its on-chain BLS G1 key (dig-identity's
107///   `resolve_bls_public_key`, injected by the caller so dig-nat needs no chain access). A DID that
108///   resolves to a DIFFERENT key is rejected; a DID the resolver cannot resolve (`None`) is
109///   tolerated (best-effort — nodes/relays are normally DID-less).
110pub fn verify_relay_descriptor(
111    descriptor: &RelayDescriptor,
112    presented_spki_der: Option<&[u8]>,
113    did_resolver: Option<&DidResolver<'_>>,
114) -> Result<(), RelayDescriptorError> {
115    // 1. The advertised seal target must be a valid G1 point before anything trusts it.
116    if !g1_subgroup_check(&descriptor.bls_pub) {
117        return Err(RelayDescriptorError::BadBlsPubkey);
118    }
119    // 2. The record must be self-signed by the holder of that BLS key.
120    if !verify_signature(
121        &descriptor.bls_pub,
122        &descriptor.signing_bytes(),
123        &descriptor.signature,
124    ) {
125        return Err(RelayDescriptorError::BadSignature);
126    }
127    // 3. On a live dial, the advertised peer_id must match the cert actually presented.
128    if let Some(spki) = presented_spki_der {
129        let hash: [u8; 32] = Sha256::digest(spki).into();
130        if hash != descriptor.peer_id_spki_hash {
131            return Err(RelayDescriptorError::PeerIdMismatch);
132        }
133    }
134    // 4. Where a DID + resolver are available, a resolvable DID must agree with the advertised key.
135    if let (Some(did), Some(resolve)) = (&descriptor.did, did_resolver) {
136        if let Some(resolved) = resolve(did) {
137            if resolved != descriptor.bls_pub {
138                return Err(RelayDescriptorError::DidMismatch);
139            }
140        }
141    }
142    Ok(())
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use dig_identity::{
149        bls::SecretKey, derive_identity_sk, master_secret_key_from_seed, public_key_bytes,
150        sign_message,
151    };
152
153    fn node_bls_sk(label: &str) -> SecretKey {
154        let seed: [u8; 32] = Sha256::digest(label.as_bytes()).into();
155        derive_identity_sk(&master_secret_key_from_seed(&seed))
156    }
157
158    /// Build a validly-signed descriptor for a relay whose SPKI is `spki`.
159    fn signed_descriptor(bls_sk: &SecretKey, spki: &[u8], did: Option<String>) -> RelayDescriptor {
160        let peer_id_spki_hash: [u8; 32] = Sha256::digest(spki).into();
161        let mut d = RelayDescriptor {
162            peer_id_spki_hash,
163            bls_pub: public_key_bytes(bls_sk),
164            addresses: vec![
165                "[::1]:9450".parse().unwrap(),
166                "127.0.0.1:9450".parse().unwrap(),
167            ],
168            network_id: "DIG_MAINNET".to_string(),
169            did,
170            signature: [0u8; 96],
171        };
172        d.signature = sign_message(bls_sk, &d.signing_bytes());
173        d
174    }
175
176    #[test]
177    fn valid_descriptor_verifies() {
178        let sk = node_bls_sk("relay-desc/valid");
179        let spki = b"fake-spki-der-bytes";
180        let d = signed_descriptor(&sk, spki, None);
181        assert!(verify_relay_descriptor(&d, Some(spki), None).is_ok());
182        // And without a live cert (pre-dial hint).
183        assert!(verify_relay_descriptor(&d, None, None).is_ok());
184    }
185
186    #[test]
187    fn tampered_signature_rejected() {
188        let sk = node_bls_sk("relay-desc/tamper");
189        let spki = b"fake-spki";
190        let mut d = signed_descriptor(&sk, spki, None);
191        d.addresses.push("10.0.0.1:1".parse().unwrap()); // change a signed field
192        assert_eq!(
193            verify_relay_descriptor(&d, Some(spki), None),
194            Err(RelayDescriptorError::BadSignature)
195        );
196    }
197
198    #[test]
199    fn peer_id_spki_mismatch_rejected() {
200        let sk = node_bls_sk("relay-desc/peerid");
201        let d = signed_descriptor(&sk, b"spki-A", None);
202        // Present a DIFFERENT cert SPKI than the descriptor committed to.
203        assert_eq!(
204            verify_relay_descriptor(&d, Some(b"spki-B"), None),
205            Err(RelayDescriptorError::PeerIdMismatch)
206        );
207    }
208
209    #[test]
210    fn substituted_bls_pubkey_rejected() {
211        // A MITM swaps the advertised BLS key; the signature no longer verifies under it.
212        let sk = node_bls_sk("relay-desc/sub");
213        let attacker = node_bls_sk("relay-desc/sub-attacker");
214        let spki = b"spki";
215        let mut d = signed_descriptor(&sk, spki, None);
216        d.bls_pub = public_key_bytes(&attacker);
217        assert_eq!(
218            verify_relay_descriptor(&d, Some(spki), None),
219            Err(RelayDescriptorError::BadSignature)
220        );
221    }
222
223    #[test]
224    fn bad_g1_point_rejected() {
225        let sk = node_bls_sk("relay-desc/g1");
226        let spki = b"spki";
227        let mut d = signed_descriptor(&sk, spki, None);
228        d.bls_pub = [0xFFu8; 48];
229        assert_eq!(
230            verify_relay_descriptor(&d, Some(spki), None),
231            Err(RelayDescriptorError::BadBlsPubkey)
232        );
233    }
234
235    #[test]
236    fn did_resolving_to_other_key_rejected() {
237        let sk = node_bls_sk("relay-desc/did");
238        let spki = b"spki";
239        let d = signed_descriptor(&sk, spki, Some("did:dig:relayX".to_string()));
240        let other = public_key_bytes(&node_bls_sk("relay-desc/did-other"));
241        let resolver = |_did: &str| -> Option<[u8; 48]> { Some(other) };
242        assert_eq!(
243            verify_relay_descriptor(&d, Some(spki), Some(&resolver)),
244            Err(RelayDescriptorError::DidMismatch)
245        );
246    }
247
248    #[test]
249    fn did_resolving_to_same_key_accepted() {
250        let sk = node_bls_sk("relay-desc/did-ok");
251        let spki = b"spki";
252        let d = signed_descriptor(&sk, spki, Some("did:dig:relayY".to_string()));
253        let pk = public_key_bytes(&sk);
254        let resolver = |_did: &str| -> Option<[u8; 48]> { Some(pk) };
255        assert!(verify_relay_descriptor(&d, Some(spki), Some(&resolver)).is_ok());
256    }
257
258    #[test]
259    fn unresolvable_did_tolerated() {
260        let sk = node_bls_sk("relay-desc/did-none");
261        let spki = b"spki";
262        let d = signed_descriptor(&sk, spki, Some("did:dig:unknown".to_string()));
263        let resolver = |_did: &str| -> Option<[u8; 48]> { None };
264        assert!(verify_relay_descriptor(&d, Some(spki), Some(&resolver)).is_ok());
265    }
266}