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(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
171pub(crate) fn encode_role_grants(
172    out: &mut Vec<u8>,
173    grants: &[DelegatedRoleGrant],
174) -> Result<(), CanonicalAuthError> {
175    encode_len(out, grants.len())?;
176    let mut previous = None;
177    for grant in grants {
178        let current = grant.target.as_str().as_bytes();
179        if previous.is_some_and(|previous| previous >= current) {
180            return Err(CanonicalAuthError::NonCanonicalRoles);
181        }
182        previous = Some(current);
183        encode_role(out, &grant.target)?;
184        encode_scopes(out, &grant.scopes)?;
185    }
186    Ok(())
187}
188
189fn hash_chain_key_header_payload(payload: &[u8]) -> Result<[u8; 32], CanonicalAuthError> {
190    let len = u32::try_from(payload.len()).map_err(|_| CanonicalAuthError::LengthOverflow)?;
191    let mut hash = Sha256::new();
192    hash.update(CHAIN_KEY_BATCH_HEADER_DOMAIN);
193    hash.update(len.to_be_bytes());
194    hash.update(payload);
195    Ok(hash.finalize().into())
196}
197
198fn hash_chain_key_leaf_payload(payload: &[u8]) -> Result<[u8; 32], CanonicalAuthError> {
199    let len = u32::try_from(payload.len()).map_err(|_| CanonicalAuthError::LengthOverflow)?;
200    let mut hash = Sha256::new();
201    hash.update([0]);
202    hash.update(CHAIN_KEY_DELEGATION_CERT_DOMAIN);
203    hash.update(len.to_be_bytes());
204    hash.update(payload);
205    Ok(hash.finalize().into())
206}
207
208fn encode_chain_key_batch_header(
209    out: &mut Vec<u8>,
210    header: &ChainKeyBatchHeaderV1,
211) -> Result<(), CanonicalAuthError> {
212    encode_u16(out, header.schema_version);
213    encode_principal(out, header.root_canister_id)?;
214    encode_fixed_32(out, header.batch_id);
215    encode_u64(out, header.proof_epoch);
216    encode_u64(out, header.registry_epoch);
217    encode_fixed_32(out, header.registry_hash);
218    encode_fixed_32(out, header.tree_root);
219    encode_u64(out, header.not_before_ns);
220    encode_u64(out, header.expires_at_ns);
221    encode_chain_key_algorithm(out, header.algorithm);
222    encode_chain_key_key_id(out, &header.key_id)?;
223    encode_fixed_32(out, header.derivation_path_hash);
224    encode_u64(out, header.key_version);
225    Ok(())
226}
227
228fn encode_chain_key_delegation_cert(
229    out: &mut Vec<u8>,
230    cert: &ChainKeyDelegationCertV1,
231) -> Result<(), CanonicalAuthError> {
232    encode_principal(out, cert.root_canister_id)?;
233    encode_principal(out, cert.issuer_canister_id)?;
234    encode_u64(out, cert.proof_epoch);
235    encode_issuer_proof_algorithm(out, cert.issuer_proof_algorithm);
236    encode_fixed_32(out, cert.issuer_proof_binding_hash);
237    encode_issuer_proof_binding(out, cert.issuer_proof_binding);
238    encode_u64(out, cert.max_token_ttl_ns);
239    encode_audience(out, &cert.audience);
240    encode_role_grants(out, &cert.grants)?;
241    encode_u64(out, cert.not_before_ns);
242    encode_u64(out, cert.expires_at_ns);
243    encode_u64(out, cert.registry_epoch);
244    encode_fixed_32(out, cert.registry_hash);
245    Ok(())
246}
247
248fn encode_root_proof(out: &mut Vec<u8>, proof: &RootProof) -> Result<(), CanonicalAuthError> {
249    match proof {
250        RootProof::IcChainKeyBatchSignatureV1(proof) => {
251            out.push(2);
252            encode_chain_key_proof(out, proof)?;
253        }
254    }
255    Ok(())
256}
257
258fn encode_chain_key_proof(
259    out: &mut Vec<u8>,
260    proof: &IcChainKeyBatchSignatureProofV1,
261) -> Result<(), CanonicalAuthError> {
262    encode_chain_key_batch_header(out, &proof.header)?;
263    encode_chain_key_delegation_cert(out, &proof.delegation_cert)?;
264    encode_chain_key_witness(out, &proof.issuer_witness)?;
265    encode_chain_key_signature(out, &proof.signature)?;
266    Ok(())
267}
268
269fn encode_chain_key_witness(
270    out: &mut Vec<u8>,
271    witness: &ChainKeyBatchWitnessV1,
272) -> Result<(), CanonicalAuthError> {
273    encode_len(out, witness.steps.len())?;
274    for step in &witness.steps {
275        match step {
276            ChainKeyBatchWitnessStepV1::LeftSibling(hash) => {
277                out.push(1);
278                encode_fixed_32(out, *hash);
279            }
280            ChainKeyBatchWitnessStepV1::RightSibling(hash) => {
281                out.push(2);
282                encode_fixed_32(out, *hash);
283            }
284        }
285    }
286    Ok(())
287}
288
289fn encode_chain_key_signature(
290    out: &mut Vec<u8>,
291    signature: &ChainKeyRootSignatureV1,
292) -> Result<(), CanonicalAuthError> {
293    encode_chain_key_algorithm(out, signature.algorithm);
294    encode_chain_key_key_id(out, &signature.key_id)?;
295    encode_chain_key_derivation_path(out, &signature.derivation_path)?;
296    encode_bytes(out, &signature.public_key)?;
297    encode_bytes(out, &signature.signature)?;
298    Ok(())
299}
300
301fn encode_chain_key_derivation_path(
302    out: &mut Vec<u8>,
303    derivation_path: &[Vec<u8>],
304) -> Result<(), CanonicalAuthError> {
305    encode_len(out, derivation_path.len())?;
306    for path_component in derivation_path {
307        encode_bytes(out, path_component)?;
308    }
309    Ok(())
310}
311
312fn encode_chain_key_algorithm(out: &mut Vec<u8>, algorithm: ChainKeyAlgorithm) {
313    let tag = match algorithm {
314        ChainKeyAlgorithm::EcdsaSecp256k1 => 1,
315    };
316    out.push(tag);
317}
318
319fn encode_chain_key_key_id(
320    out: &mut Vec<u8>,
321    key_id: &ChainKeyKeyId,
322) -> Result<(), CanonicalAuthError> {
323    encode_string(out, &key_id.name)?;
324    Ok(())
325}
326
327fn encode_issuer_proof_binding(out: &mut Vec<u8>, binding: IssuerProofBinding) {
328    match binding {
329        IssuerProofBinding::IcCanisterSignatureV1 { seed_hash } => {
330            out.push(1);
331            encode_fixed_32(out, seed_hash);
332        }
333    }
334}
335
336fn encode_role(out: &mut Vec<u8>, role: &AuthRole) -> Result<(), CanonicalAuthError> {
337    encode_bytes(out, role.as_str().as_bytes())?;
338    Ok(())
339}
340
341fn encode_scopes(out: &mut Vec<u8>, scopes: &[String]) -> Result<(), CanonicalAuthError> {
342    validate_scopes(scopes)?;
343    encode_len(out, scopes.len())?;
344    for scope in scopes {
345        encode_bytes(out, scope.as_bytes())?;
346    }
347    Ok(())
348}
349
350/// Validate label grammar and strictly increasing, unique UTF-8 byte order.
351///
352/// An empty list is valid. Cardinality, normalization and application authority
353/// remain caller-owned; use the bounded grant validator for nonempty grants.
354pub fn validate_scopes(scopes: &[String]) -> Result<(), CanonicalAuthError> {
355    let mut previous = None;
356    for scope in scopes {
357        validate_scope_label(scope)?;
358        let current = scope.as_bytes();
359        if previous.is_some_and(|previous| previous >= current) {
360            return Err(CanonicalAuthError::NonCanonicalScopes);
361        }
362        previous = Some(current);
363    }
364
365    Ok(())
366}
367
368/// A scope label's grammar failure. Offsets and lengths count UTF-8 bytes.
369#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
370pub enum ScopeLabelError {
371    #[error("scope is empty")]
372    Empty,
373    #[error("scope has {bytes} bytes; maximum is {max}")]
374    TooLong { bytes: usize, max: usize },
375    #[error("scope contains an empty segment")]
376    EmptySegment,
377    #[error("invalid scope segment start at byte {offset}")]
378    InvalidSegmentStart { offset: usize },
379    #[error("invalid scope byte at offset {offset}")]
380    InvalidByte { offset: usize },
381}
382
383/// Check the 64-byte, lowercase ASCII/digit, colon-separated scope grammar.
384///
385/// Every segment starts with a lowercase letter or digit; subsequent bytes may
386/// also be `_` or `-`. Length is checked first, then bytes from left to right.
387/// This allocation-free check supports static construction and grants no authority.
388pub const fn validate_scope_label_detailed(scope: &str) -> Result<(), ScopeLabelError> {
389    let bytes = scope.as_bytes();
390    if bytes.is_empty() {
391        return Err(ScopeLabelError::Empty);
392    }
393    if bytes.len() > crate::limits::MAX_LABEL_BYTES {
394        return Err(ScopeLabelError::TooLong {
395            bytes: bytes.len(),
396            max: crate::limits::MAX_LABEL_BYTES,
397        });
398    }
399    let mut segment_start = true;
400    let mut offset = 0;
401    while offset < bytes.len() {
402        let byte = bytes[offset];
403        if byte == b':' {
404            if segment_start {
405                return Err(ScopeLabelError::EmptySegment);
406            }
407            segment_start = true;
408        } else if segment_start {
409            if !byte.is_ascii_lowercase() && !byte.is_ascii_digit() {
410                return Err(ScopeLabelError::InvalidSegmentStart { offset });
411            }
412            segment_start = false;
413        } else if !byte.is_ascii_lowercase()
414            && !byte.is_ascii_digit()
415            && byte != b'_'
416            && byte != b'-'
417        {
418            return Err(ScopeLabelError::InvalidByte { offset });
419        }
420        offset += 1;
421    }
422    if segment_start {
423        return Err(ScopeLabelError::EmptySegment);
424    }
425    Ok(())
426}
427
428/// Check the existing lowercase, colon-separated application-scope grammar.
429pub fn validate_scope_label(scope: &str) -> Result<(), CanonicalAuthError> {
430    validate_scope_label_detailed(scope).map_err(|error| match error {
431        ScopeLabelError::Empty => CanonicalAuthError::EmptyScope,
432        _ => CanonicalAuthError::InvalidScope {
433            scope: scope.to_string(),
434        },
435    })
436}
437
438fn encode_string(out: &mut Vec<u8>, value: &str) -> Result<(), CanonicalAuthError> {
439    encode_bytes(out, value.as_bytes())?;
440    Ok(())
441}
442
443pub(crate) fn encode_principal(
444    out: &mut Vec<u8>,
445    principal: Principal,
446) -> Result<(), CanonicalAuthError> {
447    encode_bytes(out, principal.as_slice())?;
448    Ok(())
449}
450
451fn encode_bytes(out: &mut Vec<u8>, bytes: &[u8]) -> Result<(), CanonicalAuthError> {
452    encode_len(out, bytes.len())?;
453    out.extend_from_slice(bytes);
454    Ok(())
455}
456
457fn encode_fixed_32(out: &mut Vec<u8>, bytes: [u8; 32]) {
458    out.extend_from_slice(&bytes);
459}
460
461fn encode_u64(out: &mut Vec<u8>, value: u64) {
462    out.extend_from_slice(&value.to_be_bytes());
463}
464
465fn encode_u16(out: &mut Vec<u8>, value: u16) {
466    out.extend_from_slice(&value.to_be_bytes());
467}
468
469fn encode_len(out: &mut Vec<u8>, len: usize) -> Result<(), CanonicalAuthError> {
470    let len = u32::try_from(len).map_err(|_| CanonicalAuthError::LengthOverflow)?;
471    out.extend_from_slice(&len.to_be_bytes());
472    Ok(())
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478
479    #[test]
480    fn chain_key_payload_hashes_preserve_framing() {
481        for length in [0, 1, 255, 256, 4096] {
482            let payload: Vec<u8> = (0..length).map(|i| (i % 256) as u8).collect();
483            let len = (length as u32).to_be_bytes();
484            let header = [CHAIN_KEY_BATCH_HEADER_DOMAIN, &len, &payload].concat();
485            let leaf = [&[0], CHAIN_KEY_DELEGATION_CERT_DOMAIN, &len, &payload].concat();
486            assert_eq!(
487                hash_chain_key_header_payload(&payload).unwrap(),
488                <[u8; 32]>::from(Sha256::digest(header)),
489            );
490            assert_eq!(
491                hash_chain_key_leaf_payload(&payload).unwrap(),
492                <[u8; 32]>::from(Sha256::digest(leaf)),
493            );
494        }
495    }
496
497    #[test]
498    #[cfg(target_pointer_width = "64")]
499    fn length_overflow_is_fallible_before_writing() {
500        let mut out = vec![7];
501        assert_eq!(
502            encode_len(&mut out, u32::MAX as usize + 1),
503            Err(CanonicalAuthError::LengthOverflow)
504        );
505        assert_eq!(out, [7]);
506    }
507}