1use hmac::{Hmac, Mac};
8use sha2::Sha256;
9use subtle::ConstantTimeEq;
10
11type HmacSha256 = Hmac<Sha256>;
12
13pub const TAG_LEN: usize = 32;
15
16pub struct ClusterSecret {
21 key: Vec<u8>,
22}
23
24impl ClusterSecret {
25 pub fn from_password(password: &str) -> Self {
27 Self {
28 key: password.as_bytes().to_vec(),
29 }
30 }
31
32 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 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 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}