Skip to main content

mkit_attest/grant/
config.rs

1//! The deployment side of verification: the owner schemes it accepts (§4),
2//! its own auth v2 audience (§3.2, §7 step 5) and its `WebAuthn` relying
3//! parties (§4.3), as a [`VerifierConfig`].
4
5use std::net::{Ipv4Addr, Ipv6Addr};
6
7use mkit_core::write_auth::validate_audience;
8
9use super::webauthn::RelyingParty;
10use super::{GrantError, OwnerScheme};
11
12/// The owner schemes a deployment accepts, as advertised in `GetServerInfo`'s
13/// `grant_schemes` (§4). A statement signed under any other scheme fails
14/// verification.
15#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
16pub struct AcceptedSchemes(u8);
17
18impl AcceptedSchemes {
19    /// No scheme: every owner-signed statement is rejected.
20    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    /// The set of these schemes.
31    #[must_use]
32    pub fn of(schemes: &[OwnerScheme]) -> Self {
33        Self(schemes.iter().fold(0, |acc, s| acc | Self::bit(*s)))
34    }
35
36    /// Parse advertised scheme tokens. Duplicates are harmless.
37    ///
38    /// # Errors
39    /// `UnknownScheme` for a token not defined in §4.
40    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    /// Whether `scheme` is accepted.
50    #[must_use]
51    pub fn contains(self, scheme: OwnerScheme) -> bool {
52        self.0 & Self::bit(scheme) != 0
53    }
54
55    /// The accepted schemes' tokens, in §4 table order (for `grant_schemes`).
56    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/// A deployment's verifier configuration: its own auth v2 audience, the
65/// owner schemes it accepts, and the `WebAuthn` relying parties it accepts
66/// `webauthn-p256` assertions for (§4.3).
67///
68/// Only [`VerifierConfig::new`] and [`VerifierConfig::new_allowing_loopback`]
69/// build one. Both require a canonical auth v2 origin (SPEC-TRANSPORT-CONNECT
70/// §7.1), and refuse `webauthn-p256` without a relying party (§4.3: "a
71/// deployment that has configured no relying party MUST NOT accept, or
72/// advertise, `webauthn-p256`"). `new` also enforces §3.2 and §10: a
73/// deployment's own audience is "an origin whose host the operator controls,
74/// never a loopback address". That rule binds the deployment's own audience
75/// only; a grant's audience list may name loopback origins, and they simply
76/// never match a deployment built with `new`. `new` refuses loopback relying
77/// parties for the same reason: any local process can ask a passkey to sign
78/// for `localhost`.
79#[derive(Clone, Debug, PartialEq, Eq)]
80pub struct VerifierConfig {
81    audience: String,
82    schemes: AcceptedSchemes,
83    relying_parties: Vec<RelyingParty>,
84}
85
86impl VerifierConfig {
87    /// A production configuration. `relying_parties` may be empty unless
88    /// `schemes` includes `webauthn-p256`; relying parties configured while
89    /// that scheme is not accepted are unused.
90    ///
91    /// # Errors
92    /// `Audience` for an origin outside the auth v2 rules;
93    /// `LoopbackAudience` for a loopback or unspecified host (see
94    /// [`is_loopback_origin`]); `NoRelyingParty` if `schemes` includes
95    /// `webauthn-p256` and `relying_parties` is empty; `RelyingParty` for two
96    /// relying parties with one id; `LoopbackRelyingParty` for a relying
97    /// party with id `localhost` or `*.localhost`, or a loopback origin.
98    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    /// **Development and tests only.** As [`VerifierConfig::new`], but a
115    /// loopback audience such as `http://localhost:8080` or
116    /// `http://[::1]:8443`, and loopback relying parties, are allowed. A
117    /// deployment reachable by anyone else MUST NOT use this (§3.2, §10):
118    /// every local deployment shares a loopback audience, so a grant for one
119    /// would verify at all of them.
120    ///
121    /// # Errors
122    /// `Audience`, `NoRelyingParty` or `RelyingParty`, as
123    /// [`VerifierConfig::new`].
124    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    /// The deployment's own auth v2 audience, which §7 step 5, §5.2 check 4
146    /// and §9.1 look for, byte for byte.
147    #[must_use]
148    pub fn audience(&self) -> &str {
149        &self.audience
150    }
151
152    /// The accepted owner schemes.
153    #[must_use]
154    pub fn schemes(&self) -> AcceptedSchemes {
155        self.schemes
156    }
157
158    /// The configured `WebAuthn` relying parties (§4.3 rule 4).
159    #[must_use]
160    pub fn relying_parties(&self) -> &[RelyingParty] {
161        &self.relying_parties
162    }
163
164    /// Whether `rp_id` is configured with `origin` (§4.3 rule 4).
165    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/// Whether an origin's host is a loopback or unspecified address, so it can
173/// never be an origin "whose host the operator controls" (§3.2, §10).
174///
175/// Loopback here is: `localhost` and every `*.localhost` name (RFC 6761
176/// §6.3); any IPv4 address in `127.0.0.0/8` or `0.0.0.0/8`, in every form
177/// the WHATWG URL host parser accepts (so `127.1` and `0x7f.0.0.1` count);
178/// and the IPv6 addresses `::1` and `::`, plus the IPv4-mapped or
179/// -compatible forms of the IPv4 ranges. A host that ends in a number but is
180/// not a valid IPv4 address also counts, failing closed. Any other input,
181/// including a non-origin, is not loopback; [`VerifierConfig::new`] checks
182/// the origin rules separately.
183#[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
221/// One WHATWG IPv4 number: `0x` hex, a leading-`0` octal, or decimal.
222fn 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
237/// The WHATWG URL IPv4 host parser: a host whose last label is a number is
238/// an IPv4 address (in one of the dotted forms `a`, `a.b`, `a.b.c`,
239/// `a.b.c.d`), or else invalid.
240fn 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        // The explicit development constructor allows the valid ones.
336        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        // Relying parties without the scheme are allowed and unused.
399        let ed = AcceptedSchemes::of(&[OwnerScheme::Ed25519]);
400        assert!(VerifierConfig::new("https://git.example.com", ed, vec![rp.clone()]).is_ok());
401        // One id configured twice is ambiguous.
402        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}