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 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            SignDelegatedTokenInput {
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(SignRoleAttestationInput {
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(SignDelegationProofInput {
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 = SubnetRegistryOps::get(request.subject).ok_or_else(|| {
455            Error::forbidden(format!(
456                "role attestation subject {} is not registered",
457                request.subject
458            ))
459        })?;
460        if registered.role != request.role {
461            return Err(Error::forbidden(format!(
462                "role attestation role mismatch for subject {}: requested {}, registered {}",
463                request.subject, request.role, registered.role
464            )));
465        }
466
467        if let Some(requested_subnet) = request.subnet_id {
468            let local_subnet = EnvOps::subnet_pid().map_err(Error::from)?;
469            if requested_subnet != local_subnet {
470                return Err(Error::forbidden(format!(
471                    "role attestation subnet mismatch for subject {}: requested {}, local {}",
472                    request.subject, requested_subnet, local_subnet
473                )));
474            }
475        }
476
477        let max_ttl_ns = Self::role_attestation_max_ttl_ns()?;
478        if request.ttl_ns == 0 || request.ttl_ns > max_ttl_ns {
479            return Err(Error::invalid(format!(
480                "role attestation ttl_ns must satisfy 0 < ttl_ns <= {max_ttl_ns} (got {})",
481                request.ttl_ns
482            )));
483        }
484
485        Ok(())
486    }
487
488    fn role_attestation_max_ttl_ns() -> Result<u64, Error> {
489        let cfg = ConfigOps::role_attestation_config().map_err(Error::from)?;
490        cfg.max_ttl_secs.checked_mul(1_000_000_000).ok_or_else(|| {
491            Error::invalid("auth.role_attestation.max_ttl_secs overflows nanoseconds")
492        })
493    }
494
495    fn delegation_replay_metadata(
496        metadata: Option<AuthRequestMetadata>,
497    ) -> Result<AuthRequestMetadata, Error> {
498        let metadata = metadata.ok_or_else(Error::operation_id_required)?;
499        if metadata.ttl_ns == 0 {
500            return Err(Error::invalid(
501                "delegation proof replay metadata ttl_ns must be greater than zero",
502            ));
503        }
504        if metadata.ttl_ns > Self::MAX_DELEGATION_REPLAY_TTL_NS {
505            return Err(Error::invalid(format!(
506                "delegation proof replay metadata ttl_ns={} exceeds max {}",
507                metadata.ttl_ns,
508                Self::MAX_DELEGATION_REPLAY_TTL_NS
509            )));
510        }
511        Ok(metadata)
512    }
513
514    fn role_attestation_replay_metadata(
515        metadata: Option<crate::dto::rpc::RootRequestMetadata>,
516    ) -> Result<crate::dto::rpc::RootRequestMetadata, Error> {
517        let metadata = metadata.ok_or_else(Error::operation_id_required)?;
518        if metadata.ttl_ns == 0 {
519            return Err(Error::invalid(
520                "role attestation replay metadata ttl_ns must be greater than zero",
521            ));
522        }
523        if metadata.ttl_ns > Self::MAX_ROLE_ATTESTATION_REPLAY_TTL_NS {
524            return Err(Error::invalid(format!(
525                "role attestation replay metadata ttl_ns={} exceeds max {}",
526                metadata.ttl_ns,
527                Self::MAX_ROLE_ATTESTATION_REPLAY_TTL_NS
528            )));
529        }
530        Ok(metadata)
531    }
532
533    fn token_replay_metadata(
534        metadata: Option<AuthRequestMetadata>,
535        label: &str,
536    ) -> Result<AuthRequestMetadata, Error> {
537        let metadata = metadata.ok_or_else(Error::operation_id_required)?;
538        if metadata.ttl_ns == 0 {
539            return Err(Error::invalid(format!(
540                "{label} replay metadata ttl_ns must be greater than zero"
541            )));
542        }
543        if metadata.ttl_ns > Self::MAX_TOKEN_REPLAY_TTL_NS {
544            return Err(Error::invalid(format!(
545                "{label} replay metadata ttl_ns={} exceeds max {}",
546                metadata.ttl_ns,
547                Self::MAX_TOKEN_REPLAY_TTL_NS
548            )));
549        }
550        Ok(metadata)
551    }
552
553    fn delegation_replay_command_kind() -> CommandKind {
554        CommandKind::new(Self::DELEGATION_REPLAY_COMMAND_KIND)
555            .expect("delegation replay command kind is a valid static label")
556    }
557
558    fn role_attestation_replay_command_kind() -> CommandKind {
559        CommandKind::new(Self::ROLE_ATTESTATION_REPLAY_COMMAND_KIND)
560            .expect("role attestation replay command kind is a valid static label")
561    }
562
563    fn token_prepare_replay_command_kind() -> CommandKind {
564        CommandKind::new(Self::TOKEN_PREPARE_REPLAY_COMMAND_KIND)
565            .expect("delegated-token prepare replay command kind is a valid static label")
566    }
567
568    fn delegation_replay_payload_hash(
569        command_kind: &CommandKind,
570        actor: &ReplayActor,
571        request: &DelegationProofIssueRequest,
572    ) -> [u8; 32] {
573        let mut hasher = ReplayPayloadHasher::new(command_kind, actor);
574        hasher.hash_principal(&request.issuer_pid);
575        Self::hash_delegation_audience(&mut hasher, &request.aud);
576        Self::hash_delegated_role_grants(&mut hasher, &request.grants);
577        hasher.hash_u64(request.cert_ttl_ns);
578        hasher.finish()
579    }
580
581    fn role_attestation_replay_payload_hash(
582        command_kind: &CommandKind,
583        actor: &ReplayActor,
584        request: &RoleAttestationRequest,
585    ) -> [u8; 32] {
586        let mut hasher = ReplayPayloadHasher::new(command_kind, actor);
587        hasher.hash_principal(&request.subject);
588        hasher.hash_role(&request.role);
589        hasher.hash_bool(request.subnet_id.is_some());
590        if let Some(subnet_id) = request.subnet_id {
591            hasher.hash_principal(&subnet_id);
592        }
593        hasher.hash_principal(&request.audience);
594        hasher.hash_u64(request.ttl_ns);
595        hasher.hash_u64(request.epoch);
596        hasher.finish()
597    }
598
599    fn token_prepare_replay_payload_hash(
600        command_kind: &CommandKind,
601        actor: &ReplayActor,
602        request: &DelegatedTokenPrepareRequest,
603    ) -> [u8; 32] {
604        let mut hasher = ReplayPayloadHasher::new(command_kind, actor);
605        hasher.hash_principal(&request.subject);
606        Self::hash_delegation_audience(&mut hasher, &request.aud);
607        Self::hash_delegated_role_grants(&mut hasher, &request.grants);
608        hasher.hash_u64(request.ttl_ns);
609        Self::hash_optional_bytes(&mut hasher, request.ext.as_deref());
610        hasher.finish()
611    }
612
613    fn hash_delegation_audience(hasher: &mut ReplayPayloadHasher, aud: &DelegationAudience) {
614        match aud {
615            DelegationAudience::Canister(canister) => {
616                hasher.hash_str("canister");
617                hasher.hash_principal(canister);
618            }
619            DelegationAudience::CanicSubnet(subnet) => {
620                hasher.hash_str("canic_subnet");
621                hasher.hash_principal(subnet);
622            }
623            DelegationAudience::Project(project) => {
624                hasher.hash_str("project");
625                hasher.hash_str(project);
626            }
627        }
628    }
629
630    fn hash_optional_bytes(hasher: &mut ReplayPayloadHasher, bytes: Option<&[u8]>) {
631        hasher.hash_bool(bytes.is_some());
632        if let Some(bytes) = bytes {
633            hasher.hash_bytes(bytes);
634        }
635    }
636
637    fn hash_delegated_role_grants(hasher: &mut ReplayPayloadHasher, grants: &[DelegatedRoleGrant]) {
638        hasher.hash_u64(grants.len() as u64);
639        for grant in grants {
640            hasher.hash_role(&grant.target);
641            Self::hash_string_vec(hasher, &grant.scopes);
642        }
643    }
644
645    fn hash_string_vec(hasher: &mut ReplayPayloadHasher, values: &[String]) {
646        hasher.hash_u64(values.len() as u64);
647        for value in values {
648            hasher.hash_str(value);
649        }
650    }
651
652    fn map_token_prepare_replay_decision(
653        decision: ReplayReceiptDecision,
654    ) -> Result<DelegatedTokenPrepareResponse, Error> {
655        match decision {
656            ReplayReceiptDecision::Fresh(_) => Err(Error::invariant(
657                "fresh delegated token replay decision escaped",
658            )),
659            ReplayReceiptDecision::ReturnCommitted(receipt) => {
660                Self::decode_token_prepare_response(&receipt)
661            }
662            ReplayReceiptDecision::OperationInProgress => Err(Error::conflict(
663                "delegated token prepare request is already in progress; retry later with the same request id",
664            )),
665            ReplayReceiptDecision::ActorMismatch => Err(Error::conflict(
666                "delegated token prepare request id was reused by a different caller",
667            )),
668            ReplayReceiptDecision::PayloadMismatch => Err(Error::conflict(
669                "delegated token prepare request id was reused with a different payload",
670            )),
671            ReplayReceiptDecision::Expired => Err(Error::conflict(
672                "delegated token prepare replay receipt expired; retry with a new request id",
673            )),
674            ReplayReceiptDecision::RecoveryRequired(reason) => Err(Error::conflict(format!(
675                "delegated token prepare request requires recovery before replay: {reason:?}"
676            ))),
677            ReplayReceiptDecision::TerminalFailed {
678                error_code,
679                error_bytes,
680                error_bytes_truncated,
681            } => Err(Error::conflict(format!(
682                "delegated token prepare request previously failed: {error_code:?}; error_bytes_len={}; truncated={error_bytes_truncated}",
683                error_bytes.len()
684            ))),
685            ReplayReceiptDecision::PendingActorQuotaExceeded { max_pending, .. } => {
686                Err(Error::exhausted(format!(
687                    "delegated token prepare pending replay receipt quota exceeded for caller; max_pending={max_pending}"
688                )))
689            }
690            ReplayReceiptDecision::PendingCommandQuotaExceeded { max_pending, .. } => {
691                Err(Error::exhausted(format!(
692                    "delegated token prepare pending replay receipt quota exceeded for command kind; max_pending={max_pending}"
693                )))
694            }
695        }
696    }
697
698    fn map_token_prepare_replay_store_error(err: ReplayReceiptStoreError) -> Error {
699        match err {
700            ReplayReceiptStoreError::ReceiptDecodeFailed(message) => Error::internal(format!(
701                "failed to decode delegated token prepare replay receipt: {message}"
702            )),
703        }
704    }
705
706    fn encode_token_prepare_response(
707        response: &DelegatedTokenPrepareResponse,
708    ) -> Result<Vec<u8>, Error> {
709        encode_one(response).map_err(|err| {
710            Error::internal(format!(
711                "failed to encode delegated token prepare replay response: {err}"
712            ))
713        })
714    }
715
716    fn decode_token_prepare_response(
717        receipt: &crate::ops::replay::model::ReplayReceipt,
718    ) -> Result<DelegatedTokenPrepareResponse, Error> {
719        let response_schema_version = receipt.response_schema_version.ok_or_else(|| {
720            Error::internal(
721                "delegated token prepare replay receipt is missing response schema version",
722            )
723        })?;
724        if response_schema_version != Self::TOKEN_PREPARE_REPLAY_RESPONSE_SCHEMA_VERSION {
725            return Err(Error::internal(format!(
726                "unsupported delegated token prepare replay response schema version {response_schema_version}"
727            )));
728        }
729        let response_bytes = receipt.response_bytes.as_deref().ok_or_else(|| {
730            Error::internal("delegated token prepare replay receipt is missing response bytes")
731        })?;
732        decode_one(response_bytes).map_err(|err| {
733            Error::internal(format!(
734                "failed to decode delegated token prepare replay response: {err}"
735            ))
736        })
737    }
738
739    fn map_role_attestation_replay_decision(
740        decision: ReplayReceiptDecision,
741    ) -> Result<RoleAttestationPrepareResponse, Error> {
742        match decision {
743            ReplayReceiptDecision::Fresh(_) => Err(Error::invariant(
744                "fresh role attestation replay decision escaped",
745            )),
746            ReplayReceiptDecision::ReturnCommitted(receipt) => {
747                Self::decode_role_attestation_prepare_response(&receipt)
748            }
749            ReplayReceiptDecision::OperationInProgress => Err(Error::conflict(
750                "role attestation prepare request is already in progress; retry later with the same request id",
751            )),
752            ReplayReceiptDecision::ActorMismatch => Err(Error::conflict(
753                "role attestation prepare request id was reused by a different caller",
754            )),
755            ReplayReceiptDecision::PayloadMismatch => Err(Error::conflict(
756                "role attestation prepare request id was reused with a different payload",
757            )),
758            ReplayReceiptDecision::Expired => Err(Error::conflict(
759                "role attestation prepare replay receipt expired; retry with a new request id",
760            )),
761            ReplayReceiptDecision::RecoveryRequired(reason) => Err(Error::conflict(format!(
762                "role attestation prepare request requires recovery before replay: {reason:?}"
763            ))),
764            ReplayReceiptDecision::TerminalFailed {
765                error_code,
766                error_bytes,
767                error_bytes_truncated,
768            } => Err(Error::conflict(format!(
769                "role attestation prepare request previously failed: {error_code:?}; error_bytes_len={}; truncated={error_bytes_truncated}",
770                error_bytes.len()
771            ))),
772            ReplayReceiptDecision::PendingActorQuotaExceeded { max_pending, .. } => {
773                Err(Error::exhausted(format!(
774                    "role attestation prepare pending replay receipt quota exceeded for caller; max_pending={max_pending}"
775                )))
776            }
777            ReplayReceiptDecision::PendingCommandQuotaExceeded { max_pending, .. } => {
778                Err(Error::exhausted(format!(
779                    "role attestation prepare pending replay receipt quota exceeded for command kind; max_pending={max_pending}"
780                )))
781            }
782        }
783    }
784
785    fn map_role_attestation_replay_store_error(err: ReplayReceiptStoreError) -> Error {
786        match err {
787            ReplayReceiptStoreError::ReceiptDecodeFailed(message) => Error::internal(format!(
788                "failed to decode role attestation replay receipt: {message}"
789            )),
790        }
791    }
792
793    fn encode_role_attestation_prepare_response(
794        response: &RoleAttestationPrepareResponse,
795    ) -> Result<Vec<u8>, Error> {
796        encode_one(response).map_err(|err| {
797            Error::internal(format!(
798                "failed to encode role attestation prepare replay response: {err}"
799            ))
800        })
801    }
802
803    fn decode_role_attestation_prepare_response(
804        receipt: &crate::ops::replay::model::ReplayReceipt,
805    ) -> Result<RoleAttestationPrepareResponse, Error> {
806        let response_schema_version = receipt.response_schema_version.ok_or_else(|| {
807            Error::internal(
808                "role attestation prepare replay receipt is missing response schema version",
809            )
810        })?;
811        if response_schema_version != Self::ROLE_ATTESTATION_REPLAY_RESPONSE_SCHEMA_VERSION {
812            return Err(Error::internal(format!(
813                "unsupported role attestation prepare replay response schema version {response_schema_version}"
814            )));
815        }
816        let response_bytes = receipt.response_bytes.as_deref().ok_or_else(|| {
817            Error::internal("role attestation prepare replay receipt is missing response bytes")
818        })?;
819        decode_one(response_bytes).map_err(|err| {
820            Error::internal(format!(
821                "failed to decode role attestation prepare replay response: {err}"
822            ))
823        })
824    }
825
826    fn map_delegation_replay_decision(
827        decision: ReplayReceiptDecision,
828    ) -> Result<DelegationProofPrepareResponse, Error> {
829        match decision {
830            ReplayReceiptDecision::Fresh(_) => {
831                Err(Error::invariant("fresh delegation replay decision escaped"))
832            }
833            ReplayReceiptDecision::ReturnCommitted(receipt) => {
834                Self::decode_delegation_prepare_response(&receipt)
835            }
836            ReplayReceiptDecision::OperationInProgress => Err(Error::conflict(
837                "delegation proof request is already in progress; retry later with the same request id",
838            )),
839            ReplayReceiptDecision::ActorMismatch => Err(Error::conflict(
840                "delegation proof request id was reused by a different caller",
841            )),
842            ReplayReceiptDecision::PayloadMismatch => Err(Error::conflict(
843                "delegation proof request id was reused with a different payload",
844            )),
845            ReplayReceiptDecision::Expired => Err(Error::conflict(
846                "delegation proof replay receipt expired; retry with a new request id",
847            )),
848            ReplayReceiptDecision::RecoveryRequired(reason) => Err(Error::conflict(format!(
849                "delegation proof request requires recovery before replay: {reason:?}"
850            ))),
851            ReplayReceiptDecision::TerminalFailed {
852                error_code,
853                error_bytes,
854                error_bytes_truncated,
855            } => Err(Error::conflict(format!(
856                "delegation proof request previously failed: {error_code:?}; error_bytes_len={}; truncated={error_bytes_truncated}",
857                error_bytes.len()
858            ))),
859            ReplayReceiptDecision::PendingActorQuotaExceeded { max_pending, .. } => {
860                Err(Error::exhausted(format!(
861                    "delegation proof pending replay receipt quota exceeded for caller; max_pending={max_pending}"
862                )))
863            }
864            ReplayReceiptDecision::PendingCommandQuotaExceeded { max_pending, .. } => {
865                Err(Error::exhausted(format!(
866                    "delegation proof pending replay receipt quota exceeded for command kind; max_pending={max_pending}"
867                )))
868            }
869        }
870    }
871
872    fn map_delegation_replay_store_error(err: ReplayReceiptStoreError) -> Error {
873        match err {
874            ReplayReceiptStoreError::ReceiptDecodeFailed(message) => Error::internal(format!(
875                "failed to decode delegation replay receipt: {message}"
876            )),
877        }
878    }
879
880    fn encode_delegation_prepare_response(
881        response: &DelegationProofPrepareResponse,
882    ) -> Result<Vec<u8>, Error> {
883        encode_one(response).map_err(|err| {
884            Error::internal(format!(
885                "failed to encode delegation proof prepare replay response: {err}"
886            ))
887        })
888    }
889
890    fn decode_delegation_prepare_response(
891        receipt: &crate::ops::replay::model::ReplayReceipt,
892    ) -> Result<DelegationProofPrepareResponse, Error> {
893        let response_schema_version = receipt.response_schema_version.ok_or_else(|| {
894            Error::internal("delegation replay receipt is missing response schema version")
895        })?;
896        if response_schema_version != Self::DELEGATION_REPLAY_RESPONSE_SCHEMA_VERSION {
897            return Err(Error::internal(format!(
898                "unsupported delegation replay response schema version {response_schema_version}"
899            )));
900        }
901        let response_bytes = receipt.response_bytes.as_deref().ok_or_else(|| {
902            Error::internal("delegation replay receipt is missing response bytes")
903        })?;
904        decode_one(response_bytes).map_err(|err| {
905            Error::internal(format!(
906                "failed to decode delegation proof prepare replay response: {err}"
907            ))
908        })
909    }
910}
911
912impl AuthApi {
913    // Route a delegation proof prepare request over RPC to root.
914    async fn prepare_delegation_proof_remote(
915        request: DelegationProofIssueRequest,
916    ) -> Result<DelegationProofPrepareResponse, Error> {
917        let root_pid = EnvOps::root_pid().map_err(Error::from)?;
918        RootAuthMaterialClient::new(root_pid)
919            .prepare_delegation_proof(request)
920            .await
921            .map_err(Self::map_auth_error)
922    }
923}
924
925#[cfg(test)]
926mod tests {
927    use super::AuthApi;
928    use crate::{
929        cdk::types::Principal,
930        dto::{
931            auth::{
932                AuthRequestMetadata, DelegatedRoleGrant, DelegatedTokenPrepareRequest,
933                DelegationAudience, DelegationProofIssueRequest,
934            },
935            error::ErrorCode,
936        },
937    };
938
939    fn p(id: u8) -> Principal {
940        Principal::from_slice(&[id; 29])
941    }
942
943    fn delegation_request(metadata_id: u8) -> DelegationProofIssueRequest {
944        DelegationProofIssueRequest {
945            metadata: Some(meta(metadata_id, 60_000_000_000)),
946            issuer_pid: p(2),
947            aud: DelegationAudience::Project("test".to_string()),
948            grants: vec![grant("project_instance", &["canic.verify"])],
949            cert_ttl_ns: 60_000_000_000,
950        }
951    }
952
953    fn grant(role: &str, scopes: &[&str]) -> DelegatedRoleGrant {
954        DelegatedRoleGrant {
955            target: crate::ids::CanisterRole::owned(role.to_string()),
956            scopes: scopes.iter().map(|scope| (*scope).to_string()).collect(),
957        }
958    }
959
960    fn meta(id: u8, ttl_ns: u64) -> AuthRequestMetadata {
961        AuthRequestMetadata {
962            request_id: [id; 32],
963            ttl_ns,
964        }
965    }
966
967    fn token_prepare_request(metadata_id: u8) -> DelegatedTokenPrepareRequest {
968        DelegatedTokenPrepareRequest {
969            metadata: Some(meta(metadata_id, 60_000_000_000)),
970            subject: p(8),
971            aud: DelegationAudience::Project("test".to_string()),
972            grants: vec![grant("project_instance", &["canic.verify"])],
973            ttl_ns: 30_000_000_000,
974            ext: None,
975        }
976    }
977
978    #[test]
979    fn delegation_request_caller_must_match_requested_issuer() {
980        AuthApi::validate_delegation_request_caller(p(2), p(2)).expect("matching issuer");
981
982        let err = AuthApi::validate_delegation_request_caller(p(1), p(2))
983            .expect_err("mismatched caller must fail");
984
985        assert_eq!(err.code, ErrorCode::Forbidden);
986    }
987
988    #[test]
989    fn delegation_replay_metadata_rejects_missing_or_invalid_ttl() {
990        let missing = AuthApi::delegation_replay_metadata(None).expect_err("metadata is required");
991        assert_eq!(missing.code, ErrorCode::OperationIdRequired);
992
993        let zero = AuthApi::delegation_replay_metadata(Some(AuthRequestMetadata {
994            request_id: [1; 32],
995            ttl_ns: 0,
996        }))
997        .expect_err("zero ttl is invalid");
998        assert_eq!(zero.code, ErrorCode::InvalidInput);
999
1000        let too_large = AuthApi::delegation_replay_metadata(Some(AuthRequestMetadata {
1001            request_id: [1; 32],
1002            ttl_ns: AuthApi::MAX_DELEGATION_REPLAY_TTL_NS + 1,
1003        }))
1004        .expect_err("oversized ttl is invalid");
1005        assert_eq!(too_large.code, ErrorCode::InvalidInput);
1006    }
1007
1008    #[test]
1009    fn delegation_replay_payload_hash_ignores_metadata() {
1010        let command_kind = AuthApi::delegation_replay_command_kind();
1011        let actor = crate::ops::replay::model::ReplayActor::direct_caller(p(2));
1012        let a = delegation_request(1);
1013        let b = delegation_request(9);
1014
1015        assert_eq!(
1016            AuthApi::delegation_replay_payload_hash(&command_kind, &actor, &a),
1017            AuthApi::delegation_replay_payload_hash(&command_kind, &actor, &b)
1018        );
1019    }
1020
1021    #[test]
1022    fn delegated_token_replay_metadata_rejects_missing_or_invalid_ttl() {
1023        let missing =
1024            AuthApi::token_replay_metadata(None, "delegated token mint").expect_err("required");
1025        assert_eq!(missing.code, ErrorCode::OperationIdRequired);
1026
1027        let zero = AuthApi::token_replay_metadata(Some(meta(1, 0)), "delegated token mint")
1028            .expect_err("zero ttl is invalid");
1029        assert_eq!(zero.code, ErrorCode::InvalidInput);
1030
1031        let too_large = AuthApi::token_replay_metadata(
1032            Some(meta(1, AuthApi::MAX_TOKEN_REPLAY_TTL_NS + 1)),
1033            "delegated token mint",
1034        )
1035        .expect_err("oversized ttl is invalid");
1036        assert_eq!(too_large.code, ErrorCode::InvalidInput);
1037    }
1038
1039    #[test]
1040    fn delegation_replay_payload_hash_binds_authoritative_payload() {
1041        let command_kind = AuthApi::delegation_replay_command_kind();
1042        let actor = crate::ops::replay::model::ReplayActor::direct_caller(p(2));
1043        let a = delegation_request(1);
1044        let mut b = a.clone();
1045        b.cert_ttl_ns += 1;
1046
1047        assert_ne!(
1048            AuthApi::delegation_replay_payload_hash(&command_kind, &actor, &a),
1049            AuthApi::delegation_replay_payload_hash(&command_kind, &actor, &b)
1050        );
1051    }
1052
1053    #[test]
1054    fn delegated_token_prepare_payload_hash_ignores_metadata() {
1055        let command_kind = AuthApi::token_prepare_replay_command_kind();
1056        let actor = crate::ops::replay::model::ReplayActor::direct_caller(p(2));
1057        let a = token_prepare_request(1);
1058        let b = token_prepare_request(9);
1059
1060        assert_eq!(
1061            AuthApi::token_prepare_replay_payload_hash(&command_kind, &actor, &a),
1062            AuthApi::token_prepare_replay_payload_hash(&command_kind, &actor, &b)
1063        );
1064    }
1065
1066    #[test]
1067    fn delegated_token_prepare_payload_hash_binds_authoritative_payload() {
1068        let command_kind = AuthApi::token_prepare_replay_command_kind();
1069        let actor = crate::ops::replay::model::ReplayActor::direct_caller(p(2));
1070        let a = token_prepare_request(1);
1071        let mut b = a.clone();
1072        b.ttl_ns += 1;
1073
1074        assert_ne!(
1075            AuthApi::token_prepare_replay_payload_hash(&command_kind, &actor, &a),
1076            AuthApi::token_prepare_replay_payload_hash(&command_kind, &actor, &b)
1077        );
1078    }
1079
1080    #[test]
1081    fn delegated_token_prepare_payload_hash_binds_ext() {
1082        let command_kind = AuthApi::token_prepare_replay_command_kind();
1083        let actor = crate::ops::replay::model::ReplayActor::direct_caller(p(2));
1084        let a = token_prepare_request(1);
1085        let mut b = a.clone();
1086        b.ext = Some(b"app-context".to_vec());
1087
1088        assert_ne!(
1089            AuthApi::token_prepare_replay_payload_hash(&command_kind, &actor, &a),
1090            AuthApi::token_prepare_replay_payload_hash(&command_kind, &actor, &b)
1091        );
1092    }
1093}