Skip to main content

dig_nat/
cert_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 self-signed mTLS leaf
12//! certificate as a custom X.509 extension ([`DIG_BLS_BINDING_OID`]), self-attested by a 96-byte
13//! **BLS G2 signature over the leaf's SPKI DER**. Because `peer_id = SHA-256(SPKI)`, signing the
14//! SPKI commits the 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
23//!   covers 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 it cannot be
30//! downgraded by a peer):
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. Verifies without breaking legacy peers.
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 — it can never silently
37//!   disable a required-mode session.
38
39use rcgen::{CertificateParams, CustomExtension, KeyPair};
40
41use dig_identity::bls::SecretKey;
42use dig_identity::{g1_subgroup_check, public_key_bytes, sign_message, verify_signature};
43
44/// The DIG BLS-binding X.509 extension OID (dotted-decimal arc form used by `rcgen`).
45///
46/// A DIG **provisional private-use** OID under the `1.3.6.1.4.1` (IANA private enterprise) arc. It
47/// is not a registered PEN assignment; it is a stable, ecosystem-canonical identifier for the DIG
48/// BLS peer_id-binding extension. Recorded in the `canonical` skill + dig-nat `SPEC.md` so no second
49/// implementation invents a different arc. [`DIG_BLS_BINDING_OID_STR`] is the same OID in the
50/// dotted-decimal string form used to match a parsed certificate's extensions.
51pub const DIG_BLS_BINDING_OID: &[u64] = &[1, 3, 6, 1, 4, 1, 58968, 1, 1];
52
53/// The [`DIG_BLS_BINDING_OID`] in dotted-decimal string form (for matching parsed cert extensions).
54/// A unit test asserts it equals the arc form so the two can never drift.
55pub const DIG_BLS_BINDING_OID_STR: &str = "1.3.6.1.4.1.58968.1.1";
56
57/// Version byte of the binding extension value (v1). Newer writers MAY bump this; verifiers dispatch
58/// on it and MUST keep accepting every version they understand (§5.1 additive-forever).
59pub const BINDING_VERSION_V1: u8 = 1;
60
61/// Length of the v1 extension value: `version(1) || bls_pub(48) || bls_sig(96)`.
62const BINDING_V1_LEN: usize = 1 + 48 + 96;
63
64/// Domain-separation context prefixed to the SPKI DER before the BLS-G2 self-attestation is signed /
65/// verified. Binds the signature to *this* purpose (a DIG cert peer_id-binding, v1) so it can never
66/// be cross-protocol-replayed as some other DIG BLS signature.
67const BINDING_SIG_CONTEXT: &[u8] = b"dig-nat/cert-bls-binding/v1";
68
69/// The exact byte string the BLS-G2 self-attestation covers: [`BINDING_SIG_CONTEXT`] then the leaf's
70/// SPKI DER. Used identically when signing (attest) and verifying.
71pub fn binding_message(spki_der: &[u8]) -> Vec<u8> {
72    let mut msg = Vec::with_capacity(BINDING_SIG_CONTEXT.len() + spki_der.len());
73    msg.extend_from_slice(BINDING_SIG_CONTEXT);
74    msg.extend_from_slice(spki_der);
75    msg
76}
77
78/// Encode the v1 extension value from a BLS G1 pubkey + its G2 self-attestation signature.
79pub fn encode_binding_extension_value(bls_pub: &[u8; 48], bls_sig: &[u8; 96]) -> Vec<u8> {
80    let mut value = Vec::with_capacity(BINDING_V1_LEN);
81    value.push(BINDING_VERSION_V1);
82    value.extend_from_slice(bls_pub);
83    value.extend_from_slice(bls_sig);
84    value
85}
86
87/// The raw contents of a parsed (not-yet-verified) binding extension.
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct CertBlsBinding {
90    /// The claimed 48-byte compressed BLS G1 public key.
91    pub bls_pub: [u8; 48],
92    /// The 96-byte BLS G2 self-attestation over [`binding_message`] of the leaf SPKI.
93    pub bls_sig: [u8; 96],
94}
95
96/// Parse a v1 extension value into its fields. Returns `None` for a wrong length or an unrecognised
97/// version — an unknown version is treated as "no binding this verifier understands" (additive
98/// forward-compat), NOT as tampering.
99pub fn parse_binding_extension_value(value: &[u8]) -> Option<CertBlsBinding> {
100    if value.first().copied()? != BINDING_VERSION_V1 || value.len() != BINDING_V1_LEN {
101        return None;
102    }
103    let mut bls_pub = [0u8; 48];
104    let mut bls_sig = [0u8; 96];
105    bls_pub.copy_from_slice(&value[1..49]);
106    bls_sig.copy_from_slice(&value[49..145]);
107    Some(CertBlsBinding { bls_pub, bls_sig })
108}
109
110/// The result of checking a leaf certificate for a valid BLS binding, BEFORE the policy is applied.
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub enum BindingOutcome {
113    /// A cryptographically valid binding: `peer_id ↔ bls_pub` is proven. Carries the verified pubkey.
114    Bound {
115        /// The verified 48-byte BLS G1 public key the `peer_id` is bound to.
116        bls_pub: [u8; 48],
117    },
118    /// No DIG BLS-binding extension is present (a legacy / un-bound peer).
119    Absent,
120    /// A binding extension IS present but did not verify — malformed, bad subgroup point, or the
121    /// self-attestation signature did not check out. Carries a static reason for logging.
122    Invalid(&'static str),
123}
124
125/// The verification stance for a peer's cert binding — a LOCAL decision (never wire-negotiated).
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
127pub enum BindingPolicy {
128    /// Do not verify the binding at all (pre-adoption / explicit opt-out).
129    Off,
130    /// The rollout default: verify-if-present, reject-if-present-but-invalid, accept-if-absent.
131    #[default]
132    Opportunistic,
133    /// Strict: a valid binding is mandatory; both ABSENT and INVALID are rejected (anti-downgrade).
134    Required,
135}
136
137/// Apply a [`BindingPolicy`] to a [`BindingOutcome`], deciding whether the handshake may proceed.
138///
139/// Returns `Ok(Some(bls_pub))` when a binding was verified, `Ok(None)` when the connection is
140/// permitted without a verified binding (Off, or Opportunistic-and-absent), and `Err(reason)` when
141/// the policy REJECTS the peer (fail-closed).
142pub fn evaluate(
143    outcome: &BindingOutcome,
144    policy: BindingPolicy,
145) -> Result<Option<[u8; 48]>, &'static str> {
146    match (policy, outcome) {
147        // Off never verifies and never rejects on binding grounds.
148        (BindingPolicy::Off, _) => Ok(None),
149
150        // A valid binding is always accepted (any non-Off policy).
151        (_, BindingOutcome::Bound { bls_pub }) => Ok(Some(*bls_pub)),
152
153        // Opportunistic tolerates a legacy peer but never a tampered binding.
154        (BindingPolicy::Opportunistic, BindingOutcome::Absent) => Ok(None),
155        (BindingPolicy::Opportunistic, BindingOutcome::Invalid(reason)) => Err(reason),
156
157        // Required rejects both absence (anti-downgrade) and invalidity.
158        (BindingPolicy::Required, BindingOutcome::Absent) => {
159            Err("cert BLS binding required but absent (possible downgrade)")
160        }
161        (BindingPolicy::Required, BindingOutcome::Invalid(reason)) => Err(reason),
162    }
163}
164
165/// Verify the BLS binding carried by a DER-encoded leaf certificate.
166///
167/// Extracts the [`DIG_BLS_BINDING_OID`] extension, and — when present — recomputes the binding
168/// message from the cert's own SPKI, subgroup-checks the embedded G1 pubkey, and verifies the BLS-G2
169/// self-attestation against it. The returned [`BindingOutcome`] is then fed to [`evaluate`] with the
170/// verifier's [`BindingPolicy`].
171///
172/// A certificate that cannot be parsed as X.509 returns [`BindingOutcome::Invalid`] (a peer that
173/// reached cert verification presented SOMETHING; an unparseable leaf is a hard reject regardless of
174/// policy in Opportunistic/Required — the mTLS layer would fail on it anyway).
175pub fn verify_binding_from_leaf_cert(cert_der: &[u8]) -> BindingOutcome {
176    let Ok((_, x509)) = x509_parser::parse_x509_certificate(cert_der) else {
177        return BindingOutcome::Invalid("leaf certificate could not be parsed as X.509");
178    };
179    let spki_der = x509.tbs_certificate.subject_pki.raw;
180
181    let mut binding_value: Option<&[u8]> = None;
182    for ext in x509.extensions() {
183        if ext.oid.to_id_string() == DIG_BLS_BINDING_OID_STR {
184            binding_value = Some(ext.value);
185            break;
186        }
187    }
188    let Some(value) = binding_value else {
189        return BindingOutcome::Absent;
190    };
191
192    let Some(binding) = parse_binding_extension_value(value) else {
193        return BindingOutcome::Invalid("binding extension malformed or unknown version");
194    };
195    // Reject a small-subgroup / identity / non-canonical G1 point BEFORE trusting it as a seal target.
196    if !g1_subgroup_check(&binding.bls_pub) {
197        return BindingOutcome::Invalid("binding BLS pubkey failed the G1 subgroup check");
198    }
199    // The self-attestation must be over THIS leaf's SPKI (which fixes peer_id = SHA-256(SPKI)).
200    if !verify_signature(
201        &binding.bls_pub,
202        &binding_message(spki_der),
203        &binding.bls_sig,
204    ) {
205        return BindingOutcome::Invalid(
206            "binding BLS self-attestation did not verify over the SPKI",
207        );
208    }
209    BindingOutcome::Bound {
210        bls_pub: binding.bls_pub,
211    }
212}
213
214/// Errors from building a BLS-bound leaf certificate.
215#[derive(Debug, thiserror::Error)]
216pub enum CertBindingError {
217    /// The underlying `rcgen` certificate build failed.
218    #[error("certificate build failed: {0}")]
219    Rcgen(#[from] rcgen::Error),
220}
221
222/// Build a self-signed mTLS leaf certificate (DER) that CARRIES the BLS peer_id-binding extension.
223///
224/// `key_pair` is the node/relay TLS key pair (its public key becomes the cert SPKI, hence `peer_id`);
225/// `bls_sk` is the node/relay BLS G1 identity secret key (dig-identity slot 0x0010). The returned
226/// certificate embeds `bls_sk`'s public key + a BLS-G2 self-attestation over the SPKI, so any peer
227/// can verify `peer_id ↔ bls_pub` via [`verify_binding_from_leaf_cert`].
228///
229/// dig-node / dig-relay call this to mint their bound identity certs (the transport keygen stays in
230/// those repos; this is the one place the binding is assembled so it can never drift).
231pub fn build_bound_cert(
232    key_pair: &KeyPair,
233    bls_sk: &SecretKey,
234    subject_alt_names: Vec<String>,
235) -> Result<Vec<u8>, CertBindingError> {
236    // The SPKI DER the cert will carry — signing it commits the BLS key to this exact peer_id.
237    let spki_der = key_pair.public_key_der();
238    let bls_pub = public_key_bytes(bls_sk);
239    let bls_sig = sign_message(bls_sk, &binding_message(&spki_der));
240    let ext_value = encode_binding_extension_value(&bls_pub, &bls_sig);
241
242    let mut params = CertificateParams::new(subject_alt_names)?;
243    params
244        .custom_extensions
245        .push(CustomExtension::from_oid_content(
246            DIG_BLS_BINDING_OID,
247            ext_value,
248        ));
249    let cert = params.self_signed(key_pair)?;
250    Ok(cert.der().to_vec())
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256    use dig_identity::{derive_identity_sk, master_secret_key_from_seed};
257    use sha2::{Digest, Sha256};
258
259    /// A deterministic node BLS identity key from a label (test-only; mirrors dig-identity's KAT
260    /// helper). Derived — never an integer-literal secret (CodeQL "hard-coded crypto value").
261    fn node_bls_sk(label: &str) -> SecretKey {
262        let seed: [u8; 32] = Sha256::digest(label.as_bytes()).into();
263        derive_identity_sk(&master_secret_key_from_seed(&seed))
264    }
265
266    fn tls_key_pair() -> KeyPair {
267        KeyPair::generate().expect("generate TLS key pair")
268    }
269
270    #[test]
271    fn oid_arc_and_string_forms_agree() {
272        let dotted = DIG_BLS_BINDING_OID
273            .iter()
274            .map(|n| n.to_string())
275            .collect::<Vec<_>>()
276            .join(".");
277        assert_eq!(dotted, DIG_BLS_BINDING_OID_STR);
278    }
279
280    #[test]
281    fn extension_value_round_trips() {
282        let bls_pub = [7u8; 48];
283        let bls_sig = [9u8; 96];
284        let value = encode_binding_extension_value(&bls_pub, &bls_sig);
285        assert_eq!(value.len(), BINDING_V1_LEN);
286        let parsed = parse_binding_extension_value(&value).expect("parses");
287        assert_eq!(parsed.bls_pub, bls_pub);
288        assert_eq!(parsed.bls_sig, bls_sig);
289    }
290
291    #[test]
292    fn parse_rejects_bad_length_and_unknown_version() {
293        assert_eq!(parse_binding_extension_value(&[]), None);
294        assert_eq!(
295            parse_binding_extension_value(&[BINDING_VERSION_V1; 10]),
296            None
297        );
298        // Right length, wrong version byte.
299        let mut wrong = vec![0u8; BINDING_V1_LEN];
300        wrong[0] = 0xFE;
301        assert_eq!(parse_binding_extension_value(&wrong), None);
302    }
303
304    // --- The anti-substitution property: a valid bound cert verifies ---
305    #[test]
306    fn valid_bound_cert_verifies() {
307        let kp = tls_key_pair();
308        let bls_sk = node_bls_sk("cert-binding/valid");
309        let cert = build_bound_cert(&kp, &bls_sk, vec!["peer.dig".into()]).unwrap();
310        match verify_binding_from_leaf_cert(&cert) {
311            BindingOutcome::Bound { bls_pub } => {
312                assert_eq!(
313                    bls_pub,
314                    public_key_bytes(&bls_sk),
315                    "verified pubkey is the signer's"
316                );
317            }
318            other => panic!("expected Bound, got {other:?}"),
319        }
320    }
321
322    #[test]
323    fn cert_without_extension_is_absent() {
324        let c = rcgen::generate_simple_self_signed(vec!["peer.dig".into()]).unwrap();
325        assert_eq!(
326            verify_binding_from_leaf_cert(c.cert.der()),
327            BindingOutcome::Absent
328        );
329    }
330
331    // --- ANTI-SUBSTITUTION: attacker's BLS key under a cert it did not sign for ---
332    #[test]
333    fn anti_substitution_wrong_bls_key_rejected() {
334        // Attacker builds a cert binding with a sig from key A but embeds key B's pubkey.
335        let kp = tls_key_pair();
336        let victim_sk = node_bls_sk("cert-binding/victim");
337        let attacker_sk = node_bls_sk("cert-binding/attacker");
338        let spki = kp.public_key_der();
339        // Sign with the victim key, but claim the attacker's pubkey (a substitution attempt).
340        let sig = sign_message(&victim_sk, &binding_message(&spki));
341        let ext = encode_binding_extension_value(&public_key_bytes(&attacker_sk), &sig);
342        let mut params = CertificateParams::new(vec!["peer.dig".into()]).unwrap();
343        params
344            .custom_extensions
345            .push(CustomExtension::from_oid_content(DIG_BLS_BINDING_OID, ext));
346        let cert = params.self_signed(&kp).unwrap().der().to_vec();
347        assert!(
348            matches!(
349                verify_binding_from_leaf_cert(&cert),
350                BindingOutcome::Invalid(_)
351            ),
352            "a sig that does not verify under the embedded pubkey must be rejected"
353        );
354    }
355
356    // --- ANTI-SUBSTITUTION: replaying a valid binding under a DIFFERENT SPKI (different peer_id) ---
357    #[test]
358    fn anti_substitution_binding_replayed_on_other_cert_rejected() {
359        let kp_a = tls_key_pair();
360        let bls_sk = node_bls_sk("cert-binding/replay");
361        // Build a valid binding for cert A.
362        let sig = sign_message(&bls_sk, &binding_message(&kp_a.public_key_der()));
363        let ext = encode_binding_extension_value(&public_key_bytes(&bls_sk), &sig);
364        // Graft that exact extension onto a DIFFERENT cert (kp_b → different SPKI → different peer_id).
365        let kp_b = tls_key_pair();
366        let mut params = CertificateParams::new(vec!["peer.dig".into()]).unwrap();
367        params
368            .custom_extensions
369            .push(CustomExtension::from_oid_content(DIG_BLS_BINDING_OID, ext));
370        let cert_b = params.self_signed(&kp_b).unwrap().der().to_vec();
371        assert!(
372            matches!(
373                verify_binding_from_leaf_cert(&cert_b),
374                BindingOutcome::Invalid(_)
375            ),
376            "a binding signed over cert A's SPKI must not verify inside cert B"
377        );
378    }
379
380    // --- Subgroup check rejects a bad G1 point in the extension ---
381    #[test]
382    fn subgroup_check_rejects_bad_g1_point() {
383        let kp = tls_key_pair();
384        let bls_sk = node_bls_sk("cert-binding/subgroup");
385        // A sig valid in shape but the embedded pubkey is a non-subgroup point (all-0xFF).
386        let sig = sign_message(&bls_sk, &binding_message(&kp.public_key_der()));
387        let ext = encode_binding_extension_value(&[0xFFu8; 48], &sig);
388        let mut params = CertificateParams::new(vec!["peer.dig".into()]).unwrap();
389        params
390            .custom_extensions
391            .push(CustomExtension::from_oid_content(DIG_BLS_BINDING_OID, ext));
392        let cert = params.self_signed(&kp).unwrap().der().to_vec();
393        assert!(matches!(
394            verify_binding_from_leaf_cert(&cert),
395            BindingOutcome::Invalid(_)
396        ));
397    }
398
399    // --- Policy matrix ---
400    #[test]
401    fn policy_off_accepts_everything() {
402        let pk = [1u8; 48];
403        assert_eq!(
404            evaluate(&BindingOutcome::Absent, BindingPolicy::Off),
405            Ok(None)
406        );
407        assert_eq!(
408            evaluate(&BindingOutcome::Invalid("x"), BindingPolicy::Off),
409            Ok(None)
410        );
411        assert_eq!(
412            evaluate(&BindingOutcome::Bound { bls_pub: pk }, BindingPolicy::Off),
413            Ok(None)
414        );
415    }
416
417    #[test]
418    fn policy_opportunistic_accepts_absent_rejects_invalid() {
419        let pk = [2u8; 48];
420        assert_eq!(
421            evaluate(&BindingOutcome::Absent, BindingPolicy::Opportunistic),
422            Ok(None)
423        );
424        assert!(evaluate(
425            &BindingOutcome::Invalid("bad"),
426            BindingPolicy::Opportunistic
427        )
428        .is_err());
429        assert_eq!(
430            evaluate(
431                &BindingOutcome::Bound { bls_pub: pk },
432                BindingPolicy::Opportunistic
433            ),
434            Ok(Some(pk))
435        );
436    }
437
438    #[test]
439    fn policy_required_rejects_absent_and_invalid() {
440        let pk = [3u8; 48];
441        assert!(
442            evaluate(&BindingOutcome::Absent, BindingPolicy::Required).is_err(),
443            "anti-downgrade: a stripped extension is rejected in Required mode"
444        );
445        assert!(evaluate(&BindingOutcome::Invalid("bad"), BindingPolicy::Required).is_err());
446        assert_eq!(
447            evaluate(
448                &BindingOutcome::Bound { bls_pub: pk },
449                BindingPolicy::Required
450            ),
451            Ok(Some(pk))
452        );
453    }
454
455    #[test]
456    fn default_policy_is_opportunistic() {
457        assert_eq!(BindingPolicy::default(), BindingPolicy::Opportunistic);
458    }
459}