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<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 #[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}