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