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}