1use std::net::{Ipv4Addr, Ipv6Addr};
6
7use mkit_core::write_auth::validate_audience;
8
9use super::webauthn::RelyingParty;
10use super::{GrantError, OwnerScheme};
11
12#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
16pub struct AcceptedSchemes(u8);
17
18impl AcceptedSchemes {
19 pub const NONE: Self = Self(0);
21
22 fn bit(scheme: OwnerScheme) -> u8 {
23 match scheme {
24 OwnerScheme::Ed25519 => 1,
25 OwnerScheme::Secp256k1Eip191 => 1 << 1,
26 OwnerScheme::WebAuthnP256 => 1 << 2,
27 }
28 }
29
30 #[must_use]
32 pub fn of(schemes: &[OwnerScheme]) -> Self {
33 Self(schemes.iter().fold(0, |acc, s| acc | Self::bit(*s)))
34 }
35
36 pub fn from_tokens<'a>(tokens: impl IntoIterator<Item = &'a str>) -> Result<Self, GrantError> {
41 let mut out = Self::NONE;
42 for token in tokens {
43 let scheme = OwnerScheme::from_token(token).ok_or(GrantError::UnknownScheme)?;
44 out.0 |= Self::bit(scheme);
45 }
46 Ok(out)
47 }
48
49 #[must_use]
51 pub fn contains(self, scheme: OwnerScheme) -> bool {
52 self.0 & Self::bit(scheme) != 0
53 }
54
55 pub fn tokens(self) -> impl Iterator<Item = &'static str> {
57 OwnerScheme::ALL
58 .into_iter()
59 .filter(move |s| self.contains(*s))
60 .map(OwnerScheme::token)
61 }
62}
63
64#[derive(Clone, Debug, PartialEq, Eq)]
80pub struct VerifierConfig {
81 audience: String,
82 schemes: AcceptedSchemes,
83 relying_parties: Vec<RelyingParty>,
84}
85
86impl VerifierConfig {
87 pub fn new(
99 audience: &str,
100 schemes: AcceptedSchemes,
101 relying_parties: Vec<RelyingParty>,
102 ) -> Result<Self, GrantError> {
103 if is_loopback_origin(audience) {
104 validate_audience(audience).map_err(|_| GrantError::Audience)?;
105 return Err(GrantError::LoopbackAudience);
106 }
107 let cfg = Self::new_allowing_loopback(audience, schemes, relying_parties)?;
108 if cfg.relying_parties.iter().any(RelyingParty::is_loopback) {
109 return Err(GrantError::LoopbackRelyingParty);
110 }
111 Ok(cfg)
112 }
113
114 pub fn new_allowing_loopback(
125 audience: &str,
126 schemes: AcceptedSchemes,
127 relying_parties: Vec<RelyingParty>,
128 ) -> Result<Self, GrantError> {
129 validate_audience(audience).map_err(|_| GrantError::Audience)?;
130 if schemes.contains(OwnerScheme::WebAuthnP256) && relying_parties.is_empty() {
131 return Err(GrantError::NoRelyingParty);
132 }
133 for (i, rp) in relying_parties.iter().enumerate() {
134 if relying_parties[..i].iter().any(|r| r.id() == rp.id()) {
135 return Err(GrantError::RelyingParty);
136 }
137 }
138 Ok(Self {
139 audience: audience.to_owned(),
140 schemes,
141 relying_parties,
142 })
143 }
144
145 #[must_use]
148 pub fn audience(&self) -> &str {
149 &self.audience
150 }
151
152 #[must_use]
154 pub fn schemes(&self) -> AcceptedSchemes {
155 self.schemes
156 }
157
158 #[must_use]
160 pub fn relying_parties(&self) -> &[RelyingParty] {
161 &self.relying_parties
162 }
163
164 pub(crate) fn allows_relying_party(&self, rp_id: &str, origin: &str) -> bool {
166 self.relying_parties
167 .iter()
168 .any(|rp| rp.id() == rp_id && rp.allows(origin))
169 }
170}
171
172#[must_use]
184pub fn is_loopback_origin(origin: &str) -> bool {
185 let Some(authority) = origin
186 .strip_prefix("https://")
187 .or_else(|| origin.strip_prefix("http://"))
188 else {
189 return false;
190 };
191 if let Some(rest) = authority.strip_prefix('[') {
192 let Some((v6, _)) = rest.split_once(']') else {
193 return false;
194 };
195 return v6.parse::<Ipv6Addr>().is_ok_and(|ip| {
196 ip.is_loopback() || ip.is_unspecified() || ip.to_ipv4().is_some_and(ipv4_is_local)
197 });
198 }
199 let host = authority.split_once(':').map_or(authority, |(h, _)| h);
200 let host = host.to_ascii_lowercase();
201 if host == "localhost" || host.ends_with(".localhost") {
202 return true;
203 }
204 match whatwg_ipv4(&host) {
205 Ipv4Host::NotIpv4 => false,
206 Ipv4Host::Invalid => true,
207 Ipv4Host::Addr(ip) => ipv4_is_local(ip),
208 }
209}
210
211fn ipv4_is_local(ip: Ipv4Addr) -> bool {
212 matches!(ip.octets()[0], 0 | 127)
213}
214
215enum Ipv4Host {
216 NotIpv4,
217 Invalid,
218 Addr(Ipv4Addr),
219}
220
221fn whatwg_number(part: &str) -> Option<u64> {
223 let (digits, radix) =
224 if let Some(hex) = part.strip_prefix("0x").or_else(|| part.strip_prefix("0X")) {
225 (hex, 16)
226 } else if part.len() > 1 && part.starts_with('0') {
227 (&part[1..], 8)
228 } else {
229 (part, 10)
230 };
231 if digits.is_empty() {
232 return (radix == 16).then_some(0);
233 }
234 u64::from_str_radix(digits, radix).ok()
235}
236
237fn whatwg_ipv4(host: &str) -> Ipv4Host {
241 let labels: Vec<&str> = host.split('.').collect();
242 let last = labels.last().copied().unwrap_or_default();
243 let is_number = !last.is_empty()
244 && (last.bytes().all(|b| b.is_ascii_digit())
245 || last
246 .strip_prefix("0x")
247 .is_some_and(|h| h.bytes().all(|b| b.is_ascii_hexdigit())));
248 if !is_number {
249 return Ipv4Host::NotIpv4;
250 }
251 if labels.len() > 4 {
252 return Ipv4Host::Invalid;
253 }
254 let Some(numbers) = labels
255 .iter()
256 .map(|l| whatwg_number(l))
257 .collect::<Option<Vec<u64>>>()
258 else {
259 return Ipv4Host::Invalid;
260 };
261 let (init, tail) = numbers.split_at(numbers.len() - 1);
262 if init.iter().any(|n| *n > 255) {
263 return Ipv4Host::Invalid;
264 }
265 let tail_bits = 8 * (5 - numbers.len());
266 if tail[0] >= 1 << tail_bits {
267 return Ipv4Host::Invalid;
268 }
269 let mut value = tail[0];
270 for (i, n) in init.iter().enumerate() {
271 value |= n << (24 - 8 * i);
272 }
273 u32::try_from(value).map_or(Ipv4Host::Invalid, |v| Ipv4Host::Addr(Ipv4Addr::from(v)))
274}
275
276#[cfg(test)]
277mod tests {
278 use super::*;
279
280 #[test]
281 fn accepted_schemes_from_tokens() {
282 let all =
283 AcceptedSchemes::from_tokens(["ed25519", "secp256k1-eip191", "webauthn-p256"]).unwrap();
284 assert!(OwnerScheme::ALL.iter().all(|s| all.contains(*s)));
285 assert_eq!(
286 all.tokens().collect::<Vec<_>>(),
287 ["ed25519", "secp256k1-eip191", "webauthn-p256"]
288 );
289 let ed = AcceptedSchemes::from_tokens(["ed25519", "ed25519"]).unwrap();
290 assert_eq!(ed, AcceptedSchemes::of(&[OwnerScheme::Ed25519]));
291 assert!(!ed.contains(OwnerScheme::Secp256k1Eip191));
292 assert_eq!(
293 AcceptedSchemes::from_tokens([]).unwrap(),
294 AcceptedSchemes::NONE
295 );
296 for bad in ["ED25519", "p256", ""] {
297 assert_eq!(
298 AcceptedSchemes::from_tokens(["ed25519", bad]),
299 Err(GrantError::UnknownScheme)
300 );
301 }
302 }
303
304 #[test]
305 fn verifier_config_rejects_loopback_audience() {
306 let ed = AcceptedSchemes::of(&[OwnerScheme::Ed25519]);
307 for loopback in [
308 "http://localhost",
309 "http://localhost:8080",
310 "https://git.localhost",
311 "http://127.0.0.1:8080",
312 "http://127.1",
313 "http://127.255.255.254",
314 "http://0x7f.0.0.1",
315 "http://0177.0.0.1",
316 "http://2130706433",
317 "http://0.0.0.0:8080",
318 "http://0",
319 "http://[::1]:8443",
320 "https://[::1]",
321 "http://[::]",
322 "http://[::ffff:127.0.0.1]",
323 "http://[::ffff:7f00:1]",
324 "http://1.2.3.4.5",
325 "http://256.0.0.1",
326 "http://1.2.3.08",
327 ] {
328 assert!(is_loopback_origin(loopback), "{loopback}");
329 assert_eq!(
330 VerifierConfig::new(loopback, ed, vec![]),
331 Err(GrantError::LoopbackAudience),
332 "{loopback}"
333 );
334 }
335 let dev = VerifierConfig::new_allowing_loopback("http://[::1]:8443", ed, vec![]).unwrap();
337 assert_eq!(dev.audience(), "http://[::1]:8443");
338 assert!(VerifierConfig::new_allowing_loopback("http://localhost:8080", ed, vec![]).is_ok());
339 for public in [
340 "https://git.example.com",
341 "https://127.example.com",
342 "https://localhost.example.com",
343 "https://example.com1",
344 "http://10.0.0.1:8080",
345 "http://128.0.0.1",
346 "https://[2001:db8::1]:8443",
347 ] {
348 assert!(!is_loopback_origin(public), "{public}");
349 let cfg = VerifierConfig::new(public, ed, vec![]).unwrap();
350 assert_eq!(cfg.audience(), public);
351 assert_eq!(cfg.schemes(), ed);
352 }
353 }
354
355 #[test]
356 fn verifier_config_requires_a_canonical_origin() {
357 let ed = AcceptedSchemes::of(&[OwnerScheme::Ed25519]);
358 for bad in [
359 "https://git.example.com/",
360 "https://Git.example.com",
361 "https://git.example.com:443",
362 "git.example.com",
363 "https://*.example.com",
364 "HTTP://LOCALHOST",
365 "http://localhost:80",
366 ] {
367 let err = VerifierConfig::new(bad, ed, vec![]).unwrap_err();
368 assert!(
369 matches!(err, GrantError::Audience),
370 "{bad}: {err:?} (a bad origin is an audience error, loopback or not)"
371 );
372 assert_eq!(
373 VerifierConfig::new_allowing_loopback(bad, ed, vec![]),
374 Err(GrantError::Audience),
375 "{bad}"
376 );
377 }
378 }
379
380 #[test]
381 fn verifier_config_webauthn_needs_relying_party() {
382 let schemes = AcceptedSchemes::of(&[OwnerScheme::Ed25519, OwnerScheme::WebAuthnP256]);
383 assert_eq!(
384 VerifierConfig::new("https://git.example.com", schemes, vec![]),
385 Err(GrantError::NoRelyingParty)
386 );
387 assert_eq!(
388 VerifierConfig::new_allowing_loopback("http://localhost:8080", schemes, vec![]),
389 Err(GrantError::NoRelyingParty)
390 );
391 let rp = RelyingParty::new("example.com", ["https://example.com"]).unwrap();
392 let cfg =
393 VerifierConfig::new("https://git.example.com", schemes, vec![rp.clone()]).unwrap();
394 assert_eq!(cfg.relying_parties(), std::slice::from_ref(&rp));
395 assert!(cfg.allows_relying_party("example.com", "https://example.com"));
396 assert!(!cfg.allows_relying_party("example.com", "https://example.org"));
397 assert!(!cfg.allows_relying_party("example.org", "https://example.com"));
398 let ed = AcceptedSchemes::of(&[OwnerScheme::Ed25519]);
400 assert!(VerifierConfig::new("https://git.example.com", ed, vec![rp.clone()]).is_ok());
401 let twin = RelyingParty::new("example.com", ["https://www.example.com"]).unwrap();
403 assert_eq!(
404 VerifierConfig::new("https://git.example.com", schemes, vec![rp, twin]),
405 Err(GrantError::RelyingParty)
406 );
407 }
408
409 #[test]
410 fn verifier_config_rejects_loopback_relying_party() {
411 let schemes = AcceptedSchemes::of(&[OwnerScheme::WebAuthnP256]);
412 let public = RelyingParty::new("example.com", ["https://example.com"]).unwrap();
413 for (id, origin) in [
414 ("localhost", "http://localhost:8080"),
415 ("dev.localhost", "https://dev.localhost"),
416 ("example.net", "http://127.0.0.1:8080"),
417 ] {
418 let rp = RelyingParty::new(id, [origin]).unwrap();
419 assert_eq!(
420 VerifierConfig::new(
421 "https://git.example.com",
422 schemes,
423 vec![public.clone(), rp.clone()]
424 ),
425 Err(GrantError::LoopbackRelyingParty),
426 "{id} {origin}"
427 );
428 assert!(
429 VerifierConfig::new_allowing_loopback("https://git.example.com", schemes, vec![rp])
430 .is_ok()
431 );
432 }
433 }
434}