use std::time::{Duration, Instant};
use koi_crypto::auth::AuthState;
use koi_crypto::key_agreement::EphemeralKeyPair;
use koi_crypto::keys::{self, CaKeyPair};
use koi_crypto::signing;
use zeroize::Zeroize;
use crate::ca::CaState;
use crate::error::CertmeshError;
use crate::protocol::{PromoteResponse, RosterManifest};
use crate::roster::Roster;
pub const FAILOVER_GRACE_SECS: u64 = 60;
pub const ROSTER_SYNC_INTERVAL_SECS: u64 = 300;
pub fn prepare_promotion(
ca: &CaState,
auth_state: &AuthState,
roster: &Roster,
client_public_key: &[u8; 32],
) -> Result<PromoteResponse, CertmeshError> {
let server_kp = EphemeralKeyPair::generate();
let server_pub = server_kp.public_key_bytes();
let mut shared_key = server_kp
.derive_shared_key(client_public_key)
.map_err(|e| CertmeshError::PromotionFailed(format!("key derivation: {e}")))?;
let shared_key_hex =
koi_crypto::secret::SecretString::new(koi_common::encoding::hex_encode(&shared_key));
shared_key.zeroize();
let encrypted_ca_key = keys::encrypt_key(&ca.key, shared_key_hex.as_ref())?;
let auth_data = match auth_state {
AuthState::Totp(secret) => {
let encrypted_totp = koi_crypto::totp::encrypt_secret(secret, shared_key_hex.as_ref())?;
serde_json::to_value(&koi_crypto::auth::StoredAuth::Totp {
encrypted_secret: encrypted_totp,
})
.map_err(|e| CertmeshError::Internal(format!("auth serialize: {e}")))?
}
AuthState::Fido2(cred) => serde_json::to_value(koi_crypto::auth::store_fido2(cred.clone()))
.map_err(|e| CertmeshError::Internal(format!("auth serialize: {e}")))?,
};
let roster_json = serde_json::to_string(roster)
.map_err(|e| CertmeshError::Internal(format!("roster serialization failed: {e}")))?;
Ok(PromoteResponse {
encrypted_ca_key,
auth_data,
roster_json,
ca_cert_pem: ca.cert_pem.clone(),
ephemeral_public: Some(server_pub),
})
}
pub fn accept_promotion(
response: &PromoteResponse,
our_keypair: EphemeralKeyPair,
) -> Result<(CaKeyPair, AuthState, Roster), CertmeshError> {
let server_pub = response.ephemeral_public.as_ref().ok_or_else(|| {
CertmeshError::PromotionFailed("server did not provide ephemeral public key".into())
})?;
let mut shared_key = our_keypair
.derive_shared_key(server_pub)
.map_err(|e| CertmeshError::PromotionFailed(format!("key derivation: {e}")))?;
let shared_key_hex =
koi_crypto::secret::SecretString::new(koi_common::encoding::hex_encode(&shared_key));
shared_key.zeroize();
let ca_key = keys::decrypt_key(&response.encrypted_ca_key, shared_key_hex.as_ref())
.map_err(|e| CertmeshError::PromotionFailed(format!("CA key DH decryption: {e}")))?;
let stored: koi_crypto::auth::StoredAuth = serde_json::from_value(response.auth_data.clone())
.map_err(|e| {
CertmeshError::PromotionFailed(format!("auth data deserialization: {e}"))
})?;
let auth_state = stored
.unlock(shared_key_hex.as_ref())
.map_err(|e| CertmeshError::PromotionFailed(format!("auth unlock: {e}")))?;
let roster: Roster = serde_json::from_str(&response.roster_json)
.map_err(|e| CertmeshError::PromotionFailed(format!("roster deserialization: {e}")))?;
Ok((ca_key, auth_state, roster))
}
pub fn build_signed_manifest(
ca: &CaState,
roster: &Roster,
) -> Result<RosterManifest, CertmeshError> {
let roster_json = serde_json::to_string(roster)
.map_err(|e| CertmeshError::Internal(format!("roster serialization failed: {e}")))?;
let signature = signing::sign_bytes(&ca.key, roster_json.as_bytes());
let ca_public_key = ca
.key
.public_key_pem()
.map_err(|e| CertmeshError::Crypto(e.to_string()))?;
Ok(RosterManifest {
roster_json,
signature,
ca_public_key,
})
}
pub fn verify_manifest(manifest: &RosterManifest) -> Result<Roster, CertmeshError> {
let valid = signing::verify_signature(
&manifest.ca_public_key,
manifest.roster_json.as_bytes(),
&manifest.signature,
);
if !valid {
return Err(CertmeshError::InvalidManifest);
}
serde_json::from_str(&manifest.roster_json)
.map_err(|e| CertmeshError::Internal(format!("roster deserialization: {e}")))
}
pub fn should_promote(primary_absent_since: Option<Instant>, grace: Duration) -> bool {
match primary_absent_since {
Some(since) => since.elapsed() >= grace,
None => false,
}
}
pub fn tiebreaker_wins(my_hostname: &str, other_hostname: &str) -> bool {
my_hostname < other_hostname
}
pub fn find_active_primary(
ca_fingerprint: &str,
services: &[(String, u16, std::collections::HashMap<String, String>)],
) -> Option<String> {
for (host, port, txt) in services {
let is_primary = txt.get("role").map(|r| r == "primary").unwrap_or(false);
let fp_matches = txt
.get("fingerprint")
.map(|fp| koi_crypto::pinning::fingerprints_match(fp, ca_fingerprint))
.unwrap_or(false);
if is_primary && fp_matches {
return Some(format!("{host}:{port}"));
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ca;
use crate::profiles::TrustProfile;
use crate::roster::{MemberRole, MemberStatus, Roster, RosterMember};
use chrono::Utc;
use std::collections::HashMap;
fn test_paths() -> crate::CertmeshPaths {
crate::CertmeshPaths::with_data_dir(koi_common::test::ensure_data_dir(
"koi-certmesh-failover-tests",
))
}
fn make_test_ca() -> CaState {
ca::create_ca("test-pass", &[42u8; 32], &test_paths())
.unwrap()
.0
}
fn make_test_roster() -> Roster {
let mut r = Roster::new(TrustProfile::JustMe, None);
r.members.push(RosterMember {
hostname: "stone-01".to_string(),
role: MemberRole::Primary,
enrolled_at: Utc::now(),
enrolled_by: None,
cert_fingerprint: "fp-abc".to_string(),
cert_expires: Utc::now(),
cert_sans: vec!["stone-01".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 promotion_round_trip_with_dh() {
let ca = make_test_ca();
let totp = koi_crypto::totp::generate_secret();
let auth_state = AuthState::Totp(totp);
let roster = make_test_roster();
let client_kp = koi_crypto::key_agreement::EphemeralKeyPair::generate();
let client_pub = client_kp.public_key_bytes();
let response = prepare_promotion(&ca, &auth_state, &roster, &client_pub).unwrap();
assert!(!response.encrypted_ca_key.ciphertext.is_empty());
assert!(!response.auth_data.is_null());
assert!(!response.roster_json.is_empty());
assert!(response.ca_cert_pem.contains("BEGIN CERTIFICATE"));
assert!(response.ephemeral_public.is_some());
let (ca_key, accepted_auth, accepted_roster) =
accept_promotion(&response, client_kp).unwrap();
assert_eq!(
ca_key.public_key_pem().unwrap(),
ca.key.public_key_pem().unwrap()
);
assert_eq!(accepted_auth.method_name(), "totp");
assert_eq!(accepted_roster.members.len(), 1);
assert_eq!(accepted_roster.members[0].hostname, "stone-01");
}
#[test]
fn promotion_missing_server_ephemeral_key_fails() {
let ca = make_test_ca();
let totp = koi_crypto::totp::generate_secret();
let auth_state = AuthState::Totp(totp);
let roster = make_test_roster();
let client_kp = koi_crypto::key_agreement::EphemeralKeyPair::generate();
let client_pub = client_kp.public_key_bytes();
let mut response = prepare_promotion(&ca, &auth_state, &roster, &client_pub).unwrap();
response.ephemeral_public = None;
let result = accept_promotion(&response, client_kp);
assert!(matches!(result, Err(CertmeshError::PromotionFailed(_))));
}
#[test]
fn promotion_dh_wrong_keypair_fails() {
let ca = make_test_ca();
let totp = koi_crypto::totp::generate_secret();
let auth_state = AuthState::Totp(totp);
let roster = make_test_roster();
let client_kp = koi_crypto::key_agreement::EphemeralKeyPair::generate();
let client_pub = client_kp.public_key_bytes();
let response = prepare_promotion(&ca, &auth_state, &roster, &client_pub).unwrap();
let wrong_kp = koi_crypto::key_agreement::EphemeralKeyPair::generate();
let result = accept_promotion(&response, wrong_kp);
assert!(matches!(result, Err(CertmeshError::PromotionFailed(_))));
}
#[test]
fn manifest_sign_verify_round_trip() {
let ca = make_test_ca();
let roster = make_test_roster();
let manifest = build_signed_manifest(&ca, &roster).unwrap();
assert!(!manifest.signature.is_empty());
assert!(!manifest.ca_public_key.is_empty());
let verified_roster = verify_manifest(&manifest).unwrap();
assert_eq!(verified_roster.members.len(), 1);
assert_eq!(verified_roster.members[0].hostname, "stone-01");
}
#[test]
fn tampered_manifest_fails_verification() {
let ca = make_test_ca();
let roster = make_test_roster();
let mut manifest = build_signed_manifest(&ca, &roster).unwrap();
manifest.roster_json = manifest.roster_json.replace("stone-01", "evil-host");
let result = verify_manifest(&manifest);
assert!(matches!(result, Err(CertmeshError::InvalidManifest)));
}
#[test]
fn wrong_key_manifest_fails_verification() {
let ca1 = make_test_ca();
let (ca2, _) = ca::create_ca("other-pass", &[99u8; 32], &test_paths()).unwrap();
let roster = make_test_roster();
let mut manifest = build_signed_manifest(&ca1, &roster).unwrap();
manifest.ca_public_key = ca2.key.public_key_pem().unwrap();
let result = verify_manifest(&manifest);
assert!(matches!(result, Err(CertmeshError::InvalidManifest)));
}
#[test]
fn should_promote_false_when_no_absence() {
assert!(!should_promote(None, Duration::from_secs(60)));
}
#[test]
fn should_promote_false_within_grace() {
let since = Instant::now();
assert!(!should_promote(Some(since), Duration::from_secs(60)));
}
#[test]
fn should_promote_true_after_grace() {
let since = Instant::now() - Duration::from_secs(1);
assert!(should_promote(Some(since), Duration::from_secs(0)));
}
#[test]
fn tiebreaker_lower_hostname_wins() {
assert!(tiebreaker_wins("alpha", "bravo"));
assert!(!tiebreaker_wins("bravo", "alpha"));
assert!(!tiebreaker_wins("alpha", "alpha")); }
#[test]
fn tiebreaker_is_case_sensitive() {
assert!(tiebreaker_wins("Alpha", "alpha"));
}
#[test]
fn find_active_primary_matches_fingerprint() {
let fp = "abc123";
let mut txt = HashMap::new();
txt.insert("role".to_string(), "primary".to_string());
txt.insert("fingerprint".to_string(), fp.to_string());
let services = vec![("stone-01.local".to_string(), 5641u16, txt)];
let result = find_active_primary(fp, &services);
assert_eq!(result.as_deref(), Some("stone-01.local:5641"));
}
#[test]
fn find_active_primary_skips_standby() {
let fp = "abc123";
let mut txt = HashMap::new();
txt.insert("role".to_string(), "standby".to_string());
txt.insert("fingerprint".to_string(), fp.to_string());
let services = vec![("stone-02.local".to_string(), 5641u16, txt)];
let result = find_active_primary(fp, &services);
assert!(result.is_none());
}
#[test]
fn find_active_primary_wrong_fingerprint() {
let mut txt = HashMap::new();
txt.insert("role".to_string(), "primary".to_string());
txt.insert("fingerprint".to_string(), "wrong-fp".to_string());
let services = vec![("stone-01.local".to_string(), 5641u16, txt)];
let result = find_active_primary("correct-fp", &services);
assert!(result.is_none());
}
#[test]
fn find_active_primary_empty_services() {
let result = find_active_primary("abc123", &[]);
assert!(result.is_none());
}
#[test]
fn promotion_dh_preserves_roster_metadata() {
let ca = make_test_ca();
let totp = koi_crypto::totp::generate_secret();
let auth = koi_crypto::auth::AuthState::Totp(totp);
let mut roster = make_test_roster();
roster.metadata.operator = Some("ops-team".to_string());
let client_kp = koi_crypto::key_agreement::EphemeralKeyPair::generate();
let client_pub = client_kp.public_key_bytes();
let response = prepare_promotion(&ca, &auth, &roster, &client_pub).unwrap();
let (_, _, accepted_roster) = accept_promotion(&response, client_kp).unwrap();
assert_eq!(
accepted_roster.metadata.operator.as_deref(),
Some("ops-team")
);
assert_eq!(
accepted_roster.metadata.trust_profile,
roster.metadata.trust_profile
);
}
#[test]
fn promotion_dh_with_empty_roster() {
let ca = make_test_ca();
let totp = koi_crypto::totp::generate_secret();
let auth = koi_crypto::auth::AuthState::Totp(totp);
let roster = Roster::new(TrustProfile::JustMe, None);
assert!(roster.members.is_empty());
let client_kp = koi_crypto::key_agreement::EphemeralKeyPair::generate();
let client_pub = client_kp.public_key_bytes();
let response = prepare_promotion(&ca, &auth, &roster, &client_pub).unwrap();
let (_, _, accepted_roster) = accept_promotion(&response, client_kp).unwrap();
assert!(accepted_roster.members.is_empty());
}
#[test]
fn manifest_with_empty_roster() {
let ca = make_test_ca();
let roster = Roster::new(TrustProfile::JustMe, None);
let manifest = build_signed_manifest(&ca, &roster).unwrap();
let verified = verify_manifest(&manifest).unwrap();
assert!(verified.members.is_empty());
}
#[test]
fn manifest_with_multiple_members() {
let ca = make_test_ca();
let mut roster = make_test_roster();
roster.members.push(RosterMember {
hostname: "stone-02".to_string(),
role: MemberRole::Standby,
enrolled_at: Utc::now(),
enrolled_by: None,
cert_fingerprint: "fp-def".to_string(),
cert_expires: Utc::now(),
cert_sans: vec!["stone-02".to_string()],
cert_path: String::new(),
status: MemberStatus::Active,
reload_hook: None,
last_seen: None,
pinned_ca_fingerprint: None,
proxy_entries: Vec::new(),
});
roster.members.push(RosterMember {
hostname: "stone-03".to_string(),
role: MemberRole::Member,
enrolled_at: Utc::now(),
enrolled_by: None,
cert_fingerprint: "fp-ghi".to_string(),
cert_expires: Utc::now(),
cert_sans: vec!["stone-03".to_string()],
cert_path: String::new(),
status: MemberStatus::Active,
reload_hook: None,
last_seen: None,
pinned_ca_fingerprint: None,
proxy_entries: Vec::new(),
});
let manifest = build_signed_manifest(&ca, &roster).unwrap();
let verified = verify_manifest(&manifest).unwrap();
assert_eq!(verified.members.len(), 3);
}
#[test]
fn manifest_tampered_signature_fails() {
let ca = make_test_ca();
let roster = make_test_roster();
let mut manifest = build_signed_manifest(&ca, &roster).unwrap();
if let Some(byte) = manifest.signature.first_mut() {
*byte ^= 0xFF;
}
assert!(matches!(
verify_manifest(&manifest),
Err(CertmeshError::InvalidManifest)
));
}
#[test]
fn manifest_empty_signature_fails() {
let ca = make_test_ca();
let roster = make_test_roster();
let mut manifest = build_signed_manifest(&ca, &roster).unwrap();
manifest.signature = vec![];
assert!(matches!(
verify_manifest(&manifest),
Err(CertmeshError::InvalidManifest)
));
}
#[test]
fn manifest_empty_public_key_fails() {
let ca = make_test_ca();
let roster = make_test_roster();
let mut manifest = build_signed_manifest(&ca, &roster).unwrap();
manifest.ca_public_key = String::new();
assert!(matches!(
verify_manifest(&manifest),
Err(CertmeshError::InvalidManifest)
));
}
#[test]
fn should_promote_at_exact_boundary() {
let grace = Duration::from_millis(50);
let since = Instant::now() - Duration::from_millis(60);
assert!(should_promote(Some(since), grace));
}
#[test]
fn should_promote_with_zero_grace() {
let since = Instant::now();
assert!(should_promote(Some(since), Duration::ZERO));
}
#[test]
fn tiebreaker_with_numeric_hostnames() {
assert!(tiebreaker_wins("1", "2"));
assert!(tiebreaker_wins("10", "2"));
}
#[test]
fn tiebreaker_with_empty_hostname() {
assert!(tiebreaker_wins("", "any"));
assert!(!tiebreaker_wins("any", ""));
}
#[test]
fn tiebreaker_with_common_prefixes() {
assert!(tiebreaker_wins("node-01", "node-02"));
assert!(!tiebreaker_wins("node-02", "node-01"));
}
#[test]
fn find_active_primary_multiple_primaries_returns_first() {
let fp = "abc123";
let mut txt1 = HashMap::new();
txt1.insert("role".to_string(), "primary".to_string());
txt1.insert("fingerprint".to_string(), fp.to_string());
let mut txt2 = HashMap::new();
txt2.insert("role".to_string(), "primary".to_string());
txt2.insert("fingerprint".to_string(), fp.to_string());
let services = vec![
("stone-01.local".to_string(), 5641u16, txt1),
("stone-02.local".to_string(), 5642u16, txt2),
];
let result = find_active_primary(fp, &services);
assert_eq!(result.as_deref(), Some("stone-01.local:5641"));
}
#[test]
fn find_active_primary_missing_role_key() {
let fp = "abc123";
let mut txt = HashMap::new();
txt.insert("fingerprint".to_string(), fp.to_string());
let services = vec![("stone-01.local".to_string(), 5641u16, txt)];
assert!(find_active_primary(fp, &services).is_none());
}
#[test]
fn find_active_primary_missing_fingerprint_key() {
let mut txt = HashMap::new();
txt.insert("role".to_string(), "primary".to_string());
let services = vec![("stone-01.local".to_string(), 5641u16, txt)];
assert!(find_active_primary("abc123", &services).is_none());
}
#[test]
fn find_active_primary_mixed_roles() {
let fp = "abc123";
let mut txt_standby = HashMap::new();
txt_standby.insert("role".to_string(), "standby".to_string());
txt_standby.insert("fingerprint".to_string(), fp.to_string());
let mut txt_member = HashMap::new();
txt_member.insert("role".to_string(), "member".to_string());
txt_member.insert("fingerprint".to_string(), fp.to_string());
let mut txt_primary = HashMap::new();
txt_primary.insert("role".to_string(), "primary".to_string());
txt_primary.insert("fingerprint".to_string(), fp.to_string());
let services = vec![
("standby.local".to_string(), 5641u16, txt_standby),
("member.local".to_string(), 5641u16, txt_member),
("primary.local".to_string(), 5641u16, txt_primary),
];
let result = find_active_primary(fp, &services);
assert_eq!(result.as_deref(), Some("primary.local:5641"));
}
}