Skip to main content

chio_kernel/
supplemental_quota.rs

1//! Verification boundary for supplemental invocation quotas.
2//!
3//! The kernel treats supplemental authorization artifacts as opaque bytes. A
4//! trusted verifier installed by the runtime composition root parses and
5//! verifies those bytes, then returns a request-bound claim. The kernel checks
6//! every request binding again before exposing a quota owner or maximum.
7
8use 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
16/// The only supplemental invocation-quota profile supported by v1.
17pub 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/// Request facts supplied by the kernel, never by the supplemental artifact.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct SupplementalQuotaVerificationContext {
33    pub capability_id: String,
34    pub capability_digest: String,
35    /// Digest of the authenticated request namespace used for replay identity.
36    pub request_namespace_digest: String,
37    /// Deterministic durable admission-operation identifier for this request.
38    pub operation_id: String,
39    pub subject: PublicKey,
40    pub request_id: String,
41    pub normalized_destination: String,
42    /// Lowercase SHA-256 hex over canonical JSON for the complete normalized,
43    /// uncredentialed request arguments. The normalized value must include
44    /// every behavior-affecting method, path, query, header, body, and execution
45    /// option exposed by the adapter.
46    pub arguments_hash: String,
47    pub negotiated_profile: String,
48    pub negotiated_features: CapabilityNegotiation,
49    /// Identity and configuration pinned by the runtime composition root.
50    pub verifier_binding: SupplementalQuotaVerifierBinding,
51}
52
53/// Runtime evidence identifying the installed supplemental verifier.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct SupplementalQuotaVerifierBinding {
56    pub verifier_identity: String,
57    pub configuration_digest: String,
58}
59
60impl SupplementalQuotaVerifierBinding {
61    /// Validate composition-root identity before accepting requests.
62    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/// A claim returned by an installed trusted supplemental verifier.
88///
89/// This value is not admission authority by itself. The kernel accepts it only
90/// as the immediate result of its composition-installed verifier.
91#[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/// A supplemental claim rechecked and bound by the kernel.
114///
115/// Fields are private so request handling cannot substitute a caller-built
116/// quota key or maximum after verification.
117#[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/// Error returned by an installed supplemental verifier.
204#[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
219/// Trusted extension point installed by the runtime composition root.
220pub trait SupplementalQuotaVerifier: Send + Sync {
221    /// Verify the opaque signed extension against the kernel-built context.
222    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/// Hash the opaque authorization artifact exactly as received by the kernel.
342#[must_use]
343pub fn supplemental_authorization_artifact_digest(signed_extension: &[u8]) -> String {
344    sha256_hex(signed_extension)
345}
346
347/// Compute the request binding that a trusted verifier must return.
348pub 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
370/// Derive the broker quota owner without accepting a caller-provided key.
371pub(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/// Verify an opaque supplemental authorization and bind it to one request.
612///
613/// Missing verifier support and every mismatch deny before a quota claim can
614/// reach the budget authority.
615#[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/// Combine one rechecked supplemental claim with the capability lineage that
726/// the kernel validated for the same request.
727#[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/// The exact sorted revocation identifiers bound into an admission operation.
756#[derive(Debug, Clone, PartialEq, Eq)]
757pub struct CanonicalRevocationSet {
758    ids: Vec<String>,
759    digest: String,
760}
761
762impl CanonicalRevocationSet {
763    /// Sort and bind a set of revocation identifiers by UTF-8 byte order.
764    ///
765    /// This constructor proves canonicality and digest integrity only. It does
766    /// not prove that the caller supplied a complete capability lineage.
767    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    /// Reconstruct a persisted or transported canonical set after checking its
797    /// order, bounds, uniqueness, and digest. This does not prove semantic
798    /// completeness of the identifiers.
799    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    /// Recheck an exact capture-time presentation against the admission-bound
837    /// set. The caller must present canonical order and the matching digest.
838    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;