stremio-addon-core 0.1.4

Reusable Rust core for authenticated Stremio addon servers
Documentation
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use hmac::{Hmac, Mac};
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use thiserror::Error;
use time::{Duration, OffsetDateTime};

type HmacSha256 = Hmac<Sha256>;

#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SignedPlayback {
    pub ident: String,
    pub expires_at: i64,
}

impl SignedPlayback {
    pub fn new(ident: impl Into<String>, ttl_seconds: i64) -> Self {
        Self {
            ident: ident.into(),
            expires_at: (OffsetDateTime::now_utc() + Duration::seconds(ttl_seconds))
                .unix_timestamp(),
        }
    }

    pub fn sign(&self, key: &[u8]) -> Result<String, SigningError> {
        let payload = serde_json::to_vec(self).map_err(SigningError::Serialize)?;
        let payload = URL_SAFE_NO_PAD.encode(payload);
        let signature = sign_bytes(key, payload.as_bytes())?;
        Ok(format!("{payload}.{signature}"))
    }

    pub fn verify(token: &str, key: &[u8]) -> Result<Self, SigningError> {
        let (payload, signature) = token.split_once('.').ok_or(SigningError::MalformedToken)?;

        verify_signature(key, payload.as_bytes(), signature)?;

        let payload_bytes = URL_SAFE_NO_PAD
            .decode(payload)
            .map_err(|_| SigningError::MalformedToken)?;
        let decoded: SignedPlayback =
            serde_json::from_slice(&payload_bytes).map_err(|_| SigningError::MalformedToken)?;

        if decoded.expires_at <= OffsetDateTime::now_utc().unix_timestamp() {
            return Err(SigningError::Expired);
        }

        Ok(decoded)
    }
}

#[derive(Debug, Error)]
pub enum SigningError {
    #[error("failed to serialize signed playback payload: {0}")]
    Serialize(serde_json::Error),
    #[error("invalid signing key")]
    InvalidKey,
    #[error("malformed signed playback token")]
    MalformedToken,
    #[error("invalid signed playback signature")]
    InvalidSignature,
    #[error("signed playback token expired")]
    Expired,
}

fn sign_bytes(key: &[u8], bytes: &[u8]) -> Result<String, SigningError> {
    let mut mac = HmacSha256::new_from_slice(key).map_err(|_| SigningError::InvalidKey)?;
    mac.update(bytes);
    Ok(URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()))
}

fn verify_signature(key: &[u8], bytes: &[u8], signature: &str) -> Result<(), SigningError> {
    let signature = URL_SAFE_NO_PAD
        .decode(signature)
        .map_err(|_| SigningError::MalformedToken)?;
    let mut mac = HmacSha256::new_from_slice(key).map_err(|_| SigningError::InvalidKey)?;
    mac.update(bytes);
    mac.verify_slice(&signature)
        .map_err(|_| SigningError::InvalidSignature)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn signed_payload_round_trips() {
        let payload = SignedPlayback::new("abc123", 60);
        let token = payload.sign(b"secret").unwrap();
        let verified = SignedPlayback::verify(&token, b"secret").unwrap();

        assert_eq!(verified.ident, "abc123");
    }

    #[test]
    fn rejects_wrong_key() {
        let payload = SignedPlayback::new("abc123", 60);
        let token = payload.sign(b"secret").unwrap();
        assert!(matches!(
            SignedPlayback::verify(&token, b"wrong").unwrap_err(),
            SigningError::InvalidSignature
        ));
    }

    #[test]
    fn rejects_expired_payload() {
        let payload = SignedPlayback {
            ident: "abc123".to_string(),
            expires_at: OffsetDateTime::now_utc().unix_timestamp() - 1,
        };
        let token = payload.sign(b"secret").unwrap();
        assert!(matches!(
            SignedPlayback::verify(&token, b"secret").unwrap_err(),
            SigningError::Expired
        ));
    }

    #[test]
    fn token_does_not_include_raw_ident_text() {
        let payload = SignedPlayback::new("webshare-token-like-value", 60);
        let token = payload.sign(b"secret").unwrap();
        assert!(!token.contains("webshare-token-like-value"));
    }
}