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