Skip to main content

canic_core/api/
auth.rs

1use crate::{
2    InternalError, InternalErrorOrigin,
3    cdk::types::Principal,
4    dto::{
5        auth::{
6            DelegatedToken, DelegatedTokenClaims, DelegationAdminCommand, DelegationAdminResponse,
7            DelegationCert, DelegationProof, DelegationProofStatus, DelegationProvisionRequest,
8            DelegationProvisionResponse, DelegationProvisionTargetKind, DelegationRotationStatus,
9            DelegationStatusResponse,
10        },
11        error::Error,
12    },
13    error::InternalErrorClass,
14    log,
15    log::Topic,
16    ops::{
17        auth::DelegatedTokenOps,
18        config::ConfigOps,
19        ic::IcOps,
20        runtime::delegation::DelegationRuntimeOps,
21        runtime::env::EnvOps,
22        runtime::metrics::auth::record_signer_mint_without_proof,
23        storage::{
24            auth::DelegationStateOps, placement::sharding_lifecycle::ShardingLifecycleOps,
25            registry::subnet::SubnetRegistryOps,
26        },
27    },
28    workflow::auth::{DelegationPushOrigin, DelegationWorkflow},
29};
30use std::{sync::Arc, time::Duration};
31
32///
33/// DelegationApi
34///
35/// Requires auth.delegated_tokens.enabled = true in config.
36///
37
38pub struct DelegationApi;
39
40impl DelegationApi {
41    const DELEGATED_TOKENS_DISABLED: &str =
42        "delegated token auth disabled; set auth.delegated_tokens.enabled=true in canic.toml";
43
44    fn map_delegation_error(err: crate::InternalError) -> Error {
45        match err.class() {
46            InternalErrorClass::Infra | InternalErrorClass::Ops | InternalErrorClass::Workflow => {
47                Error::internal(err.to_string())
48            }
49            _ => Error::from(err),
50        }
51    }
52
53    /// Full delegation proof verification (structure + signature).
54    ///
55    /// Purely local verification; does not read certified data or require a
56    /// query context.
57    pub fn verify_delegation_proof(
58        proof: &DelegationProof,
59        authority_pid: Principal,
60    ) -> Result<(), Error> {
61        DelegatedTokenOps::verify_delegation_proof(proof, authority_pid)
62            .map_err(Self::map_delegation_error)
63    }
64
65    pub fn sign_token(
66        token_version: u16,
67        claims: DelegatedTokenClaims,
68        proof: DelegationProof,
69    ) -> Result<DelegatedToken, Error> {
70        DelegatedTokenOps::sign_token(token_version, claims, proof)
71            .map_err(Self::map_delegation_error)
72    }
73
74    /// Full delegated token verification (structure + signature).
75    ///
76    /// Purely local verification; does not read certified data or require a
77    /// query context.
78    pub fn verify_token(
79        token: &DelegatedToken,
80        authority_pid: Principal,
81        now_secs: u64,
82    ) -> Result<(), Error> {
83        DelegatedTokenOps::verify_token(token, authority_pid, now_secs)
84            .map(|_| ())
85            .map_err(Self::map_delegation_error)
86    }
87
88    /// Verify a delegated token and return verified contents.
89    ///
90    /// This is intended for application-layer session construction.
91    /// It performs full verification and returns verified claims and cert.
92    pub fn verify_token_verified(
93        token: &DelegatedToken,
94        authority_pid: Principal,
95        now_secs: u64,
96    ) -> Result<(DelegatedTokenClaims, DelegationCert), Error> {
97        DelegatedTokenOps::verify_token(token, authority_pid, now_secs)
98            .map(|verified| (verified.claims, verified.cert))
99            .map_err(Self::map_delegation_error)
100    }
101
102    /// Delegation provisioning is explicit and operator-driven.
103    /// Root does not infer targets; callers must supply them.
104    pub async fn provision(
105        request: DelegationProvisionRequest,
106    ) -> Result<DelegationProvisionResponse, Error> {
107        let cfg = ConfigOps::delegated_tokens_config().map_err(Error::from)?;
108        if !cfg.enabled {
109            return Err(Error::forbidden(Self::DELEGATED_TOKENS_DISABLED));
110        }
111
112        let root_pid = EnvOps::root_pid().map_err(Error::from)?;
113        let caller = IcOps::msg_caller();
114        if caller != root_pid {
115            return Err(Error::forbidden(
116                "delegation provision requires root caller",
117            ));
118        }
119
120        validate_issuance_policy(&request.cert)?;
121        log!(
122            Topic::Auth,
123            Info,
124            "delegation provision start signer={} signer_targets={:?} verifier_targets={:?}",
125            request.cert.signer_pid,
126            request.signer_targets,
127            request.verifier_targets
128        );
129        DelegationWorkflow::provision(request)
130            .await
131            .map_err(Self::map_delegation_error)
132    }
133
134    pub fn store_proof(
135        proof: DelegationProof,
136        kind: DelegationProvisionTargetKind,
137    ) -> Result<(), Error> {
138        let cfg = ConfigOps::delegated_tokens_config().map_err(Error::from)?;
139        if !cfg.enabled {
140            return Err(Error::forbidden(Self::DELEGATED_TOKENS_DISABLED));
141        }
142
143        let root_pid = EnvOps::root_pid().map_err(Error::from)?;
144        let caller = IcOps::msg_caller();
145        if caller != root_pid {
146            return Err(Error::forbidden(
147                "delegation proof store requires root caller",
148            ));
149        }
150
151        if let Err(err) = DelegatedTokenOps::verify_delegation_proof(&proof, root_pid) {
152            let local = IcOps::canister_self();
153            log!(
154                Topic::Auth,
155                Warn,
156                "delegation proof rejected kind={:?} local={} signer={} issued_at={} expires_at={} error={}",
157                kind,
158                local,
159                proof.cert.signer_pid,
160                proof.cert.issued_at,
161                proof.cert.expires_at,
162                err
163            );
164            return Err(Self::map_delegation_error(err));
165        }
166
167        DelegationStateOps::set_proof_from_dto(proof);
168        let local = IcOps::canister_self();
169        let stored = DelegationStateOps::proof_dto()
170            .ok_or_else(|| Error::invariant("delegation proof missing after store"))?;
171        log!(
172            Topic::Auth,
173            Info,
174            "delegation proof stored kind={:?} local={} signer={} issued_at={} expires_at={}",
175            kind,
176            local,
177            stored.cert.signer_pid,
178            stored.cert.issued_at,
179            stored.cert.expires_at
180        );
181
182        Ok(())
183    }
184
185    pub fn require_proof() -> Result<DelegationProof, Error> {
186        let cfg = ConfigOps::delegated_tokens_config().map_err(Error::from)?;
187        if !cfg.enabled {
188            return Err(Error::forbidden(Self::DELEGATED_TOKENS_DISABLED));
189        }
190
191        DelegationStateOps::proof_dto().ok_or_else(|| {
192            record_signer_mint_without_proof();
193            Error::not_found("delegation proof not set")
194        })
195    }
196
197    /// Root-only internal status for delegated-auth observability.
198    ///
199    /// NOTE: per-target push status is not persisted; only rotation targets
200    /// and stored proof metadata are reported. Rotation state is runtime-only
201    /// and resets on upgrade.
202    pub fn status() -> Result<DelegationStatusResponse, Error> {
203        let proof = DelegationStateOps::proof_dto();
204        let rotation_state = DelegationRuntimeOps::rotation_state();
205        let rotation_targets = ShardingLifecycleOps::rotation_targets();
206
207        Ok(DelegationStatusResponse {
208            has_proof: proof.is_some(),
209            proof: proof.map(|proof| DelegationProofStatus {
210                signer_pid: proof.cert.signer_pid,
211                issued_at: proof.cert.issued_at,
212                expires_at: proof.cert.expires_at,
213            }),
214            rotation: DelegationRotationStatus {
215                active: rotation_state.active,
216                interval_secs: rotation_state.interval_secs,
217                last_rotation_at: rotation_state.last_rotation_at,
218            },
219            rotation_targets,
220        })
221    }
222}
223
224///
225/// DelegationAdminApi
226///
227/// Admin façade for delegation rotation control.
228///
229
230pub struct DelegationAdminApi;
231
232impl DelegationAdminApi {
233    pub async fn admin(cmd: DelegationAdminCommand) -> Result<DelegationAdminResponse, Error> {
234        match cmd {
235            DelegationAdminCommand::StartRotation { interval_secs } => {
236                let started = Self::start_rotation(interval_secs).await?;
237                Ok(if started {
238                    DelegationAdminResponse::RotationStarted
239                } else {
240                    DelegationAdminResponse::RotationAlreadyRunning
241                })
242            }
243            DelegationAdminCommand::StopRotation => {
244                let stopped = Self::stop_rotation().await?;
245                Ok(if stopped {
246                    DelegationAdminResponse::RotationStopped
247                } else {
248                    DelegationAdminResponse::RotationNotRunning
249                })
250            }
251        }
252    }
253
254    #[allow(clippy::unused_async)]
255    pub async fn start_rotation(interval_secs: u64) -> Result<bool, Error> {
256        // Delegation rotation is explicit and operator-driven.
257        // Root does not infer targets; pushes are best-effort and require
258        // reprovision if a canister misses an update.
259        let cfg = ConfigOps::delegated_tokens_config().map_err(Error::from)?;
260        if !cfg.enabled {
261            return Err(Error::forbidden(DelegationApi::DELEGATED_TOKENS_DISABLED));
262        }
263
264        if interval_secs == 0 {
265            return Err(Error::invalid(
266                "rotation interval must be greater than zero",
267            ));
268        }
269
270        let template = rotation_template()?;
271        let template = Arc::new(template);
272        let interval = Duration::from_secs(interval_secs);
273
274        let started = DelegationWorkflow::start_rotation(
275            interval,
276            Arc::new({
277                let template = Arc::clone(&template);
278                move || {
279                    let now_secs = IcOps::now_secs();
280                    let cert = build_rotation_cert(template.as_ref(), now_secs);
281                    validate_issuance_policy_internal(&cert)?;
282                    Ok(cert)
283                }
284            }),
285            Arc::new(|proof| {
286                DelegationStateOps::set_proof_from_dto(proof.clone());
287
288                let targets = ShardingLifecycleOps::rotation_targets();
289                log!(
290                    Topic::Auth,
291                    Info,
292                    "delegation rotation targets={:?} signer={} issued_at={} expires_at={}",
293                    targets,
294                    proof.cert.signer_pid,
295                    proof.cert.issued_at,
296                    proof.cert.expires_at
297                );
298                if !targets.is_empty() {
299                    IcOps::spawn(async move {
300                        for target in targets {
301                            let _ = DelegationWorkflow::push_proof(
302                                target,
303                                &proof,
304                                DelegationProvisionTargetKind::Signer,
305                                DelegationPushOrigin::Rotation,
306                            )
307                            .await;
308                        }
309                    });
310                }
311
312                Ok(())
313            }),
314        );
315
316        if started {
317            log!(
318                Topic::Auth,
319                Info,
320                "delegation rotation started interval_secs={interval_secs}"
321            );
322        }
323
324        Ok(started)
325    }
326
327    #[allow(clippy::unused_async)]
328    pub async fn stop_rotation() -> Result<bool, Error> {
329        Ok(DelegationWorkflow::stop_rotation())
330    }
331}
332
333fn validate_issuance_policy(cert: &DelegationCert) -> Result<(), Error> {
334    if cert.expires_at <= cert.issued_at {
335        return Err(Error::invalid(
336            "delegation expires_at must be greater than issued_at",
337        ));
338    }
339
340    if cert.audiences.is_empty() {
341        return Err(Error::invalid("delegation audiences must not be empty"));
342    }
343
344    if cert.scopes.is_empty() {
345        return Err(Error::invalid("delegation scopes must not be empty"));
346    }
347
348    if cert.audiences.iter().any(String::is_empty) {
349        return Err(Error::invalid("delegation audience must not be empty"));
350    }
351
352    if cert.scopes.iter().any(String::is_empty) {
353        return Err(Error::invalid("delegation scope must not be empty"));
354    }
355
356    let root_pid = EnvOps::root_pid().map_err(Error::from)?;
357    if cert.signer_pid == root_pid {
358        return Err(Error::invalid("delegation signer must not be root"));
359    }
360
361    let record = SubnetRegistryOps::get(cert.signer_pid)
362        .ok_or_else(|| Error::invalid("delegation signer must be registered to subnet"))?;
363    if record.role.is_root() {
364        return Err(Error::invalid("delegation signer role must not be root"));
365    }
366
367    Ok(())
368}
369
370fn validate_issuance_policy_internal(cert: &DelegationCert) -> Result<(), InternalError> {
371    validate_issuance_policy(cert)
372        .map_err(|err| InternalError::domain(InternalErrorOrigin::Domain, err.message))
373}
374
375///
376/// DelegationRotationTemplate
377///
378
379struct DelegationRotationTemplate {
380    v: u16,
381    signer_pid: Principal,
382    audiences: Vec<String>,
383    scopes: Vec<String>,
384    ttl_secs: u64,
385}
386
387fn rotation_template() -> Result<DelegationRotationTemplate, Error> {
388    let proof = DelegationStateOps::proof_dto()
389        .ok_or_else(|| Error::not_found("delegation proof not set"))?;
390    let cert = proof.cert;
391
392    if cert.expires_at <= cert.issued_at {
393        return Err(Error::invalid(
394            "delegation cert expires_at must be greater than issued_at",
395        ));
396    }
397
398    let ttl_secs = cert.expires_at - cert.issued_at;
399
400    Ok(DelegationRotationTemplate {
401        v: cert.v,
402        signer_pid: cert.signer_pid,
403        audiences: cert.audiences,
404        scopes: cert.scopes,
405        ttl_secs,
406    })
407}
408
409fn build_rotation_cert(template: &DelegationRotationTemplate, now_secs: u64) -> DelegationCert {
410    DelegationCert {
411        v: template.v,
412        signer_pid: template.signer_pid,
413        audiences: template.audiences.clone(),
414        scopes: template.scopes.clone(),
415        issued_at: now_secs,
416        expires_at: now_secs.saturating_add(template.ttl_secs),
417    }
418}