Skip to main content

canic_core/api/auth/
mod.rs

1use crate::{
2    cdk::types::Principal,
3    dto::{
4        auth::{
5            AuthRequestMetadata, DelegatedRoleGrant, DelegatedToken, DelegatedTokenGetRequest,
6            DelegatedTokenPrepareRequest, DelegatedTokenPrepareResponse, DelegationAudience,
7            DelegationProof, DelegationProofGetRequest, DelegationProofIssueRequest,
8            DelegationProofPrepareResponse, InstallActiveDelegationProofRequest,
9            InstallActiveDelegationProofResponse, RoleAttestationGetRequest,
10            RoleAttestationPrepareResponse, RoleAttestationRequest, SignedRoleAttestation,
11        },
12        error::Error,
13    },
14    error::InternalErrorClass,
15    ops::{
16        auth::{
17            AuthOps, SignDelegatedTokenInput, SignDelegationProofInput, SignRoleAttestationInput,
18            VerifyDelegatedTokenRuntimeInput,
19        },
20        config::ConfigOps,
21        ic::IcOps,
22        replay::{
23            guard::secs_to_ns,
24            model::{CommandKind, OperationId, RecoveryReason, ReplayActor, ReplayPayloadHasher},
25            receipt::{
26                ReplayReceiptDecision, ReplayReceiptReserveInput, ReplayReceiptStoreError,
27                ReplayReceiptToken, abort_reserved_receipt, commit_receipt_response,
28                mark_recovery_required, reserve_or_replay_receipt,
29            },
30        },
31        runtime::env::EnvOps,
32        storage::registry::subnet::SubnetRegistryOps,
33    },
34};
35use candid::{decode_one, encode_one};
36use root_client::RootAuthMaterialClient;
37
38// Internal auth pipeline:
39// - `session` owns delegated-session ingress and replay/session state handling.
40// - `metadata` owns root request metadata construction.
41mod metadata;
42mod root_client;
43mod session;
44
45///
46/// AuthApi
47///
48/// Owns delegated-token helpers and root-signed role-attestation helpers.
49///
50
51pub struct AuthApi;
52
53impl AuthApi {
54    const DELEGATED_TOKENS_DISABLED: &str =
55        "delegated token auth disabled; set auth.delegated_tokens.enabled=true in canic.toml";
56    const MAX_DELEGATED_SESSION_TTL_SECS: u64 = 24 * 60 * 60;
57    const DELEGATION_REPLAY_COMMAND_KIND: &str = "auth.prepare_delegation_proof.v1";
58    const DELEGATION_REPLAY_RESPONSE_SCHEMA_VERSION: u32 = 1;
59    const MAX_DELEGATION_REPLAY_TTL_NS: u64 = 300_000_000_000;
60    const ROLE_ATTESTATION_REPLAY_COMMAND_KIND: &str = "auth.prepare_role_attestation.v1";
61    const ROLE_ATTESTATION_REPLAY_RESPONSE_SCHEMA_VERSION: u32 = 1;
62    const MAX_ROLE_ATTESTATION_REPLAY_TTL_NS: u64 = 300_000_000_000;
63    const TOKEN_PREPARE_REPLAY_COMMAND_KIND: &str = "auth.prepare_delegated_token.v1";
64    const TOKEN_PREPARE_REPLAY_RESPONSE_SCHEMA_VERSION: u32 = 1;
65    const MAX_TOKEN_REPLAY_TTL_NS: u64 = 300_000_000_000;
66    const SESSION_BOOTSTRAP_TOKEN_FINGERPRINT_DOMAIN: &[u8] =
67        b"canic-session-bootstrap-token-fingerprint";
68
69    // Map internal auth failures onto public endpoint errors.
70    fn map_auth_error(err: crate::InternalError) -> Error {
71        match err.class() {
72            InternalErrorClass::Infra | InternalErrorClass::Ops | InternalErrorClass::Workflow => {
73                Error::internal(err.to_string())
74            }
75            _ => Error::from(err),
76        }
77    }
78
79    // Verify delegated-token material and return the token subject.
80    //
81    // This is intentionally private: endpoint authorization must also bind the
82    // verified subject to the caller before dispatch.
83    fn verify_token_material(
84        token: &DelegatedToken,
85        max_cert_ttl_ns: u64,
86        max_token_ttl_ns: u64,
87        required_scopes: &[String],
88        now_ns: u64,
89    ) -> Result<Principal, Error> {
90        AuthOps::verify_token(VerifyDelegatedTokenRuntimeInput {
91            token,
92            caller: IcOps::msg_caller(),
93            max_cert_ttl_ns,
94            max_token_ttl_ns,
95            required_scopes,
96            now_ns,
97        })
98        .map(|verified| verified.subject)
99        .map_err(Self::map_auth_error)
100    }
101
102    /// Prepare a delegated token from the issuer-local active delegation proof.
103    pub fn prepare_delegated_token(
104        request: DelegatedTokenPrepareRequest,
105    ) -> Result<DelegatedTokenPrepareResponse, Error> {
106        let label = "delegated token prepare";
107        let metadata = Self::token_replay_metadata(request.metadata, label)?;
108        let caller = IcOps::msg_caller();
109        let command_kind = Self::token_prepare_replay_command_kind();
110        let actor = ReplayActor::direct_caller(caller);
111        let payload_hash = Self::token_prepare_replay_payload_hash(&command_kind, &actor, &request);
112        let now_ns = IcOps::now_nanos();
113        let expires_at_ns = now_ns.checked_add(metadata.ttl_ns).ok_or_else(|| {
114            Error::invalid("delegated token prepare replay metadata ttl_ns overflows nanoseconds")
115        })?;
116        let replay_input = ReplayReceiptReserveInput::new(
117            command_kind,
118            OperationId::from_bytes(metadata.request_id),
119            actor,
120            payload_hash,
121            now_ns,
122        )
123        .with_expires_at_ns(expires_at_ns);
124
125        let token = match reserve_or_replay_receipt(replay_input)
126            .map_err(Self::map_token_prepare_replay_store_error)?
127        {
128            ReplayReceiptDecision::Fresh(token) => token,
129            decision => return Self::map_token_prepare_replay_decision(decision),
130        };
131
132        let prepared = AuthOps::prepare_delegated_token_issuer_proof(
133            SignDelegatedTokenInput {
134                subject: request.subject,
135                audience: request.aud,
136                grants: request.grants,
137                ttl_ns: request.ttl_ns,
138                nonce: request.nonce,
139                ext: request.ext,
140            },
141            metadata.request_id,
142            caller,
143        )
144        .map_err(|err| {
145            abort_reserved_receipt(&token);
146            Self::map_auth_error(err)
147        })?;
148
149        let response = DelegatedTokenPrepareResponse {
150            claims: prepared.prepared.claims,
151            claims_hash: prepared.claims_hash,
152            retrieval_expires_at_ns: prepared.retrieval_expires_at_ns,
153        };
154
155        let response_bytes = match Self::encode_token_prepare_response(&response) {
156            Ok(response_bytes) => response_bytes,
157            Err(err) => {
158                mark_recovery_required(
159                    &token,
160                    RecoveryReason::ResponseCommitFailed,
161                    secs_to_ns(IcOps::now_secs()),
162                );
163                return Err(err);
164            }
165        };
166
167        commit_receipt_response(
168            &token,
169            Self::TOKEN_PREPARE_REPLAY_RESPONSE_SCHEMA_VERSION,
170            response_bytes,
171            secs_to_ns(IcOps::now_secs()),
172        );
173        Ok(response)
174    }
175
176    /// Retrieve a prepared delegated token with its issuer canister-signature proof.
177    pub fn get_delegated_token(request: DelegatedTokenGetRequest) -> Result<DelegatedToken, Error> {
178        AuthOps::get_delegated_token_issuer_proof(request.claims_hash, IcOps::msg_caller())
179            .map_err(Self::map_auth_error)
180    }
181
182    /// Install validated root-certified delegation material for issuer-local token issuance.
183    pub fn install_active_delegation_proof(
184        request: InstallActiveDelegationProofRequest,
185    ) -> Result<InstallActiveDelegationProofResponse, Error> {
186        let active_proof =
187            AuthOps::install_active_delegation_proof(request.proof, IcOps::msg_caller())
188                .map_err(Self::map_auth_error)?;
189
190        Ok(InstallActiveDelegationProofResponse { active_proof })
191    }
192
193    /// Prepare a root delegation proof from root over RPC.
194    pub async fn prepare_delegation_proof(
195        request: DelegationProofIssueRequest,
196    ) -> Result<DelegationProofPrepareResponse, Error> {
197        let request = metadata::with_delegation_request_metadata(request);
198        Self::prepare_delegation_proof_remote(request).await
199    }
200
201    /// Prepare a root-certified delegation proof from the local root update path.
202    pub fn prepare_delegation_proof_root(
203        request: DelegationProofIssueRequest,
204    ) -> Result<DelegationProofPrepareResponse, Error> {
205        EnvOps::require_root().map_err(Error::from)?;
206        let caller = IcOps::msg_caller();
207        Self::validate_delegation_request_caller(caller, request.issuer_pid)?;
208        let max_cert_ttl_ns = Self::delegated_token_max_ttl_ns()?;
209        let metadata = Self::delegation_replay_metadata(request.metadata)?;
210        let command_kind = Self::delegation_replay_command_kind();
211        let actor = ReplayActor::direct_caller(caller);
212        let payload_hash = Self::delegation_replay_payload_hash(&command_kind, &actor, &request);
213        let now_ns = IcOps::now_nanos();
214        let expires_at_ns = now_ns.checked_add(metadata.ttl_ns).ok_or_else(|| {
215            Error::invalid("delegation proof replay metadata ttl_ns overflows nanoseconds")
216        })?;
217        let replay_input = ReplayReceiptReserveInput::new(
218            command_kind,
219            OperationId::from_bytes(metadata.request_id),
220            actor,
221            payload_hash,
222            now_ns,
223        )
224        .with_expires_at_ns(expires_at_ns);
225
226        let token = match reserve_or_replay_receipt(replay_input)
227            .map_err(Self::map_delegation_replay_store_error)?
228        {
229            ReplayReceiptDecision::Fresh(token) => token,
230            decision => return Self::map_delegation_replay_decision(decision),
231        };
232
233        Self::prepare_fresh_delegation_proof(token, caller, request, max_cert_ttl_ns)
234    }
235
236    /// Retrieve a prepared self-contained delegation proof from the local root query path.
237    pub fn get_delegation_proof_root(
238        request: DelegationProofGetRequest,
239    ) -> Result<DelegationProof, Error> {
240        EnvOps::require_root().map_err(Error::from)?;
241        let caller = IcOps::msg_caller();
242        AuthOps::get_delegation_proof(caller, request.cert_hash).map_err(Self::map_auth_error)
243    }
244
245    /// Prepare a root-certified role attestation from the local root update path.
246    pub fn prepare_role_attestation_root(
247        request: RoleAttestationRequest,
248    ) -> Result<RoleAttestationPrepareResponse, Error> {
249        EnvOps::require_root().map_err(Error::from)?;
250        let caller = IcOps::msg_caller();
251        Self::validate_role_attestation_request(caller, &request)?;
252        let metadata = Self::role_attestation_replay_metadata(request.metadata)?;
253        let command_kind = Self::role_attestation_replay_command_kind();
254        let actor = ReplayActor::direct_caller(caller);
255        let payload_hash =
256            Self::role_attestation_replay_payload_hash(&command_kind, &actor, &request);
257        let now_ns = IcOps::now_nanos();
258        let expires_at_ns = now_ns.checked_add(metadata.ttl_ns).ok_or_else(|| {
259            Error::invalid("role attestation replay metadata ttl_ns overflows nanoseconds")
260        })?;
261        let replay_input = ReplayReceiptReserveInput::new(
262            command_kind,
263            OperationId::from_bytes(metadata.request_id),
264            actor,
265            payload_hash,
266            now_ns,
267        )
268        .with_expires_at_ns(expires_at_ns);
269
270        let token = match reserve_or_replay_receipt(replay_input)
271            .map_err(Self::map_role_attestation_replay_store_error)?
272        {
273            ReplayReceiptDecision::Fresh(token) => token,
274            decision => return Self::map_role_attestation_replay_decision(decision),
275        };
276
277        let prepared = match AuthOps::prepare_role_attestation(SignRoleAttestationInput {
278            operation_id: token.receipt().operation_id.into_bytes(),
279            subject: request.subject,
280            role: request.role,
281            subnet_id: request.subnet_id,
282            audience: request.audience,
283            ttl_ns: request.ttl_ns,
284            epoch: request.epoch,
285            issued_at_ns: now_ns,
286        }) {
287            Ok(prepared) => prepared,
288            Err(err) => {
289                abort_reserved_receipt(&token);
290                return Err(Self::map_auth_error(err));
291            }
292        };
293
294        let response = RoleAttestationPrepareResponse {
295            payload: prepared.payload,
296            payload_hash: prepared.payload_hash,
297            retrieval_expires_at_ns: prepared.retrieval_expires_at_ns,
298        };
299
300        let response_bytes = match Self::encode_role_attestation_prepare_response(&response) {
301            Ok(response_bytes) => response_bytes,
302            Err(err) => {
303                mark_recovery_required(
304                    &token,
305                    RecoveryReason::ResponseCommitFailed,
306                    secs_to_ns(IcOps::now_secs()),
307                );
308                return Err(err);
309            }
310        };
311
312        commit_receipt_response(
313            &token,
314            Self::ROLE_ATTESTATION_REPLAY_RESPONSE_SCHEMA_VERSION,
315            response_bytes,
316            secs_to_ns(IcOps::now_secs()),
317        );
318        Ok(response)
319    }
320
321    /// Retrieve a prepared role attestation with its root canister-signature proof.
322    pub fn get_role_attestation_root(
323        request: RoleAttestationGetRequest,
324    ) -> Result<SignedRoleAttestation, Error> {
325        EnvOps::require_root().map_err(Error::from)?;
326        AuthOps::get_role_attestation(IcOps::msg_caller(), request.payload_hash)
327            .map_err(Self::map_auth_error)
328    }
329
330    fn prepare_fresh_delegation_proof(
331        token: ReplayReceiptToken,
332        _caller: Principal,
333        request: DelegationProofIssueRequest,
334        max_cert_ttl_ns: u64,
335    ) -> Result<DelegationProofPrepareResponse, Error> {
336        let max_token_ttl_ns = request.cert_ttl_ns.min(max_cert_ttl_ns);
337        let prepared = match AuthOps::prepare_delegation_proof(SignDelegationProofInput {
338            operation_id: token.receipt().operation_id.into_bytes(),
339            audience: request.aud,
340            grants: request.grants,
341            issuer_pid: request.issuer_pid,
342            cert_ttl_ns: request.cert_ttl_ns,
343            max_token_ttl_ns,
344            max_cert_ttl_ns,
345            issued_at_ns: IcOps::now_nanos(),
346        }) {
347            Ok(prepared) => prepared,
348            Err(err) => {
349                abort_reserved_receipt(&token);
350                return Err(Self::map_auth_error(err));
351            }
352        };
353
354        let response = DelegationProofPrepareResponse {
355            cert: prepared.cert,
356            cert_hash: prepared.cert_hash,
357            retrieval_expires_at_ns: prepared.retrieval_expires_at_ns,
358        };
359
360        let response_bytes = match Self::encode_delegation_prepare_response(&response) {
361            Ok(response_bytes) => response_bytes,
362            Err(err) => {
363                mark_recovery_required(
364                    &token,
365                    RecoveryReason::ResponseCommitFailed,
366                    secs_to_ns(IcOps::now_secs()),
367                );
368                return Err(err);
369            }
370        };
371
372        commit_receipt_response(
373            &token,
374            Self::DELEGATION_REPLAY_RESPONSE_SCHEMA_VERSION,
375            response_bytes,
376            secs_to_ns(IcOps::now_secs()),
377        );
378        Ok(response)
379    }
380
381    /// Verify a role attestation locally from its embedded root proof.
382    pub async fn verify_role_attestation(
383        attestation: &SignedRoleAttestation,
384        min_accepted_epoch: u64,
385    ) -> Result<(), Error> {
386        crate::workflow::runtime::auth::RuntimeAuthWorkflow::verify_role_attestation(
387            attestation,
388            min_accepted_epoch,
389        )
390        .await
391        .map_err(Self::map_auth_error)
392    }
393
394    // Resolve the root-owned TTL ceiling from delegated-token config.
395    fn delegated_token_max_ttl_ns() -> Result<u64, Error> {
396        let cfg = ConfigOps::delegated_tokens_config().map_err(Error::from)?;
397        if !cfg.enabled {
398            return Err(Error::forbidden(Self::DELEGATED_TOKENS_DISABLED));
399        }
400
401        let max_ttl_secs = cfg
402            .max_ttl_secs
403            .unwrap_or(Self::MAX_DELEGATED_SESSION_TTL_SECS);
404        max_ttl_secs.checked_mul(1_000_000_000).ok_or_else(|| {
405            Error::invalid("auth.delegated_tokens.max_ttl_secs overflows nanoseconds")
406        })
407    }
408
409    fn validate_delegation_request_caller(
410        caller: Principal,
411        issuer_pid: Principal,
412    ) -> Result<(), Error> {
413        if caller == issuer_pid {
414            return Ok(());
415        }
416
417        Err(Error::forbidden(format!(
418            "delegation request caller {caller} must match issuer_pid {issuer_pid}"
419        )))
420    }
421
422    fn validate_role_attestation_request(
423        caller: Principal,
424        request: &RoleAttestationRequest,
425    ) -> Result<(), Error> {
426        if request.subject != caller {
427            return Err(Error::forbidden(format!(
428                "role attestation subject {} must match caller {}",
429                request.subject, caller
430            )));
431        }
432
433        let registered = SubnetRegistryOps::get(request.subject).ok_or_else(|| {
434            Error::forbidden(format!(
435                "role attestation subject {} is not registered",
436                request.subject
437            ))
438        })?;
439        if registered.role != request.role {
440            return Err(Error::forbidden(format!(
441                "role attestation role mismatch for subject {}: requested {}, registered {}",
442                request.subject, request.role, registered.role
443            )));
444        }
445
446        if let Some(requested_subnet) = request.subnet_id {
447            let local_subnet = EnvOps::subnet_pid().map_err(Error::from)?;
448            if requested_subnet != local_subnet {
449                return Err(Error::forbidden(format!(
450                    "role attestation subnet mismatch for subject {}: requested {}, local {}",
451                    request.subject, requested_subnet, local_subnet
452                )));
453            }
454        }
455
456        let max_ttl_ns = Self::role_attestation_max_ttl_ns()?;
457        if request.ttl_ns == 0 || request.ttl_ns > max_ttl_ns {
458            return Err(Error::invalid(format!(
459                "role attestation ttl_ns must satisfy 0 < ttl_ns <= {max_ttl_ns} (got {})",
460                request.ttl_ns
461            )));
462        }
463
464        Ok(())
465    }
466
467    fn role_attestation_max_ttl_ns() -> Result<u64, Error> {
468        let cfg = ConfigOps::role_attestation_config().map_err(Error::from)?;
469        cfg.max_ttl_secs.checked_mul(1_000_000_000).ok_or_else(|| {
470            Error::invalid("auth.role_attestation.max_ttl_secs overflows nanoseconds")
471        })
472    }
473
474    fn delegation_replay_metadata(
475        metadata: Option<AuthRequestMetadata>,
476    ) -> Result<AuthRequestMetadata, Error> {
477        let metadata = metadata.ok_or_else(Error::operation_id_required)?;
478        if metadata.ttl_ns == 0 {
479            return Err(Error::invalid(
480                "delegation proof replay metadata ttl_ns must be greater than zero",
481            ));
482        }
483        if metadata.ttl_ns > Self::MAX_DELEGATION_REPLAY_TTL_NS {
484            return Err(Error::invalid(format!(
485                "delegation proof replay metadata ttl_ns={} exceeds max {}",
486                metadata.ttl_ns,
487                Self::MAX_DELEGATION_REPLAY_TTL_NS
488            )));
489        }
490        Ok(metadata)
491    }
492
493    fn role_attestation_replay_metadata(
494        metadata: Option<crate::dto::rpc::RootRequestMetadata>,
495    ) -> Result<crate::dto::rpc::RootRequestMetadata, Error> {
496        let metadata = metadata.ok_or_else(Error::operation_id_required)?;
497        if metadata.ttl_ns == 0 {
498            return Err(Error::invalid(
499                "role attestation replay metadata ttl_ns must be greater than zero",
500            ));
501        }
502        if metadata.ttl_ns > Self::MAX_ROLE_ATTESTATION_REPLAY_TTL_NS {
503            return Err(Error::invalid(format!(
504                "role attestation replay metadata ttl_ns={} exceeds max {}",
505                metadata.ttl_ns,
506                Self::MAX_ROLE_ATTESTATION_REPLAY_TTL_NS
507            )));
508        }
509        Ok(metadata)
510    }
511
512    fn token_replay_metadata(
513        metadata: Option<AuthRequestMetadata>,
514        label: &str,
515    ) -> Result<AuthRequestMetadata, Error> {
516        let metadata = metadata.ok_or_else(Error::operation_id_required)?;
517        if metadata.ttl_ns == 0 {
518            return Err(Error::invalid(format!(
519                "{label} replay metadata ttl_ns must be greater than zero"
520            )));
521        }
522        if metadata.ttl_ns > Self::MAX_TOKEN_REPLAY_TTL_NS {
523            return Err(Error::invalid(format!(
524                "{label} replay metadata ttl_ns={} exceeds max {}",
525                metadata.ttl_ns,
526                Self::MAX_TOKEN_REPLAY_TTL_NS
527            )));
528        }
529        Ok(metadata)
530    }
531
532    fn delegation_replay_command_kind() -> CommandKind {
533        CommandKind::new(Self::DELEGATION_REPLAY_COMMAND_KIND)
534            .expect("delegation replay command kind is a valid static label")
535    }
536
537    fn role_attestation_replay_command_kind() -> CommandKind {
538        CommandKind::new(Self::ROLE_ATTESTATION_REPLAY_COMMAND_KIND)
539            .expect("role attestation replay command kind is a valid static label")
540    }
541
542    fn token_prepare_replay_command_kind() -> CommandKind {
543        CommandKind::new(Self::TOKEN_PREPARE_REPLAY_COMMAND_KIND)
544            .expect("delegated-token prepare replay command kind is a valid static label")
545    }
546
547    fn delegation_replay_payload_hash(
548        command_kind: &CommandKind,
549        actor: &ReplayActor,
550        request: &DelegationProofIssueRequest,
551    ) -> [u8; 32] {
552        let mut hasher = ReplayPayloadHasher::new(command_kind, actor);
553        hasher.hash_principal(&request.issuer_pid);
554        Self::hash_delegation_audience(&mut hasher, &request.aud);
555        Self::hash_delegated_role_grants(&mut hasher, &request.grants);
556        hasher.hash_u64(request.cert_ttl_ns);
557        hasher.finish()
558    }
559
560    fn role_attestation_replay_payload_hash(
561        command_kind: &CommandKind,
562        actor: &ReplayActor,
563        request: &RoleAttestationRequest,
564    ) -> [u8; 32] {
565        let mut hasher = ReplayPayloadHasher::new(command_kind, actor);
566        hasher.hash_principal(&request.subject);
567        hasher.hash_role(&request.role);
568        hasher.hash_bool(request.subnet_id.is_some());
569        if let Some(subnet_id) = request.subnet_id {
570            hasher.hash_principal(&subnet_id);
571        }
572        hasher.hash_principal(&request.audience);
573        hasher.hash_u64(request.ttl_ns);
574        hasher.hash_u64(request.epoch);
575        hasher.finish()
576    }
577
578    fn token_prepare_replay_payload_hash(
579        command_kind: &CommandKind,
580        actor: &ReplayActor,
581        request: &DelegatedTokenPrepareRequest,
582    ) -> [u8; 32] {
583        let mut hasher = ReplayPayloadHasher::new(command_kind, actor);
584        hasher.hash_principal(&request.subject);
585        Self::hash_delegation_audience(&mut hasher, &request.aud);
586        Self::hash_delegated_role_grants(&mut hasher, &request.grants);
587        hasher.hash_u64(request.ttl_ns);
588        hasher.hash_bytes(&request.nonce);
589        Self::hash_optional_bytes(&mut hasher, request.ext.as_deref());
590        hasher.finish()
591    }
592
593    fn hash_delegation_audience(hasher: &mut ReplayPayloadHasher, aud: &DelegationAudience) {
594        match aud {
595            DelegationAudience::Canister(canister) => {
596                hasher.hash_str("canister");
597                hasher.hash_principal(canister);
598            }
599            DelegationAudience::CanicSubnet(subnet) => {
600                hasher.hash_str("canic_subnet");
601                hasher.hash_principal(subnet);
602            }
603            DelegationAudience::Project(project) => {
604                hasher.hash_str("project");
605                hasher.hash_str(project);
606            }
607        }
608    }
609
610    fn hash_optional_bytes(hasher: &mut ReplayPayloadHasher, bytes: Option<&[u8]>) {
611        hasher.hash_bool(bytes.is_some());
612        if let Some(bytes) = bytes {
613            hasher.hash_bytes(bytes);
614        }
615    }
616
617    fn hash_delegated_role_grants(hasher: &mut ReplayPayloadHasher, grants: &[DelegatedRoleGrant]) {
618        hasher.hash_u64(grants.len() as u64);
619        for grant in grants {
620            hasher.hash_role(&grant.target);
621            Self::hash_string_vec(hasher, &grant.scopes);
622        }
623    }
624
625    fn hash_string_vec(hasher: &mut ReplayPayloadHasher, values: &[String]) {
626        hasher.hash_u64(values.len() as u64);
627        for value in values {
628            hasher.hash_str(value);
629        }
630    }
631
632    fn map_token_prepare_replay_decision(
633        decision: ReplayReceiptDecision,
634    ) -> Result<DelegatedTokenPrepareResponse, Error> {
635        match decision {
636            ReplayReceiptDecision::Fresh(_) => Err(Error::invariant(
637                "fresh delegated token replay decision escaped",
638            )),
639            ReplayReceiptDecision::ReturnCommitted(receipt) => {
640                Self::decode_token_prepare_response(&receipt)
641            }
642            ReplayReceiptDecision::OperationInProgress => Err(Error::conflict(
643                "delegated token prepare request is already in progress; retry later with the same request id",
644            )),
645            ReplayReceiptDecision::ActorMismatch => Err(Error::conflict(
646                "delegated token prepare request id was reused by a different caller",
647            )),
648            ReplayReceiptDecision::PayloadMismatch => Err(Error::conflict(
649                "delegated token prepare request id was reused with a different payload",
650            )),
651            ReplayReceiptDecision::Expired => Err(Error::conflict(
652                "delegated token prepare replay receipt expired; retry with a new request id",
653            )),
654            ReplayReceiptDecision::RecoveryRequired(reason) => Err(Error::conflict(format!(
655                "delegated token prepare request requires recovery before replay: {reason:?}"
656            ))),
657            ReplayReceiptDecision::TerminalFailed {
658                error_code,
659                error_bytes,
660                error_bytes_truncated,
661            } => Err(Error::conflict(format!(
662                "delegated token prepare request previously failed: {error_code:?}; error_bytes_len={}; truncated={error_bytes_truncated}",
663                error_bytes.len()
664            ))),
665            ReplayReceiptDecision::PendingActorQuotaExceeded { max_pending, .. } => {
666                Err(Error::exhausted(format!(
667                    "delegated token prepare pending replay receipt quota exceeded for caller; max_pending={max_pending}"
668                )))
669            }
670            ReplayReceiptDecision::PendingCommandQuotaExceeded { max_pending, .. } => {
671                Err(Error::exhausted(format!(
672                    "delegated token prepare pending replay receipt quota exceeded for command kind; max_pending={max_pending}"
673                )))
674            }
675        }
676    }
677
678    fn map_token_prepare_replay_store_error(err: ReplayReceiptStoreError) -> Error {
679        match err {
680            ReplayReceiptStoreError::ReceiptDecodeFailed(message) => Error::internal(format!(
681                "failed to decode delegated token prepare replay receipt: {message}"
682            )),
683        }
684    }
685
686    fn encode_token_prepare_response(
687        response: &DelegatedTokenPrepareResponse,
688    ) -> Result<Vec<u8>, Error> {
689        encode_one(response).map_err(|err| {
690            Error::internal(format!(
691                "failed to encode delegated token prepare replay response: {err}"
692            ))
693        })
694    }
695
696    fn decode_token_prepare_response(
697        receipt: &crate::ops::replay::model::ReplayReceipt,
698    ) -> Result<DelegatedTokenPrepareResponse, Error> {
699        let response_schema_version = receipt.response_schema_version.ok_or_else(|| {
700            Error::internal(
701                "delegated token prepare replay receipt is missing response schema version",
702            )
703        })?;
704        if response_schema_version != Self::TOKEN_PREPARE_REPLAY_RESPONSE_SCHEMA_VERSION {
705            return Err(Error::internal(format!(
706                "unsupported delegated token prepare replay response schema version {response_schema_version}"
707            )));
708        }
709        let response_bytes = receipt.response_bytes.as_deref().ok_or_else(|| {
710            Error::internal("delegated token prepare replay receipt is missing response bytes")
711        })?;
712        decode_one(response_bytes).map_err(|err| {
713            Error::internal(format!(
714                "failed to decode delegated token prepare replay response: {err}"
715            ))
716        })
717    }
718
719    fn map_role_attestation_replay_decision(
720        decision: ReplayReceiptDecision,
721    ) -> Result<RoleAttestationPrepareResponse, Error> {
722        match decision {
723            ReplayReceiptDecision::Fresh(_) => Err(Error::invariant(
724                "fresh role attestation replay decision escaped",
725            )),
726            ReplayReceiptDecision::ReturnCommitted(receipt) => {
727                Self::decode_role_attestation_prepare_response(&receipt)
728            }
729            ReplayReceiptDecision::OperationInProgress => Err(Error::conflict(
730                "role attestation prepare request is already in progress; retry later with the same request id",
731            )),
732            ReplayReceiptDecision::ActorMismatch => Err(Error::conflict(
733                "role attestation prepare request id was reused by a different caller",
734            )),
735            ReplayReceiptDecision::PayloadMismatch => Err(Error::conflict(
736                "role attestation prepare request id was reused with a different payload",
737            )),
738            ReplayReceiptDecision::Expired => Err(Error::conflict(
739                "role attestation prepare replay receipt expired; retry with a new request id",
740            )),
741            ReplayReceiptDecision::RecoveryRequired(reason) => Err(Error::conflict(format!(
742                "role attestation prepare request requires recovery before replay: {reason:?}"
743            ))),
744            ReplayReceiptDecision::TerminalFailed {
745                error_code,
746                error_bytes,
747                error_bytes_truncated,
748            } => Err(Error::conflict(format!(
749                "role attestation prepare request previously failed: {error_code:?}; error_bytes_len={}; truncated={error_bytes_truncated}",
750                error_bytes.len()
751            ))),
752            ReplayReceiptDecision::PendingActorQuotaExceeded { max_pending, .. } => {
753                Err(Error::exhausted(format!(
754                    "role attestation prepare pending replay receipt quota exceeded for caller; max_pending={max_pending}"
755                )))
756            }
757            ReplayReceiptDecision::PendingCommandQuotaExceeded { max_pending, .. } => {
758                Err(Error::exhausted(format!(
759                    "role attestation prepare pending replay receipt quota exceeded for command kind; max_pending={max_pending}"
760                )))
761            }
762        }
763    }
764
765    fn map_role_attestation_replay_store_error(err: ReplayReceiptStoreError) -> Error {
766        match err {
767            ReplayReceiptStoreError::ReceiptDecodeFailed(message) => Error::internal(format!(
768                "failed to decode role attestation replay receipt: {message}"
769            )),
770        }
771    }
772
773    fn encode_role_attestation_prepare_response(
774        response: &RoleAttestationPrepareResponse,
775    ) -> Result<Vec<u8>, Error> {
776        encode_one(response).map_err(|err| {
777            Error::internal(format!(
778                "failed to encode role attestation prepare replay response: {err}"
779            ))
780        })
781    }
782
783    fn decode_role_attestation_prepare_response(
784        receipt: &crate::ops::replay::model::ReplayReceipt,
785    ) -> Result<RoleAttestationPrepareResponse, Error> {
786        let response_schema_version = receipt.response_schema_version.ok_or_else(|| {
787            Error::internal(
788                "role attestation prepare replay receipt is missing response schema version",
789            )
790        })?;
791        if response_schema_version != Self::ROLE_ATTESTATION_REPLAY_RESPONSE_SCHEMA_VERSION {
792            return Err(Error::internal(format!(
793                "unsupported role attestation prepare replay response schema version {response_schema_version}"
794            )));
795        }
796        let response_bytes = receipt.response_bytes.as_deref().ok_or_else(|| {
797            Error::internal("role attestation prepare replay receipt is missing response bytes")
798        })?;
799        decode_one(response_bytes).map_err(|err| {
800            Error::internal(format!(
801                "failed to decode role attestation prepare replay response: {err}"
802            ))
803        })
804    }
805
806    fn map_delegation_replay_decision(
807        decision: ReplayReceiptDecision,
808    ) -> Result<DelegationProofPrepareResponse, Error> {
809        match decision {
810            ReplayReceiptDecision::Fresh(_) => {
811                Err(Error::invariant("fresh delegation replay decision escaped"))
812            }
813            ReplayReceiptDecision::ReturnCommitted(receipt) => {
814                Self::decode_delegation_prepare_response(&receipt)
815            }
816            ReplayReceiptDecision::OperationInProgress => Err(Error::conflict(
817                "delegation proof request is already in progress; retry later with the same request id",
818            )),
819            ReplayReceiptDecision::ActorMismatch => Err(Error::conflict(
820                "delegation proof request id was reused by a different caller",
821            )),
822            ReplayReceiptDecision::PayloadMismatch => Err(Error::conflict(
823                "delegation proof request id was reused with a different payload",
824            )),
825            ReplayReceiptDecision::Expired => Err(Error::conflict(
826                "delegation proof replay receipt expired; retry with a new request id",
827            )),
828            ReplayReceiptDecision::RecoveryRequired(reason) => Err(Error::conflict(format!(
829                "delegation proof request requires recovery before replay: {reason:?}"
830            ))),
831            ReplayReceiptDecision::TerminalFailed {
832                error_code,
833                error_bytes,
834                error_bytes_truncated,
835            } => Err(Error::conflict(format!(
836                "delegation proof request previously failed: {error_code:?}; error_bytes_len={}; truncated={error_bytes_truncated}",
837                error_bytes.len()
838            ))),
839            ReplayReceiptDecision::PendingActorQuotaExceeded { max_pending, .. } => {
840                Err(Error::exhausted(format!(
841                    "delegation proof pending replay receipt quota exceeded for caller; max_pending={max_pending}"
842                )))
843            }
844            ReplayReceiptDecision::PendingCommandQuotaExceeded { max_pending, .. } => {
845                Err(Error::exhausted(format!(
846                    "delegation proof pending replay receipt quota exceeded for command kind; max_pending={max_pending}"
847                )))
848            }
849        }
850    }
851
852    fn map_delegation_replay_store_error(err: ReplayReceiptStoreError) -> Error {
853        match err {
854            ReplayReceiptStoreError::ReceiptDecodeFailed(message) => Error::internal(format!(
855                "failed to decode delegation replay receipt: {message}"
856            )),
857        }
858    }
859
860    fn encode_delegation_prepare_response(
861        response: &DelegationProofPrepareResponse,
862    ) -> Result<Vec<u8>, Error> {
863        encode_one(response).map_err(|err| {
864            Error::internal(format!(
865                "failed to encode delegation proof prepare replay response: {err}"
866            ))
867        })
868    }
869
870    fn decode_delegation_prepare_response(
871        receipt: &crate::ops::replay::model::ReplayReceipt,
872    ) -> Result<DelegationProofPrepareResponse, Error> {
873        let response_schema_version = receipt.response_schema_version.ok_or_else(|| {
874            Error::internal("delegation replay receipt is missing response schema version")
875        })?;
876        if response_schema_version != Self::DELEGATION_REPLAY_RESPONSE_SCHEMA_VERSION {
877            return Err(Error::internal(format!(
878                "unsupported delegation replay response schema version {response_schema_version}"
879            )));
880        }
881        let response_bytes = receipt.response_bytes.as_deref().ok_or_else(|| {
882            Error::internal("delegation replay receipt is missing response bytes")
883        })?;
884        decode_one(response_bytes).map_err(|err| {
885            Error::internal(format!(
886                "failed to decode delegation proof prepare replay response: {err}"
887            ))
888        })
889    }
890}
891
892impl AuthApi {
893    // Route a delegation proof prepare request over RPC to root.
894    async fn prepare_delegation_proof_remote(
895        request: DelegationProofIssueRequest,
896    ) -> Result<DelegationProofPrepareResponse, Error> {
897        let root_pid = EnvOps::root_pid().map_err(Error::from)?;
898        RootAuthMaterialClient::new(root_pid)
899            .prepare_delegation_proof(request)
900            .await
901            .map_err(Self::map_auth_error)
902    }
903}
904
905#[cfg(test)]
906mod tests {
907    use super::AuthApi;
908    use crate::{
909        cdk::types::Principal,
910        dto::{
911            auth::{
912                AuthRequestMetadata, DelegatedRoleGrant, DelegatedTokenPrepareRequest,
913                DelegationAudience, DelegationProofIssueRequest,
914            },
915            error::ErrorCode,
916        },
917    };
918
919    fn p(id: u8) -> Principal {
920        Principal::from_slice(&[id; 29])
921    }
922
923    fn delegation_request(metadata_id: u8) -> DelegationProofIssueRequest {
924        DelegationProofIssueRequest {
925            metadata: Some(meta(metadata_id, 60_000_000_000)),
926            issuer_pid: p(2),
927            aud: DelegationAudience::Project("test".to_string()),
928            grants: vec![grant("project_instance", &["canic.verify"])],
929            cert_ttl_ns: 60_000_000_000,
930        }
931    }
932
933    fn grant(role: &str, scopes: &[&str]) -> DelegatedRoleGrant {
934        DelegatedRoleGrant {
935            target: crate::ids::CanisterRole::owned(role.to_string()),
936            scopes: scopes.iter().map(|scope| (*scope).to_string()).collect(),
937        }
938    }
939
940    fn meta(id: u8, ttl_ns: u64) -> AuthRequestMetadata {
941        AuthRequestMetadata {
942            request_id: [id; 32],
943            ttl_ns,
944        }
945    }
946
947    fn token_prepare_request(metadata_id: u8) -> DelegatedTokenPrepareRequest {
948        DelegatedTokenPrepareRequest {
949            metadata: Some(meta(metadata_id, 60_000_000_000)),
950            subject: p(8),
951            aud: DelegationAudience::Project("test".to_string()),
952            grants: vec![grant("project_instance", &["canic.verify"])],
953            ttl_ns: 30_000_000_000,
954            nonce: [9; 16],
955            ext: None,
956        }
957    }
958
959    #[test]
960    fn delegation_request_caller_must_match_requested_issuer() {
961        AuthApi::validate_delegation_request_caller(p(2), p(2)).expect("matching shard");
962
963        let err = AuthApi::validate_delegation_request_caller(p(1), p(2))
964            .expect_err("mismatched caller must fail");
965
966        assert_eq!(err.code, ErrorCode::Forbidden);
967    }
968
969    #[test]
970    fn delegation_replay_metadata_rejects_missing_or_invalid_ttl() {
971        let missing = AuthApi::delegation_replay_metadata(None).expect_err("metadata is required");
972        assert_eq!(missing.code, ErrorCode::OperationIdRequired);
973
974        let zero = AuthApi::delegation_replay_metadata(Some(AuthRequestMetadata {
975            request_id: [1; 32],
976            ttl_ns: 0,
977        }))
978        .expect_err("zero ttl is invalid");
979        assert_eq!(zero.code, ErrorCode::InvalidInput);
980
981        let too_large = AuthApi::delegation_replay_metadata(Some(AuthRequestMetadata {
982            request_id: [1; 32],
983            ttl_ns: AuthApi::MAX_DELEGATION_REPLAY_TTL_NS + 1,
984        }))
985        .expect_err("oversized ttl is invalid");
986        assert_eq!(too_large.code, ErrorCode::InvalidInput);
987    }
988
989    #[test]
990    fn delegation_replay_payload_hash_ignores_metadata() {
991        let command_kind = AuthApi::delegation_replay_command_kind();
992        let actor = crate::ops::replay::model::ReplayActor::direct_caller(p(2));
993        let a = delegation_request(1);
994        let b = delegation_request(9);
995
996        assert_eq!(
997            AuthApi::delegation_replay_payload_hash(&command_kind, &actor, &a),
998            AuthApi::delegation_replay_payload_hash(&command_kind, &actor, &b)
999        );
1000    }
1001
1002    #[test]
1003    fn delegated_token_replay_metadata_rejects_missing_or_invalid_ttl() {
1004        let missing =
1005            AuthApi::token_replay_metadata(None, "delegated token mint").expect_err("required");
1006        assert_eq!(missing.code, ErrorCode::OperationIdRequired);
1007
1008        let zero = AuthApi::token_replay_metadata(Some(meta(1, 0)), "delegated token mint")
1009            .expect_err("zero ttl is invalid");
1010        assert_eq!(zero.code, ErrorCode::InvalidInput);
1011
1012        let too_large = AuthApi::token_replay_metadata(
1013            Some(meta(1, AuthApi::MAX_TOKEN_REPLAY_TTL_NS + 1)),
1014            "delegated token mint",
1015        )
1016        .expect_err("oversized ttl is invalid");
1017        assert_eq!(too_large.code, ErrorCode::InvalidInput);
1018    }
1019
1020    #[test]
1021    fn delegation_replay_payload_hash_binds_authoritative_payload() {
1022        let command_kind = AuthApi::delegation_replay_command_kind();
1023        let actor = crate::ops::replay::model::ReplayActor::direct_caller(p(2));
1024        let a = delegation_request(1);
1025        let mut b = a.clone();
1026        b.cert_ttl_ns += 1;
1027
1028        assert_ne!(
1029            AuthApi::delegation_replay_payload_hash(&command_kind, &actor, &a),
1030            AuthApi::delegation_replay_payload_hash(&command_kind, &actor, &b)
1031        );
1032    }
1033
1034    #[test]
1035    fn delegated_token_prepare_payload_hash_ignores_metadata() {
1036        let command_kind = AuthApi::token_prepare_replay_command_kind();
1037        let actor = crate::ops::replay::model::ReplayActor::direct_caller(p(2));
1038        let a = token_prepare_request(1);
1039        let b = token_prepare_request(9);
1040
1041        assert_eq!(
1042            AuthApi::token_prepare_replay_payload_hash(&command_kind, &actor, &a),
1043            AuthApi::token_prepare_replay_payload_hash(&command_kind, &actor, &b)
1044        );
1045    }
1046
1047    #[test]
1048    fn delegated_token_prepare_payload_hash_binds_authoritative_payload() {
1049        let command_kind = AuthApi::token_prepare_replay_command_kind();
1050        let actor = crate::ops::replay::model::ReplayActor::direct_caller(p(2));
1051        let a = token_prepare_request(1);
1052        let mut b = a.clone();
1053        b.nonce = [10; 16];
1054
1055        assert_ne!(
1056            AuthApi::token_prepare_replay_payload_hash(&command_kind, &actor, &a),
1057            AuthApi::token_prepare_replay_payload_hash(&command_kind, &actor, &b)
1058        );
1059    }
1060
1061    #[test]
1062    fn delegated_token_prepare_payload_hash_binds_ext() {
1063        let command_kind = AuthApi::token_prepare_replay_command_kind();
1064        let actor = crate::ops::replay::model::ReplayActor::direct_caller(p(2));
1065        let a = token_prepare_request(1);
1066        let mut b = a.clone();
1067        b.ext = Some(b"app-context".to_vec());
1068
1069        assert_ne!(
1070            AuthApi::token_prepare_replay_payload_hash(&command_kind, &actor, &a),
1071            AuthApi::token_prepare_replay_payload_hash(&command_kind, &actor, &b)
1072        );
1073    }
1074}