1use crate::{
2 cdk::types::Principal,
3 dto::{
4 auth::{
5 AttestationKeySet, AuthRequestMetadata, DelegatedRoleGrant, DelegatedToken,
6 DelegatedTokenIssueRequest, DelegationAudience, DelegationCert, DelegationProof,
7 DelegationProofGetRequest, DelegationProofIssueRequest, DelegationProofPrepareResponse,
8 InternalInvocationProofRequest, RoleAttestationRequest, ShardKeyBinding,
9 SignedInternalInvocationProofV1, SignedRoleAttestation,
10 },
11 error::{Error, ErrorCode},
12 rpc::{Request as RootRequest, Response as RootCapabilityResponse},
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;
45
46mod metadata;
51mod root_client;
52mod session;
53mod verify_flow;
54
55pub struct AuthApi;
62
63impl AuthApi {
64 const DELEGATED_TOKENS_DISABLED: &str =
65 "delegated token auth disabled; set auth.delegated_tokens.enabled=true in canic.toml";
66 const MAX_DELEGATED_SESSION_TTL_SECS: u64 = 24 * 60 * 60;
67 const DELEGATION_REPLAY_COMMAND_KIND: &str = "auth.prepare_delegation_proof.v1";
68 const DELEGATION_REPLAY_RESPONSE_SCHEMA_VERSION: u32 = 1;
69 const MAX_DELEGATION_REPLAY_TTL_NS: u64 = 300_000_000_000;
70 const TOKEN_ISSUE_REPLAY_COMMAND_KIND: &str = "auth.issue_token.v1";
71 const TOKEN_REPLAY_RESPONSE_SCHEMA_VERSION: u32 = 1;
72 const MAX_TOKEN_REPLAY_TTL_NS: u64 = 300_000_000_000;
73 const TOKEN_SIGNING_QUOTA_WINDOW_SECONDS: u64 = 60;
74 const MAX_TOKEN_SIGNING_OPERATIONS_PER_WINDOW: u64 = 60;
75 const TOKEN_SIGNING_CYCLE_RESERVATION_CYCLES: u128 = 1_000_000_000;
76 const MIN_TOKEN_SIGNING_CYCLES_AFTER_RESERVATION: u128 = 1_000_000_000;
77 const SESSION_BOOTSTRAP_TOKEN_FINGERPRINT_DOMAIN: &[u8] =
78 b"canic-session-bootstrap-token-fingerprint";
79
80 fn map_auth_error(err: crate::InternalError) -> Error {
82 match err.class() {
83 InternalErrorClass::Infra | InternalErrorClass::Ops | InternalErrorClass::Workflow => {
84 Error::internal(err.to_string())
85 }
86 _ => Error::from(err),
87 }
88 }
89
90 fn map_internal_invocation_verify_error(err: AuthOpsError) -> Error {
91 match err {
92 AuthOpsError::Validation(AuthValidationError::AttestationUnknownKeyId { .. }) => {
93 Error::new(ErrorCode::AuthKeyUnknown, err.to_string())
94 }
95 AuthOpsError::Expiry(AuthExpiryError::AttestationEpochRejected { .. }) => {
96 Error::new(ErrorCode::AuthMaterialStale, err.to_string())
97 }
98 AuthOpsError::Expiry(
99 AuthExpiryError::AttestationExpired { .. }
100 | AuthExpiryError::AttestationNotYetValid { .. },
101 ) => Error::new(ErrorCode::AuthProofExpired, err.to_string()),
102 _ => Error::unauthorized(err.to_string()),
103 }
104 }
105
106 fn verify_token_material(
111 token: &DelegatedToken,
112 max_cert_ttl_ns: u64,
113 max_token_ttl_ns: u64,
114 required_scopes: &[String],
115 now_ns: u64,
116 ) -> Result<Principal, Error> {
117 AuthOps::verify_token(VerifyDelegatedTokenRuntimeInput {
118 token,
119 max_cert_ttl_ns,
120 max_token_ttl_ns,
121 required_scopes,
122 now_ns,
123 })
124 .map(|verified| verified.subject)
125 .map_err(Self::map_auth_error)
126 }
127
128 pub async fn local_shard_public_key_sec1() -> Result<Vec<u8>, Error> {
130 AuthOps::local_shard_public_key_sec1(IcOps::canister_self())
131 .await
132 .map_err(Self::map_auth_error)
133 }
134
135 pub async fn issue_token(request: DelegatedTokenIssueRequest) -> Result<DelegatedToken, Error> {
137 let label = "delegated token issue";
138 let metadata = Self::token_replay_metadata(request.metadata, "delegated token issue")?;
139 let operation_id = OperationId::from_bytes(metadata.request_id);
140 let command_kind = Self::token_issue_replay_command_kind();
141 let caller = IcOps::msg_caller();
142 let actor = ReplayActor::direct_caller(caller);
143 let payload_hash = Self::token_issue_replay_payload_hash(&command_kind, &actor, &request);
144 let token = match Self::reserve_token_replay_receipt(
145 command_kind.clone(),
146 metadata,
147 actor,
148 payload_hash,
149 )? {
150 ReplayReceiptDecision::Fresh(token) => {
151 Self::log_token_replay_reserved(label, &command_kind, operation_id, caller);
152 token
153 }
154 decision => {
155 Self::log_token_replay_decision(
156 label,
157 &command_kind,
158 operation_id,
159 caller,
160 &decision,
161 );
162 return Self::map_token_replay_decision(decision, label);
163 }
164 };
165
166 Self::issue_fresh_token_from_proof(
167 token,
168 command_kind,
169 caller,
170 operation_id,
171 label,
172 request,
173 )
174 .await
175 }
176
177 pub async fn prepare_delegation_proof(
179 request: DelegationProofIssueRequest,
180 ) -> Result<DelegationProofPrepareResponse, Error> {
181 let request = metadata::with_delegation_request_metadata(request);
182 Self::prepare_delegation_proof_remote(request).await
183 }
184
185 pub async fn prepare_delegation_proof_root(
187 request: DelegationProofIssueRequest,
188 ) -> Result<DelegationProofPrepareResponse, Error> {
189 EnvOps::require_root().map_err(Error::from)?;
190 let caller = IcOps::msg_caller();
191 Self::validate_delegation_request_caller(caller, request.shard_pid)?;
192 let max_cert_ttl_ns = Self::delegated_token_max_ttl_ns()?;
193 let metadata = Self::delegation_replay_metadata(request.metadata)?;
194 let command_kind = Self::delegation_replay_command_kind();
195 let actor = ReplayActor::direct_caller(caller);
196 let payload_hash = Self::delegation_replay_payload_hash(&command_kind, &actor, &request);
197 let now_ns = IcOps::now_nanos();
198 let expires_at_ns = now_ns.checked_add(metadata.ttl_ns).ok_or_else(|| {
199 Error::invalid("delegation proof replay metadata ttl_ns overflows nanoseconds")
200 })?;
201 let replay_input = ReplayReceiptReserveInput::new(
202 command_kind.clone(),
203 OperationId::from_bytes(metadata.request_id),
204 actor,
205 payload_hash,
206 now_ns,
207 )
208 .with_expires_at_ns(expires_at_ns);
209
210 let token = match reserve_or_replay_receipt(replay_input)
211 .map_err(Self::map_delegation_replay_store_error)?
212 {
213 ReplayReceiptDecision::Fresh(token) => token,
214 decision => return Self::map_delegation_replay_decision(decision),
215 };
216
217 Self::prepare_fresh_delegation_proof(token, caller, request, max_cert_ttl_ns).await
218 }
219
220 pub fn get_delegation_proof_root(
222 request: DelegationProofGetRequest,
223 ) -> Result<DelegationProof, Error> {
224 EnvOps::require_root().map_err(Error::from)?;
225 let caller = IcOps::msg_caller();
226 AuthOps::get_delegation_proof(caller, request.cert_hash).map_err(Self::map_auth_error)
227 }
228
229 async fn prepare_fresh_delegation_proof(
230 token: ReplayReceiptToken,
231 _caller: Principal,
232 request: DelegationProofIssueRequest,
233 max_cert_ttl_ns: u64,
234 ) -> Result<DelegationProofPrepareResponse, Error> {
235 let max_token_ttl_ns = request.cert_ttl_ns.min(max_cert_ttl_ns);
236 let prepared = match AuthOps::prepare_delegation_proof(SignDelegationProofInput {
237 operation_id: token.receipt().operation_id.into_bytes(),
238 audience: request.aud,
239 grants: request.grants,
240 shard_pid: request.shard_pid,
241 cert_ttl_ns: request.cert_ttl_ns,
242 max_token_ttl_ns,
243 max_cert_ttl_ns,
244 issued_at_ns: IcOps::now_nanos(),
245 })
246 .await
247 {
248 Ok(prepared) => prepared,
249 Err(err) => {
250 abort_reserved_receipt(&token);
251 return Err(Self::map_auth_error(err));
252 }
253 };
254
255 let response = DelegationProofPrepareResponse {
256 cert: prepared.cert,
257 cert_hash: prepared.cert_hash,
258 retrieval_expires_at_ns: prepared.retrieval_expires_at_ns,
259 };
260
261 let response_bytes = match Self::encode_delegation_prepare_response(&response) {
262 Ok(response_bytes) => response_bytes,
263 Err(err) => {
264 mark_recovery_required(
265 &token,
266 RecoveryReason::ResponseCommitFailed,
267 secs_to_ns(IcOps::now_secs()),
268 );
269 return Err(err);
270 }
271 };
272
273 commit_receipt_response(
274 &token,
275 Self::DELEGATION_REPLAY_RESPONSE_SCHEMA_VERSION,
276 response_bytes,
277 secs_to_ns(IcOps::now_secs()),
278 );
279 Ok(response)
280 }
281
282 async fn issue_fresh_token_from_proof(
283 token: ReplayReceiptToken,
284 command_kind: CommandKind,
285 caller: Principal,
286 operation_id: OperationId,
287 label: &'static str,
288 request: DelegatedTokenIssueRequest,
289 ) -> Result<DelegatedToken, Error> {
290 let prepared = match AuthOps::prepare_delegated_token_signature(SignDelegatedTokenInput {
291 proof: request.proof,
292 subject: request.subject,
293 audience: request.aud,
294 grants: request.grants,
295 ttl_ns: request.ttl_ns,
296 nonce: request.nonce,
297 }) {
298 Ok(prepared) => prepared,
299 Err(err) => {
300 abort_reserved_receipt(&token);
301 return Err(Self::map_auth_error(err));
302 }
303 };
304
305 let cost_permit = match CostGuardOps::reserve(Self::token_signing_cost_guard_request(
306 command_kind.clone(),
307 caller,
308 )) {
309 Ok(permit) => permit,
310 Err(err) => {
311 abort_reserved_receipt(&token);
312 return Err(Self::map_auth_error(err));
313 }
314 };
315 Self::log_token_signing_cost_guard_reserved(label, &command_kind, operation_id, caller);
316
317 let effect = AuthOps::delegated_token_signing_effect(&prepared);
318 mark_external_effect_in_flight(&token, effect.clone(), secs_to_ns(IcOps::now_secs()));
319 Self::log_token_replay_effect_marked(label, &command_kind, operation_id, caller, &effect);
320
321 let delegated_token =
322 match AuthOps::sign_prepared_delegated_token(&cost_permit, prepared).await {
323 Ok(delegated_token) => delegated_token,
324 Err(err) => {
325 let _ = CostGuardOps::recover(&cost_permit, IcOps::now_secs());
326 mark_recovery_required(
327 &token,
328 RecoveryReason::ExternalEffectStatusUnknown,
329 secs_to_ns(IcOps::now_secs()),
330 );
331 Self::log_token_replay_recovery_required(
332 label,
333 &command_kind,
334 operation_id,
335 caller,
336 &err,
337 );
338 return Err(Self::map_auth_error(err));
339 }
340 };
341
342 let response_bytes = match Self::encode_delegated_token_response(&delegated_token) {
343 Ok(response_bytes) => response_bytes,
344 Err(err) => {
345 let _ = CostGuardOps::recover(&cost_permit, IcOps::now_secs());
346 mark_recovery_required(
347 &token,
348 RecoveryReason::ResponseCommitFailed,
349 secs_to_ns(IcOps::now_secs()),
350 );
351 Self::log_token_replay_response_commit_failed(
352 label,
353 &command_kind,
354 operation_id,
355 caller,
356 &err,
357 );
358 return Err(err);
359 }
360 };
361
362 if let Err(err) = CostGuardOps::complete(&cost_permit, IcOps::now_secs()) {
363 mark_recovery_required(
364 &token,
365 RecoveryReason::ResponseCommitFailed,
366 secs_to_ns(IcOps::now_secs()),
367 );
368 Self::log_token_replay_response_commit_failed_internal(
369 label,
370 &command_kind,
371 operation_id,
372 caller,
373 &err,
374 );
375 return Err(Self::map_auth_error(err));
376 }
377
378 commit_receipt_response(
379 &token,
380 Self::TOKEN_REPLAY_RESPONSE_SCHEMA_VERSION,
381 response_bytes,
382 secs_to_ns(IcOps::now_secs()),
383 );
384 Self::log_token_replay_commit(label, &command_kind, operation_id, caller);
385 Ok(delegated_token)
386 }
387
388 pub async fn request_role_attestation(
390 request: RoleAttestationRequest,
391 ) -> Result<SignedRoleAttestation, Error> {
392 let request = metadata::with_root_attestation_request_metadata(request);
393 Self::request_role_attestation_remote(request).await
394 }
395
396 pub async fn request_internal_invocation_proof(
398 request: InternalInvocationProofRequest,
399 ) -> Result<SignedInternalInvocationProofV1, Error> {
400 let request = metadata::with_internal_invocation_proof_request_metadata(request);
401 Self::request_internal_invocation_proof_remote(request).await
402 }
403
404 pub async fn attestation_key_set() -> Result<AttestationKeySet, Error> {
406 AuthOps::attestation_key_set()
407 .await
408 .map_err(Self::map_auth_error)
409 }
410
411 pub async fn publish_root_auth_material() -> Result<(), Error> {
413 EnvOps::require_root().map_err(Error::from)?;
414 AuthOps::publish_root_auth_material().await.map_err(|err| {
415 log!(
416 Topic::Auth,
417 Warn,
418 "root auth material publish failed: {err}"
419 );
420 Self::map_auth_error(err)
421 })
422 }
423
424 pub fn replace_attestation_key_set(key_set: AttestationKeySet) {
426 AuthOps::replace_attestation_key_set(key_set);
427 }
428
429 pub async fn verify_role_attestation(
431 attestation: &SignedRoleAttestation,
432 min_accepted_epoch: u64,
433 ) -> Result<(), Error> {
434 crate::workflow::runtime::auth::RuntimeAuthWorkflow::verify_role_attestation(
435 attestation,
436 min_accepted_epoch,
437 )
438 .await
439 .map_err(Self::map_auth_error)
440 }
441
442 pub async fn verify_internal_invocation_proof(
444 proof: &SignedInternalInvocationProofV1,
445 target_method: &str,
446 accepted_roles: &[CanisterRole],
447 ) -> Result<(), Error> {
448 let configured_min_accepted_epoch = ConfigOps::role_attestation_config()
449 .map_err(Error::from)?
450 .min_accepted_epoch_by_role
451 .get(proof.payload.role.as_str())
452 .copied();
453 let min_accepted_epoch =
454 verify_flow::resolve_min_accepted_epoch(0, configured_min_accepted_epoch);
455
456 let caller = IcOps::msg_caller();
457 let self_pid = IcOps::canister_self();
458 let now_ns = IcOps::now_nanos();
459 let verifier_subnet = Some(EnvOps::subnet_pid().map_err(Error::from)?);
460 let root_pid = EnvOps::root_pid().map_err(Error::from)?;
461
462 let verify = || {
463 AuthOps::verify_internal_invocation_proof_cached(
464 proof,
465 crate::ops::auth::InternalInvocationProofVerificationInput {
466 caller,
467 self_pid,
468 target_method,
469 accepted_roles,
470 verifier_subnet,
471 now_ns,
472 min_accepted_epoch,
473 },
474 )
475 .map(|_| ())
476 };
477 let refresh = || async {
478 let key_set = RootAuthMaterialClient::new(root_pid)
479 .attestation_key_set()
480 .await?;
481 AuthOps::replace_attestation_key_set(key_set);
482 Ok(())
483 };
484
485 match verify_flow::verify_role_attestation_with_single_refresh(verify, refresh).await {
486 Ok(()) => Ok(()),
487 Err(verify_flow::RoleAttestationVerifyFlowError::Initial(err)) => {
488 verify_flow::record_attestation_verifier_rejection(&err);
489 log!(
490 Topic::Auth,
491 Warn,
492 "internal invocation proof rejected phase=cached local={} caller={} subject={} role={} key_id={} audience={} method={} epoch={} error={}",
493 self_pid,
494 caller,
495 proof.payload.subject,
496 proof.payload.role,
497 proof.key_id,
498 proof.payload.audience,
499 proof.payload.audience_method,
500 proof.payload.epoch,
501 err
502 );
503 Err(Self::map_internal_invocation_verify_error(err))
504 }
505 Err(verify_flow::RoleAttestationVerifyFlowError::Refresh { trigger, source }) => {
506 verify_flow::record_attestation_verifier_rejection(&trigger);
507 record_attestation_refresh_failed();
508 log!(
509 Topic::Auth,
510 Warn,
511 "internal invocation proof refresh failed local={} caller={} key_id={} error={}",
512 self_pid,
513 caller,
514 proof.key_id,
515 source
516 );
517 Err(Self::map_auth_error(source))
518 }
519 Err(verify_flow::RoleAttestationVerifyFlowError::PostRefresh(err)) => {
520 verify_flow::record_attestation_verifier_rejection(&err);
521 log!(
522 Topic::Auth,
523 Warn,
524 "internal invocation proof rejected phase=post_refresh local={} caller={} subject={} role={} key_id={} audience={} method={} epoch={} error={}",
525 self_pid,
526 caller,
527 proof.payload.subject,
528 proof.payload.role,
529 proof.key_id,
530 proof.payload.audience,
531 proof.payload.audience_method,
532 proof.payload.epoch,
533 err
534 );
535 Err(Self::map_internal_invocation_verify_error(err))
536 }
537 }
538 }
539
540 fn delegated_token_max_ttl_ns() -> Result<u64, Error> {
542 let cfg = ConfigOps::delegated_tokens_config().map_err(Error::from)?;
543 if !cfg.enabled {
544 return Err(Error::forbidden(Self::DELEGATED_TOKENS_DISABLED));
545 }
546
547 let max_ttl_secs = cfg
548 .max_ttl_secs
549 .unwrap_or(Self::MAX_DELEGATED_SESSION_TTL_SECS);
550 max_ttl_secs.checked_mul(1_000_000_000).ok_or_else(|| {
551 Error::invalid("auth.delegated_tokens.max_ttl_secs overflows nanoseconds")
552 })
553 }
554
555 fn validate_delegation_request_caller(
556 caller: Principal,
557 shard_pid: Principal,
558 ) -> Result<(), Error> {
559 if caller == shard_pid {
560 return Ok(());
561 }
562
563 Err(Error::forbidden(format!(
564 "delegation request caller {caller} must match shard_pid {shard_pid}"
565 )))
566 }
567
568 fn delegation_replay_metadata(
569 metadata: Option<AuthRequestMetadata>,
570 ) -> Result<AuthRequestMetadata, Error> {
571 let metadata = metadata.ok_or_else(Error::operation_id_required)?;
572 if metadata.ttl_ns == 0 {
573 return Err(Error::invalid(
574 "delegation proof replay metadata ttl_ns must be greater than zero",
575 ));
576 }
577 if metadata.ttl_ns > Self::MAX_DELEGATION_REPLAY_TTL_NS {
578 return Err(Error::invalid(format!(
579 "delegation proof replay metadata ttl_ns={} exceeds max {}",
580 metadata.ttl_ns,
581 Self::MAX_DELEGATION_REPLAY_TTL_NS
582 )));
583 }
584 Ok(metadata)
585 }
586
587 fn token_replay_metadata(
588 metadata: Option<AuthRequestMetadata>,
589 label: &str,
590 ) -> Result<AuthRequestMetadata, Error> {
591 let metadata = metadata.ok_or_else(Error::operation_id_required)?;
592 if metadata.ttl_ns == 0 {
593 return Err(Error::invalid(format!(
594 "{label} replay metadata ttl_ns must be greater than zero"
595 )));
596 }
597 if metadata.ttl_ns > Self::MAX_TOKEN_REPLAY_TTL_NS {
598 return Err(Error::invalid(format!(
599 "{label} replay metadata ttl_ns={} exceeds max {}",
600 metadata.ttl_ns,
601 Self::MAX_TOKEN_REPLAY_TTL_NS
602 )));
603 }
604 Ok(metadata)
605 }
606
607 fn delegation_replay_command_kind() -> CommandKind {
608 CommandKind::new(Self::DELEGATION_REPLAY_COMMAND_KIND)
609 .expect("delegation replay command kind is a valid static label")
610 }
611
612 fn token_issue_replay_command_kind() -> CommandKind {
613 CommandKind::new(Self::TOKEN_ISSUE_REPLAY_COMMAND_KIND)
614 .expect("delegated-token issue replay command kind is a valid static label")
615 }
616
617 fn delegation_replay_payload_hash(
618 command_kind: &CommandKind,
619 actor: &ReplayActor,
620 request: &DelegationProofIssueRequest,
621 ) -> [u8; 32] {
622 let mut hasher = ReplayPayloadHasher::new(command_kind, actor);
623 hasher.hash_principal(&request.shard_pid);
624 Self::hash_delegation_audience(&mut hasher, &request.aud);
625 Self::hash_delegated_role_grants(&mut hasher, &request.grants);
626 hasher.hash_u64(request.cert_ttl_ns);
627 hasher.finish()
628 }
629
630 fn token_issue_replay_payload_hash(
631 command_kind: &CommandKind,
632 actor: &ReplayActor,
633 request: &DelegatedTokenIssueRequest,
634 ) -> [u8; 32] {
635 let mut hasher = ReplayPayloadHasher::new(command_kind, actor);
636 Self::hash_delegation_proof(&mut hasher, &request.proof);
637 hasher.hash_principal(&request.subject);
638 Self::hash_delegation_audience(&mut hasher, &request.aud);
639 Self::hash_delegated_role_grants(&mut hasher, &request.grants);
640 hasher.hash_u64(request.ttl_ns);
641 hasher.hash_bytes(&request.nonce);
642 hasher.finish()
643 }
644
645 fn hash_delegation_audience(hasher: &mut ReplayPayloadHasher, aud: &DelegationAudience) {
646 match aud {
647 DelegationAudience::Canic => {
648 hasher.hash_str("canic");
649 }
650 DelegationAudience::Project(project) => {
651 hasher.hash_str("project");
652 hasher.hash_str(project);
653 }
654 }
655 }
656
657 fn hash_delegated_role_grants(hasher: &mut ReplayPayloadHasher, grants: &[DelegatedRoleGrant]) {
658 hasher.hash_u64(grants.len() as u64);
659 for grant in grants {
660 hasher.hash_role(&grant.target);
661 Self::hash_string_vec(hasher, &grant.scopes);
662 }
663 }
664
665 fn hash_delegation_proof(hasher: &mut ReplayPayloadHasher, proof: &DelegationProof) {
666 Self::hash_delegation_cert(hasher, &proof.cert);
667 Self::hash_root_proof(hasher, &proof.root_proof);
668 }
669
670 fn hash_delegation_cert(hasher: &mut ReplayPayloadHasher, cert: &DelegationCert) {
671 hasher.hash_principal(&cert.root_pid);
672 hasher.hash_principal(&cert.shard_pid);
673 hasher.hash_str(&cert.shard_key_id);
674 Self::hash_shard_signature_algorithm(hasher, cert.shard_sig_alg);
675 hasher.hash_bytes(&cert.shard_public_key_sec1);
676 hasher.hash_bytes(&cert.shard_key_hash);
677 Self::hash_shard_key_binding(hasher, cert.shard_key_binding);
678 hasher.hash_u64(cert.issued_at_ns);
679 hasher.hash_u64(cert.not_before_ns);
680 hasher.hash_u64(cert.expires_at_ns);
681 hasher.hash_u64(cert.max_token_ttl_ns);
682 Self::hash_delegation_audience(hasher, &cert.aud);
683 Self::hash_delegated_role_grants(hasher, &cert.grants);
684 }
685
686 fn hash_root_proof(hasher: &mut ReplayPayloadHasher, proof: &crate::dto::auth::RootProof) {
687 match proof {
688 crate::dto::auth::RootProof::IcCanisterSignatureV1(proof) => {
689 hasher.hash_str("IcCanisterSignatureV1");
690 hasher.hash_bytes(&proof.signature_cbor);
691 hasher.hash_bytes(&proof.public_key_der);
692 }
693 }
694 }
695
696 fn hash_shard_signature_algorithm(
697 hasher: &mut ReplayPayloadHasher,
698 alg: crate::dto::auth::ShardSignatureAlgorithm,
699 ) {
700 match alg {
701 crate::dto::auth::ShardSignatureAlgorithm::IcThresholdEcdsaSecp256k1 => {
702 hasher.hash_str("IcThresholdEcdsaSecp256k1");
703 }
704 }
705 }
706
707 fn hash_shard_key_binding(hasher: &mut ReplayPayloadHasher, binding: ShardKeyBinding) {
708 match binding {
709 ShardKeyBinding::IcThresholdEcdsaSecp256k1 {
710 key_name_hash,
711 derivation_path_hash,
712 } => {
713 hasher.hash_str("IcThresholdEcdsaSecp256k1");
714 hasher.hash_bytes(&key_name_hash);
715 hasher.hash_bytes(&derivation_path_hash);
716 }
717 }
718 }
719
720 fn hash_string_vec(hasher: &mut ReplayPayloadHasher, values: &[String]) {
721 hasher.hash_u64(values.len() as u64);
722 for value in values {
723 hasher.hash_str(value);
724 }
725 }
726
727 fn reserve_token_replay_receipt(
728 command_kind: CommandKind,
729 metadata: AuthRequestMetadata,
730 actor: ReplayActor,
731 payload_hash: [u8; 32],
732 ) -> Result<ReplayReceiptDecision, Error> {
733 let now_ns = IcOps::now_nanos();
734 let expires_at_ns = now_ns.checked_add(metadata.ttl_ns).ok_or_else(|| {
735 Error::invalid("delegated token issue replay metadata ttl_ns overflows nanoseconds")
736 })?;
737 let replay_input = ReplayReceiptReserveInput::new(
738 command_kind,
739 OperationId::from_bytes(metadata.request_id),
740 actor,
741 payload_hash,
742 now_ns,
743 )
744 .with_expires_at_ns(expires_at_ns);
745
746 reserve_or_replay_receipt(replay_input).map_err(Self::map_delegation_replay_store_error)
747 }
748
749 fn token_signing_cost_guard_request(
750 command_kind: CommandKind,
751 caller: Principal,
752 ) -> CostGuardRequest {
753 Self::token_signing_cost_guard_request_at(
754 command_kind,
755 caller,
756 IcOps::canister_self(),
757 IcOps::now_secs(),
758 MgmtOps::canister_cycle_balance().to_u128(),
759 )
760 }
761
762 const fn token_signing_cost_guard_request_at(
763 command_kind: CommandKind,
764 caller: Principal,
765 payer: Principal,
766 now_secs: u64,
767 current_cycle_balance: u128,
768 ) -> CostGuardRequest {
769 CostGuardRequest {
770 cost_class: crate::replay_policy::CostClass::ShardTokenSign,
771 command_kind,
772 quota_subject: caller,
773 payer,
774 now_secs,
775 quota_window_secs: Self::TOKEN_SIGNING_QUOTA_WINDOW_SECONDS,
776 max_operations_per_window: Self::MAX_TOKEN_SIGNING_OPERATIONS_PER_WINDOW,
777 current_cycle_balance,
778 cycle_reservation_cycles: Self::TOKEN_SIGNING_CYCLE_RESERVATION_CYCLES,
779 min_cycles_after_reservation: Self::MIN_TOKEN_SIGNING_CYCLES_AFTER_RESERVATION,
780 }
781 }
782
783 fn map_delegation_replay_decision(
784 decision: ReplayReceiptDecision,
785 ) -> Result<DelegationProofPrepareResponse, Error> {
786 match decision {
787 ReplayReceiptDecision::Fresh(_) => {
788 Err(Error::invariant("fresh delegation replay decision escaped"))
789 }
790 ReplayReceiptDecision::ReturnCommitted(receipt) => {
791 Self::decode_delegation_prepare_response(&receipt)
792 }
793 ReplayReceiptDecision::OperationInProgress => Err(Error::conflict(
794 "delegation proof request is already in progress; retry later with the same request id",
795 )),
796 ReplayReceiptDecision::ActorMismatch => Err(Error::conflict(
797 "delegation proof request id was reused by a different caller",
798 )),
799 ReplayReceiptDecision::PayloadMismatch => Err(Error::conflict(
800 "delegation proof request id was reused with a different payload",
801 )),
802 ReplayReceiptDecision::Expired => Err(Error::conflict(
803 "delegation proof replay receipt expired; retry with a new request id",
804 )),
805 ReplayReceiptDecision::RecoveryRequired(reason) => Err(Error::conflict(format!(
806 "delegation proof request requires recovery before replay: {reason:?}"
807 ))),
808 ReplayReceiptDecision::TerminalFailed {
809 error_code,
810 error_bytes,
811 error_bytes_truncated,
812 } => Err(Error::conflict(format!(
813 "delegation proof request previously failed: {error_code:?}; error_bytes_len={}; truncated={error_bytes_truncated}",
814 error_bytes.len()
815 ))),
816 ReplayReceiptDecision::PendingActorQuotaExceeded { max_pending, .. } => {
817 Err(Error::exhausted(format!(
818 "delegation proof pending replay receipt quota exceeded for caller; max_pending={max_pending}"
819 )))
820 }
821 ReplayReceiptDecision::PendingCommandQuotaExceeded { max_pending, .. } => {
822 Err(Error::exhausted(format!(
823 "delegation proof pending replay receipt quota exceeded for command kind; max_pending={max_pending}"
824 )))
825 }
826 }
827 }
828
829 fn map_token_replay_decision(
830 decision: ReplayReceiptDecision,
831 label: &str,
832 ) -> Result<DelegatedToken, Error> {
833 match decision {
834 ReplayReceiptDecision::Fresh(_) => Err(Error::invariant(format!(
835 "fresh {label} replay decision escaped"
836 ))),
837 ReplayReceiptDecision::ReturnCommitted(receipt) => {
838 Self::decode_delegated_token_response(&receipt)
839 }
840 ReplayReceiptDecision::OperationInProgress => Err(Error::conflict(format!(
841 "{label} request is already in progress; retry later with the same request id"
842 ))),
843 ReplayReceiptDecision::ActorMismatch => Err(Error::conflict(format!(
844 "{label} request id was reused by a different caller"
845 ))),
846 ReplayReceiptDecision::PayloadMismatch => Err(Error::conflict(format!(
847 "{label} request id was reused with a different payload"
848 ))),
849 ReplayReceiptDecision::Expired => Err(Error::conflict(format!(
850 "{label} replay receipt expired; retry with a new request id"
851 ))),
852 ReplayReceiptDecision::RecoveryRequired(reason) => Err(Error::conflict(format!(
853 "{label} request requires recovery before replay: {reason:?}"
854 ))),
855 ReplayReceiptDecision::TerminalFailed {
856 error_code,
857 error_bytes,
858 error_bytes_truncated,
859 } => Err(Error::conflict(format!(
860 "{label} request previously failed: {error_code:?}; error_bytes_len={}; truncated={error_bytes_truncated}",
861 error_bytes.len()
862 ))),
863 ReplayReceiptDecision::PendingActorQuotaExceeded { max_pending, .. } => {
864 Err(Error::exhausted(format!(
865 "{label} pending replay receipt quota exceeded for caller; max_pending={max_pending}"
866 )))
867 }
868 ReplayReceiptDecision::PendingCommandQuotaExceeded { max_pending, .. } => {
869 Err(Error::exhausted(format!(
870 "{label} pending replay receipt quota exceeded for command kind; max_pending={max_pending}"
871 )))
872 }
873 }
874 }
875
876 fn log_token_replay_reserved(
877 label: &str,
878 command_kind: &CommandKind,
879 operation_id: OperationId,
880 caller: Principal,
881 ) {
882 log!(
883 Topic::Auth,
884 Info,
885 "{} replay receipt reserved command_kind={} operation_id={} caller={}",
886 label,
887 command_kind.as_str(),
888 operation_id,
889 caller
890 );
891 }
892
893 fn log_token_replay_decision(
894 label: &str,
895 command_kind: &CommandKind,
896 operation_id: OperationId,
897 caller: Principal,
898 decision: &ReplayReceiptDecision,
899 ) {
900 match decision {
901 ReplayReceiptDecision::ReturnCommitted(_) => log!(
902 Topic::Auth,
903 Info,
904 "{} committed replay returned command_kind={} operation_id={} caller={}",
905 label,
906 command_kind.as_str(),
907 operation_id,
908 caller
909 ),
910 _ => log!(
911 Topic::Auth,
912 Warn,
913 "{} replay decision blocked command_kind={} operation_id={} caller={} decision={}",
914 label,
915 command_kind.as_str(),
916 operation_id,
917 caller,
918 Self::token_replay_decision_name(decision)
919 ),
920 }
921 }
922
923 fn log_token_signing_cost_guard_reserved(
924 label: &str,
925 command_kind: &CommandKind,
926 operation_id: OperationId,
927 caller: Principal,
928 ) {
929 log!(
930 Topic::Auth,
931 Info,
932 "{} signing cost guard reserved command_kind={} operation_id={} caller={}",
933 label,
934 command_kind.as_str(),
935 operation_id,
936 caller
937 );
938 }
939
940 fn log_token_replay_effect_marked(
941 label: &str,
942 command_kind: &CommandKind,
943 operation_id: OperationId,
944 caller: Principal,
945 effect: &ExternalEffectDescriptor,
946 ) {
947 log!(
948 Topic::Auth,
949 Info,
950 "{} replay effect marked effect={} command_kind={} operation_id={} caller={}",
951 label,
952 Self::token_effect_name(effect),
953 command_kind.as_str(),
954 operation_id,
955 caller
956 );
957 }
958
959 fn log_token_replay_recovery_required(
960 label: &str,
961 command_kind: &CommandKind,
962 operation_id: OperationId,
963 caller: Principal,
964 err: &crate::InternalError,
965 ) {
966 let (error_class, error_origin) = err.log_fields();
967 log!(
968 Topic::Auth,
969 Error,
970 "{} replay recovery required effect=threshold_ecdsa_sign_delegated_token command_kind={} operation_id={} caller={} error_class={} error_origin={}",
971 label,
972 command_kind.as_str(),
973 operation_id,
974 caller,
975 error_class,
976 error_origin
977 );
978 }
979
980 fn log_token_replay_response_commit_failed(
981 label: &str,
982 command_kind: &CommandKind,
983 operation_id: OperationId,
984 caller: Principal,
985 err: &Error,
986 ) {
987 log!(
988 Topic::Auth,
989 Error,
990 "{} replay response commit failed command_kind={} operation_id={} caller={} error_code={:?}",
991 label,
992 command_kind.as_str(),
993 operation_id,
994 caller,
995 err.code
996 );
997 }
998
999 fn log_token_replay_response_commit_failed_internal(
1000 label: &str,
1001 command_kind: &CommandKind,
1002 operation_id: OperationId,
1003 caller: Principal,
1004 err: &crate::InternalError,
1005 ) {
1006 let (error_class, error_origin) = err.log_fields();
1007 log!(
1008 Topic::Auth,
1009 Error,
1010 "{} replay response commit failed command_kind={} operation_id={} caller={} error_class={} error_origin={}",
1011 label,
1012 command_kind.as_str(),
1013 operation_id,
1014 caller,
1015 error_class,
1016 error_origin
1017 );
1018 }
1019
1020 fn log_token_replay_commit(
1021 label: &str,
1022 command_kind: &CommandKind,
1023 operation_id: OperationId,
1024 caller: Principal,
1025 ) {
1026 log!(
1027 Topic::Auth,
1028 Ok,
1029 "{} replay response committed command_kind={} operation_id={} caller={}",
1030 label,
1031 command_kind.as_str(),
1032 operation_id,
1033 caller
1034 );
1035 }
1036
1037 const fn token_replay_decision_name(decision: &ReplayReceiptDecision) -> &'static str {
1038 match decision {
1039 ReplayReceiptDecision::Fresh(_) => "fresh",
1040 ReplayReceiptDecision::ReturnCommitted(_) => "return_committed",
1041 ReplayReceiptDecision::OperationInProgress => "operation_in_progress",
1042 ReplayReceiptDecision::ActorMismatch => "actor_mismatch",
1043 ReplayReceiptDecision::PayloadMismatch => "payload_mismatch",
1044 ReplayReceiptDecision::Expired => "expired",
1045 ReplayReceiptDecision::RecoveryRequired(_) => "recovery_required",
1046 ReplayReceiptDecision::TerminalFailed { .. } => "terminal_failed",
1047 ReplayReceiptDecision::PendingActorQuotaExceeded { .. } => {
1048 "pending_actor_quota_exceeded"
1049 }
1050 ReplayReceiptDecision::PendingCommandQuotaExceeded { .. } => {
1051 "pending_command_quota_exceeded"
1052 }
1053 }
1054 }
1055
1056 const fn token_effect_name(effect: &ExternalEffectDescriptor) -> &'static str {
1057 match effect {
1058 ExternalEffectDescriptor::ThresholdEcdsaSign {
1059 purpose: EcdsaPurpose::DelegatedToken,
1060 ..
1061 } => "threshold_ecdsa_sign_delegated_token",
1062 ExternalEffectDescriptor::ThresholdEcdsaSign { .. } => "threshold_ecdsa_sign",
1063 ExternalEffectDescriptor::ManagementCreateCanister { .. } => {
1064 "management_create_canister"
1065 }
1066 ExternalEffectDescriptor::ManagementCall { .. } => "management_call",
1067 ExternalEffectDescriptor::IcpTransfer { .. } => "icp_transfer",
1068 }
1069 }
1070
1071 fn map_delegation_replay_store_error(err: ReplayReceiptStoreError) -> Error {
1072 match err {
1073 ReplayReceiptStoreError::ReceiptDecodeFailed(message) => Error::internal(format!(
1074 "failed to decode delegation replay receipt: {message}"
1075 )),
1076 }
1077 }
1078
1079 fn encode_delegation_prepare_response(
1080 response: &DelegationProofPrepareResponse,
1081 ) -> Result<Vec<u8>, Error> {
1082 encode_one(response).map_err(|err| {
1083 Error::internal(format!(
1084 "failed to encode delegation proof prepare replay response: {err}"
1085 ))
1086 })
1087 }
1088
1089 fn encode_delegated_token_response(token: &DelegatedToken) -> Result<Vec<u8>, Error> {
1090 encode_one(token).map_err(|err| {
1091 Error::internal(format!(
1092 "failed to encode delegated token replay response: {err}"
1093 ))
1094 })
1095 }
1096
1097 fn decode_delegation_prepare_response(
1098 receipt: &crate::ops::replay::model::ReplayReceipt,
1099 ) -> Result<DelegationProofPrepareResponse, Error> {
1100 let response_schema_version = receipt.response_schema_version.ok_or_else(|| {
1101 Error::internal("delegation replay receipt is missing response schema version")
1102 })?;
1103 if response_schema_version != Self::DELEGATION_REPLAY_RESPONSE_SCHEMA_VERSION {
1104 return Err(Error::internal(format!(
1105 "unsupported delegation replay response schema version {response_schema_version}"
1106 )));
1107 }
1108 let response_bytes = receipt.response_bytes.as_deref().ok_or_else(|| {
1109 Error::internal("delegation replay receipt is missing response bytes")
1110 })?;
1111 decode_one(response_bytes).map_err(|err| {
1112 Error::internal(format!(
1113 "failed to decode delegation proof prepare replay response: {err}"
1114 ))
1115 })
1116 }
1117
1118 fn decode_delegated_token_response(
1119 receipt: &crate::ops::replay::model::ReplayReceipt,
1120 ) -> Result<DelegatedToken, Error> {
1121 let response_schema_version = receipt.response_schema_version.ok_or_else(|| {
1122 Error::internal("delegated token replay receipt is missing response schema version")
1123 })?;
1124 if response_schema_version != Self::TOKEN_REPLAY_RESPONSE_SCHEMA_VERSION {
1125 return Err(Error::internal(format!(
1126 "unsupported delegated token replay response schema version {response_schema_version}"
1127 )));
1128 }
1129 let response_bytes = receipt.response_bytes.as_deref().ok_or_else(|| {
1130 Error::internal("delegated token replay receipt is missing response bytes")
1131 })?;
1132 decode_one(response_bytes).map_err(|err| {
1133 Error::internal(format!(
1134 "failed to decode delegated token replay response: {err}"
1135 ))
1136 })
1137 }
1138}
1139
1140impl AuthApi {
1141 async fn prepare_delegation_proof_remote(
1143 request: DelegationProofIssueRequest,
1144 ) -> Result<DelegationProofPrepareResponse, Error> {
1145 let root_pid = EnvOps::root_pid().map_err(Error::from)?;
1146 RootAuthMaterialClient::new(root_pid)
1147 .prepare_delegation_proof(request)
1148 .await
1149 .map_err(Self::map_auth_error)
1150 }
1151
1152 pub async fn request_role_attestation_root(
1154 request: RoleAttestationRequest,
1155 ) -> Result<SignedRoleAttestation, Error> {
1156 let request = metadata::with_root_attestation_request_metadata(request);
1157 let response = RootResponseWorkflow::response(RootRequest::issue_role_attestation(request))
1158 .await
1159 .map_err(Self::map_auth_error)?;
1160
1161 match response {
1162 RootCapabilityResponse::RoleAttestationIssued(response) => Ok(response),
1163 _ => Err(Error::internal(
1164 "invalid root response type for role attestation request",
1165 )),
1166 }
1167 }
1168
1169 pub async fn request_internal_invocation_proof_root(
1171 request: InternalInvocationProofRequest,
1172 ) -> Result<SignedInternalInvocationProofV1, Error> {
1173 let request = metadata::with_internal_invocation_proof_request_metadata(request);
1174 let response =
1175 RootResponseWorkflow::response(RootRequest::issue_internal_invocation_proof(request))
1176 .await
1177 .map_err(Self::map_auth_error)?;
1178
1179 match response {
1180 RootCapabilityResponse::InternalInvocationProofIssued(response) => Ok(response),
1181 _ => Err(Error::internal(
1182 "invalid root response type for internal invocation proof request",
1183 )),
1184 }
1185 }
1186
1187 async fn request_role_attestation_remote(
1189 request: RoleAttestationRequest,
1190 ) -> Result<SignedRoleAttestation, Error> {
1191 let root_pid = EnvOps::root_pid().map_err(Error::from)?;
1192 RootAuthMaterialClient::new(root_pid)
1193 .request_role_attestation(request)
1194 .await
1195 .map_err(Self::map_auth_error)
1196 }
1197
1198 async fn request_internal_invocation_proof_remote(
1200 request: InternalInvocationProofRequest,
1201 ) -> Result<SignedInternalInvocationProofV1, Error> {
1202 let root_pid = EnvOps::root_pid().map_err(Error::from)?;
1203 RootAuthMaterialClient::new(root_pid)
1204 .request_internal_invocation_proof(request)
1205 .await
1206 .map_err(Self::map_auth_error)
1207 }
1208}
1209
1210#[cfg(test)]
1211mod tests {
1212 use super::AuthApi;
1213 use crate::{
1214 cdk::types::Principal,
1215 dto::{
1216 auth::{
1217 AuthRequestMetadata, DelegatedRoleGrant, DelegatedTokenIssueRequest,
1218 DelegationAudience, DelegationCert, DelegationProof, DelegationProofIssueRequest,
1219 IcCanisterSignatureProofV1, RootProof, ShardKeyBinding, ShardSignatureAlgorithm,
1220 },
1221 error::ErrorCode,
1222 },
1223 ops::auth::{AuthExpiryError, AuthOpsError},
1224 };
1225
1226 fn p(id: u8) -> Principal {
1227 Principal::from_slice(&[id; 29])
1228 }
1229
1230 fn delegation_request(metadata_id: u8) -> DelegationProofIssueRequest {
1231 DelegationProofIssueRequest {
1232 metadata: Some(meta(metadata_id, 60_000_000_000)),
1233 shard_pid: p(2),
1234 aud: DelegationAudience::Project("test".to_string()),
1235 grants: vec![grant("project_instance", &["canic.verify"])],
1236 cert_ttl_ns: 60_000_000_000,
1237 }
1238 }
1239
1240 fn grant(role: &str, scopes: &[&str]) -> DelegatedRoleGrant {
1241 DelegatedRoleGrant {
1242 target: crate::ids::CanisterRole::owned(role.to_string()),
1243 scopes: scopes.iter().map(|scope| (*scope).to_string()).collect(),
1244 }
1245 }
1246
1247 fn meta(id: u8, ttl_ns: u64) -> AuthRequestMetadata {
1248 AuthRequestMetadata {
1249 request_id: [id; 32],
1250 ttl_ns,
1251 }
1252 }
1253
1254 fn delegation_proof() -> DelegationProof {
1255 DelegationProof {
1256 cert: DelegationCert {
1257 root_pid: p(1),
1258 shard_pid: p(2),
1259 shard_key_id: "shard-key".to_string(),
1260 shard_sig_alg: ShardSignatureAlgorithm::IcThresholdEcdsaSecp256k1,
1261 shard_public_key_sec1: vec![3; 33],
1262 shard_key_hash: [4; 32],
1263 shard_key_binding: ShardKeyBinding::IcThresholdEcdsaSecp256k1 {
1264 key_name_hash: [5; 32],
1265 derivation_path_hash: [6; 32],
1266 },
1267 issued_at_ns: 10,
1268 not_before_ns: 10,
1269 expires_at_ns: 100,
1270 max_token_ttl_ns: 60,
1271 aud: DelegationAudience::Project("test".to_string()),
1272 grants: vec![grant("project_instance", &["canic.verify"])],
1273 },
1274 root_proof: RootProof::IcCanisterSignatureV1(IcCanisterSignatureProofV1 {
1275 signature_cbor: vec![7; 64],
1276 public_key_der: vec![8; 32],
1277 }),
1278 }
1279 }
1280
1281 fn issue_request(metadata_id: u8) -> DelegatedTokenIssueRequest {
1282 DelegatedTokenIssueRequest {
1283 metadata: Some(meta(metadata_id, 60_000_000_000)),
1284 proof: delegation_proof(),
1285 subject: p(8),
1286 aud: DelegationAudience::Project("test".to_string()),
1287 grants: vec![grant("project_instance", &["canic.verify"])],
1288 ttl_ns: 30_000_000_000,
1289 nonce: [9; 16],
1290 }
1291 }
1292
1293 #[test]
1294 fn internal_invocation_not_yet_valid_maps_to_non_retryable_proof_expiry() {
1295 let err = AuthApi::map_internal_invocation_verify_error(AuthOpsError::Expiry(
1296 AuthExpiryError::AttestationNotYetValid {
1297 issued_at_ns: 20,
1298 now_ns: 10,
1299 },
1300 ));
1301
1302 assert_eq!(err.code, ErrorCode::AuthProofExpired);
1303 }
1304
1305 #[test]
1306 fn delegation_request_caller_must_match_requested_shard() {
1307 AuthApi::validate_delegation_request_caller(p(2), p(2)).expect("matching shard");
1308
1309 let err = AuthApi::validate_delegation_request_caller(p(1), p(2))
1310 .expect_err("mismatched caller must fail");
1311
1312 assert_eq!(err.code, ErrorCode::Forbidden);
1313 }
1314
1315 #[test]
1316 fn delegation_replay_metadata_rejects_missing_or_invalid_ttl() {
1317 let missing = AuthApi::delegation_replay_metadata(None).expect_err("metadata is required");
1318 assert_eq!(missing.code, ErrorCode::OperationIdRequired);
1319
1320 let zero = AuthApi::delegation_replay_metadata(Some(AuthRequestMetadata {
1321 request_id: [1; 32],
1322 ttl_ns: 0,
1323 }))
1324 .expect_err("zero ttl is invalid");
1325 assert_eq!(zero.code, ErrorCode::InvalidInput);
1326
1327 let too_large = AuthApi::delegation_replay_metadata(Some(AuthRequestMetadata {
1328 request_id: [1; 32],
1329 ttl_ns: AuthApi::MAX_DELEGATION_REPLAY_TTL_NS + 1,
1330 }))
1331 .expect_err("oversized ttl is invalid");
1332 assert_eq!(too_large.code, ErrorCode::InvalidInput);
1333 }
1334
1335 #[test]
1336 fn delegation_replay_payload_hash_ignores_metadata() {
1337 let command_kind = AuthApi::delegation_replay_command_kind();
1338 let actor = crate::ops::replay::model::ReplayActor::direct_caller(p(2));
1339 let a = delegation_request(1);
1340 let b = delegation_request(9);
1341
1342 assert_eq!(
1343 AuthApi::delegation_replay_payload_hash(&command_kind, &actor, &a),
1344 AuthApi::delegation_replay_payload_hash(&command_kind, &actor, &b)
1345 );
1346 }
1347
1348 #[test]
1349 fn delegated_token_replay_metadata_rejects_missing_or_invalid_ttl() {
1350 let missing =
1351 AuthApi::token_replay_metadata(None, "delegated token mint").expect_err("required");
1352 assert_eq!(missing.code, ErrorCode::OperationIdRequired);
1353
1354 let zero = AuthApi::token_replay_metadata(Some(meta(1, 0)), "delegated token mint")
1355 .expect_err("zero ttl is invalid");
1356 assert_eq!(zero.code, ErrorCode::InvalidInput);
1357
1358 let too_large = AuthApi::token_replay_metadata(
1359 Some(meta(1, AuthApi::MAX_TOKEN_REPLAY_TTL_NS + 1)),
1360 "delegated token mint",
1361 )
1362 .expect_err("oversized ttl is invalid");
1363 assert_eq!(too_large.code, ErrorCode::InvalidInput);
1364 }
1365
1366 #[test]
1367 fn delegation_replay_payload_hash_binds_authoritative_payload() {
1368 let command_kind = AuthApi::delegation_replay_command_kind();
1369 let actor = crate::ops::replay::model::ReplayActor::direct_caller(p(2));
1370 let a = delegation_request(1);
1371 let mut b = a.clone();
1372 b.cert_ttl_ns += 1;
1373
1374 assert_ne!(
1375 AuthApi::delegation_replay_payload_hash(&command_kind, &actor, &a),
1376 AuthApi::delegation_replay_payload_hash(&command_kind, &actor, &b)
1377 );
1378 }
1379
1380 #[test]
1381 fn delegated_token_issue_payload_hash_ignores_metadata() {
1382 let command_kind = AuthApi::token_issue_replay_command_kind();
1383 let actor = crate::ops::replay::model::ReplayActor::direct_caller(p(2));
1384 let a = issue_request(1);
1385 let b = issue_request(9);
1386
1387 assert_eq!(
1388 AuthApi::token_issue_replay_payload_hash(&command_kind, &actor, &a),
1389 AuthApi::token_issue_replay_payload_hash(&command_kind, &actor, &b)
1390 );
1391 }
1392
1393 #[test]
1394 fn delegated_token_issue_payload_hash_binds_authoritative_payload() {
1395 let command_kind = AuthApi::token_issue_replay_command_kind();
1396 let actor = crate::ops::replay::model::ReplayActor::direct_caller(p(2));
1397 let a = issue_request(1);
1398 let mut b = a.clone();
1399 b.nonce = [10; 16];
1400
1401 assert_ne!(
1402 AuthApi::token_issue_replay_payload_hash(&command_kind, &actor, &a),
1403 AuthApi::token_issue_replay_payload_hash(&command_kind, &actor, &b)
1404 );
1405 }
1406}