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, DelegationAudience, DelegationProof,
7            DelegationProofIssueRequest, InternalInvocationProofRequest, RoleAttestationRequest,
8            SignedInternalInvocationProofV1, SignedRoleAttestation,
9        },
10        error::{Error, ErrorCode},
11        rpc::{Request as RootRequest, Response as RootCapabilityResponse, RootRequestMetadata},
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        cost_guard::{CostGuardOps, CostGuardRequest},
24        ic::{IcOps, mgmt::MgmtOps},
25        replay::{
26            guard::secs_to_ns,
27            model::{
28                CommandKind, EcdsaPurpose, ExternalEffectDescriptor, OperationId, RecoveryReason,
29                ReplayActor, ReplayPayloadHasher,
30            },
31            receipt::{
32                ReplayReceiptDecision, ReplayReceiptReserveInput, ReplayReceiptStoreError,
33                ReplayReceiptToken, abort_reserved_receipt, commit_receipt_response,
34                mark_external_effect_in_flight, mark_recovery_required, reserve_or_replay_receipt,
35            },
36        },
37        runtime::env::EnvOps,
38        runtime::metrics::auth::record_attestation_refresh_failed,
39    },
40    workflow::rpc::request::handler::RootResponseWorkflow,
41};
42use candid::{decode_one, encode_one};
43use root_client::RootAuthMaterialClient;
44use sha2::{Digest, Sha256};
45
46// Internal auth pipeline:
47// - `session` owns delegated-session ingress and replay/session state handling.
48// - `metadata` owns root request metadata construction.
49// - `verify_flow` owns verifier-side attestation refresh behavior.
50mod metadata;
51mod root_client;
52mod session;
53mod verify_flow;
54
55///
56/// AuthApi
57///
58/// Owns delegated-token helpers and root-signed role-attestation helpers.
59///
60
61pub struct AuthApi;
62
63impl AuthApi {
64    const DELEGATED_TOKENS_DISABLED: &str =
65        "delegated token auth disabled; set auth.delegated_tokens.enabled=true in canic.toml";
66    const MAX_DELEGATED_SESSION_TTL_SECS: u64 = 24 * 60 * 60;
67    const DELEGATION_REPLAY_COMMAND_KIND: &str = "auth.issue_delegation_proof.v1";
68    const DELEGATION_REPLAY_RESPONSE_SCHEMA_VERSION: u32 = 1;
69    const MAX_DELEGATION_REPLAY_TTL_SECONDS: u64 = 300;
70    const DELEGATION_SIGNING_QUOTA_WINDOW_SECONDS: u64 = 60;
71    const MAX_DELEGATION_SIGNING_OPERATIONS_PER_WINDOW: u64 = 60;
72    const DELEGATION_SIGNING_CYCLE_RESERVATION_CYCLES: u128 = 1_000_000_000;
73    const MIN_DELEGATION_SIGNING_CYCLES_AFTER_RESERVATION: u128 = 1_000_000_000;
74    const SESSION_BOOTSTRAP_TOKEN_FINGERPRINT_DOMAIN: &[u8] =
75        b"canic-session-bootstrap-token-fingerprint";
76
77    // Map internal auth failures onto public endpoint errors.
78    fn map_auth_error(err: crate::InternalError) -> Error {
79        match err.class() {
80            InternalErrorClass::Infra | InternalErrorClass::Ops | InternalErrorClass::Workflow => {
81                Error::internal(err.to_string())
82            }
83            _ => Error::from(err),
84        }
85    }
86
87    fn map_internal_invocation_verify_error(err: AuthOpsError) -> Error {
88        match err {
89            AuthOpsError::Validation(AuthValidationError::AttestationUnknownKeyId { .. }) => {
90                Error::new(ErrorCode::AuthKeyUnknown, err.to_string())
91            }
92            AuthOpsError::Expiry(AuthExpiryError::AttestationEpochRejected { .. }) => {
93                Error::new(ErrorCode::AuthMaterialStale, err.to_string())
94            }
95            AuthOpsError::Expiry(
96                AuthExpiryError::AttestationExpired { .. }
97                | AuthExpiryError::AttestationNotYetValid { .. },
98            ) => Error::new(ErrorCode::AuthProofExpired, err.to_string()),
99            _ => Error::unauthorized(err.to_string()),
100        }
101    }
102
103    // Verify delegated-token material and return the token subject.
104    //
105    // This is intentionally private: endpoint authorization must also bind the
106    // verified subject to the caller and consume update tokens once.
107    fn verify_token_material(
108        token: &DelegatedToken,
109        max_cert_ttl_secs: u64,
110        max_token_ttl_secs: u64,
111        required_scopes: &[String],
112        now_secs: u64,
113    ) -> Result<Principal, Error> {
114        AuthOps::verify_token(VerifyDelegatedTokenRuntimeInput {
115            token,
116            max_cert_ttl_secs,
117            max_token_ttl_secs,
118            required_scopes,
119            now_secs,
120        })
121        .map(|verified| verified.subject)
122        .map_err(Self::map_auth_error)
123    }
124
125    /// Resolve the local shard public key in SEC1 encoding.
126    pub async fn local_shard_public_key_sec1() -> Result<Vec<u8>, Error> {
127        AuthOps::local_shard_public_key_sec1(IcOps::canister_self())
128            .await
129            .map_err(Self::map_auth_error)
130    }
131
132    /// Issue a delegated token from an explicit self-contained proof.
133    pub async fn issue_token(request: DelegatedTokenIssueRequest) -> Result<DelegatedToken, Error> {
134        AuthOps::sign_token(SignDelegatedTokenInput {
135            proof: request.proof,
136            subject: request.subject,
137            audience: request.aud,
138            scopes: request.scopes,
139            ttl_secs: request.ttl_secs,
140            nonce: request.nonce,
141        })
142        .await
143        .map_err(Self::map_auth_error)
144    }
145
146    /// Request a root proof, then issue a self-contained delegated token.
147    pub async fn mint_token(request: DelegatedTokenMintRequest) -> Result<DelegatedToken, Error> {
148        let proof = Self::request_delegation(DelegationProofIssueRequest {
149            metadata: None,
150            shard_pid: IcOps::canister_self(),
151            scopes: request.scopes.clone(),
152            aud: request.aud.clone(),
153            cert_ttl_secs: request.cert_ttl_secs,
154        })
155        .await?;
156
157        Self::issue_token(DelegatedTokenIssueRequest {
158            proof,
159            subject: request.subject,
160            aud: request.aud,
161            scopes: request.scopes,
162            ttl_secs: request.token_ttl_secs,
163            nonce: request.nonce,
164        })
165        .await
166    }
167
168    /// Request a self-contained delegation proof from root over RPC.
169    pub async fn request_delegation(
170        request: DelegationProofIssueRequest,
171    ) -> Result<DelegationProof, Error> {
172        let request = metadata::with_delegation_request_metadata(request);
173        Self::request_delegation_remote(request).await
174    }
175
176    /// Issue a self-contained delegation proof from the local root.
177    pub async fn issue_delegation_proof(
178        request: DelegationProofIssueRequest,
179    ) -> Result<DelegationProof, Error> {
180        EnvOps::require_root().map_err(Error::from)?;
181        let caller = IcOps::msg_caller();
182        Self::validate_delegation_request_caller(caller, request.shard_pid)?;
183        let max_cert_ttl_secs = Self::delegated_token_max_ttl_secs()?;
184        let metadata = Self::delegation_replay_metadata(request.metadata)?;
185        let command_kind = Self::delegation_replay_command_kind();
186        let actor = ReplayActor::direct_caller(caller);
187        let payload_hash = Self::delegation_replay_payload_hash(&command_kind, &actor, &request);
188        let now_secs = IcOps::now_secs();
189        let replay_input = ReplayReceiptReserveInput::new(
190            command_kind.clone(),
191            OperationId::from_bytes(metadata.request_id),
192            actor,
193            payload_hash,
194            secs_to_ns(now_secs),
195        )
196        .with_expires_at_ns(secs_to_ns(now_secs.saturating_add(metadata.ttl_seconds)));
197
198        let token = match reserve_or_replay_receipt(replay_input)
199            .map_err(Self::map_delegation_replay_store_error)?
200        {
201            ReplayReceiptDecision::Fresh(token) => token,
202            decision => return Self::map_delegation_replay_decision(decision),
203        };
204
205        Self::issue_fresh_delegation_proof(token, command_kind, caller, request, max_cert_ttl_secs)
206            .await
207    }
208
209    async fn issue_fresh_delegation_proof(
210        token: ReplayReceiptToken,
211        command_kind: CommandKind,
212        caller: Principal,
213        request: DelegationProofIssueRequest,
214        max_cert_ttl_secs: u64,
215    ) -> Result<DelegationProof, Error> {
216        let max_token_ttl_secs = request.cert_ttl_secs.min(max_cert_ttl_secs);
217        let prepared = match AuthOps::prepare_delegation_proof(SignDelegationProofInput {
218            audience: request.aud,
219            scopes: request.scopes,
220            shard_pid: request.shard_pid,
221            cert_ttl_secs: request.cert_ttl_secs,
222            max_token_ttl_secs,
223            max_cert_ttl_secs,
224            issued_at: IcOps::now_secs(),
225        })
226        .await
227        {
228            Ok(prepared) => prepared,
229            Err(err) => {
230                abort_reserved_receipt(&token);
231                return Err(Self::map_auth_error(err));
232            }
233        };
234
235        let cost_permit = match CostGuardOps::reserve(CostGuardRequest {
236            cost_class: crate::replay_policy::CostClass::ThresholdEcdsaSign,
237            command_kind,
238            quota_subject: caller,
239            payer: IcOps::canister_self(),
240            now_secs: IcOps::now_secs(),
241            quota_window_secs: Self::DELEGATION_SIGNING_QUOTA_WINDOW_SECONDS,
242            max_operations_per_window: Self::MAX_DELEGATION_SIGNING_OPERATIONS_PER_WINDOW,
243            current_cycle_balance: MgmtOps::canister_cycle_balance().to_u128(),
244            cycle_reservation_cycles: Self::DELEGATION_SIGNING_CYCLE_RESERVATION_CYCLES,
245            min_cycles_after_reservation: Self::MIN_DELEGATION_SIGNING_CYCLES_AFTER_RESERVATION,
246        }) {
247            Ok(permit) => permit,
248            Err(err) => {
249                abort_reserved_receipt(&token);
250                return Err(Self::map_auth_error(err));
251            }
252        };
253
254        mark_external_effect_in_flight(
255            &token,
256            ExternalEffectDescriptor::ThresholdEcdsaSign {
257                key_id_hash: Self::hash_delegation_effect_key(&prepared.key_name),
258                purpose: EcdsaPurpose::DelegationProof,
259                message_hash: prepared.cert_hash,
260            },
261            secs_to_ns(IcOps::now_secs()),
262        );
263
264        let proof = match AuthOps::sign_prepared_delegation_proof(&cost_permit, prepared).await {
265            Ok(proof) => proof,
266            Err(err) => {
267                let _ = CostGuardOps::recover(&cost_permit, IcOps::now_secs());
268                mark_recovery_required(
269                    &token,
270                    RecoveryReason::ExternalEffectStatusUnknown,
271                    secs_to_ns(IcOps::now_secs()),
272                );
273                return Err(Self::map_auth_error(err));
274            }
275        };
276
277        let response_bytes = match Self::encode_delegation_proof_response(&proof) {
278            Ok(response_bytes) => response_bytes,
279            Err(err) => {
280                let _ = CostGuardOps::recover(&cost_permit, IcOps::now_secs());
281                mark_recovery_required(
282                    &token,
283                    RecoveryReason::ResponseCommitFailed,
284                    secs_to_ns(IcOps::now_secs()),
285                );
286                return Err(err);
287            }
288        };
289
290        if let Err(err) = CostGuardOps::complete(&cost_permit, IcOps::now_secs()) {
291            mark_recovery_required(
292                &token,
293                RecoveryReason::ResponseCommitFailed,
294                secs_to_ns(IcOps::now_secs()),
295            );
296            return Err(Self::map_auth_error(err));
297        }
298
299        commit_receipt_response(
300            &token,
301            Self::DELEGATION_REPLAY_RESPONSE_SCHEMA_VERSION,
302            response_bytes,
303            secs_to_ns(IcOps::now_secs()),
304        );
305        Ok(proof)
306    }
307
308    /// Request a signed role attestation from root over RPC.
309    pub async fn request_role_attestation(
310        request: RoleAttestationRequest,
311    ) -> Result<SignedRoleAttestation, Error> {
312        let request = metadata::with_root_attestation_request_metadata(request);
313        Self::request_role_attestation_remote(request).await
314    }
315
316    /// Request a method-scoped internal invocation proof from root over RPC.
317    pub async fn request_internal_invocation_proof(
318        request: InternalInvocationProofRequest,
319    ) -> Result<SignedInternalInvocationProofV1, Error> {
320        let request = metadata::with_internal_invocation_proof_request_metadata(request);
321        Self::request_internal_invocation_proof_remote(request).await
322    }
323
324    /// Return the current root role-attestation key set.
325    pub async fn attestation_key_set() -> Result<AttestationKeySet, Error> {
326        AuthOps::attestation_key_set()
327            .await
328            .map_err(Self::map_auth_error)
329    }
330
331    /// Publish root auth material into subnet state and warm root-owned keys once.
332    pub async fn publish_root_auth_material() -> Result<(), Error> {
333        EnvOps::require_root().map_err(Error::from)?;
334        AuthOps::publish_root_auth_material().await.map_err(|err| {
335            log!(
336                Topic::Auth,
337                Warn,
338                "root auth material publish failed: {err}"
339            );
340            Self::map_auth_error(err)
341        })
342    }
343
344    /// Replace the verifier-local role-attestation key set.
345    pub fn replace_attestation_key_set(key_set: AttestationKeySet) {
346        AuthOps::replace_attestation_key_set(key_set);
347    }
348
349    /// Verify a role attestation, refreshing root keys once on unknown key.
350    pub async fn verify_role_attestation(
351        attestation: &SignedRoleAttestation,
352        min_accepted_epoch: u64,
353    ) -> Result<(), Error> {
354        crate::workflow::runtime::auth::RuntimeAuthWorkflow::verify_role_attestation(
355            attestation,
356            min_accepted_epoch,
357        )
358        .await
359        .map_err(Self::map_auth_error)
360    }
361
362    /// Verify a root-signed, method-scoped internal invocation proof for this endpoint.
363    pub async fn verify_internal_invocation_proof(
364        proof: &SignedInternalInvocationProofV1,
365        target_method: &str,
366        accepted_roles: &[CanisterRole],
367    ) -> Result<(), Error> {
368        let configured_min_accepted_epoch = ConfigOps::role_attestation_config()
369            .map_err(Error::from)?
370            .min_accepted_epoch_by_role
371            .get(proof.payload.role.as_str())
372            .copied();
373        let min_accepted_epoch =
374            verify_flow::resolve_min_accepted_epoch(0, configured_min_accepted_epoch);
375
376        let caller = IcOps::msg_caller();
377        let self_pid = IcOps::canister_self();
378        let now_secs = IcOps::now_secs();
379        let verifier_subnet = Some(EnvOps::subnet_pid().map_err(Error::from)?);
380        let root_pid = EnvOps::root_pid().map_err(Error::from)?;
381
382        let verify = || {
383            AuthOps::verify_internal_invocation_proof_cached(
384                proof,
385                crate::ops::auth::InternalInvocationProofVerificationInput {
386                    caller,
387                    self_pid,
388                    target_method,
389                    accepted_roles,
390                    verifier_subnet,
391                    now_secs,
392                    min_accepted_epoch,
393                },
394            )
395            .map(|_| ())
396        };
397        let refresh = || async {
398            let key_set = RootAuthMaterialClient::new(root_pid)
399                .attestation_key_set()
400                .await?;
401            AuthOps::replace_attestation_key_set(key_set);
402            Ok(())
403        };
404
405        match verify_flow::verify_role_attestation_with_single_refresh(verify, refresh).await {
406            Ok(()) => Ok(()),
407            Err(verify_flow::RoleAttestationVerifyFlowError::Initial(err)) => {
408                verify_flow::record_attestation_verifier_rejection(&err);
409                log!(
410                    Topic::Auth,
411                    Warn,
412                    "internal invocation proof rejected phase=cached local={} caller={} subject={} role={} key_id={} audience={} method={} epoch={} error={}",
413                    self_pid,
414                    caller,
415                    proof.payload.subject,
416                    proof.payload.role,
417                    proof.key_id,
418                    proof.payload.audience,
419                    proof.payload.audience_method,
420                    proof.payload.epoch,
421                    err
422                );
423                Err(Self::map_internal_invocation_verify_error(err))
424            }
425            Err(verify_flow::RoleAttestationVerifyFlowError::Refresh { trigger, source }) => {
426                verify_flow::record_attestation_verifier_rejection(&trigger);
427                record_attestation_refresh_failed();
428                log!(
429                    Topic::Auth,
430                    Warn,
431                    "internal invocation proof refresh failed local={} caller={} key_id={} error={}",
432                    self_pid,
433                    caller,
434                    proof.key_id,
435                    source
436                );
437                Err(Self::map_auth_error(source))
438            }
439            Err(verify_flow::RoleAttestationVerifyFlowError::PostRefresh(err)) => {
440                verify_flow::record_attestation_verifier_rejection(&err);
441                log!(
442                    Topic::Auth,
443                    Warn,
444                    "internal invocation proof rejected phase=post_refresh local={} caller={} subject={} role={} key_id={} audience={} method={} epoch={} error={}",
445                    self_pid,
446                    caller,
447                    proof.payload.subject,
448                    proof.payload.role,
449                    proof.key_id,
450                    proof.payload.audience,
451                    proof.payload.audience_method,
452                    proof.payload.epoch,
453                    err
454                );
455                Err(Self::map_internal_invocation_verify_error(err))
456            }
457        }
458    }
459
460    // Resolve the root-owned TTL ceiling from delegated-token config.
461    fn delegated_token_max_ttl_secs() -> Result<u64, Error> {
462        let cfg = ConfigOps::delegated_tokens_config().map_err(Error::from)?;
463        if !cfg.enabled {
464            return Err(Error::forbidden(Self::DELEGATED_TOKENS_DISABLED));
465        }
466
467        Ok(cfg
468            .max_ttl_secs
469            .unwrap_or(Self::MAX_DELEGATED_SESSION_TTL_SECS))
470    }
471
472    fn validate_delegation_request_caller(
473        caller: Principal,
474        shard_pid: Principal,
475    ) -> Result<(), Error> {
476        if caller == shard_pid {
477            return Ok(());
478        }
479
480        Err(Error::forbidden(format!(
481            "delegation request caller {caller} must match shard_pid {shard_pid}"
482        )))
483    }
484
485    fn delegation_replay_metadata(
486        metadata: Option<RootRequestMetadata>,
487    ) -> Result<RootRequestMetadata, Error> {
488        let metadata = metadata
489            .ok_or_else(|| Error::invalid("delegation proof request requires replay metadata"))?;
490        if metadata.ttl_seconds == 0 {
491            return Err(Error::invalid(
492                "delegation proof replay metadata ttl_seconds must be greater than zero",
493            ));
494        }
495        if metadata.ttl_seconds > Self::MAX_DELEGATION_REPLAY_TTL_SECONDS {
496            return Err(Error::invalid(format!(
497                "delegation proof replay metadata ttl_seconds={} exceeds max {}",
498                metadata.ttl_seconds,
499                Self::MAX_DELEGATION_REPLAY_TTL_SECONDS
500            )));
501        }
502        Ok(metadata)
503    }
504
505    fn delegation_replay_command_kind() -> CommandKind {
506        CommandKind::new(Self::DELEGATION_REPLAY_COMMAND_KIND)
507            .expect("delegation replay command kind is a valid static label")
508    }
509
510    fn delegation_replay_payload_hash(
511        command_kind: &CommandKind,
512        actor: &ReplayActor,
513        request: &DelegationProofIssueRequest,
514    ) -> [u8; 32] {
515        let mut hasher = ReplayPayloadHasher::new(command_kind, actor);
516        hasher.hash_principal(&request.shard_pid);
517        hasher.hash_u64(request.scopes.len() as u64);
518        for scope in &request.scopes {
519            hasher.hash_str(scope);
520        }
521        Self::hash_delegation_audience(&mut hasher, &request.aud);
522        hasher.hash_u64(request.cert_ttl_secs);
523        hasher.finish()
524    }
525
526    fn hash_delegation_audience(hasher: &mut ReplayPayloadHasher, aud: &DelegationAudience) {
527        match aud {
528            DelegationAudience::Role(role) => {
529                hasher.hash_str("role");
530                hasher.hash_role(role);
531            }
532            DelegationAudience::Principal(principal) => {
533                hasher.hash_str("principal");
534                hasher.hash_principal(principal);
535            }
536        }
537    }
538
539    fn map_delegation_replay_decision(
540        decision: ReplayReceiptDecision,
541    ) -> Result<DelegationProof, Error> {
542        match decision {
543            ReplayReceiptDecision::Fresh(_) => {
544                Err(Error::invariant("fresh delegation replay decision escaped"))
545            }
546            ReplayReceiptDecision::ReturnCommitted(receipt) => {
547                Self::decode_delegation_proof_response(&receipt)
548            }
549            ReplayReceiptDecision::OperationInProgress => Err(Error::conflict(
550                "delegation proof request is already in progress; retry later with the same request id",
551            )),
552            ReplayReceiptDecision::ActorMismatch => Err(Error::conflict(
553                "delegation proof request id was reused by a different caller",
554            )),
555            ReplayReceiptDecision::PayloadMismatch => Err(Error::conflict(
556                "delegation proof request id was reused with a different payload",
557            )),
558            ReplayReceiptDecision::Expired => Err(Error::conflict(
559                "delegation proof replay receipt expired; retry with a new request id",
560            )),
561            ReplayReceiptDecision::RecoveryRequired(reason) => Err(Error::conflict(format!(
562                "delegation proof request requires recovery before replay: {reason:?}"
563            ))),
564            ReplayReceiptDecision::TerminalFailed {
565                error_code,
566                error_bytes,
567                error_bytes_truncated,
568            } => Err(Error::conflict(format!(
569                "delegation proof request previously failed: {error_code:?}; error_bytes_len={}; truncated={error_bytes_truncated}",
570                error_bytes.len()
571            ))),
572        }
573    }
574
575    fn map_delegation_replay_store_error(err: ReplayReceiptStoreError) -> Error {
576        match err {
577            ReplayReceiptStoreError::ReceiptDecodeFailed(message) => Error::internal(format!(
578                "failed to decode delegation replay receipt: {message}"
579            )),
580        }
581    }
582
583    fn encode_delegation_proof_response(proof: &DelegationProof) -> Result<Vec<u8>, Error> {
584        encode_one(proof).map_err(|err| {
585            Error::internal(format!(
586                "failed to encode delegation proof replay response: {err}"
587            ))
588        })
589    }
590
591    fn decode_delegation_proof_response(
592        receipt: &crate::ops::replay::model::ReplayReceipt,
593    ) -> Result<DelegationProof, Error> {
594        let response_schema_version = receipt.response_schema_version.ok_or_else(|| {
595            Error::internal("delegation replay receipt is missing response schema version")
596        })?;
597        if response_schema_version != Self::DELEGATION_REPLAY_RESPONSE_SCHEMA_VERSION {
598            return Err(Error::internal(format!(
599                "unsupported delegation replay response schema version {response_schema_version}"
600            )));
601        }
602        let response_bytes = receipt.response_bytes.as_deref().ok_or_else(|| {
603            Error::internal("delegation replay receipt is missing response bytes")
604        })?;
605        decode_one(response_bytes).map_err(|err| {
606            Error::internal(format!(
607                "failed to decode delegation proof replay response: {err}"
608            ))
609        })
610    }
611
612    fn hash_delegation_effect_key(key_name: &str) -> [u8; 32] {
613        let mut hasher = Sha256::new();
614        hasher.update(b"canic-delegation-proof-effect-key:v1");
615        hasher.update(key_name.as_bytes());
616        hasher.finalize().into()
617    }
618}
619
620impl AuthApi {
621    // Route a self-contained delegation proof request over RPC to root.
622    async fn request_delegation_remote(
623        request: DelegationProofIssueRequest,
624    ) -> Result<DelegationProof, Error> {
625        let root_pid = EnvOps::root_pid().map_err(Error::from)?;
626        RootAuthMaterialClient::new(root_pid)
627            .request_delegation(request)
628            .await
629            .map_err(Self::map_auth_error)
630    }
631
632    // Execute one local root role-attestation request.
633    pub async fn request_role_attestation_root(
634        request: RoleAttestationRequest,
635    ) -> Result<SignedRoleAttestation, Error> {
636        let request = metadata::with_root_attestation_request_metadata(request);
637        let response = RootResponseWorkflow::response(RootRequest::issue_role_attestation(request))
638            .await
639            .map_err(Self::map_auth_error)?;
640
641        match response {
642            RootCapabilityResponse::RoleAttestationIssued(response) => Ok(response),
643            _ => Err(Error::internal(
644                "invalid root response type for role attestation request",
645            )),
646        }
647    }
648
649    // Execute one local root internal-invocation proof request.
650    pub async fn request_internal_invocation_proof_root(
651        request: InternalInvocationProofRequest,
652    ) -> Result<SignedInternalInvocationProofV1, Error> {
653        let request = metadata::with_internal_invocation_proof_request_metadata(request);
654        let response =
655            RootResponseWorkflow::response(RootRequest::issue_internal_invocation_proof(request))
656                .await
657                .map_err(Self::map_auth_error)?;
658
659        match response {
660            RootCapabilityResponse::InternalInvocationProofIssued(response) => Ok(response),
661            _ => Err(Error::internal(
662                "invalid root response type for internal invocation proof request",
663            )),
664        }
665    }
666
667    // Route a canonical role-attestation request over RPC to root.
668    async fn request_role_attestation_remote(
669        request: RoleAttestationRequest,
670    ) -> Result<SignedRoleAttestation, Error> {
671        let root_pid = EnvOps::root_pid().map_err(Error::from)?;
672        RootAuthMaterialClient::new(root_pid)
673            .request_role_attestation(request)
674            .await
675            .map_err(Self::map_auth_error)
676    }
677
678    // Route a canonical internal-invocation proof request over RPC to root.
679    async fn request_internal_invocation_proof_remote(
680        request: InternalInvocationProofRequest,
681    ) -> Result<SignedInternalInvocationProofV1, Error> {
682        let root_pid = EnvOps::root_pid().map_err(Error::from)?;
683        RootAuthMaterialClient::new(root_pid)
684            .request_internal_invocation_proof(request)
685            .await
686            .map_err(Self::map_auth_error)
687    }
688}
689
690#[cfg(test)]
691mod tests {
692    use super::AuthApi;
693    use crate::{
694        cdk::types::Principal,
695        dto::{
696            auth::{DelegationAudience, DelegationProofIssueRequest},
697            error::ErrorCode,
698            rpc::RootRequestMetadata,
699        },
700        ops::auth::{AuthExpiryError, AuthOpsError},
701    };
702
703    fn p(id: u8) -> Principal {
704        Principal::from_slice(&[id; 29])
705    }
706
707    fn delegation_request(metadata_id: u8) -> DelegationProofIssueRequest {
708        DelegationProofIssueRequest {
709            metadata: Some(RootRequestMetadata {
710                request_id: [metadata_id; 32],
711                ttl_seconds: 60,
712            }),
713            shard_pid: p(2),
714            scopes: vec!["canic.verify".to_string()],
715            aud: DelegationAudience::Principal(p(3)),
716            cert_ttl_secs: 60,
717        }
718    }
719
720    #[test]
721    fn internal_invocation_not_yet_valid_maps_to_non_retryable_proof_expiry() {
722        let err = AuthApi::map_internal_invocation_verify_error(AuthOpsError::Expiry(
723            AuthExpiryError::AttestationNotYetValid {
724                issued_at: 20,
725                now_secs: 10,
726            },
727        ));
728
729        assert_eq!(err.code, ErrorCode::AuthProofExpired);
730    }
731
732    #[test]
733    fn delegation_request_caller_must_match_requested_shard() {
734        AuthApi::validate_delegation_request_caller(p(2), p(2)).expect("matching shard");
735
736        let err = AuthApi::validate_delegation_request_caller(p(1), p(2))
737            .expect_err("mismatched caller must fail");
738
739        assert_eq!(err.code, ErrorCode::Forbidden);
740        assert!(err.message.contains("must match shard_pid"));
741    }
742
743    #[test]
744    fn delegation_replay_metadata_rejects_missing_or_invalid_ttl() {
745        let missing = AuthApi::delegation_replay_metadata(None).expect_err("metadata is required");
746        assert_eq!(missing.code, ErrorCode::InvalidInput);
747
748        let zero = AuthApi::delegation_replay_metadata(Some(RootRequestMetadata {
749            request_id: [1; 32],
750            ttl_seconds: 0,
751        }))
752        .expect_err("zero ttl is invalid");
753        assert_eq!(zero.code, ErrorCode::InvalidInput);
754
755        let too_large = AuthApi::delegation_replay_metadata(Some(RootRequestMetadata {
756            request_id: [1; 32],
757            ttl_seconds: AuthApi::MAX_DELEGATION_REPLAY_TTL_SECONDS + 1,
758        }))
759        .expect_err("oversized ttl is invalid");
760        assert_eq!(too_large.code, ErrorCode::InvalidInput);
761    }
762
763    #[test]
764    fn delegation_replay_payload_hash_ignores_metadata() {
765        let command_kind = AuthApi::delegation_replay_command_kind();
766        let actor = crate::ops::replay::model::ReplayActor::direct_caller(p(2));
767        let a = delegation_request(1);
768        let b = delegation_request(9);
769
770        assert_eq!(
771            AuthApi::delegation_replay_payload_hash(&command_kind, &actor, &a),
772            AuthApi::delegation_replay_payload_hash(&command_kind, &actor, &b)
773        );
774    }
775
776    #[test]
777    fn delegation_replay_payload_hash_binds_authoritative_payload() {
778        let command_kind = AuthApi::delegation_replay_command_kind();
779        let actor = crate::ops::replay::model::ReplayActor::direct_caller(p(2));
780        let a = delegation_request(1);
781        let mut b = a.clone();
782        b.cert_ttl_secs += 1;
783
784        assert_ne!(
785            AuthApi::delegation_replay_payload_hash(&command_kind, &actor, &a),
786            AuthApi::delegation_replay_payload_hash(&command_kind, &actor, &b)
787        );
788    }
789}