Skip to main content

stack_auth/
access_key.rs

1use std::str::FromStr;
2
3use crate::SecretToken;
4use vitaminc::protected::OpaqueDebug;
5
6/// The prefix that all CipherStash access keys start with.
7const ACCESS_KEY_PREFIX: &str = "CSAK";
8
9/// A CipherStash access key.
10///
11/// Access keys have the format `CSAK<key_id>.<key_secret>` and are used to
12/// authenticate with the CipherStash Token Service (CTS).
13///
14/// The inner value is stored as a [`SecretToken`], so it is zeroized on drop
15/// and hidden from debug output.
16///
17/// # Parsing
18///
19/// ```
20/// use stack_auth::AccessKey;
21///
22/// let key: AccessKey = "CSAKmyKeyId.myKeySecret".parse().unwrap();
23/// ```
24///
25/// Invalid keys are rejected:
26///
27/// ```
28/// use stack_auth::AccessKey;
29///
30/// assert!("not-a-valid-key".parse::<AccessKey>().is_err());
31/// assert!("CSAKmissing-dot".parse::<AccessKey>().is_err());
32/// assert!("CSAK.no-key-id".parse::<AccessKey>().is_err());
33/// assert!("CSAKno-secret.".parse::<AccessKey>().is_err());
34/// ```
35#[derive(OpaqueDebug)]
36pub struct AccessKey(SecretToken);
37
38impl AccessKey {
39    /// Expose the underlying [`SecretToken`].
40    pub(crate) fn into_secret_token(self) -> SecretToken {
41        self.0
42    }
43}
44
45// NOTE: The format validation here mirrors `UnverifiedAccessKey::new()` in
46// `cts-domain`. If the `CSAK<key_id>.<key_secret>` format changes, both
47// locations must be updated.
48impl FromStr for AccessKey {
49    type Err = InvalidAccessKey;
50
51    fn from_str(s: &str) -> Result<Self, Self::Err> {
52        let rest = s
53            .strip_prefix(ACCESS_KEY_PREFIX)
54            .ok_or(InvalidAccessKey::MissingPrefix)?;
55
56        let (id, secret) = rest.split_once('.').ok_or(InvalidAccessKey::MissingDot)?;
57
58        if id.is_empty() {
59            return Err(InvalidAccessKey::EmptyKeyId);
60        }
61        if secret.is_empty() {
62            return Err(InvalidAccessKey::EmptySecret);
63        }
64
65        Ok(Self(SecretToken::new(s)))
66    }
67}
68
69/// Error returned when parsing an invalid access key string.
70#[derive(Debug, thiserror::Error)]
71pub enum InvalidAccessKey {
72    /// The string does not start with the `CSAK` prefix.
73    #[error("access key must start with \"{ACCESS_KEY_PREFIX}\"")]
74    MissingPrefix,
75    /// No `.` separator found between key ID and secret.
76    #[error("access key must contain a \".\" separator")]
77    MissingDot,
78    /// The key ID portion (before the `.`) is empty.
79    #[error("access key ID must not be empty")]
80    EmptyKeyId,
81    /// The secret portion (after the `.`) is empty.
82    #[error("access key secret must not be empty")]
83    EmptySecret,
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89
90    #[test]
91    fn valid_key() {
92        let key: AccessKey =
93            "CSAKT4ZMT2AUPXI7TCD2.ZAQRW2BWXP3Z6SHR4YG2TP3N35LLU46ZAWLR3BL5WUR4IIGA"
94                .parse()
95                .unwrap();
96        assert_eq!(
97            key.0.as_str(),
98            "CSAKT4ZMT2AUPXI7TCD2.ZAQRW2BWXP3Z6SHR4YG2TP3N35LLU46ZAWLR3BL5WUR4IIGA"
99        );
100    }
101
102    #[test]
103    fn missing_prefix() {
104        let err = "key_id.key_secret".parse::<AccessKey>().unwrap_err();
105        assert!(matches!(err, InvalidAccessKey::MissingPrefix));
106    }
107
108    #[test]
109    fn missing_dot() {
110        let err = "CSAKnodot".parse::<AccessKey>().unwrap_err();
111        assert!(matches!(err, InvalidAccessKey::MissingDot));
112    }
113
114    #[test]
115    fn empty_key_id() {
116        let err = "CSAK.secret".parse::<AccessKey>().unwrap_err();
117        assert!(matches!(err, InvalidAccessKey::EmptyKeyId));
118    }
119
120    #[test]
121    fn empty_secret() {
122        let err = "CSAKid.".parse::<AccessKey>().unwrap_err();
123        assert!(matches!(err, InvalidAccessKey::EmptySecret));
124    }
125
126    #[test]
127    fn empty_string() {
128        let err = "".parse::<AccessKey>().unwrap_err();
129        assert!(matches!(err, InvalidAccessKey::MissingPrefix));
130    }
131
132    #[test]
133    fn into_secret_token() {
134        let key: AccessKey = "CSAKmyKeyId.myKeySecret".parse().unwrap();
135        let secret = key.into_secret_token();
136        assert_eq!(secret.as_str(), "CSAKmyKeyId.myKeySecret");
137    }
138
139    #[test]
140    fn debug_does_not_leak() {
141        let key: AccessKey = "CSAKid.secret".parse().unwrap();
142        let debug = format!("{key:?}");
143        assert!(!debug.contains("secret"));
144        assert!(
145            debug.contains("AccessKey") && debug.contains("***"),
146            "debug should hide secret: {debug}"
147        );
148    }
149}