Skip to main content

canic_core/access/
auth.rs

1//! Auth access checks.
2//!
3//! This bucket includes:
4//! - caller identity checks (controller/whitelist)
5//! - topology checks (parent/child/root/same canister)
6//! - registry-based role checks
7//! - delegated token verification
8//!
9//! Security invariants for delegated tokens:
10//! - Delegated tokens are only valid if their proof matches a verifier-local keyed delegation proof.
11//! - Delegation rotation may retain multiple proofs concurrently until older tokens age out.
12//! - All temporal validation (iat/exp/now) is enforced before access is granted.
13//! - Endpoint-required scopes are enforced against delegated token claims.
14
15use crate::{
16    access::AccessError,
17    cdk::{
18        api::{canister_self, is_controller as caller_is_controller, msg_arg_data},
19        candid::de::IDLDeserialize,
20        types::Principal,
21    },
22    config::Config,
23    dto::auth::DelegatedToken,
24    ids::CanisterRole,
25    ops::{
26        auth::{DelegatedTokenOps, VerifiedDelegatedToken},
27        ic::IcOps,
28        runtime::env::EnvOps,
29        runtime::metrics::auth::{
30            record_session_fallback_invalid_subject, record_session_fallback_raw_caller,
31        },
32        storage::{
33            auth::DelegationStateOps, children::CanisterChildrenOps,
34            registry::subnet::SubnetRegistryOps,
35        },
36    },
37};
38use std::fmt;
39
40const MAX_INGRESS_BYTES: usize = 64 * 1024; // 64 KiB
41
42pub type Role = CanisterRole;
43
44///
45/// AuthenticatedIdentitySource
46///
47
48#[derive(Clone, Copy, Debug, Eq, PartialEq)]
49pub enum AuthenticatedIdentitySource {
50    RawCaller,
51    DelegatedSession,
52}
53
54///
55/// ResolvedAuthenticatedIdentity
56///
57
58#[derive(Clone, Copy, Debug, Eq, PartialEq)]
59pub struct ResolvedAuthenticatedIdentity {
60    pub transport_caller: Principal,
61    pub authenticated_subject: Principal,
62    pub identity_source: AuthenticatedIdentitySource,
63}
64
65///
66/// DelegatedSessionSubjectRejection
67///
68
69#[derive(Clone, Copy, Debug, Eq, PartialEq)]
70pub enum DelegatedSessionSubjectRejection {
71    Anonymous,
72    ManagementCanister,
73    LocalCanister,
74    RootCanister,
75    ParentCanister,
76    SubnetCanister,
77    PrimeRootCanister,
78    RegisteredCanister,
79}
80
81impl fmt::Display for DelegatedSessionSubjectRejection {
82    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83        let reason = match self {
84            Self::Anonymous => "anonymous principals are not allowed",
85            Self::ManagementCanister => "management canister principal is not allowed",
86            Self::LocalCanister => "current canister principal is not allowed",
87            Self::RootCanister => "root canister principal is not allowed",
88            Self::ParentCanister => "parent canister principal is not allowed",
89            Self::SubnetCanister => "subnet principal is not allowed",
90            Self::PrimeRootCanister => "prime root principal is not allowed",
91            Self::RegisteredCanister => "subnet-registered canister principal is not allowed",
92        };
93        f.write_str(reason)
94    }
95}
96
97///
98/// CallerBoundToken
99///
100/// Verified delegated token that has passed caller-subject binding.
101struct CallerBoundToken {
102    verified: VerifiedDelegatedToken,
103}
104
105impl CallerBoundToken {
106    /// bind_to_caller
107    ///
108    /// Enforce subject binding and return a caller-bound token wrapper.
109    fn bind_to_caller(
110        verified: VerifiedDelegatedToken,
111        caller: Principal,
112    ) -> Result<Self, AccessError> {
113        enforce_subject_binding(verified.claims.subject(), caller)?;
114        Ok(Self { verified })
115    }
116
117    /// scopes
118    ///
119    /// Borrow token scopes after caller binding has been enforced.
120    fn scopes(&self) -> &[String] {
121        self.verified.claims.scopes()
122    }
123
124    /// into_verified
125    ///
126    /// Unwrap the verified delegated token for downstream consumers.
127    fn into_verified(self) -> VerifiedDelegatedToken {
128        self.verified
129    }
130}
131
132/// resolve_authenticated_identity
133///
134/// Resolve transport caller and authenticated subject for user auth checks.
135#[must_use]
136pub fn resolve_authenticated_identity(
137    transport_caller: Principal,
138) -> ResolvedAuthenticatedIdentity {
139    resolve_authenticated_identity_at(transport_caller, IcOps::now_secs())
140}
141
142pub(crate) fn resolve_authenticated_identity_at(
143    transport_caller: Principal,
144    now_secs: u64,
145) -> ResolvedAuthenticatedIdentity {
146    if let Some(session) = DelegationStateOps::delegated_session(transport_caller, now_secs) {
147        if validate_delegated_session_subject(session.delegated_pid).is_ok() {
148            return ResolvedAuthenticatedIdentity {
149                transport_caller,
150                authenticated_subject: session.delegated_pid,
151                identity_source: AuthenticatedIdentitySource::DelegatedSession,
152            };
153        }
154
155        DelegationStateOps::clear_delegated_session(transport_caller);
156        record_session_fallback_invalid_subject();
157    }
158
159    record_session_fallback_raw_caller();
160    ResolvedAuthenticatedIdentity {
161        transport_caller,
162        authenticated_subject: transport_caller,
163        identity_source: AuthenticatedIdentitySource::RawCaller,
164    }
165}
166
167/// validate_delegated_session_subject
168///
169/// Reject obvious canister and infrastructure identities for delegated user sessions.
170pub fn validate_delegated_session_subject(
171    subject: Principal,
172) -> Result<(), DelegatedSessionSubjectRejection> {
173    if subject == Principal::anonymous() {
174        return Err(DelegatedSessionSubjectRejection::Anonymous);
175    }
176
177    if subject == Principal::management_canister() {
178        return Err(DelegatedSessionSubjectRejection::ManagementCanister);
179    }
180
181    if try_canister_self().is_some_and(|pid| pid == subject) {
182        return Err(DelegatedSessionSubjectRejection::LocalCanister);
183    }
184
185    let env = EnvOps::snapshot();
186    if env.root_pid.is_some_and(|pid| pid == subject) {
187        return Err(DelegatedSessionSubjectRejection::RootCanister);
188    }
189    if env.parent_pid.is_some_and(|pid| pid == subject) {
190        return Err(DelegatedSessionSubjectRejection::ParentCanister);
191    }
192    if env.subnet_pid.is_some_and(|pid| pid == subject) {
193        return Err(DelegatedSessionSubjectRejection::SubnetCanister);
194    }
195    if env.prime_root_pid.is_some_and(|pid| pid == subject) {
196        return Err(DelegatedSessionSubjectRejection::PrimeRootCanister);
197    }
198    if SubnetRegistryOps::is_registered(subject) {
199        return Err(DelegatedSessionSubjectRejection::RegisteredCanister);
200    }
201
202    Ok(())
203}
204
205#[cfg(target_arch = "wasm32")]
206#[expect(clippy::unnecessary_wraps)]
207fn try_canister_self() -> Option<Principal> {
208    Some(IcOps::canister_self())
209}
210
211#[cfg(not(target_arch = "wasm32"))]
212const fn try_canister_self() -> Option<Principal> {
213    None
214}
215
216pub(crate) async fn delegated_token_verified(
217    authenticated_subject: Principal,
218    required_scope: Option<&str>,
219) -> Result<VerifiedDelegatedToken, AccessError> {
220    let token = delegated_token_from_args()?;
221
222    let authority_pid =
223        EnvOps::root_pid().map_err(|_| dependency_unavailable("root pid unavailable"))?;
224
225    let now_secs = IcOps::now_secs();
226    let self_pid = IcOps::canister_self();
227
228    verify_token(
229        token,
230        authenticated_subject,
231        authority_pid,
232        now_secs,
233        self_pid,
234        required_scope,
235    )
236    .await
237}
238
239/// Verify a delegated token against the configured authority.
240#[expect(clippy::unused_async)]
241async fn verify_token(
242    token: DelegatedToken,
243    caller: Principal,
244    authority_pid: Principal,
245    now_secs: u64,
246    self_pid: Principal,
247    required_scope: Option<&str>,
248) -> Result<VerifiedDelegatedToken, AccessError> {
249    let verified = DelegatedTokenOps::verify_token(&token, authority_pid, now_secs, self_pid)
250        .map_err(|err| AccessError::Denied(err.to_string()))?;
251
252    let caller_bound = CallerBoundToken::bind_to_caller(verified, caller)?;
253    enforce_required_scope(required_scope, caller_bound.scopes())?;
254
255    Ok(caller_bound.into_verified())
256}
257
258fn enforce_subject_binding(sub: Principal, caller: Principal) -> Result<(), AccessError> {
259    if sub == caller {
260        Ok(())
261    } else {
262        Err(AccessError::Denied(format!(
263            "delegated token subject '{sub}' does not match caller '{caller}'"
264        )))
265    }
266}
267
268fn enforce_required_scope(
269    required_scope: Option<&str>,
270    token_scopes: &[String],
271) -> Result<(), AccessError> {
272    let Some(required_scope) = required_scope else {
273        return Ok(());
274    };
275
276    if token_scopes.iter().any(|scope| scope == required_scope) {
277        Ok(())
278    } else {
279        Err(AccessError::Denied(format!(
280            "delegated token missing required scope '{required_scope}'"
281        )))
282    }
283}
284
285// -----------------------------------------------------------------------------
286// Caller & topology predicates
287// -----------------------------------------------------------------------------
288
289/// Require that the caller controls the current canister.
290/// Allows controller-only maintenance calls.
291#[expect(clippy::unused_async)]
292pub async fn is_controller(caller: Principal) -> Result<(), AccessError> {
293    if caller_is_controller(&caller) {
294        Ok(())
295    } else {
296        Err(AccessError::Denied(format!(
297            "caller '{caller}' is not a controller of this canister"
298        )))
299    }
300}
301
302/// Require that the caller appears in the active whitelist (IC deployments).
303/// No-op on local builds; enforces whitelist on IC.
304#[expect(clippy::unused_async)]
305pub async fn is_whitelisted(caller: Principal) -> Result<(), AccessError> {
306    let cfg = Config::try_get().ok_or_else(|| dependency_unavailable("config not initialized"))?;
307
308    if !cfg.is_whitelisted(&caller) {
309        return Err(AccessError::Denied(format!(
310            "caller '{caller}' is not on the whitelist"
311        )));
312    }
313
314    Ok(())
315}
316
317/// Require that the caller is a direct child of the current canister.
318#[expect(clippy::unused_async)]
319pub async fn is_child(caller: Principal) -> Result<(), AccessError> {
320    if CanisterChildrenOps::contains_pid(&caller) {
321        Ok(())
322    } else {
323        Err(AccessError::Denied(format!(
324            "caller '{caller}' is not a child of this canister"
325        )))
326    }
327}
328
329/// Require that the caller is the configured parent canister.
330#[expect(clippy::unused_async)]
331pub async fn is_parent(caller: Principal) -> Result<(), AccessError> {
332    let snapshot = EnvOps::snapshot();
333    let parent_pid = snapshot
334        .parent_pid
335        .ok_or_else(|| dependency_unavailable("parent pid unavailable"))?;
336
337    if parent_pid == caller {
338        Ok(())
339    } else {
340        Err(AccessError::Denied(format!(
341            "caller '{caller}' is not the parent of this canister"
342        )))
343    }
344}
345
346/// Require that the caller equals the configured root canister.
347#[expect(clippy::unused_async)]
348pub async fn is_root(caller: Principal) -> Result<(), AccessError> {
349    let root_pid =
350        EnvOps::root_pid().map_err(|_| dependency_unavailable("root pid unavailable"))?;
351
352    if caller == root_pid {
353        Ok(())
354    } else {
355        Err(AccessError::Denied(format!(
356            "caller '{caller}' is not root"
357        )))
358    }
359}
360
361/// Require that the caller is the currently executing canister.
362#[expect(clippy::unused_async)]
363pub async fn is_same_canister(caller: Principal) -> Result<(), AccessError> {
364    if caller == canister_self() {
365        Ok(())
366    } else {
367        Err(AccessError::Denied(format!(
368            "caller '{caller}' is not the current canister"
369        )))
370    }
371}
372
373// -----------------------------------------------------------------------------
374// Registry predicates
375// -----------------------------------------------------------------------------
376
377/// Require that the caller is registered with the expected canister role.
378#[expect(clippy::unused_async)]
379pub async fn has_role(caller: Principal, role: Role) -> Result<(), AccessError> {
380    if !EnvOps::is_root() {
381        return Err(non_root_subnet_registry_predicate_denial());
382    }
383
384    let record =
385        SubnetRegistryOps::get(caller).ok_or_else(|| caller_not_registered_denial(caller))?;
386
387    if record.role == role {
388        Ok(())
389    } else {
390        Err(AccessError::Denied(format!(
391            "authentication error: caller '{caller}' does not have role '{role}'"
392        )))
393    }
394}
395
396/// Ensure the caller matches the app directory entry recorded for `role`.
397/// Require that the caller is registered as a canister on this subnet.
398#[expect(clippy::unused_async)]
399pub async fn is_registered_to_subnet(caller: Principal) -> Result<(), AccessError> {
400    if !EnvOps::is_root() {
401        return Err(non_root_subnet_registry_predicate_denial());
402    }
403
404    if SubnetRegistryOps::is_registered(caller) {
405        Ok(())
406    } else {
407        Err(caller_not_registered_denial(caller))
408    }
409}
410
411fn delegated_token_from_args() -> Result<DelegatedToken, AccessError> {
412    let bytes = msg_arg_data();
413
414    if bytes.len() > MAX_INGRESS_BYTES {
415        return Err(AccessError::Denied(
416            "delegated token payload exceeds size limit".to_string(),
417        ));
418    }
419
420    let mut decoder = IDLDeserialize::new(&bytes)
421        .map_err(|err| AccessError::Denied(format!("failed to decode ingress arguments: {err}")))?;
422
423    decoder.get_value::<DelegatedToken>().map_err(|err| {
424        AccessError::Denied(format!(
425            "failed to decode delegated token as first argument: {err}"
426        ))
427    })
428}
429
430fn dependency_unavailable(detail: &str) -> AccessError {
431    AccessError::Denied(format!("access dependency unavailable: {detail}"))
432}
433
434fn non_root_subnet_registry_predicate_denial() -> AccessError {
435    AccessError::Denied(
436        "authentication error: illegal access to subnet registry predicate from non-root canister"
437            .to_string(),
438    )
439}
440
441fn caller_not_registered_denial(caller: Principal) -> AccessError {
442    let root = EnvOps::root_pid().map_or_else(|_| "unavailable".to_string(), |pid| pid.to_string());
443    let registry_count = SubnetRegistryOps::data().entries.len();
444    AccessError::Denied(format!(
445        "authentication error: caller '{caller}' is not registered on the subnet registry \
446         (root='{root}', registry_entries={registry_count}); verify caller root routing and \
447         canic_subnet_registry state"
448    ))
449}
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454    use crate::{
455        ids::{AccessMetricKind, cap},
456        ops::runtime::metrics::access::AccessMetrics,
457        test::seams,
458    };
459
460    fn p(id: u8) -> Principal {
461        Principal::from_slice(&[id; 29])
462    }
463
464    fn auth_session_metric_count(predicate: &str) -> u64 {
465        AccessMetrics::snapshot()
466            .entries
467            .into_iter()
468            .find_map(|(key, count)| {
469                if key.endpoint == "auth_session"
470                    && key.kind == AccessMetricKind::Auth
471                    && key.predicate == predicate
472                {
473                    Some(count)
474                } else {
475                    None
476                }
477            })
478            .unwrap_or(0)
479    }
480
481    #[test]
482    fn subject_binding_allows_matching_subject_and_caller() {
483        let sub = p(1);
484        let caller = p(1);
485        assert!(enforce_subject_binding(sub, caller).is_ok());
486    }
487
488    #[test]
489    fn subject_binding_rejects_mismatched_subject_and_caller() {
490        let sub = p(1);
491        let caller = p(2);
492        let err = enforce_subject_binding(sub, caller).expect_err("expected subject mismatch");
493        assert!(err.to_string().contains("does not match caller"));
494    }
495
496    #[test]
497    fn required_scope_allows_when_scope_present() {
498        let scopes = vec![cap::READ.to_string(), cap::VERIFY.to_string()];
499        assert!(enforce_required_scope(Some(cap::VERIFY), &scopes).is_ok());
500    }
501
502    #[test]
503    fn required_scope_rejects_when_scope_missing() {
504        let scopes = vec![cap::READ.to_string()];
505        let err = enforce_required_scope(Some(cap::VERIFY), &scopes).expect_err("expected denial");
506        assert!(err.to_string().contains("missing required scope"));
507    }
508
509    #[test]
510    fn required_scope_none_is_allowed() {
511        let scopes = vec![cap::READ.to_string()];
512        assert!(enforce_required_scope(None, &scopes).is_ok());
513    }
514
515    #[test]
516    fn resolve_authenticated_identity_defaults_to_wallet_when_no_override_exists() {
517        let _guard = seams::lock();
518        AccessMetrics::reset();
519        let wallet = p(9);
520        DelegationStateOps::clear_delegated_session(wallet);
521        let resolved = resolve_authenticated_identity(wallet);
522        assert_eq!(resolved.authenticated_subject, wallet);
523        assert_eq!(
524            auth_session_metric_count("session_fallback_raw_caller"),
525            1,
526            "missing delegated session should record raw-caller fallback"
527        );
528    }
529
530    #[test]
531    fn resolve_authenticated_identity_prefers_active_delegated_session() {
532        let _guard = seams::lock();
533        AccessMetrics::reset();
534        let wallet = p(8);
535        let delegated = p(7);
536        DelegationStateOps::upsert_delegated_session(
537            crate::ops::storage::auth::DelegatedSession {
538                wallet_pid: wallet,
539                delegated_pid: delegated,
540                issued_at: 100,
541                expires_at: 200,
542                bootstrap_token_fingerprint: None,
543            },
544            100,
545        );
546
547        let resolved = resolve_authenticated_identity_at(wallet, 150);
548        assert_eq!(resolved.transport_caller, wallet);
549        assert_eq!(resolved.authenticated_subject, delegated);
550        assert_eq!(
551            resolved.identity_source,
552            AuthenticatedIdentitySource::DelegatedSession
553        );
554        assert_eq!(
555            auth_session_metric_count("session_fallback_raw_caller"),
556            0,
557            "active delegated session should not fallback to raw caller"
558        );
559
560        DelegationStateOps::clear_delegated_session(wallet);
561    }
562
563    #[test]
564    fn resolve_authenticated_identity_falls_back_when_session_expired() {
565        let _guard = seams::lock();
566        AccessMetrics::reset();
567        let wallet = p(6);
568        let delegated = p(5);
569        DelegationStateOps::upsert_delegated_session(
570            crate::ops::storage::auth::DelegatedSession {
571                wallet_pid: wallet,
572                delegated_pid: delegated,
573                issued_at: 100,
574                expires_at: 120,
575                bootstrap_token_fingerprint: None,
576            },
577            100,
578        );
579
580        let resolved = resolve_authenticated_identity_at(wallet, 121);
581        assert_eq!(resolved.authenticated_subject, wallet);
582        assert_eq!(
583            resolved.identity_source,
584            AuthenticatedIdentitySource::RawCaller
585        );
586        assert_eq!(
587            auth_session_metric_count("session_fallback_raw_caller"),
588            1,
589            "expired delegated session should fallback to raw caller"
590        );
591
592        DelegationStateOps::clear_delegated_session(wallet);
593    }
594
595    #[test]
596    fn resolve_authenticated_identity_falls_back_after_clear() {
597        let _guard = seams::lock();
598        AccessMetrics::reset();
599        let wallet = p(4);
600        let delegated = p(3);
601        DelegationStateOps::upsert_delegated_session(
602            crate::ops::storage::auth::DelegatedSession {
603                wallet_pid: wallet,
604                delegated_pid: delegated,
605                issued_at: 50,
606                expires_at: 500,
607                bootstrap_token_fingerprint: None,
608            },
609            50,
610        );
611        DelegationStateOps::clear_delegated_session(wallet);
612
613        let resolved = resolve_authenticated_identity_at(wallet, 100);
614        assert_eq!(resolved.authenticated_subject, wallet);
615        assert_eq!(
616            resolved.identity_source,
617            AuthenticatedIdentitySource::RawCaller
618        );
619        assert_eq!(auth_session_metric_count("session_fallback_raw_caller"), 1);
620    }
621
622    #[test]
623    fn resolve_authenticated_identity_records_invalid_subject_fallback() {
624        let _guard = seams::lock();
625        AccessMetrics::reset();
626        let wallet = p(23);
627        DelegationStateOps::upsert_delegated_session(
628            crate::ops::storage::auth::DelegatedSession {
629                wallet_pid: wallet,
630                delegated_pid: Principal::management_canister(),
631                issued_at: 10,
632                expires_at: 100,
633                bootstrap_token_fingerprint: None,
634            },
635            10,
636        );
637
638        let resolved = resolve_authenticated_identity_at(wallet, 20);
639        assert_eq!(resolved.authenticated_subject, wallet);
640        assert_eq!(
641            resolved.identity_source,
642            AuthenticatedIdentitySource::RawCaller
643        );
644        assert_eq!(
645            auth_session_metric_count("session_fallback_invalid_subject"),
646            1
647        );
648        assert_eq!(auth_session_metric_count("session_fallback_raw_caller"), 1);
649        assert!(
650            DelegationStateOps::delegated_session(wallet, 20).is_none(),
651            "invalid delegated session should be cleared"
652        );
653    }
654
655    #[test]
656    fn validate_delegated_session_subject_rejects_anonymous() {
657        let _guard = seams::lock();
658        let err = validate_delegated_session_subject(Principal::anonymous())
659            .expect_err("anonymous must be rejected");
660        assert_eq!(err, DelegatedSessionSubjectRejection::Anonymous);
661    }
662
663    #[test]
664    fn validate_delegated_session_subject_rejects_management_canister() {
665        let _guard = seams::lock();
666        let err = validate_delegated_session_subject(Principal::management_canister())
667            .expect_err("management canister must be rejected");
668        assert_eq!(err, DelegatedSessionSubjectRejection::ManagementCanister);
669    }
670}