Skip to main content

modo/webhook/
secret.rs

1use std::fmt;
2use std::str::FromStr;
3
4use base64::Engine;
5use base64::engine::general_purpose::STANDARD as BASE64;
6use serde::{Deserialize, Serialize};
7
8use crate::error::{Error, Result};
9
10const PREFIX: &str = "whsec_";
11
12/// A webhook signing secret stored as raw bytes.
13///
14/// Serialized as `whsec_<base64>` strings for config files and APIs.
15/// `Debug` output is always redacted — key bytes are never printed.
16pub struct WebhookSecret {
17    key: Vec<u8>,
18}
19
20impl WebhookSecret {
21    /// Construct from raw bytes.
22    pub fn new(raw: impl Into<Vec<u8>>) -> Self {
23        Self { key: raw.into() }
24    }
25
26    /// Generate a new secret with 24 random bytes.
27    pub fn generate() -> Self {
28        let mut key = vec![0u8; 24];
29        rand::fill(&mut key[..]);
30        Self { key }
31    }
32
33    /// Access raw key bytes for HMAC operations.
34    pub fn as_bytes(&self) -> &[u8] {
35        &self.key
36    }
37}
38
39impl FromStr for WebhookSecret {
40    type Err = Error;
41
42    fn from_str(s: &str) -> Result<Self> {
43        let encoded = s
44            .strip_prefix(PREFIX)
45            .ok_or_else(|| Error::bad_request("webhook secret must start with 'whsec_'"))?;
46        let key = BASE64
47            .decode(encoded)
48            .map_err(|e| Error::bad_request(format!("invalid base64 in webhook secret: {e}")))?;
49        Ok(Self { key })
50    }
51}
52
53impl fmt::Display for WebhookSecret {
54    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55        write!(f, "{}{}", PREFIX, BASE64.encode(&self.key))
56    }
57}
58
59impl fmt::Debug for WebhookSecret {
60    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61        f.write_str("WebhookSecret(***)")
62    }
63}
64
65impl Serialize for WebhookSecret {
66    fn serialize<S: serde::Serializer>(
67        &self,
68        serializer: S,
69    ) -> std::result::Result<S::Ok, S::Error> {
70        serializer.serialize_str(&self.to_string())
71    }
72}
73
74impl<'de> Deserialize<'de> for WebhookSecret {
75    fn deserialize<D: serde::Deserializer<'de>>(
76        deserializer: D,
77    ) -> std::result::Result<Self, D::Error> {
78        let s = String::deserialize(deserializer)?;
79        s.parse().map_err(serde::de::Error::custom)
80    }
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86
87    #[test]
88    fn parse_valid_whsec_string() {
89        let raw = vec![1u8, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
90        let encoded = format!("whsec_{}", BASE64.encode(&raw));
91        let secret: WebhookSecret = encoded.parse().unwrap();
92        assert_eq!(secret.as_bytes(), &raw);
93    }
94
95    #[test]
96    fn reject_missing_prefix() {
97        let result = "notwhsec_AQIDBA==".parse::<WebhookSecret>();
98        assert!(result.is_err());
99        assert!(result.err().unwrap().message().contains("whsec_"));
100    }
101
102    #[test]
103    fn reject_invalid_base64() {
104        let result = "whsec_!!!invalid!!!".parse::<WebhookSecret>();
105        assert!(result.is_err());
106        assert!(result.err().unwrap().message().contains("base64"));
107    }
108
109    #[test]
110    fn display_roundtrip() {
111        let secret = WebhookSecret::new(vec![10, 20, 30, 40]);
112        let displayed = secret.to_string();
113        assert!(displayed.starts_with("whsec_"));
114        let parsed: WebhookSecret = displayed.parse().unwrap();
115        assert_eq!(parsed.as_bytes(), secret.as_bytes());
116    }
117
118    #[test]
119    fn debug_is_redacted() {
120        let secret = WebhookSecret::new(vec![1, 2, 3]);
121        let debug = format!("{secret:?}");
122        assert_eq!(debug, "WebhookSecret(***)");
123        assert!(!debug.contains("1"));
124    }
125
126    #[test]
127    fn generate_produces_valid_secret() {
128        let secret = WebhookSecret::generate();
129        assert_eq!(secret.as_bytes().len(), 24);
130        // Round-trip through Display/FromStr
131        let displayed = secret.to_string();
132        let parsed: WebhookSecret = displayed.parse().unwrap();
133        assert_eq!(parsed.as_bytes(), secret.as_bytes());
134    }
135
136    #[test]
137    fn serialize_roundtrip() {
138        let secret = WebhookSecret::new(vec![5, 10, 15, 20]);
139        let json = serde_json::to_string(&secret).unwrap();
140        let parsed: WebhookSecret = serde_json::from_str(&json).unwrap();
141        assert_eq!(parsed.as_bytes(), secret.as_bytes());
142    }
143
144    #[test]
145    fn deserialize_from_string() {
146        let raw = vec![99u8; 16];
147        let whsec = format!("\"whsec_{}\"", BASE64.encode(&raw));
148        let secret: WebhookSecret = serde_json::from_str(&whsec).unwrap();
149        assert_eq!(secret.as_bytes(), &raw);
150    }
151}