pushicino 1.0.1

Web Push application server components
Documentation
use crate::rfc8188;
use crate::rfc8188::{EncodingKey, EncryptedPayload, InputKeyingMaterial, Keyid};
use base64::Engine;
use base64::prelude::BASE64_URL_SAFE_NO_PAD;
use elliptic_curve::rand_core::OsRng;
use elliptic_curve::sec1::ToEncodedPoint;
use hkdf::InvalidLength;
use p256::ecdh::EphemeralSecret;
use reqwest::{Client, RequestBuilder};
use serde::de::Error as DeError;
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use url::Url;

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Keys {
    pub auth: AuthenticationSecret,

    #[serde(deserialize_with = "decode_p256dh")]
    pub p256dh: p256::PublicKey,
}

/// An object containing all the information needed to send a Web Push message.
///
/// This object contains all the secrets necessary for encoding a push message payload. This struct
/// implements the [`Deserialize`] trait such that you can use it as a parameter directly within
/// an endpoint function for Axum (or other web frameworks). The struct matches the object returned
/// by the user agent's [`PushSubscription.toJSON()`] function.
///
/// [`PushSubscription.toJSON()`]: https://developer.mozilla.org/en-US/docs/Web/API/PushSubscription/toJSON
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Subscription {
    pub endpoint: Url,

    pub expiration_time: Option<u64>,

    pub keys: Keys,
}

impl EncodingKey for Subscription {
    type Error = Error;

    fn ikm(&self) -> Result<(Keyid, InputKeyingMaterial), Error> {
        let user_agent_public_key = self.keys.p256dh;
        let application_server_private_key = EphemeralSecret::random(&mut OsRng);
        let application_service_public_key = application_server_private_key.public_key();
        let ecdh_shared_secret =
            application_server_private_key.diffie_hellman(&user_agent_public_key);

        let mut key_info = Vec::new();
        key_info.extend_from_slice(b"WebPush: info\0");
        key_info.extend_from_slice(user_agent_public_key.to_encoded_point(false).as_bytes());
        key_info.extend_from_slice(
            application_service_public_key
                .to_encoded_point(false)
                .as_bytes(),
        );

        let mut ikm = [0u8; 32];

        ecdh_shared_secret
            .extract::<Sha256>(Some(self.keys.auth.as_ref()))
            .expand(&key_info, &mut ikm)
            .map_err(Error::InvalidOkmLength)?;

        Ok((
            Keyid(
                *application_service_public_key
                    .to_encoded_point(false)
                    .to_bytes()
                    .as_array()
                    .unwrap(),
            ),
            InputKeyingMaterial(ikm),
        ))
    }
}

impl Subscription {
    /// Create a new subscription to a Push Service.
    pub fn new(
        endpoint: Url,
        expiration_time: Option<u64>,
        authentication_secret: AuthenticationSecret,
        user_agent_public_key: p256::PublicKey,
    ) -> Self {
        Self {
            endpoint,
            expiration_time,
            keys: Keys {
                auth: authentication_secret,
                p256dh: user_agent_public_key,
            },
        }
    }

    pub(crate) fn prepare_request<'a, 'b>(
        &'a self,
        client: &Client,
        ttl: u64,
        message: impl Into<&'b [u8]>,
    ) -> Result<RequestBuilder, Error> {
        let encoded_payload = self.encode_message(message)?;
        Ok(client
            .post(self.endpoint.as_str())
            .header("TTL", ttl.to_string())
            .header("Content-Type", "application/octet-stream")
            .header("Content-Encoding", "aes128gcm")
            .body::<Vec<u8>>((&encoded_payload).into()))
    }

    /// Encode the given message such that it is suitable for sending across this subscription
    /// and will be decrypted by the client. The message contents will be in the appropriate
    /// encoding as specified within the subscription itself.
    fn encode_message<'a, 'b>(
        &'a self,
        message: impl Into<&'b [u8]>,
    ) -> Result<EncryptedPayload, Error> {
        rfc8188::encode(self, message.into()).map_err(Error::FailedToEncryptPayload)
    }
}

/// A sequence of 16 octets generated by the user agent to act as a symmetric key and will
/// be mixed into the key generation process as described in
/// [https://datatracker.ietf.org/doc/html/rfc8291#section-3.2](RFC 8291 Section 3.2).
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct AuthenticationSecret(
    #[serde(deserialize_with = "decode_authentication_secret")] pub [u8; 16],
);

impl AsRef<[u8]> for AuthenticationSecret {
    fn as_ref(&self) -> &[u8] {
        &self.0
    }
}

#[derive(Debug)]
pub enum Error {
    InvalidOkmLength(InvalidLength),
    InvalidCekLength(InvalidLength),
    InvalidNonceLength(InvalidLength),
    FailedToEncryptPayload(rfc8188::Error),
}

fn decode_authentication_secret<'de, D>(deserializer: D) -> Result<[u8; 16], D::Error>
where
    D: serde::Deserializer<'de>,
{
    let s = String::deserialize(deserializer)?;
    let bytes = BASE64_URL_SAFE_NO_PAD
        .decode(s)
        .map_err(|e| D::Error::custom(format!("Failed to decode base64: {}", e)))?;
    bytes
        .try_into()
        .map_err(|_| D::Error::custom("Invalid authentication secret length"))
}

fn decode_p256dh<'de, D>(deserializer: D) -> Result<p256::PublicKey, D::Error>
where
    D: serde::Deserializer<'de>,
{
    let s = String::deserialize(deserializer)?;
    p256::PublicKey::from_sec1_bytes(
        BASE64_URL_SAFE_NO_PAD
            .decode(s)
            .map_err(|e| D::Error::custom(format!("Failed to decode base64: {}", e)))?
            .as_slice(),
    )
    .map_err(|e| D::Error::custom(format!("Failed to parse p256dh: {}", e)))
}

#[cfg(test)]
mod tests {}