use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use x509_parser::prelude::FromDer;
use crate::roster::{CertPolicy, MemberStatus, Roster};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, ToSchema)]
pub struct BundleMember {
pub hostname: String,
pub cert_fingerprint: String,
pub not_after: String,
pub status: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, ToSchema)]
pub struct BundleRevoked {
pub hostname: String,
#[serde(default)]
pub cert_fingerprint: String,
pub revoked_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, ToSchema)]
pub struct TrustBundle {
pub seq: u64,
pub issued_at: String,
pub ca_fingerprint: String,
pub ca_cert_pem: String,
pub policy: CertPolicy,
pub members: Vec<BundleMember>,
pub revoked: Vec<BundleRevoked>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct SignedBundle {
pub bundle: TrustBundle,
pub signature: String,
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum BundleError {
#[error("bundle CA cert is not valid PEM/DER")]
CaCert,
#[error("bundle CA fingerprint does not match the pinned CA")]
PinMismatch,
#[error("bundle signature is invalid")]
BadSignature,
#[error("bundle signature is not valid base64")]
BadSignatureEncoding,
#[error("bundle could not be canonicalized for verification")]
Canonicalize,
#[error("bundle seq {got} is older than last seen {last_seen} (rollback)")]
Rollback { got: u64, last_seen: u64 },
}
impl TrustBundle {
pub fn from_roster(
roster: &Roster,
ca_cert_pem: &str,
ca_fingerprint: &str,
issued_at: String,
) -> Self {
let members = roster
.members
.iter()
.map(|m| BundleMember {
hostname: m.hostname.clone(),
cert_fingerprint: m.cert_fingerprint.clone(),
not_after: m.cert_expires.to_rfc3339(),
status: match m.status {
MemberStatus::Active => "active",
MemberStatus::Revoked => "revoked",
}
.to_string(),
})
.collect();
let revoked = roster
.revocation_list
.iter()
.map(|r| BundleRevoked {
hostname: r.hostname.clone(),
cert_fingerprint: roster
.find_member(&r.hostname)
.map(|m| m.cert_fingerprint.clone())
.unwrap_or_default(),
revoked_at: r.revoked_at.to_rfc3339(),
})
.collect();
Self {
seq: roster.metadata.seq,
issued_at,
ca_fingerprint: ca_fingerprint.to_string(),
ca_cert_pem: ca_cert_pem.to_string(),
policy: roster.metadata.policy.clone(),
members,
revoked,
}
}
pub fn canonical_bytes(&self) -> Result<Vec<u8>, serde_json::Error> {
let sorted = serde_json::to_value(self)?;
serde_json::to_vec(&sorted)
}
pub fn is_revoked(&self, hostname: &str) -> bool {
self.revoked.iter().any(|r| r.hostname == hostname)
|| self
.members
.iter()
.any(|m| m.hostname == hostname && m.status == "revoked")
}
}
pub fn sign(
roster: &Roster,
ca: &crate::ca::CaState,
issued_at: String,
) -> Result<SignedBundle, crate::error::CertmeshError> {
use base64::Engine;
let ca_fingerprint = crate::ca::ca_fingerprint(ca);
let bundle = TrustBundle::from_roster(roster, &ca.cert_pem, &ca_fingerprint, issued_at);
let bytes = bundle
.canonical_bytes()
.map_err(|e| crate::error::CertmeshError::Internal(format!("canonicalize bundle: {e}")))?;
let sig = koi_crypto::signing::sign_bytes(&ca.key, &bytes);
Ok(SignedBundle {
bundle,
signature: base64::engine::general_purpose::STANDARD.encode(sig),
})
}
fn ca_spki_pem(ca_cert_pem: &str) -> Result<String, BundleError> {
let der = pem::parse(ca_cert_pem).map_err(|_| BundleError::CaCert)?;
let (_, cert) = x509_parser::certificate::X509Certificate::from_der(der.contents())
.map_err(|_| BundleError::CaCert)?;
let spki_der = cert.public_key().raw.to_vec();
Ok(pem::encode(&pem::Pem::new("PUBLIC KEY", spki_der)))
}
pub fn verify(
signed: &SignedBundle,
pinned_ca_fingerprint: &str,
last_seen_seq: Option<u64>,
) -> Result<(), BundleError> {
use base64::Engine;
let der = pem::parse(&signed.bundle.ca_cert_pem).map_err(|_| BundleError::CaCert)?;
let derived_fp = koi_crypto::pinning::fingerprint_sha256(der.contents());
if !koi_crypto::pinning::fingerprints_match(&derived_fp, pinned_ca_fingerprint) {
return Err(BundleError::PinMismatch);
}
if !koi_crypto::pinning::fingerprints_match(&derived_fp, &signed.bundle.ca_fingerprint) {
return Err(BundleError::PinMismatch);
}
let sig = base64::engine::general_purpose::STANDARD
.decode(signed.signature.as_bytes())
.map_err(|_| BundleError::BadSignatureEncoding)?;
let spki_pem = ca_spki_pem(&signed.bundle.ca_cert_pem)?;
let bytes = signed
.bundle
.canonical_bytes()
.map_err(|_| BundleError::Canonicalize)?;
if !koi_crypto::signing::verify_signature(&spki_pem, &bytes, &sig) {
return Err(BundleError::BadSignature);
}
if let Some(last_seen) = last_seen_seq {
if signed.bundle.seq < last_seen {
return Err(BundleError::Rollback {
got: signed.bundle.seq,
last_seen,
});
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ca;
use crate::roster::{MemberRole, RosterMember};
use chrono::Utc;
fn test_ca() -> ca::CaState {
let paths = crate::CertmeshPaths::with_data_dir(koi_common::test::ensure_data_dir(
"koi-certmesh-bundle-tests",
));
ca::create_ca("bundle-pass", &[9u8; 32], &paths).unwrap().0
}
fn roster_with_member(hostname: &str, fp: &str) -> Roster {
let mut r = Roster::new(true, false, None);
r.members.push(RosterMember {
hostname: hostname.to_string(),
role: MemberRole::Member,
enrolled_at: Utc::now(),
enrolled_by: None,
cert_fingerprint: fp.to_string(),
cert_expires: Utc::now() + chrono::Duration::days(90),
cert_sans: vec![hostname.to_string()],
cert_path: String::new(),
status: MemberStatus::Active,
reload_hook: None,
last_seen: None,
pinned_ca_fingerprint: None,
proxy_entries: Vec::new(),
});
r
}
#[test]
fn sign_then_verify_round_trips_through_json() {
let ca = test_ca();
let mut roster = roster_with_member("web-01", "fp-web-01");
roster.metadata.seq = 7;
let signed = sign(&roster, &ca, "2026-06-19T00:00:00Z".to_string()).unwrap();
let json = serde_json::to_string(&signed).unwrap();
let parsed: SignedBundle = serde_json::from_str(&json).unwrap();
let pin = ca::ca_fingerprint(&ca);
assert!(
verify(&parsed, &pin, Some(6)).is_ok(),
"fresh bundle verifies"
);
assert_eq!(parsed.bundle.seq, 7);
assert_eq!(parsed.bundle.members.len(), 1);
}
#[test]
fn wire_bundle_bytes_verify_externally() {
use base64::Engine;
let ca = test_ca();
let roster = roster_with_member("web-01", "fp");
let signed = sign(&roster, &ca, "2026-06-19T00:00:00Z".to_string()).unwrap();
let wire = serde_json::to_string(&signed).unwrap();
let v: serde_json::Value = serde_json::from_str(&wire).unwrap();
let bundle_bytes = serde_json::to_vec(&v["bundle"]).unwrap();
let sig = base64::engine::general_purpose::STANDARD
.decode(v["signature"].as_str().unwrap())
.unwrap();
let spki = ca_spki_pem(&ca.cert_pem).unwrap();
assert!(
koi_crypto::signing::verify_signature(&spki, &bundle_bytes, &sig),
"wire bundle bytes must verify against the CA key (external-verifier path)"
);
}
#[test]
fn verify_rejects_wrong_pin() {
let ca = test_ca();
let roster = roster_with_member("web-01", "fp");
let signed = sign(&roster, &ca, "t".to_string()).unwrap();
let err = verify(
&signed,
"0000000000000000000000000000000000000000000000000000000000000000",
None,
)
.unwrap_err();
assert_eq!(err, BundleError::PinMismatch);
}
#[test]
fn verify_rejects_tampered_bundle() {
let ca = test_ca();
let roster = roster_with_member("web-01", "fp");
let mut signed = sign(&roster, &ca, "t".to_string()).unwrap();
signed.bundle.members.push(BundleMember {
hostname: "evil".to_string(),
cert_fingerprint: "x".to_string(),
not_after: "t".to_string(),
status: "active".to_string(),
});
let pin = ca::ca_fingerprint(&ca);
assert_eq!(
verify(&signed, &pin, None).unwrap_err(),
BundleError::BadSignature
);
}
#[test]
fn verify_rejects_rollback() {
let ca = test_ca();
let mut roster = roster_with_member("web-01", "fp");
roster.metadata.seq = 3;
let signed = sign(&roster, &ca, "t".to_string()).unwrap();
let pin = ca::ca_fingerprint(&ca);
assert_eq!(
verify(&signed, &pin, Some(4)).unwrap_err(),
BundleError::Rollback {
got: 3,
last_seen: 4
}
);
assert!(verify(&signed, &pin, Some(3)).is_ok());
assert!(verify(&signed, &pin, Some(2)).is_ok());
}
#[test]
fn revoked_member_shows_in_bundle() {
let ca = test_ca();
let mut roster = roster_with_member("web-01", "fp-web-01");
roster
.revoke_member("web-01", Some("op".into()), Some("compromised".into()))
.unwrap();
let signed = sign(&roster, &ca, "t".to_string()).unwrap();
assert!(signed.bundle.is_revoked("web-01"));
assert_eq!(signed.bundle.revoked.len(), 1);
assert_eq!(signed.bundle.revoked[0].cert_fingerprint, "fp-web-01");
}
}