Skip to main content

pushicino/
subscription.rs

1use crate::rfc8188;
2use crate::rfc8188::{EncodingKey, EncryptedPayload, InputKeyingMaterial, Keyid};
3use base64::Engine;
4use base64::prelude::BASE64_URL_SAFE_NO_PAD;
5use elliptic_curve::rand_core::OsRng;
6use elliptic_curve::sec1::ToEncodedPoint;
7use hkdf::InvalidLength;
8use p256::ecdh::EphemeralSecret;
9use reqwest::{Client, RequestBuilder};
10use serde::de::Error as DeError;
11use serde::{Deserialize, Serialize};
12use sha2::Sha256;
13use url::Url;
14
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16pub struct Keys {
17    pub auth: AuthenticationSecret,
18
19    #[serde(deserialize_with = "decode_p256dh")]
20    pub p256dh: p256::PublicKey,
21}
22
23/// An object containing all the information needed to send a Web Push message.
24///
25/// This object contains all the secrets necessary for encoding a push message payload. This struct
26/// implements the [`Deserialize`] trait such that you can use it as a parameter directly within
27/// an endpoint function for Axum (or other web frameworks). The struct matches the object returned
28/// by the user agent's [`PushSubscription.toJSON()`] function.
29///
30/// [`PushSubscription.toJSON()`]: https://developer.mozilla.org/en-US/docs/Web/API/PushSubscription/toJSON
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub struct Subscription {
33    pub endpoint: Url,
34
35    pub expiration_time: Option<u64>,
36
37    pub keys: Keys,
38}
39
40impl EncodingKey for Subscription {
41    type Error = Error;
42
43    fn ikm(&self) -> Result<(Keyid, InputKeyingMaterial), Error> {
44        let user_agent_public_key = self.keys.p256dh;
45        let application_server_private_key = EphemeralSecret::random(&mut OsRng);
46        let application_service_public_key = application_server_private_key.public_key();
47        let ecdh_shared_secret =
48            application_server_private_key.diffie_hellman(&user_agent_public_key);
49
50        let mut key_info = Vec::new();
51        key_info.extend_from_slice(b"WebPush: info\0");
52        key_info.extend_from_slice(user_agent_public_key.to_encoded_point(false).as_bytes());
53        key_info.extend_from_slice(
54            application_service_public_key
55                .to_encoded_point(false)
56                .as_bytes(),
57        );
58
59        let mut ikm = [0u8; 32];
60
61        ecdh_shared_secret
62            .extract::<Sha256>(Some(self.keys.auth.as_ref()))
63            .expand(&key_info, &mut ikm)
64            .map_err(Error::InvalidOkmLength)?;
65
66        Ok((
67            Keyid(
68                *application_service_public_key
69                    .to_encoded_point(false)
70                    .to_bytes()
71                    .as_array()
72                    .unwrap(),
73            ),
74            InputKeyingMaterial(ikm),
75        ))
76    }
77}
78
79impl Subscription {
80    /// Create a new subscription to a Push Service.
81    pub fn new(
82        endpoint: Url,
83        expiration_time: Option<u64>,
84        authentication_secret: AuthenticationSecret,
85        user_agent_public_key: p256::PublicKey,
86    ) -> Self {
87        Self {
88            endpoint,
89            expiration_time,
90            keys: Keys {
91                auth: authentication_secret,
92                p256dh: user_agent_public_key,
93            },
94        }
95    }
96
97    pub(crate) fn prepare_request<'a, 'b>(
98        &'a self,
99        client: &Client,
100        ttl: u64,
101        message: impl Into<&'b [u8]>,
102    ) -> Result<RequestBuilder, Error> {
103        let encoded_payload = self.encode_message(message)?;
104        Ok(client
105            .post(self.endpoint.as_str())
106            .header("TTL", ttl.to_string())
107            .header("Content-Type", "application/octet-stream")
108            .header("Content-Encoding", "aes128gcm")
109            .body::<Vec<u8>>((&encoded_payload).into()))
110    }
111
112    /// Encode the given message such that it is suitable for sending across this subscription
113    /// and will be decrypted by the client. The message contents will be in the appropriate
114    /// encoding as specified within the subscription itself.
115    fn encode_message<'a, 'b>(
116        &'a self,
117        message: impl Into<&'b [u8]>,
118    ) -> Result<EncryptedPayload, Error> {
119        rfc8188::encode(self, message.into()).map_err(Error::FailedToEncryptPayload)
120    }
121}
122
123/// A sequence of 16 octets generated by the user agent to act as a symmetric key and will
124/// be mixed into the key generation process as described in
125/// [https://datatracker.ietf.org/doc/html/rfc8291#section-3.2](RFC 8291 Section 3.2).
126#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
127pub struct AuthenticationSecret(
128    #[serde(deserialize_with = "decode_authentication_secret")] pub [u8; 16],
129);
130
131impl AsRef<[u8]> for AuthenticationSecret {
132    fn as_ref(&self) -> &[u8] {
133        &self.0
134    }
135}
136
137#[derive(Debug)]
138pub enum Error {
139    InvalidOkmLength(InvalidLength),
140    InvalidCekLength(InvalidLength),
141    InvalidNonceLength(InvalidLength),
142    FailedToEncryptPayload(rfc8188::Error),
143}
144
145fn decode_authentication_secret<'de, D>(deserializer: D) -> Result<[u8; 16], D::Error>
146where
147    D: serde::Deserializer<'de>,
148{
149    let s = String::deserialize(deserializer)?;
150    let bytes = BASE64_URL_SAFE_NO_PAD
151        .decode(s)
152        .map_err(|e| D::Error::custom(format!("Failed to decode base64: {}", e)))?;
153    bytes
154        .try_into()
155        .map_err(|_| D::Error::custom("Invalid authentication secret length"))
156}
157
158fn decode_p256dh<'de, D>(deserializer: D) -> Result<p256::PublicKey, D::Error>
159where
160    D: serde::Deserializer<'de>,
161{
162    let s = String::deserialize(deserializer)?;
163    p256::PublicKey::from_sec1_bytes(
164        BASE64_URL_SAFE_NO_PAD
165            .decode(s)
166            .map_err(|e| D::Error::custom(format!("Failed to decode base64: {}", e)))?
167            .as_slice(),
168    )
169    .map_err(|e| D::Error::custom(format!("Failed to parse p256dh: {}", e)))
170}
171
172#[cfg(test)]
173mod tests {}