Files
inbuxa-server/crates/common/src/network/webpush.rs
T
jcoffey-dev cc6f1eb298
ci / fork-checks (pull_request) Successful in 16s
ci / build (pull_request) Successful in 7m53s
Rename the identifiers that carried the upstream name
Everything clients, users and operators meet now carries the fork's name,
with no aliases (SPEC.md §2.4, changed here from "protocol identifiers
stay"):

- JMAP: upstream's registry capability is urn:inbuxa:jmap:registry, beside
  the fork's own urn:inbuxa:jmap.
- WebDAV lock and sync tokens are urn:inbuxa:dav*; clients resync once.
- Sieve: vnd.inbuxa.while and vnd.inbuxa.expressions. sieve-rs spells these
  into its compiler, so it's vendored (vendor/sieve-rs, 0.7.3) and patched in;
  a unit test fails if Cargo.lock ever moves past the vendored copy. The
  trusted runtime now names itself too, rather than answering sieve-rs's
  default.
- The web interface's OAuth client is inbuxa-webui. On every start the old
  stalwart-webui client is removed and any application naming it is moved
  over.
- The spam filter's blobs are INBUXA_SPAM_*; every start moves any left
  under the old keys, so a trained model survives.
- SQL stores and log files default to inbuxa, in the code and in the
  schema served to the admin (checksum regenerated).
- Settings are INBUXA_* only. A STALWART_* variable that's set where its
  INBUXA_* one isn't stops the server at startup, naming it.
- The version-upgrade messages link docs.inbuxa.org's migration page, and
  the OpenAPI description, smtp crate metadata and web-push test fixtures
  lose the name.

Kept on purpose, allowlisted with reasons: the OAuth key-derivation
contexts (renaming them would end every session and invalidate every
sealed client id) and the hashed application prefix.

Also fixes a latent start-up failure: ensure_client updated an existing
first-party client with a revision of 0, which the registry's assertion
never matches, so adding a redirect URI or changing the webmail secret
failed start-up. And the principal session test now expects
legacyProtocols (C-1, added 2026-09-21), which it had missed.

Tested: the server builds without warnings; common's 106 unit tests,
including the vendoring check; a new integration test for the two
start-up migrations; and the webdav, jmap, imap and SMTP Sieve suites.
2026-09-22 19:33:02 -07:00

352 lines
12 KiB
Rust

/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use p256::{
SecretKey,
ecdsa::{Signature, SigningKey, signature::Signer},
pkcs8::{DecodePrivateKey, PrivateKeyInfo, der::SecretDocument},
};
const VAPID_TOKEN_TTL: u64 = 12 * 60 * 60;
#[derive(Clone)]
pub struct Vapid {
key: VapidKey,
contact: Option<String>,
}
impl Vapid {
pub fn new(key: VapidKey, contact: Option<String>) -> Self {
Self { key, contact }
}
pub fn public_key(&self) -> &str {
self.key.public_key()
}
pub fn authorization(&self, endpoint: &str, now: u64) -> Option<String> {
self.key
.authorization(endpoint, self.contact.as_deref(), now)
}
}
#[derive(Clone)]
pub struct VapidKey {
signing_key: SigningKey,
public_key: String,
}
impl VapidKey {
pub fn from_pkcs8_pem(pem: &str) -> Result<Self, String> {
let pem = pem.trim_start_matches('\u{feff}').trim();
if let Ok(key) = SigningKey::from_pkcs8_pem(pem) {
return Ok(Self::from_signing_key(key));
}
if let Ok(secret) = SecretKey::from_sec1_pem(pem) {
return Ok(Self::from_signing_key(secret.into()));
}
if let Some(secret) = secret_key_from_explicit_params(pem) {
return Ok(Self::from_signing_key(secret.into()));
}
Err(SigningKey::from_pkcs8_pem(pem)
.err()
.map(|err| {
format!(
"{err}. Re-encode the key as named-curve PKCS#8, \
e.g. `openssl pkey -in key.pem -out key_pkcs8.pem`."
)
})
.unwrap_or_else(|| "unsupported VAPID key encoding".to_string()))
}
fn from_signing_key(signing_key: SigningKey) -> Self {
let public_key = URL_SAFE_NO_PAD.encode(
signing_key
.verifying_key()
.to_encoded_point(false)
.as_bytes(),
);
Self {
signing_key,
public_key,
}
}
pub fn public_key(&self) -> &str {
&self.public_key
}
pub fn authorization(&self, endpoint: &str, contact: Option<&str>, now: u64) -> Option<String> {
let mut claims = serde_json::Map::new();
claims.insert("aud".into(), endpoint_origin(endpoint)?.into());
claims.insert("exp".into(), (now + VAPID_TOKEN_TTL).into());
if let Some(sub) = contact {
claims.insert("sub".into(), sub.into());
}
let header = URL_SAFE_NO_PAD.encode(br#"{"typ":"JWT","alg":"ES256"}"#);
let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).ok()?);
let signing_input = format!("{header}.{payload}");
let signature: Signature = self.signing_key.sign(signing_input.as_bytes());
Some(format!(
"vapid t={signing_input}.{}, k={}",
URL_SAFE_NO_PAD.encode(signature.to_bytes()),
self.public_key
))
}
}
fn endpoint_origin(url: &str) -> Option<String> {
let (scheme, rest) = url.split_once("://")?;
let scheme = scheme.to_ascii_lowercase();
let authority = rest.split(['/', '?', '#']).next()?;
let authority = authority
.rsplit_once('@')
.map(|(_, host)| host)
.unwrap_or(authority);
if authority.is_empty() {
return None;
}
let (host, port) = if let Some(rest) = authority.strip_prefix('[') {
let (addr, tail) = rest.split_once(']')?;
(
format!("[{}]", addr.to_ascii_lowercase()),
tail.strip_prefix(':').filter(|port| !port.is_empty()),
)
} else if let Some((host, port)) = authority.rsplit_once(':') {
(
host.to_ascii_lowercase(),
Some(port).filter(|p| !p.is_empty()),
)
} else {
(authority.to_ascii_lowercase(), None)
};
match port {
Some(port)
if !((scheme == "https" && port == "443") || (scheme == "http" && port == "80")) =>
{
Some(format!("{scheme}://{host}:{port}"))
}
_ => Some(format!("{scheme}://{host}")),
}
}
pub fn normalize_contact(contact: &str) -> Option<String> {
let contact = contact.trim();
match contact.split_once(':') {
Some((scheme, _)) if scheme.eq_ignore_ascii_case("mailto") => Some(contact.to_string()),
Some((scheme, _)) if scheme.eq_ignore_ascii_case("https") => Some(contact.to_string()),
Some(_) => None,
None if contact.contains('@') => Some(format!("mailto:{contact}")),
None => None,
}
}
pub fn generate_pkcs8_pem() -> Result<String, String> {
use p256::elliptic_curve::rand_core::OsRng;
use p256::pkcs8::{EncodePrivateKey, LineEnding};
SigningKey::random(&mut OsRng)
.to_pkcs8_pem(LineEnding::LF)
.map(|pem| pem.to_string())
.map_err(|err| err.to_string())
}
fn secret_key_from_explicit_params(pem: &str) -> Option<SecretKey> {
let (_, document) = SecretDocument::from_pem(pem).ok()?;
let private_key_info = PrivateKeyInfo::try_from(document.as_bytes()).ok()?;
SecretKey::from_sec1_der(private_key_info.private_key).ok()
}
#[cfg(test)]
mod tests {
use super::*;
use p256::ecdsa::{Signature, VerifyingKey, signature::Verifier};
fn test_key() -> VapidKey {
VapidKey::from_pkcs8_pem(&generate_pkcs8_pem().unwrap()).unwrap()
}
#[test]
fn generated_key_round_trips_through_pkcs8_pem() {
let pem = generate_pkcs8_pem().unwrap();
assert_eq!(
VapidKey::from_pkcs8_pem(&pem).unwrap().public_key(),
VapidKey::from_pkcs8_pem(&pem).unwrap().public_key()
);
}
#[test]
fn endpoint_origin_normalizes() {
assert_eq!(
endpoint_origin("HTTPS://Push.Example.COM:443/push?x=1").unwrap(),
"https://push.example.com"
);
assert_eq!(
endpoint_origin("https://127.0.0.1:19000/push").unwrap(),
"https://127.0.0.1:19000"
);
assert_eq!(
endpoint_origin("https://user:[email protected]/fcm/send/x").unwrap(),
"https://fcm.googleapis.com"
);
assert_eq!(
endpoint_origin("http://[2001:DB8::1]:80/p").unwrap(),
"http://[2001:db8::1]"
);
assert!(endpoint_origin("not-a-url").is_none());
}
#[test]
fn authorization_signs_a_verifiable_es256_token() {
let key = test_key();
let now = 1_700_000_000;
let header = key
.authorization(
"https://push.example.com/push/abc?token=1",
Some("mailto:[email protected]"),
now,
)
.unwrap();
let (token, advertised_key) = header
.strip_prefix("vapid ")
.and_then(|rest| rest.split_once(", "))
.unwrap();
let jwt = token.strip_prefix("t=").unwrap();
assert_eq!(advertised_key.strip_prefix("k=").unwrap(), key.public_key());
let parts = jwt.split('.').collect::<Vec<_>>();
assert_eq!(parts.len(), 3);
let verifying_key =
VerifyingKey::from_sec1_bytes(&URL_SAFE_NO_PAD.decode(key.public_key()).unwrap())
.unwrap();
let signature = Signature::from_slice(&URL_SAFE_NO_PAD.decode(parts[2]).unwrap()).unwrap();
verifying_key
.verify(format!("{}.{}", parts[0], parts[1]).as_bytes(), &signature)
.unwrap();
assert_eq!(
URL_SAFE_NO_PAD.decode(parts[0]).unwrap(),
br#"{"typ":"JWT","alg":"ES256"}"#
);
let claims: serde_json::Value =
serde_json::from_slice(&URL_SAFE_NO_PAD.decode(parts[1]).unwrap()).unwrap();
assert_eq!(claims["aud"], "https://push.example.com");
assert_eq!(claims["sub"], "mailto:[email protected]");
assert_eq!(claims["exp"], now + VAPID_TOKEN_TTL);
}
const SEC1_PEM: &str = "-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIP4Zv7be5hDH0x4ur6ditW+whzyZBXK1Vyjn6aIDo0jhoAoGCCqGSM49
AwEHoUQDQgAEVc4PXr+z61s9/dIas44+S0Nza3gm1UW/avddp99dUsEi3JV0H4Yk
1yfqVJ/O9KPvQ69uMAY0t3A5lx/GvOOZfg==
-----END EC PRIVATE KEY-----";
const PKCS8_NAMED_PEM: &str = "-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg/hm/tt7mEMfTHi6v
p2K1b7CHPJkFcrVXKOfpogOjSOGhRANCAARVzg9ev7PrWz390hqzjj5LQ3NreCbV
Rb9q912n311SwSLclXQfhiTXJ+pUn870o+9Dr24wBjS3cDmXH8a845l+
-----END PRIVATE KEY-----";
const PKCS8_EXPLICIT_PEM: &str = "-----BEGIN PRIVATE KEY-----
MIIBeQIBADCCAQMGByqGSM49AgEwgfcCAQEwLAYHKoZIzj0BAQIhAP////8AAAAB
AAAAAAAAAAAAAAAA////////////////MFsEIP////8AAAABAAAAAAAAAAAAAAAA
///////////////8BCBaxjXYqjqT57PrvVV2mIa8ZR0GsMxTsPY7zjw+J9JgSwMV
AMSdNgiG5wSTamZ44ROdJreBn36QBEEEaxfR8uEsQkf4vOblY6RA8ncDfYEt6zOg
9KE5RdiYwpZP40Li/hp/m47n60p8D54WK84zV2sxXs7LtkBoN79R9QIhAP////8A
AAAA//////////+85vqtpxeehPO5ysL8YyVRAgEBBG0wawIBAQQg/hm/tt7mEMfT
Hi6vp2K1b7CHPJkFcrVXKOfpogOjSOGhRANCAARVzg9ev7PrWz390hqzjj5LQ3Nr
eCbVRb9q912n311SwSLclXQfhiTXJ+pUn870o+9Dr24wBjS3cDmXH8a845l+
-----END PRIVATE KEY-----";
const PKCS8_P384_PEM: &str = "-----BEGIN PRIVATE KEY-----
MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDCUx+yT22yGHP9q+Y1y
UedDkevSvPaUuSPH8Q4FJBdYKKLqX4a5VdBIOonKPC4Yj7yhZANiAAQPRBsMOJy/
B4yDfR2rGOd2H6Kv3fQNHPj9Nu5Tks8QYMLzrX8ONCNoFnNUQl9S0r0QS6phVqD0
1kt0wbEvKr7mPM/R8XS8dX0xYC58CXHqBsTM0piQN2R7kqWDJ5i4OjE=
-----END PRIVATE KEY-----";
#[test]
fn accepts_equivalent_p256_encodings() {
let named = VapidKey::from_pkcs8_pem(PKCS8_NAMED_PEM).unwrap();
let sec1 = VapidKey::from_pkcs8_pem(SEC1_PEM).unwrap();
let explicit = VapidKey::from_pkcs8_pem(PKCS8_EXPLICIT_PEM).unwrap();
assert_eq!(named.public_key(), sec1.public_key());
assert_eq!(named.public_key(), explicit.public_key());
}
#[test]
fn accepts_pem_with_leading_bom_and_whitespace() {
let dirty = format!("\u{feff} \n{PKCS8_NAMED_PEM}\n ");
assert_eq!(
VapidKey::from_pkcs8_pem(&dirty).unwrap().public_key(),
VapidKey::from_pkcs8_pem(PKCS8_NAMED_PEM)
.unwrap()
.public_key()
);
}
#[test]
fn rejects_wrong_curve_key() {
assert!(VapidKey::from_pkcs8_pem(PKCS8_P384_PEM).is_err());
}
#[test]
fn rejects_garbage_with_actionable_error() {
let err = VapidKey::from_pkcs8_pem("not a key").err().unwrap();
assert!(err.contains("openssl pkey"), "{err}");
}
#[test]
fn contact_is_normalized_to_a_uri() {
for (input, expected) in [
("[email protected]", Some("mailto:[email protected]")),
(" [email protected] ", Some("mailto:[email protected]")),
("mailto:[email protected]", Some("mailto:[email protected]")),
("MAILTO:[email protected]", Some("MAILTO:[email protected]")),
(
"https://example.org/contact",
Some("https://example.org/contact"),
),
("example.org", None),
("http://example.org", None),
("tel:+123456789", None),
("", None),
] {
assert_eq!(
normalize_contact(input).as_deref(),
expected,
"unexpected normalization of {input:?}"
);
}
}
#[test]
fn authorization_omits_subject_when_no_contact() {
let key = test_key();
let header = key
.authorization("https://fcm.googleapis.com/fcm/send/xyz", None, 0)
.unwrap();
let payload = header.split('.').nth(1).unwrap();
let claims: serde_json::Value =
serde_json::from_slice(&URL_SAFE_NO_PAD.decode(payload).unwrap()).unwrap();
assert_eq!(claims["aud"], "https://fcm.googleapis.com");
assert!(claims.get("sub").is_none());
}
}