Skip to main content

ember_cluster/
auth.rs

1//! Shared-secret authentication for cluster transport.
2//!
3//! When a `ClusterSecret` is configured, every gossip UDP message and Raft TCP
4//! frame is authenticated with HMAC-SHA256. Unauthenticated messages are silently
5//! dropped to prevent oracle attacks.
6
7use hmac::{Hmac, Mac};
8use sha2::Sha256;
9use subtle::ConstantTimeEq;
10
11type HmacSha256 = Hmac<Sha256>;
12
13/// HMAC tag length (SHA-256 output).
14pub const TAG_LEN: usize = 32;
15
16/// A shared secret used to authenticate cluster transport messages.
17///
18/// Wraps the raw password bytes and provides sign/verify operations.
19/// The `Debug` impl redacts the secret to prevent accidental logging.
20pub struct ClusterSecret {
21    key: Vec<u8>,
22}
23
24impl ClusterSecret {
25    /// Creates a secret from a password string.
26    pub fn from_password(password: &str) -> Self {
27        Self {
28            key: password.as_bytes().to_vec(),
29        }
30    }
31
32    /// Creates a secret by reading and trimming a file.
33    pub fn from_file(path: &std::path::Path) -> Result<Self, std::io::Error> {
34        let contents = std::fs::read_to_string(path)?;
35        let password = contents.trim_end();
36        if password.is_empty() {
37            return Err(std::io::Error::new(
38                std::io::ErrorKind::InvalidData,
39                "cluster auth password file is empty",
40            ));
41        }
42        Ok(Self::from_password(password))
43    }
44
45    /// Computes an HMAC-SHA256 tag over `payload`.
46    pub fn sign(&self, payload: &[u8]) -> [u8; TAG_LEN] {
47        let mut mac = HmacSha256::new_from_slice(&self.key).expect("HMAC accepts any key length");
48        mac.update(payload);
49        mac.finalize().into_bytes().into()
50    }
51
52    /// Verifies an HMAC-SHA256 tag in constant time.
53    pub fn verify(&self, payload: &[u8], tag: &[u8]) -> bool {
54        if tag.len() != TAG_LEN {
55            return false;
56        }
57        let expected = self.sign(payload);
58        bool::from(expected.ct_eq(tag))
59    }
60}
61
62impl std::fmt::Debug for ClusterSecret {
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        f.debug_struct("ClusterSecret")
65            .field("key", &"[redacted]")
66            .finish()
67    }
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73
74    #[test]
75    fn sign_and_verify_roundtrip() {
76        let secret = ClusterSecret::from_password("test-secret");
77        let payload = b"hello cluster";
78        let tag = secret.sign(payload);
79        assert!(secret.verify(payload, &tag));
80    }
81
82    #[test]
83    fn wrong_secret_rejects() {
84        let s1 = ClusterSecret::from_password("secret-a");
85        let s2 = ClusterSecret::from_password("secret-b");
86        let tag = s1.sign(b"data");
87        assert!(!s2.verify(b"data", &tag));
88    }
89
90    #[test]
91    fn tampered_payload_rejects() {
92        let secret = ClusterSecret::from_password("test");
93        let tag = secret.sign(b"original");
94        assert!(!secret.verify(b"tampered", &tag));
95    }
96
97    #[test]
98    fn wrong_tag_length_rejects() {
99        let secret = ClusterSecret::from_password("test");
100        assert!(!secret.verify(b"data", &[0u8; 16]));
101    }
102
103    #[test]
104    fn debug_redacts_secret() {
105        let secret = ClusterSecret::from_password("super-secret");
106        let debug = format!("{:?}", secret);
107        assert!(!debug.contains("super-secret"));
108        assert!(debug.contains("redacted"));
109    }
110}