Skip to main content

canic_core/api/auth/
mod.rs

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