Skip to main content

canic_core/api/auth/
mod.rs

1use crate::{
2    cdk::types::Principal,
3    dto::{
4        auth::{
5            AttestationKeySet, DelegatedRoleGrant, 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            aud: request.aud.clone(),
218            grants: request.grants.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            grants: request.grants,
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            grants: request.grants,
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            grants: request.grants,
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        Self::hash_delegation_audience(&mut hasher, &request.aud);
732        Self::hash_delegated_role_grants(&mut hasher, &request.grants);
733        hasher.hash_u64(request.cert_ttl_secs);
734        hasher.finish()
735    }
736
737    fn token_mint_replay_payload_hash(
738        command_kind: &CommandKind,
739        actor: &ReplayActor,
740        request: &DelegatedTokenMintRequest,
741    ) -> [u8; 32] {
742        let mut hasher = ReplayPayloadHasher::new(command_kind, actor);
743        hasher.hash_principal(&request.subject);
744        Self::hash_delegation_audience(&mut hasher, &request.aud);
745        Self::hash_delegated_role_grants(&mut hasher, &request.grants);
746        hasher.hash_u64(request.token_ttl_secs);
747        hasher.hash_u64(request.cert_ttl_secs);
748        hasher.hash_bytes(&request.nonce);
749        hasher.finish()
750    }
751
752    fn token_issue_replay_payload_hash(
753        command_kind: &CommandKind,
754        actor: &ReplayActor,
755        request: &DelegatedTokenIssueRequest,
756    ) -> [u8; 32] {
757        let mut hasher = ReplayPayloadHasher::new(command_kind, actor);
758        Self::hash_delegation_proof(&mut hasher, &request.proof);
759        hasher.hash_principal(&request.subject);
760        Self::hash_delegation_audience(&mut hasher, &request.aud);
761        Self::hash_delegated_role_grants(&mut hasher, &request.grants);
762        hasher.hash_u64(request.ttl_secs);
763        hasher.hash_bytes(&request.nonce);
764        hasher.finish()
765    }
766
767    fn hash_delegation_audience(hasher: &mut ReplayPayloadHasher, aud: &DelegationAudience) {
768        match aud {
769            DelegationAudience::Canic => {
770                hasher.hash_str("canic");
771            }
772            DelegationAudience::Project(project) => {
773                hasher.hash_str("project");
774                hasher.hash_str(project);
775            }
776        }
777    }
778
779    fn hash_delegated_role_grants(hasher: &mut ReplayPayloadHasher, grants: &[DelegatedRoleGrant]) {
780        hasher.hash_u64(grants.len() as u64);
781        for grant in grants {
782            hasher.hash_role(&grant.target);
783            Self::hash_string_vec(hasher, &grant.scopes);
784        }
785    }
786
787    fn hash_delegation_proof(hasher: &mut ReplayPayloadHasher, proof: &DelegationProof) {
788        Self::hash_delegation_cert(hasher, &proof.cert);
789        hasher.hash_bytes(&proof.root_sig);
790    }
791
792    fn hash_delegation_cert(hasher: &mut ReplayPayloadHasher, cert: &DelegationCert) {
793        hasher.hash_u64(u64::from(cert.version));
794        hasher.hash_principal(&cert.root_pid);
795        hasher.hash_str(&cert.root_key_id);
796        hasher.hash_bytes(&cert.root_key_hash);
797        Self::hash_signature_algorithm(hasher, cert.alg);
798        hasher.hash_principal(&cert.shard_pid);
799        hasher.hash_str(&cert.shard_key_id);
800        hasher.hash_bytes(&cert.shard_public_key_sec1);
801        hasher.hash_bytes(&cert.shard_key_hash);
802        Self::hash_shard_key_binding(hasher, cert.shard_key_binding);
803        hasher.hash_u64(cert.issued_at);
804        hasher.hash_u64(cert.expires_at);
805        hasher.hash_u64(cert.max_token_ttl_secs);
806        Self::hash_delegation_audience(hasher, &cert.aud);
807        Self::hash_delegated_role_grants(hasher, &cert.grants);
808    }
809
810    fn hash_signature_algorithm(hasher: &mut ReplayPayloadHasher, alg: SignatureAlgorithm) {
811        match alg {
812            SignatureAlgorithm::EcdsaP256Sha256 => hasher.hash_str("EcdsaP256Sha256"),
813        }
814    }
815
816    fn hash_shard_key_binding(hasher: &mut ReplayPayloadHasher, binding: ShardKeyBinding) {
817        match binding {
818            ShardKeyBinding::IcThresholdEcdsa {
819                key_name_hash,
820                derivation_path_hash,
821            } => {
822                hasher.hash_str("IcThresholdEcdsa");
823                hasher.hash_bytes(&key_name_hash);
824                hasher.hash_bytes(&derivation_path_hash);
825            }
826        }
827    }
828
829    fn hash_string_vec(hasher: &mut ReplayPayloadHasher, values: &[String]) {
830        hasher.hash_u64(values.len() as u64);
831        for value in values {
832            hasher.hash_str(value);
833        }
834    }
835
836    fn reserve_token_replay_receipt(
837        command_kind: CommandKind,
838        metadata: RootRequestMetadata,
839        actor: ReplayActor,
840        payload_hash: [u8; 32],
841    ) -> Result<ReplayReceiptDecision, Error> {
842        let now_secs = IcOps::now_secs();
843        let replay_input = ReplayReceiptReserveInput::new(
844            command_kind,
845            OperationId::from_bytes(metadata.request_id),
846            actor,
847            payload_hash,
848            secs_to_ns(now_secs),
849        )
850        .with_expires_at_ns(secs_to_ns(now_secs.saturating_add(metadata.ttl_seconds)));
851
852        reserve_or_replay_receipt(replay_input).map_err(Self::map_delegation_replay_store_error)
853    }
854
855    fn token_signing_cost_guard_request(
856        command_kind: CommandKind,
857        caller: Principal,
858    ) -> CostGuardRequest {
859        Self::token_signing_cost_guard_request_at(
860            command_kind,
861            caller,
862            IcOps::canister_self(),
863            IcOps::now_secs(),
864            MgmtOps::canister_cycle_balance().to_u128(),
865        )
866    }
867
868    const fn token_signing_cost_guard_request_at(
869        command_kind: CommandKind,
870        caller: Principal,
871        payer: Principal,
872        now_secs: u64,
873        current_cycle_balance: u128,
874    ) -> CostGuardRequest {
875        CostGuardRequest {
876            cost_class: crate::replay_policy::CostClass::ThresholdEcdsaSign,
877            command_kind,
878            quota_subject: caller,
879            payer,
880            now_secs,
881            quota_window_secs: Self::TOKEN_SIGNING_QUOTA_WINDOW_SECONDS,
882            max_operations_per_window: Self::MAX_TOKEN_SIGNING_OPERATIONS_PER_WINDOW,
883            current_cycle_balance,
884            cycle_reservation_cycles: Self::TOKEN_SIGNING_CYCLE_RESERVATION_CYCLES,
885            min_cycles_after_reservation: Self::MIN_TOKEN_SIGNING_CYCLES_AFTER_RESERVATION,
886        }
887    }
888
889    fn map_delegation_replay_decision(
890        decision: ReplayReceiptDecision,
891    ) -> Result<DelegationProof, Error> {
892        match decision {
893            ReplayReceiptDecision::Fresh(_) => {
894                Err(Error::invariant("fresh delegation replay decision escaped"))
895            }
896            ReplayReceiptDecision::ReturnCommitted(receipt) => {
897                Self::decode_delegation_proof_response(&receipt)
898            }
899            ReplayReceiptDecision::OperationInProgress => Err(Error::conflict(
900                "delegation proof request is already in progress; retry later with the same request id",
901            )),
902            ReplayReceiptDecision::ActorMismatch => Err(Error::conflict(
903                "delegation proof request id was reused by a different caller",
904            )),
905            ReplayReceiptDecision::PayloadMismatch => Err(Error::conflict(
906                "delegation proof request id was reused with a different payload",
907            )),
908            ReplayReceiptDecision::Expired => Err(Error::conflict(
909                "delegation proof replay receipt expired; retry with a new request id",
910            )),
911            ReplayReceiptDecision::RecoveryRequired(reason) => Err(Error::conflict(format!(
912                "delegation proof request requires recovery before replay: {reason:?}"
913            ))),
914            ReplayReceiptDecision::TerminalFailed {
915                error_code,
916                error_bytes,
917                error_bytes_truncated,
918            } => Err(Error::conflict(format!(
919                "delegation proof request previously failed: {error_code:?}; error_bytes_len={}; truncated={error_bytes_truncated}",
920                error_bytes.len()
921            ))),
922            ReplayReceiptDecision::PendingActorQuotaExceeded { max_pending, .. } => {
923                Err(Error::exhausted(format!(
924                    "delegation proof pending replay receipt quota exceeded for caller; max_pending={max_pending}"
925                )))
926            }
927            ReplayReceiptDecision::PendingCommandQuotaExceeded { max_pending, .. } => {
928                Err(Error::exhausted(format!(
929                    "delegation proof pending replay receipt quota exceeded for command kind; max_pending={max_pending}"
930                )))
931            }
932        }
933    }
934
935    fn map_token_replay_decision(
936        decision: ReplayReceiptDecision,
937        label: &str,
938    ) -> Result<DelegatedToken, Error> {
939        match decision {
940            ReplayReceiptDecision::Fresh(_) => Err(Error::invariant(format!(
941                "fresh {label} replay decision escaped"
942            ))),
943            ReplayReceiptDecision::ReturnCommitted(receipt) => {
944                Self::decode_delegated_token_response(&receipt)
945            }
946            ReplayReceiptDecision::OperationInProgress => Err(Error::conflict(format!(
947                "{label} request is already in progress; retry later with the same request id"
948            ))),
949            ReplayReceiptDecision::ActorMismatch => Err(Error::conflict(format!(
950                "{label} request id was reused by a different caller"
951            ))),
952            ReplayReceiptDecision::PayloadMismatch => Err(Error::conflict(format!(
953                "{label} request id was reused with a different payload"
954            ))),
955            ReplayReceiptDecision::Expired => Err(Error::conflict(format!(
956                "{label} replay receipt expired; retry with a new request id"
957            ))),
958            ReplayReceiptDecision::RecoveryRequired(reason) => Err(Error::conflict(format!(
959                "{label} request requires recovery before replay: {reason:?}"
960            ))),
961            ReplayReceiptDecision::TerminalFailed {
962                error_code,
963                error_bytes,
964                error_bytes_truncated,
965            } => Err(Error::conflict(format!(
966                "{label} request previously failed: {error_code:?}; error_bytes_len={}; truncated={error_bytes_truncated}",
967                error_bytes.len()
968            ))),
969            ReplayReceiptDecision::PendingActorQuotaExceeded { max_pending, .. } => {
970                Err(Error::exhausted(format!(
971                    "{label} pending replay receipt quota exceeded for caller; max_pending={max_pending}"
972                )))
973            }
974            ReplayReceiptDecision::PendingCommandQuotaExceeded { max_pending, .. } => {
975                Err(Error::exhausted(format!(
976                    "{label} pending replay receipt quota exceeded for command kind; max_pending={max_pending}"
977                )))
978            }
979        }
980    }
981
982    fn log_token_replay_reserved(
983        label: &str,
984        command_kind: &CommandKind,
985        operation_id: OperationId,
986        caller: Principal,
987    ) {
988        log!(
989            Topic::Auth,
990            Info,
991            "{} replay receipt reserved command_kind={} operation_id={} caller={}",
992            label,
993            command_kind.as_str(),
994            operation_id,
995            caller
996        );
997    }
998
999    fn log_token_replay_decision(
1000        label: &str,
1001        command_kind: &CommandKind,
1002        operation_id: OperationId,
1003        caller: Principal,
1004        decision: &ReplayReceiptDecision,
1005    ) {
1006        match decision {
1007            ReplayReceiptDecision::ReturnCommitted(_) => log!(
1008                Topic::Auth,
1009                Info,
1010                "{} committed replay returned command_kind={} operation_id={} caller={}",
1011                label,
1012                command_kind.as_str(),
1013                operation_id,
1014                caller
1015            ),
1016            _ => log!(
1017                Topic::Auth,
1018                Warn,
1019                "{} replay decision blocked command_kind={} operation_id={} caller={} decision={}",
1020                label,
1021                command_kind.as_str(),
1022                operation_id,
1023                caller,
1024                Self::token_replay_decision_name(decision)
1025            ),
1026        }
1027    }
1028
1029    fn log_token_signing_cost_guard_reserved(
1030        label: &str,
1031        command_kind: &CommandKind,
1032        operation_id: OperationId,
1033        caller: Principal,
1034    ) {
1035        log!(
1036            Topic::Auth,
1037            Info,
1038            "{} signing cost guard reserved command_kind={} operation_id={} caller={}",
1039            label,
1040            command_kind.as_str(),
1041            operation_id,
1042            caller
1043        );
1044    }
1045
1046    fn log_token_replay_effect_marked(
1047        label: &str,
1048        command_kind: &CommandKind,
1049        operation_id: OperationId,
1050        caller: Principal,
1051        effect: &ExternalEffectDescriptor,
1052    ) {
1053        log!(
1054            Topic::Auth,
1055            Info,
1056            "{} replay effect marked effect={} command_kind={} operation_id={} caller={}",
1057            label,
1058            Self::token_effect_name(effect),
1059            command_kind.as_str(),
1060            operation_id,
1061            caller
1062        );
1063    }
1064
1065    fn log_token_replay_recovery_required(
1066        label: &str,
1067        command_kind: &CommandKind,
1068        operation_id: OperationId,
1069        caller: Principal,
1070        err: &crate::InternalError,
1071    ) {
1072        let (error_class, error_origin) = err.log_fields();
1073        log!(
1074            Topic::Auth,
1075            Error,
1076            "{} replay recovery required effect=threshold_ecdsa_sign_delegated_token command_kind={} operation_id={} caller={} error_class={} error_origin={}",
1077            label,
1078            command_kind.as_str(),
1079            operation_id,
1080            caller,
1081            error_class,
1082            error_origin
1083        );
1084    }
1085
1086    fn log_token_replay_response_commit_failed(
1087        label: &str,
1088        command_kind: &CommandKind,
1089        operation_id: OperationId,
1090        caller: Principal,
1091        err: &Error,
1092    ) {
1093        log!(
1094            Topic::Auth,
1095            Error,
1096            "{} replay response commit failed command_kind={} operation_id={} caller={} error_code={:?}",
1097            label,
1098            command_kind.as_str(),
1099            operation_id,
1100            caller,
1101            err.code
1102        );
1103    }
1104
1105    fn log_token_replay_response_commit_failed_internal(
1106        label: &str,
1107        command_kind: &CommandKind,
1108        operation_id: OperationId,
1109        caller: Principal,
1110        err: &crate::InternalError,
1111    ) {
1112        let (error_class, error_origin) = err.log_fields();
1113        log!(
1114            Topic::Auth,
1115            Error,
1116            "{} replay response commit failed command_kind={} operation_id={} caller={} error_class={} error_origin={}",
1117            label,
1118            command_kind.as_str(),
1119            operation_id,
1120            caller,
1121            error_class,
1122            error_origin
1123        );
1124    }
1125
1126    fn log_token_replay_commit(
1127        label: &str,
1128        command_kind: &CommandKind,
1129        operation_id: OperationId,
1130        caller: Principal,
1131    ) {
1132        log!(
1133            Topic::Auth,
1134            Ok,
1135            "{} replay response committed command_kind={} operation_id={} caller={}",
1136            label,
1137            command_kind.as_str(),
1138            operation_id,
1139            caller
1140        );
1141    }
1142
1143    const fn token_replay_decision_name(decision: &ReplayReceiptDecision) -> &'static str {
1144        match decision {
1145            ReplayReceiptDecision::Fresh(_) => "fresh",
1146            ReplayReceiptDecision::ReturnCommitted(_) => "return_committed",
1147            ReplayReceiptDecision::OperationInProgress => "operation_in_progress",
1148            ReplayReceiptDecision::ActorMismatch => "actor_mismatch",
1149            ReplayReceiptDecision::PayloadMismatch => "payload_mismatch",
1150            ReplayReceiptDecision::Expired => "expired",
1151            ReplayReceiptDecision::RecoveryRequired(_) => "recovery_required",
1152            ReplayReceiptDecision::TerminalFailed { .. } => "terminal_failed",
1153            ReplayReceiptDecision::PendingActorQuotaExceeded { .. } => {
1154                "pending_actor_quota_exceeded"
1155            }
1156            ReplayReceiptDecision::PendingCommandQuotaExceeded { .. } => {
1157                "pending_command_quota_exceeded"
1158            }
1159        }
1160    }
1161
1162    const fn token_effect_name(effect: &ExternalEffectDescriptor) -> &'static str {
1163        match effect {
1164            ExternalEffectDescriptor::ThresholdEcdsaSign {
1165                purpose: EcdsaPurpose::DelegatedToken,
1166                ..
1167            } => "threshold_ecdsa_sign_delegated_token",
1168            ExternalEffectDescriptor::ThresholdEcdsaSign { .. } => "threshold_ecdsa_sign",
1169            ExternalEffectDescriptor::ManagementCreateCanister { .. } => {
1170                "management_create_canister"
1171            }
1172            ExternalEffectDescriptor::ManagementCall { .. } => "management_call",
1173            ExternalEffectDescriptor::IcpTransfer { .. } => "icp_transfer",
1174        }
1175    }
1176
1177    fn map_delegation_replay_store_error(err: ReplayReceiptStoreError) -> Error {
1178        match err {
1179            ReplayReceiptStoreError::ReceiptDecodeFailed(message) => Error::internal(format!(
1180                "failed to decode delegation replay receipt: {message}"
1181            )),
1182        }
1183    }
1184
1185    fn encode_delegation_proof_response(proof: &DelegationProof) -> Result<Vec<u8>, Error> {
1186        encode_one(proof).map_err(|err| {
1187            Error::internal(format!(
1188                "failed to encode delegation proof replay response: {err}"
1189            ))
1190        })
1191    }
1192
1193    fn encode_delegated_token_response(token: &DelegatedToken) -> Result<Vec<u8>, Error> {
1194        encode_one(token).map_err(|err| {
1195            Error::internal(format!(
1196                "failed to encode delegated token replay response: {err}"
1197            ))
1198        })
1199    }
1200
1201    fn decode_delegation_proof_response(
1202        receipt: &crate::ops::replay::model::ReplayReceipt,
1203    ) -> Result<DelegationProof, Error> {
1204        let response_schema_version = receipt.response_schema_version.ok_or_else(|| {
1205            Error::internal("delegation replay receipt is missing response schema version")
1206        })?;
1207        if response_schema_version != Self::DELEGATION_REPLAY_RESPONSE_SCHEMA_VERSION {
1208            return Err(Error::internal(format!(
1209                "unsupported delegation replay response schema version {response_schema_version}"
1210            )));
1211        }
1212        let response_bytes = receipt.response_bytes.as_deref().ok_or_else(|| {
1213            Error::internal("delegation replay receipt is missing response bytes")
1214        })?;
1215        decode_one(response_bytes).map_err(|err| {
1216            Error::internal(format!(
1217                "failed to decode delegation proof replay response: {err}"
1218            ))
1219        })
1220    }
1221
1222    fn decode_delegated_token_response(
1223        receipt: &crate::ops::replay::model::ReplayReceipt,
1224    ) -> Result<DelegatedToken, Error> {
1225        let response_schema_version = receipt.response_schema_version.ok_or_else(|| {
1226            Error::internal("delegated token replay receipt is missing response schema version")
1227        })?;
1228        if response_schema_version != Self::TOKEN_REPLAY_RESPONSE_SCHEMA_VERSION {
1229            return Err(Error::internal(format!(
1230                "unsupported delegated token replay response schema version {response_schema_version}"
1231            )));
1232        }
1233        let response_bytes = receipt.response_bytes.as_deref().ok_or_else(|| {
1234            Error::internal("delegated token replay receipt is missing response bytes")
1235        })?;
1236        decode_one(response_bytes).map_err(|err| {
1237            Error::internal(format!(
1238                "failed to decode delegated token replay response: {err}"
1239            ))
1240        })
1241    }
1242
1243    fn hash_delegation_effect_key(key_name: &str) -> [u8; 32] {
1244        let mut hasher = Sha256::new();
1245        hasher.update(b"canic-delegation-proof-effect-key:v1");
1246        hasher.update(key_name.as_bytes());
1247        hasher.finalize().into()
1248    }
1249}
1250
1251impl AuthApi {
1252    // Route a self-contained delegation proof request over RPC to root.
1253    async fn request_delegation_remote(
1254        request: DelegationProofIssueRequest,
1255    ) -> Result<DelegationProof, Error> {
1256        let root_pid = EnvOps::root_pid().map_err(Error::from)?;
1257        RootAuthMaterialClient::new(root_pid)
1258            .request_delegation(request)
1259            .await
1260            .map_err(Self::map_auth_error)
1261    }
1262
1263    // Execute one local root role-attestation request.
1264    pub async fn request_role_attestation_root(
1265        request: RoleAttestationRequest,
1266    ) -> Result<SignedRoleAttestation, Error> {
1267        let request = metadata::with_root_attestation_request_metadata(request);
1268        let response = RootResponseWorkflow::response(RootRequest::issue_role_attestation(request))
1269            .await
1270            .map_err(Self::map_auth_error)?;
1271
1272        match response {
1273            RootCapabilityResponse::RoleAttestationIssued(response) => Ok(response),
1274            _ => Err(Error::internal(
1275                "invalid root response type for role attestation request",
1276            )),
1277        }
1278    }
1279
1280    // Execute one local root internal-invocation proof request.
1281    pub async fn request_internal_invocation_proof_root(
1282        request: InternalInvocationProofRequest,
1283    ) -> Result<SignedInternalInvocationProofV1, Error> {
1284        let request = metadata::with_internal_invocation_proof_request_metadata(request);
1285        let response =
1286            RootResponseWorkflow::response(RootRequest::issue_internal_invocation_proof(request))
1287                .await
1288                .map_err(Self::map_auth_error)?;
1289
1290        match response {
1291            RootCapabilityResponse::InternalInvocationProofIssued(response) => Ok(response),
1292            _ => Err(Error::internal(
1293                "invalid root response type for internal invocation proof request",
1294            )),
1295        }
1296    }
1297
1298    // Route a canonical role-attestation request over RPC to root.
1299    async fn request_role_attestation_remote(
1300        request: RoleAttestationRequest,
1301    ) -> Result<SignedRoleAttestation, Error> {
1302        let root_pid = EnvOps::root_pid().map_err(Error::from)?;
1303        RootAuthMaterialClient::new(root_pid)
1304            .request_role_attestation(request)
1305            .await
1306            .map_err(Self::map_auth_error)
1307    }
1308
1309    // Route a canonical internal-invocation proof request over RPC to root.
1310    async fn request_internal_invocation_proof_remote(
1311        request: InternalInvocationProofRequest,
1312    ) -> Result<SignedInternalInvocationProofV1, Error> {
1313        let root_pid = EnvOps::root_pid().map_err(Error::from)?;
1314        RootAuthMaterialClient::new(root_pid)
1315            .request_internal_invocation_proof(request)
1316            .await
1317            .map_err(Self::map_auth_error)
1318    }
1319}
1320
1321#[cfg(test)]
1322mod tests {
1323    use super::AuthApi;
1324    use crate::{
1325        cdk::types::Principal,
1326        dto::{
1327            auth::{
1328                DelegatedRoleGrant, DelegatedToken, DelegatedTokenClaims,
1329                DelegatedTokenIssueRequest, DelegatedTokenMintRequest, DelegationAudience,
1330                DelegationCert, DelegationProof, DelegationProofIssueRequest, ShardKeyBinding,
1331                SignatureAlgorithm,
1332            },
1333            error::ErrorCode,
1334            rpc::RootRequestMetadata,
1335        },
1336        ops::{
1337            auth::{AuthExpiryError, AuthOpsError},
1338            cost_guard::CostGuardOps,
1339            replay::{
1340                model::{ReplayActor, ReplayReceiptStatus},
1341                receipt::{ReplayReceiptDecision, commit_receipt_response},
1342            },
1343            storage::replay::ReplayReceiptOps,
1344        },
1345        replay_policy::CostClass,
1346        storage::stable::intent::IntentStore,
1347    };
1348
1349    fn p(id: u8) -> Principal {
1350        Principal::from_slice(&[id; 29])
1351    }
1352
1353    fn delegation_request(metadata_id: u8) -> DelegationProofIssueRequest {
1354        DelegationProofIssueRequest {
1355            metadata: Some(meta(metadata_id, 60)),
1356            shard_pid: p(2),
1357            aud: DelegationAudience::Project("test".to_string()),
1358            grants: vec![grant("project_instance", &["canic.verify"])],
1359            cert_ttl_secs: 60,
1360        }
1361    }
1362
1363    fn grant(role: &str, scopes: &[&str]) -> DelegatedRoleGrant {
1364        DelegatedRoleGrant {
1365            target: crate::ids::CanisterRole::owned(role.to_string()),
1366            scopes: scopes.iter().map(|scope| (*scope).to_string()).collect(),
1367        }
1368    }
1369
1370    fn meta(id: u8, ttl_seconds: u64) -> RootRequestMetadata {
1371        RootRequestMetadata {
1372            request_id: [id; 32],
1373            ttl_seconds,
1374        }
1375    }
1376
1377    fn delegation_proof() -> DelegationProof {
1378        DelegationProof {
1379            cert: DelegationCert {
1380                version: 1,
1381                root_pid: p(1),
1382                root_key_id: "root-key".to_string(),
1383                root_key_hash: [2; 32],
1384                alg: SignatureAlgorithm::EcdsaP256Sha256,
1385                shard_pid: p(2),
1386                shard_key_id: "shard-key".to_string(),
1387                shard_public_key_sec1: vec![3; 33],
1388                shard_key_hash: [4; 32],
1389                shard_key_binding: ShardKeyBinding::IcThresholdEcdsa {
1390                    key_name_hash: [5; 32],
1391                    derivation_path_hash: [6; 32],
1392                },
1393                issued_at: 10,
1394                expires_at: 100,
1395                max_token_ttl_secs: 60,
1396                aud: DelegationAudience::Project("test".to_string()),
1397                grants: vec![grant("project_instance", &["canic.verify"])],
1398            },
1399            root_sig: vec![7; 64],
1400        }
1401    }
1402
1403    fn mint_request(metadata_id: u8) -> DelegatedTokenMintRequest {
1404        DelegatedTokenMintRequest {
1405            metadata: Some(meta(metadata_id, 60)),
1406            subject: p(8),
1407            aud: DelegationAudience::Project("test".to_string()),
1408            grants: vec![grant("project_instance", &["canic.verify"])],
1409            token_ttl_secs: 30,
1410            cert_ttl_secs: 60,
1411            nonce: [9; 16],
1412        }
1413    }
1414
1415    fn issue_request(metadata_id: u8) -> DelegatedTokenIssueRequest {
1416        DelegatedTokenIssueRequest {
1417            metadata: Some(meta(metadata_id, 60)),
1418            proof: delegation_proof(),
1419            subject: p(8),
1420            aud: DelegationAudience::Project("test".to_string()),
1421            grants: vec![grant("project_instance", &["canic.verify"])],
1422            ttl_secs: 30,
1423            nonce: [9; 16],
1424        }
1425    }
1426
1427    fn delegated_token(nonce_byte: u8) -> DelegatedToken {
1428        DelegatedToken {
1429            claims: DelegatedTokenClaims {
1430                version: 1,
1431                subject: p(8),
1432                issuer_shard_pid: p(2),
1433                cert_hash: [11; 32],
1434                issued_at: 20,
1435                expires_at: 50,
1436                aud: DelegationAudience::Project("test".to_string()),
1437                grants: vec![grant("project_instance", &["canic.verify"])],
1438                nonce: [nonce_byte; 16],
1439            },
1440            proof: delegation_proof(),
1441            shard_sig: vec![12; 64],
1442        }
1443    }
1444
1445    fn reserve_mint_receipt(
1446        request: &DelegatedTokenMintRequest,
1447        actor: ReplayActor,
1448    ) -> ReplayReceiptDecision {
1449        let command_kind = AuthApi::token_mint_replay_command_kind();
1450        let metadata = request.metadata.expect("mint request metadata");
1451        let payload_hash = AuthApi::token_mint_replay_payload_hash(&command_kind, &actor, request);
1452        AuthApi::reserve_token_replay_receipt(command_kind, metadata, actor, payload_hash)
1453            .expect("mint receipt reservation")
1454    }
1455
1456    #[test]
1457    fn internal_invocation_not_yet_valid_maps_to_non_retryable_proof_expiry() {
1458        let err = AuthApi::map_internal_invocation_verify_error(AuthOpsError::Expiry(
1459            AuthExpiryError::AttestationNotYetValid {
1460                issued_at: 20,
1461                now_secs: 10,
1462            },
1463        ));
1464
1465        assert_eq!(err.code, ErrorCode::AuthProofExpired);
1466    }
1467
1468    #[test]
1469    fn delegation_request_caller_must_match_requested_shard() {
1470        AuthApi::validate_delegation_request_caller(p(2), p(2)).expect("matching shard");
1471
1472        let err = AuthApi::validate_delegation_request_caller(p(1), p(2))
1473            .expect_err("mismatched caller must fail");
1474
1475        assert_eq!(err.code, ErrorCode::Forbidden);
1476    }
1477
1478    #[test]
1479    fn delegation_replay_metadata_rejects_missing_or_invalid_ttl() {
1480        let missing = AuthApi::delegation_replay_metadata(None).expect_err("metadata is required");
1481        assert_eq!(missing.code, ErrorCode::OperationIdRequired);
1482
1483        let zero = AuthApi::delegation_replay_metadata(Some(RootRequestMetadata {
1484            request_id: [1; 32],
1485            ttl_seconds: 0,
1486        }))
1487        .expect_err("zero ttl is invalid");
1488        assert_eq!(zero.code, ErrorCode::InvalidInput);
1489
1490        let too_large = AuthApi::delegation_replay_metadata(Some(RootRequestMetadata {
1491            request_id: [1; 32],
1492            ttl_seconds: AuthApi::MAX_DELEGATION_REPLAY_TTL_SECONDS + 1,
1493        }))
1494        .expect_err("oversized ttl is invalid");
1495        assert_eq!(too_large.code, ErrorCode::InvalidInput);
1496    }
1497
1498    #[test]
1499    fn delegation_replay_payload_hash_ignores_metadata() {
1500        let command_kind = AuthApi::delegation_replay_command_kind();
1501        let actor = crate::ops::replay::model::ReplayActor::direct_caller(p(2));
1502        let a = delegation_request(1);
1503        let b = delegation_request(9);
1504
1505        assert_eq!(
1506            AuthApi::delegation_replay_payload_hash(&command_kind, &actor, &a),
1507            AuthApi::delegation_replay_payload_hash(&command_kind, &actor, &b)
1508        );
1509    }
1510
1511    #[test]
1512    fn delegated_token_replay_metadata_rejects_missing_or_invalid_ttl() {
1513        let missing =
1514            AuthApi::token_replay_metadata(None, "delegated token mint").expect_err("required");
1515        assert_eq!(missing.code, ErrorCode::OperationIdRequired);
1516
1517        let zero = AuthApi::token_replay_metadata(Some(meta(1, 0)), "delegated token mint")
1518            .expect_err("zero ttl is invalid");
1519        assert_eq!(zero.code, ErrorCode::InvalidInput);
1520
1521        let too_large = AuthApi::token_replay_metadata(
1522            Some(meta(1, AuthApi::MAX_TOKEN_REPLAY_TTL_SECONDS + 1)),
1523            "delegated token mint",
1524        )
1525        .expect_err("oversized ttl is invalid");
1526        assert_eq!(too_large.code, ErrorCode::InvalidInput);
1527    }
1528
1529    #[test]
1530    fn delegation_replay_payload_hash_binds_authoritative_payload() {
1531        let command_kind = AuthApi::delegation_replay_command_kind();
1532        let actor = crate::ops::replay::model::ReplayActor::direct_caller(p(2));
1533        let a = delegation_request(1);
1534        let mut b = a.clone();
1535        b.cert_ttl_secs += 1;
1536
1537        assert_ne!(
1538            AuthApi::delegation_replay_payload_hash(&command_kind, &actor, &a),
1539            AuthApi::delegation_replay_payload_hash(&command_kind, &actor, &b)
1540        );
1541    }
1542
1543    #[test]
1544    fn delegated_token_mint_payload_hash_ignores_metadata() {
1545        let command_kind = AuthApi::token_mint_replay_command_kind();
1546        let actor = crate::ops::replay::model::ReplayActor::direct_caller(p(2));
1547        let a = mint_request(1);
1548        let b = mint_request(9);
1549
1550        assert_eq!(
1551            AuthApi::token_mint_replay_payload_hash(&command_kind, &actor, &a),
1552            AuthApi::token_mint_replay_payload_hash(&command_kind, &actor, &b)
1553        );
1554    }
1555
1556    #[test]
1557    fn delegated_token_mint_payload_hash_binds_authoritative_payload() {
1558        let command_kind = AuthApi::token_mint_replay_command_kind();
1559        let actor = crate::ops::replay::model::ReplayActor::direct_caller(p(2));
1560        let a = mint_request(1);
1561        let mut b = a.clone();
1562        b.token_ttl_secs += 1;
1563
1564        assert_ne!(
1565            AuthApi::token_mint_replay_payload_hash(&command_kind, &actor, &a),
1566            AuthApi::token_mint_replay_payload_hash(&command_kind, &actor, &b)
1567        );
1568    }
1569
1570    #[test]
1571    fn delegated_token_mint_committed_replay_returns_cached_token() {
1572        ReplayReceiptOps::reset_for_tests();
1573
1574        let request = mint_request(21);
1575        let actor = ReplayActor::direct_caller(p(44));
1576        let token = match reserve_mint_receipt(&request, actor) {
1577            ReplayReceiptDecision::Fresh(token) => token,
1578            decision => panic!("expected fresh receipt, got {decision:?}"),
1579        };
1580        let response = delegated_token(31);
1581        let response_bytes =
1582            AuthApi::encode_delegated_token_response(&response).expect("response encoding");
1583
1584        commit_receipt_response(
1585            &token,
1586            AuthApi::TOKEN_REPLAY_RESPONSE_SCHEMA_VERSION,
1587            response_bytes,
1588            2_000,
1589        );
1590
1591        let replay = reserve_mint_receipt(&request, actor);
1592        let cached = AuthApi::map_token_replay_decision(replay, "delegated token mint")
1593            .expect("committed replay returns cached token");
1594
1595        assert_eq!(cached, response);
1596    }
1597
1598    #[test]
1599    fn delegated_token_mint_replay_rejects_actor_and_payload_mismatch() {
1600        ReplayReceiptOps::reset_for_tests();
1601
1602        let request = mint_request(22);
1603        let actor = ReplayActor::direct_caller(p(44));
1604        match reserve_mint_receipt(&request, actor) {
1605            ReplayReceiptDecision::Fresh(_) => {}
1606            decision => panic!("expected fresh receipt, got {decision:?}"),
1607        }
1608
1609        let actor_mismatch = reserve_mint_receipt(&request, ReplayActor::direct_caller(p(45)));
1610        assert_eq!(actor_mismatch, ReplayReceiptDecision::ActorMismatch);
1611
1612        let mut changed = request;
1613        changed.token_ttl_secs += 1;
1614        let payload_mismatch = reserve_mint_receipt(&changed, actor);
1615        assert_eq!(payload_mismatch, ReplayReceiptDecision::PayloadMismatch);
1616    }
1617
1618    #[test]
1619    fn delegated_token_mint_in_progress_duplicate_blocks_before_effect() {
1620        ReplayReceiptOps::reset_for_tests();
1621
1622        let request = mint_request(23);
1623        let actor = ReplayActor::direct_caller(p(44));
1624        let token = match reserve_mint_receipt(&request, actor) {
1625            ReplayReceiptDecision::Fresh(token) => token,
1626            decision => panic!("expected fresh receipt, got {decision:?}"),
1627        };
1628
1629        let duplicate = reserve_mint_receipt(&request, actor);
1630        let err = AuthApi::map_token_replay_decision(duplicate, "delegated token mint")
1631            .expect_err("duplicate in-progress mint must block");
1632        assert_eq!(err.code, ErrorCode::Conflict);
1633
1634        let stored = ReplayReceiptOps::get(token.key())
1635            .expect("stored receipt")
1636            .into_receipt()
1637            .expect("receipt decode");
1638        assert_eq!(stored.status, ReplayReceiptStatus::Reserved);
1639        assert_eq!(stored.effect, None);
1640    }
1641
1642    #[test]
1643    fn delegated_token_signing_quota_rejects_before_signing_adapter() {
1644        IntentStore::reset_for_tests();
1645
1646        let command_kind = AuthApi::token_mint_replay_command_kind();
1647        let caller = p(44);
1648        let payer = p(2);
1649        let current_cycle_balance = AuthApi::TOKEN_SIGNING_CYCLE_RESERVATION_CYCLES
1650            + AuthApi::MIN_TOKEN_SIGNING_CYCLES_AFTER_RESERVATION
1651            + 1;
1652        let mut first = AuthApi::token_signing_cost_guard_request_at(
1653            command_kind.clone(),
1654            caller,
1655            payer,
1656            10,
1657            current_cycle_balance,
1658        );
1659        first.max_operations_per_window = 1;
1660        assert_eq!(first.cost_class, CostClass::ThresholdEcdsaSign);
1661        assert_eq!(first.command_kind, command_kind);
1662        assert_eq!(first.quota_subject, caller);
1663        assert_eq!(first.payer, payer);
1664
1665        let permit = CostGuardOps::reserve(first).expect("first signing operation reserves");
1666        CostGuardOps::complete(&permit, 10).expect("first signing operation completes");
1667
1668        let mut second = AuthApi::token_signing_cost_guard_request_at(
1669            AuthApi::token_mint_replay_command_kind(),
1670            caller,
1671            payer,
1672            20,
1673            current_cycle_balance,
1674        );
1675        second.max_operations_per_window = 1;
1676
1677        let err = CostGuardOps::reserve(second).expect_err("quota rejects second operation");
1678        let public = err.public_error().expect("quota rejection is public");
1679        assert_eq!(public.code, ErrorCode::ResourceExhausted);
1680    }
1681
1682    #[test]
1683    fn delegated_token_issue_payload_hash_ignores_metadata() {
1684        let command_kind = AuthApi::token_issue_replay_command_kind();
1685        let actor = crate::ops::replay::model::ReplayActor::direct_caller(p(2));
1686        let a = issue_request(1);
1687        let b = issue_request(9);
1688
1689        assert_eq!(
1690            AuthApi::token_issue_replay_payload_hash(&command_kind, &actor, &a),
1691            AuthApi::token_issue_replay_payload_hash(&command_kind, &actor, &b)
1692        );
1693    }
1694
1695    #[test]
1696    fn delegated_token_issue_payload_hash_binds_authoritative_payload() {
1697        let command_kind = AuthApi::token_issue_replay_command_kind();
1698        let actor = crate::ops::replay::model::ReplayActor::direct_caller(p(2));
1699        let a = issue_request(1);
1700        let mut b = a.clone();
1701        b.nonce = [10; 16];
1702
1703        assert_ne!(
1704            AuthApi::token_issue_replay_payload_hash(&command_kind, &actor, &a),
1705            AuthApi::token_issue_replay_payload_hash(&command_kind, &actor, &b)
1706        );
1707    }
1708}