Skip to main content

canic_core/api/auth/
mod.rs

1use crate::{
2    access::auth::validate_delegated_session_subject,
3    cdk::types::Principal,
4    dto::{
5        auth::{
6            AttestationKeySet, DelegatedToken, DelegatedTokenClaims, DelegationCert,
7            DelegationProof, DelegationProvisionResponse, DelegationProvisionStatus,
8            DelegationProvisionTargetKind, DelegationRequest, RoleAttestationRequest,
9            SignedRoleAttestation,
10        },
11        error::{Error, ErrorCode},
12        rpc::{Request as RootRequest, Response as RootCapabilityResponse},
13    },
14    error::InternalErrorClass,
15    log,
16    log::Topic,
17    ops::{
18        auth::DelegatedTokenOps,
19        config::ConfigOps,
20        ic::IcOps,
21        rpc::RpcOps,
22        runtime::env::EnvOps,
23        runtime::metrics::auth::{
24            record_attestation_refresh_failed, record_delegation_provision_complete,
25            record_delegation_verifier_target_count, record_delegation_verifier_target_failed,
26            record_delegation_verifier_target_missing, record_session_bootstrap_rejected_disabled,
27            record_session_bootstrap_rejected_replay_conflict,
28            record_session_bootstrap_rejected_replay_reused,
29            record_session_bootstrap_rejected_subject_mismatch,
30            record_session_bootstrap_rejected_subject_rejected,
31            record_session_bootstrap_rejected_token_invalid,
32            record_session_bootstrap_rejected_ttl_invalid,
33            record_session_bootstrap_rejected_wallet_caller_rejected,
34            record_session_bootstrap_replay_idempotent, record_session_cleared,
35            record_session_created, record_session_pruned, record_session_replaced,
36            record_signer_issue_without_proof,
37        },
38        storage::{
39            auth::{DelegatedSession, DelegatedSessionBootstrapBinding, DelegationStateOps},
40            directory::subnet::SubnetDirectoryOps,
41            registry::subnet::SubnetRegistryOps,
42        },
43    },
44    protocol,
45    workflow::rpc::request::handler::RootResponseWorkflow,
46};
47use sha2::{Digest, Sha256};
48
49mod metadata;
50mod verify_flow;
51
52///
53/// DelegationApi
54///
55/// Requires auth.delegated_tokens.enabled = true in config.
56///
57
58pub struct DelegationApi;
59
60impl DelegationApi {
61    const DELEGATED_TOKENS_DISABLED: &str =
62        "delegated token auth disabled; set auth.delegated_tokens.enabled=true in canic.toml";
63    const MAX_DELEGATED_SESSION_TTL_SECS: u64 = 24 * 60 * 60;
64    const SESSION_BOOTSTRAP_TOKEN_FINGERPRINT_DOMAIN: &[u8] =
65        b"canic-session-bootstrap-token-fingerprint:v1";
66
67    fn map_delegation_error(err: crate::InternalError) -> Error {
68        match err.class() {
69            InternalErrorClass::Infra | InternalErrorClass::Ops | InternalErrorClass::Workflow => {
70                Error::internal(err.to_string())
71            }
72            _ => Error::from(err),
73        }
74    }
75
76    /// Full delegation proof verification (structure + signature).
77    ///
78    /// Purely local verification; does not read certified data or require a
79    /// query context.
80    pub fn verify_delegation_proof(
81        proof: &DelegationProof,
82        authority_pid: Principal,
83    ) -> Result<(), Error> {
84        DelegatedTokenOps::verify_delegation_proof(proof, authority_pid)
85            .map_err(Self::map_delegation_error)
86    }
87
88    async fn sign_token(
89        claims: DelegatedTokenClaims,
90        proof: DelegationProof,
91    ) -> Result<DelegatedToken, Error> {
92        DelegatedTokenOps::sign_token(claims, proof)
93            .await
94            .map_err(Self::map_delegation_error)
95    }
96
97    /// Issue a delegated token using a reusable local proof when possible.
98    ///
99    /// If the proof is missing or no longer valid for the requested claims, this
100    /// performs canonical shard-initiated setup and retries with the refreshed proof.
101    pub async fn issue_token(claims: DelegatedTokenClaims) -> Result<DelegatedToken, Error> {
102        let proof = Self::ensure_signing_proof(&claims).await?;
103        Self::sign_token(claims, proof).await
104    }
105
106    /// Full delegated token verification (structure + signature).
107    ///
108    /// Purely local verification; does not read certified data or require a
109    /// query context.
110    pub fn verify_token(
111        token: &DelegatedToken,
112        authority_pid: Principal,
113        now_secs: u64,
114    ) -> Result<(), Error> {
115        DelegatedTokenOps::verify_token(token, authority_pid, now_secs, IcOps::canister_self())
116            .map(|_| ())
117            .map_err(Self::map_delegation_error)
118    }
119
120    /// Verify a delegated token and return verified contents.
121    ///
122    /// This is intended for application-layer session construction.
123    /// It performs full verification and returns verified claims and cert.
124    pub fn verify_token_verified(
125        token: &DelegatedToken,
126        authority_pid: Principal,
127        now_secs: u64,
128    ) -> Result<(DelegatedTokenClaims, DelegationCert), Error> {
129        DelegatedTokenOps::verify_token(token, authority_pid, now_secs, IcOps::canister_self())
130            .map(|verified| (verified.claims, verified.cert))
131            .map_err(Self::map_delegation_error)
132    }
133
134    /// Canonical shard-initiated delegation request (user_shard -> root).
135    ///
136    /// Caller must match shard_pid and be registered to the subnet.
137    pub async fn request_delegation(
138        request: DelegationRequest,
139    ) -> Result<DelegationProvisionResponse, Error> {
140        let request = metadata::with_root_request_metadata(request);
141        if EnvOps::is_root() {
142            let response = RootResponseWorkflow::response(RootRequest::issue_delegation(request))
143                .await
144                .map_err(Self::map_delegation_error)?;
145
146            return match response {
147                RootCapabilityResponse::DelegationIssued(response) => Ok(response),
148                _ => Err(Error::internal(
149                    "invalid root response type for delegation request",
150                )),
151            };
152        }
153
154        let root_pid = EnvOps::root_pid().map_err(Error::from)?;
155        RpcOps::call_rpc_result(root_pid, protocol::CANIC_REQUEST_DELEGATION, request)
156            .await
157            .map_err(Self::map_delegation_error)
158    }
159
160    pub async fn request_role_attestation(
161        request: RoleAttestationRequest,
162    ) -> Result<SignedRoleAttestation, Error> {
163        let request = metadata::with_root_attestation_request_metadata(request);
164        let response = RootResponseWorkflow::response(RootRequest::issue_role_attestation(request))
165            .await
166            .map_err(Self::map_delegation_error)?;
167
168        match response {
169            RootCapabilityResponse::RoleAttestationIssued(response) => Ok(response),
170            _ => Err(Error::internal(
171                "invalid root response type for role attestation request",
172            )),
173        }
174    }
175
176    pub async fn attestation_key_set() -> Result<AttestationKeySet, Error> {
177        DelegatedTokenOps::attestation_key_set()
178            .await
179            .map_err(Self::map_delegation_error)
180    }
181
182    pub fn replace_attestation_key_set(key_set: AttestationKeySet) {
183        DelegatedTokenOps::replace_attestation_key_set(key_set);
184    }
185
186    pub async fn verify_role_attestation(
187        attestation: &SignedRoleAttestation,
188        min_accepted_epoch: u64,
189    ) -> Result<(), Error> {
190        let configured_min_accepted_epoch = ConfigOps::role_attestation_config()
191            .map_err(Error::from)?
192            .min_accepted_epoch_by_role
193            .get(attestation.payload.role.as_str())
194            .copied();
195        let min_accepted_epoch = verify_flow::resolve_min_accepted_epoch(
196            min_accepted_epoch,
197            configured_min_accepted_epoch,
198        );
199
200        let caller = IcOps::msg_caller();
201        let self_pid = IcOps::canister_self();
202        let now_secs = IcOps::now_secs();
203        let verifier_subnet = Some(EnvOps::subnet_pid().map_err(Error::from)?);
204        let root_pid = EnvOps::root_pid().map_err(Error::from)?;
205
206        let verify = || {
207            DelegatedTokenOps::verify_role_attestation_cached(
208                attestation,
209                caller,
210                self_pid,
211                verifier_subnet,
212                now_secs,
213                min_accepted_epoch,
214            )
215            .map(|_| ())
216        };
217        let refresh = || async {
218            let key_set: AttestationKeySet =
219                RpcOps::call_rpc_result(root_pid, protocol::CANIC_ATTESTATION_KEY_SET, ()).await?;
220            DelegatedTokenOps::replace_attestation_key_set(key_set);
221            Ok(())
222        };
223
224        match verify_flow::verify_role_attestation_with_single_refresh(verify, refresh).await {
225            Ok(()) => Ok(()),
226            Err(verify_flow::RoleAttestationVerifyFlowError::Initial(err)) => {
227                verify_flow::record_attestation_verifier_rejection(&err);
228                verify_flow::log_attestation_verifier_rejection(
229                    &err,
230                    attestation,
231                    caller,
232                    self_pid,
233                    "cached",
234                );
235                Err(Self::map_delegation_error(err.into()))
236            }
237            Err(verify_flow::RoleAttestationVerifyFlowError::Refresh { trigger, source }) => {
238                verify_flow::record_attestation_verifier_rejection(&trigger);
239                verify_flow::log_attestation_verifier_rejection(
240                    &trigger,
241                    attestation,
242                    caller,
243                    self_pid,
244                    "cache_miss_refresh",
245                );
246                record_attestation_refresh_failed();
247                log!(
248                    Topic::Auth,
249                    Warn,
250                    "role attestation refresh failed local={} caller={} key_id={} error={}",
251                    self_pid,
252                    caller,
253                    attestation.key_id,
254                    source
255                );
256                Err(Self::map_delegation_error(source))
257            }
258            Err(verify_flow::RoleAttestationVerifyFlowError::PostRefresh(err)) => {
259                verify_flow::record_attestation_verifier_rejection(&err);
260                verify_flow::log_attestation_verifier_rejection(
261                    &err,
262                    attestation,
263                    caller,
264                    self_pid,
265                    "post_refresh",
266                );
267                Err(Self::map_delegation_error(err.into()))
268            }
269        }
270    }
271
272    /// Persist a temporary delegated session subject for the caller wallet.
273    pub fn set_delegated_session_subject(
274        delegated_subject: Principal,
275        bootstrap_token: DelegatedToken,
276        requested_ttl_secs: Option<u64>,
277    ) -> Result<(), Error> {
278        let cfg = ConfigOps::delegated_tokens_config().map_err(Error::from)?;
279        if !cfg.enabled {
280            record_session_bootstrap_rejected_disabled();
281            return Err(Error::forbidden(Self::DELEGATED_TOKENS_DISABLED));
282        }
283
284        let wallet_caller = IcOps::msg_caller();
285        if let Err(reason) = validate_delegated_session_subject(wallet_caller) {
286            record_session_bootstrap_rejected_wallet_caller_rejected();
287            return Err(Error::forbidden(format!(
288                "delegated session wallet caller rejected: {reason}"
289            )));
290        }
291
292        if let Err(reason) = validate_delegated_session_subject(delegated_subject) {
293            record_session_bootstrap_rejected_subject_rejected();
294            return Err(Error::forbidden(format!(
295                "delegated session subject rejected: {reason}"
296            )));
297        }
298
299        let issued_at = IcOps::now_secs();
300        let authority_pid = EnvOps::root_pid().map_err(Error::from)?;
301        let self_pid = IcOps::canister_self();
302        let verified =
303            DelegatedTokenOps::verify_token(&bootstrap_token, authority_pid, issued_at, self_pid)
304                .map_err(|err| {
305                record_session_bootstrap_rejected_token_invalid();
306                Self::map_delegation_error(err)
307            })?;
308
309        if verified.claims.sub != delegated_subject {
310            record_session_bootstrap_rejected_subject_mismatch();
311            return Err(Error::forbidden(format!(
312                "delegated session subject mismatch: requested={} token_subject={}",
313                delegated_subject, verified.claims.sub
314            )));
315        }
316
317        let configured_max_ttl_secs = cfg
318            .max_ttl_secs
319            .unwrap_or(Self::MAX_DELEGATED_SESSION_TTL_SECS);
320        let expires_at = Self::clamp_delegated_session_expires_at(
321            issued_at,
322            verified.claims.exp,
323            configured_max_ttl_secs,
324            requested_ttl_secs,
325        )
326        .inspect_err(|_| record_session_bootstrap_rejected_ttl_invalid())?;
327
328        let token_fingerprint =
329            Self::delegated_session_bootstrap_token_fingerprint(&bootstrap_token)
330                .inspect_err(|_| record_session_bootstrap_rejected_token_invalid())?;
331
332        if Self::enforce_bootstrap_replay_policy(
333            wallet_caller,
334            delegated_subject,
335            token_fingerprint,
336            issued_at,
337        )? {
338            return Ok(());
339        }
340
341        let had_active_session =
342            DelegationStateOps::delegated_session(wallet_caller, issued_at).is_some();
343
344        DelegationStateOps::upsert_delegated_session(
345            DelegatedSession {
346                wallet_pid: wallet_caller,
347                delegated_pid: delegated_subject,
348                issued_at,
349                expires_at,
350                bootstrap_token_fingerprint: Some(token_fingerprint),
351            },
352            issued_at,
353        );
354        DelegationStateOps::upsert_delegated_session_bootstrap_binding(
355            DelegatedSessionBootstrapBinding {
356                wallet_pid: wallet_caller,
357                delegated_pid: delegated_subject,
358                token_fingerprint,
359                bound_at: issued_at,
360                expires_at: verified.claims.exp,
361            },
362            issued_at,
363        );
364
365        if had_active_session {
366            record_session_replaced();
367        } else {
368            record_session_created();
369        }
370
371        Ok(())
372    }
373
374    /// Remove the caller's delegated session subject.
375    pub fn clear_delegated_session() {
376        let wallet_caller = IcOps::msg_caller();
377        let had_active_session =
378            DelegationStateOps::delegated_session(wallet_caller, IcOps::now_secs()).is_some();
379        DelegationStateOps::clear_delegated_session(wallet_caller);
380        if had_active_session {
381            record_session_cleared();
382        }
383    }
384
385    /// Read the caller's active delegated session subject, if configured.
386    #[must_use]
387    pub fn delegated_session_subject() -> Option<Principal> {
388        let wallet_caller = IcOps::msg_caller();
389        DelegationStateOps::delegated_session_subject(wallet_caller, IcOps::now_secs())
390    }
391
392    /// Prune all currently expired delegated sessions.
393    #[must_use]
394    pub fn prune_expired_delegated_sessions() -> usize {
395        let now_secs = IcOps::now_secs();
396        let removed = DelegationStateOps::prune_expired_delegated_sessions(now_secs);
397        let _ = DelegationStateOps::prune_expired_delegated_session_bootstrap_bindings(now_secs);
398        if removed > 0 {
399            record_session_pruned(removed);
400        }
401        removed
402    }
403
404    pub async fn store_proof(
405        proof: DelegationProof,
406        kind: DelegationProvisionTargetKind,
407    ) -> Result<(), Error> {
408        let cfg = ConfigOps::delegated_tokens_config().map_err(Error::from)?;
409        if !cfg.enabled {
410            return Err(Error::forbidden(Self::DELEGATED_TOKENS_DISABLED));
411        }
412
413        let root_pid = EnvOps::root_pid().map_err(Error::from)?;
414        let caller = IcOps::msg_caller();
415        if caller != root_pid {
416            return Err(Error::forbidden(
417                "delegation proof store requires root caller",
418            ));
419        }
420
421        DelegatedTokenOps::cache_public_keys_for_cert(&proof.cert)
422            .await
423            .map_err(Self::map_delegation_error)?;
424        if let Err(err) = DelegatedTokenOps::verify_delegation_proof(&proof, root_pid) {
425            let local = IcOps::canister_self();
426            log!(
427                Topic::Auth,
428                Warn,
429                "delegation proof rejected kind={:?} local={} shard={} issued_at={} expires_at={} error={}",
430                kind,
431                local,
432                proof.cert.shard_pid,
433                proof.cert.issued_at,
434                proof.cert.expires_at,
435                err
436            );
437            return Err(Self::map_delegation_error(err));
438        }
439
440        DelegationStateOps::set_proof_from_dto(proof);
441        let local = IcOps::canister_self();
442        let stored = DelegationStateOps::proof_dto()
443            .ok_or_else(|| Error::invariant("delegation proof missing after store"))?;
444        log!(
445            Topic::Auth,
446            Info,
447            "delegation proof stored kind={:?} local={} shard={} issued_at={} expires_at={}",
448            kind,
449            local,
450            stored.cert.shard_pid,
451            stored.cert.issued_at,
452            stored.cert.expires_at
453        );
454
455        Ok(())
456    }
457
458    /// Install delegation proof and key material directly, bypassing management-key lookups.
459    ///
460    /// This is intended for controlled root-driven test flows where deterministic
461    /// key material is used instead of chain-key ECDSA.
462    // Compiled only for controlled test canister builds.
463    #[cfg(canic_test_delegation_material)]
464    pub fn install_test_delegation_material(
465        proof: DelegationProof,
466        root_public_key: Vec<u8>,
467        shard_public_key: Vec<u8>,
468    ) -> Result<(), Error> {
469        let root_pid = EnvOps::root_pid().map_err(Error::from)?;
470        let caller = IcOps::msg_caller();
471        if caller != root_pid {
472            return Err(Error::forbidden(
473                "test delegation material install requires root caller",
474            ));
475        }
476
477        if proof.cert.root_pid != root_pid {
478            return Err(Error::invalid(format!(
479                "delegation proof root mismatch: expected={} found={}",
480                root_pid, proof.cert.root_pid
481            )));
482        }
483
484        if root_public_key.is_empty() || shard_public_key.is_empty() {
485            return Err(Error::invalid("delegation public keys must not be empty"));
486        }
487
488        DelegationStateOps::set_root_public_key(root_public_key);
489        DelegationStateOps::set_shard_public_key(proof.cert.shard_pid, shard_public_key);
490        DelegationStateOps::set_proof_from_dto(proof);
491        Ok(())
492    }
493
494    fn require_proof() -> Result<DelegationProof, Error> {
495        let cfg = ConfigOps::delegated_tokens_config().map_err(Error::from)?;
496        if !cfg.enabled {
497            return Err(Error::forbidden(Self::DELEGATED_TOKENS_DISABLED));
498        }
499
500        DelegationStateOps::proof_dto().ok_or_else(|| {
501            record_signer_issue_without_proof();
502            Error::not_found("delegation proof not set")
503        })
504    }
505
506    // Resolve a proof that is currently usable for token issuance.
507    async fn ensure_signing_proof(claims: &DelegatedTokenClaims) -> Result<DelegationProof, Error> {
508        let now_secs = IcOps::now_secs();
509
510        match Self::require_proof() {
511            Ok(proof) if !Self::proof_is_reusable_for_claims(&proof, claims, now_secs) => {
512                Self::setup_delegation(claims).await
513            }
514            Ok(proof) => Ok(proof),
515            Err(err) if err.code == ErrorCode::NotFound => Self::setup_delegation(claims).await,
516            Err(err) => Err(err),
517        }
518    }
519
520    // Provision a fresh delegation from root, then load locally stored proof.
521    async fn setup_delegation(claims: &DelegatedTokenClaims) -> Result<DelegationProof, Error> {
522        let request = Self::delegation_request_from_claims(claims)?;
523        let required_verifier_targets = request.verifier_targets.clone();
524        let response = Self::request_delegation(request).await?;
525        Self::ensure_required_verifier_targets_provisioned(&required_verifier_targets, &response)?;
526        Self::require_proof()
527    }
528
529    // Build a canonical delegation request from token claims.
530    fn delegation_request_from_claims(
531        claims: &DelegatedTokenClaims,
532    ) -> Result<DelegationRequest, Error> {
533        let ttl_secs = claims.exp.saturating_sub(claims.iat);
534        if ttl_secs == 0 {
535            return Err(Error::invalid(
536                "delegation ttl_secs must be greater than zero",
537            ));
538        }
539
540        let signer_pid = IcOps::canister_self();
541        let root_pid = EnvOps::root_pid().map_err(Error::from)?;
542        let verifier_targets = Self::derive_required_verifier_targets_from_aud(
543            &claims.aud,
544            signer_pid,
545            root_pid,
546            Self::is_registered_canister,
547        )?;
548
549        Ok(DelegationRequest {
550            shard_pid: signer_pid,
551            scopes: claims.scopes.clone(),
552            aud: claims.aud.clone(),
553            ttl_secs,
554            verifier_targets,
555            include_root_verifier: true,
556            metadata: None,
557        })
558    }
559
560    // Validate required verifier fanout and fail closed when any required target is missing/failing.
561    fn ensure_required_verifier_targets_provisioned(
562        required_targets: &[Principal],
563        response: &DelegationProvisionResponse,
564    ) -> Result<(), Error> {
565        let mut checked = Vec::new();
566        for target in required_targets {
567            if checked.contains(target) {
568                continue;
569            }
570            checked.push(*target);
571        }
572        record_delegation_verifier_target_count(checked.len());
573
574        for target in &checked {
575            let Some(result) = response.results.iter().find(|entry| {
576                entry.kind == DelegationProvisionTargetKind::Verifier && entry.target == *target
577            }) else {
578                record_delegation_verifier_target_missing();
579                return Err(Error::internal(format!(
580                    "delegation provisioning missing verifier target result for '{target}'"
581                )));
582            };
583
584            if result.status != DelegationProvisionStatus::Ok {
585                record_delegation_verifier_target_failed();
586                let detail = result
587                    .error
588                    .as_ref()
589                    .map_or_else(|| "unknown error".to_string(), ToString::to_string);
590                return Err(Error::internal(format!(
591                    "delegation provisioning failed for required verifier target '{target}': {detail}"
592                )));
593            }
594        }
595
596        record_delegation_provision_complete();
597        Ok(())
598    }
599
600    // Derive required verifier targets from audience with strict filtering/validation.
601    fn derive_required_verifier_targets_from_aud<F>(
602        audience: &[Principal],
603        signer_pid: Principal,
604        root_pid: Principal,
605        mut is_valid_target: F,
606    ) -> Result<Vec<Principal>, Error>
607    where
608        F: FnMut(Principal) -> bool,
609    {
610        let mut verifier_targets = Vec::new();
611        for principal in audience {
612            if *principal == signer_pid || *principal == root_pid {
613                continue;
614            }
615
616            if !is_valid_target(*principal) {
617                return Err(Error::invalid(format!(
618                    "delegation audience principal '{principal}' is invalid for canonical verifier provisioning"
619                )));
620            }
621
622            if !verifier_targets.contains(principal) {
623                verifier_targets.push(*principal);
624            }
625        }
626
627        Ok(verifier_targets)
628    }
629
630    // Return true when a principal is a provisionable verifier canister target.
631    fn is_registered_canister(principal: Principal) -> bool {
632        if SubnetRegistryOps::is_registered(principal) {
633            return true;
634        }
635
636        SubnetDirectoryOps::data()
637            .entries
638            .iter()
639            .any(|(_, pid)| *pid == principal)
640    }
641
642    // Check whether a proof can be reused safely for the requested claims.
643    fn proof_is_reusable_for_claims(
644        proof: &DelegationProof,
645        claims: &DelegatedTokenClaims,
646        now_secs: u64,
647    ) -> bool {
648        if now_secs > proof.cert.expires_at {
649            return false;
650        }
651
652        if claims.shard_pid != proof.cert.shard_pid {
653            return false;
654        }
655
656        if claims.iat < proof.cert.issued_at || claims.exp > proof.cert.expires_at {
657            return false;
658        }
659
660        Self::is_principal_subset(&claims.aud, &proof.cert.aud)
661            && Self::is_string_subset(&claims.scopes, &proof.cert.scopes)
662    }
663
664    // Return true when every principal in `subset` is present in `superset`.
665    fn is_principal_subset(
666        subset: &[crate::cdk::types::Principal],
667        superset: &[crate::cdk::types::Principal],
668    ) -> bool {
669        subset.iter().all(|item| superset.contains(item))
670    }
671
672    // Return true when every scope in `subset` is present in `superset`.
673    fn is_string_subset(subset: &[String], superset: &[String]) -> bool {
674        subset.iter().all(|item| superset.contains(item))
675    }
676
677    fn delegated_session_bootstrap_token_fingerprint(
678        token: &DelegatedToken,
679    ) -> Result<[u8; 32], Error> {
680        let token_bytes = crate::cdk::candid::encode_one(token).map_err(|err| {
681            Error::internal(format!("bootstrap token fingerprint encode failed: {err}"))
682        })?;
683        let mut hasher = Sha256::new();
684        hasher.update(Self::SESSION_BOOTSTRAP_TOKEN_FINGERPRINT_DOMAIN);
685        hasher.update(token_bytes);
686        Ok(hasher.finalize().into())
687    }
688
689    // Enforce replay policy for delegated-session bootstrap by token fingerprint.
690    fn enforce_bootstrap_replay_policy(
691        wallet_caller: Principal,
692        delegated_subject: Principal,
693        token_fingerprint: [u8; 32],
694        issued_at: u64,
695    ) -> Result<bool, Error> {
696        let Some(binding) =
697            DelegationStateOps::delegated_session_bootstrap_binding(token_fingerprint, issued_at)
698        else {
699            return Ok(false);
700        };
701
702        if binding.wallet_pid == wallet_caller && binding.delegated_pid == delegated_subject {
703            let active_same_session =
704                DelegationStateOps::delegated_session(wallet_caller, issued_at).is_some_and(
705                    |session| {
706                        session.delegated_pid == delegated_subject
707                            && session.bootstrap_token_fingerprint == Some(token_fingerprint)
708                    },
709                );
710
711            if active_same_session {
712                record_session_bootstrap_replay_idempotent();
713                return Ok(true);
714            }
715
716            record_session_bootstrap_rejected_replay_reused();
717            return Err(Error::forbidden(
718                "delegated session bootstrap token replay rejected; use a fresh token",
719            ));
720        }
721
722        record_session_bootstrap_rejected_replay_conflict();
723        Err(Error::forbidden(format!(
724            "delegated session bootstrap token already bound (wallet={} delegated_subject={})",
725            binding.wallet_pid, binding.delegated_pid
726        )))
727    }
728
729    fn clamp_delegated_session_expires_at(
730        now_secs: u64,
731        token_expires_at: u64,
732        configured_max_ttl_secs: u64,
733        requested_ttl_secs: Option<u64>,
734    ) -> Result<u64, Error> {
735        if configured_max_ttl_secs == 0 {
736            return Err(Error::invariant(
737                "delegated session configured max ttl_secs must be greater than zero",
738            ));
739        }
740
741        if let Some(ttl_secs) = requested_ttl_secs
742            && ttl_secs == 0
743        {
744            return Err(Error::invalid(
745                "delegated session requested ttl_secs must be greater than zero",
746            ));
747        }
748
749        let mut expires_at = token_expires_at;
750        expires_at = expires_at.min(now_secs.saturating_add(configured_max_ttl_secs));
751        if let Some(ttl_secs) = requested_ttl_secs {
752            expires_at = expires_at.min(now_secs.saturating_add(ttl_secs));
753        }
754
755        if expires_at <= now_secs {
756            return Err(Error::forbidden(
757                "delegated session bootstrap token is expired",
758            ));
759        }
760
761        Ok(expires_at)
762    }
763}
764
765#[cfg(test)]
766mod tests;