Skip to main content

canic_core/api/
rpc.rs

1use crate::{
2    cdk::{candid::CandidType, types::Principal},
3    dto::{
4        auth::SignedRoleAttestation,
5        capability::{
6            CAPABILITY_VERSION_V1, CapabilityProof, CapabilityRequestMetadata, CapabilityService,
7            DelegatedGrant, DelegatedGrantProof, PROOF_VERSION_V1, RootCapabilityEnvelopeV1,
8            RootCapabilityResponseV1,
9        },
10        error::Error,
11        rpc::{
12            CreateCanisterParent, CreateCanisterResponse, Request, Response, RootRequestMetadata,
13            UpgradeCanisterResponse,
14        },
15    },
16    ids::CanisterRole,
17    log,
18    log::Topic,
19    ops::{
20        ic::{IcOps, ecdsa::EcdsaOps},
21        runtime::metrics::root_capability::{
22            RootCapabilityMetricEvent, RootCapabilityMetricKey, RootCapabilityMetrics,
23        },
24        storage::{auth::DelegationStateOps, registry::subnet::SubnetRegistryOps},
25    },
26    workflow::rpc::request::{RpcRequestWorkflow, handler::RootResponseWorkflow},
27};
28use candid::encode_one;
29use sha2::{Digest, Sha256};
30
31const CAPABILITY_HASH_DOMAIN_V1: &[u8] = b"CANIC_CAPABILITY_V1";
32const DELEGATED_GRANT_SIGNING_DOMAIN_V1: &[u8] = b"CANIC_DELEGATED_GRANT_V1";
33const REPLAY_REQUEST_ID_DOMAIN_V1: &[u8] = b"CANIC_REPLAY_REQUEST_ID_V1";
34const MAX_CAPABILITY_CLOCK_SKEW_SECONDS: u64 = 30;
35const DELEGATED_GRANT_KEY_ID_V1: u32 = 1;
36
37///
38/// RpcApi
39///
40/// Public, user-callable wrappers for Canic’s internal RPC workflows.
41///
42/// These functions:
43/// - form part of the **public API surface**
44/// - are safe to call from downstream canister `lib.rs` code
45/// - return [`Error`] suitable for IC boundaries
46///
47/// Internally, they delegate to workflow-level RPC implementations,
48/// preserving the layering:
49///
50///   user canister -> api -> workflow -> ops -> infra
51///
52/// Workflow returns internal [`InternalError`]; conversion to [`Error`]
53/// happens exclusively at this API boundary.
54///
55
56pub struct RpcApi;
57
58impl RpcApi {
59    pub async fn create_canister_request<A>(
60        canister_role: &CanisterRole,
61        parent: CreateCanisterParent,
62        extra: Option<A>,
63    ) -> Result<CreateCanisterResponse, Error>
64    where
65        A: CandidType + Send + Sync,
66    {
67        RpcRequestWorkflow::create_canister_request(canister_role, parent, extra)
68            .await
69            .map_err(Error::from)
70    }
71
72    pub async fn upgrade_canister_request(
73        canister_pid: Principal,
74    ) -> Result<UpgradeCanisterResponse, Error> {
75        RpcRequestWorkflow::upgrade_canister_request(canister_pid)
76            .await
77            .map_err(Error::from)
78    }
79
80    pub async fn response_attested(
81        request: Request,
82        attestation: SignedRoleAttestation,
83        min_accepted_epoch: u64,
84    ) -> Result<Response, Error> {
85        crate::api::auth::DelegationApi::verify_role_attestation(&attestation, min_accepted_epoch)
86            .await?;
87        RootResponseWorkflow::response(request)
88            .await
89            .map_err(Error::from)
90    }
91
92    pub async fn response_capability_v1(
93        envelope: RootCapabilityEnvelopeV1,
94    ) -> Result<RootCapabilityResponseV1, Error> {
95        let RootCapabilityEnvelopeV1 {
96            service,
97            capability_version,
98            capability,
99            proof,
100            metadata,
101        } = envelope;
102
103        let capability_key = root_capability_metric_key(&capability);
104        if let Err(err) = validate_root_capability_envelope(service, capability_version, &proof) {
105            RootCapabilityMetrics::record(
106                capability_key,
107                RootCapabilityMetricEvent::EnvelopeRejected,
108            );
109            log!(
110                Topic::Rpc,
111                Warn,
112                "root capability envelope rejected (capability={}, caller={}, service={:?}, capability_version={}, proof_mode={}): {}",
113                root_capability_family(&capability),
114                IcOps::msg_caller(),
115                service,
116                capability_version,
117                capability_proof_mode_label(&proof),
118                err
119            );
120            return Err(err);
121        }
122        RootCapabilityMetrics::record(capability_key, RootCapabilityMetricEvent::EnvelopeValidated);
123
124        if let Err(err) =
125            verify_root_capability_proof(&capability, capability_version, &proof).await
126        {
127            RootCapabilityMetrics::record(capability_key, RootCapabilityMetricEvent::ProofRejected);
128            log!(
129                Topic::Rpc,
130                Warn,
131                "root capability proof rejected (capability={}, caller={}, service={:?}, capability_version={}, proof_mode={}): {}",
132                root_capability_family(&capability),
133                IcOps::msg_caller(),
134                service,
135                capability_version,
136                capability_proof_mode_label(&proof),
137                err
138            );
139            return Err(err);
140        }
141        RootCapabilityMetrics::record(capability_key, RootCapabilityMetricEvent::ProofVerified);
142
143        let replay_metadata = project_replay_metadata(metadata, IcOps::now_secs())?;
144
145        let capability = with_root_request_metadata(capability, replay_metadata);
146
147        let response = RootResponseWorkflow::response_replay_first(capability)
148            .await
149            .map_err(Error::from)?;
150
151        Ok(RootCapabilityResponseV1 { response })
152    }
153}
154
155fn validate_root_capability_envelope(
156    service: CapabilityService,
157    capability_version: u16,
158    proof: &CapabilityProof,
159) -> Result<(), Error> {
160    if service != CapabilityService::Root {
161        return Err(Error::invalid(
162            "capability envelope service must be Root for root dispatch",
163        ));
164    }
165
166    if capability_version != CAPABILITY_VERSION_V1 {
167        return Err(Error::invalid(format!(
168            "unsupported capability_version: {capability_version}",
169        )));
170    }
171
172    match proof {
173        CapabilityProof::Structural => Ok(()),
174        CapabilityProof::RoleAttestation(proof) => {
175            if proof.proof_version != PROOF_VERSION_V1 {
176                return Err(Error::invalid(format!(
177                    "unsupported role attestation proof_version: {}",
178                    proof.proof_version
179                )));
180            }
181            Ok(())
182        }
183        CapabilityProof::DelegatedGrant(proof) => {
184            if proof.proof_version != PROOF_VERSION_V1 {
185                return Err(Error::invalid(format!(
186                    "unsupported delegated grant proof_version: {}",
187                    proof.proof_version
188                )));
189            }
190            Ok(())
191        }
192    }
193}
194
195async fn verify_root_capability_proof(
196    capability: &Request,
197    capability_version: u16,
198    proof: &CapabilityProof,
199) -> Result<(), Error> {
200    let target_canister = IcOps::canister_self();
201
202    match proof {
203        CapabilityProof::Structural => verify_root_structural_proof(capability),
204        CapabilityProof::RoleAttestation(proof) => {
205            verify_capability_hash_binding(
206                target_canister,
207                capability_version,
208                capability,
209                proof.capability_hash,
210            )?;
211
212            crate::api::auth::DelegationApi::verify_role_attestation(&proof.attestation, 0).await
213        }
214        CapabilityProof::DelegatedGrant(proof) => {
215            verify_capability_hash_binding(
216                target_canister,
217                capability_version,
218                capability,
219                proof.capability_hash,
220            )?;
221            verify_delegated_grant_hash_binding(proof)?;
222            verify_root_delegated_grant_proof(
223                capability,
224                proof,
225                IcOps::msg_caller(),
226                target_canister,
227                IcOps::now_secs(),
228            )
229        }
230    }
231}
232
233fn verify_root_structural_proof(capability: &Request) -> Result<(), Error> {
234    let caller = IcOps::msg_caller();
235
236    if SubnetRegistryOps::get(caller).is_none() {
237        return Err(Error::forbidden(
238            "structural proof requires caller to be registered in subnet registry",
239        ));
240    }
241
242    match capability {
243        Request::Cycles(_) => Ok(()),
244        Request::UpgradeCanister(req) => {
245            let target = SubnetRegistryOps::get(req.canister_pid).ok_or_else(|| {
246                Error::forbidden("structural proof requires registered upgrade target")
247            })?;
248            if target.parent_pid != Some(caller) {
249                return Err(Error::forbidden(
250                    "structural proof requires upgrade target to be a direct child of caller",
251                ));
252            }
253            Ok(())
254        }
255        _ => Err(Error::forbidden(
256            "structural proof is only supported for root cycles and upgrade capabilities",
257        )),
258    }
259}
260
261fn verify_capability_hash_binding(
262    target_canister: Principal,
263    capability_version: u16,
264    capability: &Request,
265    capability_hash: [u8; 32],
266) -> Result<(), Error> {
267    let expected = root_capability_hash(target_canister, capability_version, capability)?;
268    if capability_hash != expected {
269        return Err(Error::invalid(
270            "capability_hash does not match capability payload",
271        ));
272    }
273
274    Ok(())
275}
276
277const fn root_capability_metric_key(capability: &Request) -> RootCapabilityMetricKey {
278    match capability {
279        Request::CreateCanister(_) => RootCapabilityMetricKey::Provision,
280        Request::UpgradeCanister(_) => RootCapabilityMetricKey::Upgrade,
281        Request::Cycles(_) => RootCapabilityMetricKey::MintCycles,
282        Request::IssueDelegation(_) => RootCapabilityMetricKey::IssueDelegation,
283        Request::IssueRoleAttestation(_) => RootCapabilityMetricKey::IssueRoleAttestation,
284    }
285}
286
287const fn capability_proof_mode_label(proof: &CapabilityProof) -> &'static str {
288    match proof {
289        CapabilityProof::Structural => "Structural",
290        CapabilityProof::RoleAttestation(_) => "RoleAttestation",
291        CapabilityProof::DelegatedGrant(_) => "DelegatedGrant",
292    }
293}
294
295fn verify_delegated_grant_hash_binding(proof: &DelegatedGrantProof) -> Result<(), Error> {
296    if proof.grant.capability_hash != proof.capability_hash {
297        return Err(Error::invalid(
298            "delegated grant capability_hash does not match proof capability_hash",
299        ));
300    }
301
302    Ok(())
303}
304
305fn verify_root_delegated_grant_proof(
306    capability: &Request,
307    proof: &DelegatedGrantProof,
308    caller: Principal,
309    target_canister: Principal,
310    now_secs: u64,
311) -> Result<(), Error> {
312    verify_root_delegated_grant_claims(capability, proof, caller, target_canister, now_secs)?;
313    verify_root_delegated_grant_signature(&proof.grant, &proof.grant_sig)
314}
315
316fn verify_root_delegated_grant_claims(
317    capability: &Request,
318    proof: &DelegatedGrantProof,
319    caller: Principal,
320    target_canister: Principal,
321    now_secs: u64,
322) -> Result<(), Error> {
323    if proof.key_id != DELEGATED_GRANT_KEY_ID_V1 {
324        return Err(Error::invalid(format!(
325            "unsupported delegated grant key_id: {}",
326            proof.key_id
327        )));
328    }
329
330    let grant = &proof.grant;
331    if grant.issuer != target_canister {
332        return Err(Error::forbidden(
333            "delegated grant issuer must match target canister",
334        ));
335    }
336    if grant.subject != caller {
337        return Err(Error::forbidden(
338            "delegated grant subject must match caller",
339        ));
340    }
341    if !grant.audience.contains(&target_canister) {
342        return Err(Error::forbidden(
343            "delegated grant audience must include target canister",
344        ));
345    }
346    if grant.scope.service != CapabilityService::Root {
347        return Err(Error::forbidden(
348            "delegated grant scope service must be Root",
349        ));
350    }
351    let expected_family = root_capability_family(capability);
352    if grant.scope.capability_family != expected_family {
353        return Err(Error::forbidden(format!(
354            "delegated grant scope capability_family '{}' does not match '{}'",
355            grant.scope.capability_family, expected_family
356        )));
357    }
358    if grant.quota == 0 {
359        return Err(Error::invalid(
360            "delegated grant quota must be greater than zero",
361        ));
362    }
363    if grant.expires_at <= grant.issued_at {
364        return Err(Error::invalid(
365            "delegated grant expires_at must be greater than issued_at",
366        ));
367    }
368    if now_secs < grant.issued_at {
369        return Err(Error::forbidden(
370            "delegated grant is not valid yet for current time",
371        ));
372    }
373    if now_secs > grant.expires_at {
374        return Err(Error::forbidden("delegated grant has expired"));
375    }
376
377    Ok(())
378}
379
380fn verify_root_delegated_grant_signature(
381    grant: &DelegatedGrant,
382    signature: &[u8],
383) -> Result<(), Error> {
384    if signature.is_empty() {
385        return Err(Error::forbidden("delegated grant signature is required"));
386    }
387
388    let root_public_key = DelegationStateOps::root_public_key()
389        .ok_or_else(|| Error::forbidden("delegated grant root public key unavailable"))?;
390    let grant_hash = delegated_grant_hash(grant)?;
391    EcdsaOps::verify_signature(&root_public_key, grant_hash, signature)
392        .map_err(|err| Error::forbidden(format!("delegated grant signature invalid: {err}")))?;
393
394    Ok(())
395}
396
397const fn root_capability_family(capability: &Request) -> &'static str {
398    match capability {
399        Request::CreateCanister(_) => "Provision",
400        Request::UpgradeCanister(_) => "Upgrade",
401        Request::Cycles(_) => "MintCycles",
402        Request::IssueDelegation(_) => "IssueDelegation",
403        Request::IssueRoleAttestation(_) => "IssueRoleAttestation",
404    }
405}
406
407fn delegated_grant_hash(grant: &DelegatedGrant) -> Result<[u8; 32], Error> {
408    let payload = encode_one(grant)
409        .map_err(|err| Error::internal(format!("failed to encode delegated grant: {err}")))?;
410    let mut hasher = Sha256::new();
411    hasher.update(DELEGATED_GRANT_SIGNING_DOMAIN_V1);
412    hasher.update(payload);
413    Ok(hasher.finalize().into())
414}
415
416fn root_capability_hash(
417    target_canister: Principal,
418    capability_version: u16,
419    capability: &Request,
420) -> Result<[u8; 32], Error> {
421    let canonical = strip_request_metadata(capability.clone());
422    let payload = encode_one(&(
423        target_canister,
424        CapabilityService::Root,
425        capability_version,
426        canonical,
427    ))
428    .map_err(|err| Error::internal(format!("failed to encode capability payload: {err}")))?;
429    let mut hasher = Sha256::new();
430    hasher.update(CAPABILITY_HASH_DOMAIN_V1);
431    hasher.update(payload);
432    Ok(hasher.finalize().into())
433}
434
435fn with_root_request_metadata(request: Request, metadata: RootRequestMetadata) -> Request {
436    match request {
437        Request::CreateCanister(mut req) => {
438            req.metadata = Some(metadata);
439            Request::CreateCanister(req)
440        }
441        Request::UpgradeCanister(mut req) => {
442            req.metadata = Some(metadata);
443            Request::UpgradeCanister(req)
444        }
445        Request::Cycles(mut req) => {
446            req.metadata = Some(metadata);
447            Request::Cycles(req)
448        }
449        Request::IssueDelegation(mut req) => {
450            req.metadata = Some(metadata);
451            Request::IssueDelegation(req)
452        }
453        Request::IssueRoleAttestation(mut req) => {
454            req.metadata = Some(metadata);
455            Request::IssueRoleAttestation(req)
456        }
457    }
458}
459
460fn strip_request_metadata(request: Request) -> Request {
461    match request {
462        Request::CreateCanister(mut req) => {
463            req.metadata = None;
464            Request::CreateCanister(req)
465        }
466        Request::UpgradeCanister(mut req) => {
467            req.metadata = None;
468            Request::UpgradeCanister(req)
469        }
470        Request::Cycles(mut req) => {
471            req.metadata = None;
472            Request::Cycles(req)
473        }
474        Request::IssueDelegation(mut req) => {
475            req.metadata = None;
476            Request::IssueDelegation(req)
477        }
478        Request::IssueRoleAttestation(mut req) => {
479            req.metadata = None;
480            Request::IssueRoleAttestation(req)
481        }
482    }
483}
484
485fn project_replay_metadata(
486    metadata: CapabilityRequestMetadata,
487    now_secs: u64,
488) -> Result<RootRequestMetadata, Error> {
489    if metadata.ttl_seconds == 0 {
490        return Err(Error::invalid(
491            "capability metadata ttl_seconds must be greater than zero",
492        ));
493    }
494
495    if metadata.issued_at > now_secs.saturating_add(MAX_CAPABILITY_CLOCK_SKEW_SECONDS) {
496        return Err(Error::invalid(
497            "capability metadata issued_at is too far in the future",
498        ));
499    }
500
501    let expires_at = metadata
502        .issued_at
503        .checked_add(u64::from(metadata.ttl_seconds))
504        .ok_or_else(|| Error::invalid("capability metadata expiry overflow"))?;
505    if now_secs > expires_at {
506        return Err(Error::conflict("capability metadata has expired"));
507    }
508
509    Ok(RootRequestMetadata {
510        request_id: replay_request_id(metadata.request_id, metadata.nonce),
511        ttl_seconds: u64::from(metadata.ttl_seconds),
512    })
513}
514
515fn replay_request_id(request_id: [u8; 16], nonce: [u8; 16]) -> [u8; 32] {
516    let mut hasher = Sha256::new();
517    hasher.update(REPLAY_REQUEST_ID_DOMAIN_V1);
518    hasher.update(request_id);
519    hasher.update(nonce);
520    hasher.finalize().into()
521}
522
523#[cfg(test)]
524mod tests {
525    use super::*;
526    use crate::dto::{
527        auth::{RoleAttestation, SignedRoleAttestation},
528        capability::DelegatedGrantScope,
529        rpc::{CyclesRequest, RootRequestMetadata},
530    };
531    use k256::ecdsa::{Signature, SigningKey, signature::hazmat::PrehashSigner};
532
533    fn p(id: u8) -> Principal {
534        Principal::from_slice(&[id; 29])
535    }
536
537    fn sample_request(cycles: u128) -> Request {
538        Request::Cycles(CyclesRequest {
539            cycles,
540            metadata: None,
541        })
542    }
543
544    fn sample_metadata(
545        request_id: u8,
546        nonce: u8,
547        issued_at: u64,
548        ttl_seconds: u32,
549    ) -> CapabilityRequestMetadata {
550        CapabilityRequestMetadata {
551            request_id: [request_id; 16],
552            nonce: [nonce; 16],
553            issued_at,
554            ttl_seconds,
555        }
556    }
557
558    #[test]
559    fn root_capability_hash_changes_with_payload() {
560        let hash_a =
561            root_capability_hash(p(1), CAPABILITY_VERSION_V1, &sample_request(10)).expect("hash a");
562        let hash_b =
563            root_capability_hash(p(1), CAPABILITY_VERSION_V1, &sample_request(11)).expect("hash b");
564        assert_ne!(hash_a, hash_b);
565    }
566
567    #[test]
568    fn root_capability_hash_binds_target_canister() {
569        let req = sample_request(10);
570        let hash_a = root_capability_hash(p(1), CAPABILITY_VERSION_V1, &req).expect("hash a");
571        let hash_b = root_capability_hash(p(2), CAPABILITY_VERSION_V1, &req).expect("hash b");
572        assert_ne!(hash_a, hash_b);
573    }
574
575    #[test]
576    fn root_capability_hash_binds_capability_version() {
577        let req = sample_request(10);
578        let hash_a = root_capability_hash(p(1), 1, &req).expect("hash a");
579        let hash_b = root_capability_hash(p(1), 2, &req).expect("hash b");
580        assert_ne!(hash_a, hash_b);
581    }
582
583    #[test]
584    fn root_capability_hash_ignores_request_metadata() {
585        let req_a = Request::Cycles(CyclesRequest {
586            cycles: 10,
587            metadata: Some(RootRequestMetadata {
588                request_id: [1u8; 32],
589                ttl_seconds: 60,
590            }),
591        });
592        let req_b = Request::Cycles(CyclesRequest {
593            cycles: 10,
594            metadata: Some(RootRequestMetadata {
595                request_id: [2u8; 32],
596                ttl_seconds: 120,
597            }),
598        });
599
600        let hash_a = root_capability_hash(p(1), CAPABILITY_VERSION_V1, &req_a).expect("hash a");
601        let hash_b = root_capability_hash(p(1), CAPABILITY_VERSION_V1, &req_b).expect("hash b");
602        assert_eq!(hash_a, hash_b);
603    }
604
605    #[test]
606    fn project_replay_metadata_rejects_expired_metadata() {
607        let err = project_replay_metadata(sample_metadata(1, 2, 900, 50), 1_000)
608            .expect_err("expired metadata must fail");
609        assert!(err.message.contains("expired"));
610    }
611
612    #[test]
613    fn project_replay_metadata_rejects_future_metadata_beyond_skew() {
614        let err = project_replay_metadata(sample_metadata(1, 2, 1_031, 60), 1_000)
615            .expect_err("future metadata must fail");
616        assert!(err.message.contains("future"));
617    }
618
619    #[test]
620    fn project_replay_metadata_binds_nonce_into_request_id() {
621        let a = project_replay_metadata(sample_metadata(3, 1, 1_000, 60), 1_000).expect("a");
622        let b = project_replay_metadata(sample_metadata(3, 2, 1_000, 60), 1_000).expect("b");
623        assert_ne!(a.request_id, b.request_id);
624    }
625
626    #[test]
627    fn with_root_request_metadata_overrides_existing_metadata() {
628        let request = Request::Cycles(CyclesRequest {
629            cycles: 10,
630            metadata: Some(RootRequestMetadata {
631                request_id: [7u8; 32],
632                ttl_seconds: 10,
633            }),
634        });
635        let metadata = RootRequestMetadata {
636            request_id: [9u8; 32],
637            ttl_seconds: 60,
638        };
639
640        let updated = with_root_request_metadata(request, metadata);
641        match updated {
642            Request::Cycles(req) => assert_eq!(req.metadata, Some(metadata)),
643            _ => panic!("expected cycles request"),
644        }
645    }
646
647    fn sample_signed_attestation() -> SignedRoleAttestation {
648        SignedRoleAttestation {
649            payload: RoleAttestation {
650                subject: p(1),
651                role: crate::ids::CanisterRole::ROOT,
652                subnet_id: None,
653                audience: Some(p(2)),
654                issued_at: 1_000,
655                expires_at: 2_000,
656                epoch: 1,
657            },
658            signature: vec![],
659            key_id: 1,
660        }
661    }
662
663    fn sample_delegated_grant_proof(
664        capability: &Request,
665        caller: Principal,
666        target_canister: Principal,
667        now_secs: u64,
668    ) -> DelegatedGrantProof {
669        let capability_hash =
670            root_capability_hash(target_canister, CAPABILITY_VERSION_V1, capability).expect("hash");
671        DelegatedGrantProof {
672            proof_version: PROOF_VERSION_V1,
673            capability_hash,
674            grant: DelegatedGrant {
675                issuer: target_canister,
676                subject: caller,
677                audience: vec![target_canister],
678                scope: DelegatedGrantScope {
679                    service: CapabilityService::Root,
680                    capability_family: root_capability_family(capability).to_string(),
681                },
682                capability_hash,
683                quota: 1,
684                issued_at: now_secs.saturating_sub(10),
685                expires_at: now_secs.saturating_add(10),
686                epoch: 0,
687            },
688            grant_sig: vec![1],
689            key_id: DELEGATED_GRANT_KEY_ID_V1,
690        }
691    }
692
693    fn sign_delegated_grant(seed: u8, grant: &DelegatedGrant) -> (Vec<u8>, Vec<u8>) {
694        let signing_key = SigningKey::from_bytes((&[seed; 32]).into()).expect("signing key");
695        let signature: Signature = signing_key
696            .sign_prehash(&delegated_grant_hash(grant).expect("hash"))
697            .expect("prehash signature");
698        let public_key = signing_key
699            .verifying_key()
700            .to_encoded_point(true)
701            .as_bytes()
702            .to_vec();
703        (public_key, signature.to_bytes().to_vec())
704    }
705
706    #[test]
707    fn validate_root_capability_envelope_rejects_service_mismatch() {
708        let err = validate_root_capability_envelope(
709            CapabilityService::Cycles,
710            CAPABILITY_VERSION_V1,
711            &CapabilityProof::Structural,
712        )
713        .expect_err("service mismatch must fail");
714        assert!(err.message.contains("service"));
715    }
716
717    #[test]
718    fn validate_root_capability_envelope_rejects_capability_version_mismatch() {
719        let err = validate_root_capability_envelope(
720            CapabilityService::Root,
721            CAPABILITY_VERSION_V1 + 1,
722            &CapabilityProof::Structural,
723        )
724        .expect_err("unsupported capability version must fail");
725        assert!(err.message.contains("capability_version"));
726    }
727
728    #[test]
729    fn validate_root_capability_envelope_rejects_role_attestation_proof_version_mismatch() {
730        let err = validate_root_capability_envelope(
731            CapabilityService::Root,
732            CAPABILITY_VERSION_V1,
733            &CapabilityProof::RoleAttestation(crate::dto::capability::RoleAttestationProof {
734                proof_version: PROOF_VERSION_V1 + 1,
735                capability_hash: [0u8; 32],
736                attestation: sample_signed_attestation(),
737            }),
738        )
739        .expect_err("unsupported role proof version must fail");
740        assert!(err.message.contains("proof_version"));
741    }
742
743    #[test]
744    fn verify_capability_hash_binding_rejects_mismatch() {
745        let err = verify_capability_hash_binding(
746            p(1),
747            CAPABILITY_VERSION_V1,
748            &sample_request(10),
749            [0u8; 32],
750        )
751        .expect_err("mismatched hash must fail");
752        assert!(err.message.contains("capability_hash"));
753    }
754
755    #[test]
756    fn verify_capability_hash_binding_accepts_match() {
757        let request = sample_request(10);
758        let hash = root_capability_hash(p(1), CAPABILITY_VERSION_V1, &request).expect("hash");
759        verify_capability_hash_binding(p(1), CAPABILITY_VERSION_V1, &request, hash)
760            .expect("matching hash must verify");
761    }
762
763    #[test]
764    fn verify_delegated_grant_hash_binding_rejects_mismatch() {
765        let proof = DelegatedGrantProof {
766            proof_version: PROOF_VERSION_V1,
767            capability_hash: [1u8; 32],
768            grant: crate::dto::capability::DelegatedGrant {
769                issuer: p(1),
770                subject: p(2),
771                audience: vec![p(3)],
772                scope: crate::dto::capability::DelegatedGrantScope {
773                    service: CapabilityService::Root,
774                    capability_family: "root".to_string(),
775                },
776                capability_hash: [2u8; 32],
777                quota: 1,
778                issued_at: 1,
779                expires_at: 2,
780                epoch: 0,
781            },
782            grant_sig: vec![],
783            key_id: 1,
784        };
785
786        let err = verify_delegated_grant_hash_binding(&proof)
787            .expect_err("mismatched delegated grant hash must fail");
788        assert!(err.message.contains("capability_hash"));
789    }
790
791    #[test]
792    fn delegated_grant_hash_changes_with_payload() {
793        let grant_a = DelegatedGrant {
794            issuer: p(1),
795            subject: p(2),
796            audience: vec![p(1)],
797            scope: DelegatedGrantScope {
798                service: CapabilityService::Root,
799                capability_family: "MintCycles".to_string(),
800            },
801            capability_hash: [1u8; 32],
802            quota: 1,
803            issued_at: 10,
804            expires_at: 20,
805            epoch: 0,
806        };
807        let mut grant_b = grant_a.clone();
808        grant_b.quota = 2;
809
810        let hash_a = delegated_grant_hash(&grant_a).expect("hash a");
811        let hash_b = delegated_grant_hash(&grant_b).expect("hash b");
812        assert_ne!(hash_a, hash_b);
813    }
814
815    #[test]
816    fn verify_root_delegated_grant_claims_accepts_matching_scope() {
817        let now_secs = 100;
818        let caller = p(2);
819        let target_canister = p(1);
820        let capability = sample_request(10);
821        let proof = sample_delegated_grant_proof(&capability, caller, target_canister, now_secs);
822
823        verify_root_delegated_grant_claims(&capability, &proof, caller, target_canister, now_secs)
824            .expect("matching delegated grant claims must verify");
825    }
826
827    #[test]
828    fn verify_root_delegated_grant_claims_rejects_subject_mismatch() {
829        let now_secs = 100;
830        let caller = p(2);
831        let target_canister = p(1);
832        let capability = sample_request(10);
833        let mut proof =
834            sample_delegated_grant_proof(&capability, caller, target_canister, now_secs);
835        proof.grant.subject = p(3);
836
837        let err = verify_root_delegated_grant_claims(
838            &capability,
839            &proof,
840            caller,
841            target_canister,
842            now_secs,
843        )
844        .expect_err("subject mismatch must fail");
845        assert!(err.message.contains("subject"));
846    }
847
848    #[test]
849    fn verify_root_delegated_grant_claims_rejects_scope_family_mismatch() {
850        let now_secs = 100;
851        let caller = p(2);
852        let target_canister = p(1);
853        let capability = sample_request(10);
854        let mut proof =
855            sample_delegated_grant_proof(&capability, caller, target_canister, now_secs);
856        proof.grant.scope.capability_family = "Upgrade".to_string();
857
858        let err = verify_root_delegated_grant_claims(
859            &capability,
860            &proof,
861            caller,
862            target_canister,
863            now_secs,
864        )
865        .expect_err("scope family mismatch must fail");
866        assert!(err.message.contains("capability_family"));
867    }
868
869    #[test]
870    fn verify_root_delegated_grant_claims_rejects_key_id_mismatch() {
871        let now_secs = 100;
872        let caller = p(2);
873        let target_canister = p(1);
874        let capability = sample_request(10);
875        let mut proof =
876            sample_delegated_grant_proof(&capability, caller, target_canister, now_secs);
877        proof.key_id = DELEGATED_GRANT_KEY_ID_V1 + 1;
878
879        let err = verify_root_delegated_grant_claims(
880            &capability,
881            &proof,
882            caller,
883            target_canister,
884            now_secs,
885        )
886        .expect_err("unsupported key_id must fail");
887        assert!(err.message.contains("key_id"));
888    }
889
890    #[test]
891    fn verify_root_delegated_grant_signature_accepts_valid_signature() {
892        let capability = sample_request(10);
893        let proof = sample_delegated_grant_proof(&capability, p(2), p(1), 100);
894        let (public_key, signature) = sign_delegated_grant(7, &proof.grant);
895        DelegationStateOps::set_root_public_key(public_key);
896
897        verify_root_delegated_grant_signature(&proof.grant, &signature)
898            .expect("valid delegated grant signature must verify");
899    }
900
901    #[test]
902    fn verify_root_delegated_grant_signature_rejects_invalid_signature() {
903        let capability = sample_request(10);
904        let proof = sample_delegated_grant_proof(&capability, p(2), p(1), 100);
905        let (public_key, _signature) = sign_delegated_grant(7, &proof.grant);
906        let (_, wrong_signature) = sign_delegated_grant(8, &proof.grant);
907        DelegationStateOps::set_root_public_key(public_key);
908
909        let err = verify_root_delegated_grant_signature(&proof.grant, &wrong_signature)
910            .expect_err("invalid signature must fail");
911        assert!(err.message.contains("signature invalid"));
912    }
913}