Skip to main content

canic_core/api/auth/
mod.rs

1use crate::{
2    cdk::types::Principal,
3    dto::{
4        auth::{
5            AttestationKeySet, DelegatedToken, DelegatedTokenIssueRequest,
6            DelegatedTokenMintRequest, DelegationAudience, DelegationProof,
7            DelegationProofIssueRequest, InternalInvocationProofRequest, RoleAttestationRequest,
8            SignedInternalInvocationProofV1, SignedRoleAttestation,
9        },
10        error::{Error, ErrorCode},
11        rpc::{Request as RootRequest, Response as RootCapabilityResponse, RootRequestMetadata},
12    },
13    error::InternalErrorClass,
14    ids::CanisterRole,
15    log,
16    log::Topic,
17    ops::{
18        auth::{
19            AuthExpiryError, AuthOps, AuthOpsError, AuthValidationError, SignDelegatedTokenInput,
20            SignDelegationProofInput, VerifyDelegatedTokenRuntimeInput,
21        },
22        config::ConfigOps,
23        ic::IcOps,
24        replay::{
25            guard::secs_to_ns,
26            model::{
27                CommandKind, EcdsaPurpose, ExternalEffectDescriptor, OperationId, RecoveryReason,
28                ReplayActor, ReplayPayloadHasher,
29            },
30            receipt::{
31                ReplayReceiptDecision, ReplayReceiptReserveInput, ReplayReceiptStoreError,
32                abort_reserved_receipt, commit_receipt_response, mark_external_effect_in_flight,
33                mark_recovery_required, reserve_or_replay_receipt,
34            },
35        },
36        runtime::env::EnvOps,
37        runtime::metrics::auth::record_attestation_refresh_failed,
38    },
39    workflow::rpc::request::handler::RootResponseWorkflow,
40};
41use candid::{decode_one, encode_one};
42use root_client::RootAuthMaterialClient;
43use sha2::{Digest, Sha256};
44
45// Internal auth pipeline:
46// - `session` owns delegated-session ingress and replay/session state handling.
47// - `metadata` owns root request metadata construction.
48// - `verify_flow` owns verifier-side attestation refresh behavior.
49mod metadata;
50mod root_client;
51mod session;
52mod verify_flow;
53
54///
55/// AuthApi
56///
57/// Owns delegated-token helpers and root-signed role-attestation helpers.
58///
59
60pub struct AuthApi;
61
62impl AuthApi {
63    const DELEGATED_TOKENS_DISABLED: &str =
64        "delegated token auth disabled; set auth.delegated_tokens.enabled=true in canic.toml";
65    const MAX_DELEGATED_SESSION_TTL_SECS: u64 = 24 * 60 * 60;
66    const DELEGATION_REPLAY_COMMAND_KIND: &str = "auth.issue_delegation_proof.v1";
67    const DELEGATION_REPLAY_RESPONSE_SCHEMA_VERSION: u32 = 1;
68    const MAX_DELEGATION_REPLAY_TTL_SECONDS: u64 = 300;
69    const SESSION_BOOTSTRAP_TOKEN_FINGERPRINT_DOMAIN: &[u8] =
70        b"canic-session-bootstrap-token-fingerprint";
71
72    // Map internal auth failures onto public endpoint errors.
73    fn map_auth_error(err: crate::InternalError) -> Error {
74        match err.class() {
75            InternalErrorClass::Infra | InternalErrorClass::Ops | InternalErrorClass::Workflow => {
76                Error::internal(err.to_string())
77            }
78            _ => Error::from(err),
79        }
80    }
81
82    fn map_internal_invocation_verify_error(err: AuthOpsError) -> Error {
83        match err {
84            AuthOpsError::Validation(AuthValidationError::AttestationUnknownKeyId { .. }) => {
85                Error::new(ErrorCode::AuthKeyUnknown, err.to_string())
86            }
87            AuthOpsError::Expiry(AuthExpiryError::AttestationEpochRejected { .. }) => {
88                Error::new(ErrorCode::AuthMaterialStale, err.to_string())
89            }
90            AuthOpsError::Expiry(
91                AuthExpiryError::AttestationExpired { .. }
92                | AuthExpiryError::AttestationNotYetValid { .. },
93            ) => Error::new(ErrorCode::AuthProofExpired, err.to_string()),
94            _ => Error::unauthorized(err.to_string()),
95        }
96    }
97
98    // Verify delegated-token material and return the token subject.
99    //
100    // This is intentionally private: endpoint authorization must also bind the
101    // verified subject to the caller and consume update tokens once.
102    fn verify_token_material(
103        token: &DelegatedToken,
104        max_cert_ttl_secs: u64,
105        max_token_ttl_secs: u64,
106        required_scopes: &[String],
107        now_secs: u64,
108    ) -> Result<Principal, Error> {
109        AuthOps::verify_token(VerifyDelegatedTokenRuntimeInput {
110            token,
111            max_cert_ttl_secs,
112            max_token_ttl_secs,
113            required_scopes,
114            now_secs,
115        })
116        .map(|verified| verified.subject)
117        .map_err(Self::map_auth_error)
118    }
119
120    /// Resolve the local shard public key in SEC1 encoding.
121    pub async fn local_shard_public_key_sec1() -> Result<Vec<u8>, Error> {
122        AuthOps::local_shard_public_key_sec1(IcOps::canister_self())
123            .await
124            .map_err(Self::map_auth_error)
125    }
126
127    /// Issue a delegated token from an explicit self-contained proof.
128    pub async fn issue_token(request: DelegatedTokenIssueRequest) -> Result<DelegatedToken, Error> {
129        AuthOps::sign_token(SignDelegatedTokenInput {
130            proof: request.proof,
131            subject: request.subject,
132            audience: request.aud,
133            scopes: request.scopes,
134            ttl_secs: request.ttl_secs,
135            nonce: request.nonce,
136        })
137        .await
138        .map_err(Self::map_auth_error)
139    }
140
141    /// Request a root proof, then issue a self-contained delegated token.
142    pub async fn mint_token(request: DelegatedTokenMintRequest) -> Result<DelegatedToken, Error> {
143        let proof = Self::request_delegation(DelegationProofIssueRequest {
144            metadata: None,
145            shard_pid: IcOps::canister_self(),
146            scopes: request.scopes.clone(),
147            aud: request.aud.clone(),
148            cert_ttl_secs: request.cert_ttl_secs,
149        })
150        .await?;
151
152        Self::issue_token(DelegatedTokenIssueRequest {
153            proof,
154            subject: request.subject,
155            aud: request.aud,
156            scopes: request.scopes,
157            ttl_secs: request.token_ttl_secs,
158            nonce: request.nonce,
159        })
160        .await
161    }
162
163    /// Request a self-contained delegation proof from root over RPC.
164    pub async fn request_delegation(
165        request: DelegationProofIssueRequest,
166    ) -> Result<DelegationProof, Error> {
167        let request = metadata::with_delegation_request_metadata(request);
168        Self::request_delegation_remote(request).await
169    }
170
171    /// Issue a self-contained delegation proof from the local root.
172    pub async fn issue_delegation_proof(
173        request: DelegationProofIssueRequest,
174    ) -> Result<DelegationProof, Error> {
175        EnvOps::require_root().map_err(Error::from)?;
176        let caller = IcOps::msg_caller();
177        Self::validate_delegation_request_caller(caller, request.shard_pid)?;
178        let max_cert_ttl_secs = Self::delegated_token_max_ttl_secs()?;
179        let metadata = Self::delegation_replay_metadata(request.metadata)?;
180        let command_kind = Self::delegation_replay_command_kind();
181        let actor = ReplayActor::direct_caller(caller);
182        let payload_hash = Self::delegation_replay_payload_hash(&command_kind, &actor, &request);
183        let now_secs = IcOps::now_secs();
184        let replay_input = ReplayReceiptReserveInput::new(
185            command_kind,
186            OperationId::from_bytes(metadata.request_id),
187            actor,
188            payload_hash,
189            secs_to_ns(now_secs),
190        )
191        .with_expires_at_ns(secs_to_ns(now_secs.saturating_add(metadata.ttl_seconds)));
192
193        let token = match reserve_or_replay_receipt(replay_input)
194            .map_err(Self::map_delegation_replay_store_error)?
195        {
196            ReplayReceiptDecision::Fresh(token) => token,
197            decision => return Self::map_delegation_replay_decision(decision),
198        };
199
200        let max_token_ttl_secs = request.cert_ttl_secs.min(max_cert_ttl_secs);
201        let prepared = match AuthOps::prepare_delegation_proof(SignDelegationProofInput {
202            audience: request.aud,
203            scopes: request.scopes,
204            shard_pid: request.shard_pid,
205            cert_ttl_secs: request.cert_ttl_secs,
206            max_token_ttl_secs,
207            max_cert_ttl_secs,
208            issued_at: IcOps::now_secs(),
209        })
210        .await
211        {
212            Ok(prepared) => prepared,
213            Err(err) => {
214                abort_reserved_receipt(&token);
215                return Err(Self::map_auth_error(err));
216            }
217        };
218
219        mark_external_effect_in_flight(
220            &token,
221            ExternalEffectDescriptor::ThresholdEcdsaSign {
222                key_id_hash: Self::hash_delegation_effect_key(&prepared.key_name),
223                purpose: EcdsaPurpose::DelegationProof,
224                message_hash: prepared.cert_hash,
225            },
226            secs_to_ns(IcOps::now_secs()),
227        );
228
229        let proof = match AuthOps::sign_prepared_delegation_proof(prepared).await {
230            Ok(proof) => proof,
231            Err(err) => {
232                mark_recovery_required(
233                    &token,
234                    RecoveryReason::ExternalEffectStatusUnknown,
235                    secs_to_ns(IcOps::now_secs()),
236                );
237                return Err(Self::map_auth_error(err));
238            }
239        };
240
241        let response_bytes = match Self::encode_delegation_proof_response(&proof) {
242            Ok(response_bytes) => response_bytes,
243            Err(err) => {
244                mark_recovery_required(
245                    &token,
246                    RecoveryReason::ResponseCommitFailed,
247                    secs_to_ns(IcOps::now_secs()),
248                );
249                return Err(err);
250            }
251        };
252
253        commit_receipt_response(
254            &token,
255            Self::DELEGATION_REPLAY_RESPONSE_SCHEMA_VERSION,
256            response_bytes,
257            secs_to_ns(IcOps::now_secs()),
258        );
259        Ok(proof)
260    }
261
262    /// Request a signed role attestation from root over RPC.
263    pub async fn request_role_attestation(
264        request: RoleAttestationRequest,
265    ) -> Result<SignedRoleAttestation, Error> {
266        let request = metadata::with_root_attestation_request_metadata(request);
267        Self::request_role_attestation_remote(request).await
268    }
269
270    /// Request a method-scoped internal invocation proof from root over RPC.
271    pub async fn request_internal_invocation_proof(
272        request: InternalInvocationProofRequest,
273    ) -> Result<SignedInternalInvocationProofV1, Error> {
274        let request = metadata::with_internal_invocation_proof_request_metadata(request);
275        Self::request_internal_invocation_proof_remote(request).await
276    }
277
278    /// Return the current root role-attestation key set.
279    pub async fn attestation_key_set() -> Result<AttestationKeySet, Error> {
280        AuthOps::attestation_key_set()
281            .await
282            .map_err(Self::map_auth_error)
283    }
284
285    /// Publish root auth material into subnet state and warm root-owned keys once.
286    pub async fn publish_root_auth_material() -> Result<(), Error> {
287        EnvOps::require_root().map_err(Error::from)?;
288        AuthOps::publish_root_auth_material().await.map_err(|err| {
289            log!(
290                Topic::Auth,
291                Warn,
292                "root auth material publish failed: {err}"
293            );
294            Self::map_auth_error(err)
295        })
296    }
297
298    /// Replace the verifier-local role-attestation key set.
299    pub fn replace_attestation_key_set(key_set: AttestationKeySet) {
300        AuthOps::replace_attestation_key_set(key_set);
301    }
302
303    /// Verify a role attestation, refreshing root keys once on unknown key.
304    pub async fn verify_role_attestation(
305        attestation: &SignedRoleAttestation,
306        min_accepted_epoch: u64,
307    ) -> Result<(), Error> {
308        crate::workflow::runtime::auth::RuntimeAuthWorkflow::verify_role_attestation(
309            attestation,
310            min_accepted_epoch,
311        )
312        .await
313        .map_err(Self::map_auth_error)
314    }
315
316    /// Verify a root-signed, method-scoped internal invocation proof for this endpoint.
317    pub async fn verify_internal_invocation_proof(
318        proof: &SignedInternalInvocationProofV1,
319        target_method: &str,
320        accepted_roles: &[CanisterRole],
321    ) -> Result<(), Error> {
322        let configured_min_accepted_epoch = ConfigOps::role_attestation_config()
323            .map_err(Error::from)?
324            .min_accepted_epoch_by_role
325            .get(proof.payload.role.as_str())
326            .copied();
327        let min_accepted_epoch =
328            verify_flow::resolve_min_accepted_epoch(0, configured_min_accepted_epoch);
329
330        let caller = IcOps::msg_caller();
331        let self_pid = IcOps::canister_self();
332        let now_secs = IcOps::now_secs();
333        let verifier_subnet = Some(EnvOps::subnet_pid().map_err(Error::from)?);
334        let root_pid = EnvOps::root_pid().map_err(Error::from)?;
335
336        let verify = || {
337            AuthOps::verify_internal_invocation_proof_cached(
338                proof,
339                crate::ops::auth::InternalInvocationProofVerificationInput {
340                    caller,
341                    self_pid,
342                    target_method,
343                    accepted_roles,
344                    verifier_subnet,
345                    now_secs,
346                    min_accepted_epoch,
347                },
348            )
349            .map(|_| ())
350        };
351        let refresh = || async {
352            let key_set = RootAuthMaterialClient::new(root_pid)
353                .attestation_key_set()
354                .await?;
355            AuthOps::replace_attestation_key_set(key_set);
356            Ok(())
357        };
358
359        match verify_flow::verify_role_attestation_with_single_refresh(verify, refresh).await {
360            Ok(()) => Ok(()),
361            Err(verify_flow::RoleAttestationVerifyFlowError::Initial(err)) => {
362                verify_flow::record_attestation_verifier_rejection(&err);
363                log!(
364                    Topic::Auth,
365                    Warn,
366                    "internal invocation proof rejected phase=cached local={} caller={} subject={} role={} key_id={} audience={} method={} epoch={} error={}",
367                    self_pid,
368                    caller,
369                    proof.payload.subject,
370                    proof.payload.role,
371                    proof.key_id,
372                    proof.payload.audience,
373                    proof.payload.audience_method,
374                    proof.payload.epoch,
375                    err
376                );
377                Err(Self::map_internal_invocation_verify_error(err))
378            }
379            Err(verify_flow::RoleAttestationVerifyFlowError::Refresh { trigger, source }) => {
380                verify_flow::record_attestation_verifier_rejection(&trigger);
381                record_attestation_refresh_failed();
382                log!(
383                    Topic::Auth,
384                    Warn,
385                    "internal invocation proof refresh failed local={} caller={} key_id={} error={}",
386                    self_pid,
387                    caller,
388                    proof.key_id,
389                    source
390                );
391                Err(Self::map_auth_error(source))
392            }
393            Err(verify_flow::RoleAttestationVerifyFlowError::PostRefresh(err)) => {
394                verify_flow::record_attestation_verifier_rejection(&err);
395                log!(
396                    Topic::Auth,
397                    Warn,
398                    "internal invocation proof rejected phase=post_refresh local={} caller={} subject={} role={} key_id={} audience={} method={} epoch={} error={}",
399                    self_pid,
400                    caller,
401                    proof.payload.subject,
402                    proof.payload.role,
403                    proof.key_id,
404                    proof.payload.audience,
405                    proof.payload.audience_method,
406                    proof.payload.epoch,
407                    err
408                );
409                Err(Self::map_internal_invocation_verify_error(err))
410            }
411        }
412    }
413
414    // Resolve the root-owned TTL ceiling from delegated-token config.
415    fn delegated_token_max_ttl_secs() -> Result<u64, Error> {
416        let cfg = ConfigOps::delegated_tokens_config().map_err(Error::from)?;
417        if !cfg.enabled {
418            return Err(Error::forbidden(Self::DELEGATED_TOKENS_DISABLED));
419        }
420
421        Ok(cfg
422            .max_ttl_secs
423            .unwrap_or(Self::MAX_DELEGATED_SESSION_TTL_SECS))
424    }
425
426    fn validate_delegation_request_caller(
427        caller: Principal,
428        shard_pid: Principal,
429    ) -> Result<(), Error> {
430        if caller == shard_pid {
431            return Ok(());
432        }
433
434        Err(Error::forbidden(format!(
435            "delegation request caller {caller} must match shard_pid {shard_pid}"
436        )))
437    }
438
439    fn delegation_replay_metadata(
440        metadata: Option<RootRequestMetadata>,
441    ) -> Result<RootRequestMetadata, Error> {
442        let metadata = metadata
443            .ok_or_else(|| Error::invalid("delegation proof request requires replay metadata"))?;
444        if metadata.ttl_seconds == 0 {
445            return Err(Error::invalid(
446                "delegation proof replay metadata ttl_seconds must be greater than zero",
447            ));
448        }
449        if metadata.ttl_seconds > Self::MAX_DELEGATION_REPLAY_TTL_SECONDS {
450            return Err(Error::invalid(format!(
451                "delegation proof replay metadata ttl_seconds={} exceeds max {}",
452                metadata.ttl_seconds,
453                Self::MAX_DELEGATION_REPLAY_TTL_SECONDS
454            )));
455        }
456        Ok(metadata)
457    }
458
459    fn delegation_replay_command_kind() -> CommandKind {
460        CommandKind::new(Self::DELEGATION_REPLAY_COMMAND_KIND)
461            .expect("delegation replay command kind is a valid static label")
462    }
463
464    fn delegation_replay_payload_hash(
465        command_kind: &CommandKind,
466        actor: &ReplayActor,
467        request: &DelegationProofIssueRequest,
468    ) -> [u8; 32] {
469        let mut hasher = ReplayPayloadHasher::new(command_kind, actor);
470        hasher.hash_principal(&request.shard_pid);
471        hasher.hash_u64(request.scopes.len() as u64);
472        for scope in &request.scopes {
473            hasher.hash_str(scope);
474        }
475        Self::hash_delegation_audience(&mut hasher, &request.aud);
476        hasher.hash_u64(request.cert_ttl_secs);
477        hasher.finish()
478    }
479
480    fn hash_delegation_audience(hasher: &mut ReplayPayloadHasher, aud: &DelegationAudience) {
481        match aud {
482            DelegationAudience::Role(role) => {
483                hasher.hash_str("role");
484                hasher.hash_role(role);
485            }
486            DelegationAudience::Principal(principal) => {
487                hasher.hash_str("principal");
488                hasher.hash_principal(principal);
489            }
490        }
491    }
492
493    fn map_delegation_replay_decision(
494        decision: ReplayReceiptDecision,
495    ) -> Result<DelegationProof, Error> {
496        match decision {
497            ReplayReceiptDecision::Fresh(_) => {
498                Err(Error::invariant("fresh delegation replay decision escaped"))
499            }
500            ReplayReceiptDecision::ReturnCommitted(receipt) => {
501                Self::decode_delegation_proof_response(&receipt)
502            }
503            ReplayReceiptDecision::OperationInProgress => Err(Error::conflict(
504                "delegation proof request is already in progress; retry later with the same request id",
505            )),
506            ReplayReceiptDecision::ActorMismatch => Err(Error::conflict(
507                "delegation proof request id was reused by a different caller",
508            )),
509            ReplayReceiptDecision::PayloadMismatch => Err(Error::conflict(
510                "delegation proof request id was reused with a different payload",
511            )),
512            ReplayReceiptDecision::Expired => Err(Error::conflict(
513                "delegation proof replay receipt expired; retry with a new request id",
514            )),
515            ReplayReceiptDecision::RecoveryRequired(reason) => Err(Error::conflict(format!(
516                "delegation proof request requires recovery before replay: {reason:?}"
517            ))),
518            ReplayReceiptDecision::TerminalFailed {
519                error_code,
520                error_bytes,
521                error_bytes_truncated,
522            } => Err(Error::conflict(format!(
523                "delegation proof request previously failed: {error_code:?}; error_bytes_len={}; truncated={error_bytes_truncated}",
524                error_bytes.len()
525            ))),
526        }
527    }
528
529    fn map_delegation_replay_store_error(err: ReplayReceiptStoreError) -> Error {
530        match err {
531            ReplayReceiptStoreError::ReceiptDecodeFailed(message) => Error::internal(format!(
532                "failed to decode delegation replay receipt: {message}"
533            )),
534        }
535    }
536
537    fn encode_delegation_proof_response(proof: &DelegationProof) -> Result<Vec<u8>, Error> {
538        encode_one(proof).map_err(|err| {
539            Error::internal(format!(
540                "failed to encode delegation proof replay response: {err}"
541            ))
542        })
543    }
544
545    fn decode_delegation_proof_response(
546        receipt: &crate::ops::replay::model::ReplayReceipt,
547    ) -> Result<DelegationProof, Error> {
548        let response_schema_version = receipt.response_schema_version.ok_or_else(|| {
549            Error::internal("delegation replay receipt is missing response schema version")
550        })?;
551        if response_schema_version != Self::DELEGATION_REPLAY_RESPONSE_SCHEMA_VERSION {
552            return Err(Error::internal(format!(
553                "unsupported delegation replay response schema version {response_schema_version}"
554            )));
555        }
556        let response_bytes = receipt.response_bytes.as_deref().ok_or_else(|| {
557            Error::internal("delegation replay receipt is missing response bytes")
558        })?;
559        decode_one(response_bytes).map_err(|err| {
560            Error::internal(format!(
561                "failed to decode delegation proof replay response: {err}"
562            ))
563        })
564    }
565
566    fn hash_delegation_effect_key(key_name: &str) -> [u8; 32] {
567        let mut hasher = Sha256::new();
568        hasher.update(b"canic-delegation-proof-effect-key:v1");
569        hasher.update(key_name.as_bytes());
570        hasher.finalize().into()
571    }
572}
573
574impl AuthApi {
575    // Route a self-contained delegation proof request over RPC to root.
576    async fn request_delegation_remote(
577        request: DelegationProofIssueRequest,
578    ) -> Result<DelegationProof, Error> {
579        let root_pid = EnvOps::root_pid().map_err(Error::from)?;
580        RootAuthMaterialClient::new(root_pid)
581            .request_delegation(request)
582            .await
583            .map_err(Self::map_auth_error)
584    }
585
586    // Execute one local root role-attestation request.
587    pub async fn request_role_attestation_root(
588        request: RoleAttestationRequest,
589    ) -> Result<SignedRoleAttestation, Error> {
590        let request = metadata::with_root_attestation_request_metadata(request);
591        let response = RootResponseWorkflow::response(RootRequest::issue_role_attestation(request))
592            .await
593            .map_err(Self::map_auth_error)?;
594
595        match response {
596            RootCapabilityResponse::RoleAttestationIssued(response) => Ok(response),
597            _ => Err(Error::internal(
598                "invalid root response type for role attestation request",
599            )),
600        }
601    }
602
603    // Execute one local root internal-invocation proof request.
604    pub async fn request_internal_invocation_proof_root(
605        request: InternalInvocationProofRequest,
606    ) -> Result<SignedInternalInvocationProofV1, Error> {
607        let request = metadata::with_internal_invocation_proof_request_metadata(request);
608        let response =
609            RootResponseWorkflow::response(RootRequest::issue_internal_invocation_proof(request))
610                .await
611                .map_err(Self::map_auth_error)?;
612
613        match response {
614            RootCapabilityResponse::InternalInvocationProofIssued(response) => Ok(response),
615            _ => Err(Error::internal(
616                "invalid root response type for internal invocation proof request",
617            )),
618        }
619    }
620
621    // Route a canonical role-attestation request over RPC to root.
622    async fn request_role_attestation_remote(
623        request: RoleAttestationRequest,
624    ) -> Result<SignedRoleAttestation, Error> {
625        let root_pid = EnvOps::root_pid().map_err(Error::from)?;
626        RootAuthMaterialClient::new(root_pid)
627            .request_role_attestation(request)
628            .await
629            .map_err(Self::map_auth_error)
630    }
631
632    // Route a canonical internal-invocation proof request over RPC to root.
633    async fn request_internal_invocation_proof_remote(
634        request: InternalInvocationProofRequest,
635    ) -> Result<SignedInternalInvocationProofV1, Error> {
636        let root_pid = EnvOps::root_pid().map_err(Error::from)?;
637        RootAuthMaterialClient::new(root_pid)
638            .request_internal_invocation_proof(request)
639            .await
640            .map_err(Self::map_auth_error)
641    }
642}
643
644#[cfg(test)]
645mod tests {
646    use super::AuthApi;
647    use crate::{
648        cdk::types::Principal,
649        dto::{
650            auth::{DelegationAudience, DelegationProofIssueRequest},
651            error::ErrorCode,
652            rpc::RootRequestMetadata,
653        },
654        ops::auth::{AuthExpiryError, AuthOpsError},
655    };
656
657    fn p(id: u8) -> Principal {
658        Principal::from_slice(&[id; 29])
659    }
660
661    fn delegation_request(metadata_id: u8) -> DelegationProofIssueRequest {
662        DelegationProofIssueRequest {
663            metadata: Some(RootRequestMetadata {
664                request_id: [metadata_id; 32],
665                ttl_seconds: 60,
666            }),
667            shard_pid: p(2),
668            scopes: vec!["canic.verify".to_string()],
669            aud: DelegationAudience::Principal(p(3)),
670            cert_ttl_secs: 60,
671        }
672    }
673
674    #[test]
675    fn internal_invocation_not_yet_valid_maps_to_non_retryable_proof_expiry() {
676        let err = AuthApi::map_internal_invocation_verify_error(AuthOpsError::Expiry(
677            AuthExpiryError::AttestationNotYetValid {
678                issued_at: 20,
679                now_secs: 10,
680            },
681        ));
682
683        assert_eq!(err.code, ErrorCode::AuthProofExpired);
684    }
685
686    #[test]
687    fn delegation_request_caller_must_match_requested_shard() {
688        AuthApi::validate_delegation_request_caller(p(2), p(2)).expect("matching shard");
689
690        let err = AuthApi::validate_delegation_request_caller(p(1), p(2))
691            .expect_err("mismatched caller must fail");
692
693        assert_eq!(err.code, ErrorCode::Forbidden);
694        assert!(err.message.contains("must match shard_pid"));
695    }
696
697    #[test]
698    fn delegation_replay_metadata_rejects_missing_or_invalid_ttl() {
699        let missing = AuthApi::delegation_replay_metadata(None).expect_err("metadata is required");
700        assert_eq!(missing.code, ErrorCode::InvalidInput);
701
702        let zero = AuthApi::delegation_replay_metadata(Some(RootRequestMetadata {
703            request_id: [1; 32],
704            ttl_seconds: 0,
705        }))
706        .expect_err("zero ttl is invalid");
707        assert_eq!(zero.code, ErrorCode::InvalidInput);
708
709        let too_large = AuthApi::delegation_replay_metadata(Some(RootRequestMetadata {
710            request_id: [1; 32],
711            ttl_seconds: AuthApi::MAX_DELEGATION_REPLAY_TTL_SECONDS + 1,
712        }))
713        .expect_err("oversized ttl is invalid");
714        assert_eq!(too_large.code, ErrorCode::InvalidInput);
715    }
716
717    #[test]
718    fn delegation_replay_payload_hash_ignores_metadata() {
719        let command_kind = AuthApi::delegation_replay_command_kind();
720        let actor = crate::ops::replay::model::ReplayActor::direct_caller(p(2));
721        let a = delegation_request(1);
722        let b = delegation_request(9);
723
724        assert_eq!(
725            AuthApi::delegation_replay_payload_hash(&command_kind, &actor, &a),
726            AuthApi::delegation_replay_payload_hash(&command_kind, &actor, &b)
727        );
728    }
729
730    #[test]
731    fn delegation_replay_payload_hash_binds_authoritative_payload() {
732        let command_kind = AuthApi::delegation_replay_command_kind();
733        let actor = crate::ops::replay::model::ReplayActor::direct_caller(p(2));
734        let a = delegation_request(1);
735        let mut b = a.clone();
736        b.cert_ttl_secs += 1;
737
738        assert_ne!(
739            AuthApi::delegation_replay_payload_hash(&command_kind, &actor, &a),
740            AuthApi::delegation_replay_payload_hash(&command_kind, &actor, &b)
741        );
742    }
743}