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