1use crate::{
2 cdk::types::Principal,
3 dto::{
4 auth::{
5 AttestationKeySet, DelegatedToken, DelegatedTokenIssueRequest,
6 DelegatedTokenMintRequest, DelegationAudience, DelegationProof,
7 DelegationProofIssueRequest, InternalInvocationProofRequest, RoleAttestationRequest,
8 SignedInternalInvocationProofV1, SignedRoleAttestation,
9 },
10 error::{Error, ErrorCode},
11 rpc::{Request as RootRequest, Response as RootCapabilityResponse, RootRequestMetadata},
12 },
13 error::InternalErrorClass,
14 ids::CanisterRole,
15 log,
16 log::Topic,
17 ops::{
18 auth::{
19 AuthExpiryError, AuthOps, AuthOpsError, AuthValidationError, SignDelegatedTokenInput,
20 SignDelegationProofInput, VerifyDelegatedTokenRuntimeInput,
21 },
22 config::ConfigOps,
23 ic::IcOps,
24 replay::{
25 guard::secs_to_ns,
26 model::{
27 CommandKind, EcdsaPurpose, ExternalEffectDescriptor, OperationId, RecoveryReason,
28 ReplayActor, ReplayPayloadHasher,
29 },
30 receipt::{
31 ReplayReceiptDecision, ReplayReceiptReserveInput, ReplayReceiptStoreError,
32 abort_reserved_receipt, commit_receipt_response, mark_external_effect_in_flight,
33 mark_recovery_required, reserve_or_replay_receipt,
34 },
35 },
36 runtime::env::EnvOps,
37 runtime::metrics::auth::record_attestation_refresh_failed,
38 },
39 workflow::rpc::request::handler::RootResponseWorkflow,
40};
41use candid::{decode_one, encode_one};
42use root_client::RootAuthMaterialClient;
43use sha2::{Digest, Sha256};
44
45mod metadata;
50mod root_client;
51mod session;
52mod verify_flow;
53
54pub struct AuthApi;
61
62impl AuthApi {
63 const DELEGATED_TOKENS_DISABLED: &str =
64 "delegated token auth disabled; set auth.delegated_tokens.enabled=true in canic.toml";
65 const MAX_DELEGATED_SESSION_TTL_SECS: u64 = 24 * 60 * 60;
66 const DELEGATION_REPLAY_COMMAND_KIND: &str = "auth.issue_delegation_proof.v1";
67 const DELEGATION_REPLAY_RESPONSE_SCHEMA_VERSION: u32 = 1;
68 const MAX_DELEGATION_REPLAY_TTL_SECONDS: u64 = 300;
69 const SESSION_BOOTSTRAP_TOKEN_FINGERPRINT_DOMAIN: &[u8] =
70 b"canic-session-bootstrap-token-fingerprint";
71
72 fn map_auth_error(err: crate::InternalError) -> Error {
74 match err.class() {
75 InternalErrorClass::Infra | InternalErrorClass::Ops | InternalErrorClass::Workflow => {
76 Error::internal(err.to_string())
77 }
78 _ => Error::from(err),
79 }
80 }
81
82 fn map_internal_invocation_verify_error(err: AuthOpsError) -> Error {
83 match err {
84 AuthOpsError::Validation(AuthValidationError::AttestationUnknownKeyId { .. }) => {
85 Error::new(ErrorCode::AuthKeyUnknown, err.to_string())
86 }
87 AuthOpsError::Expiry(AuthExpiryError::AttestationEpochRejected { .. }) => {
88 Error::new(ErrorCode::AuthMaterialStale, err.to_string())
89 }
90 AuthOpsError::Expiry(
91 AuthExpiryError::AttestationExpired { .. }
92 | AuthExpiryError::AttestationNotYetValid { .. },
93 ) => Error::new(ErrorCode::AuthProofExpired, err.to_string()),
94 _ => Error::unauthorized(err.to_string()),
95 }
96 }
97
98 fn verify_token_material(
103 token: &DelegatedToken,
104 max_cert_ttl_secs: u64,
105 max_token_ttl_secs: u64,
106 required_scopes: &[String],
107 now_secs: u64,
108 ) -> Result<Principal, Error> {
109 AuthOps::verify_token(VerifyDelegatedTokenRuntimeInput {
110 token,
111 max_cert_ttl_secs,
112 max_token_ttl_secs,
113 required_scopes,
114 now_secs,
115 })
116 .map(|verified| verified.subject)
117 .map_err(Self::map_auth_error)
118 }
119
120 pub async fn local_shard_public_key_sec1() -> Result<Vec<u8>, Error> {
122 AuthOps::local_shard_public_key_sec1(IcOps::canister_self())
123 .await
124 .map_err(Self::map_auth_error)
125 }
126
127 pub async fn issue_token(request: DelegatedTokenIssueRequest) -> Result<DelegatedToken, Error> {
129 AuthOps::sign_token(SignDelegatedTokenInput {
130 proof: request.proof,
131 subject: request.subject,
132 audience: request.aud,
133 scopes: request.scopes,
134 ttl_secs: request.ttl_secs,
135 nonce: request.nonce,
136 })
137 .await
138 .map_err(Self::map_auth_error)
139 }
140
141 pub async fn mint_token(request: DelegatedTokenMintRequest) -> Result<DelegatedToken, Error> {
143 let proof = Self::request_delegation(DelegationProofIssueRequest {
144 metadata: None,
145 shard_pid: IcOps::canister_self(),
146 scopes: request.scopes.clone(),
147 aud: request.aud.clone(),
148 cert_ttl_secs: request.cert_ttl_secs,
149 })
150 .await?;
151
152 Self::issue_token(DelegatedTokenIssueRequest {
153 proof,
154 subject: request.subject,
155 aud: request.aud,
156 scopes: request.scopes,
157 ttl_secs: request.token_ttl_secs,
158 nonce: request.nonce,
159 })
160 .await
161 }
162
163 pub async fn request_delegation(
165 request: DelegationProofIssueRequest,
166 ) -> Result<DelegationProof, Error> {
167 let request = metadata::with_delegation_request_metadata(request);
168 Self::request_delegation_remote(request).await
169 }
170
171 pub async fn issue_delegation_proof(
173 request: DelegationProofIssueRequest,
174 ) -> Result<DelegationProof, Error> {
175 EnvOps::require_root().map_err(Error::from)?;
176 let caller = IcOps::msg_caller();
177 Self::validate_delegation_request_caller(caller, request.shard_pid)?;
178 let max_cert_ttl_secs = Self::delegated_token_max_ttl_secs()?;
179 let metadata = Self::delegation_replay_metadata(request.metadata)?;
180 let command_kind = Self::delegation_replay_command_kind();
181 let actor = ReplayActor::direct_caller(caller);
182 let payload_hash = Self::delegation_replay_payload_hash(&command_kind, &actor, &request);
183 let now_secs = IcOps::now_secs();
184 let replay_input = ReplayReceiptReserveInput::new(
185 command_kind,
186 OperationId::from_bytes(metadata.request_id),
187 actor,
188 payload_hash,
189 secs_to_ns(now_secs),
190 )
191 .with_expires_at_ns(secs_to_ns(now_secs.saturating_add(metadata.ttl_seconds)));
192
193 let token = match reserve_or_replay_receipt(replay_input)
194 .map_err(Self::map_delegation_replay_store_error)?
195 {
196 ReplayReceiptDecision::Fresh(token) => token,
197 decision => return Self::map_delegation_replay_decision(decision),
198 };
199
200 let max_token_ttl_secs = request.cert_ttl_secs.min(max_cert_ttl_secs);
201 let prepared = match AuthOps::prepare_delegation_proof(SignDelegationProofInput {
202 audience: request.aud,
203 scopes: request.scopes,
204 shard_pid: request.shard_pid,
205 cert_ttl_secs: request.cert_ttl_secs,
206 max_token_ttl_secs,
207 max_cert_ttl_secs,
208 issued_at: IcOps::now_secs(),
209 })
210 .await
211 {
212 Ok(prepared) => prepared,
213 Err(err) => {
214 abort_reserved_receipt(&token);
215 return Err(Self::map_auth_error(err));
216 }
217 };
218
219 mark_external_effect_in_flight(
220 &token,
221 ExternalEffectDescriptor::ThresholdEcdsaSign {
222 key_id_hash: Self::hash_delegation_effect_key(&prepared.key_name),
223 purpose: EcdsaPurpose::DelegationProof,
224 message_hash: prepared.cert_hash,
225 },
226 secs_to_ns(IcOps::now_secs()),
227 );
228
229 let proof = match AuthOps::sign_prepared_delegation_proof(prepared).await {
230 Ok(proof) => proof,
231 Err(err) => {
232 mark_recovery_required(
233 &token,
234 RecoveryReason::ExternalEffectStatusUnknown,
235 secs_to_ns(IcOps::now_secs()),
236 );
237 return Err(Self::map_auth_error(err));
238 }
239 };
240
241 let response_bytes = match Self::encode_delegation_proof_response(&proof) {
242 Ok(response_bytes) => response_bytes,
243 Err(err) => {
244 mark_recovery_required(
245 &token,
246 RecoveryReason::ResponseCommitFailed,
247 secs_to_ns(IcOps::now_secs()),
248 );
249 return Err(err);
250 }
251 };
252
253 commit_receipt_response(
254 &token,
255 Self::DELEGATION_REPLAY_RESPONSE_SCHEMA_VERSION,
256 response_bytes,
257 secs_to_ns(IcOps::now_secs()),
258 );
259 Ok(proof)
260 }
261
262 pub async fn request_role_attestation(
264 request: RoleAttestationRequest,
265 ) -> Result<SignedRoleAttestation, Error> {
266 let request = metadata::with_root_attestation_request_metadata(request);
267 Self::request_role_attestation_remote(request).await
268 }
269
270 pub async fn request_internal_invocation_proof(
272 request: InternalInvocationProofRequest,
273 ) -> Result<SignedInternalInvocationProofV1, Error> {
274 let request = metadata::with_internal_invocation_proof_request_metadata(request);
275 Self::request_internal_invocation_proof_remote(request).await
276 }
277
278 pub async fn attestation_key_set() -> Result<AttestationKeySet, Error> {
280 AuthOps::attestation_key_set()
281 .await
282 .map_err(Self::map_auth_error)
283 }
284
285 pub async fn publish_root_auth_material() -> Result<(), Error> {
287 EnvOps::require_root().map_err(Error::from)?;
288 AuthOps::publish_root_auth_material().await.map_err(|err| {
289 log!(
290 Topic::Auth,
291 Warn,
292 "root auth material publish failed: {err}"
293 );
294 Self::map_auth_error(err)
295 })
296 }
297
298 pub fn replace_attestation_key_set(key_set: AttestationKeySet) {
300 AuthOps::replace_attestation_key_set(key_set);
301 }
302
303 pub async fn verify_role_attestation(
305 attestation: &SignedRoleAttestation,
306 min_accepted_epoch: u64,
307 ) -> Result<(), Error> {
308 crate::workflow::runtime::auth::RuntimeAuthWorkflow::verify_role_attestation(
309 attestation,
310 min_accepted_epoch,
311 )
312 .await
313 .map_err(Self::map_auth_error)
314 }
315
316 pub async fn verify_internal_invocation_proof(
318 proof: &SignedInternalInvocationProofV1,
319 target_method: &str,
320 accepted_roles: &[CanisterRole],
321 ) -> Result<(), Error> {
322 let configured_min_accepted_epoch = ConfigOps::role_attestation_config()
323 .map_err(Error::from)?
324 .min_accepted_epoch_by_role
325 .get(proof.payload.role.as_str())
326 .copied();
327 let min_accepted_epoch =
328 verify_flow::resolve_min_accepted_epoch(0, configured_min_accepted_epoch);
329
330 let caller = IcOps::msg_caller();
331 let self_pid = IcOps::canister_self();
332 let now_secs = IcOps::now_secs();
333 let verifier_subnet = Some(EnvOps::subnet_pid().map_err(Error::from)?);
334 let root_pid = EnvOps::root_pid().map_err(Error::from)?;
335
336 let verify = || {
337 AuthOps::verify_internal_invocation_proof_cached(
338 proof,
339 crate::ops::auth::InternalInvocationProofVerificationInput {
340 caller,
341 self_pid,
342 target_method,
343 accepted_roles,
344 verifier_subnet,
345 now_secs,
346 min_accepted_epoch,
347 },
348 )
349 .map(|_| ())
350 };
351 let refresh = || async {
352 let key_set = RootAuthMaterialClient::new(root_pid)
353 .attestation_key_set()
354 .await?;
355 AuthOps::replace_attestation_key_set(key_set);
356 Ok(())
357 };
358
359 match verify_flow::verify_role_attestation_with_single_refresh(verify, refresh).await {
360 Ok(()) => Ok(()),
361 Err(verify_flow::RoleAttestationVerifyFlowError::Initial(err)) => {
362 verify_flow::record_attestation_verifier_rejection(&err);
363 log!(
364 Topic::Auth,
365 Warn,
366 "internal invocation proof rejected phase=cached local={} caller={} subject={} role={} key_id={} audience={} method={} epoch={} error={}",
367 self_pid,
368 caller,
369 proof.payload.subject,
370 proof.payload.role,
371 proof.key_id,
372 proof.payload.audience,
373 proof.payload.audience_method,
374 proof.payload.epoch,
375 err
376 );
377 Err(Self::map_internal_invocation_verify_error(err))
378 }
379 Err(verify_flow::RoleAttestationVerifyFlowError::Refresh { trigger, source }) => {
380 verify_flow::record_attestation_verifier_rejection(&trigger);
381 record_attestation_refresh_failed();
382 log!(
383 Topic::Auth,
384 Warn,
385 "internal invocation proof refresh failed local={} caller={} key_id={} error={}",
386 self_pid,
387 caller,
388 proof.key_id,
389 source
390 );
391 Err(Self::map_auth_error(source))
392 }
393 Err(verify_flow::RoleAttestationVerifyFlowError::PostRefresh(err)) => {
394 verify_flow::record_attestation_verifier_rejection(&err);
395 log!(
396 Topic::Auth,
397 Warn,
398 "internal invocation proof rejected phase=post_refresh local={} caller={} subject={} role={} key_id={} audience={} method={} epoch={} error={}",
399 self_pid,
400 caller,
401 proof.payload.subject,
402 proof.payload.role,
403 proof.key_id,
404 proof.payload.audience,
405 proof.payload.audience_method,
406 proof.payload.epoch,
407 err
408 );
409 Err(Self::map_internal_invocation_verify_error(err))
410 }
411 }
412 }
413
414 fn delegated_token_max_ttl_secs() -> Result<u64, Error> {
416 let cfg = ConfigOps::delegated_tokens_config().map_err(Error::from)?;
417 if !cfg.enabled {
418 return Err(Error::forbidden(Self::DELEGATED_TOKENS_DISABLED));
419 }
420
421 Ok(cfg
422 .max_ttl_secs
423 .unwrap_or(Self::MAX_DELEGATED_SESSION_TTL_SECS))
424 }
425
426 fn validate_delegation_request_caller(
427 caller: Principal,
428 shard_pid: Principal,
429 ) -> Result<(), Error> {
430 if caller == shard_pid {
431 return Ok(());
432 }
433
434 Err(Error::forbidden(format!(
435 "delegation request caller {caller} must match shard_pid {shard_pid}"
436 )))
437 }
438
439 fn delegation_replay_metadata(
440 metadata: Option<RootRequestMetadata>,
441 ) -> Result<RootRequestMetadata, Error> {
442 let metadata = metadata
443 .ok_or_else(|| Error::invalid("delegation proof request requires replay metadata"))?;
444 if metadata.ttl_seconds == 0 {
445 return Err(Error::invalid(
446 "delegation proof replay metadata ttl_seconds must be greater than zero",
447 ));
448 }
449 if metadata.ttl_seconds > Self::MAX_DELEGATION_REPLAY_TTL_SECONDS {
450 return Err(Error::invalid(format!(
451 "delegation proof replay metadata ttl_seconds={} exceeds max {}",
452 metadata.ttl_seconds,
453 Self::MAX_DELEGATION_REPLAY_TTL_SECONDS
454 )));
455 }
456 Ok(metadata)
457 }
458
459 fn delegation_replay_command_kind() -> CommandKind {
460 CommandKind::new(Self::DELEGATION_REPLAY_COMMAND_KIND)
461 .expect("delegation replay command kind is a valid static label")
462 }
463
464 fn delegation_replay_payload_hash(
465 command_kind: &CommandKind,
466 actor: &ReplayActor,
467 request: &DelegationProofIssueRequest,
468 ) -> [u8; 32] {
469 let mut hasher = ReplayPayloadHasher::new(command_kind, actor);
470 hasher.hash_principal(&request.shard_pid);
471 hasher.hash_u64(request.scopes.len() as u64);
472 for scope in &request.scopes {
473 hasher.hash_str(scope);
474 }
475 Self::hash_delegation_audience(&mut hasher, &request.aud);
476 hasher.hash_u64(request.cert_ttl_secs);
477 hasher.finish()
478 }
479
480 fn hash_delegation_audience(hasher: &mut ReplayPayloadHasher, aud: &DelegationAudience) {
481 match aud {
482 DelegationAudience::Role(role) => {
483 hasher.hash_str("role");
484 hasher.hash_role(role);
485 }
486 DelegationAudience::Principal(principal) => {
487 hasher.hash_str("principal");
488 hasher.hash_principal(principal);
489 }
490 }
491 }
492
493 fn map_delegation_replay_decision(
494 decision: ReplayReceiptDecision,
495 ) -> Result<DelegationProof, Error> {
496 match decision {
497 ReplayReceiptDecision::Fresh(_) => {
498 Err(Error::invariant("fresh delegation replay decision escaped"))
499 }
500 ReplayReceiptDecision::ReturnCommitted(receipt) => {
501 Self::decode_delegation_proof_response(&receipt)
502 }
503 ReplayReceiptDecision::OperationInProgress => Err(Error::conflict(
504 "delegation proof request is already in progress; retry later with the same request id",
505 )),
506 ReplayReceiptDecision::ActorMismatch => Err(Error::conflict(
507 "delegation proof request id was reused by a different caller",
508 )),
509 ReplayReceiptDecision::PayloadMismatch => Err(Error::conflict(
510 "delegation proof request id was reused with a different payload",
511 )),
512 ReplayReceiptDecision::Expired => Err(Error::conflict(
513 "delegation proof replay receipt expired; retry with a new request id",
514 )),
515 ReplayReceiptDecision::RecoveryRequired(reason) => Err(Error::conflict(format!(
516 "delegation proof request requires recovery before replay: {reason:?}"
517 ))),
518 ReplayReceiptDecision::TerminalFailed {
519 error_code,
520 error_bytes,
521 error_bytes_truncated,
522 } => Err(Error::conflict(format!(
523 "delegation proof request previously failed: {error_code:?}; error_bytes_len={}; truncated={error_bytes_truncated}",
524 error_bytes.len()
525 ))),
526 }
527 }
528
529 fn map_delegation_replay_store_error(err: ReplayReceiptStoreError) -> Error {
530 match err {
531 ReplayReceiptStoreError::ReceiptDecodeFailed(message) => Error::internal(format!(
532 "failed to decode delegation replay receipt: {message}"
533 )),
534 }
535 }
536
537 fn encode_delegation_proof_response(proof: &DelegationProof) -> Result<Vec<u8>, Error> {
538 encode_one(proof).map_err(|err| {
539 Error::internal(format!(
540 "failed to encode delegation proof replay response: {err}"
541 ))
542 })
543 }
544
545 fn decode_delegation_proof_response(
546 receipt: &crate::ops::replay::model::ReplayReceipt,
547 ) -> Result<DelegationProof, Error> {
548 let response_schema_version = receipt.response_schema_version.ok_or_else(|| {
549 Error::internal("delegation replay receipt is missing response schema version")
550 })?;
551 if response_schema_version != Self::DELEGATION_REPLAY_RESPONSE_SCHEMA_VERSION {
552 return Err(Error::internal(format!(
553 "unsupported delegation replay response schema version {response_schema_version}"
554 )));
555 }
556 let response_bytes = receipt.response_bytes.as_deref().ok_or_else(|| {
557 Error::internal("delegation replay receipt is missing response bytes")
558 })?;
559 decode_one(response_bytes).map_err(|err| {
560 Error::internal(format!(
561 "failed to decode delegation proof replay response: {err}"
562 ))
563 })
564 }
565
566 fn hash_delegation_effect_key(key_name: &str) -> [u8; 32] {
567 let mut hasher = Sha256::new();
568 hasher.update(b"canic-delegation-proof-effect-key:v1");
569 hasher.update(key_name.as_bytes());
570 hasher.finalize().into()
571 }
572}
573
574impl AuthApi {
575 async fn request_delegation_remote(
577 request: DelegationProofIssueRequest,
578 ) -> Result<DelegationProof, Error> {
579 let root_pid = EnvOps::root_pid().map_err(Error::from)?;
580 RootAuthMaterialClient::new(root_pid)
581 .request_delegation(request)
582 .await
583 .map_err(Self::map_auth_error)
584 }
585
586 pub async fn request_role_attestation_root(
588 request: RoleAttestationRequest,
589 ) -> Result<SignedRoleAttestation, Error> {
590 let request = metadata::with_root_attestation_request_metadata(request);
591 let response = RootResponseWorkflow::response(RootRequest::issue_role_attestation(request))
592 .await
593 .map_err(Self::map_auth_error)?;
594
595 match response {
596 RootCapabilityResponse::RoleAttestationIssued(response) => Ok(response),
597 _ => Err(Error::internal(
598 "invalid root response type for role attestation request",
599 )),
600 }
601 }
602
603 pub async fn request_internal_invocation_proof_root(
605 request: InternalInvocationProofRequest,
606 ) -> Result<SignedInternalInvocationProofV1, Error> {
607 let request = metadata::with_internal_invocation_proof_request_metadata(request);
608 let response =
609 RootResponseWorkflow::response(RootRequest::issue_internal_invocation_proof(request))
610 .await
611 .map_err(Self::map_auth_error)?;
612
613 match response {
614 RootCapabilityResponse::InternalInvocationProofIssued(response) => Ok(response),
615 _ => Err(Error::internal(
616 "invalid root response type for internal invocation proof request",
617 )),
618 }
619 }
620
621 async fn request_role_attestation_remote(
623 request: RoleAttestationRequest,
624 ) -> Result<SignedRoleAttestation, Error> {
625 let root_pid = EnvOps::root_pid().map_err(Error::from)?;
626 RootAuthMaterialClient::new(root_pid)
627 .request_role_attestation(request)
628 .await
629 .map_err(Self::map_auth_error)
630 }
631
632 async fn request_internal_invocation_proof_remote(
634 request: InternalInvocationProofRequest,
635 ) -> Result<SignedInternalInvocationProofV1, Error> {
636 let root_pid = EnvOps::root_pid().map_err(Error::from)?;
637 RootAuthMaterialClient::new(root_pid)
638 .request_internal_invocation_proof(request)
639 .await
640 .map_err(Self::map_auth_error)
641 }
642}
643
644#[cfg(test)]
645mod tests {
646 use super::AuthApi;
647 use crate::{
648 cdk::types::Principal,
649 dto::{
650 auth::{DelegationAudience, DelegationProofIssueRequest},
651 error::ErrorCode,
652 rpc::RootRequestMetadata,
653 },
654 ops::auth::{AuthExpiryError, AuthOpsError},
655 };
656
657 fn p(id: u8) -> Principal {
658 Principal::from_slice(&[id; 29])
659 }
660
661 fn delegation_request(metadata_id: u8) -> DelegationProofIssueRequest {
662 DelegationProofIssueRequest {
663 metadata: Some(RootRequestMetadata {
664 request_id: [metadata_id; 32],
665 ttl_seconds: 60,
666 }),
667 shard_pid: p(2),
668 scopes: vec!["canic.verify".to_string()],
669 aud: DelegationAudience::Principal(p(3)),
670 cert_ttl_secs: 60,
671 }
672 }
673
674 #[test]
675 fn internal_invocation_not_yet_valid_maps_to_non_retryable_proof_expiry() {
676 let err = AuthApi::map_internal_invocation_verify_error(AuthOpsError::Expiry(
677 AuthExpiryError::AttestationNotYetValid {
678 issued_at: 20,
679 now_secs: 10,
680 },
681 ));
682
683 assert_eq!(err.code, ErrorCode::AuthProofExpired);
684 }
685
686 #[test]
687 fn delegation_request_caller_must_match_requested_shard() {
688 AuthApi::validate_delegation_request_caller(p(2), p(2)).expect("matching shard");
689
690 let err = AuthApi::validate_delegation_request_caller(p(1), p(2))
691 .expect_err("mismatched caller must fail");
692
693 assert_eq!(err.code, ErrorCode::Forbidden);
694 assert!(err.message.contains("must match shard_pid"));
695 }
696
697 #[test]
698 fn delegation_replay_metadata_rejects_missing_or_invalid_ttl() {
699 let missing = AuthApi::delegation_replay_metadata(None).expect_err("metadata is required");
700 assert_eq!(missing.code, ErrorCode::InvalidInput);
701
702 let zero = AuthApi::delegation_replay_metadata(Some(RootRequestMetadata {
703 request_id: [1; 32],
704 ttl_seconds: 0,
705 }))
706 .expect_err("zero ttl is invalid");
707 assert_eq!(zero.code, ErrorCode::InvalidInput);
708
709 let too_large = AuthApi::delegation_replay_metadata(Some(RootRequestMetadata {
710 request_id: [1; 32],
711 ttl_seconds: AuthApi::MAX_DELEGATION_REPLAY_TTL_SECONDS + 1,
712 }))
713 .expect_err("oversized ttl is invalid");
714 assert_eq!(too_large.code, ErrorCode::InvalidInput);
715 }
716
717 #[test]
718 fn delegation_replay_payload_hash_ignores_metadata() {
719 let command_kind = AuthApi::delegation_replay_command_kind();
720 let actor = crate::ops::replay::model::ReplayActor::direct_caller(p(2));
721 let a = delegation_request(1);
722 let b = delegation_request(9);
723
724 assert_eq!(
725 AuthApi::delegation_replay_payload_hash(&command_kind, &actor, &a),
726 AuthApi::delegation_replay_payload_hash(&command_kind, &actor, &b)
727 );
728 }
729
730 #[test]
731 fn delegation_replay_payload_hash_binds_authoritative_payload() {
732 let command_kind = AuthApi::delegation_replay_command_kind();
733 let actor = crate::ops::replay::model::ReplayActor::direct_caller(p(2));
734 let a = delegation_request(1);
735 let mut b = a.clone();
736 b.cert_ttl_secs += 1;
737
738 assert_ne!(
739 AuthApi::delegation_replay_payload_hash(&command_kind, &actor, &a),
740 AuthApi::delegation_replay_payload_hash(&command_kind, &actor, &b)
741 );
742 }
743}