Skip to main content

oo7/
secret.rs

1use std::str::FromStr;
2
3use serde::{Deserialize, Serialize};
4use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing};
5
6#[derive(Default, PartialEq, Eq, Copy, Clone, Debug, zvariant::Type)]
7#[zvariant(signature = "s")]
8pub enum ContentType {
9    Text,
10    #[default]
11    Blob,
12}
13
14impl Serialize for ContentType {
15    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
16    where
17        S: serde::Serializer,
18    {
19        self.as_str().serialize(serializer)
20    }
21}
22
23impl<'de> Deserialize<'de> for ContentType {
24    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
25    where
26        D: serde::Deserializer<'de>,
27    {
28        let s = String::deserialize(deserializer)?;
29        Self::from_str(&s).map_err(serde::de::Error::custom)
30    }
31}
32
33impl FromStr for ContentType {
34    type Err = String;
35
36    fn from_str(s: &str) -> Result<Self, Self::Err> {
37        // MIME types may include parameters, which are irrelevant to ContentType.
38        let media_type = s
39            .split_once(';')
40            .map_or(s, |(media_type, _)| media_type)
41            .trim();
42
43        if media_type.eq_ignore_ascii_case("text/plain")
44            || media_type.eq_ignore_ascii_case("text/utf8")
45        {
46            Ok(Self::Text)
47        } else if media_type.eq_ignore_ascii_case("application/octet-stream") {
48            Ok(Self::Blob)
49        } else {
50            Err(format!("Invalid content type: {s}"))
51        }
52    }
53}
54
55impl ContentType {
56    pub const fn as_str(&self) -> &'static str {
57        match self {
58            Self::Text => "text/plain",
59            Self::Blob => "application/octet-stream",
60        }
61    }
62}
63
64/// A wrapper around a combination of (secret, content-type).
65#[derive(Clone, PartialEq, Eq, Zeroize, ZeroizeOnDrop)]
66pub enum Secret {
67    /// Corresponds to [`ContentType::Text`]
68    Text(String),
69    /// Corresponds to [`ContentType::Blob`]
70    Blob(Vec<u8>),
71}
72
73impl std::fmt::Debug for Secret {
74    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75        match self {
76            Self::Text(_) => write!(f, "Secret::Text([REDACTED])"),
77            Self::Blob(_) => write!(f, "Secret::Blob([REDACTED])"),
78        }
79    }
80}
81
82impl Secret {
83    /// Generate a random secret, used when creating a session collection.
84    pub fn random() -> Result<Self, getrandom::Error> {
85        let mut secret = [0; 64];
86        // Equivalent of `ring::rand::SecureRandom`
87        getrandom::fill(&mut secret)?;
88
89        Ok(Self::blob(secret))
90    }
91
92    /// Get the sandboxed app secret if the app is sandboxed using
93    /// org.freedesktop.portal.Secret portal.
94    pub async fn sandboxed() -> Result<Self, crate::file::Error> {
95        Ok(Self::blob(
96            ashpd::desktop::secret::retrieve()
97                .await
98                .map_err(crate::file::Error::from)?,
99        ))
100    }
101
102    /// Create a text secret, stored with `text/plain` content type.
103    pub fn text(value: impl AsRef<str>) -> Self {
104        Self::Text(value.as_ref().to_owned())
105    }
106
107    /// Create a blob secret, stored with `application/octet-stream` content
108    /// type.
109    pub fn blob(value: impl AsRef<[u8]>) -> Self {
110        Self::Blob(value.as_ref().to_owned())
111    }
112
113    pub const fn content_type(&self) -> ContentType {
114        match self {
115            Self::Text(_) => ContentType::Text,
116            Self::Blob(_) => ContentType::Blob,
117        }
118    }
119
120    /// Returns the secret as a string slice, or `None` if it is not valid
121    /// UTF-8.
122    pub fn as_str(&self) -> Option<&str> {
123        match self {
124            Self::Text(text) => Some(text.as_str()),
125            Self::Blob(bytes) => std::str::from_utf8(bytes).ok(),
126        }
127    }
128
129    pub fn as_bytes(&self) -> &[u8] {
130        match self {
131            Self::Text(text) => text.as_bytes(),
132            Self::Blob(bytes) => bytes.as_ref(),
133        }
134    }
135
136    pub fn with_content_type(content_type: ContentType, secret: impl AsRef<[u8]>) -> Self {
137        match content_type {
138            ContentType::Text => match String::from_utf8(secret.as_ref().to_owned()) {
139                Ok(text) => Secret::text(text),
140                Err(_e) => {
141                    #[cfg(feature = "tracing")]
142                    tracing::warn!(
143                        "Failed to decode secret as UTF-8: {}, falling back to blob",
144                        _e
145                    );
146
147                    Secret::blob(secret)
148                }
149            },
150            _ => Secret::blob(secret),
151        }
152    }
153}
154
155impl From<&[u8]> for Secret {
156    fn from(value: &[u8]) -> Self {
157        Self::blob(value)
158    }
159}
160
161impl From<Zeroizing<Vec<u8>>> for Secret {
162    fn from(value: Zeroizing<Vec<u8>>) -> Self {
163        Self::blob(value)
164    }
165}
166
167impl From<Vec<u8>> for Secret {
168    fn from(value: Vec<u8>) -> Self {
169        Self::blob(value)
170    }
171}
172
173impl From<&Vec<u8>> for Secret {
174    fn from(value: &Vec<u8>) -> Self {
175        Self::blob(value)
176    }
177}
178
179impl<const N: usize> From<&[u8; N]> for Secret {
180    fn from(value: &[u8; N]) -> Self {
181        Self::blob(value)
182    }
183}
184
185impl From<String> for Secret {
186    fn from(value: String) -> Self {
187        Self::text(value)
188    }
189}
190
191impl From<&str> for Secret {
192    fn from(value: &str) -> Self {
193        Self::text(value)
194    }
195}
196
197impl std::ops::Deref for Secret {
198    type Target = [u8];
199
200    fn deref(&self) -> &Self::Target {
201        self.as_bytes()
202    }
203}
204
205impl AsRef<[u8]> for Secret {
206    fn as_ref(&self) -> &[u8] {
207        self.as_bytes()
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use zvariant::{Endian, serialized::Context, to_bytes};
214
215    use super::*;
216
217    #[test]
218    fn secret_debug_is_redacted() {
219        let text_secret = Secret::text("password");
220        let blob_secret = Secret::blob([1, 2, 3]);
221
222        assert_eq!(format!("{:?}", text_secret), "Secret::Text([REDACTED])");
223        assert_eq!(format!("{:?}", blob_secret), "Secret::Blob([REDACTED])");
224    }
225
226    #[test]
227    fn content_type_serialization() {
228        let ctxt = Context::new_dbus(Endian::Little, 0);
229
230        // Test Text serialization
231        let encoded = to_bytes(ctxt, &ContentType::Text).unwrap();
232        let value: String = encoded.deserialize().unwrap().0;
233        assert_eq!(value, "text/plain");
234
235        // Test Blob serialization
236        let encoded = to_bytes(ctxt, &ContentType::Blob).unwrap();
237        let value: String = encoded.deserialize().unwrap().0;
238        assert_eq!(value, "application/octet-stream");
239
240        // Test Text deserialization
241        let encoded = to_bytes(ctxt, &"text/plain").unwrap();
242        let content_type: ContentType = encoded.deserialize().unwrap().0;
243        assert_eq!(content_type, ContentType::Text);
244
245        // Test Text deserialization with MIME parameters
246        let encoded = to_bytes(ctxt, &"text/plain; charset=utf8").unwrap();
247        let content_type: ContentType = encoded.deserialize().unwrap().0;
248        assert_eq!(content_type, ContentType::Text);
249
250        // Test Blob deserialization
251        let encoded = to_bytes(ctxt, &"application/octet-stream").unwrap();
252        let content_type: ContentType = encoded.deserialize().unwrap().0;
253        assert_eq!(content_type, ContentType::Blob);
254
255        // Test invalid content type deserialization
256        let encoded = to_bytes(ctxt, &"invalid/type").unwrap();
257        let result: Result<(ContentType, _), _> = encoded.deserialize();
258        assert!(result.is_err());
259        assert!(
260            result
261                .unwrap_err()
262                .to_string()
263                .contains("Invalid content type")
264        );
265    }
266
267    #[test]
268    fn content_type_from_str() {
269        for content_type in [
270            "text/plain",
271            "text/plain; charset=utf8",
272            "TEXT/PLAIN; CHARSET=UTF-8",
273        ] {
274            assert_eq!(
275                ContentType::from_str(content_type).unwrap(),
276                ContentType::Text
277            );
278        }
279
280        for content_type in [
281            "application/octet-stream",
282            "application/octet-stream; version=1",
283        ] {
284            assert_eq!(
285                ContentType::from_str(content_type).unwrap(),
286                ContentType::Blob
287            );
288        }
289
290        // Test error case
291        let result = ContentType::from_str("text/html; charset=utf8");
292        assert!(result.is_err());
293        let error = result.unwrap_err();
294        assert!(error.contains("Invalid content type"));
295        assert!(error.contains("text/html; charset=utf8"));
296    }
297
298    #[test]
299    fn invalid_utf8() {
300        // Test with invalid UTF-8 bytes
301        let invalid_utf8 = vec![0xFF, 0xFE, 0xFD];
302
303        // Should fall back to blob when UTF-8 decoding fails
304        let secret = Secret::with_content_type(ContentType::Text, &invalid_utf8);
305        assert_eq!(secret.content_type(), ContentType::Blob);
306        assert_eq!(&*secret, &[0xFF, 0xFE, 0xFD]);
307
308        // Test with valid UTF-8
309        let valid_utf8 = "Hello, World!";
310        let secret = Secret::with_content_type(ContentType::Text, valid_utf8.as_bytes());
311        assert_eq!(secret.content_type(), ContentType::Text);
312        assert_eq!(&*secret, valid_utf8.as_bytes());
313
314        // Test with blob content type
315        let data = vec![1, 2, 3, 4];
316        let secret = Secret::with_content_type(ContentType::Blob, &data);
317        assert_eq!(secret.content_type(), ContentType::Blob);
318        assert_eq!(&*secret, &[1, 2, 3, 4]);
319    }
320
321    #[test]
322    fn random() {
323        let secret1 = Secret::random().unwrap();
324        let secret2 = Secret::random().unwrap();
325
326        // Random secrets should be blobs
327        assert_eq!(secret1.content_type(), ContentType::Blob);
328        assert_eq!(secret2.content_type(), ContentType::Blob);
329
330        // Should be 64 bytes
331        assert_eq!(secret1.as_bytes().len(), 64);
332        assert_eq!(secret2.as_bytes().len(), 64);
333
334        // Should be different
335        assert_ne!(secret1.as_bytes(), secret2.as_bytes());
336    }
337}