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, DelegationProvisionTargetKind,
7            DelegationRequest, RoleAttestationRequest, SignedRoleAttestation,
8        },
9        error::{Error, ErrorCode},
10        rpc::{Request as RootRequest, Response as RootCapabilityResponse},
11    },
12    error::InternalErrorClass,
13    log,
14    log::Topic,
15    ops::{
16        auth::DelegatedTokenOps,
17        config::ConfigOps,
18        ic::IcOps,
19        rpc::RpcOps,
20        runtime::env::EnvOps,
21        runtime::metrics::auth::{
22            record_attestation_refresh_failed, record_signer_issue_without_proof,
23        },
24        storage::auth::DelegationStateOps,
25    },
26    protocol,
27    workflow::rpc::request::handler::RootResponseWorkflow,
28};
29
30mod metadata;
31mod verify_flow;
32
33///
34/// DelegationApi
35///
36/// Requires auth.delegated_tokens.enabled = true in config.
37///
38
39pub struct DelegationApi;
40
41impl DelegationApi {
42    const DELEGATED_TOKENS_DISABLED: &str =
43        "delegated token auth disabled; set auth.delegated_tokens.enabled=true in canic.toml";
44
45    fn map_delegation_error(err: crate::InternalError) -> Error {
46        match err.class() {
47            InternalErrorClass::Infra | InternalErrorClass::Ops | InternalErrorClass::Workflow => {
48                Error::internal(err.to_string())
49            }
50            _ => Error::from(err),
51        }
52    }
53
54    /// Full delegation proof verification (structure + signature).
55    ///
56    /// Purely local verification; does not read certified data or require a
57    /// query context.
58    pub fn verify_delegation_proof(
59        proof: &DelegationProof,
60        authority_pid: Principal,
61    ) -> Result<(), Error> {
62        DelegatedTokenOps::verify_delegation_proof(proof, authority_pid)
63            .map_err(Self::map_delegation_error)
64    }
65
66    async fn sign_token(
67        claims: DelegatedTokenClaims,
68        proof: DelegationProof,
69    ) -> Result<DelegatedToken, Error> {
70        DelegatedTokenOps::sign_token(claims, proof)
71            .await
72            .map_err(Self::map_delegation_error)
73    }
74
75    /// Issue a delegated token using a reusable local proof when possible.
76    ///
77    /// If the proof is missing or no longer valid for the requested claims, this
78    /// performs canonical shard-initiated setup and retries with the refreshed proof.
79    pub async fn issue_token(claims: DelegatedTokenClaims) -> Result<DelegatedToken, Error> {
80        let proof = Self::ensure_signing_proof(&claims).await?;
81        Self::sign_token(claims, proof).await
82    }
83
84    /// Full delegated token verification (structure + signature).
85    ///
86    /// Purely local verification; does not read certified data or require a
87    /// query context.
88    pub fn verify_token(
89        token: &DelegatedToken,
90        authority_pid: Principal,
91        now_secs: u64,
92    ) -> Result<(), Error> {
93        DelegatedTokenOps::verify_token(token, authority_pid, now_secs, IcOps::canister_self())
94            .map(|_| ())
95            .map_err(Self::map_delegation_error)
96    }
97
98    /// Verify a delegated token and return verified contents.
99    ///
100    /// This is intended for application-layer session construction.
101    /// It performs full verification and returns verified claims and cert.
102    pub fn verify_token_verified(
103        token: &DelegatedToken,
104        authority_pid: Principal,
105        now_secs: u64,
106    ) -> Result<(DelegatedTokenClaims, DelegationCert), Error> {
107        DelegatedTokenOps::verify_token(token, authority_pid, now_secs, IcOps::canister_self())
108            .map(|verified| (verified.claims, verified.cert))
109            .map_err(Self::map_delegation_error)
110    }
111
112    /// Canonical shard-initiated delegation request (user_shard -> root).
113    ///
114    /// Caller must match shard_pid and be registered to the subnet.
115    pub async fn request_delegation(
116        request: DelegationRequest,
117    ) -> Result<DelegationProvisionResponse, Error> {
118        let request = metadata::with_root_request_metadata(request);
119        let response = RootResponseWorkflow::response(RootRequest::issue_delegation(request))
120            .await
121            .map_err(Self::map_delegation_error)?;
122
123        match response {
124            RootCapabilityResponse::DelegationIssued(response) => Ok(response),
125            _ => Err(Error::internal(
126                "invalid root response type for delegation request",
127            )),
128        }
129    }
130
131    pub async fn request_role_attestation(
132        request: RoleAttestationRequest,
133    ) -> Result<SignedRoleAttestation, Error> {
134        let request = metadata::with_root_attestation_request_metadata(request);
135        let response = RootResponseWorkflow::response(RootRequest::issue_role_attestation(request))
136            .await
137            .map_err(Self::map_delegation_error)?;
138
139        match response {
140            RootCapabilityResponse::RoleAttestationIssued(response) => Ok(response),
141            _ => Err(Error::internal(
142                "invalid root response type for role attestation request",
143            )),
144        }
145    }
146
147    pub async fn attestation_key_set() -> Result<AttestationKeySet, Error> {
148        DelegatedTokenOps::attestation_key_set()
149            .await
150            .map_err(Self::map_delegation_error)
151    }
152
153    pub fn replace_attestation_key_set(key_set: AttestationKeySet) {
154        DelegatedTokenOps::replace_attestation_key_set(key_set);
155    }
156
157    pub async fn verify_role_attestation(
158        attestation: &SignedRoleAttestation,
159        min_accepted_epoch: u64,
160    ) -> Result<(), Error> {
161        let configured_min_accepted_epoch = ConfigOps::role_attestation_config()
162            .map_err(Error::from)?
163            .min_accepted_epoch_by_role
164            .get(attestation.payload.role.as_str())
165            .copied();
166        let min_accepted_epoch = verify_flow::resolve_min_accepted_epoch(
167            min_accepted_epoch,
168            configured_min_accepted_epoch,
169        );
170
171        let caller = IcOps::msg_caller();
172        let self_pid = IcOps::canister_self();
173        let now_secs = IcOps::now_secs();
174        let verifier_subnet = Some(EnvOps::subnet_pid().map_err(Error::from)?);
175        let root_pid = EnvOps::root_pid().map_err(Error::from)?;
176
177        let verify = || {
178            DelegatedTokenOps::verify_role_attestation_cached(
179                attestation,
180                caller,
181                self_pid,
182                verifier_subnet,
183                now_secs,
184                min_accepted_epoch,
185            )
186            .map(|_| ())
187        };
188        let refresh = || async {
189            let key_set: AttestationKeySet =
190                RpcOps::call_rpc_result(root_pid, protocol::CANIC_ATTESTATION_KEY_SET, ()).await?;
191            DelegatedTokenOps::replace_attestation_key_set(key_set);
192            Ok(())
193        };
194
195        match verify_flow::verify_role_attestation_with_single_refresh(verify, refresh).await {
196            Ok(()) => Ok(()),
197            Err(verify_flow::RoleAttestationVerifyFlowError::Initial(err)) => {
198                verify_flow::record_attestation_verifier_rejection(&err);
199                verify_flow::log_attestation_verifier_rejection(
200                    &err,
201                    attestation,
202                    caller,
203                    self_pid,
204                    "cached",
205                );
206                Err(Self::map_delegation_error(err.into()))
207            }
208            Err(verify_flow::RoleAttestationVerifyFlowError::Refresh { trigger, source }) => {
209                verify_flow::record_attestation_verifier_rejection(&trigger);
210                verify_flow::log_attestation_verifier_rejection(
211                    &trigger,
212                    attestation,
213                    caller,
214                    self_pid,
215                    "cache_miss_refresh",
216                );
217                record_attestation_refresh_failed();
218                log!(
219                    Topic::Auth,
220                    Warn,
221                    "role attestation refresh failed local={} caller={} key_id={} error={}",
222                    self_pid,
223                    caller,
224                    attestation.key_id,
225                    source
226                );
227                Err(Self::map_delegation_error(source))
228            }
229            Err(verify_flow::RoleAttestationVerifyFlowError::PostRefresh(err)) => {
230                verify_flow::record_attestation_verifier_rejection(&err);
231                verify_flow::log_attestation_verifier_rejection(
232                    &err,
233                    attestation,
234                    caller,
235                    self_pid,
236                    "post_refresh",
237                );
238                Err(Self::map_delegation_error(err.into()))
239            }
240        }
241    }
242
243    pub async fn store_proof(
244        proof: DelegationProof,
245        kind: DelegationProvisionTargetKind,
246    ) -> Result<(), Error> {
247        let cfg = ConfigOps::delegated_tokens_config().map_err(Error::from)?;
248        if !cfg.enabled {
249            return Err(Error::forbidden(Self::DELEGATED_TOKENS_DISABLED));
250        }
251
252        let root_pid = EnvOps::root_pid().map_err(Error::from)?;
253        let caller = IcOps::msg_caller();
254        if caller != root_pid {
255            return Err(Error::forbidden(
256                "delegation proof store requires root caller",
257            ));
258        }
259
260        DelegatedTokenOps::cache_public_keys_for_cert(&proof.cert)
261            .await
262            .map_err(Self::map_delegation_error)?;
263        if let Err(err) = DelegatedTokenOps::verify_delegation_proof(&proof, root_pid) {
264            let local = IcOps::canister_self();
265            log!(
266                Topic::Auth,
267                Warn,
268                "delegation proof rejected kind={:?} local={} shard={} issued_at={} expires_at={} error={}",
269                kind,
270                local,
271                proof.cert.shard_pid,
272                proof.cert.issued_at,
273                proof.cert.expires_at,
274                err
275            );
276            return Err(Self::map_delegation_error(err));
277        }
278
279        DelegationStateOps::set_proof_from_dto(proof);
280        let local = IcOps::canister_self();
281        let stored = DelegationStateOps::proof_dto()
282            .ok_or_else(|| Error::invariant("delegation proof missing after store"))?;
283        log!(
284            Topic::Auth,
285            Info,
286            "delegation proof stored kind={:?} local={} shard={} issued_at={} expires_at={}",
287            kind,
288            local,
289            stored.cert.shard_pid,
290            stored.cert.issued_at,
291            stored.cert.expires_at
292        );
293
294        Ok(())
295    }
296
297    fn require_proof() -> Result<DelegationProof, Error> {
298        let cfg = ConfigOps::delegated_tokens_config().map_err(Error::from)?;
299        if !cfg.enabled {
300            return Err(Error::forbidden(Self::DELEGATED_TOKENS_DISABLED));
301        }
302
303        DelegationStateOps::proof_dto().ok_or_else(|| {
304            record_signer_issue_without_proof();
305            Error::not_found("delegation proof not set")
306        })
307    }
308
309    // Resolve a proof that is currently usable for token issuance.
310    async fn ensure_signing_proof(claims: &DelegatedTokenClaims) -> Result<DelegationProof, Error> {
311        let now_secs = IcOps::now_secs();
312
313        match Self::require_proof() {
314            Ok(proof) if !Self::proof_is_reusable_for_claims(&proof, claims, now_secs) => {
315                Self::setup_delegation(claims).await
316            }
317            Ok(proof) => Ok(proof),
318            Err(err) if err.code == ErrorCode::NotFound => Self::setup_delegation(claims).await,
319            Err(err) => Err(err),
320        }
321    }
322
323    // Provision a fresh delegation from root, then load locally stored proof.
324    async fn setup_delegation(claims: &DelegatedTokenClaims) -> Result<DelegationProof, Error> {
325        let request = Self::delegation_request_from_claims(claims)?;
326        let _ = Self::request_delegation(request).await?;
327        Self::require_proof()
328    }
329
330    // Build a canonical delegation request from token claims.
331    fn delegation_request_from_claims(
332        claims: &DelegatedTokenClaims,
333    ) -> Result<DelegationRequest, Error> {
334        let ttl_secs = claims.exp.saturating_sub(claims.iat);
335        if ttl_secs == 0 {
336            return Err(Error::invalid(
337                "delegation ttl_secs must be greater than zero",
338            ));
339        }
340
341        Ok(DelegationRequest {
342            shard_pid: IcOps::canister_self(),
343            scopes: claims.scopes.clone(),
344            aud: claims.aud.clone(),
345            ttl_secs,
346            verifier_targets: Vec::new(),
347            include_root_verifier: true,
348            metadata: None,
349        })
350    }
351
352    // Check whether a proof can be reused safely for the requested claims.
353    fn proof_is_reusable_for_claims(
354        proof: &DelegationProof,
355        claims: &DelegatedTokenClaims,
356        now_secs: u64,
357    ) -> bool {
358        if now_secs > proof.cert.expires_at {
359            return false;
360        }
361
362        if claims.shard_pid != proof.cert.shard_pid {
363            return false;
364        }
365
366        if claims.iat < proof.cert.issued_at || claims.exp > proof.cert.expires_at {
367            return false;
368        }
369
370        Self::is_principal_subset(&claims.aud, &proof.cert.aud)
371            && Self::is_string_subset(&claims.scopes, &proof.cert.scopes)
372    }
373
374    // Return true when every principal in `subset` is present in `superset`.
375    fn is_principal_subset(
376        subset: &[crate::cdk::types::Principal],
377        superset: &[crate::cdk::types::Principal],
378    ) -> bool {
379        subset.iter().all(|item| superset.contains(item))
380    }
381
382    // Return true when every scope in `subset` is present in `superset`.
383    fn is_string_subset(subset: &[String], superset: &[String]) -> bool {
384        subset.iter().all(|item| superset.contains(item))
385    }
386}
387
388#[cfg(test)]
389mod tests;