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