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 = domain_bytes(CanonicalDomain::DelegationCert);
119
120    encode_principal(&mut out, cert.root_pid)?;
121    encode_principal(&mut out, cert.issuer_pid)?;
122    encode_issuer_proof_algorithm(&mut out, cert.issuer_proof_alg);
123    encode_fixed_32(&mut out, cert.issuer_proof_binding_hash);
124    encode_issuer_proof_binding(&mut out, cert.issuer_proof_binding);
125    encode_u64(&mut out, cert.issued_at_ns);
126    encode_u64(&mut out, cert.not_before_ns);
127    encode_u64(&mut out, cert.expires_at_ns);
128    encode_u64(&mut out, cert.max_token_ttl_ns);
129    encode_audience(&mut out, &cert.aud);
130    encode_role_grants(&mut out, &cert.grants)?;
131
132    Ok(out)
133}
134
135/// Encode claims exactly, including presenter, audience, nonce and extension presence.
136pub fn claims_bytes(claims: &DelegatedTokenClaims) -> Result<Vec<u8>, CanonicalAuthError> {
137    let mut out = domain_bytes(CanonicalDomain::DelegatedTokenClaims);
138
139    encode_principal(&mut out, claims.presenter)?;
140    encode_principal(&mut out, claims.subject)?;
141    encode_principal(&mut out, claims.issuer_pid)?;
142    encode_fixed_32(&mut out, claims.cert_hash);
143    encode_u64(&mut out, claims.issued_at_ns);
144    encode_u64(&mut out, claims.expires_at_ns);
145    encode_audience(&mut out, &claims.aud);
146    encode_role_grants(&mut out, &claims.grants)?;
147    out.extend_from_slice(&claims.nonce);
148    encode_token_ext(&mut out, claims.ext.as_deref())?;
149
150    Ok(out)
151}
152
153fn proof_bytes(proof: &DelegationProof) -> Result<Vec<u8>, CanonicalAuthError> {
154    let mut out = domain_bytes(CanonicalDomain::DelegationProof);
155
156    out.extend_from_slice(&cert_bytes(&proof.cert)?);
157    encode_root_proof(&mut out, &proof.root_proof)?;
158
159    Ok(out)
160}
161
162fn issuer_proof_bytes(proof: &IssuerProof) -> Result<Vec<u8>, CanonicalAuthError> {
163    let mut out = domain_bytes(CanonicalDomain::IssuerProof);
164    encode_issuer_proof(&mut out, proof)?;
165    Ok(out)
166}
167
168fn domain_bytes(domain: CanonicalDomain) -> Vec<u8> {
169    let mut out = Vec::with_capacity(128);
170    out.extend_from_slice(DOMAIN_SEPARATOR);
171    out.push(domain as u8);
172    out
173}
174
175fn hash_bytes(bytes: &[u8]) -> [u8; 32] {
176    Sha256::digest(bytes).into()
177}
178
179fn encode_issuer_proof_algorithm(out: &mut Vec<u8>, alg: IssuerProofAlgorithm) {
180    let tag = match alg {
181        IssuerProofAlgorithm::IcCanisterSignatureV1 => 1,
182    };
183    out.push(tag);
184}
185
186fn encode_audience(out: &mut Vec<u8>, audience: &DelegationAudience) {
187    match audience {
188        DelegationAudience::Fleet(fleet) => {
189            out.push(1);
190            encode_fleet_key(out, *fleet);
191        }
192    }
193}
194
195fn encode_fleet_key(out: &mut Vec<u8>, fleet: AudienceId) {
196    encode_fixed_32(out, *fleet.canonical_network_id.as_bytes());
197    encode_fixed_32(out, *fleet.fleet_id.as_bytes());
198}
199
200fn encode_role_grants(
201    out: &mut Vec<u8>,
202    grants: &[DelegatedRoleGrant],
203) -> Result<(), CanonicalAuthError> {
204    encode_len(out, grants.len())?;
205    let mut previous = None;
206    for grant in grants {
207        let current = grant.target.as_str().as_bytes();
208        if previous.is_some_and(|previous| previous >= current) {
209            return Err(CanonicalAuthError::NonCanonicalRoles);
210        }
211        previous = Some(current);
212        encode_role(out, &grant.target)?;
213        encode_scopes(out, &grant.scopes)?;
214    }
215    Ok(())
216}
217
218fn hash_chain_key_header_payload(payload: &[u8]) -> Result<[u8; 32], CanonicalAuthError> {
219    let mut out = Vec::with_capacity(CHAIN_KEY_BATCH_HEADER_DOMAIN.len() + 4 + payload.len());
220    out.extend_from_slice(CHAIN_KEY_BATCH_HEADER_DOMAIN);
221    encode_bytes(&mut out, payload)?;
222    Ok(hash_bytes(&out))
223}
224
225fn hash_chain_key_leaf_payload(payload: &[u8]) -> Result<[u8; 32], CanonicalAuthError> {
226    let mut out =
227        Vec::with_capacity(1 + CHAIN_KEY_DELEGATION_CERT_DOMAIN.len() + 4 + payload.len());
228    out.push(0);
229    out.extend_from_slice(CHAIN_KEY_DELEGATION_CERT_DOMAIN);
230    encode_bytes(&mut out, payload)?;
231    Ok(hash_bytes(&out))
232}
233
234fn chain_key_batch_header_bytes(
235    header: &ChainKeyBatchHeaderV1,
236) -> Result<Vec<u8>, CanonicalAuthError> {
237    let mut out = Vec::with_capacity(256);
238    encode_u16(&mut out, header.schema_version);
239    encode_principal(&mut out, header.root_canister_id)?;
240    encode_fixed_32(&mut out, header.batch_id);
241    encode_u64(&mut out, header.proof_epoch);
242    encode_u64(&mut out, header.registry_epoch);
243    encode_fixed_32(&mut out, header.registry_hash);
244    encode_fixed_32(&mut out, header.tree_root);
245    encode_u64(&mut out, header.not_before_ns);
246    encode_u64(&mut out, header.expires_at_ns);
247    encode_chain_key_algorithm(&mut out, header.algorithm);
248    encode_chain_key_key_id(&mut out, &header.key_id)?;
249    encode_fixed_32(&mut out, header.derivation_path_hash);
250    encode_u64(&mut out, header.key_version);
251    Ok(out)
252}
253
254fn chain_key_delegation_cert_bytes(
255    cert: &ChainKeyDelegationCertV1,
256) -> Result<Vec<u8>, CanonicalAuthError> {
257    let mut out = Vec::with_capacity(256);
258    encode_principal(&mut out, cert.root_canister_id)?;
259    encode_principal(&mut out, cert.issuer_canister_id)?;
260    encode_u64(&mut out, cert.proof_epoch);
261    encode_issuer_proof_algorithm(&mut out, cert.issuer_proof_algorithm);
262    encode_fixed_32(&mut out, cert.issuer_proof_binding_hash);
263    encode_issuer_proof_binding(&mut out, cert.issuer_proof_binding);
264    encode_u64(&mut out, cert.max_token_ttl_ns);
265    encode_audience(&mut out, &cert.audience);
266    encode_role_grants(&mut out, &cert.grants)?;
267    encode_u64(&mut out, cert.not_before_ns);
268    encode_u64(&mut out, cert.expires_at_ns);
269    encode_u64(&mut out, cert.registry_epoch);
270    encode_fixed_32(&mut out, cert.registry_hash);
271    Ok(out)
272}
273
274fn encode_root_proof(out: &mut Vec<u8>, proof: &RootProof) -> Result<(), CanonicalAuthError> {
275    match proof {
276        RootProof::IcChainKeyBatchSignatureV1(proof) => {
277            out.push(2);
278            encode_chain_key_proof(out, proof)?;
279        }
280    }
281    Ok(())
282}
283
284fn encode_chain_key_proof(
285    out: &mut Vec<u8>,
286    proof: &IcChainKeyBatchSignatureProofV1,
287) -> Result<(), CanonicalAuthError> {
288    out.extend_from_slice(&chain_key_batch_header_bytes(&proof.header)?);
289    out.extend_from_slice(&chain_key_delegation_cert_bytes(&proof.delegation_cert)?);
290    encode_chain_key_witness(out, &proof.issuer_witness)?;
291    encode_chain_key_signature(out, &proof.signature)?;
292    Ok(())
293}
294
295fn encode_chain_key_witness(
296    out: &mut Vec<u8>,
297    witness: &ChainKeyBatchWitnessV1,
298) -> Result<(), CanonicalAuthError> {
299    encode_len(out, witness.steps.len())?;
300    for step in &witness.steps {
301        match step {
302            ChainKeyBatchWitnessStepV1::LeftSibling(hash) => {
303                out.push(1);
304                encode_fixed_32(out, *hash);
305            }
306            ChainKeyBatchWitnessStepV1::RightSibling(hash) => {
307                out.push(2);
308                encode_fixed_32(out, *hash);
309            }
310        }
311    }
312    Ok(())
313}
314
315fn encode_chain_key_signature(
316    out: &mut Vec<u8>,
317    signature: &ChainKeyRootSignatureV1,
318) -> Result<(), CanonicalAuthError> {
319    encode_chain_key_algorithm(out, signature.algorithm);
320    encode_chain_key_key_id(out, &signature.key_id)?;
321    encode_chain_key_derivation_path(out, &signature.derivation_path)?;
322    encode_bytes(out, &signature.public_key)?;
323    encode_bytes(out, &signature.signature)?;
324    Ok(())
325}
326
327fn encode_chain_key_derivation_path(
328    out: &mut Vec<u8>,
329    derivation_path: &[Vec<u8>],
330) -> Result<(), CanonicalAuthError> {
331    encode_len(out, derivation_path.len())?;
332    for path_component in derivation_path {
333        encode_bytes(out, path_component)?;
334    }
335    Ok(())
336}
337
338fn encode_chain_key_algorithm(out: &mut Vec<u8>, algorithm: ChainKeyAlgorithm) {
339    let tag = match algorithm {
340        ChainKeyAlgorithm::EcdsaSecp256k1 => 1,
341    };
342    out.push(tag);
343}
344
345fn encode_chain_key_key_id(
346    out: &mut Vec<u8>,
347    key_id: &ChainKeyKeyId,
348) -> Result<(), CanonicalAuthError> {
349    encode_string(out, &key_id.name)?;
350    Ok(())
351}
352
353fn encode_issuer_proof(out: &mut Vec<u8>, proof: &IssuerProof) -> Result<(), CanonicalAuthError> {
354    match proof {
355        IssuerProof::IcCanisterSignatureV1(proof) => {
356            out.push(1);
357            encode_bytes(out, &proof.signature_cbor)?;
358            encode_bytes(out, &proof.public_key_der)?;
359        }
360    }
361    Ok(())
362}
363
364fn encode_issuer_proof_binding(out: &mut Vec<u8>, binding: IssuerProofBinding) {
365    match binding {
366        IssuerProofBinding::IcCanisterSignatureV1 { seed_hash } => {
367            out.push(1);
368            encode_fixed_32(out, seed_hash);
369        }
370    }
371}
372
373fn encode_token_ext(out: &mut Vec<u8>, ext: Option<&[u8]>) -> Result<(), CanonicalAuthError> {
374    match ext {
375        Some(ext) => {
376            if ext.len() > MAX_TOKEN_EXT_BYTES {
377                return Err(CanonicalAuthError::TokenExtTooLarge {
378                    len: ext.len(),
379                    max: MAX_TOKEN_EXT_BYTES,
380                });
381            }
382            out.push(1);
383            encode_bytes(out, ext)?;
384        }
385        None => out.push(0),
386    }
387    Ok(())
388}
389
390fn encode_role(out: &mut Vec<u8>, role: &AuthRole) -> Result<(), CanonicalAuthError> {
391    encode_bytes(out, role.as_str().as_bytes())?;
392    Ok(())
393}
394
395fn encode_scopes(out: &mut Vec<u8>, scopes: &[String]) -> Result<(), CanonicalAuthError> {
396    let mut previous = None;
397    for scope in scopes {
398        validate_scope_label(scope)?;
399        let current = scope.as_bytes();
400        if previous.is_some_and(|previous| previous >= current) {
401            return Err(CanonicalAuthError::NonCanonicalScopes);
402        }
403        previous = Some(current);
404    }
405
406    encode_len(out, scopes.len())?;
407    for scope in scopes {
408        encode_bytes(out, scope.as_bytes())?;
409    }
410
411    Ok(())
412}
413
414/// Check the existing lowercase, colon-separated application-scope grammar.
415pub fn validate_scope_label(scope: &str) -> Result<(), CanonicalAuthError> {
416    if scope.is_empty() {
417        return Err(CanonicalAuthError::EmptyScope);
418    }
419    if !is_valid_scope(scope) {
420        return Err(CanonicalAuthError::InvalidScope {
421            scope: scope.to_string(),
422        });
423    }
424    Ok(())
425}
426
427fn encode_string(out: &mut Vec<u8>, value: &str) -> Result<(), CanonicalAuthError> {
428    encode_bytes(out, value.as_bytes())?;
429    Ok(())
430}
431
432fn encode_principal(out: &mut Vec<u8>, principal: Principal) -> Result<(), CanonicalAuthError> {
433    encode_bytes(out, principal.as_slice())?;
434    Ok(())
435}
436
437fn encode_bytes(out: &mut Vec<u8>, bytes: &[u8]) -> Result<(), CanonicalAuthError> {
438    encode_len(out, bytes.len())?;
439    out.extend_from_slice(bytes);
440    Ok(())
441}
442
443fn encode_fixed_32(out: &mut Vec<u8>, bytes: [u8; 32]) {
444    out.extend_from_slice(&bytes);
445}
446
447fn encode_u64(out: &mut Vec<u8>, value: u64) {
448    out.extend_from_slice(&value.to_be_bytes());
449}
450
451fn encode_u16(out: &mut Vec<u8>, value: u16) {
452    out.extend_from_slice(&value.to_be_bytes());
453}
454
455fn encode_len(out: &mut Vec<u8>, len: usize) -> Result<(), CanonicalAuthError> {
456    let len = u32::try_from(len).map_err(|_| CanonicalAuthError::LengthOverflow)?;
457    out.extend_from_slice(&len.to_be_bytes());
458    Ok(())
459}
460
461// Existing application-scope grammar, including its 64-byte bound.
462fn is_valid_scope(scope: &str) -> bool {
463    if scope.is_empty() || scope.len() > 64 {
464        return false;
465    }
466    scope.split(':').all(|segment| {
467        let mut bytes = segment.bytes();
468        bytes
469            .next()
470            .is_some_and(|b| b.is_ascii_lowercase() || b.is_ascii_digit())
471            && bytes
472                .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'-'))
473    })
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479
480    #[test]
481    #[cfg(target_pointer_width = "64")]
482    fn length_overflow_is_fallible_before_writing() {
483        let mut out = vec![7];
484        assert_eq!(
485            encode_len(&mut out, u32::MAX as usize + 1),
486            Err(CanonicalAuthError::LengthOverflow)
487        );
488        assert_eq!(out, [7]);
489    }
490}