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, DelegationCert, DelegationProof,
7            DelegationProofIssueRequest, InternalInvocationProofRequest, RoleAttestationRequest,
8            ShardKeyBinding, SignatureAlgorithm, SignedInternalInvocationProofV1,
9            SignedRoleAttestation,
10        },
11        error::{Error, ErrorCode},
12        rpc::{Request as RootRequest, Response as RootCapabilityResponse, RootRequestMetadata},
13    },
14    error::InternalErrorClass,
15    ids::CanisterRole,
16    log,
17    log::Topic,
18    ops::{
19        auth::{
20            AuthExpiryError, AuthOps, AuthOpsError, AuthValidationError, SignDelegatedTokenInput,
21            SignDelegationProofInput, VerifyDelegatedTokenRuntimeInput,
22        },
23        config::ConfigOps,
24        cost_guard::{CostGuardOps, CostGuardRequest},
25        ic::{IcOps, mgmt::MgmtOps},
26        replay::{
27            guard::secs_to_ns,
28            model::{
29                CommandKind, EcdsaPurpose, ExternalEffectDescriptor, OperationId, RecoveryReason,
30                ReplayActor, ReplayPayloadHasher,
31            },
32            receipt::{
33                ReplayReceiptDecision, ReplayReceiptReserveInput, ReplayReceiptStoreError,
34                ReplayReceiptToken, abort_reserved_receipt, commit_receipt_response,
35                mark_external_effect_in_flight, mark_recovery_required, reserve_or_replay_receipt,
36            },
37        },
38        runtime::env::EnvOps,
39        runtime::metrics::auth::record_attestation_refresh_failed,
40    },
41    workflow::rpc::request::handler::RootResponseWorkflow,
42};
43use candid::{decode_one, encode_one};
44use root_client::RootAuthMaterialClient;
45use sha2::{Digest, Sha256};
46
47// Internal auth pipeline:
48// - `session` owns delegated-session ingress and replay/session state handling.
49// - `metadata` owns root request metadata construction.
50// - `verify_flow` owns verifier-side attestation refresh behavior.
51mod metadata;
52mod root_client;
53mod session;
54mod verify_flow;
55
56///
57/// AuthApi
58///
59/// Owns delegated-token helpers and root-signed role-attestation helpers.
60///
61
62pub struct AuthApi;
63
64impl AuthApi {
65    const DELEGATED_TOKENS_DISABLED: &str =
66        "delegated token auth disabled; set auth.delegated_tokens.enabled=true in canic.toml";
67    const MAX_DELEGATED_SESSION_TTL_SECS: u64 = 24 * 60 * 60;
68    const DELEGATION_REPLAY_COMMAND_KIND: &str = "auth.issue_delegation_proof.v1";
69    const DELEGATION_REPLAY_RESPONSE_SCHEMA_VERSION: u32 = 1;
70    const MAX_DELEGATION_REPLAY_TTL_SECONDS: u64 = 300;
71    const DELEGATION_SIGNING_QUOTA_WINDOW_SECONDS: u64 = 60;
72    const MAX_DELEGATION_SIGNING_OPERATIONS_PER_WINDOW: u64 = 60;
73    const DELEGATION_SIGNING_CYCLE_RESERVATION_CYCLES: u128 = 1_000_000_000;
74    const MIN_DELEGATION_SIGNING_CYCLES_AFTER_RESERVATION: u128 = 1_000_000_000;
75    const TOKEN_ISSUE_REPLAY_COMMAND_KIND: &str = "auth.issue_token.v1";
76    const TOKEN_MINT_REPLAY_COMMAND_KIND: &str = "auth.mint_token.v1";
77    const TOKEN_REPLAY_RESPONSE_SCHEMA_VERSION: u32 = 1;
78    const MAX_TOKEN_REPLAY_TTL_SECONDS: u64 = 300;
79    const TOKEN_SIGNING_QUOTA_WINDOW_SECONDS: u64 = 60;
80    const MAX_TOKEN_SIGNING_OPERATIONS_PER_WINDOW: u64 = 60;
81    const TOKEN_SIGNING_CYCLE_RESERVATION_CYCLES: u128 = 1_000_000_000;
82    const MIN_TOKEN_SIGNING_CYCLES_AFTER_RESERVATION: u128 = 1_000_000_000;
83    const SESSION_BOOTSTRAP_TOKEN_FINGERPRINT_DOMAIN: &[u8] =
84        b"canic-session-bootstrap-token-fingerprint";
85
86    // Map internal auth failures onto public endpoint errors.
87    fn map_auth_error(err: crate::InternalError) -> Error {
88        match err.class() {
89            InternalErrorClass::Infra | InternalErrorClass::Ops | InternalErrorClass::Workflow => {
90                Error::internal(err.to_string())
91            }
92            _ => Error::from(err),
93        }
94    }
95
96    fn map_internal_invocation_verify_error(err: AuthOpsError) -> Error {
97        match err {
98            AuthOpsError::Validation(AuthValidationError::AttestationUnknownKeyId { .. }) => {
99                Error::new(ErrorCode::AuthKeyUnknown, err.to_string())
100            }
101            AuthOpsError::Expiry(AuthExpiryError::AttestationEpochRejected { .. }) => {
102                Error::new(ErrorCode::AuthMaterialStale, err.to_string())
103            }
104            AuthOpsError::Expiry(
105                AuthExpiryError::AttestationExpired { .. }
106                | AuthExpiryError::AttestationNotYetValid { .. },
107            ) => Error::new(ErrorCode::AuthProofExpired, err.to_string()),
108            _ => Error::unauthorized(err.to_string()),
109        }
110    }
111
112    // Verify delegated-token material and return the token subject.
113    //
114    // This is intentionally private: endpoint authorization must also bind the
115    // verified subject to the caller before dispatch.
116    fn verify_token_material(
117        token: &DelegatedToken,
118        max_cert_ttl_secs: u64,
119        max_token_ttl_secs: u64,
120        required_scopes: &[String],
121        now_secs: u64,
122    ) -> Result<Principal, Error> {
123        AuthOps::verify_token(VerifyDelegatedTokenRuntimeInput {
124            token,
125            max_cert_ttl_secs,
126            max_token_ttl_secs,
127            required_scopes,
128            now_secs,
129        })
130        .map(|verified| verified.subject)
131        .map_err(Self::map_auth_error)
132    }
133
134    /// Resolve the local shard public key in SEC1 encoding.
135    pub async fn local_shard_public_key_sec1() -> Result<Vec<u8>, Error> {
136        AuthOps::local_shard_public_key_sec1(IcOps::canister_self())
137            .await
138            .map_err(Self::map_auth_error)
139    }
140
141    /// Issue a delegated token from an explicit self-contained proof.
142    pub async fn issue_token(request: DelegatedTokenIssueRequest) -> Result<DelegatedToken, Error> {
143        let label = "delegated token issue";
144        let metadata = Self::token_replay_metadata(request.metadata, "delegated token issue")?;
145        let operation_id = OperationId::from_bytes(metadata.request_id);
146        let command_kind = Self::token_issue_replay_command_kind();
147        let caller = IcOps::msg_caller();
148        let actor = ReplayActor::direct_caller(caller);
149        let payload_hash = Self::token_issue_replay_payload_hash(&command_kind, &actor, &request);
150        let token = match Self::reserve_token_replay_receipt(
151            command_kind.clone(),
152            metadata,
153            actor,
154            payload_hash,
155        )? {
156            ReplayReceiptDecision::Fresh(token) => {
157                Self::log_token_replay_reserved(label, &command_kind, operation_id, caller);
158                token
159            }
160            decision => {
161                Self::log_token_replay_decision(
162                    label,
163                    &command_kind,
164                    operation_id,
165                    caller,
166                    &decision,
167                );
168                return Self::map_token_replay_decision(decision, label);
169            }
170        };
171
172        Self::issue_fresh_token_from_proof(
173            token,
174            command_kind,
175            caller,
176            operation_id,
177            label,
178            request,
179        )
180        .await
181    }
182
183    /// Request a root proof, then issue a self-contained delegated token.
184    pub async fn mint_token(request: DelegatedTokenMintRequest) -> Result<DelegatedToken, Error> {
185        let label = "delegated token mint";
186        let metadata = Self::token_replay_metadata(request.metadata, "delegated token mint")?;
187        let operation_id = OperationId::from_bytes(metadata.request_id);
188        let command_kind = Self::token_mint_replay_command_kind();
189        let caller = IcOps::msg_caller();
190        let actor = ReplayActor::direct_caller(caller);
191        let payload_hash = Self::token_mint_replay_payload_hash(&command_kind, &actor, &request);
192        let token = match Self::reserve_token_replay_receipt(
193            command_kind.clone(),
194            metadata,
195            actor,
196            payload_hash,
197        )? {
198            ReplayReceiptDecision::Fresh(token) => {
199                Self::log_token_replay_reserved(label, &command_kind, operation_id, caller);
200                token
201            }
202            decision => {
203                Self::log_token_replay_decision(
204                    label,
205                    &command_kind,
206                    operation_id,
207                    caller,
208                    &decision,
209                );
210                return Self::map_token_replay_decision(decision, label);
211            }
212        };
213
214        let proof = Self::request_delegation(DelegationProofIssueRequest {
215            metadata: Some(metadata),
216            shard_pid: IcOps::canister_self(),
217            scopes: request.scopes.clone(),
218            aud: request.aud.clone(),
219            cert_ttl_secs: request.cert_ttl_secs,
220        })
221        .await
222        .inspect_err(|_| {
223            abort_reserved_receipt(&token);
224        })?;
225
226        let issue_request = DelegatedTokenIssueRequest {
227            metadata: None,
228            subject: request.subject,
229            aud: request.aud,
230            scopes: request.scopes,
231            ttl_secs: request.token_ttl_secs,
232            nonce: request.nonce,
233            proof,
234        };
235
236        Self::issue_fresh_token_from_proof(
237            token,
238            command_kind,
239            caller,
240            operation_id,
241            label,
242            issue_request,
243        )
244        .await
245    }
246
247    /// Request a self-contained delegation proof from root over RPC.
248    pub async fn request_delegation(
249        request: DelegationProofIssueRequest,
250    ) -> Result<DelegationProof, Error> {
251        let request = metadata::with_delegation_request_metadata(request);
252        Self::request_delegation_remote(request).await
253    }
254
255    /// Issue a self-contained delegation proof from the local root.
256    pub async fn issue_delegation_proof(
257        request: DelegationProofIssueRequest,
258    ) -> Result<DelegationProof, Error> {
259        EnvOps::require_root().map_err(Error::from)?;
260        let caller = IcOps::msg_caller();
261        Self::validate_delegation_request_caller(caller, request.shard_pid)?;
262        let max_cert_ttl_secs = Self::delegated_token_max_ttl_secs()?;
263        let metadata = Self::delegation_replay_metadata(request.metadata)?;
264        let command_kind = Self::delegation_replay_command_kind();
265        let actor = ReplayActor::direct_caller(caller);
266        let payload_hash = Self::delegation_replay_payload_hash(&command_kind, &actor, &request);
267        let now_secs = IcOps::now_secs();
268        let replay_input = ReplayReceiptReserveInput::new(
269            command_kind.clone(),
270            OperationId::from_bytes(metadata.request_id),
271            actor,
272            payload_hash,
273            secs_to_ns(now_secs),
274        )
275        .with_expires_at_ns(secs_to_ns(now_secs.saturating_add(metadata.ttl_seconds)));
276
277        let token = match reserve_or_replay_receipt(replay_input)
278            .map_err(Self::map_delegation_replay_store_error)?
279        {
280            ReplayReceiptDecision::Fresh(token) => token,
281            decision => return Self::map_delegation_replay_decision(decision),
282        };
283
284        Self::issue_fresh_delegation_proof(token, command_kind, caller, request, max_cert_ttl_secs)
285            .await
286    }
287
288    async fn issue_fresh_delegation_proof(
289        token: ReplayReceiptToken,
290        command_kind: CommandKind,
291        caller: Principal,
292        request: DelegationProofIssueRequest,
293        max_cert_ttl_secs: u64,
294    ) -> Result<DelegationProof, Error> {
295        let max_token_ttl_secs = request.cert_ttl_secs.min(max_cert_ttl_secs);
296        let prepared = match AuthOps::prepare_delegation_proof(SignDelegationProofInput {
297            audience: request.aud,
298            scopes: request.scopes,
299            shard_pid: request.shard_pid,
300            cert_ttl_secs: request.cert_ttl_secs,
301            max_token_ttl_secs,
302            max_cert_ttl_secs,
303            issued_at: IcOps::now_secs(),
304        })
305        .await
306        {
307            Ok(prepared) => prepared,
308            Err(err) => {
309                abort_reserved_receipt(&token);
310                return Err(Self::map_auth_error(err));
311            }
312        };
313
314        let cost_permit = match CostGuardOps::reserve(CostGuardRequest {
315            cost_class: crate::replay_policy::CostClass::ThresholdEcdsaSign,
316            command_kind,
317            quota_subject: caller,
318            payer: IcOps::canister_self(),
319            now_secs: IcOps::now_secs(),
320            quota_window_secs: Self::DELEGATION_SIGNING_QUOTA_WINDOW_SECONDS,
321            max_operations_per_window: Self::MAX_DELEGATION_SIGNING_OPERATIONS_PER_WINDOW,
322            current_cycle_balance: MgmtOps::canister_cycle_balance().to_u128(),
323            cycle_reservation_cycles: Self::DELEGATION_SIGNING_CYCLE_RESERVATION_CYCLES,
324            min_cycles_after_reservation: Self::MIN_DELEGATION_SIGNING_CYCLES_AFTER_RESERVATION,
325        }) {
326            Ok(permit) => permit,
327            Err(err) => {
328                abort_reserved_receipt(&token);
329                return Err(Self::map_auth_error(err));
330            }
331        };
332
333        mark_external_effect_in_flight(
334            &token,
335            ExternalEffectDescriptor::ThresholdEcdsaSign {
336                key_id_hash: Self::hash_delegation_effect_key(&prepared.key_name),
337                purpose: EcdsaPurpose::DelegationProof,
338                message_hash: prepared.cert_hash,
339            },
340            secs_to_ns(IcOps::now_secs()),
341        );
342
343        let proof = match AuthOps::sign_prepared_delegation_proof(&cost_permit, prepared).await {
344            Ok(proof) => proof,
345            Err(err) => {
346                let _ = CostGuardOps::recover(&cost_permit, IcOps::now_secs());
347                mark_recovery_required(
348                    &token,
349                    RecoveryReason::ExternalEffectStatusUnknown,
350                    secs_to_ns(IcOps::now_secs()),
351                );
352                return Err(Self::map_auth_error(err));
353            }
354        };
355
356        let response_bytes = match Self::encode_delegation_proof_response(&proof) {
357            Ok(response_bytes) => response_bytes,
358            Err(err) => {
359                let _ = CostGuardOps::recover(&cost_permit, IcOps::now_secs());
360                mark_recovery_required(
361                    &token,
362                    RecoveryReason::ResponseCommitFailed,
363                    secs_to_ns(IcOps::now_secs()),
364                );
365                return Err(err);
366            }
367        };
368
369        if let Err(err) = CostGuardOps::complete(&cost_permit, IcOps::now_secs()) {
370            mark_recovery_required(
371                &token,
372                RecoveryReason::ResponseCommitFailed,
373                secs_to_ns(IcOps::now_secs()),
374            );
375            return Err(Self::map_auth_error(err));
376        }
377
378        commit_receipt_response(
379            &token,
380            Self::DELEGATION_REPLAY_RESPONSE_SCHEMA_VERSION,
381            response_bytes,
382            secs_to_ns(IcOps::now_secs()),
383        );
384        Ok(proof)
385    }
386
387    async fn issue_fresh_token_from_proof(
388        token: ReplayReceiptToken,
389        command_kind: CommandKind,
390        caller: Principal,
391        operation_id: OperationId,
392        label: &'static str,
393        request: DelegatedTokenIssueRequest,
394    ) -> Result<DelegatedToken, Error> {
395        let prepared = match AuthOps::prepare_delegated_token_signature(SignDelegatedTokenInput {
396            proof: request.proof,
397            subject: request.subject,
398            audience: request.aud,
399            scopes: request.scopes,
400            ttl_secs: request.ttl_secs,
401            nonce: request.nonce,
402        }) {
403            Ok(prepared) => prepared,
404            Err(err) => {
405                abort_reserved_receipt(&token);
406                return Err(Self::map_auth_error(err));
407            }
408        };
409
410        let cost_permit = match CostGuardOps::reserve(Self::token_signing_cost_guard_request(
411            command_kind.clone(),
412            caller,
413        )) {
414            Ok(permit) => permit,
415            Err(err) => {
416                abort_reserved_receipt(&token);
417                return Err(Self::map_auth_error(err));
418            }
419        };
420        Self::log_token_signing_cost_guard_reserved(label, &command_kind, operation_id, caller);
421
422        let effect = AuthOps::delegated_token_signing_effect(&prepared);
423        mark_external_effect_in_flight(&token, effect.clone(), secs_to_ns(IcOps::now_secs()));
424        Self::log_token_replay_effect_marked(label, &command_kind, operation_id, caller, &effect);
425
426        let delegated_token =
427            match AuthOps::sign_prepared_delegated_token(&cost_permit, prepared).await {
428                Ok(delegated_token) => delegated_token,
429                Err(err) => {
430                    let _ = CostGuardOps::recover(&cost_permit, IcOps::now_secs());
431                    mark_recovery_required(
432                        &token,
433                        RecoveryReason::ExternalEffectStatusUnknown,
434                        secs_to_ns(IcOps::now_secs()),
435                    );
436                    Self::log_token_replay_recovery_required(
437                        label,
438                        &command_kind,
439                        operation_id,
440                        caller,
441                        &err,
442                    );
443                    return Err(Self::map_auth_error(err));
444                }
445            };
446
447        let response_bytes = match Self::encode_delegated_token_response(&delegated_token) {
448            Ok(response_bytes) => response_bytes,
449            Err(err) => {
450                let _ = CostGuardOps::recover(&cost_permit, IcOps::now_secs());
451                mark_recovery_required(
452                    &token,
453                    RecoveryReason::ResponseCommitFailed,
454                    secs_to_ns(IcOps::now_secs()),
455                );
456                Self::log_token_replay_response_commit_failed(
457                    label,
458                    &command_kind,
459                    operation_id,
460                    caller,
461                    &err,
462                );
463                return Err(err);
464            }
465        };
466
467        if let Err(err) = CostGuardOps::complete(&cost_permit, IcOps::now_secs()) {
468            mark_recovery_required(
469                &token,
470                RecoveryReason::ResponseCommitFailed,
471                secs_to_ns(IcOps::now_secs()),
472            );
473            Self::log_token_replay_response_commit_failed_internal(
474                label,
475                &command_kind,
476                operation_id,
477                caller,
478                &err,
479            );
480            return Err(Self::map_auth_error(err));
481        }
482
483        commit_receipt_response(
484            &token,
485            Self::TOKEN_REPLAY_RESPONSE_SCHEMA_VERSION,
486            response_bytes,
487            secs_to_ns(IcOps::now_secs()),
488        );
489        Self::log_token_replay_commit(label, &command_kind, operation_id, caller);
490        Ok(delegated_token)
491    }
492
493    /// Request a signed role attestation from root over RPC.
494    pub async fn request_role_attestation(
495        request: RoleAttestationRequest,
496    ) -> Result<SignedRoleAttestation, Error> {
497        let request = metadata::with_root_attestation_request_metadata(request);
498        Self::request_role_attestation_remote(request).await
499    }
500
501    /// Request a method-scoped internal invocation proof from root over RPC.
502    pub async fn request_internal_invocation_proof(
503        request: InternalInvocationProofRequest,
504    ) -> Result<SignedInternalInvocationProofV1, Error> {
505        let request = metadata::with_internal_invocation_proof_request_metadata(request);
506        Self::request_internal_invocation_proof_remote(request).await
507    }
508
509    /// Return the current root role-attestation key set.
510    pub async fn attestation_key_set() -> Result<AttestationKeySet, Error> {
511        AuthOps::attestation_key_set()
512            .await
513            .map_err(Self::map_auth_error)
514    }
515
516    /// Publish root auth material into subnet state and warm root-owned keys once.
517    pub async fn publish_root_auth_material() -> Result<(), Error> {
518        EnvOps::require_root().map_err(Error::from)?;
519        AuthOps::publish_root_auth_material().await.map_err(|err| {
520            log!(
521                Topic::Auth,
522                Warn,
523                "root auth material publish failed: {err}"
524            );
525            Self::map_auth_error(err)
526        })
527    }
528
529    /// Replace the verifier-local role-attestation key set.
530    pub fn replace_attestation_key_set(key_set: AttestationKeySet) {
531        AuthOps::replace_attestation_key_set(key_set);
532    }
533
534    /// Verify a role attestation, refreshing root keys once on unknown key.
535    pub async fn verify_role_attestation(
536        attestation: &SignedRoleAttestation,
537        min_accepted_epoch: u64,
538    ) -> Result<(), Error> {
539        crate::workflow::runtime::auth::RuntimeAuthWorkflow::verify_role_attestation(
540            attestation,
541            min_accepted_epoch,
542        )
543        .await
544        .map_err(Self::map_auth_error)
545    }
546
547    /// Verify a root-signed, method-scoped internal invocation proof for this endpoint.
548    pub async fn verify_internal_invocation_proof(
549        proof: &SignedInternalInvocationProofV1,
550        target_method: &str,
551        accepted_roles: &[CanisterRole],
552    ) -> Result<(), Error> {
553        let configured_min_accepted_epoch = ConfigOps::role_attestation_config()
554            .map_err(Error::from)?
555            .min_accepted_epoch_by_role
556            .get(proof.payload.role.as_str())
557            .copied();
558        let min_accepted_epoch =
559            verify_flow::resolve_min_accepted_epoch(0, configured_min_accepted_epoch);
560
561        let caller = IcOps::msg_caller();
562        let self_pid = IcOps::canister_self();
563        let now_secs = IcOps::now_secs();
564        let verifier_subnet = Some(EnvOps::subnet_pid().map_err(Error::from)?);
565        let root_pid = EnvOps::root_pid().map_err(Error::from)?;
566
567        let verify = || {
568            AuthOps::verify_internal_invocation_proof_cached(
569                proof,
570                crate::ops::auth::InternalInvocationProofVerificationInput {
571                    caller,
572                    self_pid,
573                    target_method,
574                    accepted_roles,
575                    verifier_subnet,
576                    now_secs,
577                    min_accepted_epoch,
578                },
579            )
580            .map(|_| ())
581        };
582        let refresh = || async {
583            let key_set = RootAuthMaterialClient::new(root_pid)
584                .attestation_key_set()
585                .await?;
586            AuthOps::replace_attestation_key_set(key_set);
587            Ok(())
588        };
589
590        match verify_flow::verify_role_attestation_with_single_refresh(verify, refresh).await {
591            Ok(()) => Ok(()),
592            Err(verify_flow::RoleAttestationVerifyFlowError::Initial(err)) => {
593                verify_flow::record_attestation_verifier_rejection(&err);
594                log!(
595                    Topic::Auth,
596                    Warn,
597                    "internal invocation proof rejected phase=cached local={} caller={} subject={} role={} key_id={} audience={} method={} epoch={} error={}",
598                    self_pid,
599                    caller,
600                    proof.payload.subject,
601                    proof.payload.role,
602                    proof.key_id,
603                    proof.payload.audience,
604                    proof.payload.audience_method,
605                    proof.payload.epoch,
606                    err
607                );
608                Err(Self::map_internal_invocation_verify_error(err))
609            }
610            Err(verify_flow::RoleAttestationVerifyFlowError::Refresh { trigger, source }) => {
611                verify_flow::record_attestation_verifier_rejection(&trigger);
612                record_attestation_refresh_failed();
613                log!(
614                    Topic::Auth,
615                    Warn,
616                    "internal invocation proof refresh failed local={} caller={} key_id={} error={}",
617                    self_pid,
618                    caller,
619                    proof.key_id,
620                    source
621                );
622                Err(Self::map_auth_error(source))
623            }
624            Err(verify_flow::RoleAttestationVerifyFlowError::PostRefresh(err)) => {
625                verify_flow::record_attestation_verifier_rejection(&err);
626                log!(
627                    Topic::Auth,
628                    Warn,
629                    "internal invocation proof rejected phase=post_refresh local={} caller={} subject={} role={} key_id={} audience={} method={} epoch={} error={}",
630                    self_pid,
631                    caller,
632                    proof.payload.subject,
633                    proof.payload.role,
634                    proof.key_id,
635                    proof.payload.audience,
636                    proof.payload.audience_method,
637                    proof.payload.epoch,
638                    err
639                );
640                Err(Self::map_internal_invocation_verify_error(err))
641            }
642        }
643    }
644
645    // Resolve the root-owned TTL ceiling from delegated-token config.
646    fn delegated_token_max_ttl_secs() -> Result<u64, Error> {
647        let cfg = ConfigOps::delegated_tokens_config().map_err(Error::from)?;
648        if !cfg.enabled {
649            return Err(Error::forbidden(Self::DELEGATED_TOKENS_DISABLED));
650        }
651
652        Ok(cfg
653            .max_ttl_secs
654            .unwrap_or(Self::MAX_DELEGATED_SESSION_TTL_SECS))
655    }
656
657    fn validate_delegation_request_caller(
658        caller: Principal,
659        shard_pid: Principal,
660    ) -> Result<(), Error> {
661        if caller == shard_pid {
662            return Ok(());
663        }
664
665        Err(Error::forbidden(format!(
666            "delegation request caller {caller} must match shard_pid {shard_pid}"
667        )))
668    }
669
670    fn delegation_replay_metadata(
671        metadata: Option<RootRequestMetadata>,
672    ) -> Result<RootRequestMetadata, Error> {
673        let metadata = metadata.ok_or_else(Error::operation_id_required)?;
674        if metadata.ttl_seconds == 0 {
675            return Err(Error::invalid(
676                "delegation proof replay metadata ttl_seconds must be greater than zero",
677            ));
678        }
679        if metadata.ttl_seconds > Self::MAX_DELEGATION_REPLAY_TTL_SECONDS {
680            return Err(Error::invalid(format!(
681                "delegation proof replay metadata ttl_seconds={} exceeds max {}",
682                metadata.ttl_seconds,
683                Self::MAX_DELEGATION_REPLAY_TTL_SECONDS
684            )));
685        }
686        Ok(metadata)
687    }
688
689    fn token_replay_metadata(
690        metadata: Option<RootRequestMetadata>,
691        label: &str,
692    ) -> Result<RootRequestMetadata, Error> {
693        let metadata = metadata.ok_or_else(Error::operation_id_required)?;
694        if metadata.ttl_seconds == 0 {
695            return Err(Error::invalid(format!(
696                "{label} replay metadata ttl_seconds must be greater than zero"
697            )));
698        }
699        if metadata.ttl_seconds > Self::MAX_TOKEN_REPLAY_TTL_SECONDS {
700            return Err(Error::invalid(format!(
701                "{label} replay metadata ttl_seconds={} exceeds max {}",
702                metadata.ttl_seconds,
703                Self::MAX_TOKEN_REPLAY_TTL_SECONDS
704            )));
705        }
706        Ok(metadata)
707    }
708
709    fn delegation_replay_command_kind() -> CommandKind {
710        CommandKind::new(Self::DELEGATION_REPLAY_COMMAND_KIND)
711            .expect("delegation replay command kind is a valid static label")
712    }
713
714    fn token_issue_replay_command_kind() -> CommandKind {
715        CommandKind::new(Self::TOKEN_ISSUE_REPLAY_COMMAND_KIND)
716            .expect("delegated-token issue replay command kind is a valid static label")
717    }
718
719    fn token_mint_replay_command_kind() -> CommandKind {
720        CommandKind::new(Self::TOKEN_MINT_REPLAY_COMMAND_KIND)
721            .expect("delegated-token mint replay command kind is a valid static label")
722    }
723
724    fn delegation_replay_payload_hash(
725        command_kind: &CommandKind,
726        actor: &ReplayActor,
727        request: &DelegationProofIssueRequest,
728    ) -> [u8; 32] {
729        let mut hasher = ReplayPayloadHasher::new(command_kind, actor);
730        hasher.hash_principal(&request.shard_pid);
731        hasher.hash_u64(request.scopes.len() as u64);
732        for scope in &request.scopes {
733            hasher.hash_str(scope);
734        }
735        Self::hash_delegation_audience(&mut hasher, &request.aud);
736        hasher.hash_u64(request.cert_ttl_secs);
737        hasher.finish()
738    }
739
740    fn token_mint_replay_payload_hash(
741        command_kind: &CommandKind,
742        actor: &ReplayActor,
743        request: &DelegatedTokenMintRequest,
744    ) -> [u8; 32] {
745        let mut hasher = ReplayPayloadHasher::new(command_kind, actor);
746        hasher.hash_principal(&request.subject);
747        Self::hash_delegation_audience(&mut hasher, &request.aud);
748        Self::hash_string_vec(&mut hasher, &request.scopes);
749        hasher.hash_u64(request.token_ttl_secs);
750        hasher.hash_u64(request.cert_ttl_secs);
751        hasher.hash_bytes(&request.nonce);
752        hasher.finish()
753    }
754
755    fn token_issue_replay_payload_hash(
756        command_kind: &CommandKind,
757        actor: &ReplayActor,
758        request: &DelegatedTokenIssueRequest,
759    ) -> [u8; 32] {
760        let mut hasher = ReplayPayloadHasher::new(command_kind, actor);
761        Self::hash_delegation_proof(&mut hasher, &request.proof);
762        hasher.hash_principal(&request.subject);
763        Self::hash_delegation_audience(&mut hasher, &request.aud);
764        Self::hash_string_vec(&mut hasher, &request.scopes);
765        hasher.hash_u64(request.ttl_secs);
766        hasher.hash_bytes(&request.nonce);
767        hasher.finish()
768    }
769
770    fn hash_delegation_audience(hasher: &mut ReplayPayloadHasher, aud: &DelegationAudience) {
771        match aud {
772            DelegationAudience::Role(role) => {
773                hasher.hash_str("role");
774                hasher.hash_role(role);
775            }
776            DelegationAudience::Principal(principal) => {
777                hasher.hash_str("principal");
778                hasher.hash_principal(principal);
779            }
780        }
781    }
782
783    fn hash_delegation_proof(hasher: &mut ReplayPayloadHasher, proof: &DelegationProof) {
784        Self::hash_delegation_cert(hasher, &proof.cert);
785        hasher.hash_bytes(&proof.root_sig);
786    }
787
788    fn hash_delegation_cert(hasher: &mut ReplayPayloadHasher, cert: &DelegationCert) {
789        hasher.hash_u64(u64::from(cert.version));
790        hasher.hash_principal(&cert.root_pid);
791        hasher.hash_str(&cert.root_key_id);
792        hasher.hash_bytes(&cert.root_key_hash);
793        Self::hash_signature_algorithm(hasher, cert.alg);
794        hasher.hash_principal(&cert.shard_pid);
795        hasher.hash_str(&cert.shard_key_id);
796        hasher.hash_bytes(&cert.shard_public_key_sec1);
797        hasher.hash_bytes(&cert.shard_key_hash);
798        Self::hash_shard_key_binding(hasher, cert.shard_key_binding);
799        hasher.hash_u64(cert.issued_at);
800        hasher.hash_u64(cert.expires_at);
801        hasher.hash_u64(cert.max_token_ttl_secs);
802        Self::hash_string_vec(hasher, &cert.scopes);
803        Self::hash_delegation_audience(hasher, &cert.aud);
804        match cert.verifier_role_hash {
805            Some(hash) => {
806                hasher.hash_bool(true);
807                hasher.hash_bytes(&hash);
808            }
809            None => hasher.hash_bool(false),
810        }
811    }
812
813    fn hash_signature_algorithm(hasher: &mut ReplayPayloadHasher, alg: SignatureAlgorithm) {
814        match alg {
815            SignatureAlgorithm::EcdsaP256Sha256 => hasher.hash_str("EcdsaP256Sha256"),
816        }
817    }
818
819    fn hash_shard_key_binding(hasher: &mut ReplayPayloadHasher, binding: ShardKeyBinding) {
820        match binding {
821            ShardKeyBinding::IcThresholdEcdsa {
822                key_name_hash,
823                derivation_path_hash,
824            } => {
825                hasher.hash_str("IcThresholdEcdsa");
826                hasher.hash_bytes(&key_name_hash);
827                hasher.hash_bytes(&derivation_path_hash);
828            }
829        }
830    }
831
832    fn hash_string_vec(hasher: &mut ReplayPayloadHasher, values: &[String]) {
833        hasher.hash_u64(values.len() as u64);
834        for value in values {
835            hasher.hash_str(value);
836        }
837    }
838
839    fn reserve_token_replay_receipt(
840        command_kind: CommandKind,
841        metadata: RootRequestMetadata,
842        actor: ReplayActor,
843        payload_hash: [u8; 32],
844    ) -> Result<ReplayReceiptDecision, Error> {
845        let now_secs = IcOps::now_secs();
846        let replay_input = ReplayReceiptReserveInput::new(
847            command_kind,
848            OperationId::from_bytes(metadata.request_id),
849            actor,
850            payload_hash,
851            secs_to_ns(now_secs),
852        )
853        .with_expires_at_ns(secs_to_ns(now_secs.saturating_add(metadata.ttl_seconds)));
854
855        reserve_or_replay_receipt(replay_input).map_err(Self::map_delegation_replay_store_error)
856    }
857
858    fn token_signing_cost_guard_request(
859        command_kind: CommandKind,
860        caller: Principal,
861    ) -> CostGuardRequest {
862        Self::token_signing_cost_guard_request_at(
863            command_kind,
864            caller,
865            IcOps::canister_self(),
866            IcOps::now_secs(),
867            MgmtOps::canister_cycle_balance().to_u128(),
868        )
869    }
870
871    const fn token_signing_cost_guard_request_at(
872        command_kind: CommandKind,
873        caller: Principal,
874        payer: Principal,
875        now_secs: u64,
876        current_cycle_balance: u128,
877    ) -> CostGuardRequest {
878        CostGuardRequest {
879            cost_class: crate::replay_policy::CostClass::ThresholdEcdsaSign,
880            command_kind,
881            quota_subject: caller,
882            payer,
883            now_secs,
884            quota_window_secs: Self::TOKEN_SIGNING_QUOTA_WINDOW_SECONDS,
885            max_operations_per_window: Self::MAX_TOKEN_SIGNING_OPERATIONS_PER_WINDOW,
886            current_cycle_balance,
887            cycle_reservation_cycles: Self::TOKEN_SIGNING_CYCLE_RESERVATION_CYCLES,
888            min_cycles_after_reservation: Self::MIN_TOKEN_SIGNING_CYCLES_AFTER_RESERVATION,
889        }
890    }
891
892    fn map_delegation_replay_decision(
893        decision: ReplayReceiptDecision,
894    ) -> Result<DelegationProof, Error> {
895        match decision {
896            ReplayReceiptDecision::Fresh(_) => {
897                Err(Error::invariant("fresh delegation replay decision escaped"))
898            }
899            ReplayReceiptDecision::ReturnCommitted(receipt) => {
900                Self::decode_delegation_proof_response(&receipt)
901            }
902            ReplayReceiptDecision::OperationInProgress => Err(Error::conflict(
903                "delegation proof request is already in progress; retry later with the same request id",
904            )),
905            ReplayReceiptDecision::ActorMismatch => Err(Error::conflict(
906                "delegation proof request id was reused by a different caller",
907            )),
908            ReplayReceiptDecision::PayloadMismatch => Err(Error::conflict(
909                "delegation proof request id was reused with a different payload",
910            )),
911            ReplayReceiptDecision::Expired => Err(Error::conflict(
912                "delegation proof replay receipt expired; retry with a new request id",
913            )),
914            ReplayReceiptDecision::RecoveryRequired(reason) => Err(Error::conflict(format!(
915                "delegation proof request requires recovery before replay: {reason:?}"
916            ))),
917            ReplayReceiptDecision::TerminalFailed {
918                error_code,
919                error_bytes,
920                error_bytes_truncated,
921            } => Err(Error::conflict(format!(
922                "delegation proof request previously failed: {error_code:?}; error_bytes_len={}; truncated={error_bytes_truncated}",
923                error_bytes.len()
924            ))),
925            ReplayReceiptDecision::PendingActorQuotaExceeded { max_pending, .. } => {
926                Err(Error::exhausted(format!(
927                    "delegation proof pending replay receipt quota exceeded for caller; max_pending={max_pending}"
928                )))
929            }
930            ReplayReceiptDecision::PendingCommandQuotaExceeded { max_pending, .. } => {
931                Err(Error::exhausted(format!(
932                    "delegation proof pending replay receipt quota exceeded for command kind; max_pending={max_pending}"
933                )))
934            }
935        }
936    }
937
938    fn map_token_replay_decision(
939        decision: ReplayReceiptDecision,
940        label: &str,
941    ) -> Result<DelegatedToken, Error> {
942        match decision {
943            ReplayReceiptDecision::Fresh(_) => Err(Error::invariant(format!(
944                "fresh {label} replay decision escaped"
945            ))),
946            ReplayReceiptDecision::ReturnCommitted(receipt) => {
947                Self::decode_delegated_token_response(&receipt)
948            }
949            ReplayReceiptDecision::OperationInProgress => Err(Error::conflict(format!(
950                "{label} request is already in progress; retry later with the same request id"
951            ))),
952            ReplayReceiptDecision::ActorMismatch => Err(Error::conflict(format!(
953                "{label} request id was reused by a different caller"
954            ))),
955            ReplayReceiptDecision::PayloadMismatch => Err(Error::conflict(format!(
956                "{label} request id was reused with a different payload"
957            ))),
958            ReplayReceiptDecision::Expired => Err(Error::conflict(format!(
959                "{label} replay receipt expired; retry with a new request id"
960            ))),
961            ReplayReceiptDecision::RecoveryRequired(reason) => Err(Error::conflict(format!(
962                "{label} request requires recovery before replay: {reason:?}"
963            ))),
964            ReplayReceiptDecision::TerminalFailed {
965                error_code,
966                error_bytes,
967                error_bytes_truncated,
968            } => Err(Error::conflict(format!(
969                "{label} request previously failed: {error_code:?}; error_bytes_len={}; truncated={error_bytes_truncated}",
970                error_bytes.len()
971            ))),
972            ReplayReceiptDecision::PendingActorQuotaExceeded { max_pending, .. } => {
973                Err(Error::exhausted(format!(
974                    "{label} pending replay receipt quota exceeded for caller; max_pending={max_pending}"
975                )))
976            }
977            ReplayReceiptDecision::PendingCommandQuotaExceeded { max_pending, .. } => {
978                Err(Error::exhausted(format!(
979                    "{label} pending replay receipt quota exceeded for command kind; max_pending={max_pending}"
980                )))
981            }
982        }
983    }
984
985    fn log_token_replay_reserved(
986        label: &str,
987        command_kind: &CommandKind,
988        operation_id: OperationId,
989        caller: Principal,
990    ) {
991        log!(
992            Topic::Auth,
993            Info,
994            "{} replay receipt reserved command_kind={} operation_id={} caller={}",
995            label,
996            command_kind.as_str(),
997            operation_id,
998            caller
999        );
1000    }
1001
1002    fn log_token_replay_decision(
1003        label: &str,
1004        command_kind: &CommandKind,
1005        operation_id: OperationId,
1006        caller: Principal,
1007        decision: &ReplayReceiptDecision,
1008    ) {
1009        match decision {
1010            ReplayReceiptDecision::ReturnCommitted(_) => log!(
1011                Topic::Auth,
1012                Info,
1013                "{} committed replay returned command_kind={} operation_id={} caller={}",
1014                label,
1015                command_kind.as_str(),
1016                operation_id,
1017                caller
1018            ),
1019            _ => log!(
1020                Topic::Auth,
1021                Warn,
1022                "{} replay decision blocked command_kind={} operation_id={} caller={} decision={}",
1023                label,
1024                command_kind.as_str(),
1025                operation_id,
1026                caller,
1027                Self::token_replay_decision_name(decision)
1028            ),
1029        }
1030    }
1031
1032    fn log_token_signing_cost_guard_reserved(
1033        label: &str,
1034        command_kind: &CommandKind,
1035        operation_id: OperationId,
1036        caller: Principal,
1037    ) {
1038        log!(
1039            Topic::Auth,
1040            Info,
1041            "{} signing cost guard reserved command_kind={} operation_id={} caller={}",
1042            label,
1043            command_kind.as_str(),
1044            operation_id,
1045            caller
1046        );
1047    }
1048
1049    fn log_token_replay_effect_marked(
1050        label: &str,
1051        command_kind: &CommandKind,
1052        operation_id: OperationId,
1053        caller: Principal,
1054        effect: &ExternalEffectDescriptor,
1055    ) {
1056        log!(
1057            Topic::Auth,
1058            Info,
1059            "{} replay effect marked effect={} command_kind={} operation_id={} caller={}",
1060            label,
1061            Self::token_effect_name(effect),
1062            command_kind.as_str(),
1063            operation_id,
1064            caller
1065        );
1066    }
1067
1068    fn log_token_replay_recovery_required(
1069        label: &str,
1070        command_kind: &CommandKind,
1071        operation_id: OperationId,
1072        caller: Principal,
1073        err: &crate::InternalError,
1074    ) {
1075        let (error_class, error_origin) = err.log_fields();
1076        log!(
1077            Topic::Auth,
1078            Error,
1079            "{} replay recovery required effect=threshold_ecdsa_sign_delegated_token command_kind={} operation_id={} caller={} error_class={} error_origin={}",
1080            label,
1081            command_kind.as_str(),
1082            operation_id,
1083            caller,
1084            error_class,
1085            error_origin
1086        );
1087    }
1088
1089    fn log_token_replay_response_commit_failed(
1090        label: &str,
1091        command_kind: &CommandKind,
1092        operation_id: OperationId,
1093        caller: Principal,
1094        err: &Error,
1095    ) {
1096        log!(
1097            Topic::Auth,
1098            Error,
1099            "{} replay response commit failed command_kind={} operation_id={} caller={} error_code={:?}",
1100            label,
1101            command_kind.as_str(),
1102            operation_id,
1103            caller,
1104            err.code
1105        );
1106    }
1107
1108    fn log_token_replay_response_commit_failed_internal(
1109        label: &str,
1110        command_kind: &CommandKind,
1111        operation_id: OperationId,
1112        caller: Principal,
1113        err: &crate::InternalError,
1114    ) {
1115        let (error_class, error_origin) = err.log_fields();
1116        log!(
1117            Topic::Auth,
1118            Error,
1119            "{} replay response commit failed command_kind={} operation_id={} caller={} error_class={} error_origin={}",
1120            label,
1121            command_kind.as_str(),
1122            operation_id,
1123            caller,
1124            error_class,
1125            error_origin
1126        );
1127    }
1128
1129    fn log_token_replay_commit(
1130        label: &str,
1131        command_kind: &CommandKind,
1132        operation_id: OperationId,
1133        caller: Principal,
1134    ) {
1135        log!(
1136            Topic::Auth,
1137            Ok,
1138            "{} replay response committed command_kind={} operation_id={} caller={}",
1139            label,
1140            command_kind.as_str(),
1141            operation_id,
1142            caller
1143        );
1144    }
1145
1146    const fn token_replay_decision_name(decision: &ReplayReceiptDecision) -> &'static str {
1147        match decision {
1148            ReplayReceiptDecision::Fresh(_) => "fresh",
1149            ReplayReceiptDecision::ReturnCommitted(_) => "return_committed",
1150            ReplayReceiptDecision::OperationInProgress => "operation_in_progress",
1151            ReplayReceiptDecision::ActorMismatch => "actor_mismatch",
1152            ReplayReceiptDecision::PayloadMismatch => "payload_mismatch",
1153            ReplayReceiptDecision::Expired => "expired",
1154            ReplayReceiptDecision::RecoveryRequired(_) => "recovery_required",
1155            ReplayReceiptDecision::TerminalFailed { .. } => "terminal_failed",
1156            ReplayReceiptDecision::PendingActorQuotaExceeded { .. } => {
1157                "pending_actor_quota_exceeded"
1158            }
1159            ReplayReceiptDecision::PendingCommandQuotaExceeded { .. } => {
1160                "pending_command_quota_exceeded"
1161            }
1162        }
1163    }
1164
1165    const fn token_effect_name(effect: &ExternalEffectDescriptor) -> &'static str {
1166        match effect {
1167            ExternalEffectDescriptor::ThresholdEcdsaSign {
1168                purpose: EcdsaPurpose::DelegatedToken,
1169                ..
1170            } => "threshold_ecdsa_sign_delegated_token",
1171            ExternalEffectDescriptor::ThresholdEcdsaSign { .. } => "threshold_ecdsa_sign",
1172            ExternalEffectDescriptor::ManagementCreateCanister { .. } => {
1173                "management_create_canister"
1174            }
1175            ExternalEffectDescriptor::ManagementCall { .. } => "management_call",
1176            ExternalEffectDescriptor::IcpTransfer { .. } => "icp_transfer",
1177        }
1178    }
1179
1180    fn map_delegation_replay_store_error(err: ReplayReceiptStoreError) -> Error {
1181        match err {
1182            ReplayReceiptStoreError::ReceiptDecodeFailed(message) => Error::internal(format!(
1183                "failed to decode delegation replay receipt: {message}"
1184            )),
1185        }
1186    }
1187
1188    fn encode_delegation_proof_response(proof: &DelegationProof) -> Result<Vec<u8>, Error> {
1189        encode_one(proof).map_err(|err| {
1190            Error::internal(format!(
1191                "failed to encode delegation proof replay response: {err}"
1192            ))
1193        })
1194    }
1195
1196    fn encode_delegated_token_response(token: &DelegatedToken) -> Result<Vec<u8>, Error> {
1197        encode_one(token).map_err(|err| {
1198            Error::internal(format!(
1199                "failed to encode delegated token replay response: {err}"
1200            ))
1201        })
1202    }
1203
1204    fn decode_delegation_proof_response(
1205        receipt: &crate::ops::replay::model::ReplayReceipt,
1206    ) -> Result<DelegationProof, Error> {
1207        let response_schema_version = receipt.response_schema_version.ok_or_else(|| {
1208            Error::internal("delegation replay receipt is missing response schema version")
1209        })?;
1210        if response_schema_version != Self::DELEGATION_REPLAY_RESPONSE_SCHEMA_VERSION {
1211            return Err(Error::internal(format!(
1212                "unsupported delegation replay response schema version {response_schema_version}"
1213            )));
1214        }
1215        let response_bytes = receipt.response_bytes.as_deref().ok_or_else(|| {
1216            Error::internal("delegation replay receipt is missing response bytes")
1217        })?;
1218        decode_one(response_bytes).map_err(|err| {
1219            Error::internal(format!(
1220                "failed to decode delegation proof replay response: {err}"
1221            ))
1222        })
1223    }
1224
1225    fn decode_delegated_token_response(
1226        receipt: &crate::ops::replay::model::ReplayReceipt,
1227    ) -> Result<DelegatedToken, Error> {
1228        let response_schema_version = receipt.response_schema_version.ok_or_else(|| {
1229            Error::internal("delegated token replay receipt is missing response schema version")
1230        })?;
1231        if response_schema_version != Self::TOKEN_REPLAY_RESPONSE_SCHEMA_VERSION {
1232            return Err(Error::internal(format!(
1233                "unsupported delegated token replay response schema version {response_schema_version}"
1234            )));
1235        }
1236        let response_bytes = receipt.response_bytes.as_deref().ok_or_else(|| {
1237            Error::internal("delegated token replay receipt is missing response bytes")
1238        })?;
1239        decode_one(response_bytes).map_err(|err| {
1240            Error::internal(format!(
1241                "failed to decode delegated token replay response: {err}"
1242            ))
1243        })
1244    }
1245
1246    fn hash_delegation_effect_key(key_name: &str) -> [u8; 32] {
1247        let mut hasher = Sha256::new();
1248        hasher.update(b"canic-delegation-proof-effect-key:v1");
1249        hasher.update(key_name.as_bytes());
1250        hasher.finalize().into()
1251    }
1252}
1253
1254impl AuthApi {
1255    // Route a self-contained delegation proof request over RPC to root.
1256    async fn request_delegation_remote(
1257        request: DelegationProofIssueRequest,
1258    ) -> Result<DelegationProof, Error> {
1259        let root_pid = EnvOps::root_pid().map_err(Error::from)?;
1260        RootAuthMaterialClient::new(root_pid)
1261            .request_delegation(request)
1262            .await
1263            .map_err(Self::map_auth_error)
1264    }
1265
1266    // Execute one local root role-attestation request.
1267    pub async fn request_role_attestation_root(
1268        request: RoleAttestationRequest,
1269    ) -> Result<SignedRoleAttestation, Error> {
1270        let request = metadata::with_root_attestation_request_metadata(request);
1271        let response = RootResponseWorkflow::response(RootRequest::issue_role_attestation(request))
1272            .await
1273            .map_err(Self::map_auth_error)?;
1274
1275        match response {
1276            RootCapabilityResponse::RoleAttestationIssued(response) => Ok(response),
1277            _ => Err(Error::internal(
1278                "invalid root response type for role attestation request",
1279            )),
1280        }
1281    }
1282
1283    // Execute one local root internal-invocation proof request.
1284    pub async fn request_internal_invocation_proof_root(
1285        request: InternalInvocationProofRequest,
1286    ) -> Result<SignedInternalInvocationProofV1, Error> {
1287        let request = metadata::with_internal_invocation_proof_request_metadata(request);
1288        let response =
1289            RootResponseWorkflow::response(RootRequest::issue_internal_invocation_proof(request))
1290                .await
1291                .map_err(Self::map_auth_error)?;
1292
1293        match response {
1294            RootCapabilityResponse::InternalInvocationProofIssued(response) => Ok(response),
1295            _ => Err(Error::internal(
1296                "invalid root response type for internal invocation proof request",
1297            )),
1298        }
1299    }
1300
1301    // Route a canonical role-attestation request over RPC to root.
1302    async fn request_role_attestation_remote(
1303        request: RoleAttestationRequest,
1304    ) -> Result<SignedRoleAttestation, Error> {
1305        let root_pid = EnvOps::root_pid().map_err(Error::from)?;
1306        RootAuthMaterialClient::new(root_pid)
1307            .request_role_attestation(request)
1308            .await
1309            .map_err(Self::map_auth_error)
1310    }
1311
1312    // Route a canonical internal-invocation proof request over RPC to root.
1313    async fn request_internal_invocation_proof_remote(
1314        request: InternalInvocationProofRequest,
1315    ) -> Result<SignedInternalInvocationProofV1, Error> {
1316        let root_pid = EnvOps::root_pid().map_err(Error::from)?;
1317        RootAuthMaterialClient::new(root_pid)
1318            .request_internal_invocation_proof(request)
1319            .await
1320            .map_err(Self::map_auth_error)
1321    }
1322}
1323
1324#[cfg(test)]
1325mod tests {
1326    use super::AuthApi;
1327    use crate::{
1328        cdk::types::Principal,
1329        dto::{
1330            auth::{
1331                DelegatedToken, DelegatedTokenClaims, DelegatedTokenIssueRequest,
1332                DelegatedTokenMintRequest, DelegationAudience, DelegationCert, DelegationProof,
1333                DelegationProofIssueRequest, ShardKeyBinding, SignatureAlgorithm,
1334            },
1335            error::ErrorCode,
1336            rpc::RootRequestMetadata,
1337        },
1338        ops::{
1339            auth::{AuthExpiryError, AuthOpsError},
1340            cost_guard::CostGuardOps,
1341            replay::{
1342                model::{ReplayActor, ReplayReceiptStatus},
1343                receipt::{ReplayReceiptDecision, commit_receipt_response},
1344            },
1345            storage::replay::ReplayReceiptOps,
1346        },
1347        replay_policy::CostClass,
1348        storage::stable::intent::IntentStore,
1349    };
1350
1351    fn p(id: u8) -> Principal {
1352        Principal::from_slice(&[id; 29])
1353    }
1354
1355    fn delegation_request(metadata_id: u8) -> DelegationProofIssueRequest {
1356        DelegationProofIssueRequest {
1357            metadata: Some(meta(metadata_id, 60)),
1358            shard_pid: p(2),
1359            scopes: vec!["canic.verify".to_string()],
1360            aud: DelegationAudience::Principal(p(3)),
1361            cert_ttl_secs: 60,
1362        }
1363    }
1364
1365    fn meta(id: u8, ttl_seconds: u64) -> RootRequestMetadata {
1366        RootRequestMetadata {
1367            request_id: [id; 32],
1368            ttl_seconds,
1369        }
1370    }
1371
1372    fn delegation_proof() -> DelegationProof {
1373        DelegationProof {
1374            cert: DelegationCert {
1375                version: 1,
1376                root_pid: p(1),
1377                root_key_id: "root-key".to_string(),
1378                root_key_hash: [2; 32],
1379                alg: SignatureAlgorithm::EcdsaP256Sha256,
1380                shard_pid: p(2),
1381                shard_key_id: "shard-key".to_string(),
1382                shard_public_key_sec1: vec![3; 33],
1383                shard_key_hash: [4; 32],
1384                shard_key_binding: ShardKeyBinding::IcThresholdEcdsa {
1385                    key_name_hash: [5; 32],
1386                    derivation_path_hash: [6; 32],
1387                },
1388                issued_at: 10,
1389                expires_at: 100,
1390                max_token_ttl_secs: 60,
1391                scopes: vec!["canic.verify".to_string()],
1392                aud: DelegationAudience::Principal(p(3)),
1393                verifier_role_hash: None,
1394            },
1395            root_sig: vec![7; 64],
1396        }
1397    }
1398
1399    fn mint_request(metadata_id: u8) -> DelegatedTokenMintRequest {
1400        DelegatedTokenMintRequest {
1401            metadata: Some(meta(metadata_id, 60)),
1402            subject: p(8),
1403            aud: DelegationAudience::Principal(p(3)),
1404            scopes: vec!["canic.verify".to_string()],
1405            token_ttl_secs: 30,
1406            cert_ttl_secs: 60,
1407            nonce: [9; 16],
1408        }
1409    }
1410
1411    fn issue_request(metadata_id: u8) -> DelegatedTokenIssueRequest {
1412        DelegatedTokenIssueRequest {
1413            metadata: Some(meta(metadata_id, 60)),
1414            proof: delegation_proof(),
1415            subject: p(8),
1416            aud: DelegationAudience::Principal(p(3)),
1417            scopes: vec!["canic.verify".to_string()],
1418            ttl_secs: 30,
1419            nonce: [9; 16],
1420        }
1421    }
1422
1423    fn delegated_token(nonce_byte: u8) -> DelegatedToken {
1424        DelegatedToken {
1425            claims: DelegatedTokenClaims {
1426                version: 1,
1427                subject: p(8),
1428                issuer_shard_pid: p(2),
1429                cert_hash: [11; 32],
1430                issued_at: 20,
1431                expires_at: 50,
1432                aud: DelegationAudience::Principal(p(3)),
1433                scopes: vec!["canic.verify".to_string()],
1434                nonce: [nonce_byte; 16],
1435            },
1436            proof: delegation_proof(),
1437            shard_sig: vec![12; 64],
1438        }
1439    }
1440
1441    fn reserve_mint_receipt(
1442        request: &DelegatedTokenMintRequest,
1443        actor: ReplayActor,
1444    ) -> ReplayReceiptDecision {
1445        let command_kind = AuthApi::token_mint_replay_command_kind();
1446        let metadata = request.metadata.expect("mint request metadata");
1447        let payload_hash = AuthApi::token_mint_replay_payload_hash(&command_kind, &actor, request);
1448        AuthApi::reserve_token_replay_receipt(command_kind, metadata, actor, payload_hash)
1449            .expect("mint receipt reservation")
1450    }
1451
1452    #[test]
1453    fn internal_invocation_not_yet_valid_maps_to_non_retryable_proof_expiry() {
1454        let err = AuthApi::map_internal_invocation_verify_error(AuthOpsError::Expiry(
1455            AuthExpiryError::AttestationNotYetValid {
1456                issued_at: 20,
1457                now_secs: 10,
1458            },
1459        ));
1460
1461        assert_eq!(err.code, ErrorCode::AuthProofExpired);
1462    }
1463
1464    #[test]
1465    fn delegation_request_caller_must_match_requested_shard() {
1466        AuthApi::validate_delegation_request_caller(p(2), p(2)).expect("matching shard");
1467
1468        let err = AuthApi::validate_delegation_request_caller(p(1), p(2))
1469            .expect_err("mismatched caller must fail");
1470
1471        assert_eq!(err.code, ErrorCode::Forbidden);
1472        assert!(err.message.contains("must match shard_pid"));
1473    }
1474
1475    #[test]
1476    fn delegation_replay_metadata_rejects_missing_or_invalid_ttl() {
1477        let missing = AuthApi::delegation_replay_metadata(None).expect_err("metadata is required");
1478        assert_eq!(missing.code, ErrorCode::OperationIdRequired);
1479        assert_eq!(missing.message, "operation_id is required for this command");
1480
1481        let zero = AuthApi::delegation_replay_metadata(Some(RootRequestMetadata {
1482            request_id: [1; 32],
1483            ttl_seconds: 0,
1484        }))
1485        .expect_err("zero ttl is invalid");
1486        assert_eq!(zero.code, ErrorCode::InvalidInput);
1487
1488        let too_large = AuthApi::delegation_replay_metadata(Some(RootRequestMetadata {
1489            request_id: [1; 32],
1490            ttl_seconds: AuthApi::MAX_DELEGATION_REPLAY_TTL_SECONDS + 1,
1491        }))
1492        .expect_err("oversized ttl is invalid");
1493        assert_eq!(too_large.code, ErrorCode::InvalidInput);
1494    }
1495
1496    #[test]
1497    fn delegation_replay_payload_hash_ignores_metadata() {
1498        let command_kind = AuthApi::delegation_replay_command_kind();
1499        let actor = crate::ops::replay::model::ReplayActor::direct_caller(p(2));
1500        let a = delegation_request(1);
1501        let b = delegation_request(9);
1502
1503        assert_eq!(
1504            AuthApi::delegation_replay_payload_hash(&command_kind, &actor, &a),
1505            AuthApi::delegation_replay_payload_hash(&command_kind, &actor, &b)
1506        );
1507    }
1508
1509    #[test]
1510    fn delegated_token_replay_metadata_rejects_missing_or_invalid_ttl() {
1511        let missing =
1512            AuthApi::token_replay_metadata(None, "delegated token mint").expect_err("required");
1513        assert_eq!(missing.code, ErrorCode::OperationIdRequired);
1514        assert_eq!(missing.message, "operation_id is required for this command");
1515
1516        let zero = AuthApi::token_replay_metadata(Some(meta(1, 0)), "delegated token mint")
1517            .expect_err("zero ttl is invalid");
1518        assert_eq!(zero.code, ErrorCode::InvalidInput);
1519
1520        let too_large = AuthApi::token_replay_metadata(
1521            Some(meta(1, AuthApi::MAX_TOKEN_REPLAY_TTL_SECONDS + 1)),
1522            "delegated token mint",
1523        )
1524        .expect_err("oversized ttl is invalid");
1525        assert_eq!(too_large.code, ErrorCode::InvalidInput);
1526    }
1527
1528    #[test]
1529    fn delegation_replay_payload_hash_binds_authoritative_payload() {
1530        let command_kind = AuthApi::delegation_replay_command_kind();
1531        let actor = crate::ops::replay::model::ReplayActor::direct_caller(p(2));
1532        let a = delegation_request(1);
1533        let mut b = a.clone();
1534        b.cert_ttl_secs += 1;
1535
1536        assert_ne!(
1537            AuthApi::delegation_replay_payload_hash(&command_kind, &actor, &a),
1538            AuthApi::delegation_replay_payload_hash(&command_kind, &actor, &b)
1539        );
1540    }
1541
1542    #[test]
1543    fn delegated_token_mint_payload_hash_ignores_metadata() {
1544        let command_kind = AuthApi::token_mint_replay_command_kind();
1545        let actor = crate::ops::replay::model::ReplayActor::direct_caller(p(2));
1546        let a = mint_request(1);
1547        let b = mint_request(9);
1548
1549        assert_eq!(
1550            AuthApi::token_mint_replay_payload_hash(&command_kind, &actor, &a),
1551            AuthApi::token_mint_replay_payload_hash(&command_kind, &actor, &b)
1552        );
1553    }
1554
1555    #[test]
1556    fn delegated_token_mint_payload_hash_binds_authoritative_payload() {
1557        let command_kind = AuthApi::token_mint_replay_command_kind();
1558        let actor = crate::ops::replay::model::ReplayActor::direct_caller(p(2));
1559        let a = mint_request(1);
1560        let mut b = a.clone();
1561        b.token_ttl_secs += 1;
1562
1563        assert_ne!(
1564            AuthApi::token_mint_replay_payload_hash(&command_kind, &actor, &a),
1565            AuthApi::token_mint_replay_payload_hash(&command_kind, &actor, &b)
1566        );
1567    }
1568
1569    #[test]
1570    fn delegated_token_mint_committed_replay_returns_cached_token() {
1571        ReplayReceiptOps::reset_for_tests();
1572
1573        let request = mint_request(21);
1574        let actor = ReplayActor::direct_caller(p(44));
1575        let token = match reserve_mint_receipt(&request, actor) {
1576            ReplayReceiptDecision::Fresh(token) => token,
1577            decision => panic!("expected fresh receipt, got {decision:?}"),
1578        };
1579        let response = delegated_token(31);
1580        let response_bytes =
1581            AuthApi::encode_delegated_token_response(&response).expect("response encoding");
1582
1583        commit_receipt_response(
1584            &token,
1585            AuthApi::TOKEN_REPLAY_RESPONSE_SCHEMA_VERSION,
1586            response_bytes,
1587            2_000,
1588        );
1589
1590        let replay = reserve_mint_receipt(&request, actor);
1591        let cached = AuthApi::map_token_replay_decision(replay, "delegated token mint")
1592            .expect("committed replay returns cached token");
1593
1594        assert_eq!(cached, response);
1595    }
1596
1597    #[test]
1598    fn delegated_token_mint_replay_rejects_actor_and_payload_mismatch() {
1599        ReplayReceiptOps::reset_for_tests();
1600
1601        let request = mint_request(22);
1602        let actor = ReplayActor::direct_caller(p(44));
1603        match reserve_mint_receipt(&request, actor) {
1604            ReplayReceiptDecision::Fresh(_) => {}
1605            decision => panic!("expected fresh receipt, got {decision:?}"),
1606        }
1607
1608        let actor_mismatch = reserve_mint_receipt(&request, ReplayActor::direct_caller(p(45)));
1609        assert_eq!(actor_mismatch, ReplayReceiptDecision::ActorMismatch);
1610
1611        let mut changed = request;
1612        changed.token_ttl_secs += 1;
1613        let payload_mismatch = reserve_mint_receipt(&changed, actor);
1614        assert_eq!(payload_mismatch, ReplayReceiptDecision::PayloadMismatch);
1615    }
1616
1617    #[test]
1618    fn delegated_token_mint_in_progress_duplicate_blocks_before_effect() {
1619        ReplayReceiptOps::reset_for_tests();
1620
1621        let request = mint_request(23);
1622        let actor = ReplayActor::direct_caller(p(44));
1623        let token = match reserve_mint_receipt(&request, actor) {
1624            ReplayReceiptDecision::Fresh(token) => token,
1625            decision => panic!("expected fresh receipt, got {decision:?}"),
1626        };
1627
1628        let duplicate = reserve_mint_receipt(&request, actor);
1629        let err = AuthApi::map_token_replay_decision(duplicate, "delegated token mint")
1630            .expect_err("duplicate in-progress mint must block");
1631        assert_eq!(err.code, ErrorCode::Conflict);
1632
1633        let stored = ReplayReceiptOps::get(token.key())
1634            .expect("stored receipt")
1635            .into_receipt()
1636            .expect("receipt decode");
1637        assert_eq!(stored.status, ReplayReceiptStatus::Reserved);
1638        assert_eq!(stored.effect, None);
1639    }
1640
1641    #[test]
1642    fn delegated_token_signing_quota_rejects_before_signing_adapter() {
1643        IntentStore::reset_for_tests();
1644
1645        let command_kind = AuthApi::token_mint_replay_command_kind();
1646        let caller = p(44);
1647        let payer = p(2);
1648        let current_cycle_balance = AuthApi::TOKEN_SIGNING_CYCLE_RESERVATION_CYCLES
1649            + AuthApi::MIN_TOKEN_SIGNING_CYCLES_AFTER_RESERVATION
1650            + 1;
1651        let mut first = AuthApi::token_signing_cost_guard_request_at(
1652            command_kind.clone(),
1653            caller,
1654            payer,
1655            10,
1656            current_cycle_balance,
1657        );
1658        first.max_operations_per_window = 1;
1659        assert_eq!(first.cost_class, CostClass::ThresholdEcdsaSign);
1660        assert_eq!(first.command_kind, command_kind);
1661        assert_eq!(first.quota_subject, caller);
1662        assert_eq!(first.payer, payer);
1663
1664        let permit = CostGuardOps::reserve(first).expect("first signing operation reserves");
1665        CostGuardOps::complete(&permit, 10).expect("first signing operation completes");
1666
1667        let mut second = AuthApi::token_signing_cost_guard_request_at(
1668            AuthApi::token_mint_replay_command_kind(),
1669            caller,
1670            payer,
1671            20,
1672            current_cycle_balance,
1673        );
1674        second.max_operations_per_window = 1;
1675
1676        let err = CostGuardOps::reserve(second).expect_err("quota rejects second operation");
1677        assert!(err.to_string().contains("quota exceeded"));
1678    }
1679
1680    #[test]
1681    fn delegated_token_issue_payload_hash_ignores_metadata() {
1682        let command_kind = AuthApi::token_issue_replay_command_kind();
1683        let actor = crate::ops::replay::model::ReplayActor::direct_caller(p(2));
1684        let a = issue_request(1);
1685        let b = issue_request(9);
1686
1687        assert_eq!(
1688            AuthApi::token_issue_replay_payload_hash(&command_kind, &actor, &a),
1689            AuthApi::token_issue_replay_payload_hash(&command_kind, &actor, &b)
1690        );
1691    }
1692
1693    #[test]
1694    fn delegated_token_issue_payload_hash_binds_authoritative_payload() {
1695        let command_kind = AuthApi::token_issue_replay_command_kind();
1696        let actor = crate::ops::replay::model::ReplayActor::direct_caller(p(2));
1697        let a = issue_request(1);
1698        let mut b = a.clone();
1699        b.nonce = [10; 16];
1700
1701        assert_ne!(
1702            AuthApi::token_issue_replay_payload_hash(&command_kind, &actor, &a),
1703            AuthApi::token_issue_replay_payload_hash(&command_kind, &actor, &b)
1704        );
1705    }
1706}