use std::time::Duration;
use base64::Engine as _;
use base64::engine::general_purpose::{URL_SAFE, URL_SAFE_NO_PAD};
use cratefield_core::Clock;
use cratefield_push_auth::{CachedToken, Es256Signer};
use serde_json::json;
#[allow(clippy::duration_suboptimal_units)] pub const TOKEN_EXPIRY: Duration = Duration::from_secs(12 * 3_600);
#[allow(clippy::duration_suboptimal_units)] pub const TOKEN_TTL: Duration = Duration::from_secs(11 * 3_600);
#[derive(Debug, thiserror::Error)]
pub enum VapidError {
#[error("invalid VAPID private key: {0}")]
Key(String),
#[error("VAPID subject must be a mailto: or https: URI, got {0:?}")]
Subject(String),
#[error("push endpoint has no usable origin: {0}")]
Endpoint(String),
}
pub struct VapidKeys {
pub private_key: String,
pub subject: String,
}
impl VapidKeys {
pub fn new(private_key: impl Into<String>, subject: impl Into<String>) -> Self {
Self {
private_key: private_key.into(),
subject: subject.into(),
}
}
}
impl std::fmt::Debug for VapidKeys {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("VapidKeys")
.field("private_key", &"[redacted]")
.field("subject", &self.subject)
.finish()
}
}
fn parse_private_key(private_key: &str) -> Result<Es256Signer, VapidError> {
let trimmed = private_key.trim();
if trimmed.contains("-----BEGIN") {
return Es256Signer::from_pkcs8_pem(trimmed)
.map_err(|err| VapidError::Key(err.to_string()));
}
let bytes = URL_SAFE_NO_PAD
.decode(trimmed)
.or_else(|_| URL_SAFE.decode(trimmed))
.map_err(|_| VapidError::Key("not a PKCS#8 PEM and not base64url".to_owned()))?;
let scalar: [u8; 32] = bytes.as_slice().try_into().map_err(|_| {
VapidError::Key(format!(
"a raw P-256 private key is 32 bytes, got {}",
bytes.len()
))
})?;
Es256Signer::from_scalar(&scalar).map_err(|err| VapidError::Key(err.to_string()))
}
pub fn origin_of(endpoint: &str) -> Result<String, VapidError> {
cratefield_core::origin_of(endpoint).map_err(|err| VapidError::Endpoint(err.to_string()))
}
pub struct Vapid {
signer: Es256Signer,
subject: String,
public_key: String,
tokens: CachedToken<String>,
}
impl Vapid {
pub fn new(keys: VapidKeys) -> Result<Self, VapidError> {
let lower = keys.subject.to_ascii_lowercase();
if !(lower.starts_with("mailto:") || lower.starts_with("https://")) {
return Err(VapidError::Subject(keys.subject));
}
let signer = parse_private_key(&keys.private_key)?;
let public_key = URL_SAFE_NO_PAD.encode(signer.public_key_uncompressed());
Ok(Self {
signer,
subject: keys.subject,
public_key,
tokens: CachedToken::new(TOKEN_TTL),
})
}
#[must_use]
pub fn public_key(&self) -> &str {
&self.public_key
}
#[allow(clippy::ptr_arg)]
pub fn authorization(&self, clock: &dyn Clock, origin: &String) -> String {
let expiry = i64::try_from(TOKEN_EXPIRY.as_secs()).unwrap_or(i64::MAX);
let token = self.tokens.get_or_mint(clock, origin, |now_unix| {
self.signer.sign_jwt(
&json!({ "typ": "JWT", "alg": "ES256" }),
&json!({
"aud": origin,
"exp": now_unix.saturating_add(expiry),
"sub": self.subject,
}),
)
});
format!("vapid t={token}, k={}", self.public_key)
}
#[allow(clippy::ptr_arg)]
pub fn invalidate(&self, origin: &String) {
self.tokens.invalidate(origin);
}
}
impl std::fmt::Debug for Vapid {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Vapid")
.field("signer", &self.signer)
.field("public_key", &self.public_key)
.field("subject", &self.subject)
.field("tokens", &self.tokens)
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
const TEST_PEM: &str = "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgcXMgRpW+eLn7ZvCx\nIuTdd8csWMZ69azlRzS0dy2FN6GhRANCAATJ6GazR2lhWcC3JYsazLR0uWOyDKrC\nmeP4HPWghRmfoa4z3Ux7mG3Ylz+auRaBukKGicSdSvVG+jGeQwr3fNag\n-----END PRIVATE KEY-----";
const TEST_SCALAR: &str = "cXMgRpW-eLn7ZvCxIuTdd8csWMZ69azlRzS0dy2FN6E";
fn vapid(private_key: &str, subject: &str) -> Result<Vapid, VapidError> {
Vapid::new(VapidKeys::new(private_key, subject))
}
#[test]
fn both_private_key_forms_give_the_same_key() {
let pem = vapid(TEST_PEM, "mailto:ops@example.test").unwrap();
let raw = vapid(TEST_SCALAR, "mailto:ops@example.test").unwrap();
assert_eq!(pem.public_key(), raw.public_key());
let padded = URL_SAFE.encode(URL_SAFE_NO_PAD.decode(TEST_SCALAR).unwrap());
assert_eq!(
vapid(&padded, "mailto:ops@example.test")
.unwrap()
.public_key(),
pem.public_key()
);
}
#[test]
fn the_configured_keys_never_print_the_private_one() {
let printed = format!("{:?}", VapidKeys::new(TEST_PEM, "mailto:ops@example.test"));
assert!(!printed.contains("MIGHAgEA"), "{printed}");
assert!(printed.contains("[redacted]"), "{printed}");
assert!(printed.contains("mailto:ops@example.test"), "{printed}");
}
#[test]
fn a_key_that_is_neither_form_is_refused() {
for bad in [
"not a key",
"-----BEGIN PRIVATE KEY-----\nnope\n-----END PRIVATE KEY-----",
"cXMgRpW-eLn7ZvCxIuTdd8csWMZ69azl",
] {
let error = vapid(bad, "mailto:ops@example.test").unwrap_err();
assert!(matches!(error, VapidError::Key(_)), "{bad}: {error}");
}
}
#[test]
fn the_subject_must_be_a_contact_uri() {
for bad in ["ops@example.test", "http://example.test", ""] {
let error = vapid(TEST_PEM, bad).unwrap_err();
assert!(matches!(error, VapidError::Subject(_)), "{bad}: {error}");
}
assert!(vapid(TEST_PEM, "mailto:ops@example.test").is_ok());
assert!(vapid(TEST_PEM, "https://example.test/contact").is_ok());
assert!(vapid(TEST_PEM, "MAILTO:ops@example.test").is_ok());
}
#[test]
fn the_audience_is_the_origin_and_never_the_path() {
assert_eq!(
origin_of("https://updates.push.services.mozilla.com/wpush/v2/gAAAAA_long_token")
.unwrap(),
"https://updates.push.services.mozilla.com"
);
assert_eq!(
origin_of("https://fcm.googleapis.com/fcm/send/abc:DEF-ghi").unwrap(),
"https://fcm.googleapis.com"
);
assert_eq!(
origin_of("http://ntfy.local:8080/up1234abcd?up=1").unwrap(),
"http://ntfy.local:8080"
);
assert_eq!(
origin_of("https://push.example:443/x").unwrap(),
"https://push.example"
);
assert_eq!(
origin_of("http://push.example:80/x").unwrap(),
"http://push.example"
);
assert_eq!(
origin_of("https://push.example/x?y=1#z").unwrap(),
"https://push.example"
);
}
#[test]
fn an_endpoint_without_an_origin_is_refused() {
for bad in [
"/relative/path",
"not a url",
"ftp://push.example/x",
"data:,x",
] {
let error = origin_of(bad).unwrap_err();
assert!(matches!(error, VapidError::Endpoint(_)), "{bad}: {error}");
}
}
}