Skip to main content

ic_auth/
portable.rs

1//! Canonical portable credential claims and explicit protected-context checks.
2//! Encoding, decoding and hashing alone do not verify time or authority.
3//! Context checks do not verify signatures, enrollment provenance or revocation.
4//! No value returned by this module is sufficient for session admission.
5
6mod revocation;
7mod rules;
8mod verified;
9pub use revocation::{LoginRevocationChange, LoginRevocationError, MemoryLoginRevocations};
10pub use rules::{PortableClaimsContext, PortableClaimsError, validate_claims_context};
11pub use verified::VerifiedPortableClaims;
12
13use crate::canonical::{CanonicalAuthError, encode_principal, encode_role_grants};
14use crate::certificate_issuance::{GrantValidationError, validate_grants};
15use ic_auth_protocol_types::{
16    AudienceId, CanonicalId, DelegatedRoleGrant, PortableClaims, PortableMechanism, Principal,
17};
18use sha2::{Digest, Sha256};
19use thiserror::Error;
20
21const DOMAIN: &[u8] = b"IC-AUTH-PORTABLE-CREDENTIAL\0";
22const SCHEMA: u16 = 1;
23const MAX_GRANTS: usize = 16;
24const MAX_SCOPES: usize = 32;
25const MAX_LABEL_BYTES: usize = 64;
26
27/// Hard cap on the signed claims blob, independent of outer transport limits.
28pub const MAX_CLAIMS_BYTES: usize = 65_536;
29
30/// Structural codec errors, never proof-verification outcomes.
31#[derive(Debug, Error, PartialEq, Eq)]
32pub enum PortableEncodingError {
33    #[error("portable claims exceed their byte bound")]
34    TooLarge,
35    #[error("truncated portable claims")]
36    Truncated,
37    #[error("wrong portable domain, schema or mechanism")]
38    WrongDomain,
39    #[error("portable claims contain trailing bytes")]
40    TrailingBytes,
41    #[error("invalid portable principal, label or count")]
42    InvalidShape,
43    #[error("anonymous account subject")]
44    AnonymousSubject,
45    #[error(transparent)]
46    Grants(#[from] GrantValidationError),
47    #[error(transparent)]
48    Canonical(#[from] CanonicalAuthError),
49}
50
51/// Encode exactly the frozen 0.6 claims domain. Never sort or normalize input.
52/// Time relationships and protected authority are verification responsibilities.
53pub fn encode_claims(claims: &PortableClaims) -> Result<Vec<u8>, PortableEncodingError> {
54    validate_shape(claims)?;
55    // Validated grant/count/label bounds limit allocation even before the final
56    // whole-blob check. No unbounded extension or caller-selected capacity.
57    let mut out = Vec::new();
58    out.extend_from_slice(DOMAIN);
59    out.extend_from_slice(&SCHEMA.to_be_bytes());
60    out.push(match claims.mechanism {
61        PortableMechanism::Certified => 0,
62        PortableMechanism::Ed25519 => 1,
63    });
64    out.extend_from_slice(claims.audience.canonical_network_id.as_bytes());
65    out.extend_from_slice(claims.audience.fleet_id.as_bytes());
66    encode_principal(&mut out, claims.issuer)?;
67    out.extend_from_slice(&claims.key_binding_id);
68    out.extend_from_slice(&claims.browser_public_key);
69    encode_principal(&mut out, claims.account_subject)?;
70    for time in [
71        claims.login_started_at_ns,
72        claims.login_deadline_ns,
73        claims.issued_at_ns,
74        claims.admission_expires_at_ns,
75    ] {
76        out.extend_from_slice(&time.to_be_bytes());
77    }
78    out.extend_from_slice(&claims.nonce);
79    encode_role_grants(&mut out, &claims.grants)?;
80    if out.len() > MAX_CLAIMS_BYTES {
81        return Err(PortableEncodingError::TooLarge);
82    }
83    Ok(out)
84}
85
86/// Decode bounded canonical claims. Lengths/counts are admitted before allocation;
87/// unknown schema/mechanism, trailing bytes and noncanonical grants are rejected.
88/// This is deliberately not a deserializer for the previous token domain.
89pub fn decode_claims(bytes: &[u8]) -> Result<PortableClaims, PortableEncodingError> {
90    if bytes.len() > MAX_CLAIMS_BYTES {
91        return Err(PortableEncodingError::TooLarge);
92    }
93    let mut input = Reader(bytes);
94    if input.take(DOMAIN.len())? != DOMAIN || input.array::<2>()? != SCHEMA.to_be_bytes() {
95        return Err(PortableEncodingError::WrongDomain);
96    }
97    let mechanism = match input.array::<1>()? {
98        [0] => PortableMechanism::Certified,
99        [1] => PortableMechanism::Ed25519,
100        _ => return Err(PortableEncodingError::WrongDomain),
101    };
102    let audience = AudienceId {
103        canonical_network_id: CanonicalId::from_bytes(input.array()?),
104        fleet_id: CanonicalId::from_bytes(input.array()?),
105    };
106    let issuer = input.principal()?;
107    let key_binding_id = input.array()?;
108    let browser_public_key = input.array()?;
109    let account_subject = input.principal()?;
110    let login_started_at_ns = u64::from_be_bytes(input.array()?);
111    let login_deadline_ns = u64::from_be_bytes(input.array()?);
112    let issued_at_ns = u64::from_be_bytes(input.array()?);
113    let admission_expires_at_ns = u64::from_be_bytes(input.array()?);
114    let nonce = input.array()?;
115    let count = input.count(MAX_GRANTS)?;
116    let mut grants = Vec::with_capacity(count);
117    for _ in 0..count {
118        let target = input
119            .label()?
120            .parse()
121            .map_err(|_| PortableEncodingError::InvalidShape)?;
122        let count = input.count(MAX_SCOPES)?;
123        let mut scopes = Vec::with_capacity(count);
124        for _ in 0..count {
125            scopes.push(input.label()?.to_owned());
126        }
127        grants.push(DelegatedRoleGrant { target, scopes });
128    }
129    if !input.0.is_empty() {
130        return Err(PortableEncodingError::TrailingBytes);
131    }
132    let claims = PortableClaims {
133        mechanism,
134        audience,
135        issuer,
136        key_binding_id,
137        browser_public_key,
138        account_subject,
139        login_started_at_ns,
140        login_deadline_ns,
141        issued_at_ns,
142        admission_expires_at_ns,
143        nonce,
144        grants,
145    };
146    validate_shape(&claims)?;
147    Ok(claims)
148}
149
150/// SHA-256 of the exact canonical signed blob, for signing and replay identity.
151/// The digest authenticates nothing by itself; signature bytes are not included.
152pub fn claims_hash(claims: &PortableClaims) -> Result<[u8; 32], PortableEncodingError> {
153    Ok(Sha256::digest(encode_claims(claims)?).into())
154}
155
156/// Derive the IC sender for a directly authenticated Ed25519 browser key.
157/// Hosts must compare this with the actual ingress caller, not a client field.
158/// This does not validate curve membership or possession of the key.
159pub fn browser_principal(public_key: &[u8; 32]) -> Principal {
160    // RFC 8410 Ed25519 SubjectPublicKeyInfo: SEQUENCE(algorithm OID, BIT STRING).
161    let mut der = [0; 44];
162    der[..12].copy_from_slice(&[
163        0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0,
164    ]);
165    der[12..].copy_from_slice(public_key);
166    Principal::self_authenticating(der)
167}
168
169fn validate_shape(claims: &PortableClaims) -> Result<(), PortableEncodingError> {
170    if claims.account_subject == Principal::anonymous() {
171        return Err(PortableEncodingError::AnonymousSubject);
172    }
173    validate_grants(&claims.grants, MAX_GRANTS, MAX_SCOPES)?;
174    if claims
175        .grants
176        .iter()
177        .any(|grant| grant.target.as_str().len() > MAX_LABEL_BYTES)
178    {
179        return Err(PortableEncodingError::InvalidShape);
180    }
181    Ok(())
182}
183
184struct Reader<'a>(&'a [u8]);
185
186impl<'a> Reader<'a> {
187    fn take(&mut self, len: usize) -> Result<&'a [u8], PortableEncodingError> {
188        let (head, tail) = self
189            .0
190            .split_at_checked(len)
191            .ok_or(PortableEncodingError::Truncated)?;
192        self.0 = tail;
193        Ok(head)
194    }
195
196    fn array<const N: usize>(&mut self) -> Result<[u8; N], PortableEncodingError> {
197        self.take(N)?
198            .try_into()
199            .map_err(|_| PortableEncodingError::Truncated)
200    }
201
202    fn length(&mut self, max: usize) -> Result<usize, PortableEncodingError> {
203        let len = u32::from_be_bytes(self.array()?);
204        let len = usize::try_from(len).map_err(|_| PortableEncodingError::InvalidShape)?;
205        if len > max {
206            return Err(PortableEncodingError::InvalidShape);
207        }
208        Ok(len)
209    }
210
211    fn count(&mut self, max: usize) -> Result<usize, PortableEncodingError> {
212        let count = self.length(max)?;
213        if count == 0 {
214            return Err(PortableEncodingError::InvalidShape);
215        }
216        Ok(count)
217    }
218
219    fn principal(&mut self) -> Result<Principal, PortableEncodingError> {
220        let len = self.length(29)?;
221        Ok(Principal::from_slice(self.take(len)?))
222    }
223
224    fn label(&mut self) -> Result<&'a str, PortableEncodingError> {
225        let len = self.count(MAX_LABEL_BYTES)?;
226        std::str::from_utf8(self.take(len)?).map_err(|_| PortableEncodingError::InvalidShape)
227    }
228}