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
12pub struct WebhookSecret {
17 key: Vec<u8>,
18}
19
20impl WebhookSecret {
21 pub fn new(raw: impl Into<Vec<u8>>) -> Self {
23 Self { key: raw.into() }
24 }
25
26 pub fn generate() -> Self {
28 let mut key = vec![0u8; 24];
29 rand::fill(&mut key[..]);
30 Self { key }
31 }
32
33 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 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}