use rcgen::{CertificateParams, CustomExtension, KeyPair};
use dig_identity::bls::SecretKey;
use dig_identity::{g1_subgroup_check, public_key_bytes, sign_message, verify_signature};
pub const DIG_BLS_BINDING_OID: &[u64] = &[1, 3, 6, 1, 4, 1, 58968, 1, 1];
pub const DIG_BLS_BINDING_OID_STR: &str = "1.3.6.1.4.1.58968.1.1";
pub const BINDING_VERSION_V1: u8 = 1;
const BINDING_V1_LEN: usize = 1 + 48 + 96;
const BINDING_SIG_CONTEXT: &[u8] = b"dig-nat/cert-bls-binding/v1";
pub fn binding_message(spki_der: &[u8]) -> Vec<u8> {
let mut msg = Vec::with_capacity(BINDING_SIG_CONTEXT.len() + spki_der.len());
msg.extend_from_slice(BINDING_SIG_CONTEXT);
msg.extend_from_slice(spki_der);
msg
}
pub fn encode_binding_extension_value(bls_pub: &[u8; 48], bls_sig: &[u8; 96]) -> Vec<u8> {
let mut value = Vec::with_capacity(BINDING_V1_LEN);
value.push(BINDING_VERSION_V1);
value.extend_from_slice(bls_pub);
value.extend_from_slice(bls_sig);
value
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CertBlsBinding {
pub bls_pub: [u8; 48],
pub bls_sig: [u8; 96],
}
pub fn parse_binding_extension_value(value: &[u8]) -> Option<CertBlsBinding> {
if value.first().copied()? != BINDING_VERSION_V1 || value.len() != BINDING_V1_LEN {
return None;
}
let mut bls_pub = [0u8; 48];
let mut bls_sig = [0u8; 96];
bls_pub.copy_from_slice(&value[1..49]);
bls_sig.copy_from_slice(&value[49..145]);
Some(CertBlsBinding { bls_pub, bls_sig })
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BindingOutcome {
Bound {
bls_pub: [u8; 48],
},
Absent,
Invalid(&'static str),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum BindingPolicy {
Off,
#[default]
Opportunistic,
Required,
}
pub fn evaluate(
outcome: &BindingOutcome,
policy: BindingPolicy,
) -> Result<Option<[u8; 48]>, &'static str> {
match (policy, outcome) {
(BindingPolicy::Off, _) => Ok(None),
(_, BindingOutcome::Bound { bls_pub }) => Ok(Some(*bls_pub)),
(BindingPolicy::Opportunistic, BindingOutcome::Absent) => Ok(None),
(BindingPolicy::Opportunistic, BindingOutcome::Invalid(reason)) => Err(reason),
(BindingPolicy::Required, BindingOutcome::Absent) => {
Err("cert BLS binding required but absent (possible downgrade)")
}
(BindingPolicy::Required, BindingOutcome::Invalid(reason)) => Err(reason),
}
}
pub fn verify_binding_from_leaf_cert(cert_der: &[u8]) -> BindingOutcome {
let Ok((_, x509)) = x509_parser::parse_x509_certificate(cert_der) else {
return BindingOutcome::Invalid("leaf certificate could not be parsed as X.509");
};
let spki_der = x509.tbs_certificate.subject_pki.raw;
let mut binding_value: Option<&[u8]> = None;
for ext in x509.extensions() {
if ext.oid.to_id_string() == DIG_BLS_BINDING_OID_STR {
binding_value = Some(ext.value);
break;
}
}
let Some(value) = binding_value else {
return BindingOutcome::Absent;
};
let Some(binding) = parse_binding_extension_value(value) else {
return BindingOutcome::Invalid("binding extension malformed or unknown version");
};
if !g1_subgroup_check(&binding.bls_pub) {
return BindingOutcome::Invalid("binding BLS pubkey failed the G1 subgroup check");
}
if !verify_signature(
&binding.bls_pub,
&binding_message(spki_der),
&binding.bls_sig,
) {
return BindingOutcome::Invalid(
"binding BLS self-attestation did not verify over the SPKI",
);
}
BindingOutcome::Bound {
bls_pub: binding.bls_pub,
}
}
#[derive(Debug, thiserror::Error)]
pub enum CertBindingError {
#[error("certificate build failed: {0}")]
Rcgen(#[from] rcgen::Error),
}
pub fn build_bound_cert(
key_pair: &KeyPair,
bls_sk: &SecretKey,
subject_alt_names: Vec<String>,
) -> Result<Vec<u8>, CertBindingError> {
let spki_der = key_pair.public_key_der();
let bls_pub = public_key_bytes(bls_sk);
let bls_sig = sign_message(bls_sk, &binding_message(&spki_der));
let ext_value = encode_binding_extension_value(&bls_pub, &bls_sig);
let mut params = CertificateParams::new(subject_alt_names)?;
params
.custom_extensions
.push(CustomExtension::from_oid_content(
DIG_BLS_BINDING_OID,
ext_value,
));
let cert = params.self_signed(key_pair)?;
Ok(cert.der().to_vec())
}
#[cfg(test)]
mod tests {
use super::*;
use dig_identity::{derive_identity_sk, master_secret_key_from_seed};
use sha2::{Digest, Sha256};
fn node_bls_sk(label: &str) -> SecretKey {
let seed: [u8; 32] = Sha256::digest(label.as_bytes()).into();
derive_identity_sk(&master_secret_key_from_seed(&seed))
}
fn tls_key_pair() -> KeyPair {
KeyPair::generate().expect("generate TLS key pair")
}
#[test]
fn oid_arc_and_string_forms_agree() {
let dotted = DIG_BLS_BINDING_OID
.iter()
.map(|n| n.to_string())
.collect::<Vec<_>>()
.join(".");
assert_eq!(dotted, DIG_BLS_BINDING_OID_STR);
}
#[test]
fn extension_value_round_trips() {
let bls_pub = [7u8; 48];
let bls_sig = [9u8; 96];
let value = encode_binding_extension_value(&bls_pub, &bls_sig);
assert_eq!(value.len(), BINDING_V1_LEN);
let parsed = parse_binding_extension_value(&value).expect("parses");
assert_eq!(parsed.bls_pub, bls_pub);
assert_eq!(parsed.bls_sig, bls_sig);
}
#[test]
fn parse_rejects_bad_length_and_unknown_version() {
assert_eq!(parse_binding_extension_value(&[]), None);
assert_eq!(
parse_binding_extension_value(&[BINDING_VERSION_V1; 10]),
None
);
let mut wrong = vec![0u8; BINDING_V1_LEN];
wrong[0] = 0xFE;
assert_eq!(parse_binding_extension_value(&wrong), None);
}
#[test]
fn valid_bound_cert_verifies() {
let kp = tls_key_pair();
let bls_sk = node_bls_sk("cert-binding/valid");
let cert = build_bound_cert(&kp, &bls_sk, vec!["peer.dig".into()]).unwrap();
match verify_binding_from_leaf_cert(&cert) {
BindingOutcome::Bound { bls_pub } => {
assert_eq!(
bls_pub,
public_key_bytes(&bls_sk),
"verified pubkey is the signer's"
);
}
other => panic!("expected Bound, got {other:?}"),
}
}
#[test]
fn cert_without_extension_is_absent() {
let c = rcgen::generate_simple_self_signed(vec!["peer.dig".into()]).unwrap();
assert_eq!(
verify_binding_from_leaf_cert(c.cert.der()),
BindingOutcome::Absent
);
}
#[test]
fn anti_substitution_wrong_bls_key_rejected() {
let kp = tls_key_pair();
let victim_sk = node_bls_sk("cert-binding/victim");
let attacker_sk = node_bls_sk("cert-binding/attacker");
let spki = kp.public_key_der();
let sig = sign_message(&victim_sk, &binding_message(&spki));
let ext = encode_binding_extension_value(&public_key_bytes(&attacker_sk), &sig);
let mut params = CertificateParams::new(vec!["peer.dig".into()]).unwrap();
params
.custom_extensions
.push(CustomExtension::from_oid_content(DIG_BLS_BINDING_OID, ext));
let cert = params.self_signed(&kp).unwrap().der().to_vec();
assert!(
matches!(
verify_binding_from_leaf_cert(&cert),
BindingOutcome::Invalid(_)
),
"a sig that does not verify under the embedded pubkey must be rejected"
);
}
#[test]
fn anti_substitution_binding_replayed_on_other_cert_rejected() {
let kp_a = tls_key_pair();
let bls_sk = node_bls_sk("cert-binding/replay");
let sig = sign_message(&bls_sk, &binding_message(&kp_a.public_key_der()));
let ext = encode_binding_extension_value(&public_key_bytes(&bls_sk), &sig);
let kp_b = tls_key_pair();
let mut params = CertificateParams::new(vec!["peer.dig".into()]).unwrap();
params
.custom_extensions
.push(CustomExtension::from_oid_content(DIG_BLS_BINDING_OID, ext));
let cert_b = params.self_signed(&kp_b).unwrap().der().to_vec();
assert!(
matches!(
verify_binding_from_leaf_cert(&cert_b),
BindingOutcome::Invalid(_)
),
"a binding signed over cert A's SPKI must not verify inside cert B"
);
}
#[test]
fn subgroup_check_rejects_bad_g1_point() {
let kp = tls_key_pair();
let bls_sk = node_bls_sk("cert-binding/subgroup");
let sig = sign_message(&bls_sk, &binding_message(&kp.public_key_der()));
let ext = encode_binding_extension_value(&[0xFFu8; 48], &sig);
let mut params = CertificateParams::new(vec!["peer.dig".into()]).unwrap();
params
.custom_extensions
.push(CustomExtension::from_oid_content(DIG_BLS_BINDING_OID, ext));
let cert = params.self_signed(&kp).unwrap().der().to_vec();
assert!(matches!(
verify_binding_from_leaf_cert(&cert),
BindingOutcome::Invalid(_)
));
}
#[test]
fn policy_off_accepts_everything() {
let pk = [1u8; 48];
assert_eq!(
evaluate(&BindingOutcome::Absent, BindingPolicy::Off),
Ok(None)
);
assert_eq!(
evaluate(&BindingOutcome::Invalid("x"), BindingPolicy::Off),
Ok(None)
);
assert_eq!(
evaluate(&BindingOutcome::Bound { bls_pub: pk }, BindingPolicy::Off),
Ok(None)
);
}
#[test]
fn policy_opportunistic_accepts_absent_rejects_invalid() {
let pk = [2u8; 48];
assert_eq!(
evaluate(&BindingOutcome::Absent, BindingPolicy::Opportunistic),
Ok(None)
);
assert!(evaluate(
&BindingOutcome::Invalid("bad"),
BindingPolicy::Opportunistic
)
.is_err());
assert_eq!(
evaluate(
&BindingOutcome::Bound { bls_pub: pk },
BindingPolicy::Opportunistic
),
Ok(Some(pk))
);
}
#[test]
fn policy_required_rejects_absent_and_invalid() {
let pk = [3u8; 48];
assert!(
evaluate(&BindingOutcome::Absent, BindingPolicy::Required).is_err(),
"anti-downgrade: a stripped extension is rejected in Required mode"
);
assert!(evaluate(&BindingOutcome::Invalid("bad"), BindingPolicy::Required).is_err());
assert_eq!(
evaluate(
&BindingOutcome::Bound { bls_pub: pk },
BindingPolicy::Required
),
Ok(Some(pk))
);
}
#[test]
fn default_policy_is_opportunistic() {
assert_eq!(BindingPolicy::default(), BindingPolicy::Opportunistic);
}
}