1use serde::Serialize;
9use std::sync::Arc;
10
11use chio_core::capability::features::CapabilityNegotiation;
12use chio_core::crypto::PublicKey;
13
14use crate::{canonical_json_bytes, sha256_hex};
15
16pub const BROKER_CAPABILITY_EXECUTION_PROFILE: &str = "chio.broker-capability-execution.v1";
18
19pub const MAX_SUPPLEMENTAL_AUTHORIZATION_BYTES: usize = 64 * 1024;
20pub const MAX_SUPPLEMENTAL_CONTEXT_FIELD_BYTES: usize = 8 * 1024;
21pub const MAX_SUPPLEMENTAL_CLAIM_FIELD_BYTES: usize = 1024;
22pub const MAX_SUPPLEMENTAL_NEGOTIATED_FEATURES: usize = 32;
23pub const MAX_SUPPLEMENTAL_REVOCATION_IDS: usize = 64;
24pub const MAX_SUPPLEMENTAL_REVOCATION_ID_BYTES: usize = 512;
25pub const MAX_ADMISSION_REVOCATION_IDS: usize = 256;
26
27const SUPPLEMENTAL_REQUEST_BINDING_DOMAIN: &str = "chio.supplemental-quota-request-binding.v1";
28const ADMISSION_REVOCATION_SET_DOMAIN: &str = "chio.admission-revocation-set.v1";
29
30#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct SupplementalQuotaVerificationContext {
33 pub capability_id: String,
34 pub capability_digest: String,
35 pub request_namespace_digest: String,
37 pub operation_id: String,
39 pub subject: PublicKey,
40 pub request_id: String,
41 pub normalized_destination: String,
42 pub arguments_hash: String,
47 pub negotiated_profile: String,
48 pub negotiated_features: CapabilityNegotiation,
49 pub verifier_binding: SupplementalQuotaVerifierBinding,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct SupplementalQuotaVerifierBinding {
56 pub verifier_identity: String,
57 pub configuration_digest: String,
58}
59
60impl SupplementalQuotaVerifierBinding {
61 pub fn validate(&self) -> Result<(), SupplementalQuotaError> {
63 if self.verifier_identity.is_empty() {
64 return Err(SupplementalQuotaError::EmptyContextField(
65 "verifier_identity",
66 ));
67 }
68 if self.configuration_digest.is_empty() {
69 return Err(SupplementalQuotaError::EmptyContextField(
70 "verifier_configuration_digest",
71 ));
72 }
73 ensure_bounded(
74 "verifier_identity",
75 self.verifier_identity.len(),
76 MAX_SUPPLEMENTAL_CONTEXT_FIELD_BYTES,
77 )?;
78 ensure_bounded(
79 "verifier_configuration_digest",
80 self.configuration_digest.len(),
81 MAX_SUPPLEMENTAL_CONTEXT_FIELD_BYTES,
82 )?;
83 ensure_sha256_hex("verifier_configuration_digest", &self.configuration_digest)
84 }
85}
86
87#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct VerifiedSupplementalQuotaClaim {
93 pub profile: String,
94 pub broker_capability_id: String,
95 pub issuer: PublicKey,
96 pub request_constraint_digest: String,
97 pub max_invocations: u32,
98 pub authorization_artifact_digest: String,
99 pub supplemental_revocation_ids: Vec<String>,
100 pub expires_at: u64,
101 pub request_binding_hash: String,
102 pub capability_id: String,
103 pub capability_digest: String,
104 pub request_namespace_digest: String,
105 pub operation_id: String,
106 pub subject: PublicKey,
107 pub request_id: String,
108 pub normalized_destination: String,
109 pub arguments_hash: String,
110 pub negotiated_features: CapabilityNegotiation,
111}
112
113#[derive(Debug, Clone, PartialEq, Eq)]
118#[allow(dead_code)]
119pub(crate) struct KernelVerifiedSupplementalQuotaClaim {
120 profile: String,
121 owner_id: String,
122 broker_capability_id: String,
123 max_invocations: u32,
124 authorization_artifact_digest: String,
125 supplemental_revocation_ids: Vec<String>,
126 expires_at: u64,
127 request_binding_hash: String,
128 capability_id: String,
129 capability_digest: String,
130 request_namespace_digest: String,
131 operation_id: String,
132 verifier_binding: SupplementalQuotaVerifierBinding,
133}
134
135#[allow(dead_code)]
136impl KernelVerifiedSupplementalQuotaClaim {
137 #[must_use]
138 pub(crate) fn profile(&self) -> &str {
139 &self.profile
140 }
141
142 #[must_use]
143 pub(crate) fn owner_id(&self) -> &str {
144 &self.owner_id
145 }
146
147 #[must_use]
148 pub(crate) fn broker_capability_id(&self) -> &str {
149 &self.broker_capability_id
150 }
151
152 #[must_use]
153 pub(crate) fn max_invocations(&self) -> u32 {
154 self.max_invocations
155 }
156
157 #[must_use]
158 pub(crate) fn authorization_artifact_digest(&self) -> &str {
159 &self.authorization_artifact_digest
160 }
161
162 #[must_use]
163 pub(crate) fn supplemental_revocation_ids(&self) -> &[String] {
164 &self.supplemental_revocation_ids
165 }
166
167 #[must_use]
168 pub(crate) fn expires_at(&self) -> u64 {
169 self.expires_at
170 }
171
172 #[must_use]
173 pub(crate) fn request_binding_hash(&self) -> &str {
174 &self.request_binding_hash
175 }
176
177 #[must_use]
178 pub(crate) fn capability_id(&self) -> &str {
179 &self.capability_id
180 }
181
182 #[must_use]
183 pub(crate) fn capability_digest(&self) -> &str {
184 &self.capability_digest
185 }
186
187 #[must_use]
188 pub(crate) fn request_namespace_digest(&self) -> &str {
189 &self.request_namespace_digest
190 }
191
192 #[must_use]
193 pub(crate) fn operation_id(&self) -> &str {
194 &self.operation_id
195 }
196
197 #[must_use]
198 pub(crate) fn verifier_binding(&self) -> &SupplementalQuotaVerifierBinding {
199 &self.verifier_binding
200 }
201}
202
203#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
205#[error("{message}")]
206pub struct SupplementalQuotaVerifierError {
207 message: String,
208}
209
210impl SupplementalQuotaVerifierError {
211 #[must_use]
212 pub fn new(message: impl Into<String>) -> Self {
213 Self {
214 message: message.into(),
215 }
216 }
217}
218
219pub trait SupplementalQuotaVerifier: Send + Sync {
221 fn verify(
223 &self,
224 signed_extension: &[u8],
225 context: &SupplementalQuotaVerificationContext,
226 ) -> Result<VerifiedSupplementalQuotaClaim, SupplementalQuotaVerifierError>;
227}
228
229pub(crate) struct SupplementalQuotaVerifierRuntime {
230 verifier: Arc<dyn SupplementalQuotaVerifier>,
231 binding: SupplementalQuotaVerifierBinding,
232}
233
234impl SupplementalQuotaVerifierRuntime {
235 pub(crate) fn new(
236 verifier: Arc<dyn SupplementalQuotaVerifier>,
237 binding: SupplementalQuotaVerifierBinding,
238 ) -> Result<Self, SupplementalQuotaError> {
239 binding.validate()?;
240 Ok(Self { verifier, binding })
241 }
242
243 pub(crate) fn verifier(&self) -> &dyn SupplementalQuotaVerifier {
244 self.verifier.as_ref()
245 }
246
247 pub(crate) fn binding(&self) -> &SupplementalQuotaVerifierBinding {
248 &self.binding
249 }
250}
251
252#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
253pub enum SupplementalQuotaError {
254 #[error("supplemental authorization requires an installed verifier")]
255 MissingVerifier,
256 #[error("supplemental authorization bytes are empty")]
257 EmptyAuthorization,
258 #[error("unsupported supplemental quota profile: {0}")]
259 UnknownProfile(String),
260 #[error("supplemental quota profile was not negotiated: {0}")]
261 ProfileNotNegotiated(String),
262 #[error("supplemental quota verifier denied the artifact: {0}")]
263 VerifierRejected(#[from] SupplementalQuotaVerifierError),
264 #[error("supplemental quota context field is empty: {0}")]
265 EmptyContextField(&'static str),
266 #[error("supplemental quota verifier result does not match context field: {0}")]
267 ContextMismatch(&'static str),
268 #[error("supplemental quota authorization artifact digest does not match")]
269 ArtifactDigestMismatch,
270 #[error("supplemental quota request binding does not match")]
271 RequestBindingMismatch,
272 #[error("supplemental quota verifier result field is empty: {0}")]
273 EmptyClaimField(&'static str),
274 #[error("supplemental quota field {field} has size {actual}, maximum is {maximum}")]
275 LimitExceeded {
276 field: &'static str,
277 actual: usize,
278 maximum: usize,
279 },
280 #[error("supplemental quota field is not lowercase SHA-256 hex: {0}")]
281 InvalidSha256Digest(&'static str),
282 #[error("invalid negotiated supplemental quota features: {0}")]
283 InvalidNegotiatedFeatures(String),
284 #[error("supplemental quota expired at {expires_at}; current time is {now}")]
285 Expired { expires_at: u64, now: u64 },
286 #[error("revocation id is empty")]
287 EmptyRevocationId,
288 #[error("admission revocation set is empty")]
289 EmptyRevocationSet,
290 #[error("duplicate revocation id: {0}")]
291 DuplicateRevocationId(String),
292 #[error("broker supplemental quota requires at least one revocation id")]
293 EmptySupplementalRevocationIds,
294 #[error("revocation ids are not in canonical sorted order")]
295 RevocationIdsNotCanonical,
296 #[error("presented revocation-set digest does not match its ids")]
297 RevocationDigestMismatch,
298 #[error("presented revocation set does not match the admission-bound set")]
299 RevocationSetMismatch,
300 #[error("canonical serialization failed: {0}")]
301 Canonicalization(String),
302}
303
304#[derive(Serialize)]
305struct SupplementalRequestBinding<'a> {
306 capability_id: &'a str,
307 capability_digest: &'a str,
308 request_namespace_digest: &'a str,
309 operation_id: &'a str,
310 subject: &'a PublicKey,
311 request_id: &'a str,
312 normalized_destination: &'a str,
313 arguments_hash: &'a str,
314 negotiated_profile: &'a str,
315 negotiated_features: &'a CapabilityNegotiation,
316 verifier_identity: &'a str,
317 verifier_configuration_digest: &'a str,
318}
319
320#[derive(Serialize)]
321struct BrokerQuotaOwnerBinding<'a> {
322 broker_capability_id: &'a str,
323 issuer: &'a PublicKey,
324 normalized_destination: &'a str,
325 request_constraint_digest: &'a str,
326}
327
328fn domain_separated_digest<T: Serialize>(
329 domain: &str,
330 value: &T,
331) -> Result<String, SupplementalQuotaError> {
332 let canonical = canonical_json_bytes(value)
333 .map_err(|error| SupplementalQuotaError::Canonicalization(error.to_string()))?;
334 let mut message = Vec::with_capacity(domain.len() + 1 + canonical.len());
335 message.extend_from_slice(domain.as_bytes());
336 message.push(0);
337 message.extend_from_slice(&canonical);
338 Ok(sha256_hex(&message))
339}
340
341#[must_use]
343pub fn supplemental_authorization_artifact_digest(signed_extension: &[u8]) -> String {
344 sha256_hex(signed_extension)
345}
346
347pub fn supplemental_request_binding_hash(
349 context: &SupplementalQuotaVerificationContext,
350) -> Result<String, SupplementalQuotaError> {
351 domain_separated_digest(
352 SUPPLEMENTAL_REQUEST_BINDING_DOMAIN,
353 &SupplementalRequestBinding {
354 capability_id: &context.capability_id,
355 capability_digest: &context.capability_digest,
356 request_namespace_digest: &context.request_namespace_digest,
357 operation_id: &context.operation_id,
358 subject: &context.subject,
359 request_id: &context.request_id,
360 normalized_destination: &context.normalized_destination,
361 arguments_hash: &context.arguments_hash,
362 negotiated_profile: &context.negotiated_profile,
363 negotiated_features: &context.negotiated_features,
364 verifier_identity: &context.verifier_binding.verifier_identity,
365 verifier_configuration_digest: &context.verifier_binding.configuration_digest,
366 },
367 )
368}
369
370pub(crate) fn derive_broker_quota_owner_id(
372 broker_capability_id: &str,
373 issuer: &PublicKey,
374 normalized_destination: &str,
375 request_constraint_digest: &str,
376) -> Result<String, SupplementalQuotaError> {
377 domain_separated_digest(
378 BROKER_CAPABILITY_EXECUTION_PROFILE,
379 &BrokerQuotaOwnerBinding {
380 broker_capability_id,
381 issuer,
382 normalized_destination,
383 request_constraint_digest,
384 },
385 )
386}
387
388fn ensure_nonempty_context(
389 context: &SupplementalQuotaVerificationContext,
390) -> Result<(), SupplementalQuotaError> {
391 for (name, value) in [
392 ("capability_id", context.capability_id.as_str()),
393 ("capability_digest", context.capability_digest.as_str()),
394 (
395 "request_namespace_digest",
396 context.request_namespace_digest.as_str(),
397 ),
398 ("operation_id", context.operation_id.as_str()),
399 ("request_id", context.request_id.as_str()),
400 (
401 "normalized_destination",
402 context.normalized_destination.as_str(),
403 ),
404 ("arguments_hash", context.arguments_hash.as_str()),
405 ("negotiated_profile", context.negotiated_profile.as_str()),
406 (
407 "verifier_identity",
408 context.verifier_binding.verifier_identity.as_str(),
409 ),
410 (
411 "verifier_configuration_digest",
412 context.verifier_binding.configuration_digest.as_str(),
413 ),
414 ] {
415 if value.is_empty() {
416 return Err(SupplementalQuotaError::EmptyContextField(name));
417 }
418 }
419 Ok(())
420}
421
422fn ensure_bounded(
423 field: &'static str,
424 actual: usize,
425 maximum: usize,
426) -> Result<(), SupplementalQuotaError> {
427 if actual <= maximum {
428 Ok(())
429 } else {
430 Err(SupplementalQuotaError::LimitExceeded {
431 field,
432 actual,
433 maximum,
434 })
435 }
436}
437
438fn ensure_sha256_hex(field: &'static str, value: &str) -> Result<(), SupplementalQuotaError> {
439 if value.len() == 64
440 && value
441 .bytes()
442 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
443 {
444 Ok(())
445 } else {
446 Err(SupplementalQuotaError::InvalidSha256Digest(field))
447 }
448}
449
450fn ensure_negotiated_features(
451 features: &CapabilityNegotiation,
452) -> Result<(), SupplementalQuotaError> {
453 ensure_bounded(
454 "negotiated_features",
455 features.features.len(),
456 MAX_SUPPLEMENTAL_NEGOTIATED_FEATURES,
457 )?;
458 features
459 .validate()
460 .map_err(|error| SupplementalQuotaError::InvalidNegotiatedFeatures(error.to_string()))
461}
462
463fn ensure_context_bounds(
464 context: &SupplementalQuotaVerificationContext,
465) -> Result<(), SupplementalQuotaError> {
466 for (field, value) in [
467 ("capability_id", context.capability_id.as_str()),
468 ("capability_digest", context.capability_digest.as_str()),
469 (
470 "request_namespace_digest",
471 context.request_namespace_digest.as_str(),
472 ),
473 ("operation_id", context.operation_id.as_str()),
474 ("request_id", context.request_id.as_str()),
475 (
476 "normalized_destination",
477 context.normalized_destination.as_str(),
478 ),
479 ("arguments_hash", context.arguments_hash.as_str()),
480 ("negotiated_profile", context.negotiated_profile.as_str()),
481 (
482 "verifier_identity",
483 context.verifier_binding.verifier_identity.as_str(),
484 ),
485 (
486 "verifier_configuration_digest",
487 context.verifier_binding.configuration_digest.as_str(),
488 ),
489 ] {
490 ensure_bounded(field, value.len(), MAX_SUPPLEMENTAL_CONTEXT_FIELD_BYTES)?;
491 }
492 for (field, value) in [
493 ("capability_digest", context.capability_digest.as_str()),
494 (
495 "request_namespace_digest",
496 context.request_namespace_digest.as_str(),
497 ),
498 ("operation_id", context.operation_id.as_str()),
499 ("arguments_hash", context.arguments_hash.as_str()),
500 (
501 "verifier_configuration_digest",
502 context.verifier_binding.configuration_digest.as_str(),
503 ),
504 ] {
505 ensure_sha256_hex(field, value)?;
506 }
507 ensure_negotiated_features(&context.negotiated_features)
508}
509
510fn ensure_claim_bounds(
511 claim: &VerifiedSupplementalQuotaClaim,
512) -> Result<(), SupplementalQuotaError> {
513 for (name, value) in [
514 ("broker_capability_id", claim.broker_capability_id.as_str()),
515 (
516 "request_constraint_digest",
517 claim.request_constraint_digest.as_str(),
518 ),
519 ] {
520 if value.is_empty() {
521 return Err(SupplementalQuotaError::EmptyClaimField(name));
522 }
523 }
524 for (field, value) in [
525 ("profile", claim.profile.as_str()),
526 ("broker_capability_id", claim.broker_capability_id.as_str()),
527 (
528 "request_constraint_digest",
529 claim.request_constraint_digest.as_str(),
530 ),
531 (
532 "authorization_artifact_digest",
533 claim.authorization_artifact_digest.as_str(),
534 ),
535 ("request_binding_hash", claim.request_binding_hash.as_str()),
536 ] {
537 ensure_bounded(field, value.len(), MAX_SUPPLEMENTAL_CLAIM_FIELD_BYTES)?;
538 }
539 for (field, value) in [
540 ("capability_id", claim.capability_id.as_str()),
541 ("capability_digest", claim.capability_digest.as_str()),
542 (
543 "request_namespace_digest",
544 claim.request_namespace_digest.as_str(),
545 ),
546 ("operation_id", claim.operation_id.as_str()),
547 ("request_id", claim.request_id.as_str()),
548 (
549 "normalized_destination",
550 claim.normalized_destination.as_str(),
551 ),
552 ("arguments_hash", claim.arguments_hash.as_str()),
553 ] {
554 ensure_bounded(field, value.len(), MAX_SUPPLEMENTAL_CONTEXT_FIELD_BYTES)?;
555 }
556 ensure_bounded(
557 "supplemental_revocation_ids",
558 claim.supplemental_revocation_ids.len(),
559 MAX_SUPPLEMENTAL_REVOCATION_IDS,
560 )?;
561 for id in &claim.supplemental_revocation_ids {
562 ensure_bounded(
563 "supplemental_revocation_id",
564 id.len(),
565 MAX_SUPPLEMENTAL_REVOCATION_ID_BYTES,
566 )?;
567 }
568 for (field, value) in [
569 (
570 "request_constraint_digest",
571 claim.request_constraint_digest.as_str(),
572 ),
573 (
574 "authorization_artifact_digest",
575 claim.authorization_artifact_digest.as_str(),
576 ),
577 ("request_binding_hash", claim.request_binding_hash.as_str()),
578 ("capability_digest", claim.capability_digest.as_str()),
579 (
580 "request_namespace_digest",
581 claim.request_namespace_digest.as_str(),
582 ),
583 ("operation_id", claim.operation_id.as_str()),
584 ("arguments_hash", claim.arguments_hash.as_str()),
585 ] {
586 ensure_sha256_hex(field, value)?;
587 }
588 ensure_negotiated_features(&claim.negotiated_features)
589}
590
591fn ensure_supported_profile(profile: &str) -> Result<(), SupplementalQuotaError> {
592 if profile == BROKER_CAPABILITY_EXECUTION_PROFILE {
593 Ok(())
594 } else {
595 Err(SupplementalQuotaError::UnknownProfile(profile.to_string()))
596 }
597}
598
599fn ensure_context_match(
600 field: &'static str,
601 actual: &str,
602 expected: &str,
603) -> Result<(), SupplementalQuotaError> {
604 if actual == expected {
605 Ok(())
606 } else {
607 Err(SupplementalQuotaError::ContextMismatch(field))
608 }
609}
610
611#[allow(dead_code)]
616pub(crate) fn verify_supplemental_quota(
617 verifier: Option<&dyn SupplementalQuotaVerifier>,
618 signed_extension: &[u8],
619 context: &SupplementalQuotaVerificationContext,
620 now: u64,
621) -> Result<KernelVerifiedSupplementalQuotaClaim, SupplementalQuotaError> {
622 let verifier = verifier.ok_or(SupplementalQuotaError::MissingVerifier)?;
623 if signed_extension.is_empty() {
624 return Err(SupplementalQuotaError::EmptyAuthorization);
625 }
626 ensure_bounded(
627 "signed_extension",
628 signed_extension.len(),
629 MAX_SUPPLEMENTAL_AUTHORIZATION_BYTES,
630 )?;
631 ensure_nonempty_context(context)?;
632 ensure_context_bounds(context)?;
633 ensure_supported_profile(&context.negotiated_profile)?;
634 if !context
635 .negotiated_features
636 .supports(&context.negotiated_profile)
637 {
638 return Err(SupplementalQuotaError::ProfileNotNegotiated(
639 context.negotiated_profile.clone(),
640 ));
641 }
642
643 let claim = verifier.verify(signed_extension, context)?;
644 ensure_claim_bounds(&claim)?;
645 ensure_supported_profile(&claim.profile)?;
646 ensure_context_match("profile", &claim.profile, &context.negotiated_profile)?;
647 ensure_context_match(
648 "capability_id",
649 &claim.capability_id,
650 &context.capability_id,
651 )?;
652 ensure_context_match(
653 "capability_digest",
654 &claim.capability_digest,
655 &context.capability_digest,
656 )?;
657 ensure_context_match(
658 "request_namespace_digest",
659 &claim.request_namespace_digest,
660 &context.request_namespace_digest,
661 )?;
662 ensure_context_match("operation_id", &claim.operation_id, &context.operation_id)?;
663 if claim.subject != context.subject {
664 return Err(SupplementalQuotaError::ContextMismatch("subject"));
665 }
666 ensure_context_match("request_id", &claim.request_id, &context.request_id)?;
667 ensure_context_match(
668 "normalized_destination",
669 &claim.normalized_destination,
670 &context.normalized_destination,
671 )?;
672 ensure_context_match(
673 "arguments_hash",
674 &claim.arguments_hash,
675 &context.arguments_hash,
676 )?;
677 if claim.negotiated_features != context.negotiated_features {
678 return Err(SupplementalQuotaError::ContextMismatch(
679 "negotiated_features",
680 ));
681 }
682
683 if claim.expires_at <= now {
684 return Err(SupplementalQuotaError::Expired {
685 expires_at: claim.expires_at,
686 now,
687 });
688 }
689 if claim.authorization_artifact_digest
690 != supplemental_authorization_artifact_digest(signed_extension)
691 {
692 return Err(SupplementalQuotaError::ArtifactDigestMismatch);
693 }
694 if claim.request_binding_hash != supplemental_request_binding_hash(context)? {
695 return Err(SupplementalQuotaError::RequestBindingMismatch);
696 }
697 let expected_owner_id = derive_broker_quota_owner_id(
698 &claim.broker_capability_id,
699 &claim.issuer,
700 &context.normalized_destination,
701 &claim.request_constraint_digest,
702 )?;
703 if claim.supplemental_revocation_ids.is_empty() {
704 return Err(SupplementalQuotaError::EmptySupplementalRevocationIds);
705 }
706 validate_canonical_revocation_ids(&claim.supplemental_revocation_ids)?;
707
708 Ok(KernelVerifiedSupplementalQuotaClaim {
709 profile: claim.profile,
710 owner_id: expected_owner_id,
711 broker_capability_id: claim.broker_capability_id,
712 max_invocations: claim.max_invocations,
713 authorization_artifact_digest: claim.authorization_artifact_digest,
714 supplemental_revocation_ids: claim.supplemental_revocation_ids,
715 expires_at: claim.expires_at,
716 request_binding_hash: claim.request_binding_hash,
717 capability_id: claim.capability_id,
718 capability_digest: claim.capability_digest,
719 request_namespace_digest: claim.request_namespace_digest,
720 operation_id: claim.operation_id,
721 verifier_binding: context.verifier_binding.clone(),
722 })
723}
724
725#[allow(dead_code)]
728pub(crate) fn canonical_revocation_set_for_verified_claim(
729 leaf_capability_id: &str,
730 ancestor_capability_ids: &[String],
731 claim: &KernelVerifiedSupplementalQuotaClaim,
732) -> Result<CanonicalRevocationSet, SupplementalQuotaError> {
733 ensure_context_match("capability_id", leaf_capability_id, claim.capability_id())?;
734 let total_ids = 1usize
735 .checked_add(ancestor_capability_ids.len())
736 .and_then(|count| count.checked_add(claim.supplemental_revocation_ids().len()))
737 .ok_or(SupplementalQuotaError::LimitExceeded {
738 field: "admission_revocation_ids",
739 actual: usize::MAX,
740 maximum: MAX_ADMISSION_REVOCATION_IDS,
741 })?;
742 ensure_bounded(
743 "admission_revocation_ids",
744 total_ids,
745 MAX_ADMISSION_REVOCATION_IDS,
746 )?;
747
748 let mut ids = Vec::with_capacity(total_ids);
749 ids.push(leaf_capability_id.to_string());
750 ids.extend_from_slice(ancestor_capability_ids);
751 ids.extend_from_slice(claim.supplemental_revocation_ids());
752 CanonicalRevocationSet::canonicalize(ids)
753}
754
755#[derive(Debug, Clone, PartialEq, Eq)]
757pub struct CanonicalRevocationSet {
758 ids: Vec<String>,
759 digest: String,
760}
761
762impl CanonicalRevocationSet {
763 pub fn canonicalize(mut ids: Vec<String>) -> Result<Self, SupplementalQuotaError> {
768 if ids.is_empty() {
769 return Err(SupplementalQuotaError::EmptyRevocationSet);
770 }
771 ensure_bounded(
772 "admission_revocation_ids",
773 ids.len(),
774 MAX_ADMISSION_REVOCATION_IDS,
775 )?;
776 for id in &ids {
777 ensure_bounded(
778 "admission_revocation_id",
779 id.len(),
780 MAX_SUPPLEMENTAL_REVOCATION_ID_BYTES,
781 )?;
782 }
783 if ids.iter().any(String::is_empty) {
784 return Err(SupplementalQuotaError::EmptyRevocationId);
785 }
786 ids.sort_unstable_by(|left, right| left.as_bytes().cmp(right.as_bytes()));
787 if let Some(duplicate) = ids.windows(2).find(|pair| pair[0] == pair[1]) {
788 return Err(SupplementalQuotaError::DuplicateRevocationId(
789 duplicate[0].clone(),
790 ));
791 }
792 let digest = domain_separated_digest(ADMISSION_REVOCATION_SET_DOMAIN, &ids)?;
793 Ok(Self { ids, digest })
794 }
795
796 pub fn from_canonical_parts(
800 ids: Vec<String>,
801 digest: String,
802 ) -> Result<Self, SupplementalQuotaError> {
803 if ids.is_empty() {
804 return Err(SupplementalQuotaError::EmptyRevocationSet);
805 }
806 ensure_bounded(
807 "admission_revocation_ids",
808 ids.len(),
809 MAX_ADMISSION_REVOCATION_IDS,
810 )?;
811 for id in &ids {
812 ensure_bounded(
813 "admission_revocation_id",
814 id.len(),
815 MAX_SUPPLEMENTAL_REVOCATION_ID_BYTES,
816 )?;
817 }
818 ensure_sha256_hex("admission_revocation_digest", &digest)?;
819 validate_canonical_revocation_ids(&ids)?;
820 if domain_separated_digest(ADMISSION_REVOCATION_SET_DOMAIN, &ids)? != digest {
821 return Err(SupplementalQuotaError::RevocationDigestMismatch);
822 }
823 Ok(Self { ids, digest })
824 }
825
826 #[must_use]
827 pub fn ids(&self) -> &[String] {
828 &self.ids
829 }
830
831 #[must_use]
832 pub fn digest(&self) -> &str {
833 &self.digest
834 }
835
836 pub fn verify_exact(
839 &self,
840 presented_ids: &[String],
841 presented_digest: &str,
842 ) -> Result<(), SupplementalQuotaError> {
843 ensure_sha256_hex("admission_revocation_digest", presented_digest)?;
844 ensure_bounded(
845 "admission_revocation_ids",
846 presented_ids.len(),
847 MAX_ADMISSION_REVOCATION_IDS,
848 )?;
849 for id in presented_ids {
850 ensure_bounded(
851 "admission_revocation_id",
852 id.len(),
853 MAX_SUPPLEMENTAL_REVOCATION_ID_BYTES,
854 )?;
855 }
856 validate_canonical_revocation_ids(presented_ids)?;
857 let expected_presented_digest =
858 domain_separated_digest(ADMISSION_REVOCATION_SET_DOMAIN, &presented_ids)?;
859 if presented_digest != expected_presented_digest {
860 return Err(SupplementalQuotaError::RevocationDigestMismatch);
861 }
862 if presented_ids != self.ids || presented_digest != self.digest {
863 return Err(SupplementalQuotaError::RevocationSetMismatch);
864 }
865 Ok(())
866 }
867}
868
869fn validate_canonical_revocation_ids(ids: &[String]) -> Result<(), SupplementalQuotaError> {
870 if ids.iter().any(String::is_empty) {
871 return Err(SupplementalQuotaError::EmptyRevocationId);
872 }
873 for pair in ids.windows(2) {
874 if pair[0] == pair[1] {
875 return Err(SupplementalQuotaError::DuplicateRevocationId(
876 pair[0].clone(),
877 ));
878 }
879 if pair[0].as_bytes() > pair[1].as_bytes() {
880 return Err(SupplementalQuotaError::RevocationIdsNotCanonical);
881 }
882 }
883 Ok(())
884}
885
886#[cfg(test)]
887#[path = "supplemental_quota_tests.rs"]
888mod tests;