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    /// Request a signed role attestation from root over RPC.
389    pub async fn request_role_attestation(
390        request: RoleAttestationRequest,
391    ) -> Result<SignedRoleAttestation, Error> {
392        let request = metadata::with_root_attestation_request_metadata(request);
393        Self::request_role_attestation_remote(request).await
394    }
395
396    /// Request a method-scoped internal invocation proof from root over RPC.
397    pub async fn request_internal_invocation_proof(
398        request: InternalInvocationProofRequest,
399    ) -> Result<SignedInternalInvocationProofV1, Error> {
400        let request = metadata::with_internal_invocation_proof_request_metadata(request);
401        Self::request_internal_invocation_proof_remote(request).await
402    }
403
404    /// Return the current root role-attestation key set.
405    pub async fn attestation_key_set() -> Result<AttestationKeySet, Error> {
406        AuthOps::attestation_key_set()
407            .await
408            .map_err(Self::map_auth_error)
409    }
410
411    /// Publish root auth material into subnet state and warm root-owned keys once.
412    pub async fn publish_root_auth_material() -> Result<(), Error> {
413        EnvOps::require_root().map_err(Error::from)?;
414        AuthOps::publish_root_auth_material().await.map_err(|err| {
415            log!(
416                Topic::Auth,
417                Warn,
418                "root auth material publish failed: {err}"
419            );
420            Self::map_auth_error(err)
421        })
422    }
423
424    /// Replace the verifier-local role-attestation key set.
425    pub fn replace_attestation_key_set(key_set: AttestationKeySet) {
426        AuthOps::replace_attestation_key_set(key_set);
427    }
428
429    /// Verify a role attestation, refreshing root keys once on unknown key.
430    pub async fn verify_role_attestation(
431        attestation: &SignedRoleAttestation,
432        min_accepted_epoch: u64,
433    ) -> Result<(), Error> {
434        crate::workflow::runtime::auth::RuntimeAuthWorkflow::verify_role_attestation(
435            attestation,
436            min_accepted_epoch,
437        )
438        .await
439        .map_err(Self::map_auth_error)
440    }
441
442    /// Verify a root-signed, method-scoped internal invocation proof for this endpoint.
443    pub async fn verify_internal_invocation_proof(
444        proof: &SignedInternalInvocationProofV1,
445        target_method: &str,
446        accepted_roles: &[CanisterRole],
447    ) -> Result<(), Error> {
448        let configured_min_accepted_epoch = ConfigOps::role_attestation_config()
449            .map_err(Error::from)?
450            .min_accepted_epoch_by_role
451            .get(proof.payload.role.as_str())
452            .copied();
453        let min_accepted_epoch =
454            verify_flow::resolve_min_accepted_epoch(0, configured_min_accepted_epoch);
455
456        let caller = IcOps::msg_caller();
457        let self_pid = IcOps::canister_self();
458        let now_ns = IcOps::now_nanos();
459        let verifier_subnet = Some(EnvOps::subnet_pid().map_err(Error::from)?);
460        let root_pid = EnvOps::root_pid().map_err(Error::from)?;
461
462        let verify = || {
463            AuthOps::verify_internal_invocation_proof_cached(
464                proof,
465                crate::ops::auth::InternalInvocationProofVerificationInput {
466                    caller,
467                    self_pid,
468                    target_method,
469                    accepted_roles,
470                    verifier_subnet,
471                    now_ns,
472                    min_accepted_epoch,
473                },
474            )
475            .map(|_| ())
476        };
477        let refresh = || async {
478            let key_set = RootAuthMaterialClient::new(root_pid)
479                .attestation_key_set()
480                .await?;
481            AuthOps::replace_attestation_key_set(key_set);
482            Ok(())
483        };
484
485        match verify_flow::verify_role_attestation_with_single_refresh(verify, refresh).await {
486            Ok(()) => Ok(()),
487            Err(verify_flow::RoleAttestationVerifyFlowError::Initial(err)) => {
488                verify_flow::record_attestation_verifier_rejection(&err);
489                log!(
490                    Topic::Auth,
491                    Warn,
492                    "internal invocation proof rejected phase=cached local={} caller={} subject={} role={} key_id={} audience={} method={} epoch={} error={}",
493                    self_pid,
494                    caller,
495                    proof.payload.subject,
496                    proof.payload.role,
497                    proof.key_id,
498                    proof.payload.audience,
499                    proof.payload.audience_method,
500                    proof.payload.epoch,
501                    err
502                );
503                Err(Self::map_internal_invocation_verify_error(err))
504            }
505            Err(verify_flow::RoleAttestationVerifyFlowError::Refresh { trigger, source }) => {
506                verify_flow::record_attestation_verifier_rejection(&trigger);
507                record_attestation_refresh_failed();
508                log!(
509                    Topic::Auth,
510                    Warn,
511                    "internal invocation proof refresh failed local={} caller={} key_id={} error={}",
512                    self_pid,
513                    caller,
514                    proof.key_id,
515                    source
516                );
517                Err(Self::map_auth_error(source))
518            }
519            Err(verify_flow::RoleAttestationVerifyFlowError::PostRefresh(err)) => {
520                verify_flow::record_attestation_verifier_rejection(&err);
521                log!(
522                    Topic::Auth,
523                    Warn,
524                    "internal invocation proof rejected phase=post_refresh local={} caller={} subject={} role={} key_id={} audience={} method={} epoch={} error={}",
525                    self_pid,
526                    caller,
527                    proof.payload.subject,
528                    proof.payload.role,
529                    proof.key_id,
530                    proof.payload.audience,
531                    proof.payload.audience_method,
532                    proof.payload.epoch,
533                    err
534                );
535                Err(Self::map_internal_invocation_verify_error(err))
536            }
537        }
538    }
539
540    // Resolve the root-owned TTL ceiling from delegated-token config.
541    fn delegated_token_max_ttl_ns() -> Result<u64, Error> {
542        let cfg = ConfigOps::delegated_tokens_config().map_err(Error::from)?;
543        if !cfg.enabled {
544            return Err(Error::forbidden(Self::DELEGATED_TOKENS_DISABLED));
545        }
546
547        let max_ttl_secs = cfg
548            .max_ttl_secs
549            .unwrap_or(Self::MAX_DELEGATED_SESSION_TTL_SECS);
550        max_ttl_secs.checked_mul(1_000_000_000).ok_or_else(|| {
551            Error::invalid("auth.delegated_tokens.max_ttl_secs overflows nanoseconds")
552        })
553    }
554
555    fn validate_delegation_request_caller(
556        caller: Principal,
557        shard_pid: Principal,
558    ) -> Result<(), Error> {
559        if caller == shard_pid {
560            return Ok(());
561        }
562
563        Err(Error::forbidden(format!(
564            "delegation request caller {caller} must match shard_pid {shard_pid}"
565        )))
566    }
567
568    fn delegation_replay_metadata(
569        metadata: Option<AuthRequestMetadata>,
570    ) -> Result<AuthRequestMetadata, Error> {
571        let metadata = metadata.ok_or_else(Error::operation_id_required)?;
572        if metadata.ttl_ns == 0 {
573            return Err(Error::invalid(
574                "delegation proof replay metadata ttl_ns must be greater than zero",
575            ));
576        }
577        if metadata.ttl_ns > Self::MAX_DELEGATION_REPLAY_TTL_NS {
578            return Err(Error::invalid(format!(
579                "delegation proof replay metadata ttl_ns={} exceeds max {}",
580                metadata.ttl_ns,
581                Self::MAX_DELEGATION_REPLAY_TTL_NS
582            )));
583        }
584        Ok(metadata)
585    }
586
587    fn token_replay_metadata(
588        metadata: Option<AuthRequestMetadata>,
589        label: &str,
590    ) -> Result<AuthRequestMetadata, Error> {
591        let metadata = metadata.ok_or_else(Error::operation_id_required)?;
592        if metadata.ttl_ns == 0 {
593            return Err(Error::invalid(format!(
594                "{label} replay metadata ttl_ns must be greater than zero"
595            )));
596        }
597        if metadata.ttl_ns > Self::MAX_TOKEN_REPLAY_TTL_NS {
598            return Err(Error::invalid(format!(
599                "{label} replay metadata ttl_ns={} exceeds max {}",
600                metadata.ttl_ns,
601                Self::MAX_TOKEN_REPLAY_TTL_NS
602            )));
603        }
604        Ok(metadata)
605    }
606
607    fn delegation_replay_command_kind() -> CommandKind {
608        CommandKind::new(Self::DELEGATION_REPLAY_COMMAND_KIND)
609            .expect("delegation replay command kind is a valid static label")
610    }
611
612    fn token_issue_replay_command_kind() -> CommandKind {
613        CommandKind::new(Self::TOKEN_ISSUE_REPLAY_COMMAND_KIND)
614            .expect("delegated-token issue replay command kind is a valid static label")
615    }
616
617    fn delegation_replay_payload_hash(
618        command_kind: &CommandKind,
619        actor: &ReplayActor,
620        request: &DelegationProofIssueRequest,
621    ) -> [u8; 32] {
622        let mut hasher = ReplayPayloadHasher::new(command_kind, actor);
623        hasher.hash_principal(&request.shard_pid);
624        Self::hash_delegation_audience(&mut hasher, &request.aud);
625        Self::hash_delegated_role_grants(&mut hasher, &request.grants);
626        hasher.hash_u64(request.cert_ttl_ns);
627        hasher.finish()
628    }
629
630    fn token_issue_replay_payload_hash(
631        command_kind: &CommandKind,
632        actor: &ReplayActor,
633        request: &DelegatedTokenIssueRequest,
634    ) -> [u8; 32] {
635        let mut hasher = ReplayPayloadHasher::new(command_kind, actor);
636        Self::hash_delegation_proof(&mut hasher, &request.proof);
637        hasher.hash_principal(&request.subject);
638        Self::hash_delegation_audience(&mut hasher, &request.aud);
639        Self::hash_delegated_role_grants(&mut hasher, &request.grants);
640        hasher.hash_u64(request.ttl_ns);
641        hasher.hash_bytes(&request.nonce);
642        hasher.finish()
643    }
644
645    fn hash_delegation_audience(hasher: &mut ReplayPayloadHasher, aud: &DelegationAudience) {
646        match aud {
647            DelegationAudience::Canic => {
648                hasher.hash_str("canic");
649            }
650            DelegationAudience::Project(project) => {
651                hasher.hash_str("project");
652                hasher.hash_str(project);
653            }
654        }
655    }
656
657    fn hash_delegated_role_grants(hasher: &mut ReplayPayloadHasher, grants: &[DelegatedRoleGrant]) {
658        hasher.hash_u64(grants.len() as u64);
659        for grant in grants {
660            hasher.hash_role(&grant.target);
661            Self::hash_string_vec(hasher, &grant.scopes);
662        }
663    }
664
665    fn hash_delegation_proof(hasher: &mut ReplayPayloadHasher, proof: &DelegationProof) {
666        Self::hash_delegation_cert(hasher, &proof.cert);
667        Self::hash_root_proof(hasher, &proof.root_proof);
668    }
669
670    fn hash_delegation_cert(hasher: &mut ReplayPayloadHasher, cert: &DelegationCert) {
671        hasher.hash_principal(&cert.root_pid);
672        hasher.hash_principal(&cert.shard_pid);
673        hasher.hash_str(&cert.shard_key_id);
674        Self::hash_shard_signature_algorithm(hasher, cert.shard_sig_alg);
675        hasher.hash_bytes(&cert.shard_public_key_sec1);
676        hasher.hash_bytes(&cert.shard_key_hash);
677        Self::hash_shard_key_binding(hasher, cert.shard_key_binding);
678        hasher.hash_u64(cert.issued_at_ns);
679        hasher.hash_u64(cert.not_before_ns);
680        hasher.hash_u64(cert.expires_at_ns);
681        hasher.hash_u64(cert.max_token_ttl_ns);
682        Self::hash_delegation_audience(hasher, &cert.aud);
683        Self::hash_delegated_role_grants(hasher, &cert.grants);
684    }
685
686    fn hash_root_proof(hasher: &mut ReplayPayloadHasher, proof: &crate::dto::auth::RootProof) {
687        match proof {
688            crate::dto::auth::RootProof::IcCanisterSignatureV1(proof) => {
689                hasher.hash_str("IcCanisterSignatureV1");
690                hasher.hash_bytes(&proof.signature_cbor);
691                hasher.hash_bytes(&proof.public_key_der);
692            }
693        }
694    }
695
696    fn hash_shard_signature_algorithm(
697        hasher: &mut ReplayPayloadHasher,
698        alg: crate::dto::auth::ShardSignatureAlgorithm,
699    ) {
700        match alg {
701            crate::dto::auth::ShardSignatureAlgorithm::IcThresholdEcdsaSecp256k1 => {
702                hasher.hash_str("IcThresholdEcdsaSecp256k1");
703            }
704        }
705    }
706
707    fn hash_shard_key_binding(hasher: &mut ReplayPayloadHasher, binding: ShardKeyBinding) {
708        match binding {
709            ShardKeyBinding::IcThresholdEcdsaSecp256k1 {
710                key_name_hash,
711                derivation_path_hash,
712            } => {
713                hasher.hash_str("IcThresholdEcdsaSecp256k1");
714                hasher.hash_bytes(&key_name_hash);
715                hasher.hash_bytes(&derivation_path_hash);
716            }
717        }
718    }
719
720    fn hash_string_vec(hasher: &mut ReplayPayloadHasher, values: &[String]) {
721        hasher.hash_u64(values.len() as u64);
722        for value in values {
723            hasher.hash_str(value);
724        }
725    }
726
727    fn reserve_token_replay_receipt(
728        command_kind: CommandKind,
729        metadata: AuthRequestMetadata,
730        actor: ReplayActor,
731        payload_hash: [u8; 32],
732    ) -> Result<ReplayReceiptDecision, Error> {
733        let now_ns = IcOps::now_nanos();
734        let expires_at_ns = now_ns.checked_add(metadata.ttl_ns).ok_or_else(|| {
735            Error::invalid("delegated token issue replay metadata ttl_ns overflows nanoseconds")
736        })?;
737        let replay_input = ReplayReceiptReserveInput::new(
738            command_kind,
739            OperationId::from_bytes(metadata.request_id),
740            actor,
741            payload_hash,
742            now_ns,
743        )
744        .with_expires_at_ns(expires_at_ns);
745
746        reserve_or_replay_receipt(replay_input).map_err(Self::map_delegation_replay_store_error)
747    }
748
749    fn token_signing_cost_guard_request(
750        command_kind: CommandKind,
751        caller: Principal,
752    ) -> CostGuardRequest {
753        Self::token_signing_cost_guard_request_at(
754            command_kind,
755            caller,
756            IcOps::canister_self(),
757            IcOps::now_secs(),
758            MgmtOps::canister_cycle_balance().to_u128(),
759        )
760    }
761
762    const fn token_signing_cost_guard_request_at(
763        command_kind: CommandKind,
764        caller: Principal,
765        payer: Principal,
766        now_secs: u64,
767        current_cycle_balance: u128,
768    ) -> CostGuardRequest {
769        CostGuardRequest {
770            cost_class: crate::replay_policy::CostClass::ShardTokenSign,
771            command_kind,
772            quota_subject: caller,
773            payer,
774            now_secs,
775            quota_window_secs: Self::TOKEN_SIGNING_QUOTA_WINDOW_SECONDS,
776            max_operations_per_window: Self::MAX_TOKEN_SIGNING_OPERATIONS_PER_WINDOW,
777            current_cycle_balance,
778            cycle_reservation_cycles: Self::TOKEN_SIGNING_CYCLE_RESERVATION_CYCLES,
779            min_cycles_after_reservation: Self::MIN_TOKEN_SIGNING_CYCLES_AFTER_RESERVATION,
780        }
781    }
782
783    fn map_delegation_replay_decision(
784        decision: ReplayReceiptDecision,
785    ) -> Result<DelegationProofPrepareResponse, Error> {
786        match decision {
787            ReplayReceiptDecision::Fresh(_) => {
788                Err(Error::invariant("fresh delegation replay decision escaped"))
789            }
790            ReplayReceiptDecision::ReturnCommitted(receipt) => {
791                Self::decode_delegation_prepare_response(&receipt)
792            }
793            ReplayReceiptDecision::OperationInProgress => Err(Error::conflict(
794                "delegation proof request is already in progress; retry later with the same request id",
795            )),
796            ReplayReceiptDecision::ActorMismatch => Err(Error::conflict(
797                "delegation proof request id was reused by a different caller",
798            )),
799            ReplayReceiptDecision::PayloadMismatch => Err(Error::conflict(
800                "delegation proof request id was reused with a different payload",
801            )),
802            ReplayReceiptDecision::Expired => Err(Error::conflict(
803                "delegation proof replay receipt expired; retry with a new request id",
804            )),
805            ReplayReceiptDecision::RecoveryRequired(reason) => Err(Error::conflict(format!(
806                "delegation proof request requires recovery before replay: {reason:?}"
807            ))),
808            ReplayReceiptDecision::TerminalFailed {
809                error_code,
810                error_bytes,
811                error_bytes_truncated,
812            } => Err(Error::conflict(format!(
813                "delegation proof request previously failed: {error_code:?}; error_bytes_len={}; truncated={error_bytes_truncated}",
814                error_bytes.len()
815            ))),
816            ReplayReceiptDecision::PendingActorQuotaExceeded { max_pending, .. } => {
817                Err(Error::exhausted(format!(
818                    "delegation proof pending replay receipt quota exceeded for caller; max_pending={max_pending}"
819                )))
820            }
821            ReplayReceiptDecision::PendingCommandQuotaExceeded { max_pending, .. } => {
822                Err(Error::exhausted(format!(
823                    "delegation proof pending replay receipt quota exceeded for command kind; max_pending={max_pending}"
824                )))
825            }
826        }
827    }
828
829    fn map_token_replay_decision(
830        decision: ReplayReceiptDecision,
831        label: &str,
832    ) -> Result<DelegatedToken, Error> {
833        match decision {
834            ReplayReceiptDecision::Fresh(_) => Err(Error::invariant(format!(
835                "fresh {label} replay decision escaped"
836            ))),
837            ReplayReceiptDecision::ReturnCommitted(receipt) => {
838                Self::decode_delegated_token_response(&receipt)
839            }
840            ReplayReceiptDecision::OperationInProgress => Err(Error::conflict(format!(
841                "{label} request is already in progress; retry later with the same request id"
842            ))),
843            ReplayReceiptDecision::ActorMismatch => Err(Error::conflict(format!(
844                "{label} request id was reused by a different caller"
845            ))),
846            ReplayReceiptDecision::PayloadMismatch => Err(Error::conflict(format!(
847                "{label} request id was reused with a different payload"
848            ))),
849            ReplayReceiptDecision::Expired => Err(Error::conflict(format!(
850                "{label} replay receipt expired; retry with a new request id"
851            ))),
852            ReplayReceiptDecision::RecoveryRequired(reason) => Err(Error::conflict(format!(
853                "{label} request requires recovery before replay: {reason:?}"
854            ))),
855            ReplayReceiptDecision::TerminalFailed {
856                error_code,
857                error_bytes,
858                error_bytes_truncated,
859            } => Err(Error::conflict(format!(
860                "{label} request previously failed: {error_code:?}; error_bytes_len={}; truncated={error_bytes_truncated}",
861                error_bytes.len()
862            ))),
863            ReplayReceiptDecision::PendingActorQuotaExceeded { max_pending, .. } => {
864                Err(Error::exhausted(format!(
865                    "{label} pending replay receipt quota exceeded for caller; max_pending={max_pending}"
866                )))
867            }
868            ReplayReceiptDecision::PendingCommandQuotaExceeded { max_pending, .. } => {
869                Err(Error::exhausted(format!(
870                    "{label} pending replay receipt quota exceeded for command kind; max_pending={max_pending}"
871                )))
872            }
873        }
874    }
875
876    fn log_token_replay_reserved(
877        label: &str,
878        command_kind: &CommandKind,
879        operation_id: OperationId,
880        caller: Principal,
881    ) {
882        log!(
883            Topic::Auth,
884            Info,
885            "{} replay receipt reserved command_kind={} operation_id={} caller={}",
886            label,
887            command_kind.as_str(),
888            operation_id,
889            caller
890        );
891    }
892
893    fn log_token_replay_decision(
894        label: &str,
895        command_kind: &CommandKind,
896        operation_id: OperationId,
897        caller: Principal,
898        decision: &ReplayReceiptDecision,
899    ) {
900        match decision {
901            ReplayReceiptDecision::ReturnCommitted(_) => log!(
902                Topic::Auth,
903                Info,
904                "{} committed replay returned command_kind={} operation_id={} caller={}",
905                label,
906                command_kind.as_str(),
907                operation_id,
908                caller
909            ),
910            _ => log!(
911                Topic::Auth,
912                Warn,
913                "{} replay decision blocked command_kind={} operation_id={} caller={} decision={}",
914                label,
915                command_kind.as_str(),
916                operation_id,
917                caller,
918                Self::token_replay_decision_name(decision)
919            ),
920        }
921    }
922
923    fn log_token_signing_cost_guard_reserved(
924        label: &str,
925        command_kind: &CommandKind,
926        operation_id: OperationId,
927        caller: Principal,
928    ) {
929        log!(
930            Topic::Auth,
931            Info,
932            "{} signing cost guard reserved command_kind={} operation_id={} caller={}",
933            label,
934            command_kind.as_str(),
935            operation_id,
936            caller
937        );
938    }
939
940    fn log_token_replay_effect_marked(
941        label: &str,
942        command_kind: &CommandKind,
943        operation_id: OperationId,
944        caller: Principal,
945        effect: &ExternalEffectDescriptor,
946    ) {
947        log!(
948            Topic::Auth,
949            Info,
950            "{} replay effect marked effect={} command_kind={} operation_id={} caller={}",
951            label,
952            Self::token_effect_name(effect),
953            command_kind.as_str(),
954            operation_id,
955            caller
956        );
957    }
958
959    fn log_token_replay_recovery_required(
960        label: &str,
961        command_kind: &CommandKind,
962        operation_id: OperationId,
963        caller: Principal,
964        err: &crate::InternalError,
965    ) {
966        let (error_class, error_origin) = err.log_fields();
967        log!(
968            Topic::Auth,
969            Error,
970            "{} replay recovery required effect=threshold_ecdsa_sign_delegated_token command_kind={} operation_id={} caller={} error_class={} error_origin={}",
971            label,
972            command_kind.as_str(),
973            operation_id,
974            caller,
975            error_class,
976            error_origin
977        );
978    }
979
980    fn log_token_replay_response_commit_failed(
981        label: &str,
982        command_kind: &CommandKind,
983        operation_id: OperationId,
984        caller: Principal,
985        err: &Error,
986    ) {
987        log!(
988            Topic::Auth,
989            Error,
990            "{} replay response commit failed command_kind={} operation_id={} caller={} error_code={:?}",
991            label,
992            command_kind.as_str(),
993            operation_id,
994            caller,
995            err.code
996        );
997    }
998
999    fn log_token_replay_response_commit_failed_internal(
1000        label: &str,
1001        command_kind: &CommandKind,
1002        operation_id: OperationId,
1003        caller: Principal,
1004        err: &crate::InternalError,
1005    ) {
1006        let (error_class, error_origin) = err.log_fields();
1007        log!(
1008            Topic::Auth,
1009            Error,
1010            "{} replay response commit failed command_kind={} operation_id={} caller={} error_class={} error_origin={}",
1011            label,
1012            command_kind.as_str(),
1013            operation_id,
1014            caller,
1015            error_class,
1016            error_origin
1017        );
1018    }
1019
1020    fn log_token_replay_commit(
1021        label: &str,
1022        command_kind: &CommandKind,
1023        operation_id: OperationId,
1024        caller: Principal,
1025    ) {
1026        log!(
1027            Topic::Auth,
1028            Ok,
1029            "{} replay response committed command_kind={} operation_id={} caller={}",
1030            label,
1031            command_kind.as_str(),
1032            operation_id,
1033            caller
1034        );
1035    }
1036
1037    const fn token_replay_decision_name(decision: &ReplayReceiptDecision) -> &'static str {
1038        match decision {
1039            ReplayReceiptDecision::Fresh(_) => "fresh",
1040            ReplayReceiptDecision::ReturnCommitted(_) => "return_committed",
1041            ReplayReceiptDecision::OperationInProgress => "operation_in_progress",
1042            ReplayReceiptDecision::ActorMismatch => "actor_mismatch",
1043            ReplayReceiptDecision::PayloadMismatch => "payload_mismatch",
1044            ReplayReceiptDecision::Expired => "expired",
1045            ReplayReceiptDecision::RecoveryRequired(_) => "recovery_required",
1046            ReplayReceiptDecision::TerminalFailed { .. } => "terminal_failed",
1047            ReplayReceiptDecision::PendingActorQuotaExceeded { .. } => {
1048                "pending_actor_quota_exceeded"
1049            }
1050            ReplayReceiptDecision::PendingCommandQuotaExceeded { .. } => {
1051                "pending_command_quota_exceeded"
1052            }
1053        }
1054    }
1055
1056    const fn token_effect_name(effect: &ExternalEffectDescriptor) -> &'static str {
1057        match effect {
1058            ExternalEffectDescriptor::ThresholdEcdsaSign {
1059                purpose: EcdsaPurpose::DelegatedToken,
1060                ..
1061            } => "threshold_ecdsa_sign_delegated_token",
1062            ExternalEffectDescriptor::ThresholdEcdsaSign { .. } => "threshold_ecdsa_sign",
1063            ExternalEffectDescriptor::ManagementCreateCanister { .. } => {
1064                "management_create_canister"
1065            }
1066            ExternalEffectDescriptor::ManagementCall { .. } => "management_call",
1067            ExternalEffectDescriptor::IcpTransfer { .. } => "icp_transfer",
1068        }
1069    }
1070
1071    fn map_delegation_replay_store_error(err: ReplayReceiptStoreError) -> Error {
1072        match err {
1073            ReplayReceiptStoreError::ReceiptDecodeFailed(message) => Error::internal(format!(
1074                "failed to decode delegation replay receipt: {message}"
1075            )),
1076        }
1077    }
1078
1079    fn encode_delegation_prepare_response(
1080        response: &DelegationProofPrepareResponse,
1081    ) -> Result<Vec<u8>, Error> {
1082        encode_one(response).map_err(|err| {
1083            Error::internal(format!(
1084                "failed to encode delegation proof prepare replay response: {err}"
1085            ))
1086        })
1087    }
1088
1089    fn encode_delegated_token_response(token: &DelegatedToken) -> Result<Vec<u8>, Error> {
1090        encode_one(token).map_err(|err| {
1091            Error::internal(format!(
1092                "failed to encode delegated token replay response: {err}"
1093            ))
1094        })
1095    }
1096
1097    fn decode_delegation_prepare_response(
1098        receipt: &crate::ops::replay::model::ReplayReceipt,
1099    ) -> Result<DelegationProofPrepareResponse, Error> {
1100        let response_schema_version = receipt.response_schema_version.ok_or_else(|| {
1101            Error::internal("delegation replay receipt is missing response schema version")
1102        })?;
1103        if response_schema_version != Self::DELEGATION_REPLAY_RESPONSE_SCHEMA_VERSION {
1104            return Err(Error::internal(format!(
1105                "unsupported delegation replay response schema version {response_schema_version}"
1106            )));
1107        }
1108        let response_bytes = receipt.response_bytes.as_deref().ok_or_else(|| {
1109            Error::internal("delegation replay receipt is missing response bytes")
1110        })?;
1111        decode_one(response_bytes).map_err(|err| {
1112            Error::internal(format!(
1113                "failed to decode delegation proof prepare replay response: {err}"
1114            ))
1115        })
1116    }
1117
1118    fn decode_delegated_token_response(
1119        receipt: &crate::ops::replay::model::ReplayReceipt,
1120    ) -> Result<DelegatedToken, Error> {
1121        let response_schema_version = receipt.response_schema_version.ok_or_else(|| {
1122            Error::internal("delegated token replay receipt is missing response schema version")
1123        })?;
1124        if response_schema_version != Self::TOKEN_REPLAY_RESPONSE_SCHEMA_VERSION {
1125            return Err(Error::internal(format!(
1126                "unsupported delegated token replay response schema version {response_schema_version}"
1127            )));
1128        }
1129        let response_bytes = receipt.response_bytes.as_deref().ok_or_else(|| {
1130            Error::internal("delegated token replay receipt is missing response bytes")
1131        })?;
1132        decode_one(response_bytes).map_err(|err| {
1133            Error::internal(format!(
1134                "failed to decode delegated token replay response: {err}"
1135            ))
1136        })
1137    }
1138}
1139
1140impl AuthApi {
1141    // Route a delegation proof prepare request over RPC to root.
1142    async fn prepare_delegation_proof_remote(
1143        request: DelegationProofIssueRequest,
1144    ) -> Result<DelegationProofPrepareResponse, Error> {
1145        let root_pid = EnvOps::root_pid().map_err(Error::from)?;
1146        RootAuthMaterialClient::new(root_pid)
1147            .prepare_delegation_proof(request)
1148            .await
1149            .map_err(Self::map_auth_error)
1150    }
1151
1152    // Execute one local root role-attestation request.
1153    pub async fn request_role_attestation_root(
1154        request: RoleAttestationRequest,
1155    ) -> Result<SignedRoleAttestation, Error> {
1156        let request = metadata::with_root_attestation_request_metadata(request);
1157        let response = RootResponseWorkflow::response(RootRequest::issue_role_attestation(request))
1158            .await
1159            .map_err(Self::map_auth_error)?;
1160
1161        match response {
1162            RootCapabilityResponse::RoleAttestationIssued(response) => Ok(response),
1163            _ => Err(Error::internal(
1164                "invalid root response type for role attestation request",
1165            )),
1166        }
1167    }
1168
1169    // Execute one local root internal-invocation proof request.
1170    pub async fn request_internal_invocation_proof_root(
1171        request: InternalInvocationProofRequest,
1172    ) -> Result<SignedInternalInvocationProofV1, Error> {
1173        let request = metadata::with_internal_invocation_proof_request_metadata(request);
1174        let response =
1175            RootResponseWorkflow::response(RootRequest::issue_internal_invocation_proof(request))
1176                .await
1177                .map_err(Self::map_auth_error)?;
1178
1179        match response {
1180            RootCapabilityResponse::InternalInvocationProofIssued(response) => Ok(response),
1181            _ => Err(Error::internal(
1182                "invalid root response type for internal invocation proof request",
1183            )),
1184        }
1185    }
1186
1187    // Route a canonical role-attestation request over RPC to root.
1188    async fn request_role_attestation_remote(
1189        request: RoleAttestationRequest,
1190    ) -> Result<SignedRoleAttestation, Error> {
1191        let root_pid = EnvOps::root_pid().map_err(Error::from)?;
1192        RootAuthMaterialClient::new(root_pid)
1193            .request_role_attestation(request)
1194            .await
1195            .map_err(Self::map_auth_error)
1196    }
1197
1198    // Route a canonical internal-invocation proof request over RPC to root.
1199    async fn request_internal_invocation_proof_remote(
1200        request: InternalInvocationProofRequest,
1201    ) -> Result<SignedInternalInvocationProofV1, Error> {
1202        let root_pid = EnvOps::root_pid().map_err(Error::from)?;
1203        RootAuthMaterialClient::new(root_pid)
1204            .request_internal_invocation_proof(request)
1205            .await
1206            .map_err(Self::map_auth_error)
1207    }
1208}
1209
1210#[cfg(test)]
1211mod tests {
1212    use super::AuthApi;
1213    use crate::{
1214        cdk::types::Principal,
1215        dto::{
1216            auth::{
1217                AuthRequestMetadata, DelegatedRoleGrant, DelegatedTokenIssueRequest,
1218                DelegationAudience, DelegationCert, DelegationProof, DelegationProofIssueRequest,
1219                IcCanisterSignatureProofV1, RootProof, ShardKeyBinding, ShardSignatureAlgorithm,
1220            },
1221            error::ErrorCode,
1222        },
1223        ops::auth::{AuthExpiryError, AuthOpsError},
1224    };
1225
1226    fn p(id: u8) -> Principal {
1227        Principal::from_slice(&[id; 29])
1228    }
1229
1230    fn delegation_request(metadata_id: u8) -> DelegationProofIssueRequest {
1231        DelegationProofIssueRequest {
1232            metadata: Some(meta(metadata_id, 60_000_000_000)),
1233            shard_pid: p(2),
1234            aud: DelegationAudience::Project("test".to_string()),
1235            grants: vec![grant("project_instance", &["canic.verify"])],
1236            cert_ttl_ns: 60_000_000_000,
1237        }
1238    }
1239
1240    fn grant(role: &str, scopes: &[&str]) -> DelegatedRoleGrant {
1241        DelegatedRoleGrant {
1242            target: crate::ids::CanisterRole::owned(role.to_string()),
1243            scopes: scopes.iter().map(|scope| (*scope).to_string()).collect(),
1244        }
1245    }
1246
1247    fn meta(id: u8, ttl_ns: u64) -> AuthRequestMetadata {
1248        AuthRequestMetadata {
1249            request_id: [id; 32],
1250            ttl_ns,
1251        }
1252    }
1253
1254    fn delegation_proof() -> DelegationProof {
1255        DelegationProof {
1256            cert: DelegationCert {
1257                root_pid: p(1),
1258                shard_pid: p(2),
1259                shard_key_id: "shard-key".to_string(),
1260                shard_sig_alg: ShardSignatureAlgorithm::IcThresholdEcdsaSecp256k1,
1261                shard_public_key_sec1: vec![3; 33],
1262                shard_key_hash: [4; 32],
1263                shard_key_binding: ShardKeyBinding::IcThresholdEcdsaSecp256k1 {
1264                    key_name_hash: [5; 32],
1265                    derivation_path_hash: [6; 32],
1266                },
1267                issued_at_ns: 10,
1268                not_before_ns: 10,
1269                expires_at_ns: 100,
1270                max_token_ttl_ns: 60,
1271                aud: DelegationAudience::Project("test".to_string()),
1272                grants: vec![grant("project_instance", &["canic.verify"])],
1273            },
1274            root_proof: RootProof::IcCanisterSignatureV1(IcCanisterSignatureProofV1 {
1275                signature_cbor: vec![7; 64],
1276                public_key_der: vec![8; 32],
1277            }),
1278        }
1279    }
1280
1281    fn issue_request(metadata_id: u8) -> DelegatedTokenIssueRequest {
1282        DelegatedTokenIssueRequest {
1283            metadata: Some(meta(metadata_id, 60_000_000_000)),
1284            proof: delegation_proof(),
1285            subject: p(8),
1286            aud: DelegationAudience::Project("test".to_string()),
1287            grants: vec![grant("project_instance", &["canic.verify"])],
1288            ttl_ns: 30_000_000_000,
1289            nonce: [9; 16],
1290        }
1291    }
1292
1293    #[test]
1294    fn internal_invocation_not_yet_valid_maps_to_non_retryable_proof_expiry() {
1295        let err = AuthApi::map_internal_invocation_verify_error(AuthOpsError::Expiry(
1296            AuthExpiryError::AttestationNotYetValid {
1297                issued_at_ns: 20,
1298                now_ns: 10,
1299            },
1300        ));
1301
1302        assert_eq!(err.code, ErrorCode::AuthProofExpired);
1303    }
1304
1305    #[test]
1306    fn delegation_request_caller_must_match_requested_shard() {
1307        AuthApi::validate_delegation_request_caller(p(2), p(2)).expect("matching shard");
1308
1309        let err = AuthApi::validate_delegation_request_caller(p(1), p(2))
1310            .expect_err("mismatched caller must fail");
1311
1312        assert_eq!(err.code, ErrorCode::Forbidden);
1313    }
1314
1315    #[test]
1316    fn delegation_replay_metadata_rejects_missing_or_invalid_ttl() {
1317        let missing = AuthApi::delegation_replay_metadata(None).expect_err("metadata is required");
1318        assert_eq!(missing.code, ErrorCode::OperationIdRequired);
1319
1320        let zero = AuthApi::delegation_replay_metadata(Some(AuthRequestMetadata {
1321            request_id: [1; 32],
1322            ttl_ns: 0,
1323        }))
1324        .expect_err("zero ttl is invalid");
1325        assert_eq!(zero.code, ErrorCode::InvalidInput);
1326
1327        let too_large = AuthApi::delegation_replay_metadata(Some(AuthRequestMetadata {
1328            request_id: [1; 32],
1329            ttl_ns: AuthApi::MAX_DELEGATION_REPLAY_TTL_NS + 1,
1330        }))
1331        .expect_err("oversized ttl is invalid");
1332        assert_eq!(too_large.code, ErrorCode::InvalidInput);
1333    }
1334
1335    #[test]
1336    fn delegation_replay_payload_hash_ignores_metadata() {
1337        let command_kind = AuthApi::delegation_replay_command_kind();
1338        let actor = crate::ops::replay::model::ReplayActor::direct_caller(p(2));
1339        let a = delegation_request(1);
1340        let b = delegation_request(9);
1341
1342        assert_eq!(
1343            AuthApi::delegation_replay_payload_hash(&command_kind, &actor, &a),
1344            AuthApi::delegation_replay_payload_hash(&command_kind, &actor, &b)
1345        );
1346    }
1347
1348    #[test]
1349    fn delegated_token_replay_metadata_rejects_missing_or_invalid_ttl() {
1350        let missing =
1351            AuthApi::token_replay_metadata(None, "delegated token mint").expect_err("required");
1352        assert_eq!(missing.code, ErrorCode::OperationIdRequired);
1353
1354        let zero = AuthApi::token_replay_metadata(Some(meta(1, 0)), "delegated token mint")
1355            .expect_err("zero ttl is invalid");
1356        assert_eq!(zero.code, ErrorCode::InvalidInput);
1357
1358        let too_large = AuthApi::token_replay_metadata(
1359            Some(meta(1, AuthApi::MAX_TOKEN_REPLAY_TTL_NS + 1)),
1360            "delegated token mint",
1361        )
1362        .expect_err("oversized ttl is invalid");
1363        assert_eq!(too_large.code, ErrorCode::InvalidInput);
1364    }
1365
1366    #[test]
1367    fn delegation_replay_payload_hash_binds_authoritative_payload() {
1368        let command_kind = AuthApi::delegation_replay_command_kind();
1369        let actor = crate::ops::replay::model::ReplayActor::direct_caller(p(2));
1370        let a = delegation_request(1);
1371        let mut b = a.clone();
1372        b.cert_ttl_ns += 1;
1373
1374        assert_ne!(
1375            AuthApi::delegation_replay_payload_hash(&command_kind, &actor, &a),
1376            AuthApi::delegation_replay_payload_hash(&command_kind, &actor, &b)
1377        );
1378    }
1379
1380    #[test]
1381    fn delegated_token_issue_payload_hash_ignores_metadata() {
1382        let command_kind = AuthApi::token_issue_replay_command_kind();
1383        let actor = crate::ops::replay::model::ReplayActor::direct_caller(p(2));
1384        let a = issue_request(1);
1385        let b = issue_request(9);
1386
1387        assert_eq!(
1388            AuthApi::token_issue_replay_payload_hash(&command_kind, &actor, &a),
1389            AuthApi::token_issue_replay_payload_hash(&command_kind, &actor, &b)
1390        );
1391    }
1392
1393    #[test]
1394    fn delegated_token_issue_payload_hash_binds_authoritative_payload() {
1395        let command_kind = AuthApi::token_issue_replay_command_kind();
1396        let actor = crate::ops::replay::model::ReplayActor::direct_caller(p(2));
1397        let a = issue_request(1);
1398        let mut b = a.clone();
1399        b.nonce = [10; 16];
1400
1401        assert_ne!(
1402            AuthApi::token_issue_replay_payload_hash(&command_kind, &actor, &a),
1403            AuthApi::token_issue_replay_payload_hash(&command_kind, &actor, &b)
1404        );
1405    }
1406}