Skip to main content

canic_core/api/auth/
mod.rs

1use crate::{
2    access::auth::validate_delegated_session_subject,
3    cdk::types::Principal,
4    dto::{
5        auth::{
6            AttestationKeySet, DelegatedToken, DelegatedTokenClaims, DelegationCert,
7            DelegationProof, DelegationProvisionResponse, DelegationProvisionTargetKind,
8            DelegationRequest, RoleAttestationRequest, SignedRoleAttestation,
9        },
10        error::{Error, ErrorCode},
11        rpc::{Request as RootRequest, Response as RootCapabilityResponse},
12    },
13    error::InternalErrorClass,
14    log,
15    log::Topic,
16    ops::{
17        auth::DelegatedTokenOps,
18        config::ConfigOps,
19        ic::IcOps,
20        rpc::RpcOps,
21        runtime::env::EnvOps,
22        runtime::metrics::auth::{
23            record_attestation_refresh_failed, record_session_bootstrap_rejected_disabled,
24            record_session_bootstrap_rejected_replay_conflict,
25            record_session_bootstrap_rejected_replay_reused,
26            record_session_bootstrap_rejected_subject_mismatch,
27            record_session_bootstrap_rejected_subject_rejected,
28            record_session_bootstrap_rejected_token_invalid,
29            record_session_bootstrap_rejected_ttl_invalid,
30            record_session_bootstrap_rejected_wallet_caller_rejected,
31            record_session_bootstrap_replay_idempotent, record_session_cleared,
32            record_session_created, record_session_pruned, record_session_replaced,
33            record_signer_issue_without_proof,
34        },
35        storage::auth::{DelegatedSession, DelegatedSessionBootstrapBinding, DelegationStateOps},
36    },
37    protocol,
38    workflow::rpc::request::handler::RootResponseWorkflow,
39};
40use sha2::{Digest, Sha256};
41
42mod metadata;
43mod verify_flow;
44
45///
46/// DelegationApi
47///
48/// Requires auth.delegated_tokens.enabled = true in config.
49///
50
51pub struct DelegationApi;
52
53impl DelegationApi {
54    const DELEGATED_TOKENS_DISABLED: &str =
55        "delegated token auth disabled; set auth.delegated_tokens.enabled=true in canic.toml";
56    const MAX_DELEGATED_SESSION_TTL_SECS: u64 = 24 * 60 * 60;
57    const SESSION_BOOTSTRAP_TOKEN_FINGERPRINT_DOMAIN: &[u8] =
58        b"canic-session-bootstrap-token-fingerprint:v1";
59
60    fn map_delegation_error(err: crate::InternalError) -> Error {
61        match err.class() {
62            InternalErrorClass::Infra | InternalErrorClass::Ops | InternalErrorClass::Workflow => {
63                Error::internal(err.to_string())
64            }
65            _ => Error::from(err),
66        }
67    }
68
69    /// Full delegation proof verification (structure + signature).
70    ///
71    /// Purely local verification; does not read certified data or require a
72    /// query context.
73    pub fn verify_delegation_proof(
74        proof: &DelegationProof,
75        authority_pid: Principal,
76    ) -> Result<(), Error> {
77        DelegatedTokenOps::verify_delegation_proof(proof, authority_pid)
78            .map_err(Self::map_delegation_error)
79    }
80
81    async fn sign_token(
82        claims: DelegatedTokenClaims,
83        proof: DelegationProof,
84    ) -> Result<DelegatedToken, Error> {
85        DelegatedTokenOps::sign_token(claims, proof)
86            .await
87            .map_err(Self::map_delegation_error)
88    }
89
90    /// Issue a delegated token using a reusable local proof when possible.
91    ///
92    /// If the proof is missing or no longer valid for the requested claims, this
93    /// performs canonical shard-initiated setup and retries with the refreshed proof.
94    pub async fn issue_token(claims: DelegatedTokenClaims) -> Result<DelegatedToken, Error> {
95        let proof = Self::ensure_signing_proof(&claims).await?;
96        Self::sign_token(claims, proof).await
97    }
98
99    /// Full delegated token verification (structure + signature).
100    ///
101    /// Purely local verification; does not read certified data or require a
102    /// query context.
103    pub fn verify_token(
104        token: &DelegatedToken,
105        authority_pid: Principal,
106        now_secs: u64,
107    ) -> Result<(), Error> {
108        DelegatedTokenOps::verify_token(token, authority_pid, now_secs, IcOps::canister_self())
109            .map(|_| ())
110            .map_err(Self::map_delegation_error)
111    }
112
113    /// Verify a delegated token and return verified contents.
114    ///
115    /// This is intended for application-layer session construction.
116    /// It performs full verification and returns verified claims and cert.
117    pub fn verify_token_verified(
118        token: &DelegatedToken,
119        authority_pid: Principal,
120        now_secs: u64,
121    ) -> Result<(DelegatedTokenClaims, DelegationCert), Error> {
122        DelegatedTokenOps::verify_token(token, authority_pid, now_secs, IcOps::canister_self())
123            .map(|verified| (verified.claims, verified.cert))
124            .map_err(Self::map_delegation_error)
125    }
126
127    /// Canonical shard-initiated delegation request (user_shard -> root).
128    ///
129    /// Caller must match shard_pid and be registered to the subnet.
130    pub async fn request_delegation(
131        request: DelegationRequest,
132    ) -> Result<DelegationProvisionResponse, Error> {
133        let request = metadata::with_root_request_metadata(request);
134        if EnvOps::is_root() {
135            let response = RootResponseWorkflow::response(RootRequest::issue_delegation(request))
136                .await
137                .map_err(Self::map_delegation_error)?;
138
139            return match response {
140                RootCapabilityResponse::DelegationIssued(response) => Ok(response),
141                _ => Err(Error::internal(
142                    "invalid root response type for delegation request",
143                )),
144            };
145        }
146
147        let root_pid = EnvOps::root_pid().map_err(Error::from)?;
148        RpcOps::call_rpc_result(root_pid, protocol::CANIC_REQUEST_DELEGATION, request)
149            .await
150            .map_err(Self::map_delegation_error)
151    }
152
153    pub async fn request_role_attestation(
154        request: RoleAttestationRequest,
155    ) -> Result<SignedRoleAttestation, Error> {
156        let request = metadata::with_root_attestation_request_metadata(request);
157        let response = RootResponseWorkflow::response(RootRequest::issue_role_attestation(request))
158            .await
159            .map_err(Self::map_delegation_error)?;
160
161        match response {
162            RootCapabilityResponse::RoleAttestationIssued(response) => Ok(response),
163            _ => Err(Error::internal(
164                "invalid root response type for role attestation request",
165            )),
166        }
167    }
168
169    pub async fn attestation_key_set() -> Result<AttestationKeySet, Error> {
170        DelegatedTokenOps::attestation_key_set()
171            .await
172            .map_err(Self::map_delegation_error)
173    }
174
175    pub fn replace_attestation_key_set(key_set: AttestationKeySet) {
176        DelegatedTokenOps::replace_attestation_key_set(key_set);
177    }
178
179    pub async fn verify_role_attestation(
180        attestation: &SignedRoleAttestation,
181        min_accepted_epoch: u64,
182    ) -> Result<(), Error> {
183        let configured_min_accepted_epoch = ConfigOps::role_attestation_config()
184            .map_err(Error::from)?
185            .min_accepted_epoch_by_role
186            .get(attestation.payload.role.as_str())
187            .copied();
188        let min_accepted_epoch = verify_flow::resolve_min_accepted_epoch(
189            min_accepted_epoch,
190            configured_min_accepted_epoch,
191        );
192
193        let caller = IcOps::msg_caller();
194        let self_pid = IcOps::canister_self();
195        let now_secs = IcOps::now_secs();
196        let verifier_subnet = Some(EnvOps::subnet_pid().map_err(Error::from)?);
197        let root_pid = EnvOps::root_pid().map_err(Error::from)?;
198
199        let verify = || {
200            DelegatedTokenOps::verify_role_attestation_cached(
201                attestation,
202                caller,
203                self_pid,
204                verifier_subnet,
205                now_secs,
206                min_accepted_epoch,
207            )
208            .map(|_| ())
209        };
210        let refresh = || async {
211            let key_set: AttestationKeySet =
212                RpcOps::call_rpc_result(root_pid, protocol::CANIC_ATTESTATION_KEY_SET, ()).await?;
213            DelegatedTokenOps::replace_attestation_key_set(key_set);
214            Ok(())
215        };
216
217        match verify_flow::verify_role_attestation_with_single_refresh(verify, refresh).await {
218            Ok(()) => Ok(()),
219            Err(verify_flow::RoleAttestationVerifyFlowError::Initial(err)) => {
220                verify_flow::record_attestation_verifier_rejection(&err);
221                verify_flow::log_attestation_verifier_rejection(
222                    &err,
223                    attestation,
224                    caller,
225                    self_pid,
226                    "cached",
227                );
228                Err(Self::map_delegation_error(err.into()))
229            }
230            Err(verify_flow::RoleAttestationVerifyFlowError::Refresh { trigger, source }) => {
231                verify_flow::record_attestation_verifier_rejection(&trigger);
232                verify_flow::log_attestation_verifier_rejection(
233                    &trigger,
234                    attestation,
235                    caller,
236                    self_pid,
237                    "cache_miss_refresh",
238                );
239                record_attestation_refresh_failed();
240                log!(
241                    Topic::Auth,
242                    Warn,
243                    "role attestation refresh failed local={} caller={} key_id={} error={}",
244                    self_pid,
245                    caller,
246                    attestation.key_id,
247                    source
248                );
249                Err(Self::map_delegation_error(source))
250            }
251            Err(verify_flow::RoleAttestationVerifyFlowError::PostRefresh(err)) => {
252                verify_flow::record_attestation_verifier_rejection(&err);
253                verify_flow::log_attestation_verifier_rejection(
254                    &err,
255                    attestation,
256                    caller,
257                    self_pid,
258                    "post_refresh",
259                );
260                Err(Self::map_delegation_error(err.into()))
261            }
262        }
263    }
264
265    /// Persist a temporary delegated session subject for the caller wallet.
266    pub fn set_delegated_session_subject(
267        delegated_subject: Principal,
268        bootstrap_token: DelegatedToken,
269        requested_ttl_secs: Option<u64>,
270    ) -> Result<(), Error> {
271        let cfg = ConfigOps::delegated_tokens_config().map_err(Error::from)?;
272        if !cfg.enabled {
273            record_session_bootstrap_rejected_disabled();
274            return Err(Error::forbidden(Self::DELEGATED_TOKENS_DISABLED));
275        }
276
277        let wallet_caller = IcOps::msg_caller();
278        if let Err(reason) = validate_delegated_session_subject(wallet_caller) {
279            record_session_bootstrap_rejected_wallet_caller_rejected();
280            return Err(Error::forbidden(format!(
281                "delegated session wallet caller rejected: {reason}"
282            )));
283        }
284
285        if let Err(reason) = validate_delegated_session_subject(delegated_subject) {
286            record_session_bootstrap_rejected_subject_rejected();
287            return Err(Error::forbidden(format!(
288                "delegated session subject rejected: {reason}"
289            )));
290        }
291
292        let issued_at = IcOps::now_secs();
293        let authority_pid = EnvOps::root_pid().map_err(Error::from)?;
294        let self_pid = IcOps::canister_self();
295        let verified =
296            DelegatedTokenOps::verify_token(&bootstrap_token, authority_pid, issued_at, self_pid)
297                .map_err(|err| {
298                record_session_bootstrap_rejected_token_invalid();
299                Self::map_delegation_error(err)
300            })?;
301
302        if verified.claims.sub != delegated_subject {
303            record_session_bootstrap_rejected_subject_mismatch();
304            return Err(Error::forbidden(format!(
305                "delegated session subject mismatch: requested={} token_subject={}",
306                delegated_subject, verified.claims.sub
307            )));
308        }
309
310        let configured_max_ttl_secs = cfg
311            .max_ttl_secs
312            .unwrap_or(Self::MAX_DELEGATED_SESSION_TTL_SECS);
313        let expires_at = Self::clamp_delegated_session_expires_at(
314            issued_at,
315            verified.claims.exp,
316            configured_max_ttl_secs,
317            requested_ttl_secs,
318        )
319        .inspect_err(|_| record_session_bootstrap_rejected_ttl_invalid())?;
320
321        let token_fingerprint =
322            Self::delegated_session_bootstrap_token_fingerprint(&bootstrap_token)
323                .inspect_err(|_| record_session_bootstrap_rejected_token_invalid())?;
324
325        if Self::enforce_bootstrap_replay_policy(
326            wallet_caller,
327            delegated_subject,
328            token_fingerprint,
329            issued_at,
330        )? {
331            return Ok(());
332        }
333
334        let had_active_session =
335            DelegationStateOps::delegated_session(wallet_caller, issued_at).is_some();
336
337        DelegationStateOps::upsert_delegated_session(
338            DelegatedSession {
339                wallet_pid: wallet_caller,
340                delegated_pid: delegated_subject,
341                issued_at,
342                expires_at,
343                bootstrap_token_fingerprint: Some(token_fingerprint),
344            },
345            issued_at,
346        );
347        DelegationStateOps::upsert_delegated_session_bootstrap_binding(
348            DelegatedSessionBootstrapBinding {
349                wallet_pid: wallet_caller,
350                delegated_pid: delegated_subject,
351                token_fingerprint,
352                bound_at: issued_at,
353                expires_at: verified.claims.exp,
354            },
355            issued_at,
356        );
357
358        if had_active_session {
359            record_session_replaced();
360        } else {
361            record_session_created();
362        }
363
364        Ok(())
365    }
366
367    /// Remove the caller's delegated session subject.
368    pub fn clear_delegated_session() {
369        let wallet_caller = IcOps::msg_caller();
370        let had_active_session =
371            DelegationStateOps::delegated_session(wallet_caller, IcOps::now_secs()).is_some();
372        DelegationStateOps::clear_delegated_session(wallet_caller);
373        if had_active_session {
374            record_session_cleared();
375        }
376    }
377
378    /// Read the caller's active delegated session subject, if configured.
379    #[must_use]
380    pub fn delegated_session_subject() -> Option<Principal> {
381        let wallet_caller = IcOps::msg_caller();
382        DelegationStateOps::delegated_session_subject(wallet_caller, IcOps::now_secs())
383    }
384
385    /// Prune all currently expired delegated sessions.
386    #[must_use]
387    pub fn prune_expired_delegated_sessions() -> usize {
388        let now_secs = IcOps::now_secs();
389        let removed = DelegationStateOps::prune_expired_delegated_sessions(now_secs);
390        let _ = DelegationStateOps::prune_expired_delegated_session_bootstrap_bindings(now_secs);
391        if removed > 0 {
392            record_session_pruned(removed);
393        }
394        removed
395    }
396
397    pub async fn store_proof(
398        proof: DelegationProof,
399        kind: DelegationProvisionTargetKind,
400    ) -> Result<(), Error> {
401        let cfg = ConfigOps::delegated_tokens_config().map_err(Error::from)?;
402        if !cfg.enabled {
403            return Err(Error::forbidden(Self::DELEGATED_TOKENS_DISABLED));
404        }
405
406        let root_pid = EnvOps::root_pid().map_err(Error::from)?;
407        let caller = IcOps::msg_caller();
408        if caller != root_pid {
409            return Err(Error::forbidden(
410                "delegation proof store requires root caller",
411            ));
412        }
413
414        DelegatedTokenOps::cache_public_keys_for_cert(&proof.cert)
415            .await
416            .map_err(Self::map_delegation_error)?;
417        if let Err(err) = DelegatedTokenOps::verify_delegation_proof(&proof, root_pid) {
418            let local = IcOps::canister_self();
419            log!(
420                Topic::Auth,
421                Warn,
422                "delegation proof rejected kind={:?} local={} shard={} issued_at={} expires_at={} error={}",
423                kind,
424                local,
425                proof.cert.shard_pid,
426                proof.cert.issued_at,
427                proof.cert.expires_at,
428                err
429            );
430            return Err(Self::map_delegation_error(err));
431        }
432
433        DelegationStateOps::set_proof_from_dto(proof);
434        let local = IcOps::canister_self();
435        let stored = DelegationStateOps::proof_dto()
436            .ok_or_else(|| Error::invariant("delegation proof missing after store"))?;
437        log!(
438            Topic::Auth,
439            Info,
440            "delegation proof stored kind={:?} local={} shard={} issued_at={} expires_at={}",
441            kind,
442            local,
443            stored.cert.shard_pid,
444            stored.cert.issued_at,
445            stored.cert.expires_at
446        );
447
448        Ok(())
449    }
450
451    /// Install delegation proof and key material directly, bypassing management-key lookups.
452    ///
453    /// This is intended for controlled root-driven test flows where deterministic
454    /// key material is used instead of chain-key ECDSA.
455    // Compiled only for controlled test canister builds.
456    #[cfg(canic_test_delegation_material)]
457    pub fn install_test_delegation_material(
458        proof: DelegationProof,
459        root_public_key: Vec<u8>,
460        shard_public_key: Vec<u8>,
461    ) -> Result<(), Error> {
462        let root_pid = EnvOps::root_pid().map_err(Error::from)?;
463        let caller = IcOps::msg_caller();
464        if caller != root_pid {
465            return Err(Error::forbidden(
466                "test delegation material install requires root caller",
467            ));
468        }
469
470        if proof.cert.root_pid != root_pid {
471            return Err(Error::invalid(format!(
472                "delegation proof root mismatch: expected={} found={}",
473                root_pid, proof.cert.root_pid
474            )));
475        }
476
477        if root_public_key.is_empty() || shard_public_key.is_empty() {
478            return Err(Error::invalid("delegation public keys must not be empty"));
479        }
480
481        DelegationStateOps::set_root_public_key(root_public_key);
482        DelegationStateOps::set_shard_public_key(proof.cert.shard_pid, shard_public_key);
483        DelegationStateOps::set_proof_from_dto(proof);
484        Ok(())
485    }
486
487    fn require_proof() -> Result<DelegationProof, Error> {
488        let cfg = ConfigOps::delegated_tokens_config().map_err(Error::from)?;
489        if !cfg.enabled {
490            return Err(Error::forbidden(Self::DELEGATED_TOKENS_DISABLED));
491        }
492
493        DelegationStateOps::proof_dto().ok_or_else(|| {
494            record_signer_issue_without_proof();
495            Error::not_found("delegation proof not set")
496        })
497    }
498
499    // Resolve a proof that is currently usable for token issuance.
500    async fn ensure_signing_proof(claims: &DelegatedTokenClaims) -> Result<DelegationProof, Error> {
501        let now_secs = IcOps::now_secs();
502
503        match Self::require_proof() {
504            Ok(proof) if !Self::proof_is_reusable_for_claims(&proof, claims, now_secs) => {
505                Self::setup_delegation(claims).await
506            }
507            Ok(proof) => Ok(proof),
508            Err(err) if err.code == ErrorCode::NotFound => Self::setup_delegation(claims).await,
509            Err(err) => Err(err),
510        }
511    }
512
513    // Provision a fresh delegation from root, then load locally stored proof.
514    async fn setup_delegation(claims: &DelegatedTokenClaims) -> Result<DelegationProof, Error> {
515        let request = Self::delegation_request_from_claims(claims)?;
516        let _ = Self::request_delegation(request).await?;
517        Self::require_proof()
518    }
519
520    // Build a canonical delegation request from token claims.
521    fn delegation_request_from_claims(
522        claims: &DelegatedTokenClaims,
523    ) -> Result<DelegationRequest, Error> {
524        let ttl_secs = claims.exp.saturating_sub(claims.iat);
525        if ttl_secs == 0 {
526            return Err(Error::invalid(
527                "delegation ttl_secs must be greater than zero",
528            ));
529        }
530
531        Ok(DelegationRequest {
532            shard_pid: IcOps::canister_self(),
533            scopes: claims.scopes.clone(),
534            aud: claims.aud.clone(),
535            ttl_secs,
536            verifier_targets: Vec::new(),
537            include_root_verifier: true,
538            metadata: None,
539        })
540    }
541
542    // Check whether a proof can be reused safely for the requested claims.
543    fn proof_is_reusable_for_claims(
544        proof: &DelegationProof,
545        claims: &DelegatedTokenClaims,
546        now_secs: u64,
547    ) -> bool {
548        if now_secs > proof.cert.expires_at {
549            return false;
550        }
551
552        if claims.shard_pid != proof.cert.shard_pid {
553            return false;
554        }
555
556        if claims.iat < proof.cert.issued_at || claims.exp > proof.cert.expires_at {
557            return false;
558        }
559
560        Self::is_principal_subset(&claims.aud, &proof.cert.aud)
561            && Self::is_string_subset(&claims.scopes, &proof.cert.scopes)
562    }
563
564    // Return true when every principal in `subset` is present in `superset`.
565    fn is_principal_subset(
566        subset: &[crate::cdk::types::Principal],
567        superset: &[crate::cdk::types::Principal],
568    ) -> bool {
569        subset.iter().all(|item| superset.contains(item))
570    }
571
572    // Return true when every scope in `subset` is present in `superset`.
573    fn is_string_subset(subset: &[String], superset: &[String]) -> bool {
574        subset.iter().all(|item| superset.contains(item))
575    }
576
577    fn delegated_session_bootstrap_token_fingerprint(
578        token: &DelegatedToken,
579    ) -> Result<[u8; 32], Error> {
580        let token_bytes = crate::cdk::candid::encode_one(token).map_err(|err| {
581            Error::internal(format!("bootstrap token fingerprint encode failed: {err}"))
582        })?;
583        let mut hasher = Sha256::new();
584        hasher.update(Self::SESSION_BOOTSTRAP_TOKEN_FINGERPRINT_DOMAIN);
585        hasher.update(token_bytes);
586        Ok(hasher.finalize().into())
587    }
588
589    // Enforce replay policy for delegated-session bootstrap by token fingerprint.
590    fn enforce_bootstrap_replay_policy(
591        wallet_caller: Principal,
592        delegated_subject: Principal,
593        token_fingerprint: [u8; 32],
594        issued_at: u64,
595    ) -> Result<bool, Error> {
596        let Some(binding) =
597            DelegationStateOps::delegated_session_bootstrap_binding(token_fingerprint, issued_at)
598        else {
599            return Ok(false);
600        };
601
602        if binding.wallet_pid == wallet_caller && binding.delegated_pid == delegated_subject {
603            let active_same_session =
604                DelegationStateOps::delegated_session(wallet_caller, issued_at).is_some_and(
605                    |session| {
606                        session.delegated_pid == delegated_subject
607                            && session.bootstrap_token_fingerprint == Some(token_fingerprint)
608                    },
609                );
610
611            if active_same_session {
612                record_session_bootstrap_replay_idempotent();
613                return Ok(true);
614            }
615
616            record_session_bootstrap_rejected_replay_reused();
617            return Err(Error::forbidden(
618                "delegated session bootstrap token replay rejected; use a fresh token",
619            ));
620        }
621
622        record_session_bootstrap_rejected_replay_conflict();
623        Err(Error::forbidden(format!(
624            "delegated session bootstrap token already bound (wallet={} delegated_subject={})",
625            binding.wallet_pid, binding.delegated_pid
626        )))
627    }
628
629    fn clamp_delegated_session_expires_at(
630        now_secs: u64,
631        token_expires_at: u64,
632        configured_max_ttl_secs: u64,
633        requested_ttl_secs: Option<u64>,
634    ) -> Result<u64, Error> {
635        if configured_max_ttl_secs == 0 {
636            return Err(Error::invariant(
637                "delegated session configured max ttl_secs must be greater than zero",
638            ));
639        }
640
641        if let Some(ttl_secs) = requested_ttl_secs
642            && ttl_secs == 0
643        {
644            return Err(Error::invalid(
645                "delegated session requested ttl_secs must be greater than zero",
646            ));
647        }
648
649        let mut expires_at = token_expires_at;
650        expires_at = expires_at.min(now_secs.saturating_add(configured_max_ttl_secs));
651        if let Some(ttl_secs) = requested_ttl_secs {
652            expires_at = expires_at.min(now_secs.saturating_add(ttl_secs));
653        }
654
655        if expires_at <= now_secs {
656            return Err(Error::forbidden(
657                "delegated session bootstrap token is expired",
658            ));
659        }
660
661        Ok(expires_at)
662    }
663}
664
665#[cfg(test)]
666mod tests;