1use 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#[derive(Clone, Debug)]
13pub struct GrantConfig {
14 verifier: VerifierConfig,
15}
16
17impl GrantConfig {
18 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 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 #[must_use]
48 pub fn audience(&self) -> &str {
49 self.verifier.audience()
50 }
51
52 #[must_use]
54 pub fn schemes(&self) -> AcceptedSchemes {
55 self.verifier.schemes()
56 }
57
58 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 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
102pub 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
118pub 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
133pub 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#[derive(Clone, Debug, PartialEq, Eq)]
159pub struct GrantSettings {
160 pub schemes: AcceptedSchemes,
162 pub relying_parties: Vec<RelyingParty>,
164 pub allow_loopback: bool,
166}
167
168impl GrantSettings {
169 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 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
216pub(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 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 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 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}