Skip to main content

canic_core/api/auth/
mod.rs

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