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