Skip to main content

ic_auth/
canonical.rs

1//! Canonical signed bytes for Root delegations, proofs and role identities.
2//! Domains and tags are protocol identities, not package branding.
3//! This module does not verify signatures, authority, validity or caller binding.
4
5use ic_auth_protocol_types::*;
6use sha2::{Digest, Sha256};
7use thiserror::Error;
8
9const DOMAIN_SEPARATOR: &[u8] = b"CANIC-AUTH\0";
10const ISSUER_PROOF_BINDING_HASH_DOMAIN: &[u8] = b"canic-issuer-proof-binding-v1";
11const CHAIN_KEY_BATCH_HEADER_DOMAIN: &[u8] = b"CANIC_ROOT_DELEGATION_CHAIN_KEY_BATCH_V1";
12const CHAIN_KEY_DELEGATION_CERT_DOMAIN: &[u8] = b"CANIC_ROOT_DELEGATION_CHAIN_KEY_ISSUER_LEAF_V1";
13const CHAIN_KEY_DERIVATION_PATH_DOMAIN: &[u8] =
14    b"CANIC_ROOT_DELEGATION_CHAIN_KEY_DERIVATION_PATH_V1";
15
16// Domain byte assigned to one delegated-auth canonical payload family.
17#[repr(u8)]
18#[derive(Clone, Copy, Debug, Eq, PartialEq)]
19enum CanonicalDomain {
20    DelegationCert = 1,
21    DelegationProof = 3,
22    RoleHash = 4,
23}
24
25///
26/// CanonicalAuthError
27///
28/// Typed failure surface for delegated auth canonicalization.
29///
30
31#[derive(Debug, Eq, Error, PartialEq)]
32pub enum CanonicalAuthError {
33    #[error("canonical vector length exceeds u32")]
34    LengthOverflow,
35    #[error("delegated auth scope is empty")]
36    EmptyScope,
37    #[error("delegated auth scope contains invalid characters: {scope}")]
38    InvalidScope { scope: String },
39    #[error("delegated auth scopes must be strictly sorted and unique")]
40    NonCanonicalScopes,
41    #[error("delegated auth role grants must be strictly sorted and unique")]
42    NonCanonicalRoles,
43}
44
45/// Hash the canonical delegation certificate; this does not authenticate it.
46pub fn cert_hash(cert: &DelegationCert) -> Result<[u8; 32], CanonicalAuthError> {
47    Ok(hash_bytes(&cert_bytes(cert)?))
48}
49
50/// Hash a certificate together with its complete root proof.
51pub fn proof_hash(proof: &DelegationProof) -> Result<[u8; 32], CanonicalAuthError> {
52    Ok(hash_bytes(&proof_bytes(proof)?))
53}
54
55/// Hash a chain-key batch header under its existing signed domain.
56pub fn chain_key_batch_header_hash(
57    header: &ChainKeyBatchHeaderV1,
58) -> Result<[u8; 32], CanonicalAuthError> {
59    let mut payload = Vec::with_capacity(256);
60    encode_chain_key_batch_header(&mut payload, header)?;
61    hash_chain_key_header_payload(&payload)
62}
63
64/// Hash an issuer leaf under the existing chain-key Merkle leaf domain.
65pub fn chain_key_delegation_cert_hash(
66    cert: &ChainKeyDelegationCertV1,
67) -> Result<[u8; 32], CanonicalAuthError> {
68    let mut payload = Vec::with_capacity(256);
69    encode_chain_key_delegation_cert(&mut payload, cert)?;
70    hash_chain_key_leaf_payload(&payload)
71}
72
73/// Hash the exact ordered derivation path under its existing Canic domain.
74/// This binds path bytes; it does not derive a key or establish its authority.
75pub fn chain_key_derivation_path_hash(
76    derivation_path: &[Vec<u8>],
77) -> Result<[u8; 32], CanonicalAuthError> {
78    let mut out = CHAIN_KEY_DERIVATION_PATH_DOMAIN.to_vec();
79    encode_chain_key_derivation_path(&mut out, derivation_path)?;
80    Ok(hash_bytes(&out))
81}
82
83/// Bind an issuer principal, proof algorithm and seed hash.
84pub fn issuer_proof_binding_hash(
85    issuer_pid: Principal,
86    issuer_proof_alg: IssuerProofAlgorithm,
87    issuer_proof_binding: IssuerProofBinding,
88) -> Result<[u8; 32], CanonicalAuthError> {
89    let mut out = Vec::with_capacity(128);
90    out.extend_from_slice(ISSUER_PROOF_BINDING_HASH_DOMAIN);
91    encode_principal(&mut out, issuer_pid)?;
92    encode_issuer_proof_algorithm(&mut out, issuer_proof_alg);
93    encode_issuer_proof_binding(&mut out, issuer_proof_binding);
94    Ok(hash_bytes(&out))
95}
96
97/// Hash the exact validated role label under the existing role domain.
98pub fn role_hash(role: &AuthRole) -> Result<[u8; 32], CanonicalAuthError> {
99    let mut out = domain_bytes(CanonicalDomain::RoleHash);
100    encode_string(&mut out, role.as_str())?;
101    Ok(hash_bytes(&out))
102}
103
104/// Encode a certificate without changing grant order or normalizing labels.
105pub fn cert_bytes(cert: &DelegationCert) -> Result<Vec<u8>, CanonicalAuthError> {
106    let mut out = Vec::with_capacity(128);
107    encode_cert(&mut out, cert)?;
108    Ok(out)
109}
110
111fn encode_cert(out: &mut Vec<u8>, cert: &DelegationCert) -> Result<(), CanonicalAuthError> {
112    out.extend_from_slice(DOMAIN_SEPARATOR);
113    out.push(CanonicalDomain::DelegationCert as u8);
114
115    encode_principal(out, cert.root_pid)?;
116    encode_principal(out, cert.issuer_pid)?;
117    encode_issuer_proof_algorithm(out, cert.issuer_proof_alg);
118    encode_fixed_32(out, cert.issuer_proof_binding_hash);
119    encode_issuer_proof_binding(out, cert.issuer_proof_binding);
120    encode_u64(out, cert.issued_at_ns);
121    encode_u64(out, cert.not_before_ns);
122    encode_u64(out, cert.expires_at_ns);
123    encode_u64(out, cert.max_token_ttl_ns);
124    encode_audience(out, &cert.aud);
125    encode_role_grants::<false>(out, &cert.grants)?;
126
127    Ok(())
128}
129
130fn proof_bytes(proof: &DelegationProof) -> Result<Vec<u8>, CanonicalAuthError> {
131    let mut out = domain_bytes(CanonicalDomain::DelegationProof);
132
133    encode_cert(&mut out, &proof.cert)?;
134    encode_root_proof(&mut out, &proof.root_proof)?;
135
136    Ok(out)
137}
138
139fn domain_bytes(domain: CanonicalDomain) -> Vec<u8> {
140    let mut out = Vec::with_capacity(128);
141    out.extend_from_slice(DOMAIN_SEPARATOR);
142    out.push(domain as u8);
143    out
144}
145
146fn hash_bytes(bytes: &[u8]) -> [u8; 32] {
147    Sha256::digest(bytes).into()
148}
149
150fn encode_issuer_proof_algorithm(out: &mut Vec<u8>, alg: IssuerProofAlgorithm) {
151    let tag = match alg {
152        IssuerProofAlgorithm::IcCanisterSignatureV1 => 1,
153    };
154    out.push(tag);
155}
156
157fn encode_audience(out: &mut Vec<u8>, audience: &DelegationAudience) {
158    match audience {
159        DelegationAudience::Fleet(fleet) => {
160            out.push(1);
161            encode_fleet_key(out, *fleet);
162        }
163    }
164}
165
166fn encode_fleet_key(out: &mut Vec<u8>, fleet: AudienceId) {
167    encode_fixed_32(out, *fleet.canonical_network_id.as_bytes());
168    encode_fixed_32(out, *fleet.fleet_id.as_bytes());
169}
170
171// ADMITTED is private same-call shape evidence: portable encoding validates the
172// immutable grants first. Standalone canonical encoders retain their own checks.
173pub(crate) fn encode_role_grants<const ADMITTED: bool>(
174    out: &mut Vec<u8>,
175    grants: &[DelegatedRoleGrant],
176) -> Result<(), CanonicalAuthError> {
177    encode_len(out, grants.len())?;
178    let mut previous = None;
179    for grant in grants {
180        let current = grant.target.as_str().as_bytes();
181        if !ADMITTED && previous.is_some_and(|previous| previous >= current) {
182            return Err(CanonicalAuthError::NonCanonicalRoles);
183        }
184        previous = Some(current);
185        encode_role(out, &grant.target)?;
186        if !ADMITTED {
187            validate_scopes(&grant.scopes)?;
188        }
189        encode_len(out, grant.scopes.len())?;
190        for scope in &grant.scopes {
191            encode_bytes(out, scope.as_bytes())?;
192        }
193    }
194    Ok(())
195}
196
197fn hash_chain_key_header_payload(payload: &[u8]) -> Result<[u8; 32], CanonicalAuthError> {
198    let len = u32::try_from(payload.len()).map_err(|_| CanonicalAuthError::LengthOverflow)?;
199    let mut hash = Sha256::new();
200    hash.update(CHAIN_KEY_BATCH_HEADER_DOMAIN);
201    hash.update(len.to_be_bytes());
202    hash.update(payload);
203    Ok(hash.finalize().into())
204}
205
206fn hash_chain_key_leaf_payload(payload: &[u8]) -> Result<[u8; 32], CanonicalAuthError> {
207    let len = u32::try_from(payload.len()).map_err(|_| CanonicalAuthError::LengthOverflow)?;
208    let mut hash = Sha256::new();
209    hash.update([0]);
210    hash.update(CHAIN_KEY_DELEGATION_CERT_DOMAIN);
211    hash.update(len.to_be_bytes());
212    hash.update(payload);
213    Ok(hash.finalize().into())
214}
215
216fn encode_chain_key_batch_header(
217    out: &mut Vec<u8>,
218    header: &ChainKeyBatchHeaderV1,
219) -> Result<(), CanonicalAuthError> {
220    encode_u16(out, header.schema_version);
221    encode_principal(out, header.root_canister_id)?;
222    encode_fixed_32(out, header.batch_id);
223    encode_u64(out, header.proof_epoch);
224    encode_u64(out, header.registry_epoch);
225    encode_fixed_32(out, header.registry_hash);
226    encode_fixed_32(out, header.tree_root);
227    encode_u64(out, header.not_before_ns);
228    encode_u64(out, header.expires_at_ns);
229    encode_chain_key_algorithm(out, header.algorithm);
230    encode_chain_key_key_id(out, &header.key_id)?;
231    encode_fixed_32(out, header.derivation_path_hash);
232    encode_u64(out, header.key_version);
233    Ok(())
234}
235
236fn encode_chain_key_delegation_cert(
237    out: &mut Vec<u8>,
238    cert: &ChainKeyDelegationCertV1,
239) -> Result<(), CanonicalAuthError> {
240    encode_principal(out, cert.root_canister_id)?;
241    encode_principal(out, cert.issuer_canister_id)?;
242    encode_u64(out, cert.proof_epoch);
243    encode_issuer_proof_algorithm(out, cert.issuer_proof_algorithm);
244    encode_fixed_32(out, cert.issuer_proof_binding_hash);
245    encode_issuer_proof_binding(out, cert.issuer_proof_binding);
246    encode_u64(out, cert.max_token_ttl_ns);
247    encode_audience(out, &cert.audience);
248    encode_role_grants::<false>(out, &cert.grants)?;
249    encode_u64(out, cert.not_before_ns);
250    encode_u64(out, cert.expires_at_ns);
251    encode_u64(out, cert.registry_epoch);
252    encode_fixed_32(out, cert.registry_hash);
253    Ok(())
254}
255
256fn encode_root_proof(out: &mut Vec<u8>, proof: &RootProof) -> Result<(), CanonicalAuthError> {
257    match proof {
258        RootProof::IcChainKeyBatchSignatureV1(proof) => {
259            out.push(2);
260            encode_chain_key_proof(out, proof)?;
261        }
262    }
263    Ok(())
264}
265
266fn encode_chain_key_proof(
267    out: &mut Vec<u8>,
268    proof: &IcChainKeyBatchSignatureProofV1,
269) -> Result<(), CanonicalAuthError> {
270    encode_chain_key_batch_header(out, &proof.header)?;
271    encode_chain_key_delegation_cert(out, &proof.delegation_cert)?;
272    encode_chain_key_witness(out, &proof.issuer_witness)?;
273    encode_chain_key_signature(out, &proof.signature)?;
274    Ok(())
275}
276
277fn encode_chain_key_witness(
278    out: &mut Vec<u8>,
279    witness: &ChainKeyBatchWitnessV1,
280) -> Result<(), CanonicalAuthError> {
281    encode_len(out, witness.steps.len())?;
282    for step in &witness.steps {
283        match step {
284            ChainKeyBatchWitnessStepV1::LeftSibling(hash) => {
285                out.push(1);
286                encode_fixed_32(out, *hash);
287            }
288            ChainKeyBatchWitnessStepV1::RightSibling(hash) => {
289                out.push(2);
290                encode_fixed_32(out, *hash);
291            }
292        }
293    }
294    Ok(())
295}
296
297fn encode_chain_key_signature(
298    out: &mut Vec<u8>,
299    signature: &ChainKeyRootSignatureV1,
300) -> Result<(), CanonicalAuthError> {
301    encode_chain_key_algorithm(out, signature.algorithm);
302    encode_chain_key_key_id(out, &signature.key_id)?;
303    encode_chain_key_derivation_path(out, &signature.derivation_path)?;
304    encode_bytes(out, &signature.public_key)?;
305    encode_bytes(out, &signature.signature)?;
306    Ok(())
307}
308
309fn encode_chain_key_derivation_path(
310    out: &mut Vec<u8>,
311    derivation_path: &[Vec<u8>],
312) -> Result<(), CanonicalAuthError> {
313    encode_len(out, derivation_path.len())?;
314    for path_component in derivation_path {
315        encode_bytes(out, path_component)?;
316    }
317    Ok(())
318}
319
320fn encode_chain_key_algorithm(out: &mut Vec<u8>, algorithm: ChainKeyAlgorithm) {
321    let tag = match algorithm {
322        ChainKeyAlgorithm::EcdsaSecp256k1 => 1,
323    };
324    out.push(tag);
325}
326
327fn encode_chain_key_key_id(
328    out: &mut Vec<u8>,
329    key_id: &ChainKeyKeyId,
330) -> Result<(), CanonicalAuthError> {
331    encode_string(out, &key_id.name)?;
332    Ok(())
333}
334
335fn encode_issuer_proof_binding(out: &mut Vec<u8>, binding: IssuerProofBinding) {
336    match binding {
337        IssuerProofBinding::IcCanisterSignatureV1 { seed_hash } => {
338            out.push(1);
339            encode_fixed_32(out, seed_hash);
340        }
341    }
342}
343
344fn encode_role(out: &mut Vec<u8>, role: &AuthRole) -> Result<(), CanonicalAuthError> {
345    encode_bytes(out, role.as_str().as_bytes())?;
346    Ok(())
347}
348
349/// Validate label grammar and strictly increasing, unique UTF-8 byte order.
350///
351/// An empty list is valid. Cardinality, normalization and application authority
352/// remain caller-owned; use the bounded grant validator for nonempty grants.
353pub fn validate_scopes(scopes: &[String]) -> Result<(), CanonicalAuthError> {
354    let mut previous = None;
355    for scope in scopes {
356        validate_scope_label(scope)?;
357        let current = scope.as_bytes();
358        if previous.is_some_and(|previous| previous >= current) {
359            return Err(CanonicalAuthError::NonCanonicalScopes);
360        }
361        previous = Some(current);
362    }
363
364    Ok(())
365}
366
367/// A scope label's grammar failure. Offsets and lengths count UTF-8 bytes.
368#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
369pub enum ScopeLabelError {
370    #[error("scope is empty")]
371    Empty,
372    #[error("scope has {bytes} bytes; maximum is {max}")]
373    TooLong { bytes: usize, max: usize },
374    #[error("scope contains an empty segment")]
375    EmptySegment,
376    #[error("invalid scope segment start at byte {offset}")]
377    InvalidSegmentStart { offset: usize },
378    #[error("invalid scope byte at offset {offset}")]
379    InvalidByte { offset: usize },
380}
381
382/// Check the 64-byte, lowercase ASCII/digit, colon-separated scope grammar.
383///
384/// Every segment starts with a lowercase letter or digit; subsequent bytes may
385/// also be `_` or `-`. Length is checked first, then bytes from left to right.
386/// This allocation-free check supports static construction and grants no authority.
387pub const fn validate_scope_label_detailed(scope: &str) -> Result<(), ScopeLabelError> {
388    let bytes = scope.as_bytes();
389    if bytes.is_empty() {
390        return Err(ScopeLabelError::Empty);
391    }
392    if bytes.len() > crate::limits::MAX_LABEL_BYTES {
393        return Err(ScopeLabelError::TooLong {
394            bytes: bytes.len(),
395            max: crate::limits::MAX_LABEL_BYTES,
396        });
397    }
398    let mut segment_start = true;
399    let mut offset = 0;
400    while offset < bytes.len() {
401        let byte = bytes[offset];
402        if byte == b':' {
403            if segment_start {
404                return Err(ScopeLabelError::EmptySegment);
405            }
406            segment_start = true;
407        } else if segment_start {
408            if !byte.is_ascii_lowercase() && !byte.is_ascii_digit() {
409                return Err(ScopeLabelError::InvalidSegmentStart { offset });
410            }
411            segment_start = false;
412        } else if !byte.is_ascii_lowercase()
413            && !byte.is_ascii_digit()
414            && byte != b'_'
415            && byte != b'-'
416        {
417            return Err(ScopeLabelError::InvalidByte { offset });
418        }
419        offset += 1;
420    }
421    if segment_start {
422        return Err(ScopeLabelError::EmptySegment);
423    }
424    Ok(())
425}
426
427/// Check the existing lowercase, colon-separated application-scope grammar.
428pub fn validate_scope_label(scope: &str) -> Result<(), CanonicalAuthError> {
429    validate_scope_label_detailed(scope).map_err(|error| match error {
430        ScopeLabelError::Empty => CanonicalAuthError::EmptyScope,
431        _ => CanonicalAuthError::InvalidScope {
432            scope: scope.to_string(),
433        },
434    })
435}
436
437fn encode_string(out: &mut Vec<u8>, value: &str) -> Result<(), CanonicalAuthError> {
438    encode_bytes(out, value.as_bytes())?;
439    Ok(())
440}
441
442pub(crate) fn encode_principal(
443    out: &mut Vec<u8>,
444    principal: Principal,
445) -> Result<(), CanonicalAuthError> {
446    encode_bytes(out, principal.as_slice())?;
447    Ok(())
448}
449
450fn encode_bytes(out: &mut Vec<u8>, bytes: &[u8]) -> Result<(), CanonicalAuthError> {
451    encode_len(out, bytes.len())?;
452    out.extend_from_slice(bytes);
453    Ok(())
454}
455
456fn encode_fixed_32(out: &mut Vec<u8>, bytes: [u8; 32]) {
457    out.extend_from_slice(&bytes);
458}
459
460fn encode_u64(out: &mut Vec<u8>, value: u64) {
461    out.extend_from_slice(&value.to_be_bytes());
462}
463
464fn encode_u16(out: &mut Vec<u8>, value: u16) {
465    out.extend_from_slice(&value.to_be_bytes());
466}
467
468fn encode_len(out: &mut Vec<u8>, len: usize) -> Result<(), CanonicalAuthError> {
469    let len = u32::try_from(len).map_err(|_| CanonicalAuthError::LengthOverflow)?;
470    out.extend_from_slice(&len.to_be_bytes());
471    Ok(())
472}
473
474#[cfg(test)]
475mod tests {
476    use super::*;
477
478    #[test]
479    fn chain_key_payload_hashes_preserve_framing() {
480        for length in [0, 1, 255, 256, 4096] {
481            let payload: Vec<u8> = (0..length).map(|i| (i % 256) as u8).collect();
482            let len = (length as u32).to_be_bytes();
483            let header = [CHAIN_KEY_BATCH_HEADER_DOMAIN, &len, &payload].concat();
484            let leaf = [&[0], CHAIN_KEY_DELEGATION_CERT_DOMAIN, &len, &payload].concat();
485            assert_eq!(
486                hash_chain_key_header_payload(&payload).unwrap(),
487                <[u8; 32]>::from(Sha256::digest(header)),
488            );
489            assert_eq!(
490                hash_chain_key_leaf_payload(&payload).unwrap(),
491                <[u8; 32]>::from(Sha256::digest(leaf)),
492            );
493        }
494    }
495
496    #[test]
497    #[cfg(target_pointer_width = "64")]
498    fn length_overflow_is_fallible_before_writing() {
499        let mut out = vec![7];
500        assert_eq!(
501            encode_len(&mut out, u32::MAX as usize + 1),
502            Err(CanonicalAuthError::LengthOverflow)
503        );
504        assert_eq!(out, [7]);
505    }
506}