Skip to main content

mkit_server/policy/
grants.rs

1//! Owner-signed write grant verification.
2
3use mkit_attest::grant::{
4    AcceptedSchemes, Capability, GrantError, GrantRequest, RelyingParty, RepositoryIdentity,
5    VerifiedEpoch, VerifiedGrant, VerifierConfig, verify_epoch_statement, verify_grant_owner,
6};
7
8use crate::error::ServerError;
9use crate::op::Operation;
10
11/// Grant verification settings for a Multi/Owner deployment.
12#[derive(Clone, Debug)]
13pub struct GrantConfig {
14    verifier: VerifierConfig,
15}
16
17impl GrantConfig {
18    /// Build production settings. Loopback audiences and relying parties are refused.
19    ///
20    /// # Errors
21    /// `invalid_argument` if the verifier settings are invalid.
22    pub fn new(
23        audience: &str,
24        schemes: AcceptedSchemes,
25        relying_parties: Vec<RelyingParty>,
26    ) -> Result<Self, ServerError> {
27        VerifierConfig::new(audience, schemes, relying_parties)
28            .map(|verifier| Self { verifier })
29            .map_err(config_error)
30    }
31
32    /// Build settings for tests and local development, allowing loopback origins.
33    ///
34    /// # Errors
35    /// `invalid_argument` if the verifier settings are invalid.
36    pub fn new_allowing_loopback(
37        audience: &str,
38        schemes: AcceptedSchemes,
39        relying_parties: Vec<RelyingParty>,
40    ) -> Result<Self, ServerError> {
41        VerifierConfig::new_allowing_loopback(audience, schemes, relying_parties)
42            .map(|verifier| Self { verifier })
43            .map_err(config_error)
44    }
45
46    /// The byte-exact auth v2 audience this verifier accepts.
47    #[must_use]
48    pub fn audience(&self) -> &str {
49        self.verifier.audience()
50    }
51
52    /// Advertised schemes in SPEC-WRITE-GRANTS §4 order.
53    #[must_use]
54    pub fn schemes(&self) -> AcceptedSchemes {
55        self.verifier.schemes()
56    }
57
58    /// The owner-scheme verifier, for statement kinds that are not write
59    /// grants (visibility statements, WP-2.9).
60    pub(crate) fn verifier(&self) -> &VerifierConfig {
61        &self.verifier
62    }
63
64    pub(crate) fn verify(
65        &self,
66        header: &str,
67        op: &Operation,
68    ) -> Result<VerifiedGrant, ServerError> {
69        let auth = op.auth.as_ref().ok_or_else(|| {
70            ServerError::permission_denied("write grant rejected: missing auth v2 signer")
71        })?;
72        let now_ms = op.business_now_ms.ok_or_else(|| {
73            ServerError::permission_denied("write grant rejected: missing business clock")
74        })?;
75        let identity = format!("{}/{}", op.repo.namespace.as_str(), op.repo.name.as_str());
76        let repository = RepositoryIdentity::parse(&identity)
77            .map_err(|_| ServerError::permission_denied("write grant rejected: repository"))?;
78        let owner = verify_grant_owner(&self.verifier, header).map_err(rejected)?;
79        owner
80            .check(
81                &self.verifier,
82                &GrantRequest {
83                    repository: &repository,
84                    signer: &auth.signer,
85                    capability: Capability::Write,
86                    now_ms,
87                },
88            )
89            .map_err(rejected)
90    }
91
92    /// §5.2 checks 1–5. This time-dependent result is never cached.
93    pub(crate) fn verify_epoch(
94        &self,
95        header: &str,
96        now_ms: i64,
97    ) -> Result<VerifiedEpoch, GrantError> {
98        verify_epoch_statement(&self.verifier, header, now_ms)
99    }
100}
101
102/// Parse the accepted owner schemes (`--grant-schemes`, the Worker
103/// `GRANT_SCHEMES` var): comma-separated SPEC-WRITE-GRANTS §4 tokens. The
104/// value must not be blank and may not contain a blank entry. Which tokens
105/// exist is `mkit-attest`'s to say.
106///
107/// # Errors
108/// A message for a blank value, a blank entry or an unknown token.
109pub fn parse_grant_schemes(text: &str) -> Result<AcceptedSchemes, String> {
110    let tokens: Vec<&str> = text.split(',').map(str::trim).collect();
111    if tokens.iter().any(|token| token.is_empty()) {
112        return Err("grant schemes must be a non-empty, comma-separated list".to_owned());
113    }
114    AcceptedSchemes::from_tokens(tokens)
115        .map_err(|error| format!("invalid grant schemes: {}", error.reason()))
116}
117
118/// Parse one relying-party entry, `id=origin[,origin...]`, split on the
119/// first `=` (origins never contain a bare `=` before their id ends, but may
120/// contain one later, for example in a query-like native-app origin). The id
121/// and origin rules are `mkit-attest`'s ([`RelyingParty::new`]).
122///
123/// # Errors
124/// A message for an entry without `=`, or one `RelyingParty::new` refuses.
125pub fn parse_relying_party(entry: &str) -> Result<RelyingParty, String> {
126    let (id, origins) = entry
127        .split_once('=')
128        .ok_or_else(|| "a relying party entry is `id=origin[,origin...]`".to_owned())?;
129    RelyingParty::new(id.trim(), origins.split(',').map(str::trim))
130        .map_err(|error| format!("invalid relying party `{}`: {}", id.trim(), error.reason()))
131}
132
133/// Parse the Worker `WEBAUTHN_RPS` var: entries ([`parse_relying_party`])
134/// separated by `;` or newlines. A blank entry and a duplicate id are
135/// refused, so a stray separator never silently drops a relying party.
136///
137/// # Errors
138/// A message naming the first bad entry, a blank value or a duplicate id.
139pub fn parse_relying_parties(text: &str) -> Result<Vec<RelyingParty>, String> {
140    let mut parties: Vec<RelyingParty> = Vec::new();
141    for entry in text.trim().split([';', '\n']) {
142        let entry = entry.trim();
143        if entry.is_empty() {
144            return Err("relying parties: blank entry".to_owned());
145        }
146        let party = parse_relying_party(entry)?;
147        if parties.iter().any(|known| known.id() == party.id()) {
148            return Err(format!("relying parties: duplicate id `{}`", party.id()));
149        }
150        parties.push(party);
151    }
152    Ok(parties)
153}
154
155/// A deployment's validated write-grant inputs, kept apart from the
156/// [`GrantConfig`] built from them so an adapter's configuration can be
157/// compared and re-built (a `GrantConfig` holds no equality).
158#[derive(Clone, Debug, PartialEq, Eq)]
159pub struct GrantSettings {
160    /// The accepted owner schemes.
161    pub schemes: AcceptedSchemes,
162    /// The accepted `WebAuthn` relying parties.
163    pub relying_parties: Vec<RelyingParty>,
164    /// Development only: allow a loopback audience or relying party.
165    pub allow_loopback: bool,
166}
167
168impl GrantSettings {
169    /// Combine the adapter's raw settings: `None` when nothing is set
170    /// (grants stay off), otherwise the settings, which a bad or partial
171    /// value never degrades to "off".
172    ///
173    /// # Errors
174    /// A message when relying parties or the loopback opt-in are set without
175    /// any scheme.
176    pub fn from_parts(
177        schemes: Option<AcceptedSchemes>,
178        relying_parties: Vec<RelyingParty>,
179        allow_loopback: bool,
180    ) -> Result<Option<Self>, String> {
181        match schemes {
182            Some(schemes) => Ok(Some(Self {
183                schemes,
184                relying_parties,
185                allow_loopback,
186            })),
187            None if relying_parties.is_empty() && !allow_loopback => Ok(None),
188            None => Err(
189                "relying parties and the loopback opt-in configure write grants: set the \
190                 grant schemes too"
191                    .to_owned(),
192            ),
193        }
194    }
195
196    /// Build the verifier for `audience` (the deployment's auth v2 audience):
197    /// the production constructor, or the loopback one when the opt-in is set.
198    ///
199    /// # Errors
200    /// `invalid_argument` for any refusal of `mkit-attest`'s verifier rules:
201    /// a loopback audience or relying party without the opt-in,
202    /// `webauthn-p256` without a relying party, or a duplicate relying party.
203    pub fn build(&self, audience: &str) -> Result<GrantConfig, ServerError> {
204        if self.allow_loopback {
205            GrantConfig::new_allowing_loopback(audience, self.schemes, self.relying_parties.clone())
206        } else {
207            GrantConfig::new(audience, self.schemes, self.relying_parties.clone())
208        }
209    }
210}
211
212fn config_error(error: GrantError) -> ServerError {
213    ServerError::invalid_argument(format!("invalid grant configuration: {}", error.reason()))
214}
215
216/// The single public mapping for a rejected write grant.
217pub(crate) fn rejected(error: GrantError) -> ServerError {
218    ServerError::permission_denied(format!("write grant rejected: {}", error.reason()))
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224    use crate::Code;
225
226    #[test]
227    fn every_grant_error_is_permission_denied() {
228        for error in [
229            GrantError::StatementTooLong,
230            GrantError::CarriageReturn,
231            GrantError::ByteOutOfRange,
232            GrantError::FinalLineFeed,
233            GrantError::FieldCount,
234            GrantError::EmptyField,
235            GrantError::Domain,
236            GrantError::Namespace,
237            GrantError::RepositoryScope,
238            GrantError::ScopeNamespaceMismatch,
239            GrantError::Decimal,
240            GrantError::DecimalOutOfRange,
241            GrantError::Hex,
242            GrantError::Capabilities,
243            GrantError::Audience,
244            GrantError::AudienceWildcard,
245            GrantError::AudienceCount,
246            GrantError::AudiencesUnordered,
247            GrantError::RefScopesOnRead,
248            GrantError::RefScopesMissing,
249            GrantError::RefPattern,
250            GrantError::PackmapPattern,
251            GrantError::UnknownRefFlag,
252            GrantError::RefFlagsNotCanonical,
253            GrantError::RefScopeCount,
254            GrantError::RefScopesUnordered,
255            GrantError::DuplicateRefPattern,
256            GrantError::ExpiryNotAfterCreated,
257            GrantError::LifetimeTooLong,
258            GrantError::HeaderTooLong,
259            GrantError::HeaderFormat,
260            GrantError::HeaderBase64,
261            GrantError::UnknownScheme,
262            GrantError::Repository,
263            GrantError::Visibility,
264            GrantError::SchemeNotAdvertised,
265            GrantError::SchemeNamespaceMismatch,
266            GrantError::SignatureLength,
267            GrantError::BadSignature,
268            GrantError::SignatureRecoveryId,
269            GrantError::SignatureScalar,
270            GrantError::HighS,
271            GrantError::OwnerMismatch,
272            GrantError::InvalidOwnerKey,
273            GrantError::WebAuthnBlob,
274            GrantError::AuthenticatorData,
275            GrantError::UserNotPresent,
276            GrantError::RelyingPartyMismatch,
277            GrantError::ClientData,
278            GrantError::ClientDataType,
279            GrantError::Challenge,
280            GrantError::CrossOrigin,
281            GrantError::TopOrigin,
282            GrantError::OriginNotAllowed,
283            GrantError::NamespaceMismatch,
284            GrantError::RepositoryMismatch,
285            GrantError::AudienceNotListed,
286            GrantError::RepositoryNotInScope,
287            GrantError::CapabilityNotGranted,
288            GrantError::GranteeMismatch,
289            GrantError::NotYetValid,
290            GrantError::Expired,
291            GrantError::LoopbackAudience,
292            GrantError::NoRelyingParty,
293            GrantError::RelyingParty,
294            GrantError::LoopbackRelyingParty,
295        ] {
296            let mapped = rejected(error);
297            assert_eq!(mapped.code(), Code::PermissionDenied);
298            assert_eq!(
299                mapped.public_message(),
300                format!("write grant rejected: {}", error.reason())
301            );
302        }
303    }
304
305    #[test]
306    fn scheme_lists_parse_and_refuse_blanks_and_unknown_tokens() {
307        let all = parse_grant_schemes("ed25519, secp256k1-eip191,webauthn-p256").unwrap();
308        assert_eq!(
309            all,
310            AcceptedSchemes::of(&mkit_attest::grant::OwnerScheme::ALL)
311        );
312        for bad in [
313            "",
314            "  ",
315            "ed25519,",
316            ",ed25519",
317            "ed25519,,webauthn-p256",
318            "rsa",
319        ] {
320            assert!(parse_grant_schemes(bad).is_err(), "{bad:?}");
321        }
322    }
323
324    #[test]
325    fn relying_party_entries_split_on_the_first_equals() {
326        let party =
327            parse_relying_party("example.test=https://example.test,https://app.example.test")
328                .unwrap();
329        assert_eq!(party.id(), "example.test");
330        assert_eq!(
331            party.origins(),
332            ["https://example.test", "https://app.example.test"]
333        );
334        // A later `=` belongs to the origin.
335        let party = parse_relying_party("example.test=android:apk-key-hash:a=b").unwrap();
336        assert_eq!(party.origins(), ["android:apk-key-hash:a=b"]);
337        for bad in [
338            "example.test",
339            "=https://example.test",
340            "example.test=",
341            "example.test=https://a,,https://b",
342            "Example.Test=https://a",
343            "example.test=https://a,https://a",
344        ] {
345            assert!(parse_relying_party(bad).is_err(), "{bad:?}");
346        }
347    }
348
349    #[test]
350    fn relying_party_lists_take_semicolons_or_newlines_and_refuse_blanks_and_duplicates() {
351        let list = "a.test=https://a.test;b.test=https://b.test\nc.test=https://c.test\n";
352        let parties = parse_relying_parties(list).unwrap();
353        assert_eq!(
354            parties.iter().map(RelyingParty::id).collect::<Vec<_>>(),
355            ["a.test", "b.test", "c.test"]
356        );
357        for bad in [
358            "",
359            "  ",
360            "a.test=https://a.test;;b.test=https://b.test",
361            "a.test=https://a.test;",
362            "a.test=https://a.test;a.test=https://other.test",
363            "a.test",
364        ] {
365            assert!(parse_relying_parties(bad).is_err(), "{bad:?}");
366        }
367    }
368
369    #[test]
370    fn grant_settings_build_refuses_what_attest_refuses_and_never_degrades() {
371        let ed = parse_grant_schemes("ed25519").unwrap();
372        let web = parse_grant_schemes("ed25519,webauthn-p256").unwrap();
373        let rp = || parse_relying_party("example.test=https://example.test").unwrap();
374        // Nothing set: grants are off. Partial settings are refused.
375        assert_eq!(GrantSettings::from_parts(None, vec![], false), Ok(None));
376        assert!(GrantSettings::from_parts(None, vec![rp()], false).is_err());
377        assert!(GrantSettings::from_parts(None, vec![], true).is_err());
378        let build = |schemes, rps, loopback, audience: &str| {
379            GrantSettings::from_parts(Some(schemes), rps, loopback)
380                .unwrap()
381                .unwrap()
382                .build(audience)
383        };
384        assert!(build(ed, vec![], false, "https://vcs.example").is_ok());
385        assert!(build(web, vec![rp()], false, "https://vcs.example").is_ok());
386        assert!(build(web, vec![], false, "https://vcs.example").is_err());
387        // Loopback audiences and relying parties need the opt-in.
388        assert!(build(ed, vec![], false, "http://localhost:8787").is_err());
389        assert!(build(ed, vec![], true, "http://localhost:8787").is_ok());
390        let local = parse_relying_party("localhost=http://localhost:8787").unwrap();
391        assert!(build(web, vec![local.clone()], false, "https://vcs.example").is_err());
392        assert!(build(web, vec![local], true, "https://vcs.example").is_ok());
393        let error = build(ed, vec![], false, "not an origin").unwrap_err();
394        assert_eq!(error.code(), Code::InvalidArgument);
395    }
396
397    #[test]
398    fn configuration_and_scheme_order() {
399        let schemes =
400            AcceptedSchemes::from_tokens(["webauthn-p256", "ed25519", "secp256k1-eip191"]).unwrap();
401        assert_eq!(
402            schemes.tokens().collect::<Vec<_>>(),
403            ["ed25519", "secp256k1-eip191", "webauthn-p256"]
404        );
405        let rp = RelyingParty::new("example.test", ["https://example.test"]).unwrap();
406        assert_eq!(
407            GrantConfig::new("http://localhost", schemes, vec![rp.clone()])
408                .unwrap_err()
409                .code(),
410            Code::InvalidArgument
411        );
412        let cfg =
413            GrantConfig::new_allowing_loopback("http://localhost", schemes, vec![rp]).unwrap();
414        assert_eq!(cfg.audience(), "http://localhost");
415        assert_eq!(cfg.schemes(), schemes);
416        assert_eq!(
417            GrantConfig::new("https://example.test", schemes, vec![])
418                .unwrap_err()
419                .code(),
420            Code::InvalidArgument
421        );
422    }
423}