Skip to main content

super_types/
credential.rs

1//! Environment routing carried by a Schematic API key. Decoding is not authentication.
2
3use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
4use uuid::Uuid;
5
6/// A canonical API origin. Public Super routes are appended by the client.
7#[derive(Clone, Debug, PartialEq, Eq)]
8pub struct ApiBase(String);
9
10impl ApiBase {
11    /// Validates an origin before it can receive credentials. HTTP is loopback-only.
12    ///
13    /// # Errors
14    /// Returns a fixed message without including the input or any credentials.
15    pub fn parse(value: &str) -> Result<Self, &'static str> {
16        if value.len() > 512 || value.bytes().any(|b| b.is_ascii_whitespace() || b.is_ascii_control()) {
17            return Err("the Super API URL is invalid");
18        }
19        let url = url::Url::parse(value).map_err(|_| "the Super API URL is invalid")?;
20        if !matches!(url.scheme(), "http" | "https") || url.host().is_none() {
21            return Err("the Super API URL must be an HTTP or HTTPS origin");
22        }
23        if !url.username().is_empty()
24            || url.password().is_some()
25            || url.path() != "/"
26            || url.query().is_some()
27            || url.fragment().is_some()
28            || value.contains('\\')
29        {
30            return Err("the Super API URL must be an origin without credentials, path, query, or fragment");
31        }
32        let loopback = match url.host() {
33            Some(url::Host::Domain("localhost")) => true,
34            Some(url::Host::Ipv4(address)) => address.is_loopback(),
35            Some(url::Host::Ipv6(address)) => address.is_loopback(),
36            _ => false,
37        };
38        if url.scheme() == "http" && !loopback {
39            return Err("the Super API URL must use HTTPS outside localhost development");
40        }
41        Ok(Self(url.origin().ascii_serialization()))
42    }
43
44    #[must_use]
45    pub fn as_str(&self) -> &str {
46        &self.0
47    }
48}
49
50/// Only non-secret metadata is returned. The original key remains the bearer credential.
51#[derive(Clone, Debug, PartialEq, Eq)]
52pub struct RoutedKey {
53    pub id: Uuid,
54    pub api_base: ApiBase,
55}
56
57impl RoutedKey {
58    /// Parses current and previously issued routed API keys.
59    ///
60    /// # Errors
61    /// Rejects malformed or unsupported versioned keys without echoing their contents.
62    pub fn parse(value: &str) -> Result<Self, &'static str> {
63        let invalid = "the Schematic API key is malformed or uses an unsupported version";
64        if value.len() > 1024 {
65            return Err(invalid);
66        }
67        let mut fields = value.split('.');
68        if !matches!(fields.next(), Some("super_v2" | "pup_v2")) {
69            return Err(invalid);
70        }
71        let id = fields.next().ok_or(invalid)?;
72        let encoded = fields.next().ok_or(invalid)?;
73        let secret = fields.next().ok_or(invalid)?;
74        let lower_hex = |s: &str| s.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
75        if fields.next().is_some() || id.len() != 32 || !lower_hex(id) || secret.len() != 64 || !lower_hex(secret) {
76            return Err(invalid);
77        }
78        let origin = URL_SAFE_NO_PAD.decode(encoded).map_err(|_| invalid)?;
79        let origin = std::str::from_utf8(&origin).map_err(|_| invalid)?;
80        let api_base = ApiBase::parse(origin).map_err(|_| invalid)?;
81        if api_base.as_str() != origin {
82            return Err(invalid);
83        }
84        Ok(Self {
85            id: Uuid::parse_str(id).map_err(|_| invalid)?,
86            api_base,
87        })
88    }
89
90    /// Encodes a key using 256 independently generated random secret bits supplied by the issuer.
91    #[must_use]
92    pub fn encode(&self, secret: &[u8; 32]) -> String {
93        format!(
94            "super_v2.{}.{}.{}",
95            self.id.simple(),
96            URL_SAFE_NO_PAD.encode(self.api_base.as_str()),
97            hex::encode(secret)
98        )
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    #[test]
107    fn keys_round_trip_for_separate_environments() {
108        for origin in [
109            "https://api.schematic.tech",
110            "https://api.staging.schematic.tech",
111            "http://127.0.0.1:8080",
112        ] {
113            let metadata = RoutedKey {
114                id: Uuid::new_v4(),
115                api_base: ApiBase::parse(origin).unwrap(),
116            };
117            let key = metadata.encode(&[0xab; 32]);
118            assert_eq!(RoutedKey::parse(&key).unwrap(), metadata.clone());
119            let legacy = key.replacen("super_v2.", "pup_v2.", 1);
120            assert_eq!(RoutedKey::parse(&legacy).unwrap(), metadata);
121            assert!(RoutedKey::parse(&format!("{key}.extra")).is_err());
122            assert!(RoutedKey::parse(&key[..key.len() - 1]).is_err());
123            assert!(!format!("{:?}", RoutedKey::parse(&key).unwrap()).contains(&hex::encode([0xab; 32])));
124        }
125        assert!(RoutedKey::parse("pup_live_legacy").is_err());
126        assert!(RoutedKey::parse("hydra_live_legacy").is_err());
127        assert!(RoutedKey::parse("pup_v3.unknown").is_err());
128        assert!(RoutedKey::parse("super_v3.unknown").is_err());
129    }
130
131    #[test]
132    fn origins_reject_credential_leaks_and_are_canonical() {
133        for value in [
134            "http://remote.test",
135            "https://user:password@api.test",
136            "https://api.test/path",
137            "https://api.test?token=secret",
138            "https://api.test/#fragment",
139            " https://api.test",
140            "https://api.test\\path",
141            "file:///secret",
142        ] {
143            assert!(ApiBase::parse(value).is_err(), "{value}");
144        }
145        assert_eq!(
146            ApiBase::parse("https://API.TEST:443/").unwrap().as_str(),
147            "https://api.test"
148        );
149        assert!(ApiBase::parse("http://[::1]:8080").is_ok());
150    }
151}