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