Skip to main content

dig_tls/
binding.rs

1//! Cert BLS-binding — the anti-substitution ROOT of the DIG recipient-seal family (#1204).
2//!
3//! A DIG peer's transport identity is `peer_id = SHA-256(TLS SPKI DER)` ([`crate::identity`]). The
4//! recipient-seal family (#1075 node↔node, #1199 relay) needs to seal a payload to a peer's **BLS
5//! G1 identity key** so a misdelivery cannot be opened by the wrong node. That is only safe if
6//! `peer_id ↔ BLS_pub` is cryptographically BOUND — otherwise a man-in-the-middle could advertise a
7//! victim's `peer_id` with its OWN BLS key and read the seal. This module is that binding.
8//!
9//! ## The binding
10//!
11//! The node/relay embeds its 48-byte compressed **BLS G1 public key** in its mTLS leaf certificate as
12//! a custom X.509 extension ([`DIG_BLS_BINDING_OID`]), self-attested by a 96-byte **BLS G2 signature
13//! over the leaf's SPKI DER**. Because `peer_id = SHA-256(SPKI)`, signing the SPKI commits the
14//! holder's BLS key to exactly that `peer_id`:
15//!
16//! - An attacker cannot present a victim's `peer_id` without the victim's exact SPKI (else the hash
17//!   differs) — and presenting that SPKI needs the victim's TLS private key (rustls proves cert-key
18//!   possession during the handshake).
19//! - An attacker cannot claim a victim's `peer_id` under their OWN BLS key: the self-attestation is
20//!   an AugScheme signature (which itself covers the signing pubkey) verified against the *embedded*
21//!   pubkey over the *presented* SPKI, so a forged pair fails.
22//! - An attacker cannot replay the victim's (pubkey, sig) with a different cert: the signature covers
23//!   the victim's SPKI, not the attacker's.
24//!
25//! ## Rollout policy (capability-negotiated, fail-closed for the strict mode)
26//!
27//! Existing peers have un-bound (no-extension) certs, so the binding is **additive** — the extension
28//! is a non-critical, unknown-to-old-verifiers X.509 extension (§5.1 spirit: old readers ignore it).
29//! Verification is governed by a LOCAL [`BindingPolicy`] (NOT wire-negotiated, so a peer cannot
30//! downgrade it):
31//!
32//! - [`BindingPolicy::Off`] — do not verify (pre-adoption / opt-out).
33//! - [`BindingPolicy::Opportunistic`] — **the rollout default**: verify a binding when present,
34//!   reject a present-but-INVALID one, accept an ABSENT one.
35//! - [`BindingPolicy::Required`] — strict: a valid binding is mandatory; ABSENT and INVALID are both
36//!   rejected. A downgrade that strips the extension is therefore rejected.
37
38use rcgen::{CertificateParams, CustomExtension, KeyPair};
39
40use crate::bls::{g1_subgroup_check, public_key_bytes, sign_message, verify_signature, SecretKey};
41
42/// The DIG BLS-binding X.509 extension OID (dotted-decimal arc form used by `rcgen`).
43///
44/// A DIG **provisional private-use** OID under the `1.3.6.1.4.1` (IANA private enterprise) arc — a
45/// stable, ecosystem-canonical identifier for the DIG BLS peer_id-binding extension. Recorded in the
46/// `canonical` skill so no second implementation invents a different arc. [`DIG_BLS_BINDING_OID_STR`]
47/// is the same OID in the dotted-decimal string form used to match a parsed certificate's extensions.
48pub const DIG_BLS_BINDING_OID: &[u64] = &[1, 3, 6, 1, 4, 1, 58968, 1, 1];
49
50/// The [`DIG_BLS_BINDING_OID`] in dotted-decimal string form (for matching parsed cert extensions).
51pub const DIG_BLS_BINDING_OID_STR: &str = "1.3.6.1.4.1.58968.1.1";
52
53/// Version byte of the binding extension value (v1). Newer writers MAY bump this; verifiers dispatch
54/// on it and MUST keep accepting every version they understand (§5.1 additive-forever).
55pub const BINDING_VERSION_V1: u8 = 1;
56
57/// Length of the v1 extension value: `version(1) || bls_pub(48) || bls_sig(96)`.
58const BINDING_V1_LEN: usize = 1 + 48 + 96;
59
60/// Domain-separation context prefixed to the SPKI DER before the BLS-G2 self-attestation is signed /
61/// verified. Kept BYTE-IDENTICAL to the value dig-nat originally shipped (`dig-nat/cert-bls-binding/
62/// v1`) so certs minted before dig-tls existed still verify unchanged — this is a canonical, must-not-
63/// drift constant, not a rename target.
64const BINDING_SIG_CONTEXT: &[u8] = b"dig-nat/cert-bls-binding/v1";
65
66/// The exact byte string the BLS-G2 self-attestation covers: the domain-separation context then the
67/// leaf's SPKI DER. Used identically when signing (attest) and verifying.
68pub fn binding_message(spki_der: &[u8]) -> Vec<u8> {
69    let mut msg = Vec::with_capacity(BINDING_SIG_CONTEXT.len() + spki_der.len());
70    msg.extend_from_slice(BINDING_SIG_CONTEXT);
71    msg.extend_from_slice(spki_der);
72    msg
73}
74
75/// Encode the v1 extension value from a BLS G1 pubkey + its G2 self-attestation signature.
76pub fn encode_binding_extension_value(bls_pub: &[u8; 48], bls_sig: &[u8; 96]) -> Vec<u8> {
77    let mut value = Vec::with_capacity(BINDING_V1_LEN);
78    value.push(BINDING_VERSION_V1);
79    value.extend_from_slice(bls_pub);
80    value.extend_from_slice(bls_sig);
81    value
82}
83
84/// The raw contents of a parsed (not-yet-verified) binding extension.
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct CertBlsBinding {
87    /// The claimed 48-byte compressed BLS G1 public key.
88    pub bls_pub: [u8; 48],
89    /// The 96-byte BLS G2 self-attestation over [`binding_message`] of the leaf SPKI.
90    pub bls_sig: [u8; 96],
91}
92
93/// Parse a v1 extension value into its fields. Returns `None` for a wrong length or an unrecognised
94/// version — an unknown version is treated as "no binding this verifier understands" (additive
95/// forward-compat), NOT as tampering.
96pub fn parse_binding_extension_value(value: &[u8]) -> Option<CertBlsBinding> {
97    if value.first().copied()? != BINDING_VERSION_V1 || value.len() != BINDING_V1_LEN {
98        return None;
99    }
100    let mut bls_pub = [0u8; 48];
101    let mut bls_sig = [0u8; 96];
102    bls_pub.copy_from_slice(&value[1..49]);
103    bls_sig.copy_from_slice(&value[49..145]);
104    Some(CertBlsBinding { bls_pub, bls_sig })
105}
106
107/// The result of checking a leaf certificate for a valid BLS binding, BEFORE the policy is applied.
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub enum BindingOutcome {
110    /// A cryptographically valid binding: `peer_id ↔ bls_pub` is proven. Carries the verified pubkey.
111    Bound {
112        /// The verified 48-byte BLS G1 public key the `peer_id` is bound to.
113        bls_pub: [u8; 48],
114    },
115    /// No DIG BLS-binding extension is present (a legacy / un-bound peer).
116    Absent,
117    /// A binding extension IS present but did not verify — malformed, bad subgroup point, or the
118    /// self-attestation signature did not check out. Carries a static reason for logging.
119    Invalid(&'static str),
120}
121
122/// The verification stance for a peer's cert binding — a LOCAL decision (never wire-negotiated).
123#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
124pub enum BindingPolicy {
125    /// Do not verify the binding at all (pre-adoption / explicit opt-out).
126    Off,
127    /// The rollout default: verify-if-present, reject-if-present-but-invalid, accept-if-absent.
128    #[default]
129    Opportunistic,
130    /// Strict: a valid binding is mandatory; both ABSENT and INVALID are rejected (anti-downgrade).
131    Required,
132}
133
134/// Apply a [`BindingPolicy`] to a [`BindingOutcome`], deciding whether the handshake may proceed.
135///
136/// Returns `Ok(Some(bls_pub))` when a binding was verified, `Ok(None)` when the connection is
137/// permitted without a verified binding (Off, or Opportunistic-and-absent), and `Err(reason)` when
138/// the policy REJECTS the peer (fail-closed).
139pub fn evaluate(
140    outcome: &BindingOutcome,
141    policy: BindingPolicy,
142) -> std::result::Result<Option<[u8; 48]>, &'static str> {
143    match (policy, outcome) {
144        // Off never verifies and never rejects on binding grounds.
145        (BindingPolicy::Off, _) => Ok(None),
146
147        // A valid binding is always accepted (any non-Off policy).
148        (_, BindingOutcome::Bound { bls_pub }) => Ok(Some(*bls_pub)),
149
150        // Opportunistic tolerates a legacy peer but never a tampered binding.
151        (BindingPolicy::Opportunistic, BindingOutcome::Absent) => Ok(None),
152        (BindingPolicy::Opportunistic, BindingOutcome::Invalid(reason)) => Err(reason),
153
154        // Required rejects both absence (anti-downgrade) and invalidity.
155        (BindingPolicy::Required, BindingOutcome::Absent) => {
156            Err("cert BLS binding required but absent (possible downgrade)")
157        }
158        (BindingPolicy::Required, BindingOutcome::Invalid(reason)) => Err(reason),
159    }
160}
161
162/// Verify the BLS binding carried by a DER-encoded leaf certificate.
163///
164/// Extracts the [`DIG_BLS_BINDING_OID`] extension, and — when present — recomputes the binding
165/// message from the cert's own SPKI, subgroup-checks the embedded G1 pubkey, and verifies the BLS-G2
166/// self-attestation against it. The returned [`BindingOutcome`] is then fed to [`evaluate`] with the
167/// verifier's [`BindingPolicy`]. A certificate that cannot be parsed as X.509 returns
168/// [`BindingOutcome::Invalid`] (the mTLS layer would fail on it anyway).
169pub fn verify_binding_from_leaf_cert(cert_der: &[u8]) -> BindingOutcome {
170    let Ok((_, x509)) = x509_parser::parse_x509_certificate(cert_der) else {
171        return BindingOutcome::Invalid("leaf certificate could not be parsed as X.509");
172    };
173    let spki_der = x509.tbs_certificate.subject_pki.raw;
174
175    let mut binding_value: Option<&[u8]> = None;
176    for ext in x509.extensions() {
177        if ext.oid.to_id_string() == DIG_BLS_BINDING_OID_STR {
178            binding_value = Some(ext.value);
179            break;
180        }
181    }
182    let Some(value) = binding_value else {
183        return BindingOutcome::Absent;
184    };
185
186    let Some(binding) = parse_binding_extension_value(value) else {
187        return BindingOutcome::Invalid("binding extension malformed or unknown version");
188    };
189    // Reject a small-subgroup / identity / non-canonical G1 point BEFORE trusting it as a seal target.
190    if !g1_subgroup_check(&binding.bls_pub) {
191        return BindingOutcome::Invalid("binding BLS pubkey failed the G1 subgroup check");
192    }
193    // The self-attestation must be over THIS leaf's SPKI (which fixes peer_id = SHA-256(SPKI)).
194    if !verify_signature(
195        &binding.bls_pub,
196        &binding_message(spki_der),
197        &binding.bls_sig,
198    ) {
199        return BindingOutcome::Invalid(
200            "binding BLS self-attestation did not verify over the SPKI",
201        );
202    }
203    BindingOutcome::Bound {
204        bls_pub: binding.bls_pub,
205    }
206}
207
208/// Attach the BLS peer_id-binding extension to certificate params, self-attesting `bls_sk` over the
209/// TLS key's SPKI. Shared by [`crate::node_cert`] so a bound leaf is assembled in exactly one place
210/// whether it is self-signed or CA-signed.
211pub(crate) fn attach_binding(
212    params: &mut CertificateParams,
213    key_pair: &KeyPair,
214    bls_sk: &SecretKey,
215) {
216    let spki_der = key_pair.public_key_der();
217    let bls_pub = public_key_bytes(bls_sk);
218    let bls_sig = sign_message(bls_sk, &binding_message(&spki_der));
219    let ext_value = encode_binding_extension_value(&bls_pub, &bls_sig);
220    params
221        .custom_extensions
222        .push(CustomExtension::from_oid_content(
223            DIG_BLS_BINDING_OID,
224            ext_value,
225        ));
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use sha2::{Digest, Sha256};
232
233    /// A deterministic node BLS identity key from a label — never an integer-literal secret.
234    fn node_bls_sk(label: &str) -> SecretKey {
235        let seed: [u8; 32] = Sha256::digest(label.as_bytes()).into();
236        SecretKey::from_seed(&seed)
237    }
238
239    fn tls_key_pair() -> KeyPair {
240        KeyPair::generate().expect("generate TLS key pair")
241    }
242
243    /// Build a self-signed leaf carrying a binding for `bls_sk` — the reference bound cert the
244    /// verifier tests exercise (CA-signed variants are covered in `node_cert`).
245    fn bound_self_signed(kp: &KeyPair, bls_sk: &SecretKey) -> Vec<u8> {
246        let mut params = CertificateParams::new(vec!["peer.dig".into()]).unwrap();
247        attach_binding(&mut params, kp, bls_sk);
248        params.self_signed(kp).unwrap().der().to_vec()
249    }
250
251    #[test]
252    fn oid_arc_and_string_forms_agree() {
253        let dotted = DIG_BLS_BINDING_OID
254            .iter()
255            .map(|n| n.to_string())
256            .collect::<Vec<_>>()
257            .join(".");
258        assert_eq!(dotted, DIG_BLS_BINDING_OID_STR);
259    }
260
261    #[test]
262    fn extension_value_round_trips() {
263        let value = encode_binding_extension_value(&[7u8; 48], &[9u8; 96]);
264        assert_eq!(value.len(), BINDING_V1_LEN);
265        let parsed = parse_binding_extension_value(&value).expect("parses");
266        assert_eq!(parsed.bls_pub, [7u8; 48]);
267        assert_eq!(parsed.bls_sig, [9u8; 96]);
268    }
269
270    #[test]
271    fn parse_rejects_bad_length_and_unknown_version() {
272        assert_eq!(parse_binding_extension_value(&[]), None);
273        assert_eq!(
274            parse_binding_extension_value(&[BINDING_VERSION_V1; 10]),
275            None
276        );
277        let mut wrong = vec![0u8; BINDING_V1_LEN];
278        wrong[0] = 0xFE;
279        assert_eq!(parse_binding_extension_value(&wrong), None);
280    }
281
282    #[test]
283    fn valid_bound_cert_verifies() {
284        let kp = tls_key_pair();
285        let bls_sk = node_bls_sk("binding/valid");
286        let cert = bound_self_signed(&kp, &bls_sk);
287        match verify_binding_from_leaf_cert(&cert) {
288            BindingOutcome::Bound { bls_pub } => {
289                assert_eq!(
290                    bls_pub,
291                    public_key_bytes(&bls_sk),
292                    "verified pubkey is the signer's"
293                );
294            }
295            other => panic!("expected Bound, got {other:?}"),
296        }
297    }
298
299    #[test]
300    fn cert_without_extension_is_absent() {
301        let c = rcgen::generate_simple_self_signed(vec!["peer.dig".into()]).unwrap();
302        assert_eq!(
303            verify_binding_from_leaf_cert(c.cert.der()),
304            BindingOutcome::Absent
305        );
306    }
307
308    #[test]
309    fn anti_substitution_wrong_bls_key_rejected() {
310        // Sign the binding with the victim key, but embed the attacker's pubkey (a substitution).
311        let kp = tls_key_pair();
312        let victim_sk = node_bls_sk("binding/victim");
313        let attacker_pub = public_key_bytes(&node_bls_sk("binding/attacker"));
314        let spki = kp.public_key_der();
315        let sig = sign_message(&victim_sk, &binding_message(&spki));
316        let ext = encode_binding_extension_value(&attacker_pub, &sig);
317        let mut params = CertificateParams::new(vec!["peer.dig".into()]).unwrap();
318        params
319            .custom_extensions
320            .push(CustomExtension::from_oid_content(DIG_BLS_BINDING_OID, ext));
321        let cert = params.self_signed(&kp).unwrap().der().to_vec();
322        assert!(matches!(
323            verify_binding_from_leaf_cert(&cert),
324            BindingOutcome::Invalid(_)
325        ));
326    }
327
328    #[test]
329    fn anti_substitution_binding_replayed_on_other_cert_rejected() {
330        let kp_a = tls_key_pair();
331        let bls_sk = node_bls_sk("binding/replay");
332        let sig = sign_message(&bls_sk, &binding_message(&kp_a.public_key_der()));
333        let ext = encode_binding_extension_value(&public_key_bytes(&bls_sk), &sig);
334        // Graft that exact extension onto a DIFFERENT cert (different SPKI → different peer_id).
335        let kp_b = tls_key_pair();
336        let mut params = CertificateParams::new(vec!["peer.dig".into()]).unwrap();
337        params
338            .custom_extensions
339            .push(CustomExtension::from_oid_content(DIG_BLS_BINDING_OID, ext));
340        let cert_b = params.self_signed(&kp_b).unwrap().der().to_vec();
341        assert!(matches!(
342            verify_binding_from_leaf_cert(&cert_b),
343            BindingOutcome::Invalid(_)
344        ));
345    }
346
347    #[test]
348    fn subgroup_check_rejects_bad_g1_point() {
349        let kp = tls_key_pair();
350        let bls_sk = node_bls_sk("binding/subgroup");
351        let sig = sign_message(&bls_sk, &binding_message(&kp.public_key_der()));
352        let ext = encode_binding_extension_value(&[0xFFu8; 48], &sig);
353        let mut params = CertificateParams::new(vec!["peer.dig".into()]).unwrap();
354        params
355            .custom_extensions
356            .push(CustomExtension::from_oid_content(DIG_BLS_BINDING_OID, ext));
357        let cert = params.self_signed(&kp).unwrap().der().to_vec();
358        assert!(matches!(
359            verify_binding_from_leaf_cert(&cert),
360            BindingOutcome::Invalid(_)
361        ));
362    }
363
364    #[test]
365    fn policy_off_accepts_everything() {
366        let pk = [1u8; 48];
367        assert_eq!(
368            evaluate(&BindingOutcome::Absent, BindingPolicy::Off),
369            Ok(None)
370        );
371        assert_eq!(
372            evaluate(&BindingOutcome::Invalid("x"), BindingPolicy::Off),
373            Ok(None)
374        );
375        assert_eq!(
376            evaluate(&BindingOutcome::Bound { bls_pub: pk }, BindingPolicy::Off),
377            Ok(None)
378        );
379    }
380
381    #[test]
382    fn policy_opportunistic_accepts_absent_rejects_invalid() {
383        let pk = [2u8; 48];
384        assert_eq!(
385            evaluate(&BindingOutcome::Absent, BindingPolicy::Opportunistic),
386            Ok(None)
387        );
388        assert!(evaluate(
389            &BindingOutcome::Invalid("bad"),
390            BindingPolicy::Opportunistic
391        )
392        .is_err());
393        assert_eq!(
394            evaluate(
395                &BindingOutcome::Bound { bls_pub: pk },
396                BindingPolicy::Opportunistic
397            ),
398            Ok(Some(pk))
399        );
400    }
401
402    #[test]
403    fn policy_required_rejects_absent_and_invalid() {
404        let pk = [3u8; 48];
405        assert!(
406            evaluate(&BindingOutcome::Absent, BindingPolicy::Required).is_err(),
407            "anti-downgrade: a stripped extension is rejected in Required mode"
408        );
409        assert!(evaluate(&BindingOutcome::Invalid("bad"), BindingPolicy::Required).is_err());
410        assert_eq!(
411            evaluate(
412                &BindingOutcome::Bound { bls_pub: pk },
413                BindingPolicy::Required
414            ),
415            Ok(Some(pk))
416        );
417    }
418
419    #[test]
420    fn default_policy_is_opportunistic() {
421        assert_eq!(BindingPolicy::default(), BindingPolicy::Opportunistic);
422    }
423}