super_types/
credential.rs1use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
4use uuid::Uuid;
5
6#[derive(Clone, Debug, PartialEq, Eq)]
8pub struct ApiBase(String);
9
10impl ApiBase {
11 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#[derive(Clone, Debug, PartialEq, Eq)]
52pub struct RoutedKey {
53 pub id: Uuid,
54 pub api_base: ApiBase,
55}
56
57impl RoutedKey {
58 pub fn parse(value: &str) -> Result<Option<Self>, &'static str> {
63 if !value.starts_with("super_v") && !value.starts_with("pup_v") {
64 return Ok(None);
65 }
66 let invalid = "the Schematic API key is malformed or uses an unsupported version";
67 if value.len() > 1024 {
68 return Err(invalid);
69 }
70 let mut fields = value.split('.');
71 if !matches!(fields.next(), Some("super_v2" | "pup_v2")) {
72 return Err(invalid);
73 }
74 let id = fields.next().ok_or(invalid)?;
75 let encoded = fields.next().ok_or(invalid)?;
76 let secret = fields.next().ok_or(invalid)?;
77 let lower_hex = |s: &str| s.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
78 if fields.next().is_some() || id.len() != 32 || !lower_hex(id) || secret.len() != 64 || !lower_hex(secret) {
79 return Err(invalid);
80 }
81 let origin = URL_SAFE_NO_PAD.decode(encoded).map_err(|_| invalid)?;
82 let origin = std::str::from_utf8(&origin).map_err(|_| invalid)?;
83 let api_base = ApiBase::parse(origin).map_err(|_| invalid)?;
84 if api_base.as_str() != origin {
85 return Err(invalid);
86 }
87 Ok(Some(Self {
88 id: Uuid::parse_str(id).map_err(|_| invalid)?,
89 api_base,
90 }))
91 }
92
93 #[must_use]
95 pub fn encode(&self, secret: &[u8; 32]) -> String {
96 format!(
97 "super_v2.{}.{}.{}",
98 self.id.simple(),
99 URL_SAFE_NO_PAD.encode(self.api_base.as_str()),
100 hex::encode(secret)
101 )
102 }
103}
104
105#[cfg(test)]
106mod tests {
107 use super::*;
108
109 #[test]
110 fn keys_round_trip_for_separate_environments() {
111 for origin in [
112 "https://api.schematic.tech",
113 "https://api.staging.schematic.tech",
114 "http://127.0.0.1:8080",
115 ] {
116 let metadata = RoutedKey {
117 id: Uuid::new_v4(),
118 api_base: ApiBase::parse(origin).unwrap(),
119 };
120 let key = metadata.encode(&[0xab; 32]);
121 assert_eq!(RoutedKey::parse(&key).unwrap(), Some(metadata.clone()));
122 let legacy = key.replacen("super_v2.", "pup_v2.", 1);
123 assert_eq!(RoutedKey::parse(&legacy).unwrap(), Some(metadata));
124 assert!(RoutedKey::parse(&format!("{key}.extra")).is_err());
125 assert!(RoutedKey::parse(&key[..key.len() - 1]).is_err());
126 assert!(!format!("{:?}", RoutedKey::parse(&key).unwrap()).contains(&hex::encode([0xab; 32])));
127 }
128 assert!(RoutedKey::parse("pup_live_legacy").unwrap().is_none());
129 assert!(RoutedKey::parse("hydra_live_legacy").unwrap().is_none());
130 assert!(RoutedKey::parse("pup_v3.unknown").is_err());
131 assert!(RoutedKey::parse("super_v3.unknown").is_err());
132 }
133
134 #[test]
135 fn origins_reject_credential_leaks_and_are_canonical() {
136 for value in [
137 "http://remote.test",
138 "https://user:password@api.test",
139 "https://api.test/path",
140 "https://api.test?token=secret",
141 "https://api.test/#fragment",
142 " https://api.test",
143 "https://api.test\\path",
144 "file:///secret",
145 ] {
146 assert!(ApiBase::parse(value).is_err(), "{value}");
147 }
148 assert_eq!(
149 ApiBase::parse("https://API.TEST:443/").unwrap().as_str(),
150 "https://api.test"
151 );
152 assert!(ApiBase::parse("http://[::1]:8080").is_ok());
153 }
154}