1use crate::{
2 cdk::types::Principal,
3 dto::{
4 auth::{
5 AttestationKeySet, DelegatedRoleGrant, DelegatedToken, DelegatedTokenIssueRequest,
6 DelegatedTokenMintRequest, DelegationAudience, DelegationCert, DelegationProof,
7 DelegationProofIssueRequest, InternalInvocationProofRequest, RoleAttestationRequest,
8 ShardKeyBinding, SignatureAlgorithm, SignedInternalInvocationProofV1,
9 SignedRoleAttestation,
10 },
11 error::{Error, ErrorCode},
12 rpc::{Request as RootRequest, Response as RootCapabilityResponse, RootRequestMetadata},
13 },
14 error::InternalErrorClass,
15 ids::CanisterRole,
16 log,
17 log::Topic,
18 ops::{
19 auth::{
20 AuthExpiryError, AuthOps, AuthOpsError, AuthValidationError, SignDelegatedTokenInput,
21 SignDelegationProofInput, VerifyDelegatedTokenRuntimeInput,
22 },
23 config::ConfigOps,
24 cost_guard::{CostGuardOps, CostGuardRequest},
25 ic::{IcOps, mgmt::MgmtOps},
26 replay::{
27 guard::secs_to_ns,
28 model::{
29 CommandKind, EcdsaPurpose, ExternalEffectDescriptor, OperationId, RecoveryReason,
30 ReplayActor, ReplayPayloadHasher,
31 },
32 receipt::{
33 ReplayReceiptDecision, ReplayReceiptReserveInput, ReplayReceiptStoreError,
34 ReplayReceiptToken, abort_reserved_receipt, commit_receipt_response,
35 mark_external_effect_in_flight, mark_recovery_required, reserve_or_replay_receipt,
36 },
37 },
38 runtime::env::EnvOps,
39 runtime::metrics::auth::record_attestation_refresh_failed,
40 },
41 workflow::rpc::request::handler::RootResponseWorkflow,
42};
43use candid::{decode_one, encode_one};
44use root_client::RootAuthMaterialClient;
45use sha2::{Digest, Sha256};
46
47mod metadata;
52mod root_client;
53mod session;
54mod verify_flow;
55
56pub struct AuthApi;
63
64impl AuthApi {
65 const DELEGATED_TOKENS_DISABLED: &str =
66 "delegated token auth disabled; set auth.delegated_tokens.enabled=true in canic.toml";
67 const MAX_DELEGATED_SESSION_TTL_SECS: u64 = 24 * 60 * 60;
68 const DELEGATION_REPLAY_COMMAND_KIND: &str = "auth.issue_delegation_proof.v1";
69 const DELEGATION_REPLAY_RESPONSE_SCHEMA_VERSION: u32 = 1;
70 const MAX_DELEGATION_REPLAY_TTL_SECONDS: u64 = 300;
71 const DELEGATION_SIGNING_QUOTA_WINDOW_SECONDS: u64 = 60;
72 const MAX_DELEGATION_SIGNING_OPERATIONS_PER_WINDOW: u64 = 60;
73 const DELEGATION_SIGNING_CYCLE_RESERVATION_CYCLES: u128 = 1_000_000_000;
74 const MIN_DELEGATION_SIGNING_CYCLES_AFTER_RESERVATION: u128 = 1_000_000_000;
75 const TOKEN_ISSUE_REPLAY_COMMAND_KIND: &str = "auth.issue_token.v1";
76 const TOKEN_MINT_REPLAY_COMMAND_KIND: &str = "auth.mint_token.v1";
77 const TOKEN_REPLAY_RESPONSE_SCHEMA_VERSION: u32 = 1;
78 const MAX_TOKEN_REPLAY_TTL_SECONDS: u64 = 300;
79 const TOKEN_SIGNING_QUOTA_WINDOW_SECONDS: u64 = 60;
80 const MAX_TOKEN_SIGNING_OPERATIONS_PER_WINDOW: u64 = 60;
81 const TOKEN_SIGNING_CYCLE_RESERVATION_CYCLES: u128 = 1_000_000_000;
82 const MIN_TOKEN_SIGNING_CYCLES_AFTER_RESERVATION: u128 = 1_000_000_000;
83 const SESSION_BOOTSTRAP_TOKEN_FINGERPRINT_DOMAIN: &[u8] =
84 b"canic-session-bootstrap-token-fingerprint";
85
86 fn map_auth_error(err: crate::InternalError) -> Error {
88 match err.class() {
89 InternalErrorClass::Infra | InternalErrorClass::Ops | InternalErrorClass::Workflow => {
90 Error::internal(err.to_string())
91 }
92 _ => Error::from(err),
93 }
94 }
95
96 fn map_internal_invocation_verify_error(err: AuthOpsError) -> Error {
97 match err {
98 AuthOpsError::Validation(AuthValidationError::AttestationUnknownKeyId { .. }) => {
99 Error::new(ErrorCode::AuthKeyUnknown, err.to_string())
100 }
101 AuthOpsError::Expiry(AuthExpiryError::AttestationEpochRejected { .. }) => {
102 Error::new(ErrorCode::AuthMaterialStale, err.to_string())
103 }
104 AuthOpsError::Expiry(
105 AuthExpiryError::AttestationExpired { .. }
106 | AuthExpiryError::AttestationNotYetValid { .. },
107 ) => Error::new(ErrorCode::AuthProofExpired, err.to_string()),
108 _ => Error::unauthorized(err.to_string()),
109 }
110 }
111
112 fn verify_token_material(
117 token: &DelegatedToken,
118 max_cert_ttl_secs: u64,
119 max_token_ttl_secs: u64,
120 required_scopes: &[String],
121 now_secs: u64,
122 ) -> Result<Principal, Error> {
123 AuthOps::verify_token(VerifyDelegatedTokenRuntimeInput {
124 token,
125 max_cert_ttl_secs,
126 max_token_ttl_secs,
127 required_scopes,
128 now_secs,
129 })
130 .map(|verified| verified.subject)
131 .map_err(Self::map_auth_error)
132 }
133
134 pub async fn local_shard_public_key_sec1() -> Result<Vec<u8>, Error> {
136 AuthOps::local_shard_public_key_sec1(IcOps::canister_self())
137 .await
138 .map_err(Self::map_auth_error)
139 }
140
141 pub async fn issue_token(request: DelegatedTokenIssueRequest) -> Result<DelegatedToken, Error> {
143 let label = "delegated token issue";
144 let metadata = Self::token_replay_metadata(request.metadata, "delegated token issue")?;
145 let operation_id = OperationId::from_bytes(metadata.request_id);
146 let command_kind = Self::token_issue_replay_command_kind();
147 let caller = IcOps::msg_caller();
148 let actor = ReplayActor::direct_caller(caller);
149 let payload_hash = Self::token_issue_replay_payload_hash(&command_kind, &actor, &request);
150 let token = match Self::reserve_token_replay_receipt(
151 command_kind.clone(),
152 metadata,
153 actor,
154 payload_hash,
155 )? {
156 ReplayReceiptDecision::Fresh(token) => {
157 Self::log_token_replay_reserved(label, &command_kind, operation_id, caller);
158 token
159 }
160 decision => {
161 Self::log_token_replay_decision(
162 label,
163 &command_kind,
164 operation_id,
165 caller,
166 &decision,
167 );
168 return Self::map_token_replay_decision(decision, label);
169 }
170 };
171
172 Self::issue_fresh_token_from_proof(
173 token,
174 command_kind,
175 caller,
176 operation_id,
177 label,
178 request,
179 )
180 .await
181 }
182
183 pub async fn mint_token(request: DelegatedTokenMintRequest) -> Result<DelegatedToken, Error> {
185 let label = "delegated token mint";
186 let metadata = Self::token_replay_metadata(request.metadata, "delegated token mint")?;
187 let operation_id = OperationId::from_bytes(metadata.request_id);
188 let command_kind = Self::token_mint_replay_command_kind();
189 let caller = IcOps::msg_caller();
190 let actor = ReplayActor::direct_caller(caller);
191 let payload_hash = Self::token_mint_replay_payload_hash(&command_kind, &actor, &request);
192 let token = match Self::reserve_token_replay_receipt(
193 command_kind.clone(),
194 metadata,
195 actor,
196 payload_hash,
197 )? {
198 ReplayReceiptDecision::Fresh(token) => {
199 Self::log_token_replay_reserved(label, &command_kind, operation_id, caller);
200 token
201 }
202 decision => {
203 Self::log_token_replay_decision(
204 label,
205 &command_kind,
206 operation_id,
207 caller,
208 &decision,
209 );
210 return Self::map_token_replay_decision(decision, label);
211 }
212 };
213
214 let proof = Self::request_delegation(DelegationProofIssueRequest {
215 metadata: Some(metadata),
216 shard_pid: IcOps::canister_self(),
217 aud: request.aud.clone(),
218 grants: request.grants.clone(),
219 cert_ttl_secs: request.cert_ttl_secs,
220 })
221 .await
222 .inspect_err(|_| {
223 abort_reserved_receipt(&token);
224 })?;
225
226 let issue_request = DelegatedTokenIssueRequest {
227 metadata: None,
228 subject: request.subject,
229 aud: request.aud,
230 grants: request.grants,
231 ttl_secs: request.token_ttl_secs,
232 nonce: request.nonce,
233 proof,
234 };
235
236 Self::issue_fresh_token_from_proof(
237 token,
238 command_kind,
239 caller,
240 operation_id,
241 label,
242 issue_request,
243 )
244 .await
245 }
246
247 pub async fn request_delegation(
249 request: DelegationProofIssueRequest,
250 ) -> Result<DelegationProof, Error> {
251 let request = metadata::with_delegation_request_metadata(request);
252 Self::request_delegation_remote(request).await
253 }
254
255 pub async fn issue_delegation_proof(
257 request: DelegationProofIssueRequest,
258 ) -> Result<DelegationProof, Error> {
259 EnvOps::require_root().map_err(Error::from)?;
260 let caller = IcOps::msg_caller();
261 Self::validate_delegation_request_caller(caller, request.shard_pid)?;
262 let max_cert_ttl_secs = Self::delegated_token_max_ttl_secs()?;
263 let metadata = Self::delegation_replay_metadata(request.metadata)?;
264 let command_kind = Self::delegation_replay_command_kind();
265 let actor = ReplayActor::direct_caller(caller);
266 let payload_hash = Self::delegation_replay_payload_hash(&command_kind, &actor, &request);
267 let now_secs = IcOps::now_secs();
268 let replay_input = ReplayReceiptReserveInput::new(
269 command_kind.clone(),
270 OperationId::from_bytes(metadata.request_id),
271 actor,
272 payload_hash,
273 secs_to_ns(now_secs),
274 )
275 .with_expires_at_ns(secs_to_ns(now_secs.saturating_add(metadata.ttl_seconds)));
276
277 let token = match reserve_or_replay_receipt(replay_input)
278 .map_err(Self::map_delegation_replay_store_error)?
279 {
280 ReplayReceiptDecision::Fresh(token) => token,
281 decision => return Self::map_delegation_replay_decision(decision),
282 };
283
284 Self::issue_fresh_delegation_proof(token, command_kind, caller, request, max_cert_ttl_secs)
285 .await
286 }
287
288 async fn issue_fresh_delegation_proof(
289 token: ReplayReceiptToken,
290 command_kind: CommandKind,
291 caller: Principal,
292 request: DelegationProofIssueRequest,
293 max_cert_ttl_secs: u64,
294 ) -> Result<DelegationProof, Error> {
295 let max_token_ttl_secs = request.cert_ttl_secs.min(max_cert_ttl_secs);
296 let prepared = match AuthOps::prepare_delegation_proof(SignDelegationProofInput {
297 audience: request.aud,
298 grants: request.grants,
299 shard_pid: request.shard_pid,
300 cert_ttl_secs: request.cert_ttl_secs,
301 max_token_ttl_secs,
302 max_cert_ttl_secs,
303 issued_at: IcOps::now_secs(),
304 })
305 .await
306 {
307 Ok(prepared) => prepared,
308 Err(err) => {
309 abort_reserved_receipt(&token);
310 return Err(Self::map_auth_error(err));
311 }
312 };
313
314 let cost_permit = match CostGuardOps::reserve(CostGuardRequest {
315 cost_class: crate::replay_policy::CostClass::ThresholdEcdsaSign,
316 command_kind,
317 quota_subject: caller,
318 payer: IcOps::canister_self(),
319 now_secs: IcOps::now_secs(),
320 quota_window_secs: Self::DELEGATION_SIGNING_QUOTA_WINDOW_SECONDS,
321 max_operations_per_window: Self::MAX_DELEGATION_SIGNING_OPERATIONS_PER_WINDOW,
322 current_cycle_balance: MgmtOps::canister_cycle_balance().to_u128(),
323 cycle_reservation_cycles: Self::DELEGATION_SIGNING_CYCLE_RESERVATION_CYCLES,
324 min_cycles_after_reservation: Self::MIN_DELEGATION_SIGNING_CYCLES_AFTER_RESERVATION,
325 }) {
326 Ok(permit) => permit,
327 Err(err) => {
328 abort_reserved_receipt(&token);
329 return Err(Self::map_auth_error(err));
330 }
331 };
332
333 mark_external_effect_in_flight(
334 &token,
335 ExternalEffectDescriptor::ThresholdEcdsaSign {
336 key_id_hash: Self::hash_delegation_effect_key(&prepared.key_name),
337 purpose: EcdsaPurpose::DelegationProof,
338 message_hash: prepared.cert_hash,
339 },
340 secs_to_ns(IcOps::now_secs()),
341 );
342
343 let proof = match AuthOps::sign_prepared_delegation_proof(&cost_permit, prepared).await {
344 Ok(proof) => proof,
345 Err(err) => {
346 let _ = CostGuardOps::recover(&cost_permit, IcOps::now_secs());
347 mark_recovery_required(
348 &token,
349 RecoveryReason::ExternalEffectStatusUnknown,
350 secs_to_ns(IcOps::now_secs()),
351 );
352 return Err(Self::map_auth_error(err));
353 }
354 };
355
356 let response_bytes = match Self::encode_delegation_proof_response(&proof) {
357 Ok(response_bytes) => response_bytes,
358 Err(err) => {
359 let _ = CostGuardOps::recover(&cost_permit, IcOps::now_secs());
360 mark_recovery_required(
361 &token,
362 RecoveryReason::ResponseCommitFailed,
363 secs_to_ns(IcOps::now_secs()),
364 );
365 return Err(err);
366 }
367 };
368
369 if let Err(err) = CostGuardOps::complete(&cost_permit, IcOps::now_secs()) {
370 mark_recovery_required(
371 &token,
372 RecoveryReason::ResponseCommitFailed,
373 secs_to_ns(IcOps::now_secs()),
374 );
375 return Err(Self::map_auth_error(err));
376 }
377
378 commit_receipt_response(
379 &token,
380 Self::DELEGATION_REPLAY_RESPONSE_SCHEMA_VERSION,
381 response_bytes,
382 secs_to_ns(IcOps::now_secs()),
383 );
384 Ok(proof)
385 }
386
387 async fn issue_fresh_token_from_proof(
388 token: ReplayReceiptToken,
389 command_kind: CommandKind,
390 caller: Principal,
391 operation_id: OperationId,
392 label: &'static str,
393 request: DelegatedTokenIssueRequest,
394 ) -> Result<DelegatedToken, Error> {
395 let prepared = match AuthOps::prepare_delegated_token_signature(SignDelegatedTokenInput {
396 proof: request.proof,
397 subject: request.subject,
398 audience: request.aud,
399 grants: request.grants,
400 ttl_secs: request.ttl_secs,
401 nonce: request.nonce,
402 }) {
403 Ok(prepared) => prepared,
404 Err(err) => {
405 abort_reserved_receipt(&token);
406 return Err(Self::map_auth_error(err));
407 }
408 };
409
410 let cost_permit = match CostGuardOps::reserve(Self::token_signing_cost_guard_request(
411 command_kind.clone(),
412 caller,
413 )) {
414 Ok(permit) => permit,
415 Err(err) => {
416 abort_reserved_receipt(&token);
417 return Err(Self::map_auth_error(err));
418 }
419 };
420 Self::log_token_signing_cost_guard_reserved(label, &command_kind, operation_id, caller);
421
422 let effect = AuthOps::delegated_token_signing_effect(&prepared);
423 mark_external_effect_in_flight(&token, effect.clone(), secs_to_ns(IcOps::now_secs()));
424 Self::log_token_replay_effect_marked(label, &command_kind, operation_id, caller, &effect);
425
426 let delegated_token =
427 match AuthOps::sign_prepared_delegated_token(&cost_permit, prepared).await {
428 Ok(delegated_token) => delegated_token,
429 Err(err) => {
430 let _ = CostGuardOps::recover(&cost_permit, IcOps::now_secs());
431 mark_recovery_required(
432 &token,
433 RecoveryReason::ExternalEffectStatusUnknown,
434 secs_to_ns(IcOps::now_secs()),
435 );
436 Self::log_token_replay_recovery_required(
437 label,
438 &command_kind,
439 operation_id,
440 caller,
441 &err,
442 );
443 return Err(Self::map_auth_error(err));
444 }
445 };
446
447 let response_bytes = match Self::encode_delegated_token_response(&delegated_token) {
448 Ok(response_bytes) => response_bytes,
449 Err(err) => {
450 let _ = CostGuardOps::recover(&cost_permit, IcOps::now_secs());
451 mark_recovery_required(
452 &token,
453 RecoveryReason::ResponseCommitFailed,
454 secs_to_ns(IcOps::now_secs()),
455 );
456 Self::log_token_replay_response_commit_failed(
457 label,
458 &command_kind,
459 operation_id,
460 caller,
461 &err,
462 );
463 return Err(err);
464 }
465 };
466
467 if let Err(err) = CostGuardOps::complete(&cost_permit, IcOps::now_secs()) {
468 mark_recovery_required(
469 &token,
470 RecoveryReason::ResponseCommitFailed,
471 secs_to_ns(IcOps::now_secs()),
472 );
473 Self::log_token_replay_response_commit_failed_internal(
474 label,
475 &command_kind,
476 operation_id,
477 caller,
478 &err,
479 );
480 return Err(Self::map_auth_error(err));
481 }
482
483 commit_receipt_response(
484 &token,
485 Self::TOKEN_REPLAY_RESPONSE_SCHEMA_VERSION,
486 response_bytes,
487 secs_to_ns(IcOps::now_secs()),
488 );
489 Self::log_token_replay_commit(label, &command_kind, operation_id, caller);
490 Ok(delegated_token)
491 }
492
493 pub async fn request_role_attestation(
495 request: RoleAttestationRequest,
496 ) -> Result<SignedRoleAttestation, Error> {
497 let request = metadata::with_root_attestation_request_metadata(request);
498 Self::request_role_attestation_remote(request).await
499 }
500
501 pub async fn request_internal_invocation_proof(
503 request: InternalInvocationProofRequest,
504 ) -> Result<SignedInternalInvocationProofV1, Error> {
505 let request = metadata::with_internal_invocation_proof_request_metadata(request);
506 Self::request_internal_invocation_proof_remote(request).await
507 }
508
509 pub async fn attestation_key_set() -> Result<AttestationKeySet, Error> {
511 AuthOps::attestation_key_set()
512 .await
513 .map_err(Self::map_auth_error)
514 }
515
516 pub async fn publish_root_auth_material() -> Result<(), Error> {
518 EnvOps::require_root().map_err(Error::from)?;
519 AuthOps::publish_root_auth_material().await.map_err(|err| {
520 log!(
521 Topic::Auth,
522 Warn,
523 "root auth material publish failed: {err}"
524 );
525 Self::map_auth_error(err)
526 })
527 }
528
529 pub fn replace_attestation_key_set(key_set: AttestationKeySet) {
531 AuthOps::replace_attestation_key_set(key_set);
532 }
533
534 pub async fn verify_role_attestation(
536 attestation: &SignedRoleAttestation,
537 min_accepted_epoch: u64,
538 ) -> Result<(), Error> {
539 crate::workflow::runtime::auth::RuntimeAuthWorkflow::verify_role_attestation(
540 attestation,
541 min_accepted_epoch,
542 )
543 .await
544 .map_err(Self::map_auth_error)
545 }
546
547 pub async fn verify_internal_invocation_proof(
549 proof: &SignedInternalInvocationProofV1,
550 target_method: &str,
551 accepted_roles: &[CanisterRole],
552 ) -> Result<(), Error> {
553 let configured_min_accepted_epoch = ConfigOps::role_attestation_config()
554 .map_err(Error::from)?
555 .min_accepted_epoch_by_role
556 .get(proof.payload.role.as_str())
557 .copied();
558 let min_accepted_epoch =
559 verify_flow::resolve_min_accepted_epoch(0, configured_min_accepted_epoch);
560
561 let caller = IcOps::msg_caller();
562 let self_pid = IcOps::canister_self();
563 let now_secs = IcOps::now_secs();
564 let verifier_subnet = Some(EnvOps::subnet_pid().map_err(Error::from)?);
565 let root_pid = EnvOps::root_pid().map_err(Error::from)?;
566
567 let verify = || {
568 AuthOps::verify_internal_invocation_proof_cached(
569 proof,
570 crate::ops::auth::InternalInvocationProofVerificationInput {
571 caller,
572 self_pid,
573 target_method,
574 accepted_roles,
575 verifier_subnet,
576 now_secs,
577 min_accepted_epoch,
578 },
579 )
580 .map(|_| ())
581 };
582 let refresh = || async {
583 let key_set = RootAuthMaterialClient::new(root_pid)
584 .attestation_key_set()
585 .await?;
586 AuthOps::replace_attestation_key_set(key_set);
587 Ok(())
588 };
589
590 match verify_flow::verify_role_attestation_with_single_refresh(verify, refresh).await {
591 Ok(()) => Ok(()),
592 Err(verify_flow::RoleAttestationVerifyFlowError::Initial(err)) => {
593 verify_flow::record_attestation_verifier_rejection(&err);
594 log!(
595 Topic::Auth,
596 Warn,
597 "internal invocation proof rejected phase=cached local={} caller={} subject={} role={} key_id={} audience={} method={} epoch={} error={}",
598 self_pid,
599 caller,
600 proof.payload.subject,
601 proof.payload.role,
602 proof.key_id,
603 proof.payload.audience,
604 proof.payload.audience_method,
605 proof.payload.epoch,
606 err
607 );
608 Err(Self::map_internal_invocation_verify_error(err))
609 }
610 Err(verify_flow::RoleAttestationVerifyFlowError::Refresh { trigger, source }) => {
611 verify_flow::record_attestation_verifier_rejection(&trigger);
612 record_attestation_refresh_failed();
613 log!(
614 Topic::Auth,
615 Warn,
616 "internal invocation proof refresh failed local={} caller={} key_id={} error={}",
617 self_pid,
618 caller,
619 proof.key_id,
620 source
621 );
622 Err(Self::map_auth_error(source))
623 }
624 Err(verify_flow::RoleAttestationVerifyFlowError::PostRefresh(err)) => {
625 verify_flow::record_attestation_verifier_rejection(&err);
626 log!(
627 Topic::Auth,
628 Warn,
629 "internal invocation proof rejected phase=post_refresh local={} caller={} subject={} role={} key_id={} audience={} method={} epoch={} error={}",
630 self_pid,
631 caller,
632 proof.payload.subject,
633 proof.payload.role,
634 proof.key_id,
635 proof.payload.audience,
636 proof.payload.audience_method,
637 proof.payload.epoch,
638 err
639 );
640 Err(Self::map_internal_invocation_verify_error(err))
641 }
642 }
643 }
644
645 fn delegated_token_max_ttl_secs() -> Result<u64, Error> {
647 let cfg = ConfigOps::delegated_tokens_config().map_err(Error::from)?;
648 if !cfg.enabled {
649 return Err(Error::forbidden(Self::DELEGATED_TOKENS_DISABLED));
650 }
651
652 Ok(cfg
653 .max_ttl_secs
654 .unwrap_or(Self::MAX_DELEGATED_SESSION_TTL_SECS))
655 }
656
657 fn validate_delegation_request_caller(
658 caller: Principal,
659 shard_pid: Principal,
660 ) -> Result<(), Error> {
661 if caller == shard_pid {
662 return Ok(());
663 }
664
665 Err(Error::forbidden(format!(
666 "delegation request caller {caller} must match shard_pid {shard_pid}"
667 )))
668 }
669
670 fn delegation_replay_metadata(
671 metadata: Option<RootRequestMetadata>,
672 ) -> Result<RootRequestMetadata, Error> {
673 let metadata = metadata.ok_or_else(Error::operation_id_required)?;
674 if metadata.ttl_seconds == 0 {
675 return Err(Error::invalid(
676 "delegation proof replay metadata ttl_seconds must be greater than zero",
677 ));
678 }
679 if metadata.ttl_seconds > Self::MAX_DELEGATION_REPLAY_TTL_SECONDS {
680 return Err(Error::invalid(format!(
681 "delegation proof replay metadata ttl_seconds={} exceeds max {}",
682 metadata.ttl_seconds,
683 Self::MAX_DELEGATION_REPLAY_TTL_SECONDS
684 )));
685 }
686 Ok(metadata)
687 }
688
689 fn token_replay_metadata(
690 metadata: Option<RootRequestMetadata>,
691 label: &str,
692 ) -> Result<RootRequestMetadata, Error> {
693 let metadata = metadata.ok_or_else(Error::operation_id_required)?;
694 if metadata.ttl_seconds == 0 {
695 return Err(Error::invalid(format!(
696 "{label} replay metadata ttl_seconds must be greater than zero"
697 )));
698 }
699 if metadata.ttl_seconds > Self::MAX_TOKEN_REPLAY_TTL_SECONDS {
700 return Err(Error::invalid(format!(
701 "{label} replay metadata ttl_seconds={} exceeds max {}",
702 metadata.ttl_seconds,
703 Self::MAX_TOKEN_REPLAY_TTL_SECONDS
704 )));
705 }
706 Ok(metadata)
707 }
708
709 fn delegation_replay_command_kind() -> CommandKind {
710 CommandKind::new(Self::DELEGATION_REPLAY_COMMAND_KIND)
711 .expect("delegation replay command kind is a valid static label")
712 }
713
714 fn token_issue_replay_command_kind() -> CommandKind {
715 CommandKind::new(Self::TOKEN_ISSUE_REPLAY_COMMAND_KIND)
716 .expect("delegated-token issue replay command kind is a valid static label")
717 }
718
719 fn token_mint_replay_command_kind() -> CommandKind {
720 CommandKind::new(Self::TOKEN_MINT_REPLAY_COMMAND_KIND)
721 .expect("delegated-token mint replay command kind is a valid static label")
722 }
723
724 fn delegation_replay_payload_hash(
725 command_kind: &CommandKind,
726 actor: &ReplayActor,
727 request: &DelegationProofIssueRequest,
728 ) -> [u8; 32] {
729 let mut hasher = ReplayPayloadHasher::new(command_kind, actor);
730 hasher.hash_principal(&request.shard_pid);
731 Self::hash_delegation_audience(&mut hasher, &request.aud);
732 Self::hash_delegated_role_grants(&mut hasher, &request.grants);
733 hasher.hash_u64(request.cert_ttl_secs);
734 hasher.finish()
735 }
736
737 fn token_mint_replay_payload_hash(
738 command_kind: &CommandKind,
739 actor: &ReplayActor,
740 request: &DelegatedTokenMintRequest,
741 ) -> [u8; 32] {
742 let mut hasher = ReplayPayloadHasher::new(command_kind, actor);
743 hasher.hash_principal(&request.subject);
744 Self::hash_delegation_audience(&mut hasher, &request.aud);
745 Self::hash_delegated_role_grants(&mut hasher, &request.grants);
746 hasher.hash_u64(request.token_ttl_secs);
747 hasher.hash_u64(request.cert_ttl_secs);
748 hasher.hash_bytes(&request.nonce);
749 hasher.finish()
750 }
751
752 fn token_issue_replay_payload_hash(
753 command_kind: &CommandKind,
754 actor: &ReplayActor,
755 request: &DelegatedTokenIssueRequest,
756 ) -> [u8; 32] {
757 let mut hasher = ReplayPayloadHasher::new(command_kind, actor);
758 Self::hash_delegation_proof(&mut hasher, &request.proof);
759 hasher.hash_principal(&request.subject);
760 Self::hash_delegation_audience(&mut hasher, &request.aud);
761 Self::hash_delegated_role_grants(&mut hasher, &request.grants);
762 hasher.hash_u64(request.ttl_secs);
763 hasher.hash_bytes(&request.nonce);
764 hasher.finish()
765 }
766
767 fn hash_delegation_audience(hasher: &mut ReplayPayloadHasher, aud: &DelegationAudience) {
768 match aud {
769 DelegationAudience::Canic => {
770 hasher.hash_str("canic");
771 }
772 DelegationAudience::Project(project) => {
773 hasher.hash_str("project");
774 hasher.hash_str(project);
775 }
776 }
777 }
778
779 fn hash_delegated_role_grants(hasher: &mut ReplayPayloadHasher, grants: &[DelegatedRoleGrant]) {
780 hasher.hash_u64(grants.len() as u64);
781 for grant in grants {
782 hasher.hash_role(&grant.target);
783 Self::hash_string_vec(hasher, &grant.scopes);
784 }
785 }
786
787 fn hash_delegation_proof(hasher: &mut ReplayPayloadHasher, proof: &DelegationProof) {
788 Self::hash_delegation_cert(hasher, &proof.cert);
789 hasher.hash_bytes(&proof.root_sig);
790 }
791
792 fn hash_delegation_cert(hasher: &mut ReplayPayloadHasher, cert: &DelegationCert) {
793 hasher.hash_u64(u64::from(cert.version));
794 hasher.hash_principal(&cert.root_pid);
795 hasher.hash_str(&cert.root_key_id);
796 hasher.hash_bytes(&cert.root_key_hash);
797 Self::hash_signature_algorithm(hasher, cert.alg);
798 hasher.hash_principal(&cert.shard_pid);
799 hasher.hash_str(&cert.shard_key_id);
800 hasher.hash_bytes(&cert.shard_public_key_sec1);
801 hasher.hash_bytes(&cert.shard_key_hash);
802 Self::hash_shard_key_binding(hasher, cert.shard_key_binding);
803 hasher.hash_u64(cert.issued_at);
804 hasher.hash_u64(cert.expires_at);
805 hasher.hash_u64(cert.max_token_ttl_secs);
806 Self::hash_delegation_audience(hasher, &cert.aud);
807 Self::hash_delegated_role_grants(hasher, &cert.grants);
808 }
809
810 fn hash_signature_algorithm(hasher: &mut ReplayPayloadHasher, alg: SignatureAlgorithm) {
811 match alg {
812 SignatureAlgorithm::EcdsaP256Sha256 => hasher.hash_str("EcdsaP256Sha256"),
813 }
814 }
815
816 fn hash_shard_key_binding(hasher: &mut ReplayPayloadHasher, binding: ShardKeyBinding) {
817 match binding {
818 ShardKeyBinding::IcThresholdEcdsa {
819 key_name_hash,
820 derivation_path_hash,
821 } => {
822 hasher.hash_str("IcThresholdEcdsa");
823 hasher.hash_bytes(&key_name_hash);
824 hasher.hash_bytes(&derivation_path_hash);
825 }
826 }
827 }
828
829 fn hash_string_vec(hasher: &mut ReplayPayloadHasher, values: &[String]) {
830 hasher.hash_u64(values.len() as u64);
831 for value in values {
832 hasher.hash_str(value);
833 }
834 }
835
836 fn reserve_token_replay_receipt(
837 command_kind: CommandKind,
838 metadata: RootRequestMetadata,
839 actor: ReplayActor,
840 payload_hash: [u8; 32],
841 ) -> Result<ReplayReceiptDecision, Error> {
842 let now_secs = IcOps::now_secs();
843 let replay_input = ReplayReceiptReserveInput::new(
844 command_kind,
845 OperationId::from_bytes(metadata.request_id),
846 actor,
847 payload_hash,
848 secs_to_ns(now_secs),
849 )
850 .with_expires_at_ns(secs_to_ns(now_secs.saturating_add(metadata.ttl_seconds)));
851
852 reserve_or_replay_receipt(replay_input).map_err(Self::map_delegation_replay_store_error)
853 }
854
855 fn token_signing_cost_guard_request(
856 command_kind: CommandKind,
857 caller: Principal,
858 ) -> CostGuardRequest {
859 Self::token_signing_cost_guard_request_at(
860 command_kind,
861 caller,
862 IcOps::canister_self(),
863 IcOps::now_secs(),
864 MgmtOps::canister_cycle_balance().to_u128(),
865 )
866 }
867
868 const fn token_signing_cost_guard_request_at(
869 command_kind: CommandKind,
870 caller: Principal,
871 payer: Principal,
872 now_secs: u64,
873 current_cycle_balance: u128,
874 ) -> CostGuardRequest {
875 CostGuardRequest {
876 cost_class: crate::replay_policy::CostClass::ThresholdEcdsaSign,
877 command_kind,
878 quota_subject: caller,
879 payer,
880 now_secs,
881 quota_window_secs: Self::TOKEN_SIGNING_QUOTA_WINDOW_SECONDS,
882 max_operations_per_window: Self::MAX_TOKEN_SIGNING_OPERATIONS_PER_WINDOW,
883 current_cycle_balance,
884 cycle_reservation_cycles: Self::TOKEN_SIGNING_CYCLE_RESERVATION_CYCLES,
885 min_cycles_after_reservation: Self::MIN_TOKEN_SIGNING_CYCLES_AFTER_RESERVATION,
886 }
887 }
888
889 fn map_delegation_replay_decision(
890 decision: ReplayReceiptDecision,
891 ) -> Result<DelegationProof, Error> {
892 match decision {
893 ReplayReceiptDecision::Fresh(_) => {
894 Err(Error::invariant("fresh delegation replay decision escaped"))
895 }
896 ReplayReceiptDecision::ReturnCommitted(receipt) => {
897 Self::decode_delegation_proof_response(&receipt)
898 }
899 ReplayReceiptDecision::OperationInProgress => Err(Error::conflict(
900 "delegation proof request is already in progress; retry later with the same request id",
901 )),
902 ReplayReceiptDecision::ActorMismatch => Err(Error::conflict(
903 "delegation proof request id was reused by a different caller",
904 )),
905 ReplayReceiptDecision::PayloadMismatch => Err(Error::conflict(
906 "delegation proof request id was reused with a different payload",
907 )),
908 ReplayReceiptDecision::Expired => Err(Error::conflict(
909 "delegation proof replay receipt expired; retry with a new request id",
910 )),
911 ReplayReceiptDecision::RecoveryRequired(reason) => Err(Error::conflict(format!(
912 "delegation proof request requires recovery before replay: {reason:?}"
913 ))),
914 ReplayReceiptDecision::TerminalFailed {
915 error_code,
916 error_bytes,
917 error_bytes_truncated,
918 } => Err(Error::conflict(format!(
919 "delegation proof request previously failed: {error_code:?}; error_bytes_len={}; truncated={error_bytes_truncated}",
920 error_bytes.len()
921 ))),
922 ReplayReceiptDecision::PendingActorQuotaExceeded { max_pending, .. } => {
923 Err(Error::exhausted(format!(
924 "delegation proof pending replay receipt quota exceeded for caller; max_pending={max_pending}"
925 )))
926 }
927 ReplayReceiptDecision::PendingCommandQuotaExceeded { max_pending, .. } => {
928 Err(Error::exhausted(format!(
929 "delegation proof pending replay receipt quota exceeded for command kind; max_pending={max_pending}"
930 )))
931 }
932 }
933 }
934
935 fn map_token_replay_decision(
936 decision: ReplayReceiptDecision,
937 label: &str,
938 ) -> Result<DelegatedToken, Error> {
939 match decision {
940 ReplayReceiptDecision::Fresh(_) => Err(Error::invariant(format!(
941 "fresh {label} replay decision escaped"
942 ))),
943 ReplayReceiptDecision::ReturnCommitted(receipt) => {
944 Self::decode_delegated_token_response(&receipt)
945 }
946 ReplayReceiptDecision::OperationInProgress => Err(Error::conflict(format!(
947 "{label} request is already in progress; retry later with the same request id"
948 ))),
949 ReplayReceiptDecision::ActorMismatch => Err(Error::conflict(format!(
950 "{label} request id was reused by a different caller"
951 ))),
952 ReplayReceiptDecision::PayloadMismatch => Err(Error::conflict(format!(
953 "{label} request id was reused with a different payload"
954 ))),
955 ReplayReceiptDecision::Expired => Err(Error::conflict(format!(
956 "{label} replay receipt expired; retry with a new request id"
957 ))),
958 ReplayReceiptDecision::RecoveryRequired(reason) => Err(Error::conflict(format!(
959 "{label} request requires recovery before replay: {reason:?}"
960 ))),
961 ReplayReceiptDecision::TerminalFailed {
962 error_code,
963 error_bytes,
964 error_bytes_truncated,
965 } => Err(Error::conflict(format!(
966 "{label} request previously failed: {error_code:?}; error_bytes_len={}; truncated={error_bytes_truncated}",
967 error_bytes.len()
968 ))),
969 ReplayReceiptDecision::PendingActorQuotaExceeded { max_pending, .. } => {
970 Err(Error::exhausted(format!(
971 "{label} pending replay receipt quota exceeded for caller; max_pending={max_pending}"
972 )))
973 }
974 ReplayReceiptDecision::PendingCommandQuotaExceeded { max_pending, .. } => {
975 Err(Error::exhausted(format!(
976 "{label} pending replay receipt quota exceeded for command kind; max_pending={max_pending}"
977 )))
978 }
979 }
980 }
981
982 fn log_token_replay_reserved(
983 label: &str,
984 command_kind: &CommandKind,
985 operation_id: OperationId,
986 caller: Principal,
987 ) {
988 log!(
989 Topic::Auth,
990 Info,
991 "{} replay receipt reserved command_kind={} operation_id={} caller={}",
992 label,
993 command_kind.as_str(),
994 operation_id,
995 caller
996 );
997 }
998
999 fn log_token_replay_decision(
1000 label: &str,
1001 command_kind: &CommandKind,
1002 operation_id: OperationId,
1003 caller: Principal,
1004 decision: &ReplayReceiptDecision,
1005 ) {
1006 match decision {
1007 ReplayReceiptDecision::ReturnCommitted(_) => log!(
1008 Topic::Auth,
1009 Info,
1010 "{} committed replay returned command_kind={} operation_id={} caller={}",
1011 label,
1012 command_kind.as_str(),
1013 operation_id,
1014 caller
1015 ),
1016 _ => log!(
1017 Topic::Auth,
1018 Warn,
1019 "{} replay decision blocked command_kind={} operation_id={} caller={} decision={}",
1020 label,
1021 command_kind.as_str(),
1022 operation_id,
1023 caller,
1024 Self::token_replay_decision_name(decision)
1025 ),
1026 }
1027 }
1028
1029 fn log_token_signing_cost_guard_reserved(
1030 label: &str,
1031 command_kind: &CommandKind,
1032 operation_id: OperationId,
1033 caller: Principal,
1034 ) {
1035 log!(
1036 Topic::Auth,
1037 Info,
1038 "{} signing cost guard reserved command_kind={} operation_id={} caller={}",
1039 label,
1040 command_kind.as_str(),
1041 operation_id,
1042 caller
1043 );
1044 }
1045
1046 fn log_token_replay_effect_marked(
1047 label: &str,
1048 command_kind: &CommandKind,
1049 operation_id: OperationId,
1050 caller: Principal,
1051 effect: &ExternalEffectDescriptor,
1052 ) {
1053 log!(
1054 Topic::Auth,
1055 Info,
1056 "{} replay effect marked effect={} command_kind={} operation_id={} caller={}",
1057 label,
1058 Self::token_effect_name(effect),
1059 command_kind.as_str(),
1060 operation_id,
1061 caller
1062 );
1063 }
1064
1065 fn log_token_replay_recovery_required(
1066 label: &str,
1067 command_kind: &CommandKind,
1068 operation_id: OperationId,
1069 caller: Principal,
1070 err: &crate::InternalError,
1071 ) {
1072 let (error_class, error_origin) = err.log_fields();
1073 log!(
1074 Topic::Auth,
1075 Error,
1076 "{} replay recovery required effect=threshold_ecdsa_sign_delegated_token command_kind={} operation_id={} caller={} error_class={} error_origin={}",
1077 label,
1078 command_kind.as_str(),
1079 operation_id,
1080 caller,
1081 error_class,
1082 error_origin
1083 );
1084 }
1085
1086 fn log_token_replay_response_commit_failed(
1087 label: &str,
1088 command_kind: &CommandKind,
1089 operation_id: OperationId,
1090 caller: Principal,
1091 err: &Error,
1092 ) {
1093 log!(
1094 Topic::Auth,
1095 Error,
1096 "{} replay response commit failed command_kind={} operation_id={} caller={} error_code={:?}",
1097 label,
1098 command_kind.as_str(),
1099 operation_id,
1100 caller,
1101 err.code
1102 );
1103 }
1104
1105 fn log_token_replay_response_commit_failed_internal(
1106 label: &str,
1107 command_kind: &CommandKind,
1108 operation_id: OperationId,
1109 caller: Principal,
1110 err: &crate::InternalError,
1111 ) {
1112 let (error_class, error_origin) = err.log_fields();
1113 log!(
1114 Topic::Auth,
1115 Error,
1116 "{} replay response commit failed command_kind={} operation_id={} caller={} error_class={} error_origin={}",
1117 label,
1118 command_kind.as_str(),
1119 operation_id,
1120 caller,
1121 error_class,
1122 error_origin
1123 );
1124 }
1125
1126 fn log_token_replay_commit(
1127 label: &str,
1128 command_kind: &CommandKind,
1129 operation_id: OperationId,
1130 caller: Principal,
1131 ) {
1132 log!(
1133 Topic::Auth,
1134 Ok,
1135 "{} replay response committed command_kind={} operation_id={} caller={}",
1136 label,
1137 command_kind.as_str(),
1138 operation_id,
1139 caller
1140 );
1141 }
1142
1143 const fn token_replay_decision_name(decision: &ReplayReceiptDecision) -> &'static str {
1144 match decision {
1145 ReplayReceiptDecision::Fresh(_) => "fresh",
1146 ReplayReceiptDecision::ReturnCommitted(_) => "return_committed",
1147 ReplayReceiptDecision::OperationInProgress => "operation_in_progress",
1148 ReplayReceiptDecision::ActorMismatch => "actor_mismatch",
1149 ReplayReceiptDecision::PayloadMismatch => "payload_mismatch",
1150 ReplayReceiptDecision::Expired => "expired",
1151 ReplayReceiptDecision::RecoveryRequired(_) => "recovery_required",
1152 ReplayReceiptDecision::TerminalFailed { .. } => "terminal_failed",
1153 ReplayReceiptDecision::PendingActorQuotaExceeded { .. } => {
1154 "pending_actor_quota_exceeded"
1155 }
1156 ReplayReceiptDecision::PendingCommandQuotaExceeded { .. } => {
1157 "pending_command_quota_exceeded"
1158 }
1159 }
1160 }
1161
1162 const fn token_effect_name(effect: &ExternalEffectDescriptor) -> &'static str {
1163 match effect {
1164 ExternalEffectDescriptor::ThresholdEcdsaSign {
1165 purpose: EcdsaPurpose::DelegatedToken,
1166 ..
1167 } => "threshold_ecdsa_sign_delegated_token",
1168 ExternalEffectDescriptor::ThresholdEcdsaSign { .. } => "threshold_ecdsa_sign",
1169 ExternalEffectDescriptor::ManagementCreateCanister { .. } => {
1170 "management_create_canister"
1171 }
1172 ExternalEffectDescriptor::ManagementCall { .. } => "management_call",
1173 ExternalEffectDescriptor::IcpTransfer { .. } => "icp_transfer",
1174 }
1175 }
1176
1177 fn map_delegation_replay_store_error(err: ReplayReceiptStoreError) -> Error {
1178 match err {
1179 ReplayReceiptStoreError::ReceiptDecodeFailed(message) => Error::internal(format!(
1180 "failed to decode delegation replay receipt: {message}"
1181 )),
1182 }
1183 }
1184
1185 fn encode_delegation_proof_response(proof: &DelegationProof) -> Result<Vec<u8>, Error> {
1186 encode_one(proof).map_err(|err| {
1187 Error::internal(format!(
1188 "failed to encode delegation proof replay response: {err}"
1189 ))
1190 })
1191 }
1192
1193 fn encode_delegated_token_response(token: &DelegatedToken) -> Result<Vec<u8>, Error> {
1194 encode_one(token).map_err(|err| {
1195 Error::internal(format!(
1196 "failed to encode delegated token replay response: {err}"
1197 ))
1198 })
1199 }
1200
1201 fn decode_delegation_proof_response(
1202 receipt: &crate::ops::replay::model::ReplayReceipt,
1203 ) -> Result<DelegationProof, Error> {
1204 let response_schema_version = receipt.response_schema_version.ok_or_else(|| {
1205 Error::internal("delegation replay receipt is missing response schema version")
1206 })?;
1207 if response_schema_version != Self::DELEGATION_REPLAY_RESPONSE_SCHEMA_VERSION {
1208 return Err(Error::internal(format!(
1209 "unsupported delegation replay response schema version {response_schema_version}"
1210 )));
1211 }
1212 let response_bytes = receipt.response_bytes.as_deref().ok_or_else(|| {
1213 Error::internal("delegation replay receipt is missing response bytes")
1214 })?;
1215 decode_one(response_bytes).map_err(|err| {
1216 Error::internal(format!(
1217 "failed to decode delegation proof replay response: {err}"
1218 ))
1219 })
1220 }
1221
1222 fn decode_delegated_token_response(
1223 receipt: &crate::ops::replay::model::ReplayReceipt,
1224 ) -> Result<DelegatedToken, Error> {
1225 let response_schema_version = receipt.response_schema_version.ok_or_else(|| {
1226 Error::internal("delegated token replay receipt is missing response schema version")
1227 })?;
1228 if response_schema_version != Self::TOKEN_REPLAY_RESPONSE_SCHEMA_VERSION {
1229 return Err(Error::internal(format!(
1230 "unsupported delegated token replay response schema version {response_schema_version}"
1231 )));
1232 }
1233 let response_bytes = receipt.response_bytes.as_deref().ok_or_else(|| {
1234 Error::internal("delegated token replay receipt is missing response bytes")
1235 })?;
1236 decode_one(response_bytes).map_err(|err| {
1237 Error::internal(format!(
1238 "failed to decode delegated token replay response: {err}"
1239 ))
1240 })
1241 }
1242
1243 fn hash_delegation_effect_key(key_name: &str) -> [u8; 32] {
1244 let mut hasher = Sha256::new();
1245 hasher.update(b"canic-delegation-proof-effect-key:v1");
1246 hasher.update(key_name.as_bytes());
1247 hasher.finalize().into()
1248 }
1249}
1250
1251impl AuthApi {
1252 async fn request_delegation_remote(
1254 request: DelegationProofIssueRequest,
1255 ) -> Result<DelegationProof, Error> {
1256 let root_pid = EnvOps::root_pid().map_err(Error::from)?;
1257 RootAuthMaterialClient::new(root_pid)
1258 .request_delegation(request)
1259 .await
1260 .map_err(Self::map_auth_error)
1261 }
1262
1263 pub async fn request_role_attestation_root(
1265 request: RoleAttestationRequest,
1266 ) -> Result<SignedRoleAttestation, Error> {
1267 let request = metadata::with_root_attestation_request_metadata(request);
1268 let response = RootResponseWorkflow::response(RootRequest::issue_role_attestation(request))
1269 .await
1270 .map_err(Self::map_auth_error)?;
1271
1272 match response {
1273 RootCapabilityResponse::RoleAttestationIssued(response) => Ok(response),
1274 _ => Err(Error::internal(
1275 "invalid root response type for role attestation request",
1276 )),
1277 }
1278 }
1279
1280 pub async fn request_internal_invocation_proof_root(
1282 request: InternalInvocationProofRequest,
1283 ) -> Result<SignedInternalInvocationProofV1, Error> {
1284 let request = metadata::with_internal_invocation_proof_request_metadata(request);
1285 let response =
1286 RootResponseWorkflow::response(RootRequest::issue_internal_invocation_proof(request))
1287 .await
1288 .map_err(Self::map_auth_error)?;
1289
1290 match response {
1291 RootCapabilityResponse::InternalInvocationProofIssued(response) => Ok(response),
1292 _ => Err(Error::internal(
1293 "invalid root response type for internal invocation proof request",
1294 )),
1295 }
1296 }
1297
1298 async fn request_role_attestation_remote(
1300 request: RoleAttestationRequest,
1301 ) -> Result<SignedRoleAttestation, Error> {
1302 let root_pid = EnvOps::root_pid().map_err(Error::from)?;
1303 RootAuthMaterialClient::new(root_pid)
1304 .request_role_attestation(request)
1305 .await
1306 .map_err(Self::map_auth_error)
1307 }
1308
1309 async fn request_internal_invocation_proof_remote(
1311 request: InternalInvocationProofRequest,
1312 ) -> Result<SignedInternalInvocationProofV1, Error> {
1313 let root_pid = EnvOps::root_pid().map_err(Error::from)?;
1314 RootAuthMaterialClient::new(root_pid)
1315 .request_internal_invocation_proof(request)
1316 .await
1317 .map_err(Self::map_auth_error)
1318 }
1319}
1320
1321#[cfg(test)]
1322mod tests {
1323 use super::AuthApi;
1324 use crate::{
1325 cdk::types::Principal,
1326 dto::{
1327 auth::{
1328 DelegatedRoleGrant, DelegatedToken, DelegatedTokenClaims,
1329 DelegatedTokenIssueRequest, DelegatedTokenMintRequest, DelegationAudience,
1330 DelegationCert, DelegationProof, DelegationProofIssueRequest, ShardKeyBinding,
1331 SignatureAlgorithm,
1332 },
1333 error::ErrorCode,
1334 rpc::RootRequestMetadata,
1335 },
1336 ops::{
1337 auth::{AuthExpiryError, AuthOpsError},
1338 cost_guard::CostGuardOps,
1339 replay::{
1340 model::{ReplayActor, ReplayReceiptStatus},
1341 receipt::{ReplayReceiptDecision, commit_receipt_response},
1342 },
1343 storage::replay::ReplayReceiptOps,
1344 },
1345 replay_policy::CostClass,
1346 storage::stable::intent::IntentStore,
1347 };
1348
1349 fn p(id: u8) -> Principal {
1350 Principal::from_slice(&[id; 29])
1351 }
1352
1353 fn delegation_request(metadata_id: u8) -> DelegationProofIssueRequest {
1354 DelegationProofIssueRequest {
1355 metadata: Some(meta(metadata_id, 60)),
1356 shard_pid: p(2),
1357 aud: DelegationAudience::Project("test".to_string()),
1358 grants: vec![grant("project_instance", &["canic.verify"])],
1359 cert_ttl_secs: 60,
1360 }
1361 }
1362
1363 fn grant(role: &str, scopes: &[&str]) -> DelegatedRoleGrant {
1364 DelegatedRoleGrant {
1365 target: crate::ids::CanisterRole::owned(role.to_string()),
1366 scopes: scopes.iter().map(|scope| (*scope).to_string()).collect(),
1367 }
1368 }
1369
1370 fn meta(id: u8, ttl_seconds: u64) -> RootRequestMetadata {
1371 RootRequestMetadata {
1372 request_id: [id; 32],
1373 ttl_seconds,
1374 }
1375 }
1376
1377 fn delegation_proof() -> DelegationProof {
1378 DelegationProof {
1379 cert: DelegationCert {
1380 version: 1,
1381 root_pid: p(1),
1382 root_key_id: "root-key".to_string(),
1383 root_key_hash: [2; 32],
1384 alg: SignatureAlgorithm::EcdsaP256Sha256,
1385 shard_pid: p(2),
1386 shard_key_id: "shard-key".to_string(),
1387 shard_public_key_sec1: vec![3; 33],
1388 shard_key_hash: [4; 32],
1389 shard_key_binding: ShardKeyBinding::IcThresholdEcdsa {
1390 key_name_hash: [5; 32],
1391 derivation_path_hash: [6; 32],
1392 },
1393 issued_at: 10,
1394 expires_at: 100,
1395 max_token_ttl_secs: 60,
1396 aud: DelegationAudience::Project("test".to_string()),
1397 grants: vec![grant("project_instance", &["canic.verify"])],
1398 },
1399 root_sig: vec![7; 64],
1400 }
1401 }
1402
1403 fn mint_request(metadata_id: u8) -> DelegatedTokenMintRequest {
1404 DelegatedTokenMintRequest {
1405 metadata: Some(meta(metadata_id, 60)),
1406 subject: p(8),
1407 aud: DelegationAudience::Project("test".to_string()),
1408 grants: vec![grant("project_instance", &["canic.verify"])],
1409 token_ttl_secs: 30,
1410 cert_ttl_secs: 60,
1411 nonce: [9; 16],
1412 }
1413 }
1414
1415 fn issue_request(metadata_id: u8) -> DelegatedTokenIssueRequest {
1416 DelegatedTokenIssueRequest {
1417 metadata: Some(meta(metadata_id, 60)),
1418 proof: delegation_proof(),
1419 subject: p(8),
1420 aud: DelegationAudience::Project("test".to_string()),
1421 grants: vec![grant("project_instance", &["canic.verify"])],
1422 ttl_secs: 30,
1423 nonce: [9; 16],
1424 }
1425 }
1426
1427 fn delegated_token(nonce_byte: u8) -> DelegatedToken {
1428 DelegatedToken {
1429 claims: DelegatedTokenClaims {
1430 version: 1,
1431 subject: p(8),
1432 issuer_shard_pid: p(2),
1433 cert_hash: [11; 32],
1434 issued_at: 20,
1435 expires_at: 50,
1436 aud: DelegationAudience::Project("test".to_string()),
1437 grants: vec![grant("project_instance", &["canic.verify"])],
1438 nonce: [nonce_byte; 16],
1439 },
1440 proof: delegation_proof(),
1441 shard_sig: vec![12; 64],
1442 }
1443 }
1444
1445 fn reserve_mint_receipt(
1446 request: &DelegatedTokenMintRequest,
1447 actor: ReplayActor,
1448 ) -> ReplayReceiptDecision {
1449 let command_kind = AuthApi::token_mint_replay_command_kind();
1450 let metadata = request.metadata.expect("mint request metadata");
1451 let payload_hash = AuthApi::token_mint_replay_payload_hash(&command_kind, &actor, request);
1452 AuthApi::reserve_token_replay_receipt(command_kind, metadata, actor, payload_hash)
1453 .expect("mint receipt reservation")
1454 }
1455
1456 #[test]
1457 fn internal_invocation_not_yet_valid_maps_to_non_retryable_proof_expiry() {
1458 let err = AuthApi::map_internal_invocation_verify_error(AuthOpsError::Expiry(
1459 AuthExpiryError::AttestationNotYetValid {
1460 issued_at: 20,
1461 now_secs: 10,
1462 },
1463 ));
1464
1465 assert_eq!(err.code, ErrorCode::AuthProofExpired);
1466 }
1467
1468 #[test]
1469 fn delegation_request_caller_must_match_requested_shard() {
1470 AuthApi::validate_delegation_request_caller(p(2), p(2)).expect("matching shard");
1471
1472 let err = AuthApi::validate_delegation_request_caller(p(1), p(2))
1473 .expect_err("mismatched caller must fail");
1474
1475 assert_eq!(err.code, ErrorCode::Forbidden);
1476 }
1477
1478 #[test]
1479 fn delegation_replay_metadata_rejects_missing_or_invalid_ttl() {
1480 let missing = AuthApi::delegation_replay_metadata(None).expect_err("metadata is required");
1481 assert_eq!(missing.code, ErrorCode::OperationIdRequired);
1482
1483 let zero = AuthApi::delegation_replay_metadata(Some(RootRequestMetadata {
1484 request_id: [1; 32],
1485 ttl_seconds: 0,
1486 }))
1487 .expect_err("zero ttl is invalid");
1488 assert_eq!(zero.code, ErrorCode::InvalidInput);
1489
1490 let too_large = AuthApi::delegation_replay_metadata(Some(RootRequestMetadata {
1491 request_id: [1; 32],
1492 ttl_seconds: AuthApi::MAX_DELEGATION_REPLAY_TTL_SECONDS + 1,
1493 }))
1494 .expect_err("oversized ttl is invalid");
1495 assert_eq!(too_large.code, ErrorCode::InvalidInput);
1496 }
1497
1498 #[test]
1499 fn delegation_replay_payload_hash_ignores_metadata() {
1500 let command_kind = AuthApi::delegation_replay_command_kind();
1501 let actor = crate::ops::replay::model::ReplayActor::direct_caller(p(2));
1502 let a = delegation_request(1);
1503 let b = delegation_request(9);
1504
1505 assert_eq!(
1506 AuthApi::delegation_replay_payload_hash(&command_kind, &actor, &a),
1507 AuthApi::delegation_replay_payload_hash(&command_kind, &actor, &b)
1508 );
1509 }
1510
1511 #[test]
1512 fn delegated_token_replay_metadata_rejects_missing_or_invalid_ttl() {
1513 let missing =
1514 AuthApi::token_replay_metadata(None, "delegated token mint").expect_err("required");
1515 assert_eq!(missing.code, ErrorCode::OperationIdRequired);
1516
1517 let zero = AuthApi::token_replay_metadata(Some(meta(1, 0)), "delegated token mint")
1518 .expect_err("zero ttl is invalid");
1519 assert_eq!(zero.code, ErrorCode::InvalidInput);
1520
1521 let too_large = AuthApi::token_replay_metadata(
1522 Some(meta(1, AuthApi::MAX_TOKEN_REPLAY_TTL_SECONDS + 1)),
1523 "delegated token mint",
1524 )
1525 .expect_err("oversized ttl is invalid");
1526 assert_eq!(too_large.code, ErrorCode::InvalidInput);
1527 }
1528
1529 #[test]
1530 fn delegation_replay_payload_hash_binds_authoritative_payload() {
1531 let command_kind = AuthApi::delegation_replay_command_kind();
1532 let actor = crate::ops::replay::model::ReplayActor::direct_caller(p(2));
1533 let a = delegation_request(1);
1534 let mut b = a.clone();
1535 b.cert_ttl_secs += 1;
1536
1537 assert_ne!(
1538 AuthApi::delegation_replay_payload_hash(&command_kind, &actor, &a),
1539 AuthApi::delegation_replay_payload_hash(&command_kind, &actor, &b)
1540 );
1541 }
1542
1543 #[test]
1544 fn delegated_token_mint_payload_hash_ignores_metadata() {
1545 let command_kind = AuthApi::token_mint_replay_command_kind();
1546 let actor = crate::ops::replay::model::ReplayActor::direct_caller(p(2));
1547 let a = mint_request(1);
1548 let b = mint_request(9);
1549
1550 assert_eq!(
1551 AuthApi::token_mint_replay_payload_hash(&command_kind, &actor, &a),
1552 AuthApi::token_mint_replay_payload_hash(&command_kind, &actor, &b)
1553 );
1554 }
1555
1556 #[test]
1557 fn delegated_token_mint_payload_hash_binds_authoritative_payload() {
1558 let command_kind = AuthApi::token_mint_replay_command_kind();
1559 let actor = crate::ops::replay::model::ReplayActor::direct_caller(p(2));
1560 let a = mint_request(1);
1561 let mut b = a.clone();
1562 b.token_ttl_secs += 1;
1563
1564 assert_ne!(
1565 AuthApi::token_mint_replay_payload_hash(&command_kind, &actor, &a),
1566 AuthApi::token_mint_replay_payload_hash(&command_kind, &actor, &b)
1567 );
1568 }
1569
1570 #[test]
1571 fn delegated_token_mint_committed_replay_returns_cached_token() {
1572 ReplayReceiptOps::reset_for_tests();
1573
1574 let request = mint_request(21);
1575 let actor = ReplayActor::direct_caller(p(44));
1576 let token = match reserve_mint_receipt(&request, actor) {
1577 ReplayReceiptDecision::Fresh(token) => token,
1578 decision => panic!("expected fresh receipt, got {decision:?}"),
1579 };
1580 let response = delegated_token(31);
1581 let response_bytes =
1582 AuthApi::encode_delegated_token_response(&response).expect("response encoding");
1583
1584 commit_receipt_response(
1585 &token,
1586 AuthApi::TOKEN_REPLAY_RESPONSE_SCHEMA_VERSION,
1587 response_bytes,
1588 2_000,
1589 );
1590
1591 let replay = reserve_mint_receipt(&request, actor);
1592 let cached = AuthApi::map_token_replay_decision(replay, "delegated token mint")
1593 .expect("committed replay returns cached token");
1594
1595 assert_eq!(cached, response);
1596 }
1597
1598 #[test]
1599 fn delegated_token_mint_replay_rejects_actor_and_payload_mismatch() {
1600 ReplayReceiptOps::reset_for_tests();
1601
1602 let request = mint_request(22);
1603 let actor = ReplayActor::direct_caller(p(44));
1604 match reserve_mint_receipt(&request, actor) {
1605 ReplayReceiptDecision::Fresh(_) => {}
1606 decision => panic!("expected fresh receipt, got {decision:?}"),
1607 }
1608
1609 let actor_mismatch = reserve_mint_receipt(&request, ReplayActor::direct_caller(p(45)));
1610 assert_eq!(actor_mismatch, ReplayReceiptDecision::ActorMismatch);
1611
1612 let mut changed = request;
1613 changed.token_ttl_secs += 1;
1614 let payload_mismatch = reserve_mint_receipt(&changed, actor);
1615 assert_eq!(payload_mismatch, ReplayReceiptDecision::PayloadMismatch);
1616 }
1617
1618 #[test]
1619 fn delegated_token_mint_in_progress_duplicate_blocks_before_effect() {
1620 ReplayReceiptOps::reset_for_tests();
1621
1622 let request = mint_request(23);
1623 let actor = ReplayActor::direct_caller(p(44));
1624 let token = match reserve_mint_receipt(&request, actor) {
1625 ReplayReceiptDecision::Fresh(token) => token,
1626 decision => panic!("expected fresh receipt, got {decision:?}"),
1627 };
1628
1629 let duplicate = reserve_mint_receipt(&request, actor);
1630 let err = AuthApi::map_token_replay_decision(duplicate, "delegated token mint")
1631 .expect_err("duplicate in-progress mint must block");
1632 assert_eq!(err.code, ErrorCode::Conflict);
1633
1634 let stored = ReplayReceiptOps::get(token.key())
1635 .expect("stored receipt")
1636 .into_receipt()
1637 .expect("receipt decode");
1638 assert_eq!(stored.status, ReplayReceiptStatus::Reserved);
1639 assert_eq!(stored.effect, None);
1640 }
1641
1642 #[test]
1643 fn delegated_token_signing_quota_rejects_before_signing_adapter() {
1644 IntentStore::reset_for_tests();
1645
1646 let command_kind = AuthApi::token_mint_replay_command_kind();
1647 let caller = p(44);
1648 let payer = p(2);
1649 let current_cycle_balance = AuthApi::TOKEN_SIGNING_CYCLE_RESERVATION_CYCLES
1650 + AuthApi::MIN_TOKEN_SIGNING_CYCLES_AFTER_RESERVATION
1651 + 1;
1652 let mut first = AuthApi::token_signing_cost_guard_request_at(
1653 command_kind.clone(),
1654 caller,
1655 payer,
1656 10,
1657 current_cycle_balance,
1658 );
1659 first.max_operations_per_window = 1;
1660 assert_eq!(first.cost_class, CostClass::ThresholdEcdsaSign);
1661 assert_eq!(first.command_kind, command_kind);
1662 assert_eq!(first.quota_subject, caller);
1663 assert_eq!(first.payer, payer);
1664
1665 let permit = CostGuardOps::reserve(first).expect("first signing operation reserves");
1666 CostGuardOps::complete(&permit, 10).expect("first signing operation completes");
1667
1668 let mut second = AuthApi::token_signing_cost_guard_request_at(
1669 AuthApi::token_mint_replay_command_kind(),
1670 caller,
1671 payer,
1672 20,
1673 current_cycle_balance,
1674 );
1675 second.max_operations_per_window = 1;
1676
1677 let err = CostGuardOps::reserve(second).expect_err("quota rejects second operation");
1678 let public = err.public_error().expect("quota rejection is public");
1679 assert_eq!(public.code, ErrorCode::ResourceExhausted);
1680 }
1681
1682 #[test]
1683 fn delegated_token_issue_payload_hash_ignores_metadata() {
1684 let command_kind = AuthApi::token_issue_replay_command_kind();
1685 let actor = crate::ops::replay::model::ReplayActor::direct_caller(p(2));
1686 let a = issue_request(1);
1687 let b = issue_request(9);
1688
1689 assert_eq!(
1690 AuthApi::token_issue_replay_payload_hash(&command_kind, &actor, &a),
1691 AuthApi::token_issue_replay_payload_hash(&command_kind, &actor, &b)
1692 );
1693 }
1694
1695 #[test]
1696 fn delegated_token_issue_payload_hash_binds_authoritative_payload() {
1697 let command_kind = AuthApi::token_issue_replay_command_kind();
1698 let actor = crate::ops::replay::model::ReplayActor::direct_caller(p(2));
1699 let a = issue_request(1);
1700 let mut b = a.clone();
1701 b.nonce = [10; 16];
1702
1703 assert_ne!(
1704 AuthApi::token_issue_replay_payload_hash(&command_kind, &actor, &a),
1705 AuthApi::token_issue_replay_payload_hash(&command_kind, &actor, &b)
1706 );
1707 }
1708}