Skip to main content

canic_core/api/auth/
mod.rs

1use crate::{
2    cdk::types::Principal,
3    dto::{
4        auth::{
5            AttestationKeySet, DelegatedToken, DelegatedTokenIssueRequest,
6            DelegatedTokenMintRequest, DelegationProof, DelegationProofIssueRequest,
7            InternalInvocationProofRequest, RoleAttestationRequest,
8            SignedInternalInvocationProofV1, SignedRoleAttestation,
9        },
10        error::{Error, ErrorCode},
11        rpc::{Request as RootRequest, Response as RootCapabilityResponse},
12    },
13    error::InternalErrorClass,
14    ids::CanisterRole,
15    log,
16    log::Topic,
17    ops::{
18        auth::{
19            AuthExpiryError, AuthOps, AuthOpsError, AuthValidationError, SignDelegatedTokenInput,
20            SignDelegationProofInput, VerifyDelegatedTokenRuntimeInput,
21        },
22        config::ConfigOps,
23        ic::IcOps,
24        runtime::env::EnvOps,
25        runtime::metrics::auth::record_attestation_refresh_failed,
26    },
27    workflow::rpc::request::handler::RootResponseWorkflow,
28};
29use root_client::RootAuthMaterialClient;
30
31// Internal auth pipeline:
32// - `session` owns delegated-session ingress and replay/session state handling.
33// - `metadata` owns root request metadata construction.
34// - `verify_flow` owns verifier-side attestation refresh behavior.
35mod metadata;
36mod root_client;
37mod session;
38mod verify_flow;
39
40///
41/// AuthApi
42///
43/// Owns delegated-token helpers and root-signed role-attestation helpers.
44///
45
46pub struct AuthApi;
47
48impl AuthApi {
49    const DELEGATED_TOKENS_DISABLED: &str =
50        "delegated token auth disabled; set auth.delegated_tokens.enabled=true in canic.toml";
51    const MAX_DELEGATED_SESSION_TTL_SECS: u64 = 24 * 60 * 60;
52    const SESSION_BOOTSTRAP_TOKEN_FINGERPRINT_DOMAIN: &[u8] =
53        b"canic-session-bootstrap-token-fingerprint";
54
55    // Map internal auth failures onto public endpoint errors.
56    fn map_auth_error(err: crate::InternalError) -> Error {
57        match err.class() {
58            InternalErrorClass::Infra | InternalErrorClass::Ops | InternalErrorClass::Workflow => {
59                Error::internal(err.to_string())
60            }
61            _ => Error::from(err),
62        }
63    }
64
65    fn map_internal_invocation_verify_error(err: AuthOpsError) -> Error {
66        match err {
67            AuthOpsError::Validation(AuthValidationError::AttestationUnknownKeyId { .. }) => {
68                Error::new(ErrorCode::AuthKeyUnknown, err.to_string())
69            }
70            AuthOpsError::Expiry(AuthExpiryError::AttestationEpochRejected { .. }) => {
71                Error::new(ErrorCode::AuthMaterialStale, err.to_string())
72            }
73            AuthOpsError::Expiry(
74                AuthExpiryError::AttestationExpired { .. }
75                | AuthExpiryError::AttestationNotYetValid { .. },
76            ) => Error::new(ErrorCode::AuthProofExpired, err.to_string()),
77            _ => Error::unauthorized(err.to_string()),
78        }
79    }
80
81    // Verify delegated-token material and return the token subject.
82    //
83    // This is intentionally private: endpoint authorization must also bind the
84    // verified subject to the caller and consume update tokens once.
85    fn verify_token_material(
86        token: &DelegatedToken,
87        max_cert_ttl_secs: u64,
88        max_token_ttl_secs: u64,
89        required_scopes: &[String],
90        now_secs: u64,
91    ) -> Result<Principal, Error> {
92        AuthOps::verify_token(VerifyDelegatedTokenRuntimeInput {
93            token,
94            max_cert_ttl_secs,
95            max_token_ttl_secs,
96            required_scopes,
97            now_secs,
98        })
99        .map(|verified| verified.subject)
100        .map_err(Self::map_auth_error)
101    }
102
103    /// Resolve the local shard public key in SEC1 encoding.
104    pub async fn local_shard_public_key_sec1() -> Result<Vec<u8>, Error> {
105        AuthOps::local_shard_public_key_sec1(IcOps::canister_self())
106            .await
107            .map_err(Self::map_auth_error)
108    }
109
110    /// Issue a delegated token from an explicit self-contained proof.
111    pub async fn issue_token(request: DelegatedTokenIssueRequest) -> Result<DelegatedToken, Error> {
112        AuthOps::sign_token(SignDelegatedTokenInput {
113            proof: request.proof,
114            subject: request.subject,
115            audience: request.aud,
116            scopes: request.scopes,
117            ttl_secs: request.ttl_secs,
118            nonce: request.nonce,
119        })
120        .await
121        .map_err(Self::map_auth_error)
122    }
123
124    /// Request a root proof, then issue a self-contained delegated token.
125    pub async fn mint_token(request: DelegatedTokenMintRequest) -> Result<DelegatedToken, Error> {
126        let proof = Self::request_delegation(DelegationProofIssueRequest {
127            shard_pid: IcOps::canister_self(),
128            scopes: request.scopes.clone(),
129            aud: request.aud.clone(),
130            cert_ttl_secs: request.cert_ttl_secs,
131        })
132        .await?;
133
134        Self::issue_token(DelegatedTokenIssueRequest {
135            proof,
136            subject: request.subject,
137            aud: request.aud,
138            scopes: request.scopes,
139            ttl_secs: request.token_ttl_secs,
140            nonce: request.nonce,
141        })
142        .await
143    }
144
145    /// Request a self-contained delegation proof from root over RPC.
146    pub async fn request_delegation(
147        request: DelegationProofIssueRequest,
148    ) -> Result<DelegationProof, Error> {
149        Self::request_delegation_remote(request).await
150    }
151
152    /// Issue a self-contained delegation proof from the local root.
153    pub async fn issue_delegation_proof(
154        request: DelegationProofIssueRequest,
155    ) -> Result<DelegationProof, Error> {
156        EnvOps::require_root().map_err(Error::from)?;
157        let max_cert_ttl_secs = Self::delegated_token_max_ttl_secs()?;
158        let max_token_ttl_secs = request.cert_ttl_secs.min(max_cert_ttl_secs);
159        AuthOps::sign_delegation_proof(SignDelegationProofInput {
160            audience: request.aud,
161            scopes: request.scopes,
162            shard_pid: request.shard_pid,
163            cert_ttl_secs: request.cert_ttl_secs,
164            max_token_ttl_secs,
165            max_cert_ttl_secs,
166            issued_at: IcOps::now_secs(),
167        })
168        .await
169        .map_err(Self::map_auth_error)
170    }
171
172    /// Request a signed role attestation from root over RPC.
173    pub async fn request_role_attestation(
174        request: RoleAttestationRequest,
175    ) -> Result<SignedRoleAttestation, Error> {
176        let request = metadata::with_root_attestation_request_metadata(request);
177        Self::request_role_attestation_remote(request).await
178    }
179
180    /// Request a method-scoped internal invocation proof from root over RPC.
181    pub async fn request_internal_invocation_proof(
182        request: InternalInvocationProofRequest,
183    ) -> Result<SignedInternalInvocationProofV1, Error> {
184        let request = metadata::with_internal_invocation_proof_request_metadata(request);
185        Self::request_internal_invocation_proof_remote(request).await
186    }
187
188    /// Return the current root role-attestation key set.
189    pub async fn attestation_key_set() -> Result<AttestationKeySet, Error> {
190        AuthOps::attestation_key_set()
191            .await
192            .map_err(Self::map_auth_error)
193    }
194
195    /// Publish root auth material into subnet state and warm root-owned keys once.
196    pub async fn publish_root_auth_material() -> Result<(), Error> {
197        EnvOps::require_root().map_err(Error::from)?;
198        AuthOps::publish_root_auth_material().await.map_err(|err| {
199            log!(
200                Topic::Auth,
201                Warn,
202                "root auth material publish failed: {err}"
203            );
204            Self::map_auth_error(err)
205        })
206    }
207
208    /// Replace the verifier-local role-attestation key set.
209    pub fn replace_attestation_key_set(key_set: AttestationKeySet) {
210        AuthOps::replace_attestation_key_set(key_set);
211    }
212
213    /// Verify a role attestation, refreshing root keys once on unknown key.
214    pub async fn verify_role_attestation(
215        attestation: &SignedRoleAttestation,
216        min_accepted_epoch: u64,
217    ) -> Result<(), Error> {
218        let configured_min_accepted_epoch = ConfigOps::role_attestation_config()
219            .map_err(Error::from)?
220            .min_accepted_epoch_by_role
221            .get(attestation.payload.role.as_str())
222            .copied();
223        let min_accepted_epoch = verify_flow::resolve_min_accepted_epoch(
224            min_accepted_epoch,
225            configured_min_accepted_epoch,
226        );
227
228        let caller = IcOps::msg_caller();
229        let self_pid = IcOps::canister_self();
230        let now_secs = IcOps::now_secs();
231        let verifier_subnet = Some(EnvOps::subnet_pid().map_err(Error::from)?);
232        let root_pid = EnvOps::root_pid().map_err(Error::from)?;
233
234        let verify = || {
235            AuthOps::verify_role_attestation_cached(
236                attestation,
237                caller,
238                self_pid,
239                verifier_subnet,
240                now_secs,
241                min_accepted_epoch,
242            )
243            .map(|_| ())
244        };
245        let refresh = || async {
246            let key_set = RootAuthMaterialClient::new(root_pid)
247                .attestation_key_set()
248                .await?;
249            AuthOps::replace_attestation_key_set(key_set);
250            Ok(())
251        };
252
253        match verify_flow::verify_role_attestation_with_single_refresh(verify, refresh).await {
254            Ok(()) => Ok(()),
255            Err(verify_flow::RoleAttestationVerifyFlowError::Initial(err)) => {
256                verify_flow::record_attestation_verifier_rejection(&err);
257                verify_flow::log_attestation_verifier_rejection(
258                    &err,
259                    attestation,
260                    caller,
261                    self_pid,
262                    "cached",
263                );
264                Err(Self::map_auth_error(err.into()))
265            }
266            Err(verify_flow::RoleAttestationVerifyFlowError::Refresh { trigger, source }) => {
267                verify_flow::record_attestation_verifier_rejection(&trigger);
268                verify_flow::log_attestation_verifier_rejection(
269                    &trigger,
270                    attestation,
271                    caller,
272                    self_pid,
273                    "cache_miss_refresh",
274                );
275                record_attestation_refresh_failed();
276                log!(
277                    Topic::Auth,
278                    Warn,
279                    "role attestation refresh failed local={} caller={} key_id={} error={}",
280                    self_pid,
281                    caller,
282                    attestation.key_id,
283                    source
284                );
285                Err(Self::map_auth_error(source))
286            }
287            Err(verify_flow::RoleAttestationVerifyFlowError::PostRefresh(err)) => {
288                verify_flow::record_attestation_verifier_rejection(&err);
289                verify_flow::log_attestation_verifier_rejection(
290                    &err,
291                    attestation,
292                    caller,
293                    self_pid,
294                    "post_refresh",
295                );
296                Err(Self::map_auth_error(err.into()))
297            }
298        }
299    }
300
301    /// Verify a root-signed, method-scoped internal invocation proof for this endpoint.
302    pub async fn verify_internal_invocation_proof(
303        proof: &SignedInternalInvocationProofV1,
304        target_method: &str,
305        accepted_roles: &[CanisterRole],
306    ) -> Result<(), Error> {
307        let configured_min_accepted_epoch = ConfigOps::role_attestation_config()
308            .map_err(Error::from)?
309            .min_accepted_epoch_by_role
310            .get(proof.payload.role.as_str())
311            .copied();
312        let min_accepted_epoch =
313            verify_flow::resolve_min_accepted_epoch(0, configured_min_accepted_epoch);
314
315        let caller = IcOps::msg_caller();
316        let self_pid = IcOps::canister_self();
317        let now_secs = IcOps::now_secs();
318        let verifier_subnet = Some(EnvOps::subnet_pid().map_err(Error::from)?);
319        let root_pid = EnvOps::root_pid().map_err(Error::from)?;
320
321        let verify = || {
322            AuthOps::verify_internal_invocation_proof_cached(
323                proof,
324                crate::ops::auth::InternalInvocationProofVerificationInput {
325                    caller,
326                    self_pid,
327                    target_method,
328                    accepted_roles,
329                    verifier_subnet,
330                    now_secs,
331                    min_accepted_epoch,
332                },
333            )
334            .map(|_| ())
335        };
336        let refresh = || async {
337            let key_set = RootAuthMaterialClient::new(root_pid)
338                .attestation_key_set()
339                .await?;
340            AuthOps::replace_attestation_key_set(key_set);
341            Ok(())
342        };
343
344        match verify_flow::verify_role_attestation_with_single_refresh(verify, refresh).await {
345            Ok(()) => Ok(()),
346            Err(verify_flow::RoleAttestationVerifyFlowError::Initial(err)) => {
347                verify_flow::record_attestation_verifier_rejection(&err);
348                log!(
349                    Topic::Auth,
350                    Warn,
351                    "internal invocation proof rejected phase=cached local={} caller={} subject={} role={} key_id={} audience={} method={} epoch={} error={}",
352                    self_pid,
353                    caller,
354                    proof.payload.subject,
355                    proof.payload.role,
356                    proof.key_id,
357                    proof.payload.audience,
358                    proof.payload.audience_method,
359                    proof.payload.epoch,
360                    err
361                );
362                Err(Self::map_internal_invocation_verify_error(err))
363            }
364            Err(verify_flow::RoleAttestationVerifyFlowError::Refresh { trigger, source }) => {
365                verify_flow::record_attestation_verifier_rejection(&trigger);
366                record_attestation_refresh_failed();
367                log!(
368                    Topic::Auth,
369                    Warn,
370                    "internal invocation proof refresh failed local={} caller={} key_id={} error={}",
371                    self_pid,
372                    caller,
373                    proof.key_id,
374                    source
375                );
376                Err(Self::map_auth_error(source))
377            }
378            Err(verify_flow::RoleAttestationVerifyFlowError::PostRefresh(err)) => {
379                verify_flow::record_attestation_verifier_rejection(&err);
380                log!(
381                    Topic::Auth,
382                    Warn,
383                    "internal invocation proof rejected phase=post_refresh local={} caller={} subject={} role={} key_id={} audience={} method={} epoch={} error={}",
384                    self_pid,
385                    caller,
386                    proof.payload.subject,
387                    proof.payload.role,
388                    proof.key_id,
389                    proof.payload.audience,
390                    proof.payload.audience_method,
391                    proof.payload.epoch,
392                    err
393                );
394                Err(Self::map_internal_invocation_verify_error(err))
395            }
396        }
397    }
398
399    // Resolve the root-owned TTL ceiling from delegated-token config.
400    fn delegated_token_max_ttl_secs() -> Result<u64, 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        Ok(cfg
407            .max_ttl_secs
408            .unwrap_or(Self::MAX_DELEGATED_SESSION_TTL_SECS))
409    }
410}
411
412impl AuthApi {
413    // Route a self-contained delegation proof request over RPC to root.
414    async fn request_delegation_remote(
415        request: DelegationProofIssueRequest,
416    ) -> Result<DelegationProof, Error> {
417        let root_pid = EnvOps::root_pid().map_err(Error::from)?;
418        RootAuthMaterialClient::new(root_pid)
419            .request_delegation(request)
420            .await
421            .map_err(Self::map_auth_error)
422    }
423
424    // Execute one local root role-attestation request.
425    pub async fn request_role_attestation_root(
426        request: RoleAttestationRequest,
427    ) -> Result<SignedRoleAttestation, Error> {
428        let request = metadata::with_root_attestation_request_metadata(request);
429        let response = RootResponseWorkflow::response(RootRequest::issue_role_attestation(request))
430            .await
431            .map_err(Self::map_auth_error)?;
432
433        match response {
434            RootCapabilityResponse::RoleAttestationIssued(response) => Ok(response),
435            _ => Err(Error::internal(
436                "invalid root response type for role attestation request",
437            )),
438        }
439    }
440
441    // Execute one local root internal-invocation proof request.
442    pub async fn request_internal_invocation_proof_root(
443        request: InternalInvocationProofRequest,
444    ) -> Result<SignedInternalInvocationProofV1, Error> {
445        let request = metadata::with_internal_invocation_proof_request_metadata(request);
446        let response =
447            RootResponseWorkflow::response(RootRequest::issue_internal_invocation_proof(request))
448                .await
449                .map_err(Self::map_auth_error)?;
450
451        match response {
452            RootCapabilityResponse::InternalInvocationProofIssued(response) => Ok(response),
453            _ => Err(Error::internal(
454                "invalid root response type for internal invocation proof request",
455            )),
456        }
457    }
458
459    // Route a canonical role-attestation request over RPC to root.
460    async fn request_role_attestation_remote(
461        request: RoleAttestationRequest,
462    ) -> Result<SignedRoleAttestation, Error> {
463        let root_pid = EnvOps::root_pid().map_err(Error::from)?;
464        RootAuthMaterialClient::new(root_pid)
465            .request_role_attestation(request)
466            .await
467            .map_err(Self::map_auth_error)
468    }
469
470    // Route a canonical internal-invocation proof request over RPC to root.
471    async fn request_internal_invocation_proof_remote(
472        request: InternalInvocationProofRequest,
473    ) -> Result<SignedInternalInvocationProofV1, Error> {
474        let root_pid = EnvOps::root_pid().map_err(Error::from)?;
475        RootAuthMaterialClient::new(root_pid)
476            .request_internal_invocation_proof(request)
477            .await
478            .map_err(Self::map_auth_error)
479    }
480}
481
482#[cfg(test)]
483mod tests {
484    use super::AuthApi;
485    use crate::{
486        dto::error::ErrorCode,
487        ops::auth::{AuthExpiryError, AuthOpsError},
488    };
489
490    #[test]
491    fn internal_invocation_not_yet_valid_maps_to_non_retryable_proof_expiry() {
492        let err = AuthApi::map_internal_invocation_verify_error(AuthOpsError::Expiry(
493            AuthExpiryError::AttestationNotYetValid {
494                issued_at: 20,
495                now_secs: 10,
496            },
497        ));
498
499        assert_eq!(err.code, ErrorCode::AuthProofExpired);
500    }
501}