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        crate::workflow::runtime::auth::RuntimeAuthWorkflow::verify_role_attestation(
219            attestation,
220            min_accepted_epoch,
221        )
222        .await
223        .map_err(Self::map_auth_error)
224    }
225
226    /// Verify a root-signed, method-scoped internal invocation proof for this endpoint.
227    pub async fn verify_internal_invocation_proof(
228        proof: &SignedInternalInvocationProofV1,
229        target_method: &str,
230        accepted_roles: &[CanisterRole],
231    ) -> Result<(), Error> {
232        let configured_min_accepted_epoch = ConfigOps::role_attestation_config()
233            .map_err(Error::from)?
234            .min_accepted_epoch_by_role
235            .get(proof.payload.role.as_str())
236            .copied();
237        let min_accepted_epoch =
238            verify_flow::resolve_min_accepted_epoch(0, configured_min_accepted_epoch);
239
240        let caller = IcOps::msg_caller();
241        let self_pid = IcOps::canister_self();
242        let now_secs = IcOps::now_secs();
243        let verifier_subnet = Some(EnvOps::subnet_pid().map_err(Error::from)?);
244        let root_pid = EnvOps::root_pid().map_err(Error::from)?;
245
246        let verify = || {
247            AuthOps::verify_internal_invocation_proof_cached(
248                proof,
249                crate::ops::auth::InternalInvocationProofVerificationInput {
250                    caller,
251                    self_pid,
252                    target_method,
253                    accepted_roles,
254                    verifier_subnet,
255                    now_secs,
256                    min_accepted_epoch,
257                },
258            )
259            .map(|_| ())
260        };
261        let refresh = || async {
262            let key_set = RootAuthMaterialClient::new(root_pid)
263                .attestation_key_set()
264                .await?;
265            AuthOps::replace_attestation_key_set(key_set);
266            Ok(())
267        };
268
269        match verify_flow::verify_role_attestation_with_single_refresh(verify, refresh).await {
270            Ok(()) => Ok(()),
271            Err(verify_flow::RoleAttestationVerifyFlowError::Initial(err)) => {
272                verify_flow::record_attestation_verifier_rejection(&err);
273                log!(
274                    Topic::Auth,
275                    Warn,
276                    "internal invocation proof rejected phase=cached local={} caller={} subject={} role={} key_id={} audience={} method={} epoch={} error={}",
277                    self_pid,
278                    caller,
279                    proof.payload.subject,
280                    proof.payload.role,
281                    proof.key_id,
282                    proof.payload.audience,
283                    proof.payload.audience_method,
284                    proof.payload.epoch,
285                    err
286                );
287                Err(Self::map_internal_invocation_verify_error(err))
288            }
289            Err(verify_flow::RoleAttestationVerifyFlowError::Refresh { trigger, source }) => {
290                verify_flow::record_attestation_verifier_rejection(&trigger);
291                record_attestation_refresh_failed();
292                log!(
293                    Topic::Auth,
294                    Warn,
295                    "internal invocation proof refresh failed local={} caller={} key_id={} error={}",
296                    self_pid,
297                    caller,
298                    proof.key_id,
299                    source
300                );
301                Err(Self::map_auth_error(source))
302            }
303            Err(verify_flow::RoleAttestationVerifyFlowError::PostRefresh(err)) => {
304                verify_flow::record_attestation_verifier_rejection(&err);
305                log!(
306                    Topic::Auth,
307                    Warn,
308                    "internal invocation proof rejected phase=post_refresh local={} caller={} subject={} role={} key_id={} audience={} method={} epoch={} error={}",
309                    self_pid,
310                    caller,
311                    proof.payload.subject,
312                    proof.payload.role,
313                    proof.key_id,
314                    proof.payload.audience,
315                    proof.payload.audience_method,
316                    proof.payload.epoch,
317                    err
318                );
319                Err(Self::map_internal_invocation_verify_error(err))
320            }
321        }
322    }
323
324    // Resolve the root-owned TTL ceiling from delegated-token config.
325    fn delegated_token_max_ttl_secs() -> Result<u64, Error> {
326        let cfg = ConfigOps::delegated_tokens_config().map_err(Error::from)?;
327        if !cfg.enabled {
328            return Err(Error::forbidden(Self::DELEGATED_TOKENS_DISABLED));
329        }
330
331        Ok(cfg
332            .max_ttl_secs
333            .unwrap_or(Self::MAX_DELEGATED_SESSION_TTL_SECS))
334    }
335}
336
337impl AuthApi {
338    // Route a self-contained delegation proof request over RPC to root.
339    async fn request_delegation_remote(
340        request: DelegationProofIssueRequest,
341    ) -> Result<DelegationProof, Error> {
342        let root_pid = EnvOps::root_pid().map_err(Error::from)?;
343        RootAuthMaterialClient::new(root_pid)
344            .request_delegation(request)
345            .await
346            .map_err(Self::map_auth_error)
347    }
348
349    // Execute one local root role-attestation request.
350    pub async fn request_role_attestation_root(
351        request: RoleAttestationRequest,
352    ) -> Result<SignedRoleAttestation, Error> {
353        let request = metadata::with_root_attestation_request_metadata(request);
354        let response = RootResponseWorkflow::response(RootRequest::issue_role_attestation(request))
355            .await
356            .map_err(Self::map_auth_error)?;
357
358        match response {
359            RootCapabilityResponse::RoleAttestationIssued(response) => Ok(response),
360            _ => Err(Error::internal(
361                "invalid root response type for role attestation request",
362            )),
363        }
364    }
365
366    // Execute one local root internal-invocation proof request.
367    pub async fn request_internal_invocation_proof_root(
368        request: InternalInvocationProofRequest,
369    ) -> Result<SignedInternalInvocationProofV1, Error> {
370        let request = metadata::with_internal_invocation_proof_request_metadata(request);
371        let response =
372            RootResponseWorkflow::response(RootRequest::issue_internal_invocation_proof(request))
373                .await
374                .map_err(Self::map_auth_error)?;
375
376        match response {
377            RootCapabilityResponse::InternalInvocationProofIssued(response) => Ok(response),
378            _ => Err(Error::internal(
379                "invalid root response type for internal invocation proof request",
380            )),
381        }
382    }
383
384    // Route a canonical role-attestation request over RPC to root.
385    async fn request_role_attestation_remote(
386        request: RoleAttestationRequest,
387    ) -> Result<SignedRoleAttestation, Error> {
388        let root_pid = EnvOps::root_pid().map_err(Error::from)?;
389        RootAuthMaterialClient::new(root_pid)
390            .request_role_attestation(request)
391            .await
392            .map_err(Self::map_auth_error)
393    }
394
395    // Route a canonical internal-invocation proof request over RPC to root.
396    async fn request_internal_invocation_proof_remote(
397        request: InternalInvocationProofRequest,
398    ) -> Result<SignedInternalInvocationProofV1, Error> {
399        let root_pid = EnvOps::root_pid().map_err(Error::from)?;
400        RootAuthMaterialClient::new(root_pid)
401            .request_internal_invocation_proof(request)
402            .await
403            .map_err(Self::map_auth_error)
404    }
405}
406
407#[cfg(test)]
408mod tests {
409    use super::AuthApi;
410    use crate::{
411        dto::error::ErrorCode,
412        ops::auth::{AuthExpiryError, AuthOpsError},
413    };
414
415    #[test]
416    fn internal_invocation_not_yet_valid_maps_to_non_retryable_proof_expiry() {
417        let err = AuthApi::map_internal_invocation_verify_error(AuthOpsError::Expiry(
418            AuthExpiryError::AttestationNotYetValid {
419                issued_at: 20,
420                now_secs: 10,
421            },
422        ));
423
424        assert_eq!(err.code, ErrorCode::AuthProofExpired);
425    }
426}