Skip to main content

canic_core/api/auth/
mod.rs

1use crate::{
2    cdk::types::Principal,
3    dto::{
4        auth::{
5            AttestationKeySet, DelegatedToken, DelegatedTokenClaims, DelegationCert,
6            DelegationProof, DelegationProvisionResponse, DelegationProvisionStatus,
7            DelegationProvisionTargetKind, DelegationRequest, RoleAttestationRequest,
8            SignedRoleAttestation,
9        },
10        error::{Error, ErrorCode},
11        rpc::{Request as RootRequest, Response as RootCapabilityResponse},
12    },
13    error::InternalErrorClass,
14    ids::cap,
15    log,
16    log::Topic,
17    ops::{
18        auth::{DelegatedTokenOps, audience},
19        config::ConfigOps,
20        ic::IcOps,
21        rpc::RpcOps,
22        runtime::env::EnvOps,
23        runtime::metrics::auth::{
24            record_attestation_refresh_failed, record_delegation_provision_complete,
25            record_delegation_verifier_target_count, record_delegation_verifier_target_failed,
26            record_delegation_verifier_target_missing, record_signer_issue_without_proof,
27        },
28        storage::auth::DelegationStateOps,
29    },
30    protocol,
31    workflow::rpc::request::handler::RootResponseWorkflow,
32};
33use std::borrow::Borrow;
34
35// Internal auth pipeline:
36// - `session` owns delegated-session ingress and replay/session state handling.
37// - `admin` owns explicit root-driven fanout preparation and routing.
38// - `proof_store` owns proof-install validation and storage/cache side effects.
39//
40// Keep these modules free of lateral calls to each other. Coordination stays here,
41// and shared invariants should live in dedicated seams like `ops::auth::audience`.
42mod admin;
43mod metadata;
44mod proof_store;
45mod session;
46mod verify_flow;
47
48///
49/// DelegationApi
50///
51/// Requires auth.delegated_tokens.enabled = true in config.
52///
53
54pub struct DelegationApi;
55
56impl DelegationApi {
57    const DELEGATED_TOKENS_DISABLED: &str =
58        "delegated token auth disabled; set auth.delegated_tokens.enabled=true in canic.toml";
59    const MAX_DELEGATED_SESSION_TTL_SECS: u64 = 24 * 60 * 60;
60    const SESSION_BOOTSTRAP_TOKEN_FINGERPRINT_DOMAIN: &[u8] =
61        b"canic-session-bootstrap-token-fingerprint:v1";
62
63    fn map_delegation_error(err: crate::InternalError) -> Error {
64        match err.class() {
65            InternalErrorClass::Infra | InternalErrorClass::Ops | InternalErrorClass::Workflow => {
66                Error::internal(err.to_string())
67            }
68            _ => Error::from(err),
69        }
70    }
71
72    /// Full delegation proof verification (structure + signature).
73    ///
74    /// Purely local verification; does not read certified data or require a
75    /// query context.
76    pub fn verify_delegation_proof(
77        proof: &DelegationProof,
78        authority_pid: Principal,
79    ) -> Result<(), Error> {
80        DelegatedTokenOps::verify_delegation_proof(proof, authority_pid)
81            .map_err(Self::map_delegation_error)
82    }
83
84    #[cfg(canic_test_delegation_material)]
85    #[must_use]
86    pub fn current_signing_proof_for_test() -> Option<DelegationProof> {
87        DelegationStateOps::latest_proof_dto()
88    }
89
90    /// Return whether this canister currently has a local signing proof.
91    #[must_use]
92    pub fn has_signing_proof() -> bool {
93        DelegationStateOps::latest_proof_dto().is_some()
94    }
95
96    async fn sign_token(
97        claims: DelegatedTokenClaims,
98        proof: DelegationProof,
99    ) -> Result<DelegatedToken, Error> {
100        DelegatedTokenOps::sign_token(claims, proof)
101            .await
102            .map_err(Self::map_delegation_error)
103    }
104
105    /// Resolve the local shard public key in SEC1 encoding.
106    pub async fn local_shard_public_key_sec1() -> Result<Vec<u8>, Error> {
107        DelegatedTokenOps::local_shard_public_key_sec1(IcOps::canister_self())
108            .await
109            .map_err(Self::map_delegation_error)
110    }
111
112    /// Issue a delegated token using a reusable local proof when possible.
113    ///
114    /// If the proof is missing or no longer valid for the requested claims, this
115    /// performs canonical shard-initiated setup and retries with the refreshed proof.
116    pub async fn issue_token(claims: DelegatedTokenClaims) -> Result<DelegatedToken, Error> {
117        let proof = Self::ensure_signing_proof(&claims).await?;
118        let claims = Self::canonicalize_claims_for_proof(claims, &proof);
119        Self::sign_token(claims, proof).await
120    }
121
122    /// Full delegated token verification (structure + signature).
123    ///
124    /// Purely local verification; does not read certified data or require a
125    /// query context.
126    pub fn verify_token(
127        token: &DelegatedToken,
128        authority_pid: Principal,
129        now_secs: u64,
130    ) -> Result<(), Error> {
131        DelegatedTokenOps::verify_token(token, authority_pid, now_secs, IcOps::canister_self())
132            .map(|_| ())
133            .map_err(Self::map_delegation_error)
134    }
135
136    /// Verify a delegated token and return verified contents.
137    ///
138    /// This is intended for application-layer session construction.
139    /// It performs full verification and returns verified claims and cert.
140    pub fn verify_token_verified(
141        token: &DelegatedToken,
142        authority_pid: Principal,
143        now_secs: u64,
144    ) -> Result<(DelegatedTokenClaims, DelegationCert), Error> {
145        DelegatedTokenOps::verify_token(token, authority_pid, now_secs, IcOps::canister_self())
146            .map(crate::ops::auth::VerifiedDelegatedToken::into_parts)
147            .map_err(Self::map_delegation_error)
148    }
149
150    /// Verify a delegated token and require its subject to match `msg_caller()`.
151    ///
152    /// This issuer-side helper does not require the old token audience to
153    /// include the local signer, which allows stale-audience reissue flows.
154    pub fn verify_token_for_caller(
155        token: &DelegatedToken,
156        authority_pid: Principal,
157        now_secs: u64,
158    ) -> Result<(DelegatedTokenClaims, DelegationCert), Error> {
159        let verified = DelegatedTokenOps::verify_token_for_reissue(token, authority_pid, now_secs)
160            .map_err(Self::map_delegation_error)?;
161        Self::ensure_claims_bound_to_caller(&verified.claims.to_dto(), IcOps::msg_caller())?;
162        Ok(verified.into_parts())
163    }
164
165    /// Reissue a caller-bound token for a new audience without extending expiry.
166    ///
167    /// Scopes and `ext` are preserved. The replacement expiry is capped at the
168    /// old token expiry, so this refreshes audience only and does not renew the
169    /// session.
170    pub async fn reissue_token<A>(token: DelegatedToken, aud: A) -> Result<DelegatedToken, Error>
171    where
172        A: IntoIterator,
173        A::Item: Borrow<Principal>,
174    {
175        let aud = Self::normalize_audience(aud)?;
176        let root_pid = EnvOps::root_pid().map_err(Error::from)?;
177        let now_secs = IcOps::now_secs();
178        let (old_claims, _) = Self::verify_token_for_caller(&token, root_pid, now_secs)?;
179        let replacement_claims = DelegatedTokenClaims {
180            aud,
181            iat: now_secs,
182            ..old_claims.clone()
183        };
184
185        Self::reissue_token_from_verified(old_claims, replacement_claims).await
186    }
187
188    /// Ensure the caller has a valid delegated token for the requested audience.
189    ///
190    /// With no token, this mints a default `verify`-scoped token for
191    /// `msg_caller()`. With a caller-bound token, this returns it unchanged when
192    /// it already covers the audience or reissues it without extending expiry.
193    pub async fn ensure_token<A>(
194        token: Option<DelegatedToken>,
195        aud: A,
196    ) -> Result<DelegatedToken, Error>
197    where
198        A: IntoIterator,
199        A::Item: Borrow<Principal>,
200    {
201        let requested_aud = Self::normalize_audience(aud)?;
202        match token {
203            Some(token) => Self::ensure_existing_token_for_audience(token, requested_aud).await,
204            None => Self::issue_token_for_caller_audience(requested_aud).await,
205        }
206    }
207
208    /// Reissue a token from previously verified claims and proposed claims.
209    ///
210    /// CANIC enforces same `sub`, same `shard_pid`, no expiry extension, and a
211    /// default scope-subset rule.
212    pub async fn reissue_token_from_verified(
213        old_claims: DelegatedTokenClaims,
214        replacement_claims: DelegatedTokenClaims,
215    ) -> Result<DelegatedToken, Error> {
216        Self::ensure_reissue_claims_allowed(&old_claims, &replacement_claims)?;
217        let proof = Self::ensure_signing_proof(&replacement_claims).await?;
218        let replacement_claims = Self::canonicalize_reissue_claims_for_proof(
219            replacement_claims,
220            &proof,
221            old_claims.exp,
222        )?;
223        Self::sign_token(replacement_claims, proof).await
224    }
225
226    // Return an existing caller-bound token or reissue it to cover missing audience entries.
227    async fn ensure_existing_token_for_audience(
228        token: DelegatedToken,
229        requested_aud: Vec<Principal>,
230    ) -> Result<DelegatedToken, Error> {
231        let root_pid = EnvOps::root_pid().map_err(Error::from)?;
232        let now_secs = IcOps::now_secs();
233        let (old_claims, _) = Self::verify_token_for_caller(&token, root_pid, now_secs)?;
234        if audience::principals_subset(&requested_aud, &old_claims.aud) {
235            return Ok(token);
236        }
237
238        let mut aud = old_claims.aud.clone();
239        Self::append_missing_principals(&mut aud, &requested_aud);
240        let replacement_claims = DelegatedTokenClaims {
241            aud,
242            iat: now_secs,
243            ..old_claims.clone()
244        };
245
246        Self::reissue_token_from_verified(old_claims, replacement_claims).await
247    }
248
249    // Issue the initial caller-bound token for an authenticated wallet/session principal.
250    async fn issue_token_for_caller_audience(aud: Vec<Principal>) -> Result<DelegatedToken, Error> {
251        let caller = IcOps::msg_caller();
252        if let Err(reason) = crate::access::auth::validate_delegated_session_subject(caller) {
253            return Err(Error::forbidden(format!(
254                "delegated token caller rejected: {reason}"
255            )));
256        }
257
258        let now_secs = IcOps::now_secs();
259        let ttl_secs = ConfigOps::delegated_tokens_config()
260            .map_err(Error::from)?
261            .max_ttl_secs
262            .unwrap_or(Self::MAX_DELEGATED_SESSION_TTL_SECS);
263        let claims = DelegatedTokenClaims {
264            sub: caller,
265            shard_pid: IcOps::canister_self(),
266            scopes: vec![cap::VERIFY.to_string()],
267            aud,
268            iat: now_secs,
269            exp: now_secs.saturating_add(ttl_secs),
270            ext: None,
271        };
272
273        Self::issue_token(claims).await
274    }
275
276    /// Canonical shard-initiated delegation request (user_shard -> root).
277    ///
278    /// Caller must match shard_pid and be registered to the subnet.
279    pub async fn request_delegation(
280        request: DelegationRequest,
281    ) -> Result<DelegationProvisionResponse, Error> {
282        let request = metadata::with_root_request_metadata(request);
283        Self::request_delegation_remote(request).await
284    }
285
286    pub async fn request_role_attestation(
287        request: RoleAttestationRequest,
288    ) -> Result<SignedRoleAttestation, Error> {
289        let request = metadata::with_root_attestation_request_metadata(request);
290        let response = Self::request_role_attestation_remote(request).await?;
291
292        match response {
293            RootCapabilityResponse::RoleAttestationIssued(response) => Ok(response),
294            _ => Err(Error::internal(
295                "invalid root response type for role attestation request",
296            )),
297        }
298    }
299
300    pub async fn attestation_key_set() -> Result<AttestationKeySet, Error> {
301        DelegatedTokenOps::attestation_key_set()
302            .await
303            .map_err(Self::map_delegation_error)
304    }
305
306    /// Warm the root delegation and attestation key caches once.
307    pub async fn prewarm_root_key_material() -> Result<(), Error> {
308        EnvOps::require_root().map_err(Error::from)?;
309        DelegatedTokenOps::prewarm_root_key_material()
310            .await
311            .map_err(|err| {
312                log!(Topic::Auth, Warn, "root auth key prewarm failed: {err}");
313                Self::map_delegation_error(err)
314            })
315    }
316
317    pub fn replace_attestation_key_set(key_set: AttestationKeySet) {
318        DelegatedTokenOps::replace_attestation_key_set(key_set);
319    }
320
321    pub async fn verify_role_attestation(
322        attestation: &SignedRoleAttestation,
323        min_accepted_epoch: u64,
324    ) -> Result<(), Error> {
325        let configured_min_accepted_epoch = ConfigOps::role_attestation_config()
326            .map_err(Error::from)?
327            .min_accepted_epoch_by_role
328            .get(attestation.payload.role.as_str())
329            .copied();
330        let min_accepted_epoch = verify_flow::resolve_min_accepted_epoch(
331            min_accepted_epoch,
332            configured_min_accepted_epoch,
333        );
334
335        let caller = IcOps::msg_caller();
336        let self_pid = IcOps::canister_self();
337        let now_secs = IcOps::now_secs();
338        let verifier_subnet = Some(EnvOps::subnet_pid().map_err(Error::from)?);
339        let root_pid = EnvOps::root_pid().map_err(Error::from)?;
340
341        let verify = || {
342            DelegatedTokenOps::verify_role_attestation_cached(
343                attestation,
344                caller,
345                self_pid,
346                verifier_subnet,
347                now_secs,
348                min_accepted_epoch,
349            )
350            .map(|_| ())
351        };
352        let refresh = || async {
353            let key_set: AttestationKeySet =
354                RpcOps::call_rpc_result(root_pid, protocol::CANIC_ATTESTATION_KEY_SET, ()).await?;
355            DelegatedTokenOps::replace_attestation_key_set(key_set);
356            Ok(())
357        };
358
359        match verify_flow::verify_role_attestation_with_single_refresh(verify, refresh).await {
360            Ok(()) => Ok(()),
361            Err(verify_flow::RoleAttestationVerifyFlowError::Initial(err)) => {
362                verify_flow::record_attestation_verifier_rejection(&err);
363                verify_flow::log_attestation_verifier_rejection(
364                    &err,
365                    attestation,
366                    caller,
367                    self_pid,
368                    "cached",
369                );
370                Err(Self::map_delegation_error(err.into()))
371            }
372            Err(verify_flow::RoleAttestationVerifyFlowError::Refresh { trigger, source }) => {
373                verify_flow::record_attestation_verifier_rejection(&trigger);
374                verify_flow::log_attestation_verifier_rejection(
375                    &trigger,
376                    attestation,
377                    caller,
378                    self_pid,
379                    "cache_miss_refresh",
380                );
381                record_attestation_refresh_failed();
382                log!(
383                    Topic::Auth,
384                    Warn,
385                    "role attestation refresh failed local={} caller={} key_id={} error={}",
386                    self_pid,
387                    caller,
388                    attestation.key_id,
389                    source
390                );
391                Err(Self::map_delegation_error(source))
392            }
393            Err(verify_flow::RoleAttestationVerifyFlowError::PostRefresh(err)) => {
394                verify_flow::record_attestation_verifier_rejection(&err);
395                verify_flow::log_attestation_verifier_rejection(
396                    &err,
397                    attestation,
398                    caller,
399                    self_pid,
400                    "post_refresh",
401                );
402                Err(Self::map_delegation_error(err.into()))
403            }
404        }
405    }
406
407    fn require_proof() -> Result<DelegationProof, Error> {
408        let cfg = ConfigOps::delegated_tokens_config().map_err(Error::from)?;
409        if !cfg.enabled {
410            return Err(Error::forbidden(Self::DELEGATED_TOKENS_DISABLED));
411        }
412
413        DelegationStateOps::latest_proof_dto().ok_or_else(|| {
414            record_signer_issue_without_proof();
415            Error::not_found("delegation proof not installed")
416        })
417    }
418
419    // Resolve a proof that is currently usable for token issuance.
420    async fn ensure_signing_proof(claims: &DelegatedTokenClaims) -> Result<DelegationProof, Error> {
421        let now_secs = IcOps::now_secs();
422
423        match Self::require_proof() {
424            Ok(proof)
425                if !DelegatedTokenOps::proof_reusable_for_claims(&proof, claims, now_secs) =>
426            {
427                Self::setup_delegation(claims).await
428            }
429            Ok(proof) => Ok(proof),
430            Err(err) if err.code == ErrorCode::NotFound => Self::setup_delegation(claims).await,
431            Err(err) => Err(err),
432        }
433    }
434
435    // Provision a fresh delegation from root, then resolve the latest locally stored proof.
436    async fn setup_delegation(claims: &DelegatedTokenClaims) -> Result<DelegationProof, Error> {
437        let mut request = Self::delegation_request_from_claims(claims)?;
438        request.shard_public_key_sec1 = Some(
439            DelegatedTokenOps::local_shard_public_key_sec1(request.shard_pid)
440                .await
441                .map_err(Self::map_delegation_error)?,
442        );
443        let required_verifier_targets = request.verifier_targets.clone();
444        let response = Self::request_delegation_remote(request).await?;
445        Self::ensure_required_verifier_targets_provisioned(&required_verifier_targets, &response)?;
446        let proof = response.proof;
447        Self::store_local_signer_proof(proof.clone()).await?;
448        Ok(proof)
449    }
450
451    // Rebase claims onto a freshly issued proof window when delegation setup
452    // completed after the original token timestamps were chosen.
453    fn canonicalize_claims_for_proof(
454        claims: DelegatedTokenClaims,
455        proof: &DelegationProof,
456    ) -> DelegatedTokenClaims {
457        if claims.iat >= proof.cert.issued_at && claims.exp <= proof.cert.expires_at {
458            return claims;
459        }
460
461        DelegatedTokenClaims {
462            iat: proof.cert.issued_at,
463            exp: proof.cert.expires_at,
464            ..claims
465        }
466    }
467
468    // Bind verified token claims to the current IC caller.
469    fn ensure_claims_bound_to_caller(
470        claims: &DelegatedTokenClaims,
471        caller: Principal,
472    ) -> Result<(), Error> {
473        if claims.sub == caller {
474            Ok(())
475        } else {
476            Err(Error::forbidden(format!(
477                "delegated token subject '{}' does not match caller '{}'",
478                claims.sub, caller
479            )))
480        }
481    }
482
483    // Normalize caller-supplied audience principals with set semantics.
484    fn normalize_audience<A>(audience: A) -> Result<Vec<Principal>, Error>
485    where
486        A: IntoIterator,
487        A::Item: Borrow<Principal>,
488    {
489        let mut out = Vec::new();
490        for principal in audience {
491            let principal = *principal.borrow();
492            if !out.contains(&principal) {
493                out.push(principal);
494            }
495        }
496
497        if out.is_empty() {
498            return Err(Error::invalid("token audience must not be empty"));
499        }
500
501        Ok(out)
502    }
503
504    // Append principals not already present in the target vector.
505    fn append_missing_principals(target: &mut Vec<Principal>, source: &[Principal]) {
506        for principal in source {
507            if !target.contains(principal) {
508                target.push(*principal);
509            }
510        }
511    }
512
513    // Enforce same-session reissue invariants before resolving signing material.
514    fn ensure_reissue_claims_allowed(
515        old_claims: &DelegatedTokenClaims,
516        replacement_claims: &DelegatedTokenClaims,
517    ) -> Result<(), Error> {
518        if replacement_claims.aud.is_empty() {
519            return Err(Error::invalid(
520                "replacement token audience must not be empty",
521            ));
522        }
523
524        if replacement_claims.sub != old_claims.sub {
525            return Err(Error::forbidden(format!(
526                "replacement token subject '{}' must match old subject '{}'",
527                replacement_claims.sub, old_claims.sub
528            )));
529        }
530
531        if replacement_claims.shard_pid != old_claims.shard_pid {
532            return Err(Error::forbidden(format!(
533                "replacement token shard '{}' must match old shard '{}'",
534                replacement_claims.shard_pid, old_claims.shard_pid
535            )));
536        }
537
538        if replacement_claims.exp > old_claims.exp {
539            return Err(Error::forbidden(
540                "replacement token expiry must not exceed old token expiry",
541            ));
542        }
543
544        if replacement_claims.exp < replacement_claims.iat {
545            return Err(Error::invalid(
546                "replacement token expiry must not precede issued_at",
547            ));
548        }
549
550        if !audience::strings_subset(&replacement_claims.scopes, &old_claims.scopes) {
551            return Err(Error::forbidden(
552                "replacement token scopes must be a subset of old token scopes",
553            ));
554        }
555
556        Ok(())
557    }
558
559    // Rebase reissue timing onto the resolved proof while preserving the old-expiry cap.
560    fn canonicalize_reissue_claims_for_proof(
561        claims: DelegatedTokenClaims,
562        proof: &DelegationProof,
563        old_exp: u64,
564    ) -> Result<DelegatedTokenClaims, Error> {
565        let iat = claims.iat.max(proof.cert.issued_at);
566        let exp = claims.exp.min(old_exp).min(proof.cert.expires_at);
567
568        if exp < iat {
569            return Err(Error::invalid(
570                "replacement token expiry is outside the current signing proof window",
571            ));
572        }
573
574        Ok(DelegatedTokenClaims { iat, exp, ..claims })
575    }
576
577    // Build a canonical delegation request from token claims.
578    fn delegation_request_from_claims(
579        claims: &DelegatedTokenClaims,
580    ) -> Result<DelegationRequest, Error> {
581        let ttl_secs = claims.exp.saturating_sub(claims.iat);
582        if ttl_secs == 0 {
583            return Err(Error::invalid(
584                "delegation ttl_secs must be greater than zero",
585            ));
586        }
587
588        let signer_pid = IcOps::canister_self();
589        let root_pid = EnvOps::root_pid().map_err(Error::from)?;
590        let verifier_targets = DelegatedTokenOps::required_verifier_targets_from_audience(
591            &claims.aud,
592            signer_pid,
593            root_pid,
594            Self::is_registered_canister,
595        )
596        .map_err(|principal| {
597            Error::invalid(format!(
598                "delegation audience principal '{principal}' is invalid for canonical verifier provisioning"
599            ))
600        })?;
601
602        Ok(DelegationRequest {
603            shard_pid: signer_pid,
604            scopes: claims.scopes.clone(),
605            aud: claims.aud.clone(),
606            ttl_secs,
607            verifier_targets,
608            include_root_verifier: true,
609            shard_public_key_sec1: None,
610            metadata: None,
611        })
612    }
613
614    // Validate required verifier fanout and fail closed when any required target is missing/failing.
615    fn ensure_required_verifier_targets_provisioned(
616        required_targets: &[Principal],
617        response: &DelegationProvisionResponse,
618    ) -> Result<(), Error> {
619        let mut checked = Vec::new();
620        for target in required_targets {
621            if checked.contains(target) {
622                continue;
623            }
624            checked.push(*target);
625        }
626        record_delegation_verifier_target_count(checked.len());
627
628        for target in &checked {
629            let Some(result) = response.results.iter().find(|entry| {
630                entry.kind == DelegationProvisionTargetKind::Verifier && entry.target == *target
631            }) else {
632                record_delegation_verifier_target_missing();
633                return Err(Error::internal(format!(
634                    "delegation provisioning missing verifier target result for '{target}'"
635                )));
636            };
637
638            if result.status != DelegationProvisionStatus::Ok {
639                record_delegation_verifier_target_failed();
640                let detail = result
641                    .error
642                    .as_ref()
643                    .map_or_else(|| "unknown error".to_string(), ToString::to_string);
644                return Err(Error::internal(format!(
645                    "delegation provisioning failed for required verifier target '{target}': {detail}"
646                )));
647            }
648        }
649
650        record_delegation_provision_complete();
651        Ok(())
652    }
653
654    // Derive required verifier targets from audience with strict filtering/validation.
655    #[cfg(test)]
656    fn derive_required_verifier_targets_from_aud<F>(
657        audience: &[Principal],
658        signer_pid: Principal,
659        root_pid: Principal,
660        is_valid_target: F,
661    ) -> Result<Vec<Principal>, Error>
662    where
663        F: FnMut(Principal) -> bool,
664    {
665        DelegatedTokenOps::required_verifier_targets_from_audience(
666            audience,
667            signer_pid,
668            root_pid,
669            is_valid_target,
670        )
671        .map_err(|principal| {
672            Error::invalid(format!(
673                "delegation audience principal '{principal}' is invalid for canonical verifier provisioning"
674            ))
675        })
676    }
677
678    // Delegated audience invariants:
679    // 1. claims.aud must be non-empty.
680    // 2. claims.aud must be a set-subset of proof.cert.aud.
681    // 3. proof installation on target T requires T ∈ proof.cert.aud.
682    // 4. token acceptance on canister C requires C ∈ claims.aud.
683    //
684    // Keep ingress, fanout, install, and runtime checks aligned to this block.
685}
686
687impl DelegationApi {
688    // Execute one local root delegation provisioning request.
689    pub async fn request_delegation_root(
690        request: DelegationRequest,
691    ) -> Result<DelegationProvisionResponse, Error> {
692        let request = metadata::with_root_request_metadata(request);
693        let response = RootResponseWorkflow::response(RootRequest::issue_delegation(request))
694            .await
695            .map_err(Self::map_delegation_error)?;
696
697        match response {
698            RootCapabilityResponse::DelegationIssued(response) => Ok(response),
699            _ => Err(Error::internal(
700                "invalid root response type for delegation request",
701            )),
702        }
703    }
704
705    // Route a canonical delegation provisioning request over RPC to root.
706    async fn request_delegation_remote(
707        request: DelegationRequest,
708    ) -> Result<DelegationProvisionResponse, Error> {
709        let root_pid = EnvOps::root_pid().map_err(Error::from)?;
710        RpcOps::call_rpc_result(root_pid, protocol::CANIC_REQUEST_DELEGATION, request)
711            .await
712            .map_err(Self::map_delegation_error)
713    }
714
715    // Execute one local root role-attestation request.
716    pub async fn request_role_attestation_root(
717        request: RoleAttestationRequest,
718    ) -> Result<SignedRoleAttestation, Error> {
719        let request = metadata::with_root_attestation_request_metadata(request);
720        let response = RootResponseWorkflow::response(RootRequest::issue_role_attestation(request))
721            .await
722            .map_err(Self::map_delegation_error)?;
723
724        match response {
725            RootCapabilityResponse::RoleAttestationIssued(response) => Ok(response),
726            _ => Err(Error::internal(
727                "invalid root response type for role attestation request",
728            )),
729        }
730    }
731
732    // Route a canonical role-attestation request over RPC to root.
733    async fn request_role_attestation_remote(
734        request: RoleAttestationRequest,
735    ) -> Result<RootCapabilityResponse, Error> {
736        let root_pid = EnvOps::root_pid().map_err(Error::from)?;
737        RpcOps::call_rpc_result(root_pid, protocol::CANIC_REQUEST_ROLE_ATTESTATION, request)
738            .await
739            .map_err(Self::map_delegation_error)
740    }
741}
742
743#[cfg(test)]
744mod tests;