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 async fn store_proof(
398 proof: DelegationProof,
399 kind: DelegationProvisionTargetKind,
400 ) -> Result<(), Error> {
401 let cfg = ConfigOps::delegated_tokens_config().map_err(Error::from)?;
402 if !cfg.enabled {
403 return Err(Error::forbidden(Self::DELEGATED_TOKENS_DISABLED));
404 }
405
406 let root_pid = EnvOps::root_pid().map_err(Error::from)?;
407 let caller = IcOps::msg_caller();
408 if caller != root_pid {
409 return Err(Error::forbidden(
410 "delegation proof store requires root caller",
411 ));
412 }
413
414 DelegatedTokenOps::cache_public_keys_for_cert(&proof.cert)
415 .await
416 .map_err(Self::map_delegation_error)?;
417 if let Err(err) = DelegatedTokenOps::verify_delegation_proof(&proof, root_pid) {
418 let local = IcOps::canister_self();
419 log!(
420 Topic::Auth,
421 Warn,
422 "delegation proof rejected kind={:?} local={} shard={} issued_at={} expires_at={} error={}",
423 kind,
424 local,
425 proof.cert.shard_pid,
426 proof.cert.issued_at,
427 proof.cert.expires_at,
428 err
429 );
430 return Err(Self::map_delegation_error(err));
431 }
432
433 DelegationStateOps::set_proof_from_dto(proof);
434 let local = IcOps::canister_self();
435 let stored = DelegationStateOps::proof_dto()
436 .ok_or_else(|| Error::invariant("delegation proof missing after store"))?;
437 log!(
438 Topic::Auth,
439 Info,
440 "delegation proof stored kind={:?} local={} shard={} issued_at={} expires_at={}",
441 kind,
442 local,
443 stored.cert.shard_pid,
444 stored.cert.issued_at,
445 stored.cert.expires_at
446 );
447
448 Ok(())
449 }
450
451 #[cfg(canic_test_delegation_material)]
457 pub fn install_test_delegation_material(
458 proof: DelegationProof,
459 root_public_key: Vec<u8>,
460 shard_public_key: Vec<u8>,
461 ) -> Result<(), Error> {
462 let root_pid = EnvOps::root_pid().map_err(Error::from)?;
463 let caller = IcOps::msg_caller();
464 if caller != root_pid {
465 return Err(Error::forbidden(
466 "test delegation material install requires root caller",
467 ));
468 }
469
470 if proof.cert.root_pid != root_pid {
471 return Err(Error::invalid(format!(
472 "delegation proof root mismatch: expected={} found={}",
473 root_pid, proof.cert.root_pid
474 )));
475 }
476
477 if root_public_key.is_empty() || shard_public_key.is_empty() {
478 return Err(Error::invalid("delegation public keys must not be empty"));
479 }
480
481 DelegationStateOps::set_root_public_key(root_public_key);
482 DelegationStateOps::set_shard_public_key(proof.cert.shard_pid, shard_public_key);
483 DelegationStateOps::set_proof_from_dto(proof);
484 Ok(())
485 }
486
487 fn require_proof() -> Result<DelegationProof, Error> {
488 let cfg = ConfigOps::delegated_tokens_config().map_err(Error::from)?;
489 if !cfg.enabled {
490 return Err(Error::forbidden(Self::DELEGATED_TOKENS_DISABLED));
491 }
492
493 DelegationStateOps::proof_dto().ok_or_else(|| {
494 record_signer_issue_without_proof();
495 Error::not_found("delegation proof not set")
496 })
497 }
498
499 async fn ensure_signing_proof(claims: &DelegatedTokenClaims) -> Result<DelegationProof, Error> {
501 let now_secs = IcOps::now_secs();
502
503 match Self::require_proof() {
504 Ok(proof) if !Self::proof_is_reusable_for_claims(&proof, claims, now_secs) => {
505 Self::setup_delegation(claims).await
506 }
507 Ok(proof) => Ok(proof),
508 Err(err) if err.code == ErrorCode::NotFound => Self::setup_delegation(claims).await,
509 Err(err) => Err(err),
510 }
511 }
512
513 async fn setup_delegation(claims: &DelegatedTokenClaims) -> Result<DelegationProof, Error> {
515 let request = Self::delegation_request_from_claims(claims)?;
516 let _ = Self::request_delegation(request).await?;
517 Self::require_proof()
518 }
519
520 fn delegation_request_from_claims(
522 claims: &DelegatedTokenClaims,
523 ) -> Result<DelegationRequest, Error> {
524 let ttl_secs = claims.exp.saturating_sub(claims.iat);
525 if ttl_secs == 0 {
526 return Err(Error::invalid(
527 "delegation ttl_secs must be greater than zero",
528 ));
529 }
530
531 Ok(DelegationRequest {
532 shard_pid: IcOps::canister_self(),
533 scopes: claims.scopes.clone(),
534 aud: claims.aud.clone(),
535 ttl_secs,
536 verifier_targets: Vec::new(),
537 include_root_verifier: true,
538 metadata: None,
539 })
540 }
541
542 fn proof_is_reusable_for_claims(
544 proof: &DelegationProof,
545 claims: &DelegatedTokenClaims,
546 now_secs: u64,
547 ) -> bool {
548 if now_secs > proof.cert.expires_at {
549 return false;
550 }
551
552 if claims.shard_pid != proof.cert.shard_pid {
553 return false;
554 }
555
556 if claims.iat < proof.cert.issued_at || claims.exp > proof.cert.expires_at {
557 return false;
558 }
559
560 Self::is_principal_subset(&claims.aud, &proof.cert.aud)
561 && Self::is_string_subset(&claims.scopes, &proof.cert.scopes)
562 }
563
564 fn is_principal_subset(
566 subset: &[crate::cdk::types::Principal],
567 superset: &[crate::cdk::types::Principal],
568 ) -> bool {
569 subset.iter().all(|item| superset.contains(item))
570 }
571
572 fn is_string_subset(subset: &[String], superset: &[String]) -> bool {
574 subset.iter().all(|item| superset.contains(item))
575 }
576
577 fn delegated_session_bootstrap_token_fingerprint(
578 token: &DelegatedToken,
579 ) -> Result<[u8; 32], Error> {
580 let token_bytes = crate::cdk::candid::encode_one(token).map_err(|err| {
581 Error::internal(format!("bootstrap token fingerprint encode failed: {err}"))
582 })?;
583 let mut hasher = Sha256::new();
584 hasher.update(Self::SESSION_BOOTSTRAP_TOKEN_FINGERPRINT_DOMAIN);
585 hasher.update(token_bytes);
586 Ok(hasher.finalize().into())
587 }
588
589 fn enforce_bootstrap_replay_policy(
591 wallet_caller: Principal,
592 delegated_subject: Principal,
593 token_fingerprint: [u8; 32],
594 issued_at: u64,
595 ) -> Result<bool, Error> {
596 let Some(binding) =
597 DelegationStateOps::delegated_session_bootstrap_binding(token_fingerprint, issued_at)
598 else {
599 return Ok(false);
600 };
601
602 if binding.wallet_pid == wallet_caller && binding.delegated_pid == delegated_subject {
603 let active_same_session =
604 DelegationStateOps::delegated_session(wallet_caller, issued_at).is_some_and(
605 |session| {
606 session.delegated_pid == delegated_subject
607 && session.bootstrap_token_fingerprint == Some(token_fingerprint)
608 },
609 );
610
611 if active_same_session {
612 record_session_bootstrap_replay_idempotent();
613 return Ok(true);
614 }
615
616 record_session_bootstrap_rejected_replay_reused();
617 return Err(Error::forbidden(
618 "delegated session bootstrap token replay rejected; use a fresh token",
619 ));
620 }
621
622 record_session_bootstrap_rejected_replay_conflict();
623 Err(Error::forbidden(format!(
624 "delegated session bootstrap token already bound (wallet={} delegated_subject={})",
625 binding.wallet_pid, binding.delegated_pid
626 )))
627 }
628
629 fn clamp_delegated_session_expires_at(
630 now_secs: u64,
631 token_expires_at: u64,
632 configured_max_ttl_secs: u64,
633 requested_ttl_secs: Option<u64>,
634 ) -> Result<u64, Error> {
635 if configured_max_ttl_secs == 0 {
636 return Err(Error::invariant(
637 "delegated session configured max ttl_secs must be greater than zero",
638 ));
639 }
640
641 if let Some(ttl_secs) = requested_ttl_secs
642 && ttl_secs == 0
643 {
644 return Err(Error::invalid(
645 "delegated session requested ttl_secs must be greater than zero",
646 ));
647 }
648
649 let mut expires_at = token_expires_at;
650 expires_at = expires_at.min(now_secs.saturating_add(configured_max_ttl_secs));
651 if let Some(ttl_secs) = requested_ttl_secs {
652 expires_at = expires_at.min(now_secs.saturating_add(ttl_secs));
653 }
654
655 if expires_at <= now_secs {
656 return Err(Error::forbidden(
657 "delegated session bootstrap token is expired",
658 ));
659 }
660
661 Ok(expires_at)
662 }
663}
664
665#[cfg(test)]
666mod tests;