1use crate::{
2 cdk::types::Principal,
3 dto::{
4 auth::{
5 AttestationKeySet, 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 scopes: request.scopes.clone(),
218 aud: request.aud.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 scopes: request.scopes,
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 scopes: request.scopes,
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 scopes: request.scopes,
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 hasher.hash_u64(request.scopes.len() as u64);
732 for scope in &request.scopes {
733 hasher.hash_str(scope);
734 }
735 Self::hash_delegation_audience(&mut hasher, &request.aud);
736 hasher.hash_u64(request.cert_ttl_secs);
737 hasher.finish()
738 }
739
740 fn token_mint_replay_payload_hash(
741 command_kind: &CommandKind,
742 actor: &ReplayActor,
743 request: &DelegatedTokenMintRequest,
744 ) -> [u8; 32] {
745 let mut hasher = ReplayPayloadHasher::new(command_kind, actor);
746 hasher.hash_principal(&request.subject);
747 Self::hash_delegation_audience(&mut hasher, &request.aud);
748 Self::hash_string_vec(&mut hasher, &request.scopes);
749 hasher.hash_u64(request.token_ttl_secs);
750 hasher.hash_u64(request.cert_ttl_secs);
751 hasher.hash_bytes(&request.nonce);
752 hasher.finish()
753 }
754
755 fn token_issue_replay_payload_hash(
756 command_kind: &CommandKind,
757 actor: &ReplayActor,
758 request: &DelegatedTokenIssueRequest,
759 ) -> [u8; 32] {
760 let mut hasher = ReplayPayloadHasher::new(command_kind, actor);
761 Self::hash_delegation_proof(&mut hasher, &request.proof);
762 hasher.hash_principal(&request.subject);
763 Self::hash_delegation_audience(&mut hasher, &request.aud);
764 Self::hash_string_vec(&mut hasher, &request.scopes);
765 hasher.hash_u64(request.ttl_secs);
766 hasher.hash_bytes(&request.nonce);
767 hasher.finish()
768 }
769
770 fn hash_delegation_audience(hasher: &mut ReplayPayloadHasher, aud: &DelegationAudience) {
771 match aud {
772 DelegationAudience::Role(role) => {
773 hasher.hash_str("role");
774 hasher.hash_role(role);
775 }
776 DelegationAudience::Principal(principal) => {
777 hasher.hash_str("principal");
778 hasher.hash_principal(principal);
779 }
780 }
781 }
782
783 fn hash_delegation_proof(hasher: &mut ReplayPayloadHasher, proof: &DelegationProof) {
784 Self::hash_delegation_cert(hasher, &proof.cert);
785 hasher.hash_bytes(&proof.root_sig);
786 }
787
788 fn hash_delegation_cert(hasher: &mut ReplayPayloadHasher, cert: &DelegationCert) {
789 hasher.hash_u64(u64::from(cert.version));
790 hasher.hash_principal(&cert.root_pid);
791 hasher.hash_str(&cert.root_key_id);
792 hasher.hash_bytes(&cert.root_key_hash);
793 Self::hash_signature_algorithm(hasher, cert.alg);
794 hasher.hash_principal(&cert.shard_pid);
795 hasher.hash_str(&cert.shard_key_id);
796 hasher.hash_bytes(&cert.shard_public_key_sec1);
797 hasher.hash_bytes(&cert.shard_key_hash);
798 Self::hash_shard_key_binding(hasher, cert.shard_key_binding);
799 hasher.hash_u64(cert.issued_at);
800 hasher.hash_u64(cert.expires_at);
801 hasher.hash_u64(cert.max_token_ttl_secs);
802 Self::hash_string_vec(hasher, &cert.scopes);
803 Self::hash_delegation_audience(hasher, &cert.aud);
804 match cert.verifier_role_hash {
805 Some(hash) => {
806 hasher.hash_bool(true);
807 hasher.hash_bytes(&hash);
808 }
809 None => hasher.hash_bool(false),
810 }
811 }
812
813 fn hash_signature_algorithm(hasher: &mut ReplayPayloadHasher, alg: SignatureAlgorithm) {
814 match alg {
815 SignatureAlgorithm::EcdsaP256Sha256 => hasher.hash_str("EcdsaP256Sha256"),
816 }
817 }
818
819 fn hash_shard_key_binding(hasher: &mut ReplayPayloadHasher, binding: ShardKeyBinding) {
820 match binding {
821 ShardKeyBinding::IcThresholdEcdsa {
822 key_name_hash,
823 derivation_path_hash,
824 } => {
825 hasher.hash_str("IcThresholdEcdsa");
826 hasher.hash_bytes(&key_name_hash);
827 hasher.hash_bytes(&derivation_path_hash);
828 }
829 }
830 }
831
832 fn hash_string_vec(hasher: &mut ReplayPayloadHasher, values: &[String]) {
833 hasher.hash_u64(values.len() as u64);
834 for value in values {
835 hasher.hash_str(value);
836 }
837 }
838
839 fn reserve_token_replay_receipt(
840 command_kind: CommandKind,
841 metadata: RootRequestMetadata,
842 actor: ReplayActor,
843 payload_hash: [u8; 32],
844 ) -> Result<ReplayReceiptDecision, Error> {
845 let now_secs = IcOps::now_secs();
846 let replay_input = ReplayReceiptReserveInput::new(
847 command_kind,
848 OperationId::from_bytes(metadata.request_id),
849 actor,
850 payload_hash,
851 secs_to_ns(now_secs),
852 )
853 .with_expires_at_ns(secs_to_ns(now_secs.saturating_add(metadata.ttl_seconds)));
854
855 reserve_or_replay_receipt(replay_input).map_err(Self::map_delegation_replay_store_error)
856 }
857
858 fn token_signing_cost_guard_request(
859 command_kind: CommandKind,
860 caller: Principal,
861 ) -> CostGuardRequest {
862 Self::token_signing_cost_guard_request_at(
863 command_kind,
864 caller,
865 IcOps::canister_self(),
866 IcOps::now_secs(),
867 MgmtOps::canister_cycle_balance().to_u128(),
868 )
869 }
870
871 const fn token_signing_cost_guard_request_at(
872 command_kind: CommandKind,
873 caller: Principal,
874 payer: Principal,
875 now_secs: u64,
876 current_cycle_balance: u128,
877 ) -> CostGuardRequest {
878 CostGuardRequest {
879 cost_class: crate::replay_policy::CostClass::ThresholdEcdsaSign,
880 command_kind,
881 quota_subject: caller,
882 payer,
883 now_secs,
884 quota_window_secs: Self::TOKEN_SIGNING_QUOTA_WINDOW_SECONDS,
885 max_operations_per_window: Self::MAX_TOKEN_SIGNING_OPERATIONS_PER_WINDOW,
886 current_cycle_balance,
887 cycle_reservation_cycles: Self::TOKEN_SIGNING_CYCLE_RESERVATION_CYCLES,
888 min_cycles_after_reservation: Self::MIN_TOKEN_SIGNING_CYCLES_AFTER_RESERVATION,
889 }
890 }
891
892 fn map_delegation_replay_decision(
893 decision: ReplayReceiptDecision,
894 ) -> Result<DelegationProof, Error> {
895 match decision {
896 ReplayReceiptDecision::Fresh(_) => {
897 Err(Error::invariant("fresh delegation replay decision escaped"))
898 }
899 ReplayReceiptDecision::ReturnCommitted(receipt) => {
900 Self::decode_delegation_proof_response(&receipt)
901 }
902 ReplayReceiptDecision::OperationInProgress => Err(Error::conflict(
903 "delegation proof request is already in progress; retry later with the same request id",
904 )),
905 ReplayReceiptDecision::ActorMismatch => Err(Error::conflict(
906 "delegation proof request id was reused by a different caller",
907 )),
908 ReplayReceiptDecision::PayloadMismatch => Err(Error::conflict(
909 "delegation proof request id was reused with a different payload",
910 )),
911 ReplayReceiptDecision::Expired => Err(Error::conflict(
912 "delegation proof replay receipt expired; retry with a new request id",
913 )),
914 ReplayReceiptDecision::RecoveryRequired(reason) => Err(Error::conflict(format!(
915 "delegation proof request requires recovery before replay: {reason:?}"
916 ))),
917 ReplayReceiptDecision::TerminalFailed {
918 error_code,
919 error_bytes,
920 error_bytes_truncated,
921 } => Err(Error::conflict(format!(
922 "delegation proof request previously failed: {error_code:?}; error_bytes_len={}; truncated={error_bytes_truncated}",
923 error_bytes.len()
924 ))),
925 ReplayReceiptDecision::PendingActorQuotaExceeded { max_pending, .. } => {
926 Err(Error::exhausted(format!(
927 "delegation proof pending replay receipt quota exceeded for caller; max_pending={max_pending}"
928 )))
929 }
930 ReplayReceiptDecision::PendingCommandQuotaExceeded { max_pending, .. } => {
931 Err(Error::exhausted(format!(
932 "delegation proof pending replay receipt quota exceeded for command kind; max_pending={max_pending}"
933 )))
934 }
935 }
936 }
937
938 fn map_token_replay_decision(
939 decision: ReplayReceiptDecision,
940 label: &str,
941 ) -> Result<DelegatedToken, Error> {
942 match decision {
943 ReplayReceiptDecision::Fresh(_) => Err(Error::invariant(format!(
944 "fresh {label} replay decision escaped"
945 ))),
946 ReplayReceiptDecision::ReturnCommitted(receipt) => {
947 Self::decode_delegated_token_response(&receipt)
948 }
949 ReplayReceiptDecision::OperationInProgress => Err(Error::conflict(format!(
950 "{label} request is already in progress; retry later with the same request id"
951 ))),
952 ReplayReceiptDecision::ActorMismatch => Err(Error::conflict(format!(
953 "{label} request id was reused by a different caller"
954 ))),
955 ReplayReceiptDecision::PayloadMismatch => Err(Error::conflict(format!(
956 "{label} request id was reused with a different payload"
957 ))),
958 ReplayReceiptDecision::Expired => Err(Error::conflict(format!(
959 "{label} replay receipt expired; retry with a new request id"
960 ))),
961 ReplayReceiptDecision::RecoveryRequired(reason) => Err(Error::conflict(format!(
962 "{label} request requires recovery before replay: {reason:?}"
963 ))),
964 ReplayReceiptDecision::TerminalFailed {
965 error_code,
966 error_bytes,
967 error_bytes_truncated,
968 } => Err(Error::conflict(format!(
969 "{label} request previously failed: {error_code:?}; error_bytes_len={}; truncated={error_bytes_truncated}",
970 error_bytes.len()
971 ))),
972 ReplayReceiptDecision::PendingActorQuotaExceeded { max_pending, .. } => {
973 Err(Error::exhausted(format!(
974 "{label} pending replay receipt quota exceeded for caller; max_pending={max_pending}"
975 )))
976 }
977 ReplayReceiptDecision::PendingCommandQuotaExceeded { max_pending, .. } => {
978 Err(Error::exhausted(format!(
979 "{label} pending replay receipt quota exceeded for command kind; max_pending={max_pending}"
980 )))
981 }
982 }
983 }
984
985 fn log_token_replay_reserved(
986 label: &str,
987 command_kind: &CommandKind,
988 operation_id: OperationId,
989 caller: Principal,
990 ) {
991 log!(
992 Topic::Auth,
993 Info,
994 "{} replay receipt reserved command_kind={} operation_id={} caller={}",
995 label,
996 command_kind.as_str(),
997 operation_id,
998 caller
999 );
1000 }
1001
1002 fn log_token_replay_decision(
1003 label: &str,
1004 command_kind: &CommandKind,
1005 operation_id: OperationId,
1006 caller: Principal,
1007 decision: &ReplayReceiptDecision,
1008 ) {
1009 match decision {
1010 ReplayReceiptDecision::ReturnCommitted(_) => log!(
1011 Topic::Auth,
1012 Info,
1013 "{} committed replay returned command_kind={} operation_id={} caller={}",
1014 label,
1015 command_kind.as_str(),
1016 operation_id,
1017 caller
1018 ),
1019 _ => log!(
1020 Topic::Auth,
1021 Warn,
1022 "{} replay decision blocked command_kind={} operation_id={} caller={} decision={}",
1023 label,
1024 command_kind.as_str(),
1025 operation_id,
1026 caller,
1027 Self::token_replay_decision_name(decision)
1028 ),
1029 }
1030 }
1031
1032 fn log_token_signing_cost_guard_reserved(
1033 label: &str,
1034 command_kind: &CommandKind,
1035 operation_id: OperationId,
1036 caller: Principal,
1037 ) {
1038 log!(
1039 Topic::Auth,
1040 Info,
1041 "{} signing cost guard reserved command_kind={} operation_id={} caller={}",
1042 label,
1043 command_kind.as_str(),
1044 operation_id,
1045 caller
1046 );
1047 }
1048
1049 fn log_token_replay_effect_marked(
1050 label: &str,
1051 command_kind: &CommandKind,
1052 operation_id: OperationId,
1053 caller: Principal,
1054 effect: &ExternalEffectDescriptor,
1055 ) {
1056 log!(
1057 Topic::Auth,
1058 Info,
1059 "{} replay effect marked effect={} command_kind={} operation_id={} caller={}",
1060 label,
1061 Self::token_effect_name(effect),
1062 command_kind.as_str(),
1063 operation_id,
1064 caller
1065 );
1066 }
1067
1068 fn log_token_replay_recovery_required(
1069 label: &str,
1070 command_kind: &CommandKind,
1071 operation_id: OperationId,
1072 caller: Principal,
1073 err: &crate::InternalError,
1074 ) {
1075 let (error_class, error_origin) = err.log_fields();
1076 log!(
1077 Topic::Auth,
1078 Error,
1079 "{} replay recovery required effect=threshold_ecdsa_sign_delegated_token command_kind={} operation_id={} caller={} error_class={} error_origin={}",
1080 label,
1081 command_kind.as_str(),
1082 operation_id,
1083 caller,
1084 error_class,
1085 error_origin
1086 );
1087 }
1088
1089 fn log_token_replay_response_commit_failed(
1090 label: &str,
1091 command_kind: &CommandKind,
1092 operation_id: OperationId,
1093 caller: Principal,
1094 err: &Error,
1095 ) {
1096 log!(
1097 Topic::Auth,
1098 Error,
1099 "{} replay response commit failed command_kind={} operation_id={} caller={} error_code={:?}",
1100 label,
1101 command_kind.as_str(),
1102 operation_id,
1103 caller,
1104 err.code
1105 );
1106 }
1107
1108 fn log_token_replay_response_commit_failed_internal(
1109 label: &str,
1110 command_kind: &CommandKind,
1111 operation_id: OperationId,
1112 caller: Principal,
1113 err: &crate::InternalError,
1114 ) {
1115 let (error_class, error_origin) = err.log_fields();
1116 log!(
1117 Topic::Auth,
1118 Error,
1119 "{} replay response commit failed command_kind={} operation_id={} caller={} error_class={} error_origin={}",
1120 label,
1121 command_kind.as_str(),
1122 operation_id,
1123 caller,
1124 error_class,
1125 error_origin
1126 );
1127 }
1128
1129 fn log_token_replay_commit(
1130 label: &str,
1131 command_kind: &CommandKind,
1132 operation_id: OperationId,
1133 caller: Principal,
1134 ) {
1135 log!(
1136 Topic::Auth,
1137 Ok,
1138 "{} replay response committed command_kind={} operation_id={} caller={}",
1139 label,
1140 command_kind.as_str(),
1141 operation_id,
1142 caller
1143 );
1144 }
1145
1146 const fn token_replay_decision_name(decision: &ReplayReceiptDecision) -> &'static str {
1147 match decision {
1148 ReplayReceiptDecision::Fresh(_) => "fresh",
1149 ReplayReceiptDecision::ReturnCommitted(_) => "return_committed",
1150 ReplayReceiptDecision::OperationInProgress => "operation_in_progress",
1151 ReplayReceiptDecision::ActorMismatch => "actor_mismatch",
1152 ReplayReceiptDecision::PayloadMismatch => "payload_mismatch",
1153 ReplayReceiptDecision::Expired => "expired",
1154 ReplayReceiptDecision::RecoveryRequired(_) => "recovery_required",
1155 ReplayReceiptDecision::TerminalFailed { .. } => "terminal_failed",
1156 ReplayReceiptDecision::PendingActorQuotaExceeded { .. } => {
1157 "pending_actor_quota_exceeded"
1158 }
1159 ReplayReceiptDecision::PendingCommandQuotaExceeded { .. } => {
1160 "pending_command_quota_exceeded"
1161 }
1162 }
1163 }
1164
1165 const fn token_effect_name(effect: &ExternalEffectDescriptor) -> &'static str {
1166 match effect {
1167 ExternalEffectDescriptor::ThresholdEcdsaSign {
1168 purpose: EcdsaPurpose::DelegatedToken,
1169 ..
1170 } => "threshold_ecdsa_sign_delegated_token",
1171 ExternalEffectDescriptor::ThresholdEcdsaSign { .. } => "threshold_ecdsa_sign",
1172 ExternalEffectDescriptor::ManagementCreateCanister { .. } => {
1173 "management_create_canister"
1174 }
1175 ExternalEffectDescriptor::ManagementCall { .. } => "management_call",
1176 ExternalEffectDescriptor::IcpTransfer { .. } => "icp_transfer",
1177 }
1178 }
1179
1180 fn map_delegation_replay_store_error(err: ReplayReceiptStoreError) -> Error {
1181 match err {
1182 ReplayReceiptStoreError::ReceiptDecodeFailed(message) => Error::internal(format!(
1183 "failed to decode delegation replay receipt: {message}"
1184 )),
1185 }
1186 }
1187
1188 fn encode_delegation_proof_response(proof: &DelegationProof) -> Result<Vec<u8>, Error> {
1189 encode_one(proof).map_err(|err| {
1190 Error::internal(format!(
1191 "failed to encode delegation proof replay response: {err}"
1192 ))
1193 })
1194 }
1195
1196 fn encode_delegated_token_response(token: &DelegatedToken) -> Result<Vec<u8>, Error> {
1197 encode_one(token).map_err(|err| {
1198 Error::internal(format!(
1199 "failed to encode delegated token replay response: {err}"
1200 ))
1201 })
1202 }
1203
1204 fn decode_delegation_proof_response(
1205 receipt: &crate::ops::replay::model::ReplayReceipt,
1206 ) -> Result<DelegationProof, Error> {
1207 let response_schema_version = receipt.response_schema_version.ok_or_else(|| {
1208 Error::internal("delegation replay receipt is missing response schema version")
1209 })?;
1210 if response_schema_version != Self::DELEGATION_REPLAY_RESPONSE_SCHEMA_VERSION {
1211 return Err(Error::internal(format!(
1212 "unsupported delegation replay response schema version {response_schema_version}"
1213 )));
1214 }
1215 let response_bytes = receipt.response_bytes.as_deref().ok_or_else(|| {
1216 Error::internal("delegation replay receipt is missing response bytes")
1217 })?;
1218 decode_one(response_bytes).map_err(|err| {
1219 Error::internal(format!(
1220 "failed to decode delegation proof replay response: {err}"
1221 ))
1222 })
1223 }
1224
1225 fn decode_delegated_token_response(
1226 receipt: &crate::ops::replay::model::ReplayReceipt,
1227 ) -> Result<DelegatedToken, Error> {
1228 let response_schema_version = receipt.response_schema_version.ok_or_else(|| {
1229 Error::internal("delegated token replay receipt is missing response schema version")
1230 })?;
1231 if response_schema_version != Self::TOKEN_REPLAY_RESPONSE_SCHEMA_VERSION {
1232 return Err(Error::internal(format!(
1233 "unsupported delegated token replay response schema version {response_schema_version}"
1234 )));
1235 }
1236 let response_bytes = receipt.response_bytes.as_deref().ok_or_else(|| {
1237 Error::internal("delegated token replay receipt is missing response bytes")
1238 })?;
1239 decode_one(response_bytes).map_err(|err| {
1240 Error::internal(format!(
1241 "failed to decode delegated token replay response: {err}"
1242 ))
1243 })
1244 }
1245
1246 fn hash_delegation_effect_key(key_name: &str) -> [u8; 32] {
1247 let mut hasher = Sha256::new();
1248 hasher.update(b"canic-delegation-proof-effect-key:v1");
1249 hasher.update(key_name.as_bytes());
1250 hasher.finalize().into()
1251 }
1252}
1253
1254impl AuthApi {
1255 async fn request_delegation_remote(
1257 request: DelegationProofIssueRequest,
1258 ) -> Result<DelegationProof, Error> {
1259 let root_pid = EnvOps::root_pid().map_err(Error::from)?;
1260 RootAuthMaterialClient::new(root_pid)
1261 .request_delegation(request)
1262 .await
1263 .map_err(Self::map_auth_error)
1264 }
1265
1266 pub async fn request_role_attestation_root(
1268 request: RoleAttestationRequest,
1269 ) -> Result<SignedRoleAttestation, Error> {
1270 let request = metadata::with_root_attestation_request_metadata(request);
1271 let response = RootResponseWorkflow::response(RootRequest::issue_role_attestation(request))
1272 .await
1273 .map_err(Self::map_auth_error)?;
1274
1275 match response {
1276 RootCapabilityResponse::RoleAttestationIssued(response) => Ok(response),
1277 _ => Err(Error::internal(
1278 "invalid root response type for role attestation request",
1279 )),
1280 }
1281 }
1282
1283 pub async fn request_internal_invocation_proof_root(
1285 request: InternalInvocationProofRequest,
1286 ) -> Result<SignedInternalInvocationProofV1, Error> {
1287 let request = metadata::with_internal_invocation_proof_request_metadata(request);
1288 let response =
1289 RootResponseWorkflow::response(RootRequest::issue_internal_invocation_proof(request))
1290 .await
1291 .map_err(Self::map_auth_error)?;
1292
1293 match response {
1294 RootCapabilityResponse::InternalInvocationProofIssued(response) => Ok(response),
1295 _ => Err(Error::internal(
1296 "invalid root response type for internal invocation proof request",
1297 )),
1298 }
1299 }
1300
1301 async fn request_role_attestation_remote(
1303 request: RoleAttestationRequest,
1304 ) -> Result<SignedRoleAttestation, Error> {
1305 let root_pid = EnvOps::root_pid().map_err(Error::from)?;
1306 RootAuthMaterialClient::new(root_pid)
1307 .request_role_attestation(request)
1308 .await
1309 .map_err(Self::map_auth_error)
1310 }
1311
1312 async fn request_internal_invocation_proof_remote(
1314 request: InternalInvocationProofRequest,
1315 ) -> Result<SignedInternalInvocationProofV1, Error> {
1316 let root_pid = EnvOps::root_pid().map_err(Error::from)?;
1317 RootAuthMaterialClient::new(root_pid)
1318 .request_internal_invocation_proof(request)
1319 .await
1320 .map_err(Self::map_auth_error)
1321 }
1322}
1323
1324#[cfg(test)]
1325mod tests {
1326 use super::AuthApi;
1327 use crate::{
1328 cdk::types::Principal,
1329 dto::{
1330 auth::{
1331 DelegatedToken, DelegatedTokenClaims, DelegatedTokenIssueRequest,
1332 DelegatedTokenMintRequest, DelegationAudience, DelegationCert, DelegationProof,
1333 DelegationProofIssueRequest, ShardKeyBinding, SignatureAlgorithm,
1334 },
1335 error::ErrorCode,
1336 rpc::RootRequestMetadata,
1337 },
1338 ops::{
1339 auth::{AuthExpiryError, AuthOpsError},
1340 cost_guard::CostGuardOps,
1341 replay::{
1342 model::{ReplayActor, ReplayReceiptStatus},
1343 receipt::{ReplayReceiptDecision, commit_receipt_response},
1344 },
1345 storage::replay::ReplayReceiptOps,
1346 },
1347 replay_policy::CostClass,
1348 storage::stable::intent::IntentStore,
1349 };
1350
1351 fn p(id: u8) -> Principal {
1352 Principal::from_slice(&[id; 29])
1353 }
1354
1355 fn delegation_request(metadata_id: u8) -> DelegationProofIssueRequest {
1356 DelegationProofIssueRequest {
1357 metadata: Some(meta(metadata_id, 60)),
1358 shard_pid: p(2),
1359 scopes: vec!["canic.verify".to_string()],
1360 aud: DelegationAudience::Principal(p(3)),
1361 cert_ttl_secs: 60,
1362 }
1363 }
1364
1365 fn meta(id: u8, ttl_seconds: u64) -> RootRequestMetadata {
1366 RootRequestMetadata {
1367 request_id: [id; 32],
1368 ttl_seconds,
1369 }
1370 }
1371
1372 fn delegation_proof() -> DelegationProof {
1373 DelegationProof {
1374 cert: DelegationCert {
1375 version: 1,
1376 root_pid: p(1),
1377 root_key_id: "root-key".to_string(),
1378 root_key_hash: [2; 32],
1379 alg: SignatureAlgorithm::EcdsaP256Sha256,
1380 shard_pid: p(2),
1381 shard_key_id: "shard-key".to_string(),
1382 shard_public_key_sec1: vec![3; 33],
1383 shard_key_hash: [4; 32],
1384 shard_key_binding: ShardKeyBinding::IcThresholdEcdsa {
1385 key_name_hash: [5; 32],
1386 derivation_path_hash: [6; 32],
1387 },
1388 issued_at: 10,
1389 expires_at: 100,
1390 max_token_ttl_secs: 60,
1391 scopes: vec!["canic.verify".to_string()],
1392 aud: DelegationAudience::Principal(p(3)),
1393 verifier_role_hash: None,
1394 },
1395 root_sig: vec![7; 64],
1396 }
1397 }
1398
1399 fn mint_request(metadata_id: u8) -> DelegatedTokenMintRequest {
1400 DelegatedTokenMintRequest {
1401 metadata: Some(meta(metadata_id, 60)),
1402 subject: p(8),
1403 aud: DelegationAudience::Principal(p(3)),
1404 scopes: vec!["canic.verify".to_string()],
1405 token_ttl_secs: 30,
1406 cert_ttl_secs: 60,
1407 nonce: [9; 16],
1408 }
1409 }
1410
1411 fn issue_request(metadata_id: u8) -> DelegatedTokenIssueRequest {
1412 DelegatedTokenIssueRequest {
1413 metadata: Some(meta(metadata_id, 60)),
1414 proof: delegation_proof(),
1415 subject: p(8),
1416 aud: DelegationAudience::Principal(p(3)),
1417 scopes: vec!["canic.verify".to_string()],
1418 ttl_secs: 30,
1419 nonce: [9; 16],
1420 }
1421 }
1422
1423 fn delegated_token(nonce_byte: u8) -> DelegatedToken {
1424 DelegatedToken {
1425 claims: DelegatedTokenClaims {
1426 version: 1,
1427 subject: p(8),
1428 issuer_shard_pid: p(2),
1429 cert_hash: [11; 32],
1430 issued_at: 20,
1431 expires_at: 50,
1432 aud: DelegationAudience::Principal(p(3)),
1433 scopes: vec!["canic.verify".to_string()],
1434 nonce: [nonce_byte; 16],
1435 },
1436 proof: delegation_proof(),
1437 shard_sig: vec![12; 64],
1438 }
1439 }
1440
1441 fn reserve_mint_receipt(
1442 request: &DelegatedTokenMintRequest,
1443 actor: ReplayActor,
1444 ) -> ReplayReceiptDecision {
1445 let command_kind = AuthApi::token_mint_replay_command_kind();
1446 let metadata = request.metadata.expect("mint request metadata");
1447 let payload_hash = AuthApi::token_mint_replay_payload_hash(&command_kind, &actor, request);
1448 AuthApi::reserve_token_replay_receipt(command_kind, metadata, actor, payload_hash)
1449 .expect("mint receipt reservation")
1450 }
1451
1452 #[test]
1453 fn internal_invocation_not_yet_valid_maps_to_non_retryable_proof_expiry() {
1454 let err = AuthApi::map_internal_invocation_verify_error(AuthOpsError::Expiry(
1455 AuthExpiryError::AttestationNotYetValid {
1456 issued_at: 20,
1457 now_secs: 10,
1458 },
1459 ));
1460
1461 assert_eq!(err.code, ErrorCode::AuthProofExpired);
1462 }
1463
1464 #[test]
1465 fn delegation_request_caller_must_match_requested_shard() {
1466 AuthApi::validate_delegation_request_caller(p(2), p(2)).expect("matching shard");
1467
1468 let err = AuthApi::validate_delegation_request_caller(p(1), p(2))
1469 .expect_err("mismatched caller must fail");
1470
1471 assert_eq!(err.code, ErrorCode::Forbidden);
1472 assert!(err.message.contains("must match shard_pid"));
1473 }
1474
1475 #[test]
1476 fn delegation_replay_metadata_rejects_missing_or_invalid_ttl() {
1477 let missing = AuthApi::delegation_replay_metadata(None).expect_err("metadata is required");
1478 assert_eq!(missing.code, ErrorCode::OperationIdRequired);
1479 assert_eq!(missing.message, "operation_id is required for this command");
1480
1481 let zero = AuthApi::delegation_replay_metadata(Some(RootRequestMetadata {
1482 request_id: [1; 32],
1483 ttl_seconds: 0,
1484 }))
1485 .expect_err("zero ttl is invalid");
1486 assert_eq!(zero.code, ErrorCode::InvalidInput);
1487
1488 let too_large = AuthApi::delegation_replay_metadata(Some(RootRequestMetadata {
1489 request_id: [1; 32],
1490 ttl_seconds: AuthApi::MAX_DELEGATION_REPLAY_TTL_SECONDS + 1,
1491 }))
1492 .expect_err("oversized ttl is invalid");
1493 assert_eq!(too_large.code, ErrorCode::InvalidInput);
1494 }
1495
1496 #[test]
1497 fn delegation_replay_payload_hash_ignores_metadata() {
1498 let command_kind = AuthApi::delegation_replay_command_kind();
1499 let actor = crate::ops::replay::model::ReplayActor::direct_caller(p(2));
1500 let a = delegation_request(1);
1501 let b = delegation_request(9);
1502
1503 assert_eq!(
1504 AuthApi::delegation_replay_payload_hash(&command_kind, &actor, &a),
1505 AuthApi::delegation_replay_payload_hash(&command_kind, &actor, &b)
1506 );
1507 }
1508
1509 #[test]
1510 fn delegated_token_replay_metadata_rejects_missing_or_invalid_ttl() {
1511 let missing =
1512 AuthApi::token_replay_metadata(None, "delegated token mint").expect_err("required");
1513 assert_eq!(missing.code, ErrorCode::OperationIdRequired);
1514 assert_eq!(missing.message, "operation_id is required for this command");
1515
1516 let zero = AuthApi::token_replay_metadata(Some(meta(1, 0)), "delegated token mint")
1517 .expect_err("zero ttl is invalid");
1518 assert_eq!(zero.code, ErrorCode::InvalidInput);
1519
1520 let too_large = AuthApi::token_replay_metadata(
1521 Some(meta(1, AuthApi::MAX_TOKEN_REPLAY_TTL_SECONDS + 1)),
1522 "delegated token mint",
1523 )
1524 .expect_err("oversized ttl is invalid");
1525 assert_eq!(too_large.code, ErrorCode::InvalidInput);
1526 }
1527
1528 #[test]
1529 fn delegation_replay_payload_hash_binds_authoritative_payload() {
1530 let command_kind = AuthApi::delegation_replay_command_kind();
1531 let actor = crate::ops::replay::model::ReplayActor::direct_caller(p(2));
1532 let a = delegation_request(1);
1533 let mut b = a.clone();
1534 b.cert_ttl_secs += 1;
1535
1536 assert_ne!(
1537 AuthApi::delegation_replay_payload_hash(&command_kind, &actor, &a),
1538 AuthApi::delegation_replay_payload_hash(&command_kind, &actor, &b)
1539 );
1540 }
1541
1542 #[test]
1543 fn delegated_token_mint_payload_hash_ignores_metadata() {
1544 let command_kind = AuthApi::token_mint_replay_command_kind();
1545 let actor = crate::ops::replay::model::ReplayActor::direct_caller(p(2));
1546 let a = mint_request(1);
1547 let b = mint_request(9);
1548
1549 assert_eq!(
1550 AuthApi::token_mint_replay_payload_hash(&command_kind, &actor, &a),
1551 AuthApi::token_mint_replay_payload_hash(&command_kind, &actor, &b)
1552 );
1553 }
1554
1555 #[test]
1556 fn delegated_token_mint_payload_hash_binds_authoritative_payload() {
1557 let command_kind = AuthApi::token_mint_replay_command_kind();
1558 let actor = crate::ops::replay::model::ReplayActor::direct_caller(p(2));
1559 let a = mint_request(1);
1560 let mut b = a.clone();
1561 b.token_ttl_secs += 1;
1562
1563 assert_ne!(
1564 AuthApi::token_mint_replay_payload_hash(&command_kind, &actor, &a),
1565 AuthApi::token_mint_replay_payload_hash(&command_kind, &actor, &b)
1566 );
1567 }
1568
1569 #[test]
1570 fn delegated_token_mint_committed_replay_returns_cached_token() {
1571 ReplayReceiptOps::reset_for_tests();
1572
1573 let request = mint_request(21);
1574 let actor = ReplayActor::direct_caller(p(44));
1575 let token = match reserve_mint_receipt(&request, actor) {
1576 ReplayReceiptDecision::Fresh(token) => token,
1577 decision => panic!("expected fresh receipt, got {decision:?}"),
1578 };
1579 let response = delegated_token(31);
1580 let response_bytes =
1581 AuthApi::encode_delegated_token_response(&response).expect("response encoding");
1582
1583 commit_receipt_response(
1584 &token,
1585 AuthApi::TOKEN_REPLAY_RESPONSE_SCHEMA_VERSION,
1586 response_bytes,
1587 2_000,
1588 );
1589
1590 let replay = reserve_mint_receipt(&request, actor);
1591 let cached = AuthApi::map_token_replay_decision(replay, "delegated token mint")
1592 .expect("committed replay returns cached token");
1593
1594 assert_eq!(cached, response);
1595 }
1596
1597 #[test]
1598 fn delegated_token_mint_replay_rejects_actor_and_payload_mismatch() {
1599 ReplayReceiptOps::reset_for_tests();
1600
1601 let request = mint_request(22);
1602 let actor = ReplayActor::direct_caller(p(44));
1603 match reserve_mint_receipt(&request, actor) {
1604 ReplayReceiptDecision::Fresh(_) => {}
1605 decision => panic!("expected fresh receipt, got {decision:?}"),
1606 }
1607
1608 let actor_mismatch = reserve_mint_receipt(&request, ReplayActor::direct_caller(p(45)));
1609 assert_eq!(actor_mismatch, ReplayReceiptDecision::ActorMismatch);
1610
1611 let mut changed = request;
1612 changed.token_ttl_secs += 1;
1613 let payload_mismatch = reserve_mint_receipt(&changed, actor);
1614 assert_eq!(payload_mismatch, ReplayReceiptDecision::PayloadMismatch);
1615 }
1616
1617 #[test]
1618 fn delegated_token_mint_in_progress_duplicate_blocks_before_effect() {
1619 ReplayReceiptOps::reset_for_tests();
1620
1621 let request = mint_request(23);
1622 let actor = ReplayActor::direct_caller(p(44));
1623 let token = match reserve_mint_receipt(&request, actor) {
1624 ReplayReceiptDecision::Fresh(token) => token,
1625 decision => panic!("expected fresh receipt, got {decision:?}"),
1626 };
1627
1628 let duplicate = reserve_mint_receipt(&request, actor);
1629 let err = AuthApi::map_token_replay_decision(duplicate, "delegated token mint")
1630 .expect_err("duplicate in-progress mint must block");
1631 assert_eq!(err.code, ErrorCode::Conflict);
1632
1633 let stored = ReplayReceiptOps::get(token.key())
1634 .expect("stored receipt")
1635 .into_receipt()
1636 .expect("receipt decode");
1637 assert_eq!(stored.status, ReplayReceiptStatus::Reserved);
1638 assert_eq!(stored.effect, None);
1639 }
1640
1641 #[test]
1642 fn delegated_token_signing_quota_rejects_before_signing_adapter() {
1643 IntentStore::reset_for_tests();
1644
1645 let command_kind = AuthApi::token_mint_replay_command_kind();
1646 let caller = p(44);
1647 let payer = p(2);
1648 let current_cycle_balance = AuthApi::TOKEN_SIGNING_CYCLE_RESERVATION_CYCLES
1649 + AuthApi::MIN_TOKEN_SIGNING_CYCLES_AFTER_RESERVATION
1650 + 1;
1651 let mut first = AuthApi::token_signing_cost_guard_request_at(
1652 command_kind.clone(),
1653 caller,
1654 payer,
1655 10,
1656 current_cycle_balance,
1657 );
1658 first.max_operations_per_window = 1;
1659 assert_eq!(first.cost_class, CostClass::ThresholdEcdsaSign);
1660 assert_eq!(first.command_kind, command_kind);
1661 assert_eq!(first.quota_subject, caller);
1662 assert_eq!(first.payer, payer);
1663
1664 let permit = CostGuardOps::reserve(first).expect("first signing operation reserves");
1665 CostGuardOps::complete(&permit, 10).expect("first signing operation completes");
1666
1667 let mut second = AuthApi::token_signing_cost_guard_request_at(
1668 AuthApi::token_mint_replay_command_kind(),
1669 caller,
1670 payer,
1671 20,
1672 current_cycle_balance,
1673 );
1674 second.max_operations_per_window = 1;
1675
1676 let err = CostGuardOps::reserve(second).expect_err("quota rejects second operation");
1677 assert!(err.to_string().contains("quota exceeded"));
1678 }
1679
1680 #[test]
1681 fn delegated_token_issue_payload_hash_ignores_metadata() {
1682 let command_kind = AuthApi::token_issue_replay_command_kind();
1683 let actor = crate::ops::replay::model::ReplayActor::direct_caller(p(2));
1684 let a = issue_request(1);
1685 let b = issue_request(9);
1686
1687 assert_eq!(
1688 AuthApi::token_issue_replay_payload_hash(&command_kind, &actor, &a),
1689 AuthApi::token_issue_replay_payload_hash(&command_kind, &actor, &b)
1690 );
1691 }
1692
1693 #[test]
1694 fn delegated_token_issue_payload_hash_binds_authoritative_payload() {
1695 let command_kind = AuthApi::token_issue_replay_command_kind();
1696 let actor = crate::ops::replay::model::ReplayActor::direct_caller(p(2));
1697 let a = issue_request(1);
1698 let mut b = a.clone();
1699 b.nonce = [10; 16];
1700
1701 assert_ne!(
1702 AuthApi::token_issue_replay_payload_hash(&command_kind, &actor, &a),
1703 AuthApi::token_issue_replay_payload_hash(&command_kind, &actor, &b)
1704 );
1705 }
1706}