Skip to main content

ic_auth/
canonical.rs

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