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
249    /// Only used as an example in a doctest
250    Example = 65535,
251}
252
253impl MessageCode {
254    /// Returns big endian bytes representation of this message code.
255    pub fn to_bytes(&self) -> [u8; 2] {
256        // TODO: surely this should be achievable without serde_json somehow.
257        u16::deserialize(serde_json::to_value(self).unwrap().into_deserializer())
258            .unwrap()
259            .to_be_bytes()
260    }
261}
262
263impl std::fmt::Display for MessageCode {
264    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
265        self.serialize(&mut *f)?; // Don't ask me why self.serialize(f) does not work..
266        write!(f, " ({:?})", &self)
267    }
268}
269
270/// The claim name used to store the [`MessageCode`].
271pub const MESSAGE_CODE_CLAIM: &str = "ph-mc";
272
273/// The claim name used to store the [`Constellation`] [`Id`].
274///
275/// [`Id`]: id::Id
276/// [`Constellation`]: crate::servers::Constellation
277pub const CONSTELLATION_CLAIM: &str = "ph-ci";
278
279/// Contents of the [`CONSTELLATION_CLAIM`]
280#[derive(Serialize, Deserialize, Debug, Clone)]
281pub struct ConstellationClaim {
282    /// [`Constellation::id`]
283    #[serde(rename = "i")]
284    id: id::Id,
285
286    /// [`Constellation::created_at`]
287    #[serde(rename = "c")]
288    created_at: NumericDate,
289}
290
291impl From<&Constellation> for ConstellationClaim {
292    fn from(c: &Constellation) -> Self {
293        Self {
294            id: c.id,
295            created_at: c.created_at,
296        }
297    }
298}
299
300impl ConstellationClaim {
301    /// Compare this constellation claim with the constellation known to me
302    pub fn compare(self, my_constellation: &Constellation) -> ConstellationCompRes {
303        match self.created_at.cmp(&my_constellation.created_at) {
304            std::cmp::Ordering::Less => ConstellationCompRes {
305                update_my_constellation: false,
306                update_constellation_claim: true,
307            },
308            std::cmp::Ordering::Greater => ConstellationCompRes {
309                update_my_constellation: true,
310                update_constellation_claim: false,
311            },
312            std::cmp::Ordering::Equal => {
313                if my_constellation.id == self.id {
314                    ConstellationCompRes {
315                        update_my_constellation: false,
316                        update_constellation_claim: false,
317                    }
318                } else {
319                    ConstellationCompRes {
320                        update_my_constellation: true,
321                        update_constellation_claim: true,
322                    }
323                }
324            }
325        }
326    }
327}
328
329/// Result of [`ConstellationClaim::compare`].
330#[derive(Serialize, Deserialize, Debug, Clone, Copy)]
331pub struct ConstellationCompRes {
332    /// The constellation claim is perhaps out of date, and it's best if the originator
333    /// of it is asked to update it.
334    pub update_constellation_claim: bool,
335
336    /// My constellation is perhaps out of date (based on the constellation claim,
337    /// which may, or may not be trustworthy), and may need to be updated.
338    pub update_my_constellation: bool,
339}
340
341impl ConstellationCompRes {
342    /// Whether the constellation and constellation claim are the same.
343    fn are_equal(&self) -> bool {
344        !self.update_constellation_claim && !self.update_my_constellation
345    }
346}
347
348/// A type that's used as the contents of a [`Signed`] message.
349pub trait Signable: serde::de::DeserializeOwned + serde::Serialize {
350    const CODE: MessageCode;
351
352    /// Include a [`CONSTELLATION_CLAIM`] in the [`Signed`] message of this type, binding the
353    /// signed message to the current [`Constellation`].
354    ///
355    /// [`Constellation`]: crate::servers::Constellation
356    const CONSTELLATION_BOUND: bool = false;
357}
358
359#[macro_export]
360macro_rules! having_message_code {
361    { $tn:ty, $mc:ident } => {
362        impl $crate::api::Signable for $tn {
363            const CODE: $crate::api::MessageCode = $crate::api::MessageCode::$mc;
364        }
365    };
366}
367/// Implements [`Signable`] for the given struct.  Use as follows:
368/// ```
369/// use pubhubs::api::Signable;
370///
371/// #[derive(serde::Serialize, serde::Deserialize)]
372/// struct T {};
373///
374/// pubhubs::api::having_message_code!{T, Example};
375///
376/// assert_eq!(T::CODE, pubhubs::api::MessageCode::Example);
377/// ```
378pub use having_message_code;
379
380#[cfg(test)]
381mod test {
382    use super::*;
383
384    #[test]
385    fn test_message_code() {
386        assert_eq!(
387            &format!("{}", MessageCode::PhcHubTicketReq),
388            "1 (PhcHubTicketReq)"
389        );
390
391        assert_eq!(MessageCode::PhcHubTicketReq.to_bytes(), [0, 1]);
392        assert_eq!(MessageCode::Example.to_bytes(), [255, 255]);
393    }
394}