Skip to main content

mur_common/trust/
rotation.rs

1//! Key rotation manifest support (ยง7.1.1).
2//!
3//! Signed by both old and new keys. Stored at `~/.mur/trust/rotations/<fingerprint>.rotation`.
4
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct RotationManifest {
9    pub old_pubkey: String,
10    pub new_pubkey: String,
11    pub issued_at: String,
12    pub sig_old: String,
13    pub sig_new: String,
14}
15
16impl RotationManifest {
17    /// Verify both signatures on the rotation manifest.
18    pub fn verify(&self) -> Result<(), String> {
19        use base64::{Engine, engine::general_purpose::STANDARD as B64};
20        use ed25519_dalek::{Signature, VerifyingKey};
21
22        let message = format!("{}{}{}", self.old_pubkey, self.new_pubkey, self.issued_at);
23        let msg_bytes = message.as_bytes();
24
25        let old_pk_bytes: [u8; 32] = B64
26            .decode(&self.old_pubkey)
27            .map_err(|e| format!("old_pubkey b64: {e}"))?
28            .try_into()
29            .map_err(|_| "old_pubkey not 32 bytes".to_string())?;
30        let old_vk = VerifyingKey::from_bytes(&old_pk_bytes)
31            .map_err(|e| format!("old_pubkey decode: {e}"))?;
32        let old_sig_bytes: [u8; 64] = B64
33            .decode(&self.sig_old)
34            .map_err(|e| format!("sig_old b64: {e}"))?
35            .try_into()
36            .map_err(|_| "sig_old not 64 bytes".to_string())?;
37        let old_sig = Signature::from_bytes(&old_sig_bytes);
38        old_vk
39            .verify_strict(msg_bytes, &old_sig)
40            .map_err(|e| format!("old key signature: {e}"))?;
41
42        let new_pk_bytes: [u8; 32] = B64
43            .decode(&self.new_pubkey)
44            .map_err(|e| format!("new_pubkey b64: {e}"))?
45            .try_into()
46            .map_err(|_| "new_pubkey not 32 bytes".to_string())?;
47        let new_vk = VerifyingKey::from_bytes(&new_pk_bytes)
48            .map_err(|e| format!("new_pubkey decode: {e}"))?;
49        let new_sig_bytes: [u8; 64] = B64
50            .decode(&self.sig_new)
51            .map_err(|e| format!("sig_new b64: {e}"))?
52            .try_into()
53            .map_err(|_| "sig_new not 64 bytes".to_string())?;
54        let new_sig = Signature::from_bytes(&new_sig_bytes);
55        new_vk
56            .verify_strict(msg_bytes, &new_sig)
57            .map_err(|e| format!("new key signature: {e}"))?;
58
59        Ok(())
60    }
61}
62
63#[cfg(test)]
64mod tests {
65    use super::*;
66    use base64::{Engine, engine::general_purpose::STANDARD as B64};
67    use ed25519_dalek::{Signer, SigningKey};
68
69    fn make_signed(old: &SigningKey, new: &SigningKey, issued_at: &str) -> RotationManifest {
70        let old_pk = B64.encode(old.verifying_key().as_bytes());
71        let new_pk = B64.encode(new.verifying_key().as_bytes());
72        let msg = format!("{}{}{}", old_pk, new_pk, issued_at);
73        let sig_old = B64.encode(old.sign(msg.as_bytes()).to_bytes());
74        let sig_new = B64.encode(new.sign(msg.as_bytes()).to_bytes());
75        RotationManifest {
76            old_pubkey: old_pk,
77            new_pubkey: new_pk,
78            issued_at: issued_at.to_string(),
79            sig_old,
80            sig_new,
81        }
82    }
83
84    #[test]
85    fn valid_rotation_verifies() {
86        let old = SigningKey::from_bytes(&[1u8; 32]);
87        let new = SigningKey::from_bytes(&[2u8; 32]);
88        let manifest = make_signed(&old, &new, "2026-05-20T12:00:00Z");
89        manifest.verify().unwrap();
90    }
91
92    #[test]
93    fn tampered_timestamp_rejected() {
94        let old = SigningKey::from_bytes(&[1u8; 32]);
95        let new = SigningKey::from_bytes(&[2u8; 32]);
96        let mut manifest = make_signed(&old, &new, "2026-05-20T12:00:00Z");
97        manifest.issued_at = "2025-01-01T00:00:00Z".into();
98        assert!(manifest.verify().is_err());
99    }
100
101    #[test]
102    fn missing_new_key_signature_rejected() {
103        let old = SigningKey::from_bytes(&[1u8; 32]);
104        let new = SigningKey::from_bytes(&[2u8; 32]);
105        let attacker = SigningKey::from_bytes(&[99u8; 32]);
106        // Old signs the legitimate message but new is replaced with attacker's signature
107        let mut manifest = make_signed(&old, &new, "2026-05-20T12:00:00Z");
108        let attacker_sig = attacker.sign(
109            format!(
110                "{}{}{}",
111                manifest.old_pubkey, manifest.new_pubkey, manifest.issued_at
112            )
113            .as_bytes(),
114        );
115        manifest.sig_new = B64.encode(attacker_sig.to_bytes());
116        assert!(manifest.verify().is_err());
117    }
118}