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