AnyBot/relay/src/push.rs

167 lines
7.4 KiB
Rust
Raw Normal View History

// Web Push (RFC 8030 / 8291 / 8292): the phone (an installed home-screen page) gets a notification
// when a bot needs the person or finishes while they're away. The payload is encrypted for the
// phone (aes128gcm); the push service only sees ciphertext.
use base64::Engine;
use ring::rand::SecureRandom;
use ring::{aead, agreement, hkdf, signature};
use serde_json::{json, Value};
use std::path::Path;
fn b64u(b: &[u8]) -> String {
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b)
}
fn unb64u(s: &str) -> Option<Vec<u8>> {
base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(s.trim_end_matches('=')).ok()
}
struct Len(usize);
impl hkdf::KeyType for Len {
fn len(&self) -> usize {
self.0
}
}
fn hkdf(salt: &[u8], ikm: &[u8], info: &[u8], len: usize) -> Vec<u8> {
let prk = hkdf::Salt::new(hkdf::HKDF_SHA256, salt).extract(ikm);
let mut out = vec![0u8; len];
prk.expand(&[info], Len(len)).expect("hkdf").fill(&mut out).expect("hkdf");
out
}
/// The server's signing key (made once, kept in the data folder).
pub struct Vapid {
pair: signature::EcdsaKeyPair,
pub public_b64: String,
}
impl Vapid {
pub fn load(dir: &Path) -> Vapid {
let rng = ring::rand::SystemRandom::new();
let path = dir.join("vapid.pk8");
let pk8 = std::fs::read(&path).ok().unwrap_or_else(|| {
let doc = signature::EcdsaKeyPair::generate_pkcs8(&signature::ECDSA_P256_SHA256_FIXED_SIGNING, &rng).expect("key");
let _ = std::fs::write(&path, doc.as_ref());
doc.as_ref().to_vec()
});
let pair = signature::EcdsaKeyPair::from_pkcs8(&signature::ECDSA_P256_SHA256_FIXED_SIGNING, &pk8, &rng).expect("vapid key");
let public_b64 = b64u(signature::KeyPair::public_key(&pair).as_ref());
Vapid { pair, public_b64 }
}
/// `Authorization: vapid t=…, k=…` for one push service.
fn header(&self, endpoint: &str, contact: &str) -> String {
let aud = endpoint.splitn(4, '/').take(3).collect::<Vec<_>>().join("/");
let exp = chrono::Utc::now().timestamp() + 12 * 3600;
let head = b64u(br#"{"typ":"JWT","alg":"ES256"}"#);
let claims = b64u(json!({"aud": aud, "exp": exp, "sub": contact}).to_string().as_bytes());
let msg = format!("{head}.{claims}");
let sig = self.pair.sign(&ring::rand::SystemRandom::new(), msg.as_bytes()).expect("sign");
format!("vapid t={msg}.{}, k={}", b64u(sig.as_ref()), self.public_b64)
}
}
/// Encrypt `payload` for one subscription (RFC 8291, one record).
pub fn encrypt(p256dh: &str, auth: &str, payload: &[u8]) -> Option<Vec<u8>> {
let rng = ring::rand::SystemRandom::new();
let ua_public = unb64u(p256dh)?;
let auth_secret = unb64u(auth)?;
let eph = agreement::EphemeralPrivateKey::generate(&agreement::ECDH_P256, &rng).ok()?;
let as_public = eph.compute_public_key().ok()?.as_ref().to_vec();
let ecdh = agreement::agree_ephemeral(eph, &agreement::UnparsedPublicKey::new(&agreement::ECDH_P256, &ua_public), |s| s.to_vec()).ok()?;
let mut key_info = b"WebPush: info\0".to_vec();
key_info.extend(&ua_public);
key_info.extend(&as_public);
let ikm = hkdf(&auth_secret, &ecdh, &key_info, 32);
let mut salt = [0u8; 16];
rng.fill(&mut salt).ok()?;
let cek = hkdf(&salt, &ikm, b"Content-Encoding: aes128gcm\0", 16);
let nonce = hkdf(&salt, &ikm, b"Content-Encoding: nonce\0", 12);
let key = aead::LessSafeKey::new(aead::UnboundKey::new(&aead::AES_128_GCM, &cek).ok()?);
let mut data = payload.to_vec();
data.push(2); // last record
key.seal_in_place_append_tag(aead::Nonce::try_assume_unique_for_key(&nonce).ok()?, aead::Aad::empty(), &mut data).ok()?;
let mut body = salt.to_vec();
body.extend(4096u32.to_be_bytes());
body.push(as_public.len() as u8);
body.extend(&as_public);
body.extend(data);
Some(body)
}
/// Send one notification. Err(true) = the subscription is gone (forget it).
pub fn send(v: &Vapid, sub: &Value, payload: &Value, contact: &str) -> Result<(), bool> {
let endpoint = sub["endpoint"].as_str().ok_or(true)?;
if !endpoint.starts_with("https://") {
return Err(true);
}
let body = encrypt(sub["keys"]["p256dh"].as_str().unwrap_or(""), sub["keys"]["auth"].as_str().unwrap_or(""), payload.to_string().as_bytes()).ok_or(true)?;
let agent: ureq::Agent = ureq::Agent::config_builder().timeout_global(Some(std::time::Duration::from_secs(15))).http_status_as_error(false).build().into();
let r = agent
.post(endpoint)
.header("Authorization", &v.header(endpoint, contact))
.header("Content-Encoding", "aes128gcm")
.header("Content-Type", "application/octet-stream")
.header("TTL", "86400")
.header("Urgency", "high")
.send(&body[..]);
match r {
Ok(resp) => match resp.status().as_u16() {
200..=299 => Ok(()),
404 | 410 => Err(true),
_ => Err(false),
},
Err(_) => Err(false),
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Decrypt as a browser would (RFC 8291), with the browser's private key.
#[test]
fn payload_decrypts_on_the_phone_side() {
let rng = ring::rand::SystemRandom::new();
let ua = agreement::EphemeralPrivateKey::generate(&agreement::ECDH_P256, &rng).unwrap();
let ua_pub = ua.compute_public_key().unwrap().as_ref().to_vec();
let mut auth = [0u8; 16];
rng.fill(&mut auth).unwrap();
let body = encrypt(&b64u(&ua_pub), &b64u(&auth), "需要你允許".as_bytes()).unwrap();
let (salt, rest) = body.split_at(16);
let idlen = rest[4] as usize;
let as_pub = &rest[5..5 + idlen];
let ct = &rest[5 + idlen..];
let ecdh = agreement::agree_ephemeral(ua, &agreement::UnparsedPublicKey::new(&agreement::ECDH_P256, as_pub), |s| s.to_vec()).unwrap();
let mut info = b"WebPush: info\0".to_vec();
info.extend(&ua_pub);
info.extend(as_pub);
let ikm = hkdf(&auth, &ecdh, &info, 32);
let cek = hkdf(salt, &ikm, b"Content-Encoding: aes128gcm\0", 16);
let nonce = hkdf(salt, &ikm, b"Content-Encoding: nonce\0", 12);
let key = aead::LessSafeKey::new(aead::UnboundKey::new(&aead::AES_128_GCM, &cek).unwrap());
let mut data = ct.to_vec();
let plain = key.open_in_place(aead::Nonce::try_assume_unique_for_key(&nonce).unwrap(), aead::Aad::empty(), &mut data).unwrap();
assert_eq!(&plain[..plain.len() - 1], "需要你允許".as_bytes());
assert_eq!(*plain.last().unwrap(), 2);
}
#[test]
fn vapid_token_verifies() {
let dir = std::env::temp_dir().join(format!("anybot-vapid-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let v = Vapid::load(&dir);
let h = v.header("https://web.push.apple.com/abc/def", "mailto:x@example.com");
let t = h.strip_prefix("vapid t=").unwrap().split(", k=").next().unwrap();
let (msg, sig) = t.rsplit_once('.').unwrap();
let pubkey = unb64u(&v.public_b64).unwrap();
signature::UnparsedPublicKey::new(&signature::ECDSA_P256_SHA256_FIXED, &pubkey).verify(msg.as_bytes(), &unb64u(sig).unwrap()).unwrap();
let claims: Value = serde_json::from_slice(&unb64u(msg.split('.').nth(1).unwrap()).unwrap()).unwrap();
assert_eq!(claims["aud"], "https://web.push.apple.com");
// Same key after a restart.
assert_eq!(Vapid::load(&dir).public_b64, v.public_b64);
let _ = std::fs::remove_dir_all(&dir);
}
}