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