Skip to main content

ic_auth_protocol_types/
identifiers.rs

1//! Validated protocol identifiers; deployment identity and trust enrollment stay
2//! with the host. Existing text wire representations are deliberately preserved.
3
4use candid::{CandidType, types};
5use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
6use std::{fmt, str::FromStr};
7use thiserror::Error;
8
9/// Invalid protocol identifier syntax. This says nothing about authorization.
10#[derive(Clone, Debug, Eq, Error, PartialEq)]
11pub enum IdentifierError {
12    #[error("identifier must contain exactly 64 lowercase hexadecimal characters")]
13    NonCanonicalId,
14    #[error("role must be nonempty and contain only a-z, 0-9, underscore, colon or hyphen")]
15    InvalidRole,
16}
17
18/// Exactly 32 identity bytes, represented as lowercase hexadecimal on the wire.
19/// No sentinel bytes are reserved by this protocol. A host must compare this
20/// value with its protected identity, never enroll trust from a client claim.
21#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
22pub struct CanonicalId([u8; 32]);
23
24impl CanonicalId {
25    /// Project an already resolved host identity without changing its bytes.
26    pub const fn from_bytes(bytes: [u8; 32]) -> Self {
27        Self(bytes)
28    }
29
30    /// Exact bytes used in canonical signed encoding.
31    pub const fn as_bytes(&self) -> &[u8; 32] {
32        &self.0
33    }
34}
35
36impl fmt::Display for CanonicalId {
37    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
38        for byte in self.0 {
39            write!(formatter, "{byte:02x}")?;
40        }
41        Ok(())
42    }
43}
44
45impl FromStr for CanonicalId {
46    type Err = IdentifierError;
47
48    fn from_str(value: &str) -> Result<Self, Self::Err> {
49        if value.len() != 64
50            || !value
51                .bytes()
52                .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
53        {
54            return Err(IdentifierError::NonCanonicalId);
55        }
56        let mut bytes = [0; 32];
57        for (byte, pair) in bytes.iter_mut().zip(value.as_bytes().as_chunks::<2>().0) {
58            let nibble = |b| if b <= b'9' { b - b'0' } else { b - b'a' + 10 };
59            *byte = (nibble(pair[0]) << 4) | nibble(pair[1]);
60        }
61        Ok(Self(bytes))
62    }
63}
64
65impl Serialize for CanonicalId {
66    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
67        serializer.collect_str(self)
68    }
69}
70
71impl<'de> Deserialize<'de> for CanonicalId {
72    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
73        String::deserialize(deserializer)?
74            .parse()
75            .map_err(de::Error::custom)
76    }
77}
78
79impl CandidType for CanonicalId {
80    fn _ty() -> types::Type {
81        types::TypeInner::Text.into()
82    }
83
84    fn idl_serialize<S: types::Serializer>(&self, serializer: S) -> Result<(), S::Error> {
85        serializer.serialize_text(&self.to_string())
86    }
87}
88
89/// Exact network-qualified audience, with existing protocol field labels.
90/// The host owns the meaning and trusted source of both identifiers; this type
91/// contains no fleet topology, membership, derivation or installation policy.
92#[derive(
93    CandidType, Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize,
94)]
95#[serde(deny_unknown_fields)]
96pub struct AudienceId {
97    pub canonical_network_id: CanonicalId,
98    pub fleet_id: CanonicalId,
99}
100
101/// Canonical role label, checked at construction and deserialization.
102/// The grammar matches the existing signed protocol; it adds no case folding,
103/// normalization, built-in roles or implicit role authority.
104#[derive(CandidType, Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
105#[serde(transparent)]
106pub struct AuthRole(String);
107
108impl AuthRole {
109    pub fn as_str(&self) -> &str {
110        &self.0
111    }
112}
113
114impl FromStr for AuthRole {
115    type Err = IdentifierError;
116
117    fn from_str(value: &str) -> Result<Self, Self::Err> {
118        if value.is_empty()
119            || !value.bytes().all(|b| {
120                b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b':' | b'-')
121            })
122        {
123            return Err(IdentifierError::InvalidRole);
124        }
125        Ok(Self(value.to_owned()))
126    }
127}
128
129impl<'de> Deserialize<'de> for AuthRole {
130    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
131        String::deserialize(deserializer)?
132            .parse()
133            .map_err(de::Error::custom)
134    }
135}