1use crate::{
2 cdk::types::Principal,
3 dto::{
4 auth::{
5 AttestationKeySet, DelegatedToken, DelegatedTokenIssueRequest,
6 DelegatedTokenMintRequest, DelegationProof, DelegationProofIssueRequest,
7 InternalInvocationProofRequest, RoleAttestationRequest,
8 SignedInternalInvocationProofV1, SignedRoleAttestation,
9 },
10 error::{Error, ErrorCode},
11 rpc::{Request as RootRequest, Response as RootCapabilityResponse},
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 runtime::env::EnvOps,
25 runtime::metrics::auth::record_attestation_refresh_failed,
26 },
27 workflow::rpc::request::handler::RootResponseWorkflow,
28};
29use root_client::RootAuthMaterialClient;
30
31mod metadata;
36mod root_client;
37mod session;
38mod verify_flow;
39
40pub struct AuthApi;
47
48impl AuthApi {
49 const DELEGATED_TOKENS_DISABLED: &str =
50 "delegated token auth disabled; set auth.delegated_tokens.enabled=true in canic.toml";
51 const MAX_DELEGATED_SESSION_TTL_SECS: u64 = 24 * 60 * 60;
52 const SESSION_BOOTSTRAP_TOKEN_FINGERPRINT_DOMAIN: &[u8] =
53 b"canic-session-bootstrap-token-fingerprint";
54
55 fn map_auth_error(err: crate::InternalError) -> Error {
57 match err.class() {
58 InternalErrorClass::Infra | InternalErrorClass::Ops | InternalErrorClass::Workflow => {
59 Error::internal(err.to_string())
60 }
61 _ => Error::from(err),
62 }
63 }
64
65 fn map_internal_invocation_verify_error(err: AuthOpsError) -> Error {
66 match err {
67 AuthOpsError::Validation(AuthValidationError::AttestationUnknownKeyId { .. }) => {
68 Error::new(ErrorCode::AuthKeyUnknown, err.to_string())
69 }
70 AuthOpsError::Expiry(AuthExpiryError::AttestationEpochRejected { .. }) => {
71 Error::new(ErrorCode::AuthMaterialStale, err.to_string())
72 }
73 AuthOpsError::Expiry(
74 AuthExpiryError::AttestationExpired { .. }
75 | AuthExpiryError::AttestationNotYetValid { .. },
76 ) => Error::new(ErrorCode::AuthProofExpired, err.to_string()),
77 _ => Error::unauthorized(err.to_string()),
78 }
79 }
80
81 fn verify_token_material(
86 token: &DelegatedToken,
87 max_cert_ttl_secs: u64,
88 max_token_ttl_secs: u64,
89 required_scopes: &[String],
90 now_secs: u64,
91 ) -> Result<Principal, Error> {
92 AuthOps::verify_token(VerifyDelegatedTokenRuntimeInput {
93 token,
94 max_cert_ttl_secs,
95 max_token_ttl_secs,
96 required_scopes,
97 now_secs,
98 })
99 .map(|verified| verified.subject)
100 .map_err(Self::map_auth_error)
101 }
102
103 pub async fn local_shard_public_key_sec1() -> Result<Vec<u8>, Error> {
105 AuthOps::local_shard_public_key_sec1(IcOps::canister_self())
106 .await
107 .map_err(Self::map_auth_error)
108 }
109
110 pub async fn issue_token(request: DelegatedTokenIssueRequest) -> Result<DelegatedToken, Error> {
112 AuthOps::sign_token(SignDelegatedTokenInput {
113 proof: request.proof,
114 subject: request.subject,
115 audience: request.aud,
116 scopes: request.scopes,
117 ttl_secs: request.ttl_secs,
118 nonce: request.nonce,
119 })
120 .await
121 .map_err(Self::map_auth_error)
122 }
123
124 pub async fn mint_token(request: DelegatedTokenMintRequest) -> Result<DelegatedToken, Error> {
126 let proof = Self::request_delegation(DelegationProofIssueRequest {
127 shard_pid: IcOps::canister_self(),
128 scopes: request.scopes.clone(),
129 aud: request.aud.clone(),
130 cert_ttl_secs: request.cert_ttl_secs,
131 })
132 .await?;
133
134 Self::issue_token(DelegatedTokenIssueRequest {
135 proof,
136 subject: request.subject,
137 aud: request.aud,
138 scopes: request.scopes,
139 ttl_secs: request.token_ttl_secs,
140 nonce: request.nonce,
141 })
142 .await
143 }
144
145 pub async fn request_delegation(
147 request: DelegationProofIssueRequest,
148 ) -> Result<DelegationProof, Error> {
149 Self::request_delegation_remote(request).await
150 }
151
152 pub async fn issue_delegation_proof(
154 request: DelegationProofIssueRequest,
155 ) -> Result<DelegationProof, Error> {
156 EnvOps::require_root().map_err(Error::from)?;
157 let max_cert_ttl_secs = Self::delegated_token_max_ttl_secs()?;
158 let max_token_ttl_secs = request.cert_ttl_secs.min(max_cert_ttl_secs);
159 AuthOps::sign_delegation_proof(SignDelegationProofInput {
160 audience: request.aud,
161 scopes: request.scopes,
162 shard_pid: request.shard_pid,
163 cert_ttl_secs: request.cert_ttl_secs,
164 max_token_ttl_secs,
165 max_cert_ttl_secs,
166 issued_at: IcOps::now_secs(),
167 })
168 .await
169 .map_err(Self::map_auth_error)
170 }
171
172 pub async fn request_role_attestation(
174 request: RoleAttestationRequest,
175 ) -> Result<SignedRoleAttestation, Error> {
176 let request = metadata::with_root_attestation_request_metadata(request);
177 Self::request_role_attestation_remote(request).await
178 }
179
180 pub async fn request_internal_invocation_proof(
182 request: InternalInvocationProofRequest,
183 ) -> Result<SignedInternalInvocationProofV1, Error> {
184 let request = metadata::with_internal_invocation_proof_request_metadata(request);
185 Self::request_internal_invocation_proof_remote(request).await
186 }
187
188 pub async fn attestation_key_set() -> Result<AttestationKeySet, Error> {
190 AuthOps::attestation_key_set()
191 .await
192 .map_err(Self::map_auth_error)
193 }
194
195 pub async fn publish_root_auth_material() -> Result<(), Error> {
197 EnvOps::require_root().map_err(Error::from)?;
198 AuthOps::publish_root_auth_material().await.map_err(|err| {
199 log!(
200 Topic::Auth,
201 Warn,
202 "root auth material publish failed: {err}"
203 );
204 Self::map_auth_error(err)
205 })
206 }
207
208 pub fn replace_attestation_key_set(key_set: AttestationKeySet) {
210 AuthOps::replace_attestation_key_set(key_set);
211 }
212
213 pub async fn verify_role_attestation(
215 attestation: &SignedRoleAttestation,
216 min_accepted_epoch: u64,
217 ) -> Result<(), Error> {
218 let configured_min_accepted_epoch = ConfigOps::role_attestation_config()
219 .map_err(Error::from)?
220 .min_accepted_epoch_by_role
221 .get(attestation.payload.role.as_str())
222 .copied();
223 let min_accepted_epoch = verify_flow::resolve_min_accepted_epoch(
224 min_accepted_epoch,
225 configured_min_accepted_epoch,
226 );
227
228 let caller = IcOps::msg_caller();
229 let self_pid = IcOps::canister_self();
230 let now_secs = IcOps::now_secs();
231 let verifier_subnet = Some(EnvOps::subnet_pid().map_err(Error::from)?);
232 let root_pid = EnvOps::root_pid().map_err(Error::from)?;
233
234 let verify = || {
235 AuthOps::verify_role_attestation_cached(
236 attestation,
237 caller,
238 self_pid,
239 verifier_subnet,
240 now_secs,
241 min_accepted_epoch,
242 )
243 .map(|_| ())
244 };
245 let refresh = || async {
246 let key_set = RootAuthMaterialClient::new(root_pid)
247 .attestation_key_set()
248 .await?;
249 AuthOps::replace_attestation_key_set(key_set);
250 Ok(())
251 };
252
253 match verify_flow::verify_role_attestation_with_single_refresh(verify, refresh).await {
254 Ok(()) => Ok(()),
255 Err(verify_flow::RoleAttestationVerifyFlowError::Initial(err)) => {
256 verify_flow::record_attestation_verifier_rejection(&err);
257 verify_flow::log_attestation_verifier_rejection(
258 &err,
259 attestation,
260 caller,
261 self_pid,
262 "cached",
263 );
264 Err(Self::map_auth_error(err.into()))
265 }
266 Err(verify_flow::RoleAttestationVerifyFlowError::Refresh { trigger, source }) => {
267 verify_flow::record_attestation_verifier_rejection(&trigger);
268 verify_flow::log_attestation_verifier_rejection(
269 &trigger,
270 attestation,
271 caller,
272 self_pid,
273 "cache_miss_refresh",
274 );
275 record_attestation_refresh_failed();
276 log!(
277 Topic::Auth,
278 Warn,
279 "role attestation refresh failed local={} caller={} key_id={} error={}",
280 self_pid,
281 caller,
282 attestation.key_id,
283 source
284 );
285 Err(Self::map_auth_error(source))
286 }
287 Err(verify_flow::RoleAttestationVerifyFlowError::PostRefresh(err)) => {
288 verify_flow::record_attestation_verifier_rejection(&err);
289 verify_flow::log_attestation_verifier_rejection(
290 &err,
291 attestation,
292 caller,
293 self_pid,
294 "post_refresh",
295 );
296 Err(Self::map_auth_error(err.into()))
297 }
298 }
299 }
300
301 pub async fn verify_internal_invocation_proof(
303 proof: &SignedInternalInvocationProofV1,
304 target_method: &str,
305 accepted_roles: &[CanisterRole],
306 ) -> Result<(), Error> {
307 let configured_min_accepted_epoch = ConfigOps::role_attestation_config()
308 .map_err(Error::from)?
309 .min_accepted_epoch_by_role
310 .get(proof.payload.role.as_str())
311 .copied();
312 let min_accepted_epoch =
313 verify_flow::resolve_min_accepted_epoch(0, configured_min_accepted_epoch);
314
315 let caller = IcOps::msg_caller();
316 let self_pid = IcOps::canister_self();
317 let now_secs = IcOps::now_secs();
318 let verifier_subnet = Some(EnvOps::subnet_pid().map_err(Error::from)?);
319 let root_pid = EnvOps::root_pid().map_err(Error::from)?;
320
321 let verify = || {
322 AuthOps::verify_internal_invocation_proof_cached(
323 proof,
324 crate::ops::auth::InternalInvocationProofVerificationInput {
325 caller,
326 self_pid,
327 target_method,
328 accepted_roles,
329 verifier_subnet,
330 now_secs,
331 min_accepted_epoch,
332 },
333 )
334 .map(|_| ())
335 };
336 let refresh = || async {
337 let key_set = RootAuthMaterialClient::new(root_pid)
338 .attestation_key_set()
339 .await?;
340 AuthOps::replace_attestation_key_set(key_set);
341 Ok(())
342 };
343
344 match verify_flow::verify_role_attestation_with_single_refresh(verify, refresh).await {
345 Ok(()) => Ok(()),
346 Err(verify_flow::RoleAttestationVerifyFlowError::Initial(err)) => {
347 verify_flow::record_attestation_verifier_rejection(&err);
348 log!(
349 Topic::Auth,
350 Warn,
351 "internal invocation proof rejected phase=cached local={} caller={} subject={} role={} key_id={} audience={} method={} epoch={} error={}",
352 self_pid,
353 caller,
354 proof.payload.subject,
355 proof.payload.role,
356 proof.key_id,
357 proof.payload.audience,
358 proof.payload.audience_method,
359 proof.payload.epoch,
360 err
361 );
362 Err(Self::map_internal_invocation_verify_error(err))
363 }
364 Err(verify_flow::RoleAttestationVerifyFlowError::Refresh { trigger, source }) => {
365 verify_flow::record_attestation_verifier_rejection(&trigger);
366 record_attestation_refresh_failed();
367 log!(
368 Topic::Auth,
369 Warn,
370 "internal invocation proof refresh failed local={} caller={} key_id={} error={}",
371 self_pid,
372 caller,
373 proof.key_id,
374 source
375 );
376 Err(Self::map_auth_error(source))
377 }
378 Err(verify_flow::RoleAttestationVerifyFlowError::PostRefresh(err)) => {
379 verify_flow::record_attestation_verifier_rejection(&err);
380 log!(
381 Topic::Auth,
382 Warn,
383 "internal invocation proof rejected phase=post_refresh local={} caller={} subject={} role={} key_id={} audience={} method={} epoch={} error={}",
384 self_pid,
385 caller,
386 proof.payload.subject,
387 proof.payload.role,
388 proof.key_id,
389 proof.payload.audience,
390 proof.payload.audience_method,
391 proof.payload.epoch,
392 err
393 );
394 Err(Self::map_internal_invocation_verify_error(err))
395 }
396 }
397 }
398
399 fn delegated_token_max_ttl_secs() -> Result<u64, Error> {
401 let cfg = ConfigOps::delegated_tokens_config().map_err(Error::from)?;
402 if !cfg.enabled {
403 return Err(Error::forbidden(Self::DELEGATED_TOKENS_DISABLED));
404 }
405
406 Ok(cfg
407 .max_ttl_secs
408 .unwrap_or(Self::MAX_DELEGATED_SESSION_TTL_SECS))
409 }
410}
411
412impl AuthApi {
413 async fn request_delegation_remote(
415 request: DelegationProofIssueRequest,
416 ) -> Result<DelegationProof, Error> {
417 let root_pid = EnvOps::root_pid().map_err(Error::from)?;
418 RootAuthMaterialClient::new(root_pid)
419 .request_delegation(request)
420 .await
421 .map_err(Self::map_auth_error)
422 }
423
424 pub async fn request_role_attestation_root(
426 request: RoleAttestationRequest,
427 ) -> Result<SignedRoleAttestation, Error> {
428 let request = metadata::with_root_attestation_request_metadata(request);
429 let response = RootResponseWorkflow::response(RootRequest::issue_role_attestation(request))
430 .await
431 .map_err(Self::map_auth_error)?;
432
433 match response {
434 RootCapabilityResponse::RoleAttestationIssued(response) => Ok(response),
435 _ => Err(Error::internal(
436 "invalid root response type for role attestation request",
437 )),
438 }
439 }
440
441 pub async fn request_internal_invocation_proof_root(
443 request: InternalInvocationProofRequest,
444 ) -> Result<SignedInternalInvocationProofV1, Error> {
445 let request = metadata::with_internal_invocation_proof_request_metadata(request);
446 let response =
447 RootResponseWorkflow::response(RootRequest::issue_internal_invocation_proof(request))
448 .await
449 .map_err(Self::map_auth_error)?;
450
451 match response {
452 RootCapabilityResponse::InternalInvocationProofIssued(response) => Ok(response),
453 _ => Err(Error::internal(
454 "invalid root response type for internal invocation proof request",
455 )),
456 }
457 }
458
459 async fn request_role_attestation_remote(
461 request: RoleAttestationRequest,
462 ) -> Result<SignedRoleAttestation, Error> {
463 let root_pid = EnvOps::root_pid().map_err(Error::from)?;
464 RootAuthMaterialClient::new(root_pid)
465 .request_role_attestation(request)
466 .await
467 .map_err(Self::map_auth_error)
468 }
469
470 async fn request_internal_invocation_proof_remote(
472 request: InternalInvocationProofRequest,
473 ) -> Result<SignedInternalInvocationProofV1, Error> {
474 let root_pid = EnvOps::root_pid().map_err(Error::from)?;
475 RootAuthMaterialClient::new(root_pid)
476 .request_internal_invocation_proof(request)
477 .await
478 .map_err(Self::map_auth_error)
479 }
480}
481
482#[cfg(test)]
483mod tests {
484 use super::AuthApi;
485 use crate::{
486 dto::error::ErrorCode,
487 ops::auth::{AuthExpiryError, AuthOpsError},
488 };
489
490 #[test]
491 fn internal_invocation_not_yet_valid_maps_to_non_retryable_proof_expiry() {
492 let err = AuthApi::map_internal_invocation_verify_error(AuthOpsError::Expiry(
493 AuthExpiryError::AttestationNotYetValid {
494 issued_at: 20,
495 now_secs: 10,
496 },
497 ));
498
499 assert_eq!(err.code, ErrorCode::AuthProofExpired);
500 }
501}