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, DelegationProvisionTargetKind,
8 DelegationRequest, RoleAttestationRequest, SignedRoleAttestation,
9 },
10 error::{Error, ErrorCode},
11 rpc::{Request as RootRequest, Response as RootCapabilityResponse},
12 },
13 error::InternalErrorClass,
14 log,
15 log::Topic,
16 ops::{
17 auth::DelegatedTokenOps,
18 config::ConfigOps,
19 ic::IcOps,
20 rpc::RpcOps,
21 runtime::env::EnvOps,
22 runtime::metrics::auth::{
23 record_attestation_refresh_failed, record_session_bootstrap_rejected_disabled,
24 record_session_bootstrap_rejected_replay_conflict,
25 record_session_bootstrap_rejected_replay_reused,
26 record_session_bootstrap_rejected_subject_mismatch,
27 record_session_bootstrap_rejected_subject_rejected,
28 record_session_bootstrap_rejected_token_invalid,
29 record_session_bootstrap_rejected_ttl_invalid,
30 record_session_bootstrap_rejected_wallet_caller_rejected,
31 record_session_bootstrap_replay_idempotent, record_session_cleared,
32 record_session_created, record_session_pruned, record_session_replaced,
33 record_signer_issue_without_proof,
34 },
35 storage::auth::{DelegatedSession, DelegatedSessionBootstrapBinding, DelegationStateOps},
36 },
37 protocol,
38 workflow::rpc::request::handler::RootResponseWorkflow,
39};
40use sha2::{Digest, Sha256};
41
42mod metadata;
43mod verify_flow;
44
45pub struct DelegationApi;
52
53impl DelegationApi {
54 const DELEGATED_TOKENS_DISABLED: &str =
55 "delegated token auth disabled; set auth.delegated_tokens.enabled=true in canic.toml";
56 const MAX_DELEGATED_SESSION_TTL_SECS: u64 = 24 * 60 * 60;
57 const SESSION_BOOTSTRAP_TOKEN_FINGERPRINT_DOMAIN: &[u8] =
58 b"canic-session-bootstrap-token-fingerprint:v1";
59
60 fn map_delegation_error(err: crate::InternalError) -> Error {
61 match err.class() {
62 InternalErrorClass::Infra | InternalErrorClass::Ops | InternalErrorClass::Workflow => {
63 Error::internal(err.to_string())
64 }
65 _ => Error::from(err),
66 }
67 }
68
69 pub fn verify_delegation_proof(
74 proof: &DelegationProof,
75 authority_pid: Principal,
76 ) -> Result<(), Error> {
77 DelegatedTokenOps::verify_delegation_proof(proof, authority_pid)
78 .map_err(Self::map_delegation_error)
79 }
80
81 async fn sign_token(
82 claims: DelegatedTokenClaims,
83 proof: DelegationProof,
84 ) -> Result<DelegatedToken, Error> {
85 DelegatedTokenOps::sign_token(claims, proof)
86 .await
87 .map_err(Self::map_delegation_error)
88 }
89
90 pub async fn issue_token(claims: DelegatedTokenClaims) -> Result<DelegatedToken, Error> {
95 let proof = Self::ensure_signing_proof(&claims).await?;
96 Self::sign_token(claims, proof).await
97 }
98
99 pub fn verify_token(
104 token: &DelegatedToken,
105 authority_pid: Principal,
106 now_secs: u64,
107 ) -> Result<(), Error> {
108 DelegatedTokenOps::verify_token(token, authority_pid, now_secs, IcOps::canister_self())
109 .map(|_| ())
110 .map_err(Self::map_delegation_error)
111 }
112
113 pub fn verify_token_verified(
118 token: &DelegatedToken,
119 authority_pid: Principal,
120 now_secs: u64,
121 ) -> Result<(DelegatedTokenClaims, DelegationCert), Error> {
122 DelegatedTokenOps::verify_token(token, authority_pid, now_secs, IcOps::canister_self())
123 .map(|verified| (verified.claims, verified.cert))
124 .map_err(Self::map_delegation_error)
125 }
126
127 pub async fn request_delegation(
131 request: DelegationRequest,
132 ) -> Result<DelegationProvisionResponse, Error> {
133 let request = metadata::with_root_request_metadata(request);
134 if EnvOps::is_root() {
135 let response = RootResponseWorkflow::response(RootRequest::issue_delegation(request))
136 .await
137 .map_err(Self::map_delegation_error)?;
138
139 return match response {
140 RootCapabilityResponse::DelegationIssued(response) => Ok(response),
141 _ => Err(Error::internal(
142 "invalid root response type for delegation request",
143 )),
144 };
145 }
146
147 let root_pid = EnvOps::root_pid().map_err(Error::from)?;
148 RpcOps::call_rpc_result(root_pid, protocol::CANIC_REQUEST_DELEGATION, request)
149 .await
150 .map_err(Self::map_delegation_error)
151 }
152
153 pub async fn request_role_attestation(
154 request: RoleAttestationRequest,
155 ) -> Result<SignedRoleAttestation, Error> {
156 let request = metadata::with_root_attestation_request_metadata(request);
157 let response = RootResponseWorkflow::response(RootRequest::issue_role_attestation(request))
158 .await
159 .map_err(Self::map_delegation_error)?;
160
161 match response {
162 RootCapabilityResponse::RoleAttestationIssued(response) => Ok(response),
163 _ => Err(Error::internal(
164 "invalid root response type for role attestation request",
165 )),
166 }
167 }
168
169 pub async fn attestation_key_set() -> Result<AttestationKeySet, Error> {
170 DelegatedTokenOps::attestation_key_set()
171 .await
172 .map_err(Self::map_delegation_error)
173 }
174
175 pub fn replace_attestation_key_set(key_set: AttestationKeySet) {
176 DelegatedTokenOps::replace_attestation_key_set(key_set);
177 }
178
179 pub async fn verify_role_attestation(
180 attestation: &SignedRoleAttestation,
181 min_accepted_epoch: u64,
182 ) -> Result<(), Error> {
183 let configured_min_accepted_epoch = ConfigOps::role_attestation_config()
184 .map_err(Error::from)?
185 .min_accepted_epoch_by_role
186 .get(attestation.payload.role.as_str())
187 .copied();
188 let min_accepted_epoch = verify_flow::resolve_min_accepted_epoch(
189 min_accepted_epoch,
190 configured_min_accepted_epoch,
191 );
192
193 let caller = IcOps::msg_caller();
194 let self_pid = IcOps::canister_self();
195 let now_secs = IcOps::now_secs();
196 let verifier_subnet = Some(EnvOps::subnet_pid().map_err(Error::from)?);
197 let root_pid = EnvOps::root_pid().map_err(Error::from)?;
198
199 let verify = || {
200 DelegatedTokenOps::verify_role_attestation_cached(
201 attestation,
202 caller,
203 self_pid,
204 verifier_subnet,
205 now_secs,
206 min_accepted_epoch,
207 )
208 .map(|_| ())
209 };
210 let refresh = || async {
211 let key_set: AttestationKeySet =
212 RpcOps::call_rpc_result(root_pid, protocol::CANIC_ATTESTATION_KEY_SET, ()).await?;
213 DelegatedTokenOps::replace_attestation_key_set(key_set);
214 Ok(())
215 };
216
217 match verify_flow::verify_role_attestation_with_single_refresh(verify, refresh).await {
218 Ok(()) => Ok(()),
219 Err(verify_flow::RoleAttestationVerifyFlowError::Initial(err)) => {
220 verify_flow::record_attestation_verifier_rejection(&err);
221 verify_flow::log_attestation_verifier_rejection(
222 &err,
223 attestation,
224 caller,
225 self_pid,
226 "cached",
227 );
228 Err(Self::map_delegation_error(err.into()))
229 }
230 Err(verify_flow::RoleAttestationVerifyFlowError::Refresh { trigger, source }) => {
231 verify_flow::record_attestation_verifier_rejection(&trigger);
232 verify_flow::log_attestation_verifier_rejection(
233 &trigger,
234 attestation,
235 caller,
236 self_pid,
237 "cache_miss_refresh",
238 );
239 record_attestation_refresh_failed();
240 log!(
241 Topic::Auth,
242 Warn,
243 "role attestation refresh failed local={} caller={} key_id={} error={}",
244 self_pid,
245 caller,
246 attestation.key_id,
247 source
248 );
249 Err(Self::map_delegation_error(source))
250 }
251 Err(verify_flow::RoleAttestationVerifyFlowError::PostRefresh(err)) => {
252 verify_flow::record_attestation_verifier_rejection(&err);
253 verify_flow::log_attestation_verifier_rejection(
254 &err,
255 attestation,
256 caller,
257 self_pid,
258 "post_refresh",
259 );
260 Err(Self::map_delegation_error(err.into()))
261 }
262 }
263 }
264
265 pub fn set_delegated_session_subject(
267 delegated_subject: Principal,
268 bootstrap_token: DelegatedToken,
269 requested_ttl_secs: Option<u64>,
270 ) -> Result<(), Error> {
271 let cfg = ConfigOps::delegated_tokens_config().map_err(Error::from)?;
272 if !cfg.enabled {
273 record_session_bootstrap_rejected_disabled();
274 return Err(Error::forbidden(Self::DELEGATED_TOKENS_DISABLED));
275 }
276
277 let wallet_caller = IcOps::msg_caller();
278 if let Err(reason) = validate_delegated_session_subject(wallet_caller) {
279 record_session_bootstrap_rejected_wallet_caller_rejected();
280 return Err(Error::forbidden(format!(
281 "delegated session wallet caller rejected: {reason}"
282 )));
283 }
284
285 if let Err(reason) = validate_delegated_session_subject(delegated_subject) {
286 record_session_bootstrap_rejected_subject_rejected();
287 return Err(Error::forbidden(format!(
288 "delegated session subject rejected: {reason}"
289 )));
290 }
291
292 let issued_at = IcOps::now_secs();
293 let authority_pid = EnvOps::root_pid().map_err(Error::from)?;
294 let self_pid = IcOps::canister_self();
295 let verified =
296 DelegatedTokenOps::verify_token(&bootstrap_token, authority_pid, issued_at, self_pid)
297 .map_err(|err| {
298 record_session_bootstrap_rejected_token_invalid();
299 Self::map_delegation_error(err)
300 })?;
301
302 if verified.claims.sub != delegated_subject {
303 record_session_bootstrap_rejected_subject_mismatch();
304 return Err(Error::forbidden(format!(
305 "delegated session subject mismatch: requested={} token_subject={}",
306 delegated_subject, verified.claims.sub
307 )));
308 }
309
310 let configured_max_ttl_secs = cfg
311 .max_ttl_secs
312 .unwrap_or(Self::MAX_DELEGATED_SESSION_TTL_SECS);
313 let expires_at = Self::clamp_delegated_session_expires_at(
314 issued_at,
315 verified.claims.exp,
316 configured_max_ttl_secs,
317 requested_ttl_secs,
318 )
319 .inspect_err(|_| record_session_bootstrap_rejected_ttl_invalid())?;
320
321 let token_fingerprint =
322 Self::delegated_session_bootstrap_token_fingerprint(&bootstrap_token)
323 .inspect_err(|_| record_session_bootstrap_rejected_token_invalid())?;
324
325 if Self::enforce_bootstrap_replay_policy(
326 wallet_caller,
327 delegated_subject,
328 token_fingerprint,
329 issued_at,
330 )? {
331 return Ok(());
332 }
333
334 let had_active_session =
335 DelegationStateOps::delegated_session(wallet_caller, issued_at).is_some();
336
337 DelegationStateOps::upsert_delegated_session(
338 DelegatedSession {
339 wallet_pid: wallet_caller,
340 delegated_pid: delegated_subject,
341 issued_at,
342 expires_at,
343 bootstrap_token_fingerprint: Some(token_fingerprint),
344 },
345 issued_at,
346 );
347 DelegationStateOps::upsert_delegated_session_bootstrap_binding(
348 DelegatedSessionBootstrapBinding {
349 wallet_pid: wallet_caller,
350 delegated_pid: delegated_subject,
351 token_fingerprint,
352 bound_at: issued_at,
353 expires_at: verified.claims.exp,
354 },
355 issued_at,
356 );
357
358 if had_active_session {
359 record_session_replaced();
360 } else {
361 record_session_created();
362 }
363
364 Ok(())
365 }
366
367 pub fn clear_delegated_session() {
369 let wallet_caller = IcOps::msg_caller();
370 let had_active_session =
371 DelegationStateOps::delegated_session(wallet_caller, IcOps::now_secs()).is_some();
372 DelegationStateOps::clear_delegated_session(wallet_caller);
373 if had_active_session {
374 record_session_cleared();
375 }
376 }
377
378 #[must_use]
380 pub fn delegated_session_subject() -> Option<Principal> {
381 let wallet_caller = IcOps::msg_caller();
382 DelegationStateOps::delegated_session_subject(wallet_caller, IcOps::now_secs())
383 }
384
385 #[must_use]
387 pub fn prune_expired_delegated_sessions() -> usize {
388 let now_secs = IcOps::now_secs();
389 let removed = DelegationStateOps::prune_expired_delegated_sessions(now_secs);
390 let _ = DelegationStateOps::prune_expired_delegated_session_bootstrap_bindings(now_secs);
391 if removed > 0 {
392 record_session_pruned(removed);
393 }
394 removed
395 }
396
397 pub fn set_delegated_caller(
399 delegated_caller: Principal,
400 bootstrap_token: DelegatedToken,
401 requested_ttl_secs: Option<u64>,
402 ) -> Result<(), Error> {
403 Self::set_delegated_session_subject(delegated_caller, bootstrap_token, requested_ttl_secs)
404 }
405
406 pub fn clear_delegated_caller() {
408 Self::clear_delegated_session();
409 }
410
411 #[must_use]
413 pub fn delegated_caller() -> Option<Principal> {
414 Self::delegated_session_subject()
415 }
416
417 pub async fn store_proof(
418 proof: DelegationProof,
419 kind: DelegationProvisionTargetKind,
420 ) -> Result<(), Error> {
421 let cfg = ConfigOps::delegated_tokens_config().map_err(Error::from)?;
422 if !cfg.enabled {
423 return Err(Error::forbidden(Self::DELEGATED_TOKENS_DISABLED));
424 }
425
426 let root_pid = EnvOps::root_pid().map_err(Error::from)?;
427 let caller = IcOps::msg_caller();
428 if caller != root_pid {
429 return Err(Error::forbidden(
430 "delegation proof store requires root caller",
431 ));
432 }
433
434 DelegatedTokenOps::cache_public_keys_for_cert(&proof.cert)
435 .await
436 .map_err(Self::map_delegation_error)?;
437 if let Err(err) = DelegatedTokenOps::verify_delegation_proof(&proof, root_pid) {
438 let local = IcOps::canister_self();
439 log!(
440 Topic::Auth,
441 Warn,
442 "delegation proof rejected kind={:?} local={} shard={} issued_at={} expires_at={} error={}",
443 kind,
444 local,
445 proof.cert.shard_pid,
446 proof.cert.issued_at,
447 proof.cert.expires_at,
448 err
449 );
450 return Err(Self::map_delegation_error(err));
451 }
452
453 DelegationStateOps::set_proof_from_dto(proof);
454 let local = IcOps::canister_self();
455 let stored = DelegationStateOps::proof_dto()
456 .ok_or_else(|| Error::invariant("delegation proof missing after store"))?;
457 log!(
458 Topic::Auth,
459 Info,
460 "delegation proof stored kind={:?} local={} shard={} issued_at={} expires_at={}",
461 kind,
462 local,
463 stored.cert.shard_pid,
464 stored.cert.issued_at,
465 stored.cert.expires_at
466 );
467
468 Ok(())
469 }
470
471 #[cfg(canic_test_delegation_material)]
477 pub fn install_test_delegation_material(
478 proof: DelegationProof,
479 root_public_key: Vec<u8>,
480 shard_public_key: Vec<u8>,
481 ) -> Result<(), Error> {
482 let root_pid = EnvOps::root_pid().map_err(Error::from)?;
483 let caller = IcOps::msg_caller();
484 if caller != root_pid {
485 return Err(Error::forbidden(
486 "test delegation material install requires root caller",
487 ));
488 }
489
490 if proof.cert.root_pid != root_pid {
491 return Err(Error::invalid(format!(
492 "delegation proof root mismatch: expected={} found={}",
493 root_pid, proof.cert.root_pid
494 )));
495 }
496
497 if root_public_key.is_empty() || shard_public_key.is_empty() {
498 return Err(Error::invalid("delegation public keys must not be empty"));
499 }
500
501 DelegationStateOps::set_root_public_key(root_public_key);
502 DelegationStateOps::set_shard_public_key(proof.cert.shard_pid, shard_public_key);
503 DelegationStateOps::set_proof_from_dto(proof);
504 Ok(())
505 }
506
507 fn require_proof() -> Result<DelegationProof, Error> {
508 let cfg = ConfigOps::delegated_tokens_config().map_err(Error::from)?;
509 if !cfg.enabled {
510 return Err(Error::forbidden(Self::DELEGATED_TOKENS_DISABLED));
511 }
512
513 DelegationStateOps::proof_dto().ok_or_else(|| {
514 record_signer_issue_without_proof();
515 Error::not_found("delegation proof not set")
516 })
517 }
518
519 async fn ensure_signing_proof(claims: &DelegatedTokenClaims) -> Result<DelegationProof, Error> {
521 let now_secs = IcOps::now_secs();
522
523 match Self::require_proof() {
524 Ok(proof) if !Self::proof_is_reusable_for_claims(&proof, claims, now_secs) => {
525 Self::setup_delegation(claims).await
526 }
527 Ok(proof) => Ok(proof),
528 Err(err) if err.code == ErrorCode::NotFound => Self::setup_delegation(claims).await,
529 Err(err) => Err(err),
530 }
531 }
532
533 async fn setup_delegation(claims: &DelegatedTokenClaims) -> Result<DelegationProof, Error> {
535 let request = Self::delegation_request_from_claims(claims)?;
536 let _ = Self::request_delegation(request).await?;
537 Self::require_proof()
538 }
539
540 fn delegation_request_from_claims(
542 claims: &DelegatedTokenClaims,
543 ) -> Result<DelegationRequest, Error> {
544 let ttl_secs = claims.exp.saturating_sub(claims.iat);
545 if ttl_secs == 0 {
546 return Err(Error::invalid(
547 "delegation ttl_secs must be greater than zero",
548 ));
549 }
550
551 Ok(DelegationRequest {
552 shard_pid: IcOps::canister_self(),
553 scopes: claims.scopes.clone(),
554 aud: claims.aud.clone(),
555 ttl_secs,
556 verifier_targets: Vec::new(),
557 include_root_verifier: true,
558 metadata: None,
559 })
560 }
561
562 fn proof_is_reusable_for_claims(
564 proof: &DelegationProof,
565 claims: &DelegatedTokenClaims,
566 now_secs: u64,
567 ) -> bool {
568 if now_secs > proof.cert.expires_at {
569 return false;
570 }
571
572 if claims.shard_pid != proof.cert.shard_pid {
573 return false;
574 }
575
576 if claims.iat < proof.cert.issued_at || claims.exp > proof.cert.expires_at {
577 return false;
578 }
579
580 Self::is_principal_subset(&claims.aud, &proof.cert.aud)
581 && Self::is_string_subset(&claims.scopes, &proof.cert.scopes)
582 }
583
584 fn is_principal_subset(
586 subset: &[crate::cdk::types::Principal],
587 superset: &[crate::cdk::types::Principal],
588 ) -> bool {
589 subset.iter().all(|item| superset.contains(item))
590 }
591
592 fn is_string_subset(subset: &[String], superset: &[String]) -> bool {
594 subset.iter().all(|item| superset.contains(item))
595 }
596
597 fn delegated_session_bootstrap_token_fingerprint(
598 token: &DelegatedToken,
599 ) -> Result<[u8; 32], Error> {
600 let token_bytes = crate::cdk::candid::encode_one(token).map_err(|err| {
601 Error::internal(format!("bootstrap token fingerprint encode failed: {err}"))
602 })?;
603 let mut hasher = Sha256::new();
604 hasher.update(Self::SESSION_BOOTSTRAP_TOKEN_FINGERPRINT_DOMAIN);
605 hasher.update(token_bytes);
606 Ok(hasher.finalize().into())
607 }
608
609 fn enforce_bootstrap_replay_policy(
611 wallet_caller: Principal,
612 delegated_subject: Principal,
613 token_fingerprint: [u8; 32],
614 issued_at: u64,
615 ) -> Result<bool, Error> {
616 let Some(binding) =
617 DelegationStateOps::delegated_session_bootstrap_binding(token_fingerprint, issued_at)
618 else {
619 return Ok(false);
620 };
621
622 if binding.wallet_pid == wallet_caller && binding.delegated_pid == delegated_subject {
623 let active_same_session =
624 DelegationStateOps::delegated_session(wallet_caller, issued_at).is_some_and(
625 |session| {
626 session.delegated_pid == delegated_subject
627 && session.bootstrap_token_fingerprint == Some(token_fingerprint)
628 },
629 );
630
631 if active_same_session {
632 record_session_bootstrap_replay_idempotent();
633 return Ok(true);
634 }
635
636 record_session_bootstrap_rejected_replay_reused();
637 return Err(Error::forbidden(
638 "delegated session bootstrap token replay rejected; use a fresh token",
639 ));
640 }
641
642 record_session_bootstrap_rejected_replay_conflict();
643 Err(Error::forbidden(format!(
644 "delegated session bootstrap token already bound (wallet={} delegated_subject={})",
645 binding.wallet_pid, binding.delegated_pid
646 )))
647 }
648
649 fn clamp_delegated_session_expires_at(
650 now_secs: u64,
651 token_expires_at: u64,
652 configured_max_ttl_secs: u64,
653 requested_ttl_secs: Option<u64>,
654 ) -> Result<u64, Error> {
655 if configured_max_ttl_secs == 0 {
656 return Err(Error::invariant(
657 "delegated session configured max ttl_secs must be greater than zero",
658 ));
659 }
660
661 if let Some(ttl_secs) = requested_ttl_secs
662 && ttl_secs == 0
663 {
664 return Err(Error::invalid(
665 "delegated session requested ttl_secs must be greater than zero",
666 ));
667 }
668
669 let mut expires_at = token_expires_at;
670 expires_at = expires_at.min(now_secs.saturating_add(configured_max_ttl_secs));
671 if let Some(ttl_secs) = requested_ttl_secs {
672 expires_at = expires_at.min(now_secs.saturating_add(ttl_secs));
673 }
674
675 if expires_at <= now_secs {
676 return Err(Error::forbidden(
677 "delegated session bootstrap token is expired",
678 ));
679 }
680
681 Ok(expires_at)
682 }
683}
684
685#[cfg(test)]
686mod tests;