Skip to main content

pubhubs/api/
signed.rs

1use core::marker::PhantomData;
2
3use std::fmt;
4
5use serde::de::IntoDeserializer as _;
6use serde::{Deserialize, Serialize};
7
8use crate::id;
9use crate::misc::jwt;
10use crate::servers::Constellation;
11
12use crate::api::*;
13
14/// A signed `T` by encoding `T` into a [`jwt::JWT`].
15#[derive(Serialize, Deserialize, Debug, Clone)]
16#[serde(transparent)]
17pub struct Signed<T> {
18    inner: jwt::JWT,
19
20    phantom: PhantomData<T>,
21}
22
23/// Error returned by [`Signed::open`].
24#[derive(thiserror::Error, Debug)]
25pub enum OpenError {
26    #[error("signature intended for other constellation")]
27    OtherConstellation(ConstellationCompRes),
28
29    #[error("signature expired")]
30    Expired,
31
32    #[error("invalid signature")]
33    InvalidSignature,
34
35    #[error("malformed jwt")]
36    OtherwiseInvalid,
37
38    #[error("unexpected error - consult logs")]
39    InternalError,
40}
41
42impl<T> Signed<T> {
43    /// Opens this [`Signed`] message using the provided key.
44    pub fn open<VK: jwt::VerifyingKey>(
45        self,
46        key: &VK,
47        constellation: Option<&Constellation>,
48    ) -> std::result::Result<T, OpenError>
49    where
50        T: Signable,
51    {
52        if T::CONSTELLATION_BOUND != constellation.is_some() {
53            log::error!(
54                "internal error: a constellation must (only) be provided to Signed::open \
55                     if the type T is constellation bound"
56            );
57            return Err(OpenError::InternalError);
58        }
59
60        let check_constellation = |mut claims| -> std::result::Result<jwt::Claims, OpenError> {
61            if !T::CONSTELLATION_BOUND {
62                return Ok(claims);
63            }
64
65            let Ok(Some(constellation_claim)) =
66                claims.extract::<ConstellationClaim>(CONSTELLATION_CLAIM)
67            else {
68                return Err(OpenError::OtherwiseInvalid);
69            };
70
71            let ccr = constellation_claim.compare(constellation.unwrap());
72            if ccr.are_equal() {
73                return Ok(claims);
74            }
75
76            Err(OpenError::OtherConstellation(ccr))
77        };
78
79        let claims: jwt::Claims = self.inner.open(key).map_err(|err| {
80            log::debug!(
81                "could not open signed message (of type {}): {}",
82                std::any::type_name::<T>(),
83                err
84            );
85
86            match err {
87                jwt::Error::Expired { .. } => OpenError::Expired,
88                jwt::Error::DeserializingHeader(_)
89                | jwt::Error::ClaimsNotJsonMap(_)
90                | jwt::Error::MissingDot
91                | jwt::Error::InvalidBase64(_)
92                | jwt::Error::UnexpectedAlgorithm { .. } => OpenError::OtherwiseInvalid,
93                jwt::Error::InvalidSignature { claims, .. } => {
94                    match check_constellation(claims) {
95                        Ok(..) => {
96                            // constellations coincide, so the invalid signature cannot be
97                            // blamed on diverging constellations
98                            OpenError::InvalidSignature
99                        }
100                        Err(err) => err,
101                    }
102                }
103                _ => {
104                    log::error!("unexpected error opening signed message: {err}");
105                    OpenError::InternalError
106                }
107            }
108        })?;
109
110        let claims = check_constellation(claims)?;
111
112        // check that the message code is correct
113        let claims = claims
114            .check_present_and(
115                MESSAGE_CODE_CLAIM,
116                |claim_name: &'static str,
117                 mesg_code: MessageCode|
118                 -> std::result::Result<(), jwt::Error> {
119                    if mesg_code == T::CODE {
120                        return Ok(());
121                    }
122
123                    Err(jwt::Error::InvalidClaim {
124                        claim_name,
125                        source: anyhow::anyhow!(
126                            "expected message code {}, but got {}",
127                            T::CODE,
128                            mesg_code
129                        ),
130                    })
131                },
132            )
133            .map_err(|err| {
134                log::debug!("could not verify signed message's claims: {err}");
135                OpenError::OtherwiseInvalid
136            })?;
137
138        let res = claims.into_custom().map_err(|err| {
139            log::info!(
140                "could not parse signed message jwt into {}: {}",
141                std::any::type_name::<T>(),
142                err
143            );
144            OpenError::OtherwiseInvalid
145        })?;
146
147        Ok(res)
148    }
149
150    /// Open this signed message without checking the signature.  Something that should be done
151    /// only in exceptional circumstances, for example, in the  [`phc::hub::TicketEP`] endpoint.
152    pub fn open_without_checking_signature(self) -> std::result::Result<T, OpenError>
153    where
154        T: Signable,
155    {
156        self.open(&jwt::IgnoreSignature, None)
157    }
158
159    /// Like `new_opts`, but with `None` for the `constellation`.
160    pub fn new<SK: jwt::SigningKey>(
161        sk: &SK,
162        message: &T,
163        valid_for: std::time::Duration,
164    ) -> Result<Self>
165    where
166        T: Signable,
167    {
168        Self::new_opts(sk, message, valid_for, None)
169    }
170
171    /// Signs `message`, and returns the resulting [`Signed`], with more options.
172    pub fn new_opts<SK: jwt::SigningKey>(
173        sk: &SK,
174        message: &T,
175        valid_for: std::time::Duration,
176        constellation: Option<&Constellation>,
177    ) -> Result<Self>
178    where
179        T: Signable,
180    {
181        if T::CONSTELLATION_BOUND != constellation.is_some() {
182            log::error!(
183                "internal error: a constellation must (only) be provided to Signed::new \
184                     if the type T is constellation bound"
185            );
186            return Err(ErrorCode::InternalError);
187        }
188
189        let result = || -> std::result::Result<jwt::JWT, jwt::Error> {
190            let mut claims = jwt::Claims::from_custom(message)?
191                .nbf()?
192                .exp_after(valid_for)?
193                .claim(MESSAGE_CODE_CLAIM, T::CODE)?;
194
195            if T::CONSTELLATION_BOUND {
196                let constellation = constellation.unwrap();
197
198                claims =
199                    claims.claim(CONSTELLATION_CLAIM, ConstellationClaim::from(constellation))?;
200            }
201
202            claims.sign(sk)
203        }();
204
205        let jwt = match result {
206            Ok(jwt) => jwt,
207            Err(err) => {
208                log::warn!("failed to create signed message: {err}");
209                return Result::Err(ErrorCode::InternalError);
210            }
211        };
212
213        Result::Ok(Self {
214            inner: jwt,
215            phantom: PhantomData,
216        })
217    }
218
219    pub fn as_str(&self) -> &str {
220        self.inner.as_str()
221    }
222}
223
224/// A number that represents the type of a message.  Every message type that's [Signed] gets such a
225/// code to prevent reuse of a message of one type as another.
226#[non_exhaustive]
227#[repr(u16)]
228#[derive(serde_repr::Serialize_repr, serde_repr::Deserialize_repr, PartialEq, Eq, Debug)]
229pub enum MessageCode {
230    // NOTE: you can freely change the names of these variants, but DO NOT CHANGE the code of a
231    // message once assigned, as this breaks existing signatures.
232    PhcHubTicketReq = 1,
233    PhcHubTicket = 2,
234    #[deprecated = "legacy; was used by phct::hub::KeyEP"]
235    PhcTHubKeyReq = 3,
236    #[deprecated = "legacy; was used by phct::hub::KeyEP"]
237    PhcTHubKeyResp = 4,
238    AdminUpdateConfigReq = 5,
239    AdminInfoReq = 6,
240    Attr = 7,
241    Ppp = 8,
242    Ehpp = 9,
243    PpNonce = 10,
244    Hhpp = 11,
245    // new >v3.0.0
246    CardPseudPackage = 12,
247    HubPing = 13,
248    // new >v3.3.0
249    /// Seals the transcryptor's master encryption key part in its discovery info.
250    MasterEncKeyPart = 14,
251
252    /// Only used as an example in a doctest
253    Example = 65535,
254}
255
256impl MessageCode {
257    /// Returns big endian bytes representation of this message code.
258    pub fn to_bytes(&self) -> [u8; 2] {
259        // TODO: surely this should be achievable without serde_json somehow.
260        u16::deserialize(serde_json::to_value(self).unwrap().into_deserializer())
261            .unwrap()
262            .to_be_bytes()
263    }
264}
265
266impl std::fmt::Display for MessageCode {
267    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
268        self.serialize(&mut *f)?; // Don't ask me why self.serialize(f) does not work..
269        write!(f, " ({:?})", &self)
270    }
271}
272
273/// The claim name used to store the [`MessageCode`].
274pub const MESSAGE_CODE_CLAIM: &str = "ph-mc";
275
276/// The claim name used to store the [`Constellation`] [`Id`].
277///
278/// [`Id`]: id::Id
279/// [`Constellation`]: crate::servers::Constellation
280pub const CONSTELLATION_CLAIM: &str = "ph-ci";
281
282/// Contents of the [`CONSTELLATION_CLAIM`]
283#[derive(Serialize, Deserialize, Debug, Clone)]
284pub struct ConstellationClaim {
285    /// [`Constellation::id`]
286    #[serde(rename = "i")]
287    id: id::Id,
288
289    /// [`Constellation::created_at`]
290    #[serde(rename = "c")]
291    created_at: NumericDate,
292}
293
294impl From<&Constellation> for ConstellationClaim {
295    fn from(c: &Constellation) -> Self {
296        Self {
297            id: c.id,
298            created_at: c.created_at,
299        }
300    }
301}
302
303impl ConstellationClaim {
304    /// Compare this constellation claim with the constellation known to me
305    pub fn compare(self, my_constellation: &Constellation) -> ConstellationCompRes {
306        match self.created_at.cmp(&my_constellation.created_at) {
307            std::cmp::Ordering::Less => ConstellationCompRes {
308                update_my_constellation: false,
309                update_constellation_claim: true,
310            },
311            std::cmp::Ordering::Greater => ConstellationCompRes {
312                update_my_constellation: true,
313                update_constellation_claim: false,
314            },
315            std::cmp::Ordering::Equal => {
316                if my_constellation.id == self.id {
317                    ConstellationCompRes {
318                        update_my_constellation: false,
319                        update_constellation_claim: false,
320                    }
321                } else {
322                    ConstellationCompRes {
323                        update_my_constellation: true,
324                        update_constellation_claim: true,
325                    }
326                }
327            }
328        }
329    }
330}
331
332/// Result of [`ConstellationClaim::compare`].
333#[derive(Serialize, Deserialize, Debug, Clone, Copy)]
334pub struct ConstellationCompRes {
335    /// The constellation claim is perhaps out of date, and it's best if the originator
336    /// of it is asked to update it.
337    pub update_constellation_claim: bool,
338
339    /// My constellation is perhaps out of date (based on the constellation claim,
340    /// which may, or may not be trustworthy), and may need to be updated.
341    pub update_my_constellation: bool,
342}
343
344impl ConstellationCompRes {
345    /// Whether the constellation and constellation claim are the same.
346    fn are_equal(&self) -> bool {
347        !self.update_constellation_claim && !self.update_my_constellation
348    }
349}
350
351/// A type that's used as the contents of a [`Signed`] message.
352pub trait Signable: serde::de::DeserializeOwned + serde::Serialize {
353    const CODE: MessageCode;
354
355    /// Include a [`CONSTELLATION_CLAIM`] in the [`Signed`] message of this type, binding the
356    /// signed message to the current [`Constellation`].
357    ///
358    /// [`Constellation`]: crate::servers::Constellation
359    const CONSTELLATION_BOUND: bool = false;
360}
361
362#[macro_export]
363macro_rules! having_message_code {
364    { $tn:ty, $mc:ident } => {
365        impl $crate::api::Signable for $tn {
366            const CODE: $crate::api::MessageCode = $crate::api::MessageCode::$mc;
367        }
368    };
369}
370/// Implements [`Signable`] for the given struct.  Use as follows:
371/// ```
372/// use pubhubs::api::Signable;
373///
374/// #[derive(serde::Serialize, serde::Deserialize)]
375/// struct T {};
376///
377/// pubhubs::api::having_message_code!{T, Example};
378///
379/// assert_eq!(T::CODE, pubhubs::api::MessageCode::Example);
380/// ```
381pub use having_message_code;
382
383#[cfg(test)]
384mod test {
385    use super::*;
386
387    #[test]
388    fn test_message_code() {
389        assert_eq!(
390            &format!("{}", MessageCode::PhcHubTicketReq),
391            "1 (PhcHubTicketReq)"
392        );
393
394        assert_eq!(MessageCode::PhcHubTicketReq.to_bytes(), [0, 1]);
395        assert_eq!(MessageCode::Example.to_bytes(), [255, 255]);
396    }
397}