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        Self::validate_delegation_request_caller(IcOps::msg_caller(), request.shard_pid)?;
158        let max_cert_ttl_secs = Self::delegated_token_max_ttl_secs()?;
159        let max_token_ttl_secs = request.cert_ttl_secs.min(max_cert_ttl_secs);
160        AuthOps::sign_delegation_proof(SignDelegationProofInput {
161            audience: request.aud,
162            scopes: request.scopes,
163            shard_pid: request.shard_pid,
164            cert_ttl_secs: request.cert_ttl_secs,
165            max_token_ttl_secs,
166            max_cert_ttl_secs,
167            issued_at: IcOps::now_secs(),
168        })
169        .await
170        .map_err(Self::map_auth_error)
171    }
172
173    /// Request a signed role attestation from root over RPC.
174    pub async fn request_role_attestation(
175        request: RoleAttestationRequest,
176    ) -> Result<SignedRoleAttestation, Error> {
177        let request = metadata::with_root_attestation_request_metadata(request);
178        Self::request_role_attestation_remote(request).await
179    }
180
181    /// Request a method-scoped internal invocation proof from root over RPC.
182    pub async fn request_internal_invocation_proof(
183        request: InternalInvocationProofRequest,
184    ) -> Result<SignedInternalInvocationProofV1, Error> {
185        let request = metadata::with_internal_invocation_proof_request_metadata(request);
186        Self::request_internal_invocation_proof_remote(request).await
187    }
188
189    /// Return the current root role-attestation key set.
190    pub async fn attestation_key_set() -> Result<AttestationKeySet, Error> {
191        AuthOps::attestation_key_set()
192            .await
193            .map_err(Self::map_auth_error)
194    }
195
196    /// Publish root auth material into subnet state and warm root-owned keys once.
197    pub async fn publish_root_auth_material() -> Result<(), Error> {
198        EnvOps::require_root().map_err(Error::from)?;
199        AuthOps::publish_root_auth_material().await.map_err(|err| {
200            log!(
201                Topic::Auth,
202                Warn,
203                "root auth material publish failed: {err}"
204            );
205            Self::map_auth_error(err)
206        })
207    }
208
209    /// Replace the verifier-local role-attestation key set.
210    pub fn replace_attestation_key_set(key_set: AttestationKeySet) {
211        AuthOps::replace_attestation_key_set(key_set);
212    }
213
214    /// Verify a role attestation, refreshing root keys once on unknown key.
215    pub async fn verify_role_attestation(
216        attestation: &SignedRoleAttestation,
217        min_accepted_epoch: u64,
218    ) -> Result<(), Error> {
219        crate::workflow::runtime::auth::RuntimeAuthWorkflow::verify_role_attestation(
220            attestation,
221            min_accepted_epoch,
222        )
223        .await
224        .map_err(Self::map_auth_error)
225    }
226
227    /// Verify a root-signed, method-scoped internal invocation proof for this endpoint.
228    pub async fn verify_internal_invocation_proof(
229        proof: &SignedInternalInvocationProofV1,
230        target_method: &str,
231        accepted_roles: &[CanisterRole],
232    ) -> Result<(), Error> {
233        let configured_min_accepted_epoch = ConfigOps::role_attestation_config()
234            .map_err(Error::from)?
235            .min_accepted_epoch_by_role
236            .get(proof.payload.role.as_str())
237            .copied();
238        let min_accepted_epoch =
239            verify_flow::resolve_min_accepted_epoch(0, configured_min_accepted_epoch);
240
241        let caller = IcOps::msg_caller();
242        let self_pid = IcOps::canister_self();
243        let now_secs = IcOps::now_secs();
244        let verifier_subnet = Some(EnvOps::subnet_pid().map_err(Error::from)?);
245        let root_pid = EnvOps::root_pid().map_err(Error::from)?;
246
247        let verify = || {
248            AuthOps::verify_internal_invocation_proof_cached(
249                proof,
250                crate::ops::auth::InternalInvocationProofVerificationInput {
251                    caller,
252                    self_pid,
253                    target_method,
254                    accepted_roles,
255                    verifier_subnet,
256                    now_secs,
257                    min_accepted_epoch,
258                },
259            )
260            .map(|_| ())
261        };
262        let refresh = || async {
263            let key_set = RootAuthMaterialClient::new(root_pid)
264                .attestation_key_set()
265                .await?;
266            AuthOps::replace_attestation_key_set(key_set);
267            Ok(())
268        };
269
270        match verify_flow::verify_role_attestation_with_single_refresh(verify, refresh).await {
271            Ok(()) => Ok(()),
272            Err(verify_flow::RoleAttestationVerifyFlowError::Initial(err)) => {
273                verify_flow::record_attestation_verifier_rejection(&err);
274                log!(
275                    Topic::Auth,
276                    Warn,
277                    "internal invocation proof rejected phase=cached local={} caller={} subject={} role={} key_id={} audience={} method={} epoch={} error={}",
278                    self_pid,
279                    caller,
280                    proof.payload.subject,
281                    proof.payload.role,
282                    proof.key_id,
283                    proof.payload.audience,
284                    proof.payload.audience_method,
285                    proof.payload.epoch,
286                    err
287                );
288                Err(Self::map_internal_invocation_verify_error(err))
289            }
290            Err(verify_flow::RoleAttestationVerifyFlowError::Refresh { trigger, source }) => {
291                verify_flow::record_attestation_verifier_rejection(&trigger);
292                record_attestation_refresh_failed();
293                log!(
294                    Topic::Auth,
295                    Warn,
296                    "internal invocation proof refresh failed local={} caller={} key_id={} error={}",
297                    self_pid,
298                    caller,
299                    proof.key_id,
300                    source
301                );
302                Err(Self::map_auth_error(source))
303            }
304            Err(verify_flow::RoleAttestationVerifyFlowError::PostRefresh(err)) => {
305                verify_flow::record_attestation_verifier_rejection(&err);
306                log!(
307                    Topic::Auth,
308                    Warn,
309                    "internal invocation proof rejected phase=post_refresh local={} caller={} subject={} role={} key_id={} audience={} method={} epoch={} error={}",
310                    self_pid,
311                    caller,
312                    proof.payload.subject,
313                    proof.payload.role,
314                    proof.key_id,
315                    proof.payload.audience,
316                    proof.payload.audience_method,
317                    proof.payload.epoch,
318                    err
319                );
320                Err(Self::map_internal_invocation_verify_error(err))
321            }
322        }
323    }
324
325    // Resolve the root-owned TTL ceiling from delegated-token config.
326    fn delegated_token_max_ttl_secs() -> Result<u64, Error> {
327        let cfg = ConfigOps::delegated_tokens_config().map_err(Error::from)?;
328        if !cfg.enabled {
329            return Err(Error::forbidden(Self::DELEGATED_TOKENS_DISABLED));
330        }
331
332        Ok(cfg
333            .max_ttl_secs
334            .unwrap_or(Self::MAX_DELEGATED_SESSION_TTL_SECS))
335    }
336
337    fn validate_delegation_request_caller(
338        caller: Principal,
339        shard_pid: Principal,
340    ) -> Result<(), Error> {
341        if caller == shard_pid {
342            return Ok(());
343        }
344
345        Err(Error::forbidden(format!(
346            "delegation request caller {caller} must match shard_pid {shard_pid}"
347        )))
348    }
349}
350
351impl AuthApi {
352    // Route a self-contained delegation proof request over RPC to root.
353    async fn request_delegation_remote(
354        request: DelegationProofIssueRequest,
355    ) -> Result<DelegationProof, Error> {
356        let root_pid = EnvOps::root_pid().map_err(Error::from)?;
357        RootAuthMaterialClient::new(root_pid)
358            .request_delegation(request)
359            .await
360            .map_err(Self::map_auth_error)
361    }
362
363    // Execute one local root role-attestation request.
364    pub async fn request_role_attestation_root(
365        request: RoleAttestationRequest,
366    ) -> Result<SignedRoleAttestation, Error> {
367        let request = metadata::with_root_attestation_request_metadata(request);
368        let response = RootResponseWorkflow::response(RootRequest::issue_role_attestation(request))
369            .await
370            .map_err(Self::map_auth_error)?;
371
372        match response {
373            RootCapabilityResponse::RoleAttestationIssued(response) => Ok(response),
374            _ => Err(Error::internal(
375                "invalid root response type for role attestation request",
376            )),
377        }
378    }
379
380    // Execute one local root internal-invocation proof request.
381    pub async fn request_internal_invocation_proof_root(
382        request: InternalInvocationProofRequest,
383    ) -> Result<SignedInternalInvocationProofV1, Error> {
384        let request = metadata::with_internal_invocation_proof_request_metadata(request);
385        let response =
386            RootResponseWorkflow::response(RootRequest::issue_internal_invocation_proof(request))
387                .await
388                .map_err(Self::map_auth_error)?;
389
390        match response {
391            RootCapabilityResponse::InternalInvocationProofIssued(response) => Ok(response),
392            _ => Err(Error::internal(
393                "invalid root response type for internal invocation proof request",
394            )),
395        }
396    }
397
398    // Route a canonical role-attestation request over RPC to root.
399    async fn request_role_attestation_remote(
400        request: RoleAttestationRequest,
401    ) -> Result<SignedRoleAttestation, Error> {
402        let root_pid = EnvOps::root_pid().map_err(Error::from)?;
403        RootAuthMaterialClient::new(root_pid)
404            .request_role_attestation(request)
405            .await
406            .map_err(Self::map_auth_error)
407    }
408
409    // Route a canonical internal-invocation proof request over RPC to root.
410    async fn request_internal_invocation_proof_remote(
411        request: InternalInvocationProofRequest,
412    ) -> Result<SignedInternalInvocationProofV1, Error> {
413        let root_pid = EnvOps::root_pid().map_err(Error::from)?;
414        RootAuthMaterialClient::new(root_pid)
415            .request_internal_invocation_proof(request)
416            .await
417            .map_err(Self::map_auth_error)
418    }
419}
420
421#[cfg(test)]
422mod tests {
423    use super::AuthApi;
424    use crate::{
425        cdk::types::Principal,
426        dto::error::ErrorCode,
427        ops::auth::{AuthExpiryError, AuthOpsError},
428    };
429
430    fn p(id: u8) -> Principal {
431        Principal::from_slice(&[id; 29])
432    }
433
434    #[test]
435    fn internal_invocation_not_yet_valid_maps_to_non_retryable_proof_expiry() {
436        let err = AuthApi::map_internal_invocation_verify_error(AuthOpsError::Expiry(
437            AuthExpiryError::AttestationNotYetValid {
438                issued_at: 20,
439                now_secs: 10,
440            },
441        ));
442
443        assert_eq!(err.code, ErrorCode::AuthProofExpired);
444    }
445
446    #[test]
447    fn delegation_request_caller_must_match_requested_shard() {
448        AuthApi::validate_delegation_request_caller(p(2), p(2)).expect("matching shard");
449
450        let err = AuthApi::validate_delegation_request_caller(p(1), p(2))
451            .expect_err("mismatched caller must fail");
452
453        assert_eq!(err.code, ErrorCode::Forbidden);
454        assert!(err.message.contains("must match shard_pid"));
455    }
456}