mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-16 19:59:56 +02:00
La façade sert la clé VAPID et les (dés)abonnements, et pousse elle-même les notifications « réponse prête » quand LOKI_PUSH=rust (expérimental ; le Go reste le défaut) : chiffrement RFC 8291 (aes128gcm, enregistrement complété à 4096 octets comme webpush-go), signature VAPID RFC 8292 (JWT ES256), abonnements purgés sur 404/410, par ring déjà présent via rustls. Mêmes clés en base que le Go. Côté Go : facade_push.go remet les routes du tunnel à la façade, et sendPushToAll (fin de tour, fin de tâche) lui délègue l'envoi. Parité : parity_push_test.go tire du Go les durées et onze appels de route avec la liste des abonnements en base, rejoués par tests/push.rs ; le chiffrement reproduit l'exemple de la RFC 8291 à l'octet près, et un envoi de bout en bout est déchiffré côté navigateur. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
317 lines
12 KiB
Rust
317 lines
12 KiB
Rust
//! Lot 12 : notifications Web Push tenues par la façade (`LOKI_PUSH=rust`).
|
|
//!
|
|
//! - le journal tiré du Go (`internal/loki/parity_push_test.go` →
|
|
//! `tests/parity_push/push.json`) : durées, clé, abonnements — la liste en
|
|
//! base comparée à l'octet près ;
|
|
//! - le chiffrement contre l'exemple de la RFC 8291 (annexe A) ;
|
|
//! - un envoi de bout en bout vers un faux service de push : en-têtes,
|
|
//! signature VAPID vérifiée, corps déchiffré côté navigateur, abonnement
|
|
//! purgé sur 410.
|
|
#![cfg(feature = "tls")]
|
|
|
|
mod common;
|
|
|
|
use std::convert::Infallible;
|
|
use std::net::SocketAddr;
|
|
use std::sync::{Arc, Mutex};
|
|
use std::time::Duration;
|
|
|
|
use base64::Engine;
|
|
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
|
use bytes::Bytes;
|
|
use common::fakego;
|
|
use http_body_util::{BodyExt, Full};
|
|
use hyper::body::Incoming;
|
|
use hyper::{Request, Response, StatusCode, service::service_fn};
|
|
use hyper_util::rt::TokioIo;
|
|
use loki_rs::push;
|
|
use ring::{aead, agreement, hkdf, signature};
|
|
use serde_json::Value;
|
|
|
|
fn fixture() -> Value {
|
|
serde_json::from_str(include_str!("parity_push/push.json")).expect("journal")
|
|
}
|
|
|
|
/// `time.ParseDuration`, réduit à ce que le journal emploie.
|
|
fn go_ms(s: &str) -> i64 {
|
|
let (neg, mut s) = match s.strip_prefix('-') {
|
|
Some(r) => (true, r),
|
|
None => (false, s),
|
|
};
|
|
let mut ms = 0f64;
|
|
while !s.is_empty() {
|
|
let n = s.find(|c: char| !c.is_ascii_digit() && c != '.').unwrap();
|
|
let v: f64 = s[..n].parse().unwrap();
|
|
let rest = &s[n..];
|
|
let (mul, len) = if rest.starts_with("ms") {
|
|
(1.0, 2)
|
|
} else if rest.starts_with('h') {
|
|
(3_600_000.0, 1)
|
|
} else if rest.starts_with('m') {
|
|
(60_000.0, 1)
|
|
} else {
|
|
(1000.0, 1)
|
|
};
|
|
ms += v * mul;
|
|
s = &rest[len..];
|
|
}
|
|
if neg { -(ms as i64) } else { ms as i64 }
|
|
}
|
|
|
|
#[test]
|
|
fn parite_des_durees() {
|
|
for (d, want) in fixture()["durations"].as_object().unwrap() {
|
|
assert_eq!(push::fmt_dur_fr(go_ms(d)), want.as_str().unwrap(), "{d}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn exemple_de_la_rfc_8291() {
|
|
let b = |s: &str| URL_SAFE_NO_PAD.decode(s).unwrap();
|
|
let body = push::encrypt_with(
|
|
b"When I grow up, I want to be a watermelon",
|
|
&b("BTBZMqHH6r4Tts7J_aSIgg"),
|
|
&b("BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4"),
|
|
b("DGv6ra1nlYgDCS1FRnbzlw").as_slice().try_into().unwrap(),
|
|
&b("BP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A8"),
|
|
&b("kyrL1jIIOHEzg3sM2ZWRHDRB62YACZhhSlknJ672kSs"),
|
|
4096,
|
|
false,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
URL_SAFE_NO_PAD.encode(body),
|
|
"DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A_yl95bQpu6cVPTpK4Mqgkf1CXztLVBSt2Ks3oZwbuwXPXLWyouBWLVWGNWQexSgSxsj_Qulcy4a-fN"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn parite_des_routes() {
|
|
let fx = fixture();
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let opts = fakego::Opts {
|
|
push: Some(Arc::new(push::Push::new())),
|
|
..Default::default()
|
|
};
|
|
let (facade, go, _stop, _) = fakego::start_with(tmp.path().to_path_buf(), opts).await;
|
|
let key_re = regex::Regex::new(r#""key":"[A-Za-z0-9_-]{87}""#).unwrap();
|
|
let mut first_key = String::new();
|
|
for (i, c) in fx["calls"].as_array().unwrap().iter().enumerate() {
|
|
let (m, p) = (c["method"].as_str().unwrap(), c["path"].as_str().unwrap());
|
|
let body = c.get("body").and_then(Value::as_str).unwrap_or("");
|
|
let (code, b) = fakego::send(facade, m, p, &[], body).await;
|
|
let txt = String::from_utf8_lossy(&b).into_owned();
|
|
if let Some(k) = key_re.find(&txt) {
|
|
// Clé stable d'un appel à l'autre.
|
|
if first_key.is_empty() {
|
|
first_key = k.as_str().to_string();
|
|
}
|
|
assert_eq!(k.as_str(), first_key, "clé VAPID changée");
|
|
}
|
|
let got: Value =
|
|
serde_json::from_str(&key_re.replace_all(&txt, r#""key":"{{CLE}}""#)).unwrap();
|
|
let ctx = format!("appel {i} : {m} {p} {body}");
|
|
assert_eq!(code.as_u16() as u64, c["status"].as_u64().unwrap(), "{ctx}");
|
|
assert_eq!(got, c["out"], "{ctx}");
|
|
assert_eq!(
|
|
go.get("state", "push_subs").unwrap_or_default(),
|
|
c["subs"].as_str().unwrap(),
|
|
"{ctx} (abonnements en base)"
|
|
);
|
|
}
|
|
assert_eq!(go.get("state", "push_vapid_priv").unwrap().len(), 43);
|
|
}
|
|
|
|
/// Faux service de push : répond 201 au premier endpoint, 410 au second, et
|
|
/// garde chaque requête (chemin, en-têtes, corps).
|
|
#[derive(Default)]
|
|
struct FakePush {
|
|
seen: Mutex<Vec<(String, hyper::HeaderMap, Bytes)>>,
|
|
}
|
|
|
|
async fn fake_push() -> (SocketAddr, Arc<FakePush>) {
|
|
let st = Arc::new(FakePush::default());
|
|
let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let addr = l.local_addr().unwrap();
|
|
let s2 = st.clone();
|
|
tokio::spawn(async move {
|
|
while let Ok((s, _)) = l.accept().await {
|
|
let st = s2.clone();
|
|
tokio::spawn(async move {
|
|
let svc = service_fn(move |req: Request<Incoming>| {
|
|
let st = st.clone();
|
|
async move {
|
|
let path = req.uri().path().to_string();
|
|
let (parts, body) = req.into_parts();
|
|
let b = body.collect().await.unwrap().to_bytes();
|
|
st.seen
|
|
.lock()
|
|
.unwrap()
|
|
.push((path.clone(), parts.headers, b));
|
|
let code = if path.ends_with("/perime") { 410 } else { 201 };
|
|
let mut r = Response::new(Full::new(Bytes::from_static(b"")));
|
|
*r.status_mut() = StatusCode::from_u16(code).unwrap();
|
|
Ok::<_, Infallible>(r)
|
|
}
|
|
});
|
|
let _ = hyper::server::conn::http1::Builder::new()
|
|
.serve_connection(TokioIo::new(s), svc)
|
|
.await;
|
|
});
|
|
}
|
|
});
|
|
(addr, st)
|
|
}
|
|
|
|
struct Len(usize);
|
|
impl hkdf::KeyType for Len {
|
|
fn len(&self) -> usize {
|
|
self.0
|
|
}
|
|
}
|
|
|
|
fn expand(salt: &[u8], ikm: &[u8], info: &[u8], n: usize) -> Vec<u8> {
|
|
let prk = hkdf::Salt::new(hkdf::HKDF_SHA256, salt).extract(ikm);
|
|
let parts = [info];
|
|
let okm = prk.expand(&parts, Len(n)).unwrap();
|
|
let mut out = vec![0u8; n];
|
|
okm.fill(&mut out).unwrap();
|
|
out
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn envoi_de_bout_en_bout() {
|
|
let (addr, svc) = fake_push().await;
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let p = Arc::new(push::Push::new());
|
|
let opts = fakego::Opts {
|
|
push: Some(p.clone()),
|
|
..Default::default()
|
|
};
|
|
let (facade, go, _stop, _) = fakego::start_with(tmp.path().to_path_buf(), opts).await;
|
|
// Côté navigateur : une clé ECDH (à usage unique ici) et le secret d'auth.
|
|
let rng = ring::rand::SystemRandom::new();
|
|
let ua_priv = agreement::EphemeralPrivateKey::generate(&agreement::ECDH_P256, &rng).unwrap();
|
|
let ua_pub = ua_priv.compute_public_key().unwrap().as_ref().to_vec();
|
|
let auth = b"seize octets !!!".to_vec();
|
|
let subs = format!(
|
|
r#"[{{"endpoint":"http://{addr}/push/ok","keys":{{"auth":"{}","p256dh":"{}"}}}},{{"endpoint":"http://{addr}/push/perime","keys":{{"auth":"{}","p256dh":"{}"}}}}]"#,
|
|
URL_SAFE_NO_PAD.encode(&auth),
|
|
URL_SAFE_NO_PAD.encode(&ua_pub),
|
|
URL_SAFE_NO_PAD.encode(&auth),
|
|
URL_SAFE_NO_PAD.encode(&ua_pub),
|
|
);
|
|
go.set("state", "push_subs", &subs);
|
|
let (code, _) = fakego::send(
|
|
facade,
|
|
"POST",
|
|
push::INTERNAL_SEND,
|
|
&[("x-loki-internal", fakego::JETON)],
|
|
r#"{"title":"Loki","body":"Réponse prête · 42s"}"#,
|
|
)
|
|
.await;
|
|
assert_eq!(code, StatusCode::NO_CONTENT);
|
|
for _ in 0..200 {
|
|
if svc.seen.lock().unwrap().len() >= 2
|
|
&& go
|
|
.get("state", "push_subs")
|
|
.is_some_and(|s| !s.contains("perime"))
|
|
{
|
|
break;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
}
|
|
let seen = svc.seen.lock().unwrap().clone();
|
|
assert_eq!(seen.len(), 2);
|
|
// Abonnement périmé (410) purgé, l'autre gardé.
|
|
let left = go.get("state", "push_subs").unwrap();
|
|
assert!(
|
|
left.contains("/push/ok") && !left.contains("perime"),
|
|
"{left}"
|
|
);
|
|
|
|
let (path, h, body) = seen.iter().find(|s| s.0 == "/push/ok").unwrap().clone();
|
|
assert_eq!(path, "/push/ok");
|
|
assert_eq!(h["content-encoding"], "aes128gcm");
|
|
assert_eq!(h["content-type"], "application/octet-stream");
|
|
assert_eq!(h["ttl"], "120");
|
|
assert_eq!(h["urgency"], "high");
|
|
// VAPID : JWT ES256 signé par la clé rangée, audience = origine du service.
|
|
let vapid_pub = go.get("state", "push_vapid_pub").unwrap();
|
|
let auth_h = h["authorization"].to_str().unwrap().to_string();
|
|
let rest = auth_h.strip_prefix("vapid t=").unwrap();
|
|
let (jwt, k) = rest.split_once(", k=").unwrap();
|
|
assert_eq!(k, vapid_pub);
|
|
let mut parts = jwt.split('.');
|
|
let (h64, c64, s64) = (
|
|
parts.next().unwrap(),
|
|
parts.next().unwrap(),
|
|
parts.next().unwrap(),
|
|
);
|
|
let claims: Value = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(c64).unwrap()).unwrap();
|
|
assert_eq!(claims["aud"], format!("http://{addr}"));
|
|
assert_eq!(claims["sub"], "mailto:mail@nathaninline.com");
|
|
signature::UnparsedPublicKey::new(
|
|
&signature::ECDSA_P256_SHA256_FIXED,
|
|
URL_SAFE_NO_PAD.decode(&vapid_pub).unwrap(),
|
|
)
|
|
.verify(
|
|
format!("{h64}.{c64}").as_bytes(),
|
|
&URL_SAFE_NO_PAD.decode(s64).unwrap(),
|
|
)
|
|
.expect("signature VAPID invalide");
|
|
|
|
// Déchiffrement côté navigateur (RFC 8291).
|
|
assert_eq!(
|
|
body.len(),
|
|
push::RECORD_SIZE,
|
|
"enregistrement complété à 4096 octets"
|
|
);
|
|
let salt = &body[..16];
|
|
assert_eq!(u32::from_be_bytes(body[16..20].try_into().unwrap()), 4096);
|
|
let id_len = body[20] as usize;
|
|
let as_pub = body[21..21 + id_len].to_vec();
|
|
let cipher = body[21 + id_len..].to_vec();
|
|
let peer = agreement::UnparsedPublicKey::new(&agreement::ECDH_P256, as_pub.clone());
|
|
let shared = agreement::agree_ephemeral(ua_priv, &peer, |s| s.to_vec()).unwrap();
|
|
let mut info = b"WebPush: info\0".to_vec();
|
|
info.extend_from_slice(&ua_pub);
|
|
info.extend_from_slice(&as_pub);
|
|
let ikm = expand(&auth, &shared, &info, 32);
|
|
let cek = expand(salt, &ikm, b"Content-Encoding: aes128gcm\0", 16);
|
|
let nonce = expand(salt, &ikm, b"Content-Encoding: nonce\0", 12);
|
|
let key = aead::LessSafeKey::new(aead::UnboundKey::new(&aead::AES_128_GCM, &cek).unwrap());
|
|
let mut buf = cipher;
|
|
let plain = key
|
|
.open_in_place(
|
|
aead::Nonce::try_assume_unique_for_key(&nonce).unwrap(),
|
|
aead::Aad::empty(),
|
|
&mut buf,
|
|
)
|
|
.expect("déchiffrement");
|
|
let end = plain.iter().rposition(|b| *b == 0x02).unwrap();
|
|
assert!(plain[end + 1..].iter().all(|b| *b == 0));
|
|
let msg: Value = serde_json::from_slice(&plain[..end]).unwrap();
|
|
assert_eq!(
|
|
msg,
|
|
serde_json::json!({ "title": "Loki", "body": "Réponse prête · 42s", "tag": "loki-turn" })
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn sans_la_variable_les_notifications_restent_au_go() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let (facade, _go, _stop) = fakego::start(tmp.path().to_path_buf()).await;
|
|
let (_, _, b) = fakego::get(facade, "/api/push/key", &[]).await;
|
|
assert_eq!(&b[..], b"go:/api/push/key");
|
|
let (code, _) = fakego::send(
|
|
facade,
|
|
"POST",
|
|
push::INTERNAL_SEND,
|
|
&[("x-loki-internal", fakego::JETON)],
|
|
"{}",
|
|
)
|
|
.await;
|
|
assert_eq!(code, StatusCode::NOT_FOUND);
|
|
}
|