From f664de7212604e6d0862c67fe0240f9e7454b5aa Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Sun, 25 Oct 2026 17:32:02 +0100 Subject: [PATCH 1/9] Move Token to pushups-token with encryption keys and add pushups-sender --- .github/workflows/ci.yml | 14 +- Cargo.lock | 24 +- Cargo.toml | 13 +- README.md | 2 +- ci/android/probe/Cargo.lock | 5 + ci/android/probe/src/lib.rs | 4 +- ci/windows/gen/Cargo.toml | 2 +- ci/windows/w0/Cargo.lock | 5 + ci/windows/w0/src/main.rs | 1 + examples/support/src/lib.rs | 2 +- pushups-macros/Cargo.toml | 2 +- pushups-sender/Cargo.toml | 38 +++ pushups-sender/README.md | 32 +++ pushups-sender/src/lib.rs | 334 +++++++++++++++++++++++++ pushups-token/Cargo.toml | 37 +++ pushups-token/README.md | 26 ++ pushups-token/src/lib.rs | 361 +++++++++++++++++++++++++++ pushups-token/src/serde_impl.rs | 261 ++++++++++++++++++++ src/android.rs | 2 +- src/apple.rs | 5 +- src/config.rs | 107 +++++++- src/delivered.rs | 9 +- src/dispatch.rs | 10 +- src/inbox.rs | 5 +- src/lib.rs | 3 +- src/linux.rs | 24 +- src/queue.rs | 7 +- src/token.rs | 417 -------------------------------- src/windows.rs | 5 +- 29 files changed, 1291 insertions(+), 466 deletions(-) create mode 100644 pushups-sender/Cargo.toml create mode 100644 pushups-sender/README.md create mode 100644 pushups-sender/src/lib.rs create mode 100644 pushups-token/Cargo.toml create mode 100644 pushups-token/README.md create mode 100644 pushups-token/src/lib.rs create mode 100644 pushups-token/src/serde_impl.rs delete mode 100644 src/token.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b946e67..cb1c04b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -91,15 +91,15 @@ jobs: steps: - uses: actions/checkout@v7 - run: | - rustup toolchain install 1.85 --profile minimal - rustup target add --toolchain 1.85 aarch64-linux-android aarch64-apple-ios aarch64-apple-darwin x86_64-pc-windows-msvc + rustup toolchain install 1.88 --profile minimal + rustup target add --toolchain 1.88 aarch64-linux-android aarch64-apple-ios aarch64-apple-darwin x86_64-pc-windows-msvc # Every feature but `dioxus`, which needs the Rust version Dioxus 0.7 apps already need. A # check needs no linker, so the Windows target runs here too. - - run: cargo +1.85 check --workspace --features stream,serde - - run: cargo +1.85 check --workspace --features stream,serde --target aarch64-linux-android - - run: cargo +1.85 check --workspace --features stream,serde --target aarch64-apple-ios - - run: cargo +1.85 check --workspace --features stream,serde --target aarch64-apple-darwin - - run: cargo +1.85 check --workspace --features stream,serde --target x86_64-pc-windows-msvc + - run: cargo +1.88 check --workspace --features stream,serde + - run: cargo +1.88 check --workspace --features stream,serde --target aarch64-linux-android + - run: cargo +1.88 check --workspace --features stream,serde --target aarch64-apple-ios + - run: cargo +1.88 check --workspace --features stream,serde --target aarch64-apple-darwin + - run: cargo +1.88 check --workspace --features stream,serde --target x86_64-pc-windows-msvc tauri-plugin: name: tauri-plugin-pushups diff --git a/Cargo.lock b/Cargo.lock index 5c2bd9b..e5e2b35 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1169,7 +1169,7 @@ dependencies = [ "p256", "parking_lot", "pushups-macros", - "serde", + "pushups-token", "serde_json", "sha2", "tempfile", @@ -1197,6 +1197,28 @@ dependencies = [ "tempfile", ] +[[package]] +name = "pushups-sender" +version = "0.1.0" +dependencies = [ + "aes-gcm", + "getrandom", + "hkdf", + "p256", + "pushups-token", + "sha2", + "thiserror 2.0.21", +] + +[[package]] +name = "pushups-token" +version = "0.1.0" +dependencies = [ + "base64", + "serde", + "serde_json", +] + [[package]] name = "quote" version = "1.0.47" diff --git a/Cargo.toml b/Cargo.toml index 14475c7..10a0fc9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ name = "pushups" version = "0.1.0" edition = "2024" -rust-version = "1.85" +rust-version = "1.88" description = "Receive push notifications in a Rust app: APNs on iOS and macOS, FCM on Android, WNS on Windows, UnifiedPush on Linux, Web Push in the browser, under any toolkit" repository = "https://github.com/LucaCappelletti94/pushups" license = "MIT" @@ -17,11 +17,12 @@ links = "pushups" stream = ["dep:futures-channel", "dep:futures-core"] # Lets `dx` bundle the Android module and the static service worker from this crate. dioxus = ["dep:manganis"] -# `Serialize` on `WebPushSubscription`, in the shape of the browser's `PushSubscription.toJSON()`. -serde = ["dep:serde"] +# `Serialize` and `Deserialize` on `Token`, and `Serialize` on `WebPushSubscription` in the shape of +# the browser's `PushSubscription.toJSON()`. +serde = ["pushups-token/serde"] [workspace] -members = ["pushups-macros"] +members = ["pushups-macros", "pushups-sender", "pushups-token"] # Built in its own workspace, since `tauri` needs GTK on Linux, Rust 1.90 and no wasm32. exclude = ["tauri-plugin-pushups"] @@ -30,7 +31,7 @@ futures-channel = { version = "0.3", optional = true, default-features = false, futures-core = { version = "0.3", optional = true, default-features = false } parking_lot = "0.12" pushups-macros = { version = "0.1.0", path = "pushups-macros" } -serde = { version = "1", optional = true, features = ["derive"] } +pushups-token = { version = "0.1.0", path = "pushups-token" } thiserror = "2" [target.'cfg(target_os = "android")'.dependencies] @@ -71,7 +72,7 @@ zbus = "5" linkme = "0.3" [target.'cfg(target_os = "windows")'.dependencies] -# The newest 0.6x line that builds on Rust 1.85, since the 0.100 line of `windows-core` needs 1.95. +# The newest 0.6x line that builds on Rust 1.88, since the 0.100 line of `windows-core` needs 1.95. # The `IMap` that `AppNotificationActivatedEventArgs` returns for `Arguments` and `UserInput`. windows = { version = "0.62", default-features = false, features = ["Foundation", "Foundation_Collections", "ApplicationModel_Background", "ApplicationModel_Core"] } windows-collections = "0.3" diff --git a/README.md b/README.md index 78a4a73..1c6a953 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![codecov](https://codecov.io/gh/LucaCappelletti94/pushups/graph/badge.svg)](https://codecov.io/gh/LucaCappelletti94/pushups) [![docs.rs](https://docs.rs/pushups/badge.svg)](https://docs.rs/pushups) [![crates.io](https://img.shields.io/crates/v/pushups.svg)](https://crates.io/crates/pushups) -[![MSRV](https://img.shields.io/badge/MSRV-1.85-blue)](https://blog.rust-lang.org/2025/02/20/Rust-1.85.0/) +[![MSRV](https://img.shields.io/badge/MSRV-1.88-blue)](https://blog.rust-lang.org/2025/06/26/Rust-1.88.0/) [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](https://github.com/LucaCappelletti94/pushups/blob/main/LICENSE) Push notifications for Rust apps through each platform's own service, [APNs](https://developer.apple.com/documentation/usernotifications/registering-your-app-with-apns) on iOS and macOS, [FCM](https://firebase.google.com/docs/cloud-messaging) or [UnifiedPush](https://unifiedpush.org/) on Android, [WNS](https://learn.microsoft.com/en-us/windows/apps/develop/notifications/push-notifications/) on Windows, UnifiedPush on Linux and [Web Push](https://developer.mozilla.org/en-US/docs/Web/API/Push_API) in the browser. The app calls `install` in `main` before its toolkit starts, sets one handler and calls `register`. The handler gets the token for the app's server and every push, including the one that started the app. diff --git a/ci/android/probe/Cargo.lock b/ci/android/probe/Cargo.lock index ec9a008..5085d32 100644 --- a/ci/android/probe/Cargo.lock +++ b/ci/android/probe/Cargo.lock @@ -1052,6 +1052,7 @@ dependencies = [ "p256", "parking_lot", "pushups-macros", + "pushups-token", "sha2", "thiserror 2.0.21", "wasm-bindgen", @@ -1086,6 +1087,10 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "pushups-token" +version = "0.1.0" + [[package]] name = "quote" version = "1.0.47" diff --git a/ci/android/probe/src/lib.rs b/ci/android/probe/src/lib.rs index 4cfa5ff..8bac075 100644 --- a/ci/android/probe/src/lib.rs +++ b/ci/android/probe/src/lib.rs @@ -119,7 +119,7 @@ fn files_dir( fn describe(event: &Event) -> String { match event { - Event::Token(Token::Fcm(token)) => format!("token {token}"), + Event::Token(Token::Fcm { token, .. }) => format!("token {token}"), Event::Token(token) => match token.web_push_subscription() { Some(subscription) => format!( "webpush {} {} {}", @@ -230,7 +230,7 @@ pushups::__private::android::exports! { &event, Event::RegistrationFailed(pushups::Error::Unregistered) ), - "registered" => matches!(&event, Event::Token(Token::Fcm(_))), + "registered" => matches!(&event, Event::Token(Token::Fcm { .. })), "session" => matches!(&event, Event::Message(_)), _ => false, }; diff --git a/ci/windows/gen/Cargo.toml b/ci/windows/gen/Cargo.toml index 1dd4790..799d18c 100644 --- a/ci/windows/gen/Cargo.toml +++ b/ci/windows/gen/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2024" publish = false [dependencies] -# The newest generator whose 0.6x output builds on the crate's Rust 1.85; the 0.100 line needs 1.95. +# The newest generator whose 0.6x output builds on the crate's Rust 1.88; the 0.100 line needs 1.95. windows-bindgen = "=0.66.0" [workspace] diff --git a/ci/windows/w0/Cargo.lock b/ci/windows/w0/Cargo.lock index ed1f566..577f36f 100644 --- a/ci/windows/w0/Cargo.lock +++ b/ci/windows/w0/Cargo.lock @@ -1041,6 +1041,7 @@ dependencies = [ "p256", "parking_lot", "pushups-macros", + "pushups-token", "sha2", "thiserror 2.0.21", "wasm-bindgen", @@ -1072,6 +1073,10 @@ dependencies = [ "pushups", ] +[[package]] +name = "pushups-token" +version = "0.1.0" + [[package]] name = "pushups-w0-probe" version = "0.0.0" diff --git a/ci/windows/w0/src/main.rs b/ci/windows/w0/src/main.rs index 3b8b701..e4bd770 100644 --- a/ci/windows/w0/src/main.rs +++ b/ci/windows/w0/src/main.rs @@ -45,6 +45,7 @@ fn on_event(event: Event) { Event::Token(Token::Wns { channel_uri, expires, + .. }) => { let days = expires .duration_since(SystemTime::now()) diff --git a/examples/support/src/lib.rs b/examples/support/src/lib.rs index 75b416b..0660e3c 100644 --- a/examples/support/src/lib.rs +++ b/examples/support/src/lib.rs @@ -79,7 +79,7 @@ pub fn token(token: &Token) -> String { return format!("apns={hex}"); } match token { - Token::Fcm(token) => format!("fcm={token}"), + Token::Fcm { token, .. } => format!("fcm={token}"), Token::Wns { channel_uri, .. } => format!("wns={channel_uri}"), _ => format!("{token:?}"), } diff --git a/pushups-macros/Cargo.toml b/pushups-macros/Cargo.toml index d31c1cb..87a4fb2 100644 --- a/pushups-macros/Cargo.toml +++ b/pushups-macros/Cargo.toml @@ -2,7 +2,7 @@ name = "pushups-macros" version = "0.1.0" edition = "2024" -rust-version = "1.85" +rust-version = "1.88" description = "Macros for pushups" repository = "https://github.com/LucaCappelletti94/pushups" license = "MIT" diff --git a/pushups-sender/Cargo.toml b/pushups-sender/Cargo.toml new file mode 100644 index 0000000..0391e88 --- /dev/null +++ b/pushups-sender/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "pushups-sender" +version = "0.1.0" +edition = "2024" +rust-version = "1.88" +description = "Encrypt pushes for a pushups token on the app's server, the RFC 8291 aes128gcm body the transports carry" +repository = "https://github.com/LucaCappelletti94/pushups" +license = "MIT" +readme = "README.md" +keywords = ["push", "notifications", "apns", "fcm", "webpush"] +categories = ["cryptography", "network-programming"] + +[dependencies] +aes-gcm = "0.11" +getrandom = "0.4" +hkdf = "0.13" +p256 = { version = "0.14", features = ["ecdh"] } +pushups-token = { version = "0.1.0", path = "../pushups-token", features = ["serde"] } +sha2 = "0.11" +thiserror = "2" + +[dev-dependencies] +# The base64url the README doctest reads the RFC 8291 section 5 keys with. +pushups-token = { version = "0.1.0", path = "../pushups-token" } + +[package.metadata.docs.rs] +all-features = true + +[lints.rust] +missing_docs = "warn" +unsafe_op_in_unsafe_fn = "deny" + +[lints.clippy] +pedantic = { level = "warn", priority = -1 } +missing_errors_doc = "warn" +missing_panics_doc = "warn" +allow_attributes = "deny" +allow_attributes_without_reason = "deny" diff --git a/pushups-sender/README.md b/pushups-sender/README.md new file mode 100644 index 0000000..fa67a41 --- /dev/null +++ b/pushups-sender/README.md @@ -0,0 +1,32 @@ +# pushups-sender + +[![CI](https://github.com/LucaCappelletti94/pushups/actions/workflows/ci.yml/badge.svg)](https://github.com/LucaCappelletti94/pushups/actions/workflows/ci.yml) +[![docs.rs](https://docs.rs/pushups-sender/badge.svg)](https://docs.rs/pushups-sender) +[![crates.io](https://img.shields.io/crates/v/pushups-sender.svg)](https://crates.io/crates/pushups-sender) +[![MSRV](https://img.shields.io/badge/MSRV-1.88-blue)](https://blog.rust-lang.org/2025/06/26/Rust-1.88.0/) +[![License: MIT](https://img.shields.io/badge/license-MIT-blue)](https://github.com/LucaCappelletti94/pushups/blob/main/LICENSE) + +The app's server half of pushups' encrypted pushes. [`encrypt`] seals a plaintext for a [`Token`]'s keys as the RFC 8291 `aes128gcm` body, and the [`Sealed`] result carries it as the base64url field value FCM and APNs put under [`ENCRYPTED_FIELD`], the exact WNS raw body, or the raw Web Push body. + +```rust +use pushups_sender::{encrypt, EncryptionKeys, Token, ENCRYPTED_FIELD}; +use pushups_token::base64url; + +// The keys of the RFC 8291 section 5 test vector. +let keys = EncryptionKeys { + p256dh: base64url::decode( + "BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4", + ) + .unwrap() + .try_into() + .unwrap(), + auth: base64url::decode("BTBZMqHH6r4Tts7J_aSIgg").unwrap().try_into().unwrap(), +}; +let token = Token::Fcm { + token: "fcm-registration-token".to_owned(), + keys: Some(keys), +}; +let sealed = encrypt(&token, b"When I grow up, I want to be a watermelon").unwrap(); +let entry = (ENCRYPTED_FIELD, sealed.to_base64url()); +println!("{entry:?}"); +``` diff --git a/pushups-sender/src/lib.rs b/pushups-sender/src/lib.rs new file mode 100644 index 0000000..a08d072 --- /dev/null +++ b/pushups-sender/src/lib.rs @@ -0,0 +1,334 @@ +#![doc = include_str!("../README.md")] + +use aes_gcm::Aes128Gcm; +use aes_gcm::aead::{Aead, KeyInit}; +use hkdf::Hkdf; +use p256::{PublicKey, SecretKey}; +use pushups_token::base64url; +use sha2::Sha256; + +pub use pushups_token::{ENCRYPTED_FIELD, EncryptionKeys, Token}; + +const SALT_LEN: usize = 16; +const RECORD_SIZE_LEN: usize = 4; +const IDLEN_LEN: usize = 1; +const KEYID_LEN: usize = 65; +const AUTH_LEN: usize = 16; +const RECORD_TAG_LEN: usize = 16; +const HEADER_LEN: usize = SALT_LEN + RECORD_SIZE_LEN + IDLEN_LEN + KEYID_LEN; +const RECORD_SIZE: u32 = 4096; +/// The largest `plaintext` one `aes128gcm` record holds, the record minus the padding +/// delimiter and the GCM tag. +const MAX_PLAINTEXT: usize = 4096 - 1 - 16; +const PADDING_DELIMITER: u8 = 0x02; +const KEY_INFO_LABEL: &[u8] = b"WebPush: info"; +const KEY_INFO_LABEL_LEN: usize = 13; +const KEY_INFO_LEN: usize = KEY_INFO_LABEL_LEN + 1 + 2 * KEYID_LEN; +const CEK_INFO: [u8; 28] = *b"Content-Encoding: aes128gcm\0"; +const NONCE_INFO: [u8; 24] = *b"Content-Encoding: nonce\0"; + +/// A push sealed for one token, the RFC 8291 `aes128gcm` body. +#[derive(Debug, Clone, PartialEq, Eq)] +#[must_use] +pub struct Sealed(Vec); + +impl Sealed { + /// The raw body, what a Web Push request carries with `Content-Encoding: aes128gcm`. + #[must_use] + pub fn as_bytes(&self) -> &[u8] { + &self.0 + } + + /// The raw body, owned. + #[must_use] + pub fn into_bytes(self) -> Vec { + self.0 + } + + /// The body as unpadded base64url, the value of the FCM data key and the APNs payload key + /// [`ENCRYPTED_FIELD`]. + #[must_use] + pub fn to_base64url(&self) -> String { + base64url::encode(&self.0) + } + + /// The WNS raw body, exactly `{"pushups":""}` with no whitespace. + #[must_use] + pub fn wns_body(&self) -> String { + format!( + "{{\"{ENCRYPTED_FIELD}\":\"{}\"}}", + base64url::encode(&self.0) + ) + } +} + +/// A failure to seal a push for a token. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum SealError { + /// The token carries no encryption keys. + #[error("the token carries no encryption keys")] + NoKeys, + /// The token's `p256dh` key is not a valid uncompressed P-256 point. + #[error("the p256dh key is not a valid P-256 point")] + InvalidKey, + /// The plaintext is longer than one `aes128gcm` record holds. + #[error("the plaintext is {len} bytes and one record holds at most {max} bytes")] + TooLarge { + /// The plaintext's length in bytes. + len: usize, + /// The largest plaintext length one record holds, in bytes. + max: usize, + }, + /// The token's transport is not one `pushups-sender` can encrypt for. + #[error("the token's transport is unsupported")] + UnsupportedTransport, + /// The randomness source failed, so no fresh salt or key pair could be drawn. + #[error("the randomness source failed")] + Randomness, +} + +/// Encrypts `plaintext` for the keys `token` carries. +/// +/// A fresh 16-byte salt and a fresh ephemeral P-256 key pair are drawn for every call, so two +/// seals of the same plaintext differ. +/// +/// # Errors +/// +/// [`SealError::NoKeys`] when the token carries no keys, [`SealError::InvalidKey`] when its +/// `p256dh` key is not a point on P-256, [`SealError::TooLarge`] when `plaintext` is longer +/// than one `aes128gcm` record holds, [`SealError::UnsupportedTransport`] for a transport this +/// crate does not cover, and [`SealError::Randomness`] when the operating system's randomness +/// source is unavailable. +pub fn encrypt(token: &Token, plaintext: &[u8]) -> Result { + let keys = keys_of(token)?; + if plaintext.len() > MAX_PLAINTEXT { + return Err(SealError::TooLarge { + len: plaintext.len(), + max: MAX_PLAINTEXT, + }); + } + let (salt, ephemeral) = fresh_material()?; + encrypt_with(&keys, plaintext, &ephemeral, salt) +} + +/// The keys the token carries, or the error that fits. +fn keys_of(token: &Token) -> Result { + match token { + Token::Apns { keys, .. } | Token::Fcm { keys, .. } | Token::Wns { keys, .. } => { + keys.ok_or(SealError::NoKeys) + } + Token::WebPush { p256dh, auth, .. } => Ok(EncryptionKeys { + p256dh: *p256dh, + auth: *auth, + }), + _ => Err(SealError::UnsupportedTransport), + } +} + +/// A fresh 16-byte salt and a fresh ephemeral P-256 key, one per `encrypt` call. The secret is +/// drawn until the scalar is below the group order, rejection sampling. +fn fresh_material() -> Result<([u8; SALT_LEN], SecretKey), SealError> { + let mut salt = [0u8; SALT_LEN]; + getrandom::fill(&mut salt).map_err(|_| SealError::Randomness)?; + let ephemeral = loop { + let mut secret = [0u8; 32]; + getrandom::fill(&mut secret).map_err(|_| SealError::Randomness)?; + if let Ok(key) = SecretKey::from_bytes(&secret.into()) { + break key; + } + }; + Ok((salt, ephemeral)) +} + +/// The RFC 8291 steps for one record, with the caller's salt and ephemeral key. +fn encrypt_with( + keys: &EncryptionKeys, + plaintext: &[u8], + ephemeral: &SecretKey, + salt: [u8; SALT_LEN], +) -> Result { + let ua_public = PublicKey::from_sec1_bytes(&keys.p256dh).map_err(|_| SealError::InvalidKey)?; + let sender_public: [u8; KEYID_LEN] = ephemeral + .public_key() + .to_sec1_bytes() + .as_ref() + .try_into() + .expect("an uncompressed P-256 point is 65 bytes"); + let shared = ephemeral.diffie_hellman(&ua_public); + let (content_key, nonce) = derive_content_key( + &keys.auth, + &salt, + &keys.p256dh, + &sender_public, + shared.raw_secret_bytes(), + ); + let mut record_plain = Vec::with_capacity(plaintext.len() + 1); + record_plain.extend_from_slice(plaintext); + record_plain.push(PADDING_DELIMITER); + let cipher = Aes128Gcm::new(&content_key.into()); + // The record is the record plaintext plus its GCM tag. + let mut body = Vec::with_capacity(HEADER_LEN + record_plain.len() + RECORD_TAG_LEN); + let record = cipher + .encrypt(&nonce.into(), record_plain.as_slice()) + .expect("aes-gcm never fails to encrypt"); + body.extend_from_slice(&salt); + body.extend_from_slice(&RECORD_SIZE.to_be_bytes()); + body.push(u8::try_from(KEYID_LEN).expect("65 fits in a byte")); + body.extend_from_slice(&sender_public); + body.extend_from_slice(&record); + Ok(Sealed(body)) +} + +/// Derive the AES-GCM content key and nonce, the RFC 8291 key combining and the RFC 8188 +/// content-encryption key derivation. +fn derive_content_key( + auth: &[u8; AUTH_LEN], + salt: &[u8], + ua_public: &[u8; KEYID_LEN], + sender_public: &[u8; KEYID_LEN], + ecdh: &[u8], +) -> ([u8; 16], [u8; 12]) { + let mut key_info = [0u8; KEY_INFO_LEN]; + key_info[..KEY_INFO_LABEL_LEN].copy_from_slice(KEY_INFO_LABEL); + key_info[KEY_INFO_LABEL_LEN + 1..KEY_INFO_LABEL_LEN + 1 + KEYID_LEN].copy_from_slice(ua_public); + key_info[KEY_INFO_LABEL_LEN + 1 + KEYID_LEN..].copy_from_slice(sender_public); + let mut ikm = [0u8; 32]; + Hkdf::::new(Some(auth), ecdh) + .expand(&key_info, &mut ikm) + .expect("the output fits one HKDF block"); + let hkdf = Hkdf::::new(Some(salt), &ikm); + let mut content_key = [0u8; 16]; + hkdf.expand(&CEK_INFO, &mut content_key) + .expect("the output fits one HKDF block"); + let mut nonce = [0u8; 12]; + hkdf.expand(&NONCE_INFO, &mut nonce) + .expect("the output fits one HKDF block"); + (content_key, nonce) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::SystemTime; + + /// The RFC 8291, section 5, receiver public key, authentication secret, sender private key + /// and body. + const UA_PUBLIC: &str = + "BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4"; + const AUTH_SECRET: &str = "BTBZMqHH6r4Tts7J_aSIgg"; + const SENDER_PRIVATE: &str = "yfWPiYE-n46HLnH0KqZOF1fJJU3MYrct3AELtAQ-oRw"; + const PLAINTEXT: &str = "When I grow up, I want to be a watermelon"; + const BODY: &str = "DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A_yl95bQpu6cVPTpK4Mqgkf1CXztLVBSt2Ks3oZwbuwXPXLWyouBWLVWGNWQexSgSxsj_Qulcy4a-fN"; + const UNCOMPRESSED_PREFIX: u8 = 0x04; + + fn decode(text: &str) -> Vec { + base64url::decode(text).expect("an RFC 8291 base64url value") + } + + fn rfc_keys() -> EncryptionKeys { + EncryptionKeys { + p256dh: decode(UA_PUBLIC).try_into().expect("65 bytes"), + auth: decode(AUTH_SECRET).try_into().expect("16 bytes"), + } + } + + fn fcm(keys: Option) -> Token { + Token::Fcm { + token: "fcm-registration-token".to_owned(), + keys, + } + } + + #[test] + fn the_rfc8291_vector_reproduces_the_rfc_body() { + let body = decode(BODY); + let salt: [u8; SALT_LEN] = body[..SALT_LEN].try_into().expect("a 16-byte salt"); + let secret: [u8; 32] = decode(SENDER_PRIVATE).try_into().expect("a 32-byte scalar"); + let ephemeral = SecretKey::from_bytes(&secret.into()).expect("the RFC 8291 sender key"); + let sealed = encrypt_with(&rfc_keys(), PLAINTEXT.as_bytes(), &ephemeral, salt) + .expect("the RFC 8291 keys are valid"); + assert_eq!(sealed.as_bytes(), body.as_slice()); + } + + #[test] + fn a_seal_carries_the_header_and_fresh_material_per_call() { + let token = fcm(Some(rfc_keys())); + let first = encrypt(&token, PLAINTEXT.as_bytes()).expect("the RFC 8291 keys are valid"); + let second = encrypt(&token, PLAINTEXT.as_bytes()).expect("the RFC 8291 keys are valid"); + let body = first.as_bytes(); + assert_eq!(body.len() - (PLAINTEXT.len() + 1 + RECORD_TAG_LEN), 86); + assert_eq!( + body.len(), + HEADER_LEN + PLAINTEXT.len() + 1 + RECORD_TAG_LEN + ); + assert_eq!( + &body[SALT_LEN..SALT_LEN + RECORD_SIZE_LEN], + RECORD_SIZE.to_be_bytes() + ); + assert_eq!( + body[SALT_LEN + RECORD_SIZE_LEN], + u8::try_from(KEYID_LEN).expect("65 fits in a byte") + ); + assert_eq!(body[SALT_LEN + RECORD_SIZE_LEN + 1], UNCOMPRESSED_PREFIX); + assert!( + PublicKey::from_sec1_bytes(&body[SALT_LEN + RECORD_SIZE_LEN + 1..HEADER_LEN]).is_ok() + ); + assert_ne!(first.as_bytes(), second.as_bytes()); + } + + #[test] + fn a_keyed_transport_without_keys_is_a_no_keys_error() { + let tokens = [ + Token::Apns { + device_token: vec![1], + keys: None, + }, + fcm(None), + Token::Wns { + channel_uri: "https://channel.windows.net/".to_owned(), + expires: SystemTime::now(), + keys: None, + }, + ]; + for token in &tokens { + assert_eq!(encrypt(token, b"hello"), Err(SealError::NoKeys)); + } + } + + #[test] + fn a_p256dh_off_the_curve_is_an_invalid_key() { + let mut p256dh = [0u8; KEYID_LEN]; + let (prefix, coordinates) = p256dh.split_first_mut().expect("65 bytes"); + *prefix = UNCOMPRESSED_PREFIX; + *coordinates.get_mut(31).expect("an x coordinate slot") = 1; + *coordinates.get_mut(63).expect("a y coordinate slot") = 2; + let token = fcm(Some(EncryptionKeys { + p256dh, + auth: [7; AUTH_LEN], + })); + assert_eq!(encrypt(&token, b"hello"), Err(SealError::InvalidKey)); + } + + #[test] + fn a_plaintext_beyond_one_record_is_too_large() { + let token = fcm(Some(rfc_keys())); + assert_eq!( + encrypt(&token, &[0u8; MAX_PLAINTEXT + 1]), + Err(SealError::TooLarge { + len: MAX_PLAINTEXT + 1, + max: MAX_PLAINTEXT, + }) + ); + assert!(encrypt(&token, &[0u8; MAX_PLAINTEXT]).is_ok()); + } + + #[test] + fn a_wns_body_is_the_envelope_of_its_base64url_body() { + let sealed = Sealed(decode(BODY)); + assert_eq!(sealed.to_base64url(), BODY); + assert_eq!( + sealed.wns_body(), + format!("{{\"{ENCRYPTED_FIELD}\":\"{BODY}\"}}") + ); + } +} diff --git a/pushups-token/Cargo.toml b/pushups-token/Cargo.toml new file mode 100644 index 0000000..c29defe --- /dev/null +++ b/pushups-token/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "pushups-token" +version = "0.1.0" +edition = "2024" +rust-version = "1.88" +description = "The push token pushups reports and an app server sends to, shared by pushups and pushups-sender" +repository = "https://github.com/LucaCappelletti94/pushups" +license = "MIT" +readme = "README.md" +keywords = ["push", "notifications", "apns", "fcm", "webpush"] +categories = ["encoding", "web-programming"] + +[features] +# `Serialize` and `Deserialize` on `Token`, and `Serialize` on `WebPushSubscription`. +serde = ["dep:serde"] + +[dependencies] +serde = { version = "1", optional = true, features = ["derive"] } + +[dev-dependencies] +# The reference decoder the base64url tests compare against. +base64 = "0.22" +serde_json = "1" + +[package.metadata.docs.rs] +all-features = true + +[lints.rust] +missing_docs = "warn" +unsafe_op_in_unsafe_fn = "deny" + +[lints.clippy] +pedantic = { level = "warn", priority = -1 } +missing_errors_doc = "warn" +missing_panics_doc = "warn" +allow_attributes = "deny" +allow_attributes_without_reason = "deny" diff --git a/pushups-token/README.md b/pushups-token/README.md new file mode 100644 index 0000000..e15da16 --- /dev/null +++ b/pushups-token/README.md @@ -0,0 +1,26 @@ +# pushups-token + +[![CI](https://github.com/LucaCappelletti94/pushups/actions/workflows/ci.yml/badge.svg)](https://github.com/LucaCappelletti94/pushups/actions/workflows/ci.yml) +[![docs.rs](https://docs.rs/pushups-token/badge.svg)](https://docs.rs/pushups-token) +[![crates.io](https://img.shields.io/crates/v/pushups-token.svg)](https://crates.io/crates/pushups-token) +[![MSRV](https://img.shields.io/badge/MSRV-1.88-blue)](https://blog.rust-lang.org/2025/06/26/Rust-1.88.0/) +[![License: MIT](https://img.shields.io/badge/license-MIT-blue)](https://github.com/LucaCappelletti94/pushups/blob/main/LICENSE) + +The push token [`pushups`](https://crates.io/crates/pushups) reports to an app and the app's server sends pushes to, in a crate of its own so the server reads it without compiling the app's push backends. [`pushups-sender`](https://crates.io/crates/pushups-sender) encrypts for it. + +With the `serde` feature a token travels as JSON, tagged by its transport, bytes as unpadded base64url and times as milliseconds since the Unix epoch. + +```rust +use pushups_token::{EncryptionKeys, Token}; + +let token = Token::Fcm { + token: "fcm-registration-token".to_owned(), + keys: Some(EncryptionKeys { p256dh: [4; 65], auth: [7; 16] }), +}; +# #[cfg(feature = "serde")] +# { +let json = serde_json::to_string(&token).unwrap(); +assert!(json.starts_with(r#"{"transport":"fcm","token":"fcm-registration-token","keys":{"p256dh":"BAQE"#)); +assert_eq!(serde_json::from_str::(&json).unwrap(), token); +# } +``` diff --git a/pushups-token/src/lib.rs b/pushups-token/src/lib.rs new file mode 100644 index 0000000..3248e21 --- /dev/null +++ b/pushups-token/src/lib.rs @@ -0,0 +1,361 @@ +#![doc = include_str!("../README.md")] +#![cfg_attr(docsrs, feature(doc_cfg))] + +use std::time::SystemTime; + +#[cfg(feature = "serde")] +mod serde_impl; + +/// The key of the FCM data map and of the APNs payload, and the one member of the WNS body, that +/// carries an encrypted push as unpadded base64url. +pub const ENCRYPTED_FIELD: &str = "pushups"; + +/// A push token, the address the app's server sends pushes to. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr( + feature = "serde", + serde(tag = "transport", rename_all = "snake_case", deny_unknown_fields) +)] +#[non_exhaustive] +pub enum Token { + /// An APNs device token. + Apns { + /// The device token. Apple gives no guarantee on its length, which is 32 bytes today. + #[cfg_attr(feature = "serde", serde(with = "serde_impl::bytes"))] + device_token: Vec, + /// The keys pushes to this device are encrypted for, when the app stores them. + keys: Option, + }, + /// An FCM registration token. + Fcm { + /// The registration token. + token: String, + /// The keys pushes to this device are encrypted for, when the app stores them. + keys: Option, + }, + /// A WNS channel. + Wns { + /// The channel URI the server posts to. + channel_uri: String, + /// When the channel stops accepting pushes. + #[cfg_attr(feature = "serde", serde(with = "serde_impl::time"))] + expires: SystemTime, + /// The keys pushes to this device are encrypted for, when the app stores them. + keys: Option, + }, + /// A Web Push subscription. + WebPush { + /// The push service URL the server posts to. + endpoint: String, + /// The subscription's P-256 public key, uncompressed. + #[cfg_attr(feature = "serde", serde(with = "serde_impl::fixed"))] + p256dh: [u8; 65], + /// The subscription's authentication secret. + #[cfg_attr(feature = "serde", serde(with = "serde_impl::fixed"))] + auth: [u8; 16], + /// When the subscription expires, if the push service set an expiry. + #[cfg_attr(feature = "serde", serde(with = "serde_impl::optional_time"))] + expires: Option, + }, +} + +/// The RFC 8291 keys a device decrypts its pushes with, the public half the server encrypts for. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(deny_unknown_fields))] +pub struct EncryptionKeys { + /// The device's P-256 public key, uncompressed. + #[cfg_attr(feature = "serde", serde(with = "serde_impl::fixed"))] + pub p256dh: [u8; 65], + /// The device's authentication secret. + #[cfg_attr(feature = "serde", serde(with = "serde_impl::fixed"))] + pub auth: [u8; 16], +} + +/// The keys of a Web Push subscription, base64url encoded without padding. +/// +/// These are the `keys` of a browser `PushSubscription` serialised to JSON, and the two strings +/// `web_push::SubscriptionInfo::new` takes after the endpoint. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +pub struct WebPushKeys { + /// The subscription's P-256 public key. + pub p256dh: String, + /// The subscription's authentication secret. + pub auth: String, +} + +/// A Web Push subscription, everything the app's server needs to send the app pushes. +/// +/// With the `serde` feature it serialises as a browser's `PushSubscription.toJSON()`, the JSON +/// that `web-push` (Rust and npm) and `pywebpush` read. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))] +pub struct WebPushSubscription { + /// The push service URL the server posts to. + pub endpoint: String, + /// When the subscription expires, if the push service set an expiry, serialised as + /// milliseconds since the Unix epoch or `null`. + #[cfg_attr(feature = "serde", serde(with = "serde_impl::optional_time"))] + pub expiration_time: Option, + /// The subscription's keys. + pub keys: WebPushKeys, +} + +impl Token { + /// The APNs device token as lowercase hex, the form APNs request paths and `a2` take. + #[must_use] + pub fn apns_device_token(&self) -> Option { + const DIGITS: &[u8; 16] = b"0123456789abcdef"; + let Self::Apns { device_token, .. } = self else { + return None; + }; + let mut hex = String::with_capacity(device_token.len() * 2); + for byte in device_token { + hex.push(char::from(DIGITS[usize::from(byte >> 4)])); + hex.push(char::from(DIGITS[usize::from(byte & 0x0f)])); + } + Some(hex) + } + + /// The Web Push subscription, with its keys base64url encoded as a server expects them. + #[must_use] + pub fn web_push_subscription(&self) -> Option { + let Self::WebPush { + endpoint, + p256dh, + auth, + expires, + } = self + else { + return None; + }; + Some(WebPushSubscription { + endpoint: endpoint.clone(), + expiration_time: *expires, + keys: WebPushKeys { + p256dh: base64url::encode(p256dh), + auth: base64url::encode(auth), + }, + }) + } +} + +/// RFC 4648 base64 with the URL and filename safe alphabet and no padding, for `pushups` and +/// `pushups-sender`. +#[doc(hidden)] +pub mod base64url { + const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + + /// Encodes `bytes` without padding. + #[must_use] + pub fn encode(bytes: &[u8]) -> String { + let symbol = |index: u8| char::from(ALPHABET[usize::from(index)]); + let mut encoded = String::with_capacity(bytes.len().div_ceil(3) * 4); + for chunk in bytes.chunks(3) { + let first = chunk[0]; + let second = chunk.get(1).copied().unwrap_or(0); + let third = chunk.get(2).copied().unwrap_or(0); + encoded.push(symbol(first >> 2)); + encoded.push(symbol(((first & 0x03) << 4) | (second >> 4))); + if chunk.len() > 1 { + encoded.push(symbol(((second & 0x0f) << 2) | (third >> 6))); + } + if chunk.len() > 2 { + encoded.push(symbol(third & 0x3f)); + } + } + encoded + } + + /// Decodes unpadded base64url, or none when `text` is not unpadded base64url. + /// + /// The unused bits of a trailing sextet may hold anything, so this discards them where the + /// reference decoders reject them. + #[must_use] + pub fn decode(text: &str) -> Option> { + if text.len() % 4 == 1 { + return None; + } + let mut bytes = Vec::with_capacity(text.len() / 4 * 3 + 2); + let mut pending = 0u16; + let mut pending_bits = 0; + for byte in text.bytes() { + pending = (pending << 6) | u16::from(sextet(byte)?); + pending_bits += 6; + if pending_bits >= 8 { + pending_bits -= 8; + // The shift leaves the byte in the low eight bits. + bytes.push((pending >> pending_bits).to_le_bytes()[0]); + pending &= (1 << pending_bits) - 1; + } + } + Some(bytes) + } + + /// The six bits `byte` encodes, or none when it is outside the alphabet. + #[must_use] + pub fn sextet(byte: u8) -> Option { + match byte { + b'A'..=b'Z' => Some(byte - b'A'), + b'a'..=b'z' => Some(byte - b'a' + 26), + b'0'..=b'9' => Some(byte - b'0' + 52), + b'-' => Some(62), + b'_' => Some(63), + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use base64::Engine as _; + + use super::*; + + /// The user agent key and authentication secret of RFC 8291, section 5. + pub(crate) const AUTH: [u8; 16] = [ + 0x05, 0x30, 0x59, 0x32, 0xa1, 0xc7, 0xea, 0xbe, 0x13, 0xb6, 0xce, 0xc9, 0xfd, 0xa4, 0x88, + 0x82, + ]; + pub(crate) const P256DH: [u8; 65] = [ + 0x04, 0x25, 0x71, 0xb2, 0xbe, 0xcd, 0xfd, 0xe3, 0x60, 0x55, 0x1a, 0xaf, 0x1e, 0xd0, 0xf4, + 0xcd, 0x36, 0x6c, 0x11, 0xce, 0xbe, 0x55, 0x5f, 0x89, 0xbc, 0xb7, 0xb1, 0x86, 0xa5, 0x33, + 0x39, 0x17, 0x31, 0x68, 0xec, 0xe2, 0xeb, 0xe0, 0x18, 0x59, 0x7b, 0xd3, 0x04, 0x79, 0xb8, + 0x6e, 0x3c, 0x8f, 0x8e, 0xce, 0xd5, 0x77, 0xca, 0x59, 0x18, 0x7e, 0x92, 0x46, 0x99, 0x0d, + 0xb6, 0x82, 0x00, 0x8b, 0x0e, + ]; + pub(crate) const P256DH_BASE64URL: &str = + "BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4"; + pub(crate) const AUTH_BASE64URL: &str = "BTBZMqHH6r4Tts7J_aSIgg"; + + fn web_push(p256dh: [u8; 65], auth: [u8; 16]) -> Token { + Token::WebPush { + endpoint: "https://push.example.net/sub".to_owned(), + p256dh, + auth, + expires: None, + } + } + + fn fcm(token: &str) -> Token { + Token::Fcm { + token: token.to_owned(), + keys: None, + } + } + + #[test] + fn apns_device_token_is_lowercase_hex_of_every_byte() { + let token = Token::Apns { + device_token: vec![0x00, 0x0f, 0xa0, 0xff, 0x5c], + keys: None, + }; + assert_eq!(token.apns_device_token().as_deref(), Some("000fa0ff5c")); + } + + #[test] + fn apns_device_token_is_none_for_other_transports() { + assert_eq!(fcm("abc").apns_device_token(), None); + assert_eq!(web_push(P256DH, AUTH).apns_device_token(), None); + } + + fn keys(token: &Token) -> WebPushKeys { + token.web_push_subscription().unwrap().keys + } + + #[test] + fn web_push_keys_match_rfc_8291_example() { + let keys = keys(&web_push(P256DH, AUTH)); + assert_eq!(keys.auth, AUTH_BASE64URL); + assert_eq!(keys.p256dh, P256DH_BASE64URL); + } + + #[test] + fn web_push_keys_use_the_url_alphabet_without_padding() { + let mut auth = [0u8; 16]; + auth[..3].copy_from_slice(&[0xfb, 0xff, 0xbf]); + let mut p256dh = [0xffu8; 65]; + p256dh[0] = 0xfb; + let keys = keys(&web_push(p256dh, auth)); + assert_eq!(keys.auth, "-_-_AAAAAAAAAAAAAAAAAA"); + assert!(keys.p256dh.starts_with("-___")); + assert!(keys.p256dh.ends_with("__8")); + assert_eq!(keys.p256dh.len(), 87); + } + + #[test] + fn web_push_subscription_carries_the_endpoint_and_expiry() { + let expires = SystemTime::UNIX_EPOCH + Duration::from_millis(1_791_881_200_386); + let token = Token::WebPush { + endpoint: "https://push.example.net/sub".to_owned(), + p256dh: P256DH, + auth: AUTH, + expires: Some(expires), + }; + let subscription = token.web_push_subscription().unwrap(); + assert_eq!(subscription.endpoint, "https://push.example.net/sub"); + assert_eq!(subscription.expiration_time, Some(expires)); + assert_eq!( + web_push(P256DH, AUTH) + .web_push_subscription() + .unwrap() + .expiration_time, + None + ); + } + + #[test] + fn web_push_subscription_is_none_for_other_transports() { + let apns = Token::Apns { + device_token: vec![1, 2], + keys: None, + }; + assert_eq!(apns.web_push_subscription(), None); + assert_eq!(fcm("abc").web_push_subscription(), None); + let wns = Token::Wns { + channel_uri: "https://wns.example.net/c".to_owned(), + expires: SystemTime::UNIX_EPOCH, + keys: None, + }; + assert_eq!(wns.web_push_subscription(), None); + } + + fn pseudo_random_bytes(length: usize, state: &mut u32) -> Vec { + (0..length) + .map(|_| { + *state = state.wrapping_mul(1_103_515_245).wrapping_add(12_345); + state.to_be_bytes()[0] + }) + .collect() + } + + #[test] + fn base64url_agrees_with_the_base64_crate() { + let mut state = 1u32; + for length in [0usize, 1, 2, 3, 4, 5, 63, 65, 1000, 4096] { + let bytes = pseudo_random_bytes(length, &mut state); + let text = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&bytes); + assert_eq!(base64url::encode(&bytes), text); + assert_eq!(base64url::decode(&text), Some(bytes)); + } + } + + #[test] + fn base64url_decode_rejects_characters_outside_the_alphabet() { + for character in ['+', '/', '=', ' ', '\t', 'é'] { + assert_eq!(base64url::decode(&format!("AB{character}")), None); + } + } + + #[test] + fn base64url_decode_rejects_a_length_of_one_modulo_four() { + for length in [1usize, 5, 9, 1001] { + assert_eq!(base64url::decode(&"A".repeat(length)), None); + } + } +} diff --git a/pushups-token/src/serde_impl.rs b/pushups-token/src/serde_impl.rs new file mode 100644 index 0000000..9d25ab2 --- /dev/null +++ b/pushups-token/src/serde_impl.rs @@ -0,0 +1,261 @@ +//! The JSON shapes of the token's fields: bytes as unpadded base64url, times as milliseconds +//! since the Unix epoch. + +use std::time::{Duration, SystemTime}; + +use serde::de::Error as _; +use serde::{Deserialize as _, Deserializer, Serializer}; + +use crate::base64url; + +fn decode<'de, D: Deserializer<'de>>(deserializer: D) -> Result, D::Error> { + let text = std::borrow::Cow::::deserialize(deserializer)?; + base64url::decode(&text).ok_or_else(|| D::Error::custom("not unpadded base64url")) +} + +fn milliseconds(time: SystemTime) -> Result { + let since_epoch = time + .duration_since(SystemTime::UNIX_EPOCH) + .map_err(|_| E::custom("the time is before the Unix epoch"))?; + u64::try_from(since_epoch.as_millis()) + .map_err(|_| E::custom("the time overflows u64 milliseconds")) +} + +fn from_milliseconds(milliseconds: u64) -> Result { + SystemTime::UNIX_EPOCH + .checked_add(Duration::from_millis(milliseconds)) + .ok_or_else(|| E::custom("the time overflows SystemTime")) +} + +pub(crate) mod bytes { + use super::{Deserializer, Serializer, base64url}; + + pub(crate) fn serialize(bytes: &[u8], serializer: S) -> Result { + serializer.serialize_str(&base64url::encode(bytes)) + } + + pub(crate) fn deserialize<'de, D: Deserializer<'de>>( + deserializer: D, + ) -> Result, D::Error> { + super::decode(deserializer) + } +} + +pub(crate) mod fixed { + use super::{Deserializer, Serializer, base64url}; + + pub(crate) fn serialize( + bytes: &[u8; N], + serializer: S, + ) -> Result { + serializer.serialize_str(&base64url::encode(bytes)) + } + + pub(crate) fn deserialize<'de, D: Deserializer<'de>, const N: usize>( + deserializer: D, + ) -> Result<[u8; N], D::Error> { + let bytes = super::decode(deserializer)?; + let length = bytes.len(); + bytes.try_into().map_err(|_| { + serde::de::Error::custom(format_args!("{length} bytes where {N} are expected")) + }) + } +} + +pub(crate) mod time { + use super::{Deserializer, Serializer, SystemTime}; + + #[cfg_attr( + target_os = "windows", + expect( + clippy::trivially_copy_pass_by_ref, + reason = "`with` passes the field by reference, and Windows's `SystemTime` is 8 bytes" + ) + )] + pub(crate) fn serialize( + time: &SystemTime, + serializer: S, + ) -> Result { + serializer.serialize_u64(super::milliseconds(*time)?) + } + + pub(crate) fn deserialize<'de, D: Deserializer<'de>>( + deserializer: D, + ) -> Result { + super::from_milliseconds(serde::Deserialize::deserialize(deserializer)?) + } +} + +pub(crate) mod optional_time { + use super::{Deserializer, Serializer, SystemTime}; + + #[expect( + clippy::ref_option, + reason = "`serialize_with` passes the field by reference" + )] + pub(crate) fn serialize( + time: &Option, + serializer: S, + ) -> Result { + let Some(time) = time else { + return serializer.serialize_none(); + }; + serializer.serialize_some(&super::milliseconds::(*time)?) + } + + pub(crate) fn deserialize<'de, D: Deserializer<'de>>( + deserializer: D, + ) -> Result, D::Error> { + let milliseconds: Option = serde::Deserialize::deserialize(deserializer)?; + milliseconds.map(super::from_milliseconds).transpose() + } +} + +#[cfg(test)] +mod tests { + use std::time::{Duration, SystemTime}; + + use serde_json::json; + + use crate::tests::{AUTH, AUTH_BASE64URL, P256DH, P256DH_BASE64URL}; + use crate::{EncryptionKeys, Token}; + + const KEYS: EncryptionKeys = EncryptionKeys { + p256dh: P256DH, + auth: AUTH, + }; + + fn at(milliseconds: u64) -> SystemTime { + SystemTime::UNIX_EPOCH + Duration::from_millis(milliseconds) + } + + /// Every transport, with and without keys, in the JSON a server receives, and back. + #[test] + fn every_token_round_trips_through_its_json() { + let keys = json!({ "p256dh": P256DH_BASE64URL, "auth": AUTH_BASE64URL }); + let cases = [ + ( + Token::Apns { + device_token: vec![0xfb, 0xff, 0x00], + keys: Some(KEYS), + }, + json!({ "transport": "apns", "device_token": "-_8A", "keys": keys }), + ), + ( + Token::Fcm { + token: "fcm:token".to_owned(), + keys: None, + }, + json!({ "transport": "fcm", "token": "fcm:token", "keys": null }), + ), + ( + Token::Wns { + channel_uri: "https://wns.example.net/c".to_owned(), + expires: at(1_791_881_200_386), + keys: Some(KEYS), + }, + json!({ + "transport": "wns", + "channel_uri": "https://wns.example.net/c", + "expires": 1_791_881_200_386_u64, + "keys": keys, + }), + ), + ( + Token::WebPush { + endpoint: "https://push.example.net/sub".to_owned(), + p256dh: P256DH, + auth: AUTH, + expires: None, + }, + json!({ + "transport": "web_push", + "endpoint": "https://push.example.net/sub", + "p256dh": P256DH_BASE64URL, + "auth": AUTH_BASE64URL, + "expires": null, + }), + ), + ]; + for (token, expected) in cases { + assert_eq!(serde_json::to_value(&token).unwrap(), expected); + assert_eq!(serde_json::from_value::(expected).unwrap(), token); + } + } + + #[test] + fn a_key_of_the_wrong_length_is_refused() { + let short = &P256DH_BASE64URL[..86]; + let error = serde_json::from_value::(json!({ + "transport": "fcm", + "token": "t", + "keys": { "p256dh": short, "auth": AUTH_BASE64URL }, + })) + .unwrap_err(); + assert!( + error.to_string().contains("64 bytes where 65 are expected"), + "{error}" + ); + } + + #[test] + fn bytes_outside_unpadded_base64url_are_refused() { + for device_token in ["AB+/", "AA==", "A"] { + let error = serde_json::from_value::(json!({ + "transport": "apns", + "device_token": device_token, + "keys": null, + })) + .unwrap_err(); + assert!( + error.to_string().contains("not unpadded base64url"), + "{error}" + ); + } + } + + #[test] + fn an_unknown_transport_or_field_is_refused() { + assert!( + serde_json::from_value::(json!({ "transport": "mqtt", "token": "t" })).is_err() + ); + assert!( + serde_json::from_value::( + json!({ "transport": "fcm", "token": "t", "keys": null, "extra": 1 }) + ) + .is_err() + ); + } + + #[test] + fn a_time_before_the_epoch_does_not_serialise() { + let token = Token::Wns { + channel_uri: "c".to_owned(), + expires: SystemTime::UNIX_EPOCH - Duration::from_millis(1), + keys: None, + }; + let error = serde_json::to_value(&token).unwrap_err(); + assert!( + error.to_string().contains("before the Unix epoch"), + "{error}" + ); + } + + /// The shape a browser's `PushSubscription.toJSON()` gives, which Web Push servers read. + #[test] + fn web_push_subscription_serialises_as_push_subscription_to_json() { + let token = Token::WebPush { + endpoint: "https://push.example.net/sub?a=\"b\"".to_owned(), + p256dh: P256DH, + auth: AUTH, + expires: Some(at(1_791_881_200_386)), + }; + let expected = json!({ + "endpoint": "https://push.example.net/sub?a=\"b\"", + "expirationTime": 1_791_881_200_386_u64, + "keys": { "p256dh": P256DH_BASE64URL, "auth": AUTH_BASE64URL }, + }); + let json = serde_json::to_value(token.web_push_subscription().unwrap()).unwrap(); + assert_eq!(json, expected); + } +} diff --git a/src/android.rs b/src/android.rs index c0321a1..8c1e105 100644 --- a/src/android.rs +++ b/src/android.rs @@ -327,7 +327,7 @@ crate::__pushups_android_exports! { Event::registration_failed(reason) } else { let token: String = env.get_string(&token)?.into(); - Event::Token(Token::Fcm(token)) + Event::Token(Token::Fcm { token, keys: None }) }; DISPATCHER.emit(event); Ok(()) diff --git a/src/apple.rs b/src/apple.rs index cdac4df..f4ed99f 100644 --- a/src/apple.rs +++ b/src/apple.rs @@ -218,7 +218,10 @@ fn became_active() { } fn token(token: Vec) { - DISPATCHER.emit(Event::Token(Token::Apns(token))); + DISPATCHER.emit(Event::Token(Token::Apns { + device_token: token, + keys: None, + })); } fn registration_failed(error: String) { diff --git a/src/config.rs b/src/config.rs index c60a720..e0fc480 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,5 +1,5 @@ -use crate::Error; -use crate::token::base64url_decode_vapid_key; +use crate::{Error, VapidKeyError}; +use pushups_token::base64url; /// What [`install`](crate::install) needs beyond the platform's own configuration. /// @@ -79,10 +79,38 @@ impl VapidPublicKey { /// The key's 87 base64url characters, the form the UnifiedPush transports send. #[cfg(any(target_os = "android", target_os = "linux"))] pub(crate) fn as_base64url(&self) -> String { - crate::token::base64url(&self.0) + base64url::encode(&self.0) } } +/// Decodes the 87 characters of unpadded base64url that encode a 65-byte VAPID public key. +fn base64url_decode_vapid_key(text: &str) -> Result<[u8; 65], VapidKeyError> { + let mut bytes = [0u8; 65]; + let mut written = 0; + let mut pending = 0u16; + let mut pending_bits = 0; + for (index, character) in text.char_indices() { + let byte = + u8::try_from(character).map_err(|_| VapidKeyError::Character { index, character })?; + let sextet = + base64url::sextet(byte).ok_or(VapidKeyError::Character { index, character })?; + pending = (pending << 6) | u16::from(sextet); + pending_bits += 6; + if pending_bits >= 8 { + pending_bits -= 8; + if let Some(byte) = bytes.get_mut(written) { + *byte = (pending >> pending_bits).to_le_bytes()[0]; + } + written += 1; + pending &= (1 << pending_bits) - 1; + } + } + if text.len() != 87 { + return Err(VapidKeyError::Length(text.len())); + } + Ok(bytes) +} + impl From<[u8; 65]> for VapidPublicKey { fn from(bytes: [u8; 65]) -> Self { Self(bytes) @@ -238,3 +266,76 @@ impl WebPushConfig { self } } + +#[cfg(test)] +mod tests { + use base64::Engine as _; + + use super::*; + + const P256DH: [u8; 65] = [ + 0x04, 0x25, 0x71, 0xb2, 0xbe, 0xcd, 0xfd, 0xe3, 0x60, 0x55, 0x1a, 0xaf, 0x1e, 0xd0, 0xf4, + 0xcd, 0x36, 0x6c, 0x11, 0xce, 0xbe, 0x55, 0x5f, 0x89, 0xbc, 0xb7, 0xb1, 0x86, 0xa5, 0x33, + 0x39, 0x17, 0x31, 0x68, 0xec, 0xe2, 0xeb, 0xe0, 0x18, 0x59, 0x7b, 0xd3, 0x04, 0x79, 0xb8, + 0x6e, 0x3c, 0x8f, 0x8e, 0xce, 0xd5, 0x77, 0xca, 0x59, 0x18, 0x7e, 0x92, 0x46, 0x99, 0x0d, + 0xb6, 0x82, 0x00, 0x8b, 0x0e, + ]; + + const P256DH_BASE64URL: &str = + "BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4"; + + #[test] + fn vapid_key_from_base64url_agrees_with_the_base64_crate() { + let mut cases = vec![[0u8; 65], [0xff; 65], P256DH]; + let mut state = 1u32; + cases.extend((0..64).map(|_| { + std::array::from_fn(|_| { + state = state.wrapping_mul(1_103_515_245).wrapping_add(12_345); + state.to_be_bytes()[0] + }) + })); + for bytes in cases { + let text = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes); + assert_eq!( + VapidPublicKey::from_base64url(&text), + Ok(VapidPublicKey::from(bytes)) + ); + } + assert_eq!( + VapidPublicKey::from_base64url(P256DH_BASE64URL), + Ok(VapidPublicKey::from(P256DH)) + ); + } + + #[test] + fn vapid_key_from_base64url_names_the_first_character_outside_the_alphabet() { + let invalid = |index, character| { + Err(Error::InvalidVapidKey(VapidKeyError::Character { + index, + character, + })) + }; + let replaced = |index: usize, with: &str| { + let mut text = P256DH_BASE64URL.to_owned(); + text.replace_range(index..=index, with); + VapidPublicKey::from_base64url(&text) + }; + assert_eq!(replaced(10, "+"), invalid(10, '+')); + assert_eq!(replaced(0, "/"), invalid(0, '/')); + assert_eq!(replaced(86, " "), invalid(86, ' ')); + assert_eq!(replaced(5, "é"), invalid(5, 'é')); + let padded = format!("{P256DH_BASE64URL}="); + assert_eq!(VapidPublicKey::from_base64url(&padded), invalid(87, '=')); + assert_eq!(VapidPublicKey::from_base64url("AB+"), invalid(2, '+')); + } + + #[test] + fn vapid_key_from_base64url_rejects_any_length_but_87() { + for length in [0, 2, 86, 88, 116] { + assert_eq!( + VapidPublicKey::from_base64url(&"A".repeat(length)), + Err(Error::InvalidVapidKey(VapidKeyError::Length(length))) + ); + } + } +} diff --git a/src/delivered.rs b/src/delivered.rs index 8a70270..f124504 100644 --- a/src/delivered.rs +++ b/src/delivered.rs @@ -47,9 +47,12 @@ impl Delivered { let Some(body) = bytes.strip_prefix(HEADER.as_slice()) else { return Vec::new(); }; - body.chunks_exact(8) - .map(|chunk| u64::from_le_bytes(chunk.try_into().expect("chunks of 8 bytes"))) - .collect() + let (chunks, _remainder) = body.as_chunks::<8>(); + let mut remembered = Vec::with_capacity(chunks.len()); + for chunk in chunks { + remembered.push(u64::from_le_bytes(*chunk)); + } + remembered } } diff --git a/src/dispatch.rs b/src/dispatch.rs index a0097ba..f289799 100644 --- a/src/dispatch.rs +++ b/src/dispatch.rs @@ -217,7 +217,10 @@ mod tests { #[test] fn events_before_the_handler_are_replayed_in_order_then_live_events_follow() { let dispatcher = Dispatcher::new(); - dispatcher.emit(Event::Token(Token::Fcm("t".to_owned()))); + dispatcher.emit(Event::Token(Token::Fcm { + token: "t".to_owned(), + keys: None, + })); dispatcher.emit(message(1)); let (seen, handler) = recorder(); dispatcher.set_handler(handler); @@ -225,7 +228,10 @@ mod tests { assert_eq!( *seen.lock(), [ - Event::Token(Token::Fcm("t".to_owned())), + Event::Token(Token::Fcm { + token: "t".to_owned(), + keys: None + }), message(1), message(2) ] diff --git a/src/inbox.rs b/src/inbox.rs index 1c60b33..4fc336b 100644 --- a/src/inbox.rs +++ b/src/inbox.rs @@ -131,7 +131,10 @@ mod tests { session_changed(&inbox, true); assert_eq!( *seen.lock(), - vec![Event::Token(crate::Token::Fcm("queued".to_owned()))] + vec![Event::Token(crate::Token::Fcm { + token: "queued".to_owned(), + keys: None + })] ); } } diff --git a/src/lib.rs b/src/lib.rs index a563aef..7c359da 100755 --- a/src/lib.rs +++ b/src/lib.rs @@ -66,7 +66,6 @@ mod rendering; mod session; #[cfg(any(target_os = "ios", target_os = "macos", test))] mod started; -mod token; #[cfg(any( not(any( target_os = "android", @@ -122,8 +121,8 @@ pub use event::{Event, Message}; pub use notification::Notification; pub use permission::Permission; pub use pushups_macros::{background_handler, firebase_config, notification_service}; +pub use pushups_token::{EncryptionKeys, Token, WebPushKeys, WebPushSubscription}; pub use rendering::{Rendering, RenderingContext, RenderingOutcome, Suppression}; -pub use token::{Token, WebPushKeys, WebPushSubscription}; pub use worker_file::write_service_worker; /// Connects the app to the platform's push service, before the toolkit's event loop starts. diff --git a/src/linux.rs b/src/linux.rs index 387807d..90adb9e 100644 --- a/src/linux.rs +++ b/src/linux.rs @@ -273,14 +273,14 @@ fn take_name(runtime: &Runtime, headless: bool) -> Result<(), Error> { /// Serves the calls a push brought, then exits once idle or replaced by a UI process. fn serve_headless(runtime: &'static Runtime) -> ! { - if let Ok(proxy) = DBusProxy::new(&runtime.connection) { - if let Ok(lost) = proxy.receive_name_lost() { - thread::spawn(move || { - if lost.into_iter().next().is_some() { - runtime.activity.lock().replaced = true; - } - }); - } + if let Ok(proxy) = DBusProxy::new(&runtime.connection) + && let Ok(lost) = proxy.receive_name_lost() + { + thread::spawn(move || { + if lost.into_iter().next().is_some() { + runtime.activity.lock().replaced = true; + } + }); } loop { { @@ -519,10 +519,10 @@ fn load_keys(path: &Path) -> io::Result { /// The stored registration token, or a new one stored in place of a missing one. fn load_token(path: &Path) -> io::Result { - if let Ok(token) = fs::read_to_string(path) { - if !token.is_empty() { - return Ok(token); - } + if let Ok(token) = fs::read_to_string(path) + && !token.is_empty() + { + return Ok(token); } let mut bytes = [0; 16]; getrandom::fill(&mut bytes).map_err(io::Error::other)?; diff --git a/src/queue.rs b/src/queue.rs index f86a150..49061ec 100644 --- a/src/queue.rs +++ b/src/queue.rs @@ -60,7 +60,7 @@ impl Entry { pub(crate) fn into_event(self) -> Event { match self { Self::Message(message) => Event::Message(message), - Self::FcmToken(token) => Event::Token(Token::Fcm(token)), + Self::FcmToken(token) => Event::Token(Token::Fcm { token, keys: None }), Self::MessagesDropped => Event::MessagesDropped, Self::WebPushToken { endpoint, @@ -487,7 +487,10 @@ mod tests { fn entries_become_the_matching_events() { assert_eq!( Entry::FcmToken("t".to_owned()).into_event(), - Event::Token(Token::Fcm("t".to_owned())) + Event::Token(Token::Fcm { + token: "t".to_owned(), + keys: None + }) ); assert_eq!( message(b"p", true).into_event(), diff --git a/src/token.rs b/src/token.rs deleted file mode 100644 index e72003a..0000000 --- a/src/token.rs +++ /dev/null @@ -1,417 +0,0 @@ -use std::time::SystemTime; - -use crate::VapidKeyError; - -/// A push token, the address the app's server sends pushes to. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -#[non_exhaustive] -pub enum Token { - /// An APNs device token. Apple gives no guarantee on its length, which is 32 bytes today. - Apns(Vec), - /// An FCM registration token. - Fcm(String), - /// A WNS channel. - Wns { - /// The channel URI the server posts to. - channel_uri: String, - /// When the channel stops accepting pushes. - expires: SystemTime, - }, - /// A Web Push subscription. - WebPush { - /// The push service URL the server posts to. - endpoint: String, - /// The subscription's P-256 public key, uncompressed. - p256dh: [u8; 65], - /// The subscription's authentication secret. - auth: [u8; 16], - /// When the subscription expires, if the push service set an expiry. - expires: Option, - }, -} - -/// The keys of a Web Push subscription, base64url encoded without padding. -/// -/// These are the `keys` of a browser `PushSubscription` serialised to JSON, and the two strings -/// `web_push::SubscriptionInfo::new` takes after the endpoint. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -#[cfg_attr(feature = "serde", derive(serde::Serialize))] -pub struct WebPushKeys { - /// The subscription's P-256 public key. - pub p256dh: String, - /// The subscription's authentication secret. - pub auth: String, -} - -/// A Web Push subscription, everything the app's server needs to send the app pushes. -/// -/// With the `serde` feature it serialises as a browser's `PushSubscription.toJSON()`, the JSON -/// that `web-push` (Rust and npm) and `pywebpush` read. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -#[cfg_attr(feature = "serde", derive(serde::Serialize))] -#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))] -pub struct WebPushSubscription { - /// The push service URL the server posts to. - pub endpoint: String, - /// When the subscription expires, if the push service set an expiry, serialised as - /// milliseconds since the Unix epoch or `null`. - #[cfg_attr(feature = "serde", serde(serialize_with = "milliseconds_since_epoch"))] - pub expiration_time: Option, - /// The subscription's keys. - pub keys: WebPushKeys, -} - -#[cfg(feature = "serde")] -#[expect( - clippy::ref_option, - reason = "`serialize_with` passes the field by reference" -)] -fn milliseconds_since_epoch( - time: &Option, - serializer: S, -) -> Result { - use serde::ser::Error as _; - let Some(time) = time else { - return serializer.serialize_none(); - }; - let since_epoch = time - .duration_since(SystemTime::UNIX_EPOCH) - .map_err(|_| S::Error::custom("the expiration time is before the Unix epoch"))?; - let milliseconds = u64::try_from(since_epoch.as_millis()) - .map_err(|_| S::Error::custom("the expiration time overflows u64 milliseconds"))?; - serializer.serialize_some(&milliseconds) -} - -impl Token { - /// The APNs device token as lowercase hex, the form APNs request paths and `a2` take. - #[must_use] - pub fn apns_device_token(&self) -> Option { - const DIGITS: &[u8; 16] = b"0123456789abcdef"; - let Self::Apns(bytes) = self else { - return None; - }; - let mut hex = String::with_capacity(bytes.len() * 2); - for byte in bytes { - hex.push(char::from(DIGITS[usize::from(byte >> 4)])); - hex.push(char::from(DIGITS[usize::from(byte & 0x0f)])); - } - Some(hex) - } - - /// The Web Push subscription, with its keys base64url encoded as a server expects them. - #[must_use] - pub fn web_push_subscription(&self) -> Option { - let Self::WebPush { - endpoint, - p256dh, - auth, - expires, - } = self - else { - return None; - }; - Some(WebPushSubscription { - endpoint: endpoint.clone(), - expiration_time: *expires, - keys: WebPushKeys { - p256dh: base64url(p256dh), - auth: base64url(auth), - }, - }) - } -} - -/// RFC 4648 base64 with the URL and filename safe alphabet and no padding. -pub(crate) fn base64url(bytes: &[u8]) -> String { - const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; - let symbol = |index: u8| char::from(ALPHABET[usize::from(index)]); - let mut encoded = String::with_capacity(bytes.len().div_ceil(3) * 4); - for chunk in bytes.chunks(3) { - let first = chunk[0]; - let second = chunk.get(1).copied().unwrap_or(0); - let third = chunk.get(2).copied().unwrap_or(0); - encoded.push(symbol(first >> 2)); - encoded.push(symbol(((first & 0x03) << 4) | (second >> 4))); - if chunk.len() > 1 { - encoded.push(symbol(((second & 0x0f) << 2) | (third >> 6))); - } - if chunk.len() > 2 { - encoded.push(symbol(third & 0x3f)); - } - } - encoded -} - -/// Decodes base64url without padding, or none when the text is not unpadded base64url. -/// -/// The unused bits of a trailing sextet may hold anything, so this discards them where the -/// reference decoders reject them. -#[cfg(any(target_os = "windows", test))] -pub(crate) fn base64url_decode(text: &str) -> Option> { - if text.len() % 4 == 1 { - return None; - } - let mut bytes = Vec::with_capacity(text.len() / 4 * 3); - let mut pending = 0u16; - let mut pending_bits = 0; - for character in text.chars() { - let sextet = base64url_sextet(character)?; - pending = (pending << 6) | u16::from(sextet); - pending_bits += 6; - if pending_bits >= 8 { - pending_bits -= 8; - // The shift leaves the byte in the low eight bits. - bytes.push((pending >> pending_bits).to_le_bytes()[0]); - pending &= (1 << pending_bits) - 1; - } - } - Some(bytes) -} - -/// Decodes the 87 characters of unpadded base64url that encode a 65-byte VAPID public key. -pub(crate) fn base64url_decode_vapid_key(text: &str) -> Result<[u8; 65], VapidKeyError> { - let mut bytes = [0u8; 65]; - let mut written = 0; - let mut pending = 0u16; - let mut pending_bits = 0; - for (index, character) in text.char_indices() { - let sextet = - base64url_sextet(character).ok_or(VapidKeyError::Character { index, character })?; - pending = (pending << 6) | u16::from(sextet); - pending_bits += 6; - if pending_bits >= 8 { - pending_bits -= 8; - if let Some(byte) = bytes.get_mut(written) { - *byte = (pending >> pending_bits).to_le_bytes()[0]; - } - written += 1; - pending &= (1 << pending_bits) - 1; - } - } - if text.len() != 87 { - return Err(VapidKeyError::Length(text.len())); - } - Ok(bytes) -} - -fn base64url_sextet(character: char) -> Option { - let byte = u8::try_from(character).ok()?; - match byte { - b'A'..=b'Z' => Some(byte - b'A'), - b'a'..=b'z' => Some(byte - b'a' + 26), - b'0'..=b'9' => Some(byte - b'0' + 52), - b'-' => Some(62), - b'_' => Some(63), - _ => None, - } -} - -#[cfg(test)] -mod tests { - use std::time::Duration; - - use base64::Engine as _; - - use super::*; - use crate::{Error, VapidKeyError, VapidPublicKey}; - - const AUTH: [u8; 16] = [ - 0x05, 0x30, 0x59, 0x32, 0xa1, 0xc7, 0xea, 0xbe, 0x13, 0xb6, 0xce, 0xc9, 0xfd, 0xa4, 0x88, - 0x82, - ]; - const P256DH: [u8; 65] = [ - 0x04, 0x25, 0x71, 0xb2, 0xbe, 0xcd, 0xfd, 0xe3, 0x60, 0x55, 0x1a, 0xaf, 0x1e, 0xd0, 0xf4, - 0xcd, 0x36, 0x6c, 0x11, 0xce, 0xbe, 0x55, 0x5f, 0x89, 0xbc, 0xb7, 0xb1, 0x86, 0xa5, 0x33, - 0x39, 0x17, 0x31, 0x68, 0xec, 0xe2, 0xeb, 0xe0, 0x18, 0x59, 0x7b, 0xd3, 0x04, 0x79, 0xb8, - 0x6e, 0x3c, 0x8f, 0x8e, 0xce, 0xd5, 0x77, 0xca, 0x59, 0x18, 0x7e, 0x92, 0x46, 0x99, 0x0d, - 0xb6, 0x82, 0x00, 0x8b, 0x0e, - ]; - - fn web_push(p256dh: [u8; 65], auth: [u8; 16]) -> Token { - Token::WebPush { - endpoint: "https://push.example.net/sub".to_owned(), - p256dh, - auth, - expires: None, - } - } - - #[test] - fn apns_device_token_is_lowercase_hex_of_every_byte() { - let token = Token::Apns(vec![0x00, 0x0f, 0xa0, 0xff, 0x5c]); - assert_eq!(token.apns_device_token().as_deref(), Some("000fa0ff5c")); - } - - #[test] - fn apns_device_token_is_none_for_other_transports() { - assert_eq!(Token::Fcm("abc".to_owned()).apns_device_token(), None); - assert_eq!(web_push(P256DH, AUTH).apns_device_token(), None); - } - - fn keys(token: &Token) -> WebPushKeys { - token.web_push_subscription().unwrap().keys - } - - /// The user agent key and authentication secret of RFC 8291, section 5. - #[test] - fn web_push_keys_match_rfc_8291_example() { - let keys = keys(&web_push(P256DH, AUTH)); - assert_eq!(keys.auth, "BTBZMqHH6r4Tts7J_aSIgg"); - assert_eq!(keys.p256dh, P256DH_BASE64URL); - } - - const P256DH_BASE64URL: &str = - "BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4"; - - #[test] - fn web_push_keys_use_the_url_alphabet_without_padding() { - let mut auth = [0u8; 16]; - auth[..3].copy_from_slice(&[0xfb, 0xff, 0xbf]); - let mut p256dh = [0xffu8; 65]; - p256dh[0] = 0xfb; - let keys = keys(&web_push(p256dh, auth)); - assert_eq!(keys.auth, "-_-_AAAAAAAAAAAAAAAAAA"); - assert!(keys.p256dh.starts_with("-___")); - assert!(keys.p256dh.ends_with("__8")); - assert_eq!(keys.p256dh.len(), 87); - } - - #[test] - fn web_push_subscription_carries_the_endpoint_and_expiry() { - let expires = SystemTime::UNIX_EPOCH + Duration::from_millis(1_791_881_200_386); - let token = Token::WebPush { - endpoint: "https://push.example.net/sub".to_owned(), - p256dh: P256DH, - auth: AUTH, - expires: Some(expires), - }; - let subscription = token.web_push_subscription().unwrap(); - assert_eq!(subscription.endpoint, "https://push.example.net/sub"); - assert_eq!(subscription.expiration_time, Some(expires)); - assert_eq!( - web_push(P256DH, AUTH) - .web_push_subscription() - .unwrap() - .expiration_time, - None - ); - } - - #[test] - fn web_push_subscription_is_none_for_other_transports() { - assert_eq!(Token::Apns(vec![1, 2]).web_push_subscription(), None); - assert_eq!(Token::Fcm("abc".to_owned()).web_push_subscription(), None); - let wns = Token::Wns { - channel_uri: "https://wns.example.net/c".to_owned(), - expires: SystemTime::UNIX_EPOCH, - }; - assert_eq!(wns.web_push_subscription(), None); - } - - /// The shape a browser's `PushSubscription.toJSON()` gives, which Web Push servers read. - #[cfg(feature = "serde")] - #[test] - fn web_push_subscription_serialises_as_push_subscription_to_json() { - let token = Token::WebPush { - endpoint: "https://push.example.net/sub?a=\"b\"".to_owned(), - p256dh: P256DH, - auth: AUTH, - expires: Some(SystemTime::UNIX_EPOCH + Duration::from_millis(1_791_881_200_386)), - }; - let expected = serde_json::json!({ - "endpoint": "https://push.example.net/sub?a=\"b\"", - "expirationTime": 1_791_881_200_386_u64, - "keys": { "p256dh": P256DH_BASE64URL, "auth": "BTBZMqHH6r4Tts7J_aSIgg" }, - }); - let json = serde_json::to_value(token.web_push_subscription().unwrap()).unwrap(); - assert_eq!(json, expected); - let json = serde_json::to_value(web_push(P256DH, AUTH).web_push_subscription()).unwrap(); - assert_eq!(json["expirationTime"], serde_json::Value::Null); - } - - #[test] - fn vapid_key_from_base64url_agrees_with_the_base64_crate() { - let mut cases = vec![[0u8; 65], [0xff; 65], P256DH]; - let mut state = 1u32; - cases.extend((0..64).map(|_| { - std::array::from_fn(|_| { - state = state.wrapping_mul(1_103_515_245).wrapping_add(12_345); - state.to_be_bytes()[0] - }) - })); - for bytes in cases { - let text = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes); - assert_eq!(base64url(&bytes), text); - assert_eq!( - VapidPublicKey::from_base64url(&text), - Ok(VapidPublicKey::from(bytes)) - ); - } - assert_eq!( - VapidPublicKey::from_base64url(P256DH_BASE64URL), - Ok(VapidPublicKey::from(P256DH)) - ); - } - - #[test] - fn vapid_key_from_base64url_names_the_first_character_outside_the_alphabet() { - let invalid = |index, character| { - Err(Error::InvalidVapidKey(VapidKeyError::Character { - index, - character, - })) - }; - let replaced = |index: usize, with: &str| { - let mut text = P256DH_BASE64URL.to_owned(); - text.replace_range(index..=index, with); - VapidPublicKey::from_base64url(&text) - }; - assert_eq!(replaced(10, "+"), invalid(10, '+')); - assert_eq!(replaced(0, "/"), invalid(0, '/')); - assert_eq!(replaced(86, " "), invalid(86, ' ')); - assert_eq!(replaced(5, "é"), invalid(5, 'é')); - let padded = format!("{P256DH_BASE64URL}="); - assert_eq!(VapidPublicKey::from_base64url(&padded), invalid(87, '=')); - assert_eq!(VapidPublicKey::from_base64url("AB+"), invalid(2, '+')); - } - - #[test] - fn vapid_key_from_base64url_rejects_any_length_but_87() { - for length in [0, 2, 86, 88, 116] { - assert_eq!( - VapidPublicKey::from_base64url(&"A".repeat(length)), - Err(Error::InvalidVapidKey(VapidKeyError::Length(length))) - ); - } - } - - #[test] - fn base64url_decode_agrees_with_the_base64_crate() { - let mut state = 1u32; - for length in [0usize, 1, 2, 3, 4, 5, 63, 1000, 4096] { - let bytes = (0..length) - .map(|_| { - state = state.wrapping_mul(1_103_515_245).wrapping_add(12_345); - state.to_be_bytes()[0] - }) - .collect::>(); - let text = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&bytes); - assert_eq!(base64url(&bytes), text); - assert_eq!(base64url_decode(&text), Some(bytes)); - } - } - - #[test] - fn base64url_decode_rejects_characters_outside_the_alphabet() { - for character in ['+', '/', '=', ' ', '\t', 'é'] { - assert_eq!(base64url_decode(&format!("AB{character}")), None); - } - } - - #[test] - fn base64url_decode_rejects_a_length_of_one_modulo_four() { - for length in [1usize, 5, 9, 1001] { - assert_eq!(base64url_decode(&"A".repeat(length)), None); - } - } -} diff --git a/src/windows.rs b/src/windows.rs index 4bbc6a5..511db85 100644 --- a/src/windows.rs +++ b/src/windows.rs @@ -119,7 +119,7 @@ const DATA_KEY: &str = "pushups-data"; /// The value the builder's `AddArgument` takes for a notification's `data`, base64url so the /// builder's own encoding is a no-op. fn launch_argument(data: &str) -> String { - crate::token::base64url(data.as_bytes()) + pushups_token::base64url::encode(data.as_bytes()) } /// The `data` bytes a launch attribute carries, or none when it lacks the key or its value is @@ -127,7 +127,7 @@ fn launch_argument(data: &str) -> String { fn launch_argument_data(argument: &str) -> Option> { for (key, value) in argument.split(';').filter_map(|pair| pair.split_once('=')) { if key == DATA_KEY { - return crate::token::base64url_decode(value); + return pushups_token::base64url::decode(value); } } None @@ -568,6 +568,7 @@ fn create_channel(remote_id: windows_core::GUID) -> Result { Ok(Token::Wns { channel_uri, expires, + keys: None, }) } From 78481125ddabdb05b4c5b05a4f1d070b0c1ee0ae Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Sun, 25 Oct 2026 19:32:15 +0100 Subject: [PATCH 2/9] Add rendering_handler on every native platform and the encryption key store core --- .github/workflows/ci.yml | 8 +- Cargo.lock | 138 +++++ Cargo.toml | 36 +- README.md | 6 +- .../src/main/kotlin/rs/pushups/Delivery.kt | 11 +- .../kotlin/rs/pushups/RenderingHandler.kt | 12 + ci/android/probe/Cargo.toml | 3 +- ci/android/probe/src/lib.rs | 37 +- ci/android/run.sh | 1 + .../androidTest/kotlin/rs/pushups/ci/Probe.kt | 4 + .../rs/pushups/ci/RenderingHandlerTest.kt | 130 +++++ ci/apple/probe/src/lib.rs | 2 +- docs/mobile-notifications.md | 11 +- examples/dioxus/Cargo.lock | 9 +- examples/dioxus/src/background.rs | 15 +- examples/support/src/lib.rs | 25 +- examples/tao/src/main.rs | 12 + examples/tauri/src/lib.rs | 12 + examples/winit/src/app.rs | 12 + ios/PushupsNotificationService.swift | 2 +- pushups-macros/src/lib.rs | 81 ++- ...cation_service.rs => rendering_handler.rs} | 12 +- src/apple/service.rs | 24 +- src/backend.rs | 7 + src/envelope.rs | 200 +++++++ src/key_store.rs | 314 +++++++++++ src/lib.rs | 60 +- src/linux.rs | 1 + src/private.rs | 116 +++- src/rendering.rs | 523 +++++++++++++----- src/webpush_crypto.rs | 46 +- src/windows.rs | 1 + tests/linux_notifications.rs | 170 +++++- tests/windows.rs | 20 +- 34 files changed, 1848 insertions(+), 213 deletions(-) create mode 100644 android/src/main/kotlin/rs/pushups/RenderingHandler.kt create mode 100644 ci/android/src/androidTest/kotlin/rs/pushups/ci/RenderingHandlerTest.kt rename pushups-macros/tests/{notification_service.rs => rendering_handler.rs} (78%) create mode 100644 src/envelope.rs create mode 100644 src/key_store.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cb1c04b..d47d510 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -96,10 +96,10 @@ jobs: # Every feature but `dioxus`, which needs the Rust version Dioxus 0.7 apps already need. A # check needs no linker, so the Windows target runs here too. - run: cargo +1.88 check --workspace --features stream,serde - - run: cargo +1.88 check --workspace --features stream,serde --target aarch64-linux-android - - run: cargo +1.88 check --workspace --features stream,serde --target aarch64-apple-ios - - run: cargo +1.88 check --workspace --features stream,serde --target aarch64-apple-darwin - - run: cargo +1.88 check --workspace --features stream,serde --target x86_64-pc-windows-msvc + - run: cargo +1.88 check --workspace --features stream,serde,encryption --target aarch64-linux-android + - run: cargo +1.88 check --workspace --features stream,serde,encryption --target aarch64-apple-ios + - run: cargo +1.88 check --workspace --features stream,serde,encryption --target aarch64-apple-darwin + - run: cargo +1.88 check --workspace --features stream,serde,encryption --target x86_64-pc-windows-msvc tauri-plugin: name: tauri-plugin-pushups diff --git a/Cargo.lock b/Cargo.lock index e5e2b35..50f1349 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -37,6 +37,43 @@ dependencies = [ "ghash", ] +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "android-native-keyring-store" +version = "1.0.0" +source = "git+https://github.com/open-source-cooperative/android-native-keyring-store?rev=56f0c537a7c1#56f0c537a7c13eee1715c8a77b0301ab6cd44b32" +dependencies = [ + "base64", + "jni", + "keyring-core", + "log", + "ndk-context", + "regex", + "serde", + "serde_json", + "thiserror 2.0.21", + "tracing", +] + +[[package]] +name = "apple-native-keyring-store" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b350bfd03649e07aa05c0a81b3e15934374e585c98204a57e20b9d49f49bb9a" +dependencies = [ + "keyring-core", + "log", + "security-framework", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -241,6 +278,12 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.12.1" @@ -343,6 +386,22 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpubits" version = "0.1.1" @@ -787,6 +846,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keyring-core" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29fbf4b551298c67925f38cf341a1ada797a75f1837bdf8ae1fc8e2ebc6efb59" +dependencies = [ + "log", +] + [[package]] name = "libc" version = "0.2.190" @@ -1148,6 +1216,8 @@ name = "pushups" version = "0.1.0" dependencies = [ "aes-gcm", + "android-native-keyring-store", + "apple-native-keyring-store", "base64", "block2", "dispatch2", @@ -1158,6 +1228,7 @@ dependencies = [ "hkdf", "jni", "js-sys", + "keyring-core", "libc", "linkme", "manganis", @@ -1169,6 +1240,7 @@ dependencies = [ "p256", "parking_lot", "pushups-macros", + "pushups-sender", "pushups-token", "serde_json", "sha2", @@ -1182,6 +1254,7 @@ dependencies = [ "windows-core", "windows-future", "windows-link", + "windows-native-keyring-store", "zbus", ] @@ -1249,6 +1322,35 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "rfc6979" version = "0.6.0" @@ -1307,6 +1409,29 @@ dependencies = [ "zeroize", ] +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "serde" version = "1.0.229" @@ -1777,6 +1902,19 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-native-keyring-store" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8" +dependencies = [ + "byteorder", + "keyring-core", + "regex", + "windows-sys 0.61.2", + "zeroize", +] + [[package]] name = "windows-numerics" version = "0.3.1" diff --git a/Cargo.toml b/Cargo.toml index 10a0fc9..c84dfc3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,19 @@ dioxus = ["dep:manganis"] # `Serialize` and `Deserialize` on `Token`, and `Serialize` on `WebPushSubscription` in the shape of # the browser's `PushSubscription.toJSON()`. serde = ["pushups-token/serde"] +# The keys the device decrypts its pushes with, in the platform key store, and the decryption +# of every envelope before the app's callbacks, on Android, iOS, macOS and Windows. +encryption = [ + "dep:aes-gcm", + "dep:getrandom", + "dep:hkdf", + "dep:keyring-core", + "dep:p256", + "dep:sha2", + "dep:android-native-keyring-store", + "dep:apple-native-keyring-store", + "dep:windows-native-keyring-store", +] [workspace] members = ["pushups-macros", "pushups-sender", "pushups-token"] @@ -27,6 +40,13 @@ members = ["pushups-macros", "pushups-sender", "pushups-token"] exclude = ["tauri-plugin-pushups"] [dependencies] +# The RFC 8291 crypto, optional here and unconditional on Linux, which keeps its own copies for +# the `UnifiedPush` connector. +aes-gcm = { version = "0.11", optional = true } +getrandom = { version = "0.4", optional = true } +hkdf = { version = "0.13", optional = true } +p256 = { version = "0.14", optional = true, features = ["ecdh"] } +sha2 = { version = "0.11", optional = true } futures-channel = { version = "0.3", optional = true, default-features = false, features = ["std"] } futures-core = { version = "0.3", optional = true, default-features = false } parking_lot = "0.12" @@ -37,6 +57,8 @@ thiserror = "2" [target.'cfg(target_os = "android")'.dependencies] futures-channel = { version = "0.3", default-features = false, features = ["std"] } jni = "0.21" +# The branch of PR 50, pinned until its release, which this crate's publishing waits for. +android-native-keyring-store = { git = "https://github.com/open-source-cooperative/android-native-keyring-store", rev = "56f0c537a7c1", optional = true } [target.'cfg(any(target_os = "android", all(target_arch = "wasm32", target_os = "unknown")))'.dependencies] # The `dioxus` the app depends on resolves this to its own version. @@ -46,7 +68,11 @@ manganis = { version = "0.7", optional = true } # `flock`, which keeps the queue file to one owner process, and `dlsym` on Apple. libc = "0.2" +[target.'cfg(any(target_os = "android", target_os = "ios", target_os = "macos", target_os = "windows"))'.dependencies] +keyring-core = { version = "1", optional = true } + [target.'cfg(any(target_os = "ios", target_os = "macos"))'.dependencies] +apple-native-keyring-store = { version = "1.0.2", optional = true, features = ["protected"] } block2 = "0.6" dispatch2 = { version = "0.3", default-features = false, features = ["std", "block2"] } futures-channel = { version = "0.3", default-features = false, features = ["std"] } @@ -79,6 +105,7 @@ windows-collections = "0.3" windows-core = "0.62" windows-future = "0.3" windows-link = "0.2" +windows-native-keyring-store = { version = "1.1.0", optional = true } [target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies] js-sys = "0.3" @@ -89,10 +116,13 @@ web-sys = { version = "0.3", features = ["Document", "Navigator", "Node", "Notif [dev-dependencies] # The reference decoder the base64url tests compare against. base64 = "0.22" +# The public key derivation of tests/linux_notifications.rs. +p256 = { version = "0.14", features = ["ecdh"] } futures-executor = "0.3" +keyring-core = { version = "1" } +pushups-sender = { path = "pushups-sender" } serde_json = "1" tempfile = "3" - # The fake distributor of tests/linux.rs. [target.'cfg(target_os = "linux")'.dev-dependencies] zbus = "5" @@ -119,3 +149,7 @@ missing_errors_doc = "warn" missing_panics_doc = "warn" allow_attributes = "deny" allow_attributes_without_reason = "deny" + +# The native key stores are referenced only by the platform `install` wiring. +[lints.cargo] +unused_dependencies = "allow" diff --git a/README.md b/README.md index 1c6a953..a0b0f7d 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ pushups::set_handler(|event| match event { let _ = pushups::register(); ``` -The handler runs on the platform's delivery thread, so waking the toolkit is its job. The `stream` feature adds `pushups::events()`, the same events as a stream. +The handler runs on the platform's delivery thread, so waking the toolkit is its job. The `stream` feature adds `pushups::events()`, the same events as a stream. The `encryption` feature makes the pushes end-to-end encrypted, the device's keys in the platform key store, and `pushups::rotate_keys()` replaces the keys and registers again. ## Support @@ -70,7 +70,7 @@ fn on_push(context: Context<'_>, message: Message) { ## iOS and macOS -The crate adds its push methods to whatever app delegate the toolkit installs, while closed-app iOS alert transformation uses a separate [`notification_service`] Rust callback and [Swift extension adapter](https://github.com/LucaCappelletti94/pushups/blob/main/docs/mobile-notifications.md). The bundle needs the push entitlement and, on iOS, the `remote-notification` background mode, which a `dx` app sets in `Dioxus.toml`. A Tauri app gets the iOS entitlement and the background mode from `tauri-plugin-pushups` and names a macOS entitlements file in `tauri.conf.json`, as [`examples/tauri`](https://github.com/LucaCappelletti94/pushups/tree/main/examples/tauri) does. +The crate adds its push methods to whatever app delegate the toolkit installs, while closed-app iOS alert transformation uses a separate [`rendering_handler`] Rust callback and [Swift extension adapter](https://github.com/LucaCappelletti94/pushups/blob/main/docs/mobile-notifications.md). The bundle needs the push entitlement and, on iOS, the `remote-notification` background mode, which a `dx` app sets in `Dioxus.toml`. A Tauri app gets the iOS entitlement and the background mode from `tauri-plugin-pushups` and names a macOS entitlements file in `tauri.conf.json`, as [`examples/tauri`](https://github.com/LucaCappelletti94/pushups/tree/main/examples/tauri) does. ```toml [ios.entitlements] @@ -102,7 +102,7 @@ let config = Config::new().web(WebPushConfig::new(key)); ## Linux -The crate speaks UnifiedPush over D-Bus to the user's distributor, such as KDE's, and a push to a closed app starts it through D-Bus activation for the background handler. The desktop shows nothing for a push by itself, so the app calls `Notification::show`, and a tap on a notification with `data` reaches the app as a message. Through the desktop portal, 1.19.4 or later, a tap on a closed app opens it. Without that portal a tap reaches the app only while the process that showed the notification runs. `LinuxConfig::app_notifications` names the app and its icon on the notifications, otherwise they carry the app id. +The crate speaks UnifiedPush over D-Bus to the user's distributor, such as KDE's, and a push to a closed app starts it through D-Bus activation for the background handler. The desktop shows nothing for a push by itself, so the app's `rendering_handler` calls `Notification::show` with the notification it returns, and a tap on a notification with `data` reaches the app as a message. Through the desktop portal, 1.19.4 or later, a tap on a closed app opens it. Without that portal a tap reaches the app only while the process that showed the notification runs. `LinuxConfig::app_notifications` names the app and its icon on the notifications, otherwise they carry the app id. ```rust use pushups::{Config, LinuxConfig, UnifiedPushConfig, VapidPublicKey}; diff --git a/android/src/main/kotlin/rs/pushups/Delivery.kt b/android/src/main/kotlin/rs/pushups/Delivery.kt index 3ddc658..36d50f4 100644 --- a/android/src/main/kotlin/rs/pushups/Delivery.kt +++ b/android/src/main/kotlin/rs/pushups/Delivery.kt @@ -4,8 +4,8 @@ import android.content.Context import android.util.Log /** - * Hands one push to Rust, which persists or emits it, then runs the app's background handler - * when one exists. FCM and UnifiedPush both deliver through here. + * Hands one push to Rust, which persists or emits it, then runs the app's rendering handler + * and background handler when they exist. FCM and UnifiedPush both deliver through here. */ internal fun deliverPush(context: Context, payload: ByteArray) { if (!ProcessState.loaded) { @@ -14,6 +14,13 @@ internal fun deliverPush(context: Context, payload: ByteArray) { } val startedApp = ProcessState.pushStartedApp() invokeNative("onMessage") { Native.onMessage(payload, startedApp) } + try { + RenderingHandler.render(context, payload, startedApp) + } catch (e: UnsatisfiedLinkError) { + Log.i(TAG, "no rendering handler") + } catch (e: RuntimeException) { + Log.e(TAG, "rendering handler", e) + } try { BackgroundHandler.handle(context, payload, startedApp) } catch (e: UnsatisfiedLinkError) { diff --git a/android/src/main/kotlin/rs/pushups/RenderingHandler.kt b/android/src/main/kotlin/rs/pushups/RenderingHandler.kt new file mode 100644 index 0000000..d7f5188 --- /dev/null +++ b/android/src/main/kotlin/rs/pushups/RenderingHandler.kt @@ -0,0 +1,12 @@ +package rs.pushups + +import android.content.Context + +/** + * App-implemented through pushups-macros (rendering_handler). Until the app + * declares a handler the symbol is missing and surfaces as an UnsatisfiedLinkError. + */ +object RenderingHandler { + @JvmStatic + external fun render(context: Context, payload: ByteArray, startedApp: Boolean) +} diff --git a/ci/android/probe/Cargo.toml b/ci/android/probe/Cargo.toml index eceb353..a338a57 100644 --- a/ci/android/probe/Cargo.toml +++ b/ci/android/probe/Cargo.toml @@ -18,9 +18,10 @@ parking_lot = "0.12" pushups = { path = "../../.." } # What the app compiles in, chosen per run.sh variant: one Firebase configuration or none, and -# the background handler or not. +# the background handler or the rendering handler. [features] background-handler = [] +rendering-handler = [] # The example's real configuration. config-real = [] # The repository's fixture, whose package matches and whose keys Firebase rejects. diff --git a/ci/android/probe/src/lib.rs b/ci/android/probe/src/lib.rs index 8bac075..953412e 100644 --- a/ci/android/probe/src/lib.rs +++ b/ci/android/probe/src/lib.rs @@ -1,6 +1,6 @@ //! The app library the Android instrumented tests load in place of a real app's cdylib. //! -//! It holds the Firebase configuration and the background handler, as an app would, and exports +//! It holds the Firebase configuration and the app's handlers, as an app would, and exports //! `rs.pushups.ci.Probe`, through which the Kotlin tests call `pushups`' public API and read //! back every event as a line of text. It is built with `-Cinstrument-coverage`, and //! `Probe.writeCoverage` writes the profile the CI job turns into LCOV. @@ -26,6 +26,9 @@ pushups::firebase_config!("mismatched-services.json"); static EVENTS: Mutex> = Mutex::new(Vec::new()); /// Every background push and fixture presentation failure, one line each. static HANDLED: Mutex> = Mutex::new(Vec::new()); +/// Every rendering push, one line each. +#[cfg(feature = "rendering-handler")] +static RENDERED: Mutex> = Mutex::new(Vec::new()); /// The answer to the last `startPermission`, until `permission` takes it. static PERMISSION: Mutex> = Mutex::new(None); @@ -54,8 +57,29 @@ fn on_push(context: pushups::Context<'_>, message: Message) { } } +/// The rendering push the fixture carries, as `{"rendered":""}` with a unique `id`. +#[cfg(feature = "rendering-handler")] +#[pushups::rendering_handler] +fn on_render( + _context: pushups::RenderingContext, + message: Message, +) -> Result { + RENDERED.lock().push(describe_message(&message)); + Ok(rendering_notification(&message.payload).into()) +} + +/// The notification a rendering test push shows, keyed as `{"rendered":""}`. +#[cfg(feature = "rendering-handler")] +fn rendering_notification(payload: &[u8]) -> Notification { + let id = fixture_id(payload, "rendered").unwrap_or("unknown"); + Notification::new(format!("pushups {id}")) + .body("shown by the rendering handler") + .tag(id) + .data(String::from_utf8_lossy(payload).into_owned()) +} + /// The unique `id` a fixture push carries as `{"":""}`. -#[cfg(feature = "background-handler")] +#[cfg(any(feature = "background-handler", feature = "rendering-handler"))] fn fixture_id<'a>(payload: &'a [u8], key: &str) -> Option<&'a str> { let text = std::str::from_utf8(payload).ok()?; let id = text @@ -305,6 +329,15 @@ pushups::__private::android::exports! { java_string(env, Some(HANDLED.lock().join("\n"))) } + /// `Probe.rendered()`: every push the rendering handler received, one per line. + #[cfg(feature = "rendering-handler")] + fn Java_rs_pushups_ci_Probe_rendered<'local>( + env: JNIEnv<'local>, + _class: JClass<'local>, + ) -> jstring { + java_string(env, Some(RENDERED.lock().join("\n"))) + } + /// `Probe.presentBytes(...)`: `null`, or the error of `Notification::show`. `data` arrives as /// UTF-8 bytes, which JNI 0.21's string decoding would corrupt as a Java string. fn Java_rs_pushups_ci_Probe_presentBytes<'local>( diff --git a/ci/android/run.sh b/ci/android/run.sh index cea8080..abd6dbc 100755 --- a/ci/android/run.sh +++ b/ci/android/run.sh @@ -116,6 +116,7 @@ run_variant jni-boundary "config-real" default NativeBoundaryTest run_variant full "config-real background-handler" default PushupsTest run_variant fallback-name config-real fallback-name FallbackNameTest run_variant no-background-handler config-real default NoBackgroundHandlerTest +run_variant rendering "config-real rendering-handler" default RenderingHandlerTest run_variant unconfigured "" default RegistrationFailsTest expect "FCM is not configured" run_variant mismatched config-mismatched default RegistrationFailsTest expect "FCM is not configured" run_variant rejected config-fixture default RegistrationFailsTest expect "Please set a valid API key" diff --git a/ci/android/src/androidTest/kotlin/rs/pushups/ci/Probe.kt b/ci/android/src/androidTest/kotlin/rs/pushups/ci/Probe.kt index edc3628..c97f69a 100644 --- a/ci/android/src/androidTest/kotlin/rs/pushups/ci/Probe.kt +++ b/ci/android/src/androidTest/kotlin/rs/pushups/ci/Probe.kt @@ -51,6 +51,10 @@ object Probe { @JvmStatic external fun handled(): String + /** Every push the rendering handler received, one per line. */ + @JvmStatic + external fun rendered(): String + /** Writes the Rust coverage profile to [path]. */ @JvmStatic external fun writeCoverage(path: String): Boolean diff --git a/ci/android/src/androidTest/kotlin/rs/pushups/ci/RenderingHandlerTest.kt b/ci/android/src/androidTest/kotlin/rs/pushups/ci/RenderingHandlerTest.kt new file mode 100644 index 0000000..7bbf9f7 --- /dev/null +++ b/ci/android/src/androidTest/kotlin/rs/pushups/ci/RenderingHandlerTest.kt @@ -0,0 +1,130 @@ +package rs.pushups.ci + +import android.app.Notification +import android.app.NotificationManager +import android.content.Context +import android.os.Build +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry +import androidx.test.runner.lifecycle.Stage +import androidx.test.uiautomator.By +import androidx.test.uiautomator.StaleObjectException +import androidx.test.uiautomator.UiDevice +import org.junit.AfterClass +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.BeforeClass +import org.junit.Test +import org.junit.runner.RunWith +import rs.pushups.deliverPush +import java.nio.charset.StandardCharsets +import java.util.UUID + +/** A rendering handler presents a push's notification before the background handler runs. */ +@RunWith(AndroidJUnit4::class) +class RenderingHandlerTest { + + companion object { + private val instrumentation get() = InstrumentationRegistry.getInstrumentation() + private val context: Context get() = instrumentation.targetContext + private val device: UiDevice get() = UiDevice.getInstance(instrumentation) + private val notifications: NotificationManager + get() = context.getSystemService(NotificationManager::class.java) + private val live = listOf( + Stage.PRE_ON_CREATE, + Stage.CREATED, + Stage.STARTED, + Stage.RESUMED, + Stage.PAUSED, + Stage.STOPPED, + Stage.RESTARTED, + ) + + @BeforeClass + @JvmStatic + fun setUp() { + if (Build.VERSION.SDK_INT >= 33) { + instrumentation.uiAutomation.grantRuntimePermission( + context.packageName, + android.Manifest.permission.POST_NOTIFICATIONS, + ) + } + assertNull(Probe.install()) + Probe.setHandler() + } + + @AfterClass + @JvmStatic + fun finish() { + writeCoverage() + } + + private fun activityAlive(): Boolean { + var alive = false + instrumentation.runOnMainSync { + val monitor = ActivityLifecycleMonitorRegistry.getInstance() + alive = live.any { monitor.getActivitiesInStage(it).isNotEmpty() } + } + return alive + } + + private fun closeUi() { + if (activityAlive()) { + instrumentation.runOnMainSync { + val monitor = ActivityLifecycleMonitorRegistry.getInstance() + live.forEach { stage -> monitor.getActivitiesInStage(stage).forEach { it.finish() } } + } + instrumentation.waitForIdleSync() + } + awaitNoActivity() + } + + /** The module's active user notifications, as tag and title. */ + private fun shown(): List> = + notifications.activeNotifications + .filter { it.notification.flags and Notification.FLAG_GROUP_SUMMARY == 0 } + .map { it.tag to it.notification.extras.getCharSequence(Notification.EXTRA_TITLE)?.toString() } + } + + @Test + fun aPushIsRenderedThroughTheRenderingHandlerAndTheTapDeliversItsDataOnce() { + awaitNoActivity() + val id = "proof-${UUID.randomUUID()}" + val payload = """{"rendered":"$id"}""" + val title = "pushups $id" + deliverPush(context.applicationContext, payload.toByteArray(StandardCharsets.UTF_8)) + val rendered = "message started_app=true $payload" + awaitLine(Probe::rendered, payload, UI_MS) + assertEquals(listOf(rendered), lines(Probe.rendered())) + waitFor(UI_MS) { + val shown = shown() + if (shown.size == 1 && shown[0].first == id && shown[0].second == title) Unit else null + } ?: error("the rendering handler's notification did not show within ${UI_MS / 1000} s, notifications\n${shown()}\nrendered\n${Probe.rendered()}") + assertTrue(Probe.handled().isEmpty()) + assertFalse(activityAlive()) + val tap = "message started_app=false $payload" + val monitor = instrumentation.addMonitor(TapActivity::class.java.name, null, false) + try { + device.openNotification() + waitFor(DELIVERY_MS) { + try { + device.findObject(By.text(title))?.click() + } catch (e: StaleObjectException) { + // The shade can replace a row while opening. + } + val events = lines(Probe.events()) + if (events.contains(rendered) && events.contains(tap) && monitor.lastActivity != null) "delivered" else null + } ?: error("tapping $title never delivered the replay and the tap within ${DELIVERY_MS / 1000} s, events\n${Probe.events()}") + val activity = checkNotNull(monitor.lastActivity) { "tapping $title never opened an Activity" } + instrumentation.runOnMainSync { activity.recreate() } + instrumentation.waitForIdleSync() + assertEquals(listOf(rendered, tap), lines(Probe.events()).filter { it.contains(payload) }) + } finally { + instrumentation.removeMonitor(monitor) + closeUi() + } + } +} diff --git a/ci/apple/probe/src/lib.rs b/ci/apple/probe/src/lib.rs index 9d6a763..97206fe 100644 --- a/ci/apple/probe/src/lib.rs +++ b/ci/apple/probe/src/lib.rs @@ -2,7 +2,7 @@ use pushups::{Error, Message, Notification, Rendering, RenderingContext}; -#[pushups::notification_service] +#[pushups::rendering_handler] fn render(_context: RenderingContext, message: Message) -> Result { let payload: serde_json::Value = serde_json::from_slice(&message.payload) .map_err(|error| Error::Platform(error.to_string()))?; diff --git a/docs/mobile-notifications.md b/docs/mobile-notifications.md index a9e727b..d791569 100644 --- a/docs/mobile-notifications.md +++ b/docs/mobile-notifications.md @@ -9,7 +9,7 @@ The extension's Rust crate declares `crate-type = ["staticlib"]` and one callbac ```rust use pushups::{Error, Message, Notification, Rendering, RenderingContext}; -#[pushups::notification_service] +#[pushups::rendering_handler] fn render(context: RenderingContext, message: Message) -> Result { if context.is_cancelled() { return Err(Error::Platform("notification rendering expired".into())); @@ -49,7 +49,7 @@ iOS cannot read an extension's signed entitlements at runtime, so `Declared` is ### Bundle and signing -Compile the shipped `ios/PushupsNotificationService.swift` into the extension and link the consumer's Rust static library. The Swift adapter calls the Rust receive and expiration symbols generated by `#[pushups::notification_service]`. Its module-qualified `PushupsNotificationService` class is the extension's principal class. +Compile the shipped `ios/PushupsNotificationService.swift` into the extension and link the consumer's Rust static library. The Swift adapter calls the Rust receive and expiration symbols generated by `#[pushups::rendering_handler]`. Its module-qualified `PushupsNotificationService` class is the extension's principal class. The `.appex` belongs in the application's `PlugIns` directory. Its property list needs `NSExtensionPointIdentifier = com.apple.usernotifications.service` and `NSExtensionPrincipalClass = .PushupsNotificationService`. Link `Foundation` and `UserNotifications`, compile with application-extension restrictions, and use compatible application and extension deployment targets. @@ -59,6 +59,13 @@ Shared files require the same App Group entitlement on both targets. Shared keys [Dioxus app-extension tooling](https://github.com/DioxusLabs/dioxus/pull/5896) supplies `[[ios.app_extensions]]`, `rust_crate`, `frameworks`, module-qualified principal classes and nested signing. Point `source` at the Swift extension sources, `rust_crate` at the consumer's static-library crate, `extension_point` at `com.apple.usernotifications.service`, and `principal_class` at `PushupsNotificationService`. This capability depends on that tooling contribution, rather than released `dx` support being assumed. +## Android, Linux and Windows + +The same callback runs for every push on Android, Linux and Windows, in the process the push reaches, and the crate shows the notification it returns through the platform's `Notification::show` before the background handler runs. `Message.started_app` carries whether the push started the app, `Rendering::NoAlert` shows nothing, and a failed, panicking or expired render shows nothing and leaves the presentation to the background handler's own presentation of the same push. + +The render is bounded to five seconds on each platform, sized to the window the platform gives the push's delivery. On Android the bound sits inside FCM's `onMessageReceived` processing, which gives the process a few seconds to render a notification at once. On Linux it sits inside the D-Bus call the push arrived on, which holds the process's idle counter. On Windows it sits inside the push's deferral, which keeps the task alive. An icon URL settles `Failed` on Android, whose presentation refuses it, and shows the notification whole on Linux and Windows. + + Tauri and direct `tao` or `winit` applications embed the same native extension target in their Apple project and link the Rust static library. Their build tooling owns target creation, embedding, provisioning and signing. The application's APNs entitlement and registration remain separate from service-extension packaging. ## Android presentation diff --git a/examples/dioxus/Cargo.lock b/examples/dioxus/Cargo.lock index f87760e..6b4272e 100644 --- a/examples/dioxus/Cargo.lock +++ b/examples/dioxus/Cargo.lock @@ -4577,7 +4577,7 @@ dependencies = [ "p256", "parking_lot", "pushups-macros", - "serde", + "pushups-token", "sha2 0.11.0", "thiserror 2.0.21", "wasm-bindgen", @@ -4625,6 +4625,13 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "pushups-token" +version = "0.1.0" +dependencies = [ + "serde", +] + [[package]] name = "pxfm" version = "0.1.30" diff --git a/examples/dioxus/src/background.rs b/examples/dioxus/src/background.rs index 3c7982c..589d218 100644 --- a/examples/dioxus/src/background.rs +++ b/examples/dioxus/src/background.rs @@ -13,13 +13,20 @@ use super::log; /// `adb reverse tcp:8787 tcp:8787`. const CATCH_UP: &str = "127.0.0.1:8787"; +/// Shows the push as a desktop notification on Linux and Windows, where the push arrives in the +/// process this module's background handler runs in. +#[cfg(any(target_os = "linux", target_os = "windows"))] +#[pushups::rendering_handler] +fn render( + _context: pushups::RenderingContext, + message: Message, +) -> Result { + Ok(support::rendered(&message).into()) +} + /// Runs in the process the push woke, before any UI exists. #[pushups::background_handler] fn on_push(_context: Context, message: Message) { - #[cfg(any(target_os = "linux", target_os = "windows"))] - if let Err(error) = support::popup(&message) { - log(&format!("popup failed: {error}")); - } let entered = support::now_ms(); let started = Instant::now(); let fetched = catch_up(); diff --git a/examples/support/src/lib.rs b/examples/support/src/lib.rs index 0660e3c..5d1426d 100644 --- a/examples/support/src/lib.rs +++ b/examples/support/src/lib.rs @@ -4,7 +4,7 @@ //! An example keeps what an app author writes, the `install`, the handler, the button that asks //! for permission, the background handler and the worker entry, and takes the configuration from //! the build-time keys, the log line format, the timing, the worker's notification and the desktop -//! popup from this crate, calling `log` with its own prefix. +//! render from this crate, calling `log` with its own prefix. use pushups::{ Config, Event, LinuxConfig, Message, Notification, Permission, Token, UnifiedPushConfig, @@ -155,20 +155,17 @@ pub fn log(prefix: &str, line: &str) { } } -/// Shows the push as a desktop notification, which Windows and Linux leave to the app. A tap on -/// it hands the payload back as a message, and starts the app when it has exited. -/// -/// # Errors -/// -/// The error of [`pushups::Notification::show`], which the example logs under its own prefix. +/// The push as the example renders it on Linux and Windows, its `title` and `body` or the +/// defaults, with the payload as its data. A tap on the notification it shows hands the payload +/// back as a message, and starts the app when it has exited. +#[must_use] #[cfg(any(target_os = "linux", target_os = "windows"))] -pub fn popup(message: &Message) -> Result<(), pushups::Error> { +pub fn rendered(message: &Message) -> Notification { let title = field(message, "title").unwrap_or_else(|| "pushups".to_owned()); let body = field(message, "body").unwrap_or_else(|| "A push arrived".to_owned()); - pushups::Notification::new(title) + Notification::new(title) .body(body) - .data(String::from_utf8_lossy(&message.payload)) - .show() + .data(String::from_utf8_lossy(&message.payload).into_owned()) } /// The icon of the examples' desktop notifications, the brand's 256 px PNG in this checkout, as @@ -248,12 +245,8 @@ pub fn handled(message: &Message, entered: u128) -> String { ) } -/// What an example's background handler does for every push, the desktop popup on Linux and Windows and the handler line, both logged through `log`. +/// What an example's background handler does for every push, the handler line logged through `log`. pub fn handle_push(message: &Message, log: impl Fn(&str)) { - #[cfg(any(target_os = "linux", target_os = "windows"))] - if let Err(error) = popup(message) { - log(&format!("popup failed: {error}")); - } log(&handled(message, now_ms())); } diff --git a/examples/tao/src/main.rs b/examples/tao/src/main.rs index ecfea7d..5f07eda 100644 --- a/examples/tao/src/main.rs +++ b/examples/tao/src/main.rs @@ -128,6 +128,18 @@ fn on_push(_context: Context, message: Message) { support::handle_push(&message, log); } +/// Runs in the process the push woke, and shows the push as a desktop notification on Linux and +/// Windows, before the background handler runs. +#[cfg(any(target_os = "linux", target_os = "windows"))] +#[pushups::rendering_handler] +fn render( + _context: pushups::RenderingContext, + message: Message, +) -> Result { + Ok(support::rendered(&message).into()) +} + + fn log(line: &str) { support::log("pushups-tao-example", line); } diff --git a/examples/tauri/src/lib.rs b/examples/tauri/src/lib.rs index b671c2b..1427dcf 100644 --- a/examples/tauri/src/lib.rs +++ b/examples/tauri/src/lib.rs @@ -98,6 +98,18 @@ fn on_push(_context: Context, message: Message) { support::handle_push(&message, log); } +/// Runs in the process the push woke, and shows the push as a desktop notification on Linux and +/// Windows, before the background handler runs. +#[cfg(any(target_os = "linux", target_os = "windows"))] +#[pushups::rendering_handler] +fn render( + _context: pushups::RenderingContext, + message: Message, +) -> Result { + Ok(support::rendered(&message).into()) +} + + fn log(line: &str) { support::log("pushups-tauri-example", line); } diff --git a/examples/winit/src/app.rs b/examples/winit/src/app.rs index 66d8ff4..eb42b7e 100644 --- a/examples/winit/src/app.rs +++ b/examples/winit/src/app.rs @@ -244,6 +244,18 @@ fn on_push(_context: Context, message: Message) { support::handle_push(&message, log); } +/// Runs in the process the push woke, and shows the push as a desktop notification on Linux and +/// Windows, before the background handler runs. +#[cfg(any(target_os = "linux", target_os = "windows"))] +#[pushups::rendering_handler] +fn render( + _context: pushups::RenderingContext, + message: Message, +) -> Result { + Ok(support::rendered(&message).into()) +} + + /// Runs in the service worker for every push, and decides what the user sees. #[cfg(target_arch = "wasm32")] pub(crate) async fn notification_for(message: Message) -> pushups::Notification { diff --git a/ios/PushupsNotificationService.swift b/ios/PushupsNotificationService.swift index 1f20cda..e588d95 100644 --- a/ios/PushupsNotificationService.swift +++ b/ios/PushupsNotificationService.swift @@ -108,7 +108,7 @@ private func pushupsExpired(_ service: UnsafeMutableRawPointer) @_silgen_name("__pushups_notification_service_deallocated") private func pushupsDeallocated(_ service: UnsafeMutableRawPointer) -/// Transforms an alert through the Rust `notification_service` callback. +/// Transforms an alert through the Rust `rendering_handler` callback. public final class PushupsNotificationService: UNNotificationServiceExtension { private let service: UnsafeMutableRawPointer diff --git a/pushups-macros/src/lib.rs b/pushups-macros/src/lib.rs index acc9acf..77921a4 100644 --- a/pushups-macros/src/lib.rs +++ b/pushups-macros/src/lib.rs @@ -183,9 +183,39 @@ pub fn background_handler(args: TokenStream, item: TokenStream) -> TokenStream { .into() } -/// Exports a synchronous `fn(RenderingContext, Message) -> Result` for the [iOS service extension](https://github.com/LucaCappelletti94/pushups/blob/main/docs/mobile-notifications.md). +/// Mark a free function as the rendering handler. +/// +/// The function must take `pushups::RenderingContext` and `pushups::Message`, return +/// `pushups::Rendering` as a `Result`, be synchronous, be non-generic and have no `self`. +/// The function is kept unchanged. On every target the expansion type-checks the signature, +/// so a wrong one fails with a clear type error. A second handler in one binary is a +/// duplicate symbol at link time. +/// +/// On Android the expansion additionally exports `Java_rs_pushups_RenderingHandler_render`, +/// which the `pushups` module's push delivery calls before the background handler for +/// every push that reaches a process, and the crate shows the rendered `Notification` +/// through the module's presentation. On iOS and macOS it exports the +/// `__pushups_notification_service` symbols the [iOS service extension](https://github.com/LucaCappelletti94/pushups/blob/main/docs/mobile-notifications.md) +/// calls, which run the render within Apple's 30 seconds. On Linux and Windows it adds the +/// function to a `linkme` slice the crate reads, since an executable there exports no symbol +/// the crate could look up, and the crate runs the render for every push before the +/// background handler, showing the rendered `Notification` for `Show` and nothing for +/// `NoAlert`. +/// +/// Android native calls preserve a pending Java exception and translate JNI errors or unwinding panics into Java exceptions. +/// +/// # Example +/// +/// ```ignore +/// use pushups::{Error, Message, Notification, Rendering, RenderingContext}; +/// +/// #[pushups::rendering_handler] +/// fn render(_context: RenderingContext, message: Message) -> Result { +/// Ok(Notification::new(message.payload.len().to_string()).into()) +/// } +/// ``` #[proc_macro_attribute] -pub fn notification_service(args: TokenStream, item: TokenStream) -> TokenStream { +pub fn rendering_handler(args: TokenStream, item: TokenStream) -> TokenStream { let item: TokenStream2 = item.into(); let input: syn::ItemFn = match syn::parse2(item.clone()) { Ok(input) => input, @@ -196,13 +226,14 @@ pub fn notification_service(args: TokenStream, item: TokenStream) -> TokenStream .into_iter() .next() .map_or_else(|| input.sig.ident.span(), |token| token.span().into()); - return error("notification_service", span, "takes no arguments"); + return error("rendering_handler", span, "takes no arguments"); } if let Some(message) = validate(&input) { - return error("notification_service", input.sig.ident.span(), message); + return error("rendering_handler", input.sig.ident.span(), message); } let name = &input.sig.ident; let pushups = pushups_path(); + let apple = apple_exports(name, &pushups); quote! { #item const _: fn( @@ -210,6 +241,47 @@ pub fn notification_service(args: TokenStream, item: TokenStream) -> TokenStream #pushups::Message, ) -> core::result::Result<#pushups::Rendering, #pushups::Error> = #name; + #[cfg(target_os = "android")] + #pushups::__private::android::exports! { + fn Java_rs_pushups_RenderingHandler_render<'local>( + env: #pushups::__private::android::jni::JNIEnv<'local>, + _class: #pushups::__private::android::jni::objects::JClass<'local>, + _context: #pushups::__private::android::jni::objects::JObject<'local>, + payload: #pushups::__private::android::jni::objects::JByteArray<'local>, + started_app: #pushups::__private::android::jni::sys::jboolean, + ) -> () { + #pushups::__private::android::run_rendering_handler( + env, + &payload, + started_app != #pushups::__private::android::jni::sys::JNI_FALSE, + #name, + ) + } + } + + #apple + + #[cfg(any(target_os = "linux", target_os = "windows"))] + #[#pushups::__private::linkme::distributed_slice(#pushups::__private::RENDERING_HANDLERS)] + #[linkme(crate = #pushups::__private::linkme)] + static __PUSHUPS_RENDERING_HANDLER: fn( + #pushups::RenderingContext, + #pushups::Message, + ) -> core::result::Result<#pushups::Rendering, #pushups::Error> = #name; + + // Makes a second handler a duplicate symbol at link time, as on the other targets. + #[cfg(any(target_os = "linux", target_os = "windows"))] + #[unsafe(no_mangle)] + #[used] + static __PUSHUPS_ONE_RENDERING_HANDLER: u8 = 0; + } + .into() +} + +/// The iOS and macOS exports the `rendering_handler` expansion names, with the static that +/// keeps them through the linker's dead-strip, since only the extension adapter names them. +fn apple_exports(name: &syn::Ident, pushups: &TokenStream2) -> TokenStream2 { + quote! { #[cfg(any(target_os = "ios", target_os = "macos"))] #[unsafe(no_mangle)] pub extern "C" fn __pushups_notification_service_create( @@ -295,7 +367,6 @@ pub fn notification_service(args: TokenStream, item: TokenStream) -> TokenStream __pushups_notification_service_deallocated, ); } - .into() } /// The rules a background handler function must satisfy. diff --git a/pushups-macros/tests/notification_service.rs b/pushups-macros/tests/rendering_handler.rs similarity index 78% rename from pushups-macros/tests/notification_service.rs rename to pushups-macros/tests/rendering_handler.rs index ae6e325..84da21e 100644 --- a/pushups-macros/tests/notification_service.rs +++ b/pushups-macros/tests/rendering_handler.rs @@ -1,11 +1,11 @@ -//! The `notification_service` macro expands in a crate that depends on `pushups` under another -//! name, and the transform's signature is type-checked through the renamed dependency. +//! The `rendering_handler` macro expands in a crate that depends on `pushups` under another +//! name, and the render's signature is type-checked through the renamed dependency. use std::path::Path; use std::process::Command; #[test] -fn notification_service_expands_under_a_renamed_dependency() { +fn rendering_handler_expands_under_a_renamed_dependency() { let workspace = Path::new(env!("CARGO_MANIFEST_DIR")) .parent() .expect("the macro crate sits in the workspace"); @@ -13,7 +13,7 @@ fn notification_service_expands_under_a_renamed_dependency() { std::fs::write( consumer.path().join("Cargo.toml"), format!( - "[package]\nname = \"notification-service-consumer\"\nversion = \"0.0.0\"\nedition = \"2024\"\npublish = false\n\n\ + "[package]\nname = \"rendering-handler-consumer\"\nversion = \"0.0.0\"\nedition = \"2024\"\npublish = false\n\n\ [dependencies]\nnotifications = {{ package = \"pushups\", path = {:?} }}\n\n[workspace]\n", workspace.display().to_string() ), @@ -28,7 +28,7 @@ fn notification_service_expands_under_a_renamed_dependency() { std::fs::create_dir(consumer.path().join("src")).expect("the consumer's src"); std::fs::write( consumer.path().join("src/lib.rs"), - "#[notifications::notification_service]\n\ + "#[notifications::rendering_handler]\n\ fn on_render(\n _context: notifications::RenderingContext,\n\ message: notifications::Message,\n) -> Result {\n\ Ok(notifications::Notification::new(\"Rendered\").body(message.payload.len().to_string()).into())\n}\n", @@ -41,7 +41,7 @@ fn notification_service_expands_under_a_renamed_dependency() { "CARGO_TARGET_DIR", std::env::current_exe() .expect("the test executable") - .with_file_name("notification-service"), + .with_file_name("rendering-handler"), ) .output() .expect("cargo runs"); diff --git a/src/apple/service.rs b/src/apple/service.rs index 603bc9a..4e3e5a2 100644 --- a/src/apple/service.rs +++ b/src/apple/service.rs @@ -1,14 +1,16 @@ -//! The Apple entries the `#[pushups::notification_service]` expansion exports, which drive the +//! The Apple entries the `#[pushups::rendering_handler]` expansion exports, which drive the //! rendering service of one native service instance with the adapter's completion thunk. use std::ffi::c_void; use std::time::{Duration, Instant}; use crate::__private::ios::Finish; -use crate::rendering::{CompletionCallback, RenderingOutcome, RenderingService}; +use crate::rendering::{ + CompletionCallback, RenderingOutcome, RenderingService, Settled, mapped_fields, +}; use crate::{Error, Message, Rendering, RenderingContext, Suppression}; -/// How long a transform may run before the alert is left as sent, inside Apple's 30 seconds. +/// How long a render may run before the alert is left as sent, inside Apple's 30 seconds. const RENDERING_BOUND: Duration = Duration::from_secs(25); /// Nanoseconds per second, for capping the adapter's measured budget. @@ -30,7 +32,7 @@ pub(crate) fn create(filtering_declared: bool) -> *mut c_void { } /// Starts the request for the alert the adapter received, superseding a pending one, and -/// runs the transform within the budget the adapter measured from the alert's entry. +/// runs the render within the budget the adapter measured from the alert's entry. /// /// # Safety /// @@ -57,7 +59,11 @@ pub(crate) unsafe fn receive( deadline, finish_callback(finish, SendToken(token)), handler, - user_info.to_vec(), + mapped_fields, + Message { + payload: user_info.to_vec(), + started_app: false, + }, ); } } @@ -102,16 +108,16 @@ impl SendToken { /// The completion callback that hands the settled result to the adapter's thunk. fn finish_callback(finish: Finish, token: SendToken) -> CompletionCallback { - Box::new(move |outcome, fields, _context| { - let (title, body, tag, data, navigate) = match &fields { - Some(fields) => ( + Box::new(move |outcome, content, _context| { + let (title, body, tag, data, navigate) = match &content { + Some(Settled::Fields(fields)) => ( Some(fields.title.as_str()), fields.body.as_deref(), fields.tag.as_deref(), fields.data.as_deref(), fields.navigate.as_deref(), ), - None => (None, None, None, None, None), + _ => (None, None, None, None, None), }; // SAFETY: the machine settles the request exactly once, and this is its one call, with // the strings alive for the call and `finish` the adapter's thunk. diff --git a/src/backend.rs b/src/backend.rs index 1ae96aa..03115dd 100644 --- a/src/backend.rs +++ b/src/backend.rs @@ -18,6 +18,13 @@ pub(crate) trait Backend { false } + /// Replaces the stored keys with fresh ones and registers again, so the new keys arrive as + /// an [`Event::Token`](crate::Event::Token). + #[cfg(feature = "encryption")] + fn rotate_keys() -> Result<(), Error> { + Err(Error::Unsupported) + } + fn serve_service_worker(_handler: H) -> Result<(), Error> where H: Fn(Message) -> F + 'static, diff --git a/src/envelope.rs b/src/envelope.rs new file mode 100644 index 0000000..c3b234e --- /dev/null +++ b/src/envelope.rs @@ -0,0 +1,200 @@ +//! The encrypted push envelope the transports carry, and its opening with the device's keys. + +use pushups_token::base64url; +use pushups_token::{ENCRYPTED_FIELD, EncryptionKeys}; + +use crate::error::Error; +use crate::key_store::KeyStore; +use crate::webpush_crypto::{DecryptError, Keys}; + +/// A failure to open an encrypted push, for the platform's log lines. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub(crate) enum OpenError { + /// The field is not unpadded base64url. + #[error("the field is not unpadded base64url")] + NotBase64url, + /// The device has no stored keys. + #[error("the device has no stored keys")] + NoKeys, + /// The key store failed. + #[error("the key store failed: {0}")] + Store(Error), + /// The body failed to decrypt. + #[error("decryption failed: {0}")] + Decrypt(DecryptError), +} + +/// Opens one encrypted push, `sealed` the base64url field the transport carried. +/// +/// The keys come from `store`. +/// +/// # Errors +/// +/// [`OpenError::NotBase64url`] when `sealed` is not unpadded base64url, [`OpenError::NoKeys`] +/// when the store holds no keys, [`OpenError::Store`] when the key store fails, and +/// [`OpenError::Decrypt`] when the body fails to decrypt. +#[cfg_attr( + all( + not(test), + any( + target_os = "android", + target_os = "ios", + target_os = "macos", + target_os = "windows" + ) + ), + expect(dead_code, reason = "the platform ingress calls this") +)] +pub(crate) fn open(store: &KeyStore, sealed: &str) -> Result, OpenError> { + let body = base64url::decode(sealed).ok_or(OpenError::NotBase64url)?; + let keys = store.load().map_err(OpenError::Store)?; + let Some(keys) = keys else { + return Err(OpenError::NoKeys); + }; + keys.decrypt(&body).map_err(OpenError::Decrypt) +} + +/// The value of the `pushups` field when `body` is exactly the WNS envelope, +/// `{"pushups":""}` with no whitespace, or none for anything else. +#[must_use] +#[cfg_attr( + all( + not(test), + any( + target_os = "android", + target_os = "ios", + target_os = "macos", + target_os = "windows" + ) + ), + expect(dead_code, reason = "the Windows `deliver` calls this") +)] +pub(crate) fn wns_sealed(body: &[u8]) -> Option<&str> { + let prefix = format!("{{\"{ENCRYPTED_FIELD}\":\""); + let prefix = prefix.as_bytes(); + let suffix = b"\"}"; + if body.len() < prefix.len() + suffix.len() + 1 { + return None; + } + if &body[..prefix.len()] != prefix || &body[body.len() - suffix.len()..] != suffix { + return None; + } + let value = &body[prefix.len()..body.len() - suffix.len()]; + if value + .iter() + .any(|octet| base64url::sextet(*octet).is_none()) + { + return None; + } + Some(core::str::from_utf8(value).expect("base64url characters are ASCII")) +} + +/// The public keys `keys` decrypts its pushes with, the token's `keys`. +#[must_use] +#[cfg_attr( + all( + not(test), + any( + target_os = "android", + target_os = "ios", + target_os = "macos", + target_os = "windows" + ) + ), + expect(dead_code, reason = "the platform `register` calls this") +)] +pub(crate) fn token_keys(keys: &Keys) -> EncryptionKeys { + EncryptionKeys { + p256dh: keys.public_key(), + auth: keys.auth(), + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use keyring_core::mock::Store; + use pushups_sender::{Token, encrypt}; + + use super::*; + + fn key_store() -> KeyStore { + let store: Arc = Store::new().expect("the mock store builds"); + KeyStore::new(store, "rs.pushups") + } + + #[test] + fn opens_a_body_sealed_for_the_stored_keys() { + let store = key_store(); + let keys = store.load_or_create().expect("fresh keys store"); + let token = Token::Fcm { + token: "token".to_owned(), + keys: Some(token_keys(&keys)), + }; + let sealed = encrypt(&token, b"the plaintext").expect("the token's keys seal"); + let opened = open(&store, &sealed.to_base64url()).expect("the body opens"); + assert_eq!(opened, b"the plaintext"); + } + + #[test] + fn a_tampered_body_fails_authentication() { + let store = key_store(); + let keys = store.load_or_create().expect("fresh keys store"); + let token = Token::Fcm { + token: "token".to_owned(), + keys: Some(token_keys(&keys)), + }; + let sealed = encrypt(&token, b"the plaintext").expect("the token's keys seal"); + let mut body = sealed.as_bytes().to_vec(); + let last = body.len() - 1; + body[last] ^= 1; + let opened = open(&store, &base64url::encode(&body)); + assert!( + matches!( + opened, + Err(OpenError::Decrypt(DecryptError::AuthenticationFailure)) + ), + "{opened:?}" + ); + } + + #[test] + fn an_empty_store_has_no_keys() { + let store = key_store(); + let opened = open(&store, "YWJj"); + assert!(matches!(opened, Err(OpenError::NoKeys))); + } + + #[test] + fn a_non_base64url_field_does_not_open() { + let store = key_store(); + let opened = open(&store, "not base64url!"); + assert!(matches!(opened, Err(OpenError::NotBase64url))); + } + + #[test] + fn wns_sealed_recognises_the_envelope() { + let store = key_store(); + let keys = store.load_or_create().expect("fresh keys store"); + let token = Token::Fcm { + token: "token".to_owned(), + keys: Some(token_keys(&keys)), + }; + let sealed = encrypt(&token, b"the plaintext").expect("the token's keys seal"); + let body = sealed.wns_body(); + let value = wns_sealed(body.as_bytes()).expect("the envelope is recognised"); + assert_eq!(value, sealed.to_base64url()); + // Whitespace inside the body. + assert!(wns_sealed(br#"{"pushups": "abc"}"#).is_none()); + // An extra member. + assert!(wns_sealed(br#"{"pushups":"abc","aps":{"alert":"x"}}"#).is_none()); + // A different key. + assert!(wns_sealed(br#"{"alert":"abc"}"#).is_none()); + // Characters outside base64url. + assert!(wns_sealed(br#"{"pushups":"ab=c"}"#).is_none()); + assert!(wns_sealed(br#"{"pushups":"ab c"}"#).is_none()); + // Not an object at all. + assert!(wns_sealed(b"plain body").is_none()); + } +} diff --git a/src/key_store.rs b/src/key_store.rs new file mode 100644 index 0000000..cac58b0 --- /dev/null +++ b/src/key_store.rs @@ -0,0 +1,314 @@ +//! The device's keys in the platform key store, one entry per service name. +//! +//! The entry's user is `keys`, and its secret is the 48 bytes `Keys::to_bytes` writes. The +//! process-wide store is installed once by the platform, before `register` or `rotate_keys` +//! runs, and `pushups` never sets `keyring_core`'s default store, so an app's own keyring use +//! is untouched. + +use std::sync::{Arc, OnceLock}; + +use keyring_core::{CredentialStore, Entry, Error as StoreError}; + +use crate::error::Error; +use crate::webpush_crypto::Keys; + +/// The entry's user. +const USER: &str = "keys"; + +static KEY_STORE: OnceLock = OnceLock::new(); + +/// The device's keys in one credential store, under a service name and the `keys` user. +pub(crate) struct KeyStore { + store: Arc, + service: String, +} + +impl core::fmt::Debug for KeyStore { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("KeyStore") + .field("service", &self.service) + .finish_non_exhaustive() + } +} + +impl KeyStore { + /// New keys over `store`, under `service`. + pub(crate) fn new(store: Arc, service: impl Into) -> Self { + Self { + store, + service: service.into(), + } + } + + /// The stored keys, or none when there is no entry or the entry does not hold a valid key + /// pair. + /// + /// # Errors + /// + /// [`Error::Platform`] when the key store fails. + #[cfg_attr( + all( + not(test), + any( + target_os = "android", + target_os = "ios", + target_os = "macos", + target_os = "windows" + ) + ), + expect( + dead_code, + reason = "the platform ingress calls this through `envelope::open`" + ) + )] + pub(crate) fn load(&self) -> Result, Error> { + let entry = self.entry()?; + match entry.get_secret() { + Ok(secret) => Ok(Keys::from_bytes(&secret)), + Err(StoreError::NoEntry) => Ok(None), + Err(error) => Err(store_error(&error)), + } + } + + #[cfg_attr( + all( + not(test), + any( + target_os = "android", + target_os = "ios", + target_os = "macos", + target_os = "windows" + ) + ), + expect(dead_code, reason = "the platform `register` calls this") + )] + /// The stored keys, creating and storing fresh ones when there is no entry or it does not + /// hold a valid key pair. + /// + /// # Errors + /// + /// [`Error::Platform`] when the keys cannot be created or the key store fails. + pub(crate) fn load_or_create(&self) -> Result { + match self.load()? { + Some(keys) => Ok(keys), + None => self.store_new(), + } + } + + #[cfg_attr( + all( + not(test), + any( + target_os = "android", + target_os = "ios", + target_os = "macos", + target_os = "windows" + ) + ), + expect(dead_code, reason = "the platform `rotate_keys` calls this") + )] + /// Fresh keys, replacing whatever the entry held. + /// + /// # Errors + /// + /// [`Error::Platform`] when the keys cannot be created or the key store fails. + pub(crate) fn rotate(&self) -> Result { + self.store_new() + } + + /// The store's entry for the service and the `keys` user. + /// + /// # Errors + /// + /// [`Error::Platform`] when the key store fails. + fn entry(&self) -> Result { + self.store + .build(&self.service, USER, None) + .map_err(|error| store_error(&error)) + } + + #[cfg_attr( + all( + not(test), + any( + target_os = "android", + target_os = "ios", + target_os = "macos", + target_os = "windows" + ) + ), + expect(dead_code, reason = "used by `load_or_create` and `rotate`") + )] + /// Fresh keys, stored in the entry. + /// + /// # Errors + /// + /// [`Error::Platform`] when the keys cannot be created or the key store fails. + fn store_new(&self) -> Result { + let keys = Keys::generate().map_err(|_| randomness_error())?; + self.entry()? + .set_secret(&keys.to_bytes()) + .map_err(|error| store_error(&error))?; + Ok(keys) + } +} + +/// Installs the process-wide key store. The first install wins. +#[cfg_attr( + all( + not(test), + any( + target_os = "android", + target_os = "ios", + target_os = "macos", + target_os = "windows" + ) + ), + expect(dead_code, reason = "the platform `install` calls this") +)] +pub(crate) fn install(store: Arc, service: impl Into) { + let _ = KEY_STORE.set(KeyStore::new(store, service)); +} + +/// The installed key store, or none when no platform installed one. +#[cfg_attr( + all( + not(test), + any( + target_os = "android", + target_os = "ios", + target_os = "macos", + target_os = "windows" + ) + ), + expect( + dead_code, + reason = "the platform `register` and `rotate_keys` call this" + ) +)] +pub(crate) fn installed() -> Option<&'static KeyStore> { + KEY_STORE.get() +} + +/// A key store failure, named as such. +fn store_error(error: &StoreError) -> Error { + Error::Platform(format!("the key store failed: {error}")) +} + +#[cfg_attr( + all( + not(test), + any( + target_os = "android", + target_os = "ios", + target_os = "macos", + target_os = "windows" + ) + ), + expect(dead_code, reason = "used by `store_new`") +)] +/// A failed randomness source while creating fresh keys. +fn randomness_error() -> Error { + Error::Platform("could not read random bytes for the key pair".to_owned()) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use keyring_core::api::CredentialStoreApi; + use keyring_core::mock::{Cred, Store}; + + use super::*; + + fn key_store() -> (Arc, KeyStore) { + let store = Store::new().expect("the mock store builds"); + (store.clone(), KeyStore::new(store.clone(), "rs.pushups")) + } + + #[test] + fn load_or_create_is_stable_across_calls_and_stores() { + let (store, first) = key_store(); + let keys = first.load_or_create().expect("fresh keys store"); + let again = first.load_or_create().expect("the keys load"); + assert_eq!(again.to_bytes(), keys.to_bytes()); + let fresh = KeyStore::new(store.clone(), "rs.pushups"); + assert_eq!( + fresh + .load() + .expect("the keys load") + .expect("an entry now exists") + .to_bytes(), + keys.to_bytes() + ); + } + + #[test] + fn rotate_replaces_the_keys_and_persists_them() { + let (store, first) = key_store(); + let old = first.load_or_create().expect("fresh keys store"); + let new = first.rotate().expect("fresh keys store"); + assert_ne!(new.to_bytes(), old.to_bytes()); + let fresh = KeyStore::new(store.clone(), "rs.pushups"); + assert_eq!( + fresh + .load() + .expect("the keys load") + .expect("the rotated keys persist") + .to_bytes(), + new.to_bytes() + ); + } + + #[test] + fn an_entry_with_unreadable_bytes_is_replaced() { + let (raw, store) = key_store(); + let entry = raw + .build("rs.pushups", "keys", None) + .expect("the entry builds"); + entry + .set_secret(&[0u8; 40]) + .expect("the wrong-length secret stores"); + let keys = store.load_or_create().expect("fresh keys store"); + assert_eq!( + store + .load() + .expect("the keys load") + .expect("the replacement loads") + .to_bytes(), + keys.to_bytes() + ); + } + + #[test] + fn a_store_error_surfaces() { + let (_, keys) = key_store(); + let entry = keys.entry().expect("the entry builds"); + let cred = entry + .as_any() + .downcast_ref::() + .expect("the mock credential"); + cred.set_error(StoreError::PlatformFailure( + "the key store locked out".into(), + )); + let error = keys.load().expect_err("the store failure surfaces"); + assert!( + error.to_string().contains("the key store failed"), + "the error names the key store" + ); + } + + #[test] + fn the_first_install_wins() { + let (store, _) = key_store(); + install(store, "rs.pushups"); + let first = installed().expect("the store installs"); + assert_eq!(first.service, "rs.pushups"); + install( + Store::new().expect("the mock store builds"), + "rs.pushups.other", + ); + let still = installed().expect("the first store installs"); + assert_eq!(still.service, "rs.pushups"); + } +} diff --git a/src/lib.rs b/src/lib.rs index 7c359da..46dbc6d 100755 --- a/src/lib.rs +++ b/src/lib.rs @@ -34,6 +34,17 @@ mod dispatch; ) ))] mod dx; +#[cfg(all( + feature = "encryption", + any( + test, + target_os = "android", + target_os = "ios", + target_os = "macos", + target_os = "windows" + ) +))] +mod envelope; mod error; mod event; #[cfg(any( @@ -43,6 +54,17 @@ mod event; target_os = "macos" ))] mod inbox; +#[cfg(all( + feature = "encryption", + any( + test, + target_os = "android", + target_os = "ios", + target_os = "macos", + target_os = "windows" + ) +))] +mod key_store; #[cfg(target_os = "linux")] mod linux; mod notification; @@ -82,7 +104,19 @@ mod unsupported; mod web; #[cfg(any(all(target_arch = "wasm32", target_os = "unknown"), test))] mod web_parts; -#[cfg(target_os = "linux")] +#[cfg(any( + target_os = "linux", + all( + feature = "encryption", + any( + test, + target_os = "android", + target_os = "ios", + target_os = "macos", + target_os = "windows" + ) + ) +))] mod webpush_crypto; #[cfg(any(target_os = "windows", test))] mod windows; @@ -120,7 +154,7 @@ pub use error::{Error, VapidKeyError}; pub use event::{Event, Message}; pub use notification::Notification; pub use permission::Permission; -pub use pushups_macros::{background_handler, firebase_config, notification_service}; +pub use pushups_macros::{background_handler, firebase_config, rendering_handler}; pub use pushups_token::{EncryptionKeys, Token, WebPushKeys, WebPushSubscription}; pub use rendering::{Rendering, RenderingContext, RenderingOutcome, Suppression}; pub use worker_file::write_service_worker; @@ -155,6 +189,28 @@ pub fn register() -> Result<(), Error> { Platform::register() } +/// Replaces the keys the device decrypts its pushes with and registers again, so the new keys +/// arrive as [`Event::Token`]. +/// +/// On Android with FCM, on iOS, on macOS and on Windows the stored keys are replaced and +/// [`register`] runs again. On Linux a new `keys` file is written, and the token is reported +/// again with the new keys. The web keeps [`Error::Unsupported`], since the browser owns the +/// Web Push keys, and so does the UnifiedPush distributor on Android, which keeps +/// [`Error::Unsupported`] too. +/// +/// A missing store entry, after an reinstall or a cleared store, creates new keys the same way +/// at [`register`]. +/// +/// # Errors +/// +/// [`Error::Unsupported`] on the web and on Android with a UnifiedPush distributor, and +/// [`Error::Platform`] when the keys cannot be created or stored. +#[cfg(feature = "encryption")] +#[cfg_attr(docsrs, doc(cfg(feature = "encryption")))] +pub fn rotate_keys() -> Result<(), Error> { + Platform::rotate_keys() +} + /// Asks the user to let pushes show notifications, and returns the answer. /// /// On Linux and Windows it reports transport readiness, the session bus and a distributor on diff --git a/src/linux.rs b/src/linux.rs index 90adb9e..a475c77 100644 --- a/src/linux.rs +++ b/src/linux.rs @@ -397,6 +397,7 @@ impl Connector { message }; DISPATCHER.flush(); + crate::__private::run_rendering_handler(&message); crate::__private::run_background_handler(message); Ok(reply) } diff --git a/src/private.rs b/src/private.rs index 1cbbed1..a6b84e4 100644 --- a/src/private.rs +++ b/src/private.rs @@ -60,7 +60,7 @@ pub mod ios { use crate::{Error, Message, Rendering, RenderingContext}; /// The adapter's completion thunk. The machine settles the request exactly once, with - /// the settled outcome and, for a rendered alert, the transform's fields. + /// the settled outcome and, for a rendered alert, the render's fields. /// /// `token` is the adapter's retained completion box, which the thunk releases after its /// one call. Each string pointer is null or `*_len` readable UTF-8 bytes, alive for the @@ -89,7 +89,7 @@ pub mod ios { } /// Starts the rendering request for the alert the adapter received, superseding a pending - /// one, under the remaining budget the adapter measured, and runs the app's transform. + /// one, under the remaining budget the adapter measured, and runs the app's render. /// /// # Safety /// @@ -145,11 +145,19 @@ pub mod android { pub use crate::android_boundary::boundary; pub use jni; + use std::time::{Duration, Instant}; + use jni::objects::{JByteArray, JObject, JString}; use jni::{JNIEnv, sys::jobjectArray}; pub use super::FirebaseClient; - use crate::{Context, Message}; + use crate::rendering::{RenderingService, Settled, Suppression, presentable}; + use crate::{Context, Error, Message, Rendering, RenderingContext, RenderingOutcome}; + + /// The bound of one rendering request, inside FCM's `onMessageReceived` processing, + /// which gives the process a few seconds to render a notification at once, and leaves + /// the presentation call room inside that window. + const RENDERING_BOUND: Duration = Duration::from_secs(5); /// Returns the selected client's values as a Java-owned array in the caller's native frame. /// @@ -202,6 +210,49 @@ pub mod android { ); Ok(()) } + + /// Runs the app's rendering handler for one push through the rendering service, waiting + /// for it to settle, and shows the settled notification through the module's + /// presentation. + /// + /// # Errors + /// + /// Returns JNI conversion errors, and the presentation's refusal of the settled + /// notification as a pending Java exception. + pub fn run_rendering_handler( + env: &mut JNIEnv<'_>, + payload: &JByteArray<'_>, + started_app: bool, + handler: fn(RenderingContext, Message) -> Result, + ) -> jni::errors::Result<()> { + static SERVICE: RenderingService = RenderingService::new(Suppression::Declared); + let payload = env.convert_byte_array(payload)?; + let deadline = Instant::now() + RENDERING_BOUND; + let (settled, wait) = std::sync::mpsc::channel(); + SERVICE.begin( + deadline, + Box::new(move |outcome, content, _context| { + let _ = settled.send((outcome, content)); + }), + handler, + presentable, + Message { + payload, + started_app, + }, + ); + let (outcome, content) = wait + .recv_timeout(deadline.saturating_duration_since(Instant::now())) + .unwrap_or((RenderingOutcome::Expired, None)); + if outcome == RenderingOutcome::Rendered + && let Some(Settled::Notification(notification)) = content + && let Err(error) = crate::android::show(¬ification) + { + env.throw_new("java/lang/RuntimeException", error.to_string())?; + return Err(jni::errors::Error::JavaException); + } + Ok(()) + } } #[cfg(any(target_os = "linux", target_os = "windows"))] @@ -222,6 +273,65 @@ pub(crate) fn run_background_handler(message: crate::Message) { } } +/// The app's rendering handler, which `#[rendering_handler]` adds on Linux and Windows. +#[cfg(any(target_os = "linux", target_os = "windows"))] +#[linkme::distributed_slice] +pub static RENDERING_HANDLERS: [fn( + crate::RenderingContext, + crate::Message, +) -> core::result::Result]; + +/// The bound of one rendering request on Linux, inside the D-Bus call the push arrived on, +/// which holds the process's idle counter, so a headless process stays alive for the render +/// and its show inside its 10-second idle exit, and matches the crate's own notification +/// service bound. +#[cfg(target_os = "linux")] +const RENDERING_BOUND: std::time::Duration = std::time::Duration::from_secs(5); + +/// The bound of one rendering request on Windows, inside the push's deferral, which keeps +/// the task alive for the render and its show, and matches the crate's own `Show` bound. +#[cfg(target_os = "windows")] +const RENDERING_BOUND: std::time::Duration = std::time::Duration::from_secs(5); + +/// Runs the app's rendering handler for one push on Linux and Windows, if it has one, so the +/// settled notification shows before the background handler runs. A failed, panicking or +/// expired render shows nothing, and a show the platform refuses is left to the background +/// handler's own presentation of the same push. +#[cfg(any(target_os = "linux", target_os = "windows"))] +pub(crate) fn run_rendering_handler(message: &crate::Message) { + static SERVICE: crate::rendering::RenderingService = + crate::rendering::RenderingService::new(crate::Suppression::Declared); + let Some(handler) = RENDERING_HANDLERS.first() else { + return; + }; + let deadline = std::time::Instant::now() + RENDERING_BOUND; + let (settled, wait) = std::sync::mpsc::channel(); + SERVICE.begin( + deadline, + Box::new(move |outcome, content, _context| { + let _ = settled.send((outcome, content)); + }), + *handler, + crate::rendering::whole, + message.clone(), + ); + let (outcome, content) = wait + .recv_timeout(deadline.saturating_duration_since(std::time::Instant::now())) + .unwrap_or((crate::RenderingOutcome::Expired, None)); + if outcome == crate::RenderingOutcome::Rendered + && let Some(crate::rendering::Settled::Notification(notification)) = content + { + #[cfg(target_os = "linux")] + { + let _ = crate::linux::show(¬ification); + } + #[cfg(target_os = "windows")] + { + let _ = crate::windows::show(¬ification); + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/rendering.rs b/src/rendering.rs index a81b4f0..27b2655 100644 --- a/src/rendering.rs +++ b/src/rendering.rs @@ -1,9 +1,23 @@ -//! Bounded notification transformation and shared request completion. +//! Bounded notification rendering and shared request completion. -#[cfg(any(target_os = "ios", target_os = "macos", test))] +#[cfg(any( + target_os = "ios", + target_os = "macos", + target_os = "android", + target_os = "linux", + target_os = "windows", + test +))] use std::panic::{self, AssertUnwindSafe}; use std::sync::Arc; -#[cfg(any(target_os = "ios", target_os = "macos", test))] +#[cfg(any( + target_os = "ios", + target_os = "macos", + target_os = "android", + target_os = "linux", + target_os = "windows", + test +))] use std::sync::atomic::AtomicU64; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Instant; @@ -11,15 +25,25 @@ use std::time::Instant; use parking_lot::Mutex; use crate::Notification; -#[cfg(any(target_os = "ios", target_os = "macos", test))] +#[cfg(any( + target_os = "ios", + target_os = "macos", + target_os = "android", + target_os = "linux", + target_os = "windows", + test +))] use crate::{Error, Message}; -/// What a [`notification_service`](crate::notification_service) callback asks the system to present. +/// What a [`rendering_handler`](crate::rendering_handler) callback asks the system to present. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Rendering { - /// Replace the original alert's title and body with this notification's. + /// Present this notification, replacing the original alert on Apple and shown through + /// the platform's `Notification::show` on Android, Linux and Windows. Show(Notification), - /// Present no alert, honoured only under a declared [`Suppression`] policy. + /// Present no alert, which on Apple requires a declared [`Suppression`] policy to + /// suppress the original alert, while on Android, Linux and Windows it simply shows + /// nothing. NoAlert, } @@ -29,33 +53,41 @@ impl From for Rendering { } } -/// Whether the notification service extension declares Apple's filtering policy in its `Info.plist`. +/// Whether a render may suppress its alert. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Suppression { - /// No declaration, so [`Rendering::NoAlert`] keeps the original alert. + /// No declaration, so [`Rendering::NoAlert`] keeps the original alert, which only an + /// Apple extension that does not declare the filtering policy reaches. Undeclared, - /// `PushupsNotificationFiltering` is true, an assertion iOS cannot check at runtime, so [`Rendering::NoAlert`] completes with empty content. + /// The alert may be suppressed, which on Apple completes [`Rendering::NoAlert`] with + /// empty content under `PushupsNotificationFiltering`, an assertion Apple cannot check + /// at runtime. Declared, } -/// What the system shows for the alert after the transform settles. +/// What the system shows after the render settles. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RenderingOutcome { - /// The transform's content replaced the original alert. + /// The render's notification was presented, replacing the original alert on Apple and + /// shown through the platform on Android, Linux and Windows. Rendered, - /// The transform asked for no alert under a declared policy, and empty content went to the system. + /// The render asked for no alert under a declared policy, which shows nothing off Apple + /// and sends empty content on Apple. SuppressionRequested, - /// The transform asked for no alert without a declared policy, and the original alert stands. + /// The render asked for no alert without a declared policy, and the original alert + /// stands, which only an Apple extension that does not declare the policy reaches. SuppressionUndeclared, - /// The transform returned an error, or content the platform cannot show, such as an icon URL. + /// The render returned an error, or content the platform cannot show, such as an icon + /// URL, and nothing is shown. Failed, - /// The transform unwound. + /// The render unwound. Panicked, - /// The bound or the platform's expiration passed, and the original alert stands. + /// The bound or the platform's expiration passed, and the original alert stands on + /// Apple. Expired, - /// The worker could not start, and the original alert stands. + /// The worker could not start, and the original alert stands on Apple. WorkerUnavailable, - /// A newer alert replaced this one before it settled. + /// A newer render replaced this one before it settled. Superseded, } @@ -76,7 +108,7 @@ impl RenderingOutcome { } } -/// Owned request identity, deadline and cooperative cancellation for a notification transform. +/// Owned request identity, deadline and cooperative cancellation for a notification render. #[derive(Clone)] pub struct RenderingContext { state: Arc, @@ -89,34 +121,36 @@ impl RenderingContext { &self.state.id } - /// The monotonic deadline of the transform's total bound. + /// The monotonic deadline of the render's total bound. #[must_use] pub fn deadline(&self) -> Instant { self.state.deadline } - /// Whether the transform should stop, true at the deadline and once the request settled. + /// Whether the render should stop, true at the deadline and once the request settled. #[must_use] pub fn is_cancelled(&self) -> bool { self.state.cancelled.load(Ordering::Acquire) || Instant::now() >= self.state.deadline } - /// Whether the extension declares the filtering policy that lets [`Rendering::NoAlert`] suppress the alert. + /// Whether the render may suppress its alert, declared off Apple where the service + /// decides for the whole process. #[must_use] pub fn suppression(&self) -> Suppression { self.state.suppression } - /// The settled outcome, or `None` while the transform runs. + /// The settled outcome, or `None` while the render runs. #[must_use] pub fn outcome(&self) -> Option { self.state.completion.lock().outcome } } -/// The settled alert's transform content, the mobile-supported fields of the `Notification` -/// the transform returned. +/// The fields the alert's user info carries on Apple, the mobile-supported fields of the +/// `Notification` the render returned. #[cfg(any(target_os = "ios", target_os = "macos", test))] +#[derive(Debug, PartialEq)] pub(crate) struct RenderedFields { pub(crate) title: String, pub(crate) body: Option, @@ -125,29 +159,88 @@ pub(crate) struct RenderedFields { pub(crate) navigate: Option, } -#[cfg(any(target_os = "ios", target_os = "macos", test))] +/// The content a settled render hands to the platform, which presents it or not by its +/// outcome. +#[cfg(any( + target_os = "ios", + target_os = "macos", + target_os = "android", + target_os = "linux", + target_os = "windows", + test +))] +#[derive(Debug, PartialEq)] +pub(crate) enum Settled { + /// The alert's user info fields, the content Apple presents. + #[cfg(any(target_os = "ios", target_os = "macos", test))] + Fields(RenderedFields), + /// The notification the render returned, whole, the content the platform shows. + #[cfg(any( + target_os = "android", + target_os = "linux", + target_os = "windows", + test + ))] + Notification(Notification), +} + +#[cfg(any( + target_os = "ios", + target_os = "macos", + target_os = "android", + target_os = "linux", + target_os = "windows", + test +))] pub(crate) type CompletionCallback = - Box, RenderingContext) + Send>; + Box, RenderingContext) + Send>; +/// The content Apple presents, the fields the platform can show, or `None` when the +/// notification carries an icon URL, which has no Apple mapping. #[cfg(any(target_os = "ios", target_os = "macos", test))] -/// The fields the platform can show, taken from the `Notification` the transform returned, -/// or `None` when the notification carries an icon URL, which has no mobile mapping. -pub(crate) fn mapped_fields(notification: Notification) -> Option { +pub(crate) fn mapped_fields(notification: Notification) -> Option { if notification.icon.is_some() { return None; } - Some(RenderedFields { + Some(Settled::Fields(RenderedFields { title: notification.title, body: notification.body, tag: notification.tag, data: notification.data, navigate: notification.navigate, - }) + })) } -/// The rendering service of one native service instance, the one its platform entries -/// drive. -#[cfg(any(target_os = "ios", target_os = "macos", test))] +/// The content Android presents, the notification whole, or `None` for the icon URL its +/// presentation refuses. +#[cfg(any(target_os = "android", test))] +pub(crate) fn presentable(notification: Notification) -> Option { + notification + .icon + .is_none() + .then_some(Settled::Notification(notification)) +} + +/// The content Linux and Windows present, the notification whole. +#[cfg(any(target_os = "linux", target_os = "windows", test))] +#[expect( + clippy::unnecessary_wraps, + reason = "the content functions settle through one signature that can refuse content" +)] +pub(crate) fn whole(notification: Notification) -> Option { + Some(Settled::Notification(notification)) +} + +/// The rendering service of one rendering instance, the one its platform entries drive, the +/// Apple extension or the process-wide handler on Android, Linux and Windows. +#[cfg(any( + target_os = "ios", + target_os = "macos", + target_os = "android", + target_os = "linux", + target_os = "windows", + test +))] pub(crate) struct RenderingService { current: Mutex>>, suppression: Suppression, @@ -155,12 +248,26 @@ pub(crate) struct RenderingService { /// The process-wide generation of rendering requests, keeping request identities fresh /// across service instances. -#[cfg(any(target_os = "ios", target_os = "macos", test))] +#[cfg(any( + target_os = "ios", + target_os = "macos", + target_os = "android", + target_os = "linux", + target_os = "windows", + test +))] static NEXT_GENERATION: AtomicU64 = AtomicU64::new(0); -#[cfg(any(target_os = "ios", target_os = "macos", test))] +#[cfg(any( + target_os = "ios", + target_os = "macos", + target_os = "android", + target_os = "linux", + target_os = "windows", + test +))] impl RenderingService { - /// The service with no request yet, under the extension's declared policy. + /// The service with no request yet, under the declared `suppression`. #[must_use] pub(crate) const fn new(suppression: Suppression) -> Self { Self { @@ -169,14 +276,25 @@ impl RenderingService { } } - /// Starts the request for `user_info` under `deadline`, superseding a pending one, and - /// returns the context the transform runs with. + /// Starts the request for `message` under `deadline`, superseding a pending one, and + /// returns the context the render runs with. `content` maps the `Show` notification to + /// the content the platform presents, or `None` for content the platform cannot show, + /// which settles `Failed`. + #[cfg(any( + target_os = "ios", + target_os = "macos", + target_os = "android", + target_os = "linux", + target_os = "windows", + test + ))] pub(crate) fn begin( &self, deadline: Instant, callback: CompletionCallback, handler: fn(RenderingContext, Message) -> Result, - user_info: Vec, + content: fn(Notification) -> Option, + message: Message, ) -> RenderingContext { let generation = NEXT_GENERATION.fetch_add(1, Ordering::Relaxed) + 1; let state = Arc::new(RequestState { @@ -195,7 +313,7 @@ impl RenderingService { old.settle(RenderingOutcome::Superseded, None); } // The budget is already spent, so settle the original alert without running the - // transform. + // render. if Instant::now() >= deadline { state.settle(RenderingOutcome::Expired, None); return state.context(); @@ -215,24 +333,19 @@ impl RenderingService { } let worker_state = Arc::clone(&state); let worker = std::thread::Builder::new() - .name("pushups notification service".to_owned()) + .name("pushups rendering worker".to_owned()) .spawn(move || { - let context = worker_state.context(); - let message = Message { - payload: user_info, - started_app: false, - }; - let result = panic::catch_unwind(AssertUnwindSafe(|| handler(context, message))); - let (outcome, fields) = if Instant::now() >= worker_state.deadline { + let render_context = worker_state.context(); + let result = + panic::catch_unwind(AssertUnwindSafe(|| handler(render_context, message))); + let (outcome, settled) = if Instant::now() >= worker_state.deadline { (RenderingOutcome::Expired, None) } else { match result { - Ok(Ok(Rendering::Show(notification))) => { - match mapped_fields(notification) { - Some(fields) => (RenderingOutcome::Rendered, Some(fields)), - None => (RenderingOutcome::Failed, None), - } - } + Ok(Ok(Rendering::Show(notification))) => match content(notification) { + Some(settled) => (RenderingOutcome::Rendered, Some(settled)), + None => (RenderingOutcome::Failed, None), + }, Ok(Ok(Rendering::NoAlert)) => match worker_state.suppression { Suppression::Declared => (RenderingOutcome::SuppressionRequested, None), Suppression::Undeclared => { @@ -243,7 +356,7 @@ impl RenderingService { Err(_) => (RenderingOutcome::Panicked, None), } }; - worker_state.settle(outcome, fields); + worker_state.settle(outcome, settled); }); if worker.is_err() { state.settle(RenderingOutcome::WorkerUnavailable, None); @@ -283,7 +396,14 @@ pub(crate) struct RequestState { /// The one-shot completion of a request, decided and delivered exactly once. pub(crate) struct Completion { outcome: Option, - #[cfg(any(target_os = "ios", target_os = "macos", test))] + #[cfg(any( + target_os = "ios", + target_os = "macos", + target_os = "android", + target_os = "linux", + target_os = "windows", + test + ))] callback: Option, } @@ -293,8 +413,15 @@ impl RequestState { /// service. At the deadline, the selection normalizes to `Expired` under the arbitration /// lock, so a worker that crossed it while mapping or waiting cannot commit a late /// `Rendered`. - #[cfg(any(target_os = "ios", target_os = "macos", test))] - fn settle(self: &Arc, outcome: RenderingOutcome, fields: Option) { + #[cfg(any( + target_os = "ios", + target_os = "macos", + target_os = "android", + target_os = "linux", + target_os = "windows", + test + ))] + fn settle(self: &Arc, outcome: RenderingOutcome, content: Option) { let (outcome, callback) = { let mut completion = self.completion.lock(); if completion.outcome.is_some() { @@ -309,18 +436,25 @@ impl RequestState { completion.outcome = Some(outcome); (outcome, completion.callback.take()) }; - let fields = if outcome == RenderingOutcome::Expired { + let content = if outcome == RenderingOutcome::Expired { None } else { - fields + content }; if let Some(callback) = callback { - callback(outcome, fields, self.context()); + callback(outcome, content, self.context()); } } - /// The context this request's transform runs with. - #[cfg(any(target_os = "ios", target_os = "macos", test))] + /// The context this request's render runs with. + #[cfg(any( + target_os = "ios", + target_os = "macos", + target_os = "android", + target_os = "linux", + target_os = "windows", + test + ))] fn context(self: &Arc) -> RenderingContext { RenderingContext { state: Arc::clone(self), @@ -340,7 +474,7 @@ mod tests { struct Recorded { count: usize, outcome: Option, - fields: Option, + content: Option, } /// Waits until `holds` or the monotonic deadline passes, panicking on the deadline. @@ -357,13 +491,33 @@ mod tests { Instant::now() + Duration::from_millis(ms) } + /// The request every scenario starts under the Apple content mapping and a message the + /// app had not started for. + fn begin( + service: &RenderingService, + deadline: Instant, + callback: CompletionCallback, + payload: Vec, + ) -> RenderingContext { + service.begin( + deadline, + callback, + test_handler, + mapped_fields, + Message { + payload, + started_app: false, + }, + ) + } + fn callback(recorded: &Arc>) -> CompletionCallback { let recorded = Arc::clone(recorded); - Box::new(move |outcome, fields, _context| { + Box::new(move |outcome, content, _context| { let mut recorded = recorded.lock(); recorded.count += 1; recorded.outcome = Some(outcome); - recorded.fields = fields; + recorded.content = content; }) } @@ -375,8 +529,8 @@ mod tests { .navigate("https://example.com") } - /// The consumer transform every scenario drives. A payload is a kind, optionally followed - /// by `:sleep-`, which the transform sleeps before answering. + /// The consumer render every scenario drives. A payload is a kind, optionally followed + /// by `:sleep-`, which the render sleeps before answering. fn test_handler(_context: RenderingContext, message: Message) -> Result { let Message { payload, @@ -396,9 +550,9 @@ mod tests { } match kind { "error" => Err(Error::Platform( - "the test transform refused the payload".to_owned(), + "the test render refused the payload".to_owned(), )), - "panic" => panic!("the test transform unwinds"), + "panic" => panic!("the test render unwinds"), "icon" => Ok(Notification::new("Icon") .body("Icon body") .icon("https://example.com/icon.png") @@ -416,10 +570,10 @@ mod tests { fn the_context_keeps_its_identity_and_deadline_after_completion() { let service = RenderingService::new(Suppression::Undeclared); let recorded = Arc::new(Mutex::new(Recorded::default())); - let context = service.begin( + let context = begin( + &service, deadline_in(5_000), callback(&recorded), - test_handler, b"render".to_vec(), ); let deadline = context.deadline(); @@ -436,10 +590,10 @@ mod tests { fn a_transformed_alert_completes_rendered_with_its_mapped_fields() { let service = RenderingService::new(Suppression::Undeclared); let recorded = Arc::new(Mutex::new(Recorded::default())); - let context = service.begin( + let context = begin( + &service, deadline_in(5_000), callback(&recorded), - test_handler, b"render".to_vec(), ); wait_for( @@ -448,7 +602,10 @@ mod tests { ); let recorded = recorded.lock(); assert_eq!(recorded.outcome, Some(RenderingOutcome::Rendered)); - let fields = recorded.fields.as_ref().expect("the transform's content"); + let settled = recorded.content.as_ref().expect("the render's content"); + let Settled::Fields(fields) = settled else { + panic!("the content is not the mapped fields: {settled:?}") + }; assert_eq!(fields.title, "Rendered"); assert_eq!(fields.body.as_deref(), Some("Decrypted body")); assert_eq!(fields.tag.as_deref(), Some("thread-1")); @@ -460,10 +617,10 @@ mod tests { fn a_returned_error_completes_failed_on_the_fallback() { let service = RenderingService::new(Suppression::Undeclared); let recorded = Arc::new(Mutex::new(Recorded::default())); - let context = service.begin( + let context = begin( + &service, deadline_in(5_000), callback(&recorded), - test_handler, b"error".to_vec(), ); wait_for( @@ -471,19 +628,19 @@ mod tests { Duration::from_secs(5), ); assert!( - recorded.lock().fields.is_none(), + recorded.lock().content.is_none(), "the fallback keeps no content" ); } #[test] - fn an_unwinding_transform_completes_panicked() { + fn an_unwinding_render_completes_panicked() { let service = RenderingService::new(Suppression::Undeclared); let recorded = Arc::new(Mutex::new(Recorded::default())); - let context = service.begin( + let context = begin( + &service, deadline_in(5_000), callback(&recorded), - test_handler, b"panic".to_vec(), ); wait_for( @@ -491,19 +648,19 @@ mod tests { Duration::from_secs(5), ); assert!( - recorded.lock().fields.is_none(), + recorded.lock().content.is_none(), "the fallback keeps no content" ); } #[test] - fn an_icon_url_completes_failed_on_the_fallback() { + fn an_icon_url_completes_failed_for_the_fallback() { let service = RenderingService::new(Suppression::Undeclared); let recorded = Arc::new(Mutex::new(Recorded::default())); - let context = service.begin( + let context = begin( + &service, deadline_in(5_000), callback(&recorded), - test_handler, b"icon".to_vec(), ); wait_for( @@ -511,7 +668,7 @@ mod tests { Duration::from_secs(5), ); assert!( - recorded.lock().fields.is_none(), + recorded.lock().content.is_none(), "the fallback keeps no content" ); } @@ -521,10 +678,10 @@ mod tests { let service = RenderingService::new(Suppression::Undeclared); let recorded = Arc::new(Mutex::new(Recorded::default())); let payload = b"record:the-original-user-info".to_vec(); - let context = service.begin( + let context = begin( + &service, deadline_in(5_000), callback(&recorded), - test_handler, payload.clone(), ); wait_for( @@ -535,19 +692,87 @@ mod tests { let entry = messages .iter() .find(|(entry_payload, _)| *entry_payload == payload) - .expect("the transform recorded the message"); + .expect("the render recorded the message"); assert_eq!(&entry.0, &payload); assert!(!entry.1, "the extension does not start the app"); } #[test] - fn a_worker_returning_after_the_deadline_completes_expired_once() { + fn a_started_app_message_reaches_the_handler() { let service = RenderingService::new(Suppression::Undeclared); let recorded = Arc::new(Mutex::new(Recorded::default())); + let payload = b"record:started-app".to_vec(); service.begin( - deadline_in(60), + deadline_in(5_000), + callback(&recorded), + test_handler, + whole, + Message { + payload: payload.clone(), + started_app: true, + }, + ); + wait_for(|| recorded.lock().count == 1, Duration::from_secs(5)); + let messages = MESSAGES.lock(); + let entry = messages + .iter() + .find(|(entry_payload, _)| *entry_payload == payload) + .expect("the render recorded the message"); + assert!(entry.1, "the started flag reaches the handler"); + } + + #[test] + fn the_whole_notification_reaches_the_platform_content() { + let service = RenderingService::new(Suppression::Declared); + let recorded = Arc::new(Mutex::new(Recorded::default())); + let context = service.begin( + deadline_in(5_000), callback(&recorded), test_handler, + whole, + Message { + payload: b"render".to_vec(), + started_app: false, + }, + ); + assert_eq!(context.suppression(), Suppression::Declared); + wait_for( + || recorded.lock().count == 1 && context.outcome() == Some(RenderingOutcome::Rendered), + Duration::from_secs(5), + ); + let recorded = recorded.lock(); + assert_eq!(recorded.outcome, Some(RenderingOutcome::Rendered)); + assert_eq!(recorded.content, Some(Settled::Notification(rendered()))); + } + + #[test] + fn an_icon_url_settles_failed_for_the_platform_content() { + let service = RenderingService::new(Suppression::Undeclared); + let recorded = Arc::new(Mutex::new(Recorded::default())); + service.begin( + deadline_in(5_000), + callback(&recorded), + test_handler, + presentable, + Message { + payload: b"icon".to_vec(), + started_app: false, + }, + ); + wait_for(|| recorded.lock().count == 1, Duration::from_secs(5)); + let recorded = recorded.lock(); + assert_eq!(recorded.outcome, Some(RenderingOutcome::Failed)); + assert!(recorded.content.is_none()); + } + + #[test] + fn a_worker_returning_after_the_deadline_completes_expired_once() { + let service = RenderingService::new(Suppression::Undeclared); + let recorded = Arc::new(Mutex::new(Recorded::default())); + begin( + &service, + deadline_in(60), + callback(&recorded), b"render:sleep-150".to_vec(), ); wait_for(|| recorded.lock().count >= 1, Duration::from_secs(5)); @@ -555,7 +780,7 @@ mod tests { let recorded = recorded.lock(); assert_eq!(recorded.count, 1); assert_eq!(recorded.outcome, Some(RenderingOutcome::Expired)); - assert!(recorded.fields.is_none(), "the late result is discarded"); + assert!(recorded.content.is_none(), "the late result is discarded"); } // The worker's late return cannot add a second completion. std::thread::sleep(Duration::from_millis(200)); @@ -563,13 +788,13 @@ mod tests { } #[test] - fn an_already_spent_budget_completes_expired_without_running_the_transform() { + fn an_already_spent_budget_completes_expired_without_running_the_render() { let service = RenderingService::new(Suppression::Undeclared); let recorded = Arc::new(Mutex::new(Recorded::default())); - let context = service.begin( + let context = begin( + &service, Instant::now(), callback(&recorded), - test_handler, b"record:spent".to_vec(), ); wait_for( @@ -580,15 +805,15 @@ mod tests { assert_eq!(recorded.count, 1); assert_eq!(recorded.outcome, Some(RenderingOutcome::Expired)); assert!( - recorded.fields.is_none(), - "the spent budget runs no transform" + recorded.content.is_none(), + "the spent budget runs no render" ); assert!( MESSAGES .lock() .iter() .all(|(payload, _)| *payload != b"record:spent"), - "the spent budget runs no transform" + "the spent budget runs no render" ); } @@ -596,10 +821,10 @@ mod tests { fn an_error_returning_after_the_deadline_completes_expired() { let service = RenderingService::new(Suppression::Undeclared); let recorded = Arc::new(Mutex::new(Recorded::default())); - service.begin( + begin( + &service, deadline_in(60), callback(&recorded), - test_handler, b"error:sleep-150".to_vec(), ); wait_for(|| recorded.lock().count >= 1, Duration::from_secs(5)); @@ -612,10 +837,10 @@ mod tests { fn the_deadline_cancels_the_context_before_the_timer_delivers() { let service = RenderingService::new(Suppression::Undeclared); let recorded = Arc::new(Mutex::new(Recorded::default())); - let context = service.begin( + let context = begin( + &service, deadline_in(80), callback(&recorded), - test_handler, b"render:sleep-200".to_vec(), ); assert!(!context.is_cancelled(), "the deadline has not passed"); @@ -630,16 +855,16 @@ mod tests { let service = RenderingService::new(Suppression::Undeclared); let first = Arc::new(Mutex::new(Recorded::default())); let second = Arc::new(Mutex::new(Recorded::default())); - let context_first = service.begin( + let context_first = begin( + &service, deadline_in(5_000), callback(&first), - test_handler, b"render:sleep-300".to_vec(), ); - let context_second = service.begin( + let context_second = begin( + &service, deadline_in(5_000), callback(&second), - test_handler, b"render".to_vec(), ); wait_for( @@ -652,7 +877,7 @@ mod tests { ); assert!(context_first.is_cancelled()); assert!( - first.lock().fields.is_none(), + first.lock().content.is_none(), "the fallback keeps no content" ); } @@ -662,17 +887,17 @@ mod tests { let service = RenderingService::new(Suppression::Undeclared); let first = Arc::new(Mutex::new(Recorded::default())); let second = Arc::new(Mutex::new(Recorded::default())); - let context_first = service.begin( + let context_first = begin( + &service, deadline_in(60), callback(&first), - test_handler, b"render:sleep-300".to_vec(), ); std::thread::sleep(Duration::from_millis(120)); - let context_second = service.begin( + let context_second = begin( + &service, deadline_in(5_000), callback(&second), - test_handler, b"render".to_vec(), ); wait_for( @@ -690,16 +915,16 @@ mod tests { let service = RenderingService::new(Suppression::Undeclared); let first = Arc::new(Mutex::new(Recorded::default())); let second = Arc::new(Mutex::new(Recorded::default())); - let context_first = service.begin( + let context_first = begin( + &service, deadline_in(80), callback(&first), - test_handler, b"render:sleep-400".to_vec(), ); - let context_second = service.begin( + let context_second = begin( + &service, deadline_in(5_000), callback(&second), - test_handler, b"render".to_vec(), ); wait_for( @@ -725,10 +950,10 @@ mod tests { fn the_platform_expiration_completes_expired_and_drops_the_late_worker() { let service = RenderingService::new(Suppression::Undeclared); let recorded = Arc::new(Mutex::new(Recorded::default())); - let context = service.begin( + let context = begin( + &service, deadline_in(5_000), callback(&recorded), - test_handler, b"render:sleep-300".to_vec(), ); std::thread::sleep(Duration::from_millis(50)); @@ -745,10 +970,10 @@ mod tests { fn a_settled_request_ignores_later_expirations_and_deallocations() { let service = RenderingService::new(Suppression::Undeclared); let recorded = Arc::new(Mutex::new(Recorded::default())); - let context = service.begin( + let context = begin( + &service, deadline_in(5_000), callback(&recorded), - test_handler, b"render".to_vec(), ); wait_for( @@ -765,10 +990,10 @@ mod tests { fn the_deallocation_supersedes_the_pending_request() { let service = RenderingService::new(Suppression::Undeclared); let recorded = Arc::new(Mutex::new(Recorded::default())); - let context = service.begin( + let context = begin( + &service, deadline_in(5_000), callback(&recorded), - test_handler, b"render:sleep-300".to_vec(), ); std::thread::sleep(Duration::from_millis(50)); @@ -783,20 +1008,20 @@ mod tests { fn reused_payloads_keep_fresh_request_ids() { let service = RenderingService::new(Suppression::Undeclared); let payload = b"render".to_vec(); - let first = service.begin( + let first = begin( + &service, deadline_in(5_000), callback(&Arc::new(Mutex::new(Recorded::default()))), - test_handler, payload.clone(), ); wait_for( || first.outcome() == Some(RenderingOutcome::Rendered), Duration::from_secs(5), ); - let second = service.begin( + let second = begin( + &service, deadline_in(5_000), callback(&Arc::new(Mutex::new(Recorded::default()))), - test_handler, payload, ); wait_for( @@ -816,7 +1041,7 @@ mod tests { let callback_recorded = Arc::clone(&recorded); let context = service.begin( deadline_in(5_000), - Box::new(move |outcome, fields, context| { + Box::new(move |outcome, content, context| { // The completion lock is not held here, so the outcome is readable and the // service can be reentered. assert_eq!(context.outcome(), Some(outcome)); @@ -824,7 +1049,11 @@ mod tests { deadline_in(5_000), inner_callback, test_handler, - b"render".to_vec(), + mapped_fields, + Message { + payload: b"render".to_vec(), + started_app: false, + }, ); wait_for( || reentered.outcome() == Some(RenderingOutcome::Rendered), @@ -833,10 +1062,14 @@ mod tests { let mut recorded = callback_recorded.lock(); recorded.count += 1; recorded.outcome = Some(outcome); - recorded.fields = fields; + recorded.content = content; }), test_handler, - b"render".to_vec(), + mapped_fields, + Message { + payload: b"render".to_vec(), + started_app: false, + }, ); wait_for( || { @@ -872,7 +1105,7 @@ mod tests { assert!(state.context().is_cancelled()); let result = recorded.lock(); assert_eq!(result.outcome, Some(RenderingOutcome::Expired)); - assert!(result.fields.is_none()); + assert!(result.content.is_none()); } #[test] @@ -888,10 +1121,10 @@ mod tests { fn no_alert_under_a_declared_policy_completes_suppression_requested() { let service = RenderingService::new(Suppression::Declared); let recorded = Arc::new(Mutex::new(Recorded::default())); - let context = service.begin( + let context = begin( + &service, deadline_in(5_000), callback(&recorded), - test_handler, b"no-alert".to_vec(), ); assert_eq!(context.suppression(), Suppression::Declared); @@ -901,17 +1134,17 @@ mod tests { recorded.outcome, Some(RenderingOutcome::SuppressionRequested) ); - assert!(recorded.fields.is_none()); + assert!(recorded.content.is_none()); } #[test] fn no_alert_without_a_declared_policy_keeps_the_original_alert() { let service = RenderingService::new(Suppression::Undeclared); let recorded = Arc::new(Mutex::new(Recorded::default())); - let context = service.begin( + let context = begin( + &service, deadline_in(5_000), callback(&recorded), - test_handler, b"no-alert".to_vec(), ); assert_eq!(context.suppression(), Suppression::Undeclared); @@ -921,17 +1154,17 @@ mod tests { recorded.outcome, Some(RenderingOutcome::SuppressionUndeclared) ); - assert!(recorded.fields.is_none()); + assert!(recorded.content.is_none()); } #[test] fn a_no_alert_after_the_deadline_completes_expired_once() { let service = RenderingService::new(Suppression::Declared); let recorded = Arc::new(Mutex::new(Recorded::default())); - let context = service.begin( + let context = begin( + &service, deadline_in(50), callback(&recorded), - test_handler, b"no-alert:sleep-200".to_vec(), ); wait_for( @@ -949,16 +1182,16 @@ mod tests { let service = RenderingService::new(Suppression::Declared); let first = Arc::new(Mutex::new(Recorded::default())); let second = Arc::new(Mutex::new(Recorded::default())); - let context_first = service.begin( + let context_first = begin( + &service, deadline_in(5_000), callback(&first), - test_handler, b"no-alert:sleep-200".to_vec(), ); - let context_second = service.begin( + let context_second = begin( + &service, deadline_in(5_000), callback(&second), - test_handler, b"render".to_vec(), ); wait_for( diff --git a/src/webpush_crypto.rs b/src/webpush_crypto.rs index cb7993a..34fdd6d 100644 --- a/src/webpush_crypto.rs +++ b/src/webpush_crypto.rs @@ -48,6 +48,18 @@ impl core::fmt::Debug for Keys { /// A failure to decrypt a Web Push message body. #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub(crate) enum DecryptError { + #[cfg_attr( + all( + not(test), + any( + target_os = "android", + target_os = "ios", + target_os = "macos", + target_os = "windows" + ) + ), + expect(dead_code, reason = "constructed by `Keys::generate`") + )] /// The randomness source failed, so no fresh key pair could be built. #[error("could not read random bytes")] Randomness, @@ -89,13 +101,33 @@ impl Keys { } } + #[cfg_attr( + all( + not(test), + any( + target_os = "android", + target_os = "ios", + target_os = "macos", + target_os = "windows" + ) + ), + expect(dead_code, reason = "the key store's `store_new` calls this") + )] /// Generate a fresh P-256 secret key and authentication secret. + /// + /// The secret scalar is drawn again while it is not below the group order, rejection + /// sampling. pub(crate) fn generate() -> Result { let mut secret = [0u8; 32]; let mut auth = [0u8; AUTH_LEN]; getrandom::fill(&mut secret).map_err(|_| DecryptError::Randomness)?; getrandom::fill(&mut auth).map_err(|_| DecryptError::Randomness)?; - let secret = SecretKey::from_bytes(&secret.into()).map_err(|_| DecryptError::Randomness)?; + let secret = loop { + if let Ok(key) = SecretKey::from_bytes(&secret.into()) { + break key; + } + getrandom::fill(&mut secret).map_err(|_| DecryptError::Randomness)?; + }; Ok(Self::new(secret, auth)) } @@ -112,6 +144,18 @@ impl Keys { Some(Self::new(secret, auth)) } + #[cfg_attr( + all( + not(test), + any( + target_os = "android", + target_os = "ios", + target_os = "macos", + target_os = "windows" + ) + ), + expect(dead_code, reason = "the key store's `store_new` calls this") + )] /// Serialize the key pair as the 32-byte secret scalar and the 16-byte authentication /// secret. pub(crate) fn to_bytes(&self) -> [u8; KEY_LEN] { diff --git a/src/windows.rs b/src/windows.rs index 511db85..e615d38 100644 --- a/src/windows.rs +++ b/src/windows.rs @@ -205,6 +205,7 @@ fn deliver( started_app, }; DISPATCHER.emit(Event::Message(message.clone())); + crate::__private::run_rendering_handler(&message); crate::__private::run_background_handler(message); }); // The deferral keeps the task alive while the handler runs, so it completes last. diff --git a/tests/linux_notifications.rs b/tests/linux_notifications.rs index 0baa9b9..47f0811 100644 --- a/tests/linux_notifications.rs +++ b/tests/linux_notifications.rs @@ -11,8 +11,49 @@ fn main() { #[cfg(not(target_os = "linux"))] fn main() {} +/// The test's rendering handler, which answers `NoAlert` to `no-alert`, unwinds on `panic` and +/// shows the payload kind as a tagged notification otherwise. +#[cfg(target_os = "linux")] +#[expect( + clippy::unnecessary_wraps, + reason = "the rendering handler signature settles through a result the runner turns into an outcome" +)] +#[pushups::rendering_handler] +fn render( + _context: pushups::RenderingContext, + message: pushups::Message, +) -> Result { + let kind = String::from_utf8(message.payload).expect("the test payloads are ascii"); + match kind.as_str() { + "no-alert" => Ok(pushups::Rendering::NoAlert), + "panic" => panic!("the test rendering handler unwinds"), + _ => Ok(pushups::Notification::new(&kind) + .tag(&kind) + .body("rendered") + .into()), + } +} + +/// The test's background handler, which marks whether the render's notification was shown +/// before it ran. +#[cfg(target_os = "linux")] +#[pushups::background_handler] +fn on_background(_context: pushups::Context, message: pushups::Message) { + let kind = String::from_utf8(message.payload).expect("the test payloads are ascii"); + let marks = std::env::var_os("PUSHUPS_TEST_MARKS").expect("the parent names the marks"); + let rendered = std::path::Path::new(&marks).join(format!("rendered-{kind}")); + let order = if rendered.exists() { "after" } else { "before" }; + std::fs::write( + std::path::Path::new(&marks).join(format!("b-{kind}")), + order, + ) + .expect("the background mark"); +} + #[cfg(target_os = "linux")] mod linux { + use base64::Engine; + use p256::elliptic_curve::point::AffineCoordinates; use std::collections::HashMap; use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; @@ -44,6 +85,8 @@ mod linux { const NAMED: &str = "named"; /// An entry this crate wrote with other contents, which `install` rewrites and keeps the portal for. const STALE_ENTRY: &str = "stale-entry"; + /// A push rendered by the app's rendering handler before the background handler runs it. + const RENDERING: &str = "rendering"; /// The desktop entry the parent puts in place for the scenarios that need one already. const PACKAGED_ENTRY: &str = "[Desktop Entry]\nType=Application\nName=Packaged\n"; /// An entry an earlier run of this crate wrote, before the app had a display name. @@ -80,6 +123,7 @@ mod linux { HUNG, NAMED, STALE_ENTRY, + RENDERING, ] { run(scenario); println!("linux notification scenario {scenario}: ok"); @@ -151,6 +195,7 @@ mod linux { struct Notifications { log: Log, next: AtomicU32, + marks: Option, } #[expect( @@ -179,6 +224,10 @@ mod linux { "notify app={app_name} replaces={replaces_id} summary={summary} body={body} actions={actions:?} icon={app_icon} image={}", text(&hints, "image-path") )); + if let Some(marks) = &self.marks { + std::fs::write(marks.join(format!("rendered-{summary}")), b"") + .expect("the rendered mark"); + } if replaces_id == 0 { self.next.fetch_add(1, Ordering::SeqCst) } else { @@ -319,7 +368,7 @@ mod linux { notifications: Option, } - fn serve(scenario: &str, address: &str, log: &Log) -> Services { + fn serve(scenario: &str, address: &str, log: &Log, marks: &Path) -> Services { let portal = matches!( scenario, PORTAL @@ -349,7 +398,7 @@ mod linux { builder.build().expect("the portal connects") }); let notifications = match scenario { - FALLBACK | FRESH_ENTRY | NO_BACKEND | NAMED => Some( + FALLBACK | FRESH_ENTRY | NO_BACKEND | NAMED | RENDERING => Some( connect(address) .name(NOTIFICATIONS_NAME) .expect("the notifications name is valid") @@ -358,6 +407,7 @@ mod linux { Notifications { log: Arc::clone(log), next: AtomicU32::new(7), + marks: (scenario == RENDERING).then(|| marks.to_path_buf()), }, ) .expect("the notifications object is served") @@ -412,7 +462,7 @@ mod linux { .expect("the desktop entry in place"); } let log: Log = Arc::default(); - let fakes = serve(scenario, &address, &log); + let fakes = serve(scenario, &address, &log, &marks); let mut app = Reaped( Command::new(&exe) .env(CHILD, scenario) @@ -446,6 +496,7 @@ mod linux { FRESH_ENTRY => check_fresh_entry(&marks, &data_home, &log), NAMED => check_named(&marks, &data_home, &log), STALE_ENTRY => check_stale_entry(&marks, &data_home, &log), + RENDERING => check_rendering(&marks, &data_home, &log, &address), _ => {} } let mut status = None; @@ -624,6 +675,97 @@ mod linux { ); } + /// The push renders through the app's rendering handler before the background handler runs, + /// `NoAlert` shows nothing, and a panicking render still lets the background handler run. + fn check_rendering(marks: &Path, data_home: &Path, log: &Log, address: &str) { + assert!( + wait_until(|| marks.join("ready").exists()), + "{RENDERING}: the child never became ready" + ); + let dir = data_home.join(APP_ID).join("pushups"); + let token_file = std::fs::read_to_string(dir.join("token")).expect("the token file"); + let keys = std::fs::read(dir.join("keys")).expect("the keys file"); + assert_eq!(keys.len(), 48, "{RENDERING}: the keys file"); + let secret: [u8; 32] = keys[0..32].try_into().expect("the scalar is 32 bytes"); + let auth: [u8; 16] = keys[32..48] + .try_into() + .expect("the auth secret is 16 bytes"); + let secret = p256::SecretKey::from_bytes(&secret.into()).expect("the scalar is in range"); + let public = secret.public_key(); + let point = public.as_affine(); + let mut p256dh = [0u8; 65]; + p256dh[0] = 0x04; + for (slot, coordinate) in p256dh[1..] + .iter_mut() + .zip(point.x().iter().chain(point.y().iter())) + { + *slot = *coordinate; + } + let token: pushups::Token = serde_json::from_value(serde_json::json!({ + "transport": "web_push", + "endpoint": "https://push.example/app", + "p256dh": base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(p256dh), + "auth": base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(auth), + "expires": serde_json::Value::Null, + })) + .expect("the token parses"); + let sender = connect(address) + .build() + .expect("a connection for the pushes"); + for (index, kind) in ["show", "no-alert", "panic"].iter().enumerate() { + let sealed = pushups_sender::encrypt(&token, kind.as_bytes()) + .expect("the seal encrypts") + .into_bytes(); + let args = HashMap::from([ + ("token", Value::from(token_file.as_str())), + ("message", Value::from(sealed)), + ("id", Value::from(format!("r-{index}"))), + ]); + sender + .call_method( + Some(APP_ID), + "/org/unifiedpush/Connector", + Some("org.unifiedpush.Connector2"), + "Message", + &args, + ) + .expect("the push reaches the connector"); + } + std::fs::write(marks.join("done"), b"").expect("the done mark"); + let recorded = log.lock().expect("the log").clone(); + let shown: Vec<&String> = recorded + .iter() + .filter(|line| line.contains("summary=show")) + .collect(); + assert_eq!( + shown.len(), + 1, + "{RENDERING}: the notification the render showed is not in the Notify calls, {recorded:?}" + ); + let line = &shown[0]; + assert!(line.contains("body=rendered"), "{line}"); + assert!( + recorded + .iter() + .all(|line| !line.contains("summary=no-alert") && !line.contains("summary=panic")), + "{RENDERING}: NoAlert or the panic showed something, {recorded:?}" + ); + for kind in ["show", "no-alert", "panic"] { + let order = + std::fs::read_to_string(marks.join(format!("b-{kind}"))).unwrap_or_else(|error| { + panic!("{RENDERING}: no background mark for {kind}, {error}") + }); + if kind == "show" { + assert_eq!( + order, "after", + "{RENDERING}: the background handler ran before the render finished" + ); + } else { + assert_eq!(order, "before", "{RENDERING}: {kind} showed something"); + } + } + } + /// The first process installs, which writes the activation file, and exits. A tap, or a /// launcher's `Open`, then makes the bus start a headless process, which starts the UI with /// the call's activation token. The UI drains the tap, or finds nothing for a launch. @@ -757,6 +899,27 @@ mod linux { } /// The app side, run in a child process on the private bus. + /// The render's pushes render, refuse, or unwind in the app before the background handler + /// runs, and nothing else arrives on the channel. + fn child_rendering(received: &mpsc::Receiver) { + std::fs::write(mark("ready"), b"").expect("the ready mark"); + while !mark("done").exists() { + std::thread::sleep(Duration::from_millis(50)); + } + for kind in ["show", "no-alert", "panic"] { + let seen = next_message(received, BOUND).expect("the push reaches the channel"); + assert_eq!(seen.payload, kind.as_bytes().to_vec()); + assert!( + !seen.started_app, + "a push to a running process started nothing" + ); + } + assert!( + next_message(received, QUIET).is_none(), + "a fourth message arrived" + ); + } + fn child(scenario: &str) { let activated = std::env::var_os("PUSHUPS_DBUS_ACTIVATED").is_some(); // A process the bus started for a tap stays inside `install`, and starts the UI process. @@ -833,6 +996,7 @@ mod linux { .expect("the portal shows the notification"); std::fs::write(mark("shown"), b"").expect("the shown mark"); } + RENDERING => child_rendering(&received), HUNG => { let started = Instant::now(); let shown = Notification::new("Held").show(); diff --git a/tests/windows.rs b/tests/windows.rs index c0088fc..8ccb44a 100644 --- a/tests/windows.rs +++ b/tests/windows.rs @@ -1,11 +1,29 @@ //! `install` and `register` on a Windows machine without the Windows App SDK runtime beside the -//! test binary, as an app shipped without `Microsoft.WindowsAppRuntime.Bootstrap.dll` finds them. +//! test binary, as an app shipped without `Microsoft.WindowsAppRuntime.Bootstrap.dll` finds them, +//! with the handler expansions type-checked for the Windows target. #![cfg(target_os = "windows")] use pushups::{Config, Error, WnsConfig}; const REMOTE_ID: &str = "6f9619ff-8b86-d011-b42d-00c04fc964ff"; +/// The test's rendering handler, which shows nothing, for the expansion's type check. +#[expect( + clippy::unnecessary_wraps, + reason = "the rendering handler signature settles through a result the runner turns into an outcome" +)] +#[pushups::rendering_handler] +fn render( + _context: pushups::RenderingContext, + _message: pushups::Message, +) -> Result { + Ok(pushups::Rendering::NoAlert) +} + +/// The test's background handler, for the expansion's type check. +#[pushups::background_handler] +fn on_background(_context: pushups::Context, _message: pushups::Message) {} + #[test] fn install_without_a_windows_part_is_not_configured() { assert_eq!(pushups::install(Config::new()), Err(Error::NotConfigured)); From 788baad78bfc4494ff76784aca12e004f7723c09 Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Sun, 25 Oct 2026 23:35:49 +0100 Subject: [PATCH 3/9] Decrypt pushes on Android, Apple and Windows and rotate keys on every keyed backend --- Cargo.toml | 4 - README.md | 4 +- .../src/main/kotlin/rs/pushups/Delivery.kt | 10 + android/src/main/kotlin/rs/pushups/Native.kt | 19 +- android/src/main/kotlin/rs/pushups/Pushups.kt | 4 + .../rs/pushups/PushupsMessagingService.kt | 12 +- ci/android/compile-contract/Cargo.lock | 5 + ci/android/probe/Cargo.lock | 150 +++++++++ ci/android/probe/Cargo.toml | 3 + ci/android/probe/src/lib.rs | 21 ++ ci/android/run.sh | 6 +- ci/android/send.py | 41 ++- .../rs/pushups/ci/EncryptedPushesTest.kt | 168 ++++++++++ .../androidTest/kotlin/rs/pushups/ci/Probe.kt | 4 + .../kotlin/rs/pushups/ci/Pushes.kt | 23 ++ ci/apple/harness/Info.plist | 2 + ci/apple/harness/main.swift | 48 +++ ci/apple/probe/Cargo.lock | 175 ++++++++++ ci/apple/probe/Cargo.toml | 7 +- ci/apple/probe/src/lib.rs | 120 +++++++ ci/apple/run.sh | 6 + ci/linux/probe/Cargo.lock | 24 +- ci/linux/probe/Cargo.toml | 7 + ci/linux/probe/src/seal.rs | 42 +++ ci/probe-support/Cargo.lock | 5 + ci/web/probe/Cargo.lock | 5 + ci/windows/w1/Cargo.lock | 5 + docs/encryption.md | 38 +++ docs/mobile-notifications.md | 2 +- examples/leptos/Cargo.lock | 9 +- examples/tao/Cargo.lock | 9 +- examples/tauri/Cargo.lock | 9 +- examples/winit/Cargo.lock | 9 +- examples/yew/Cargo.lock | 9 +- ios/PushupsNotificationService.swift | 20 +- pushups-macros/src/lib.rs | 14 +- src/android.rs | 136 +++++++- src/apple.rs | 28 ++ src/apple/keychain.rs | 89 +++++ src/apple/runtime.rs | 51 ++- src/apple/service.rs | 162 +++++---- src/envelope.rs | 41 +-- src/inbox.rs | 5 +- src/key_store.rs | 90 ----- src/linux.rs | 51 ++- src/private.rs | 24 +- src/queue.rs | 135 ++++++-- src/webpush_crypto.rs | 36 -- src/windows.rs | 228 +++++++++++-- tauri-plugin-pushups/Cargo.lock | 5 + tests/linux.rs | 307 +++++++++++++++++- tests/linux_notifications.rs | 15 +- tests/windows.rs | 8 + 53 files changed, 2122 insertions(+), 328 deletions(-) create mode 100644 ci/android/src/androidTest/kotlin/rs/pushups/ci/EncryptedPushesTest.kt create mode 100644 ci/linux/probe/src/seal.rs create mode 100644 docs/encryption.md create mode 100644 src/apple/keychain.rs diff --git a/Cargo.toml b/Cargo.toml index c84dfc3..bd98ead 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -149,7 +149,3 @@ missing_errors_doc = "warn" missing_panics_doc = "warn" allow_attributes = "deny" allow_attributes_without_reason = "deny" - -# The native key stores are referenced only by the platform `install` wiring. -[lints.cargo] -unused_dependencies = "allow" diff --git a/README.md b/README.md index a0b0f7d..ac1a7c5 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ pushups::set_handler(|event| match event { let _ = pushups::register(); ``` -The handler runs on the platform's delivery thread, so waking the toolkit is its job. The `stream` feature adds `pushups::events()`, the same events as a stream. The `encryption` feature makes the pushes end-to-end encrypted, the device's keys in the platform key store, and `pushups::rotate_keys()` replaces the keys and registers again. +The handler runs on the platform's delivery thread, so waking the toolkit is its job. The `stream` feature adds `pushups::events()`, the same events as a stream. The `encryption` feature makes the pushes [end-to-end encrypted](https://github.com/LucaCappelletti94/pushups/blob/main/docs/encryption.md), with the device's keys in the platform key store. ## Support @@ -70,7 +70,7 @@ fn on_push(context: Context<'_>, message: Message) { ## iOS and macOS -The crate adds its push methods to whatever app delegate the toolkit installs, while closed-app iOS alert transformation uses a separate [`rendering_handler`] Rust callback and [Swift extension adapter](https://github.com/LucaCappelletti94/pushups/blob/main/docs/mobile-notifications.md). The bundle needs the push entitlement and, on iOS, the `remote-notification` background mode, which a `dx` app sets in `Dioxus.toml`. A Tauri app gets the iOS entitlement and the background mode from `tauri-plugin-pushups` and names a macOS entitlements file in `tauri.conf.json`, as [`examples/tauri`](https://github.com/LucaCappelletti94/pushups/tree/main/examples/tauri) does. +The crate adds its push methods to whatever app delegate the toolkit installs, while closed-app iOS alert transformation uses a separate [`rendering_handler`] Rust callback and [Swift extension adapter](https://github.com/LucaCappelletti94/pushups/blob/main/docs/mobile-notifications.md). The bundle needs the push entitlement and, on iOS, the `remote-notification` background mode, which a `dx` app sets in `Dioxus.toml`. A Tauri app gets the iOS entitlement and the background mode from `tauri-plugin-pushups` and names a macOS entitlements file in `tauri.conf.json`, as [`examples/tauri`](https://github.com/LucaCappelletti94/pushups/tree/main/examples/tauri) does. With the `encryption` feature the app and the extension share the keychain access group both targets carry in the `keychain-access-groups` entitlement, which each `Info.plist` names to the crate with the `PushupsKeychainAccessGroup` string key. ```toml [ios.entitlements] diff --git a/android/src/main/kotlin/rs/pushups/Delivery.kt b/android/src/main/kotlin/rs/pushups/Delivery.kt index 36d50f4..8c20648 100644 --- a/android/src/main/kotlin/rs/pushups/Delivery.kt +++ b/android/src/main/kotlin/rs/pushups/Delivery.kt @@ -44,3 +44,13 @@ internal object Transport { context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putString(KEY, transport).apply() } } + +/** + * The sealed field of an encrypted push, which the module looks up when the crate's + * `encryption` feature was on at build time, asked of Rust once the library loads. + */ +internal object Envelope { + const val ENCRYPTED_FIELD = "pushups" + + val enabled: Boolean by lazy { invokeNative("encrypts") { Native.encrypts() } ?: false } +} diff --git a/android/src/main/kotlin/rs/pushups/Native.kt b/android/src/main/kotlin/rs/pushups/Native.kt index a4f3c63..f86a86a 100644 --- a/android/src/main/kotlin/rs/pushups/Native.kt +++ b/android/src/main/kotlin/rs/pushups/Native.kt @@ -38,23 +38,32 @@ object Native { @JvmStatic external fun onPermissionResult(requestId: Long, granted: Boolean) + + /** Whether the crate's `encryption` feature was on, which the module asks once. */ + @JvmStatic + external fun encrypts(): Boolean + + /** The plaintext of a sealed `pushups` field, or a thrown `RuntimeException` on failure. */ + @JvmStatic + external fun openEnvelope(sealed: String): ByteArray } /** Log tag shared by every pushups line. */ const val TAG = "pushups" /** - * Calls a Rust export only once the provider loaded the cdylib and logs the - * RuntimeException the Rust side throws instead of taking the app down. + * Calls a Rust export only once the provider loaded the cdylib, returns the export's value, + * and logs the RuntimeException the Rust side throws instead of taking the app down. */ -internal fun invokeNative(op: String, block: () -> Unit) { +internal fun invokeNative(op: String, block: () -> T): T? { if (!ProcessState.loaded) { Log.w(TAG, "$op skipped, the native library is not loaded") - return + return null } - try { + return try { block() } catch (e: RuntimeException) { Log.e(TAG, op, e) + null } } diff --git a/android/src/main/kotlin/rs/pushups/Pushups.kt b/android/src/main/kotlin/rs/pushups/Pushups.kt index 919eddb..56ccb2f 100644 --- a/android/src/main/kotlin/rs/pushups/Pushups.kt +++ b/android/src/main/kotlin/rs/pushups/Pushups.kt @@ -135,6 +135,10 @@ object Pushups { return Presentation.present(appContext, title, body, navigate, tag, icon, data) } + /** Reports the transport this app's pushes come through, which Rust's key rotation reads. */ + @JvmStatic + fun transport(): String = context?.let { Transport.get(it) } ?: Transport.FCM + private fun enabled(context: Context): Boolean = NotificationManagerCompat.from(context).areNotificationsEnabled() diff --git a/android/src/main/kotlin/rs/pushups/PushupsMessagingService.kt b/android/src/main/kotlin/rs/pushups/PushupsMessagingService.kt index 6e9ffee..83e41e1 100644 --- a/android/src/main/kotlin/rs/pushups/PushupsMessagingService.kt +++ b/android/src/main/kotlin/rs/pushups/PushupsMessagingService.kt @@ -24,7 +24,17 @@ class PushupsMessagingService : FirebaseMessagingService() { override fun onMessageReceived(message: RemoteMessage) { Log.d(TAG, "message ${message.messageId} received, priority ${message.priority}") - deliverPush(applicationContext, JSONObject(message.data).toString().toByteArray(StandardCharsets.UTF_8)) + val sealed = if (Envelope.enabled) message.data[Envelope.ENCRYPTED_FIELD] else null + if (sealed != null) { + // The Rust side logs the open failure and throws, so a null drops the push. + val plaintext = invokeNative("openEnvelope") { Native.openEnvelope(sealed) } ?: return + deliverPush(applicationContext, plaintext) + return + } + deliverPush( + applicationContext, + JSONObject(message.data).toString().toByteArray(StandardCharsets.UTF_8) + ) } override fun onDeletedMessages() { diff --git a/ci/android/compile-contract/Cargo.lock b/ci/android/compile-contract/Cargo.lock index 477b301..2dc6a53 100644 --- a/ci/android/compile-contract/Cargo.lock +++ b/ci/android/compile-contract/Cargo.lock @@ -1041,6 +1041,7 @@ dependencies = [ "p256", "parking_lot", "pushups-macros", + "pushups-token", "sha2", "thiserror 2.0.21", "wasm-bindgen", @@ -1073,6 +1074,10 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "pushups-token" +version = "0.1.0" + [[package]] name = "quote" version = "1.0.47" diff --git a/ci/android/probe/Cargo.lock b/ci/android/probe/Cargo.lock index 5085d32..e4e2128 100644 --- a/ci/android/probe/Cargo.lock +++ b/ci/android/probe/Cargo.lock @@ -37,6 +37,43 @@ dependencies = [ "ghash", ] +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "android-native-keyring-store" +version = "1.0.0" +source = "git+https://github.com/open-source-cooperative/android-native-keyring-store?rev=56f0c537a7c1#56f0c537a7c13eee1715c8a77b0301ab6cd44b32" +dependencies = [ + "base64", + "jni", + "keyring-core", + "log", + "ndk-context", + "regex", + "serde", + "serde_json", + "thiserror 2.0.21", + "tracing", +] + +[[package]] +name = "apple-native-keyring-store" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b350bfd03649e07aa05c0a81b3e15934374e585c98204a57e20b9d49f49bb9a" +dependencies = [ + "keyring-core", + "log", + "security-framework", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -186,6 +223,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "base64ct" version = "1.8.3" @@ -235,6 +278,12 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.12.1" @@ -295,6 +344,22 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpubits" version = "0.1.1" @@ -733,6 +798,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keyring-core" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29fbf4b551298c67925f38cf341a1ada797a75f1837bdf8ae1fc8e2ebc6efb59" +dependencies = [ + "log", +] + [[package]] name = "libc" version = "0.2.190" @@ -795,6 +869,12 @@ dependencies = [ "autocfg", ] +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + [[package]] name = "num-traits" version = "0.2.19" @@ -1035,6 +1115,8 @@ name = "pushups" version = "0.1.0" dependencies = [ "aes-gcm", + "android-native-keyring-store", + "apple-native-keyring-store", "block2", "dispatch2", "futures-channel", @@ -1042,6 +1124,7 @@ dependencies = [ "hkdf", "jni", "js-sys", + "keyring-core", "libc", "linkme", "objc2", @@ -1063,6 +1146,7 @@ dependencies = [ "windows-core", "windows-future", "windows-link", + "windows-native-keyring-store", "zbus", ] @@ -1074,6 +1158,7 @@ dependencies = [ "jni", "parking_lot", "pushups", + "pushups-token", ] [[package]] @@ -1121,6 +1206,35 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "rfc6979" version = "0.6.0" @@ -1179,6 +1293,29 @@ dependencies = [ "zeroize", ] +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "serde" version = "1.0.229" @@ -1649,6 +1786,19 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-native-keyring-store" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8" +dependencies = [ + "byteorder", + "keyring-core", + "regex", + "windows-sys 0.61.2", + "zeroize", +] + [[package]] name = "windows-numerics" version = "0.3.1" diff --git a/ci/android/probe/Cargo.toml b/ci/android/probe/Cargo.toml index a338a57..a088ce3 100644 --- a/ci/android/probe/Cargo.toml +++ b/ci/android/probe/Cargo.toml @@ -16,6 +16,7 @@ futures-executor = "0.3" jni = "0.21" parking_lot = "0.12" pushups = { path = "../../.." } +pushups-token = { path = "../../../pushups-token" } # What the app compiles in, chosen per run.sh variant: one Firebase configuration or none, and # the background handler or the rendering handler. @@ -28,3 +29,5 @@ config-real = [] config-fixture = [] # Two clients, neither for the test package. config-mismatched = [] +# The crate's `encryption` feature, which opens the test's encrypted pushes. +encryption = ["pushups/encryption"] diff --git a/ci/android/probe/src/lib.rs b/ci/android/probe/src/lib.rs index 953412e..2ebd32a 100644 --- a/ci/android/probe/src/lib.rs +++ b/ci/android/probe/src/lib.rs @@ -143,6 +143,15 @@ fn files_dir( fn describe(event: &Event) -> String { match event { + #[cfg(feature = "encryption")] + Event::Token(Token::Fcm { + token, + keys: Some(keys), + }) => format!( + "token {token} p256dh={} auth={}", + pushups_token::base64url::encode(&keys.p256dh), + pushups_token::base64url::encode(&keys.auth) + ), Event::Token(Token::Fcm { token, .. }) => format!("token {token}"), Event::Token(token) => match token.web_push_subscription() { Some(subscription) => format!( @@ -275,6 +284,18 @@ pushups::__private::android::exports! { ) } + /// `Probe.rotateKeys()`: `null`, or the error of `pushups::rotate_keys`. + #[cfg(feature = "encryption")] + fn Java_rs_pushups_ci_Probe_rotateKeys<'local>( + env: JNIEnv<'local>, + _class: JClass<'local>, + ) -> jstring { + java_string( + env, + pushups::rotate_keys().err().map(|error| error.to_string()), + ) + } + /// `Probe.startPermission()`: asks for the permission on a thread of its own, since the /// test thread has to answer the prompt. fn Java_rs_pushups_ci_Probe_startPermission<'local>( diff --git a/ci/android/run.sh b/ci/android/run.sh index abd6dbc..3e4bbca 100755 --- a/ci/android/run.sh +++ b/ci/android/run.sh @@ -44,7 +44,7 @@ fi # The app server's side of Web Push: a fresh VAPID key pair and the `web-push` crate's sender. vapid_generate "$out" vapid_b64url=$(vapid_public_b64url "$out/vapid.pem") -(cd "$root/ci/linux/probe" && cargo build --quiet --bin send) +(cd "$root/ci/linux/probe" && cargo build --quiet --bin send --bin seal) # The screen stays on and unlocked while the tests run, since install checks, the prompt and the # shade show on it. A variant's build takes minutes, so each one wakes the device again. @@ -66,7 +66,7 @@ adb shell input keyevent KEYCODE_HOME # Request files a previous run left on the device would be sent ahead of this run's, so they go. adb uninstall "$package" >/dev/null 2>&1 || true -python3 "$ci/send.py" "$key" --webpush-sender "$root/ci/linux/probe/target/debug/send" --vapid "$out/vapid.pem" & +python3 "$ci/send.py" "$key" --webpush-sender "$root/ci/linux/probe/target/debug/send" --vapid "$out/vapid.pem" --fcm-sealer "$root/ci/linux/probe/target/debug/seal" & sender=$! trap 'kill $sender 2>/dev/null || true; adb shell pm enable io.heckel.ntfy >/dev/null 2>&1 || true; adb shell svc power stayon false || true' EXIT @@ -117,6 +117,8 @@ run_variant full "config-real background-handler" default PushupsTest run_variant fallback-name config-real fallback-name FallbackNameTest run_variant no-background-handler config-real default NoBackgroundHandlerTest run_variant rendering "config-real rendering-handler" default RenderingHandlerTest +# The crate's `encryption` feature: the token reports its keys and encrypted pushes arrive as the plaintext. +run_variant encryption "config-real encryption background-handler rendering-handler" default EncryptedPushesTest run_variant unconfigured "" default RegistrationFailsTest expect "FCM is not configured" run_variant mismatched config-mismatched default RegistrationFailsTest expect "FCM is not configured" run_variant rejected config-fixture default RegistrationFailsTest expect "Please set a valid API key" diff --git a/ci/android/send.py b/ci/android/send.py index 5da67ce..8e22b2b 100644 --- a/ci/android/send.py +++ b/ci/android/send.py @@ -1,12 +1,15 @@ """Sends the pushes the instrumented tests ask for. The tests write `.json` into `files/pushups-ci` of the test package, either an FCM HTTP v1 -`message` object or `{"webpush": , "payload": }`. This script polls that -directory over adb, sends each push with keys that stay on the host, the FCM service account or the -VAPID private key, and writes the answer to `.status` as ` `. It stops -after `--bound` seconds on the monotonic clock, or when killed by run.sh. - -Usage: python3 send.py [--webpush-sender path --vapid pem] [--bound seconds] +`message` object, `{"webpush": , "payload": }`, or +`{"fcm": , "keys": , "plaintext": , "corrupt": true}`, which +this script seals with the token's keys through the `seal` binary before it sends the message. +It polls the directory over adb, sends each push with keys that stay on the host, the FCM +service account or the VAPID private key, and writes the answer to `.status` as +` `. It stops after `--bound` seconds on the monotonic clock, or when +killed by run.sh. + +Usage: python3 send.py [--webpush-sender path --vapid pem] [--fcm-sealer path] [--bound seconds] """ import argparse @@ -111,11 +114,32 @@ def send_web_push(sender: Path, vapid: Path, request: dict) -> str: return f"200 {done.stdout.strip()}" if done.returncode == 0 else f"502 {done.stdout.strip()} {done.stderr.strip()}" +def send_encrypted_fcm(sealer: Path, key: dict, token: str, request: dict) -> str: + """Seals the request's plaintext with the token's keys and sends it as the `pushups` field.""" + done = subprocess.run( + [str(sealer), request["keys"]["p256dh"], request["keys"]["auth"], request["plaintext"]], + capture_output=True, + text=True, + timeout=30, + ) + if done.returncode != 0: + return f"502 {done.stdout.strip()} {done.stderr.strip()}" + sealed = done.stdout.strip() + if request.get("corrupt"): + # A single flipped base64url character, which the device's decoder keeps and the decrypt + # refuses. + sealed = sealed[:-1] + ("B" if sealed[-1] == "A" else "A") + message = request["fcm"] + message["data"]["pushups"] = sealed + return send(key, token, message) + + def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("key", type=Path) parser.add_argument("--webpush-sender", type=Path) parser.add_argument("--vapid", type=Path) + parser.add_argument("--fcm-sealer", type=Path) parser.add_argument("--bound", type=float, default=1800) args = parser.parse_args() key = json.loads(args.key.read_text()) @@ -141,6 +165,11 @@ def main() -> None: status = "500 send.py has no --webpush-sender and --vapid" else: status = send_web_push(args.webpush_sender, args.vapid, request) + elif "fcm" in request: + if args.fcm_sealer is None: + status = "500 send.py has no --fcm-sealer" + else: + status = send_encrypted_fcm(args.fcm_sealer, key, token, request) else: status = send(key, token, request) print(f"push {request_id}: {status.splitlines()[0][:120]}", flush=True) diff --git a/ci/android/src/androidTest/kotlin/rs/pushups/ci/EncryptedPushesTest.kt b/ci/android/src/androidTest/kotlin/rs/pushups/ci/EncryptedPushesTest.kt new file mode 100644 index 0000000..125a1f5 --- /dev/null +++ b/ci/android/src/androidTest/kotlin/rs/pushups/ci/EncryptedPushesTest.kt @@ -0,0 +1,168 @@ +package rs.pushups.ci + +import android.content.Context +import android.os.Build +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.google.android.gms.tasks.Tasks +import com.google.firebase.messaging.FirebaseMessaging +import org.json.JSONObject +import org.junit.AfterClass +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.BeforeClass +import org.junit.FixMethodOrder +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.MethodSorters +import java.util.UUID +import java.util.concurrent.TimeUnit + +/** + * The plan's Android encrypted push lines with real FCM pushes: the token reports the keys its + * envelopes are sealed against, an encrypted push reaches every job as the plaintext, in the UI + * and headless, a corrupt one reaches none, and an unencrypted push arrives as the data map. + * The steps share the process, so they run in name order. + */ +@RunWith(AndroidJUnit4::class) +@FixMethodOrder(MethodSorters.NAME_ASCENDING) +class EncryptedPushesTest { + + companion object { + private val instrumentation get() = InstrumentationRegistry.getInstrumentation() + private val context: Context get() = instrumentation.targetContext + + private lateinit var token: String + private lateinit var p256dh: String + private lateinit var auth: String + private var scenario: ActivityScenario? = null + + @BeforeClass + @JvmStatic + fun setup() { + if (Build.VERSION.SDK_INT >= 33) { + instrumentation.uiAutomation.grantRuntimePermission( + context.packageName, + android.Manifest.permission.POST_NOTIFICATIONS, + ) + } + token = Tasks.await(FirebaseMessaging.getInstance().token, 120, TimeUnit.SECONDS) + assertNull(Probe.install()) + Probe.setHandler() + assertNull(Probe.register()) + } + + @AfterClass + @JvmStatic + fun finish() { + scenario?.close() + writeCoverage() + } + + /** The FCM message object of a data push to the token. */ + private fun message(id: String): JSONObject = + JSONObject().put("token", token).put("data", JSONObject().put("id", id)) + + /** Waits for a token line whose keys differ from [p256dh] and [auth] and remembers them. */ + private fun awaitNewKeys(): String { + val fresh = waitFor(DELIVERY_MS) { + lines(Probe.events()) + .filter { it.startsWith("token $token p256dh=") } + .map { it.substringAfter("p256dh=").substringBefore(" auth=") to it.substringAfter(" auth=") } + .lastOrNull { it.first != p256dh || it.second != auth } + } ?: error("the rotated token's keys did not differ within ${DELIVERY_MS / 1000} s, got:\n${Probe.events()}") + p256dh = fresh.first + auth = fresh.second + return fresh.first + } + + /** Checks that [line] is a job's push line for the plaintext [plaintext]. */ + private fun assertPlaintextLine(line: String, plaintext: String) { + assertTrue(line, line.startsWith("message started_app=")) + assertTrue(line, line.contains(" $plaintext")) + assertFalse(line, line.contains("\"pushups\"")) + } + + private fun openUi() { + scenario = ActivityScenario.launch(TapActivity::class.java) + } + + /** Ends the UI session. */ + private fun closeUi() { + scenario?.close() + scenario = null + awaitNoActivity() + } + } + + @Test + fun a01_registerReportsTheTokenWithItsKeys() { + val line = awaitLine(Probe::events, "token $token p256dh=") + p256dh = line.substringAfter("p256dh=").substringBefore(" auth=") + auth = line.substringAfter(" auth=") + assertTrue(line, p256dh.isNotEmpty() && auth.isNotEmpty()) + } + + @Test + fun a02_anEncryptedPushReachesEveryJobInTheUi() { + openUi() + val id = "ui-${UUID.randomUUID()}" + val plaintext = """{"encrypted":"$id"}""" + Pushes.sendEncrypted(context, id, message(id), p256dh, auth, plaintext) + assertPlaintextLine(awaitLine(Probe::events, id), plaintext) + assertPlaintextLine(awaitLine(Probe::rendered, id), plaintext) + assertPlaintextLine(awaitLine(Probe::handled, id), plaintext) + } + + @Test + fun a03_anEncryptedPushReachesEveryJobHeadless() { + closeUi() + val id = "headless-${UUID.randomUUID()}" + val plaintext = """{"encrypted":"$id"}""" + Pushes.sendEncrypted(context, id, message(id), p256dh, auth, plaintext) + assertPlaintextLine(awaitLine(Probe::events, id), plaintext) + assertPlaintextLine(awaitLine(Probe::rendered, id), plaintext) + assertPlaintextLine(awaitLine(Probe::handled, id), plaintext) + } + + @Test + fun a04_aCorruptEnvelopeReachesNoJob() { + val id = "corrupt-${UUID.randomUUID()}" + Pushes.sendEncrypted(context, id, message(id), p256dh, auth, """{"encrypted":"$id"}""", corrupt = true) + val arrived = waitFor(5_000) { + if (Probe.events().contains(id) || Probe.rendered().contains(id) || Probe.handled().contains(id)) { + "the corrupt envelope reached a job" + } else { + null + } + } + assertNull(Probe.events(), arrived) + assertNull(Probe.rendered(), arrived) + assertNull(Probe.handled(), arrived) + } + + @Test + fun a05_anUnencryptedPushArrivesAsTheDataMap() { + val id = "plain-${UUID.randomUUID()}" + Pushes.send(context, id, message(id)) + val line = awaitLine(Probe::events, id) + assertTrue(line, line.startsWith("message started_app=")) + assertTrue(line, line.contains("""{"id":"$id"}""")) + } + + @Test + fun a06_rotatedKeysReportAgainAndOpenTheNewPushes() { + val old = p256dh + assertNull(Probe.rotateKeys()) + val fresh = awaitNewKeys() + assertTrue(fresh, fresh != old) + val id = "rotated-${UUID.randomUUID()}" + val plaintext = """{"encrypted":"$id"}""" + Pushes.sendEncrypted(context, id, message(id), p256dh, auth, plaintext) + assertPlaintextLine(awaitLine(Probe::events, id), plaintext) + assertPlaintextLine(awaitLine(Probe::rendered, id), plaintext) + assertPlaintextLine(awaitLine(Probe::handled, id), plaintext) + } +} diff --git a/ci/android/src/androidTest/kotlin/rs/pushups/ci/Probe.kt b/ci/android/src/androidTest/kotlin/rs/pushups/ci/Probe.kt index c97f69a..d01c55f 100644 --- a/ci/android/src/androidTest/kotlin/rs/pushups/ci/Probe.kt +++ b/ci/android/src/androidTest/kotlin/rs/pushups/ci/Probe.kt @@ -22,6 +22,10 @@ object Probe { @JvmStatic external fun register(): String? + /** `null`, or the error of `pushups::rotate_keys`. */ + @JvmStatic + external fun rotateKeys(): String? + /** Starts `pushups::request_permission` on a thread of its own. */ @JvmStatic external fun startPermission() diff --git a/ci/android/src/androidTest/kotlin/rs/pushups/ci/Pushes.kt b/ci/android/src/androidTest/kotlin/rs/pushups/ci/Pushes.kt index d276bdf..1e5c284 100644 --- a/ci/android/src/androidTest/kotlin/rs/pushups/ci/Pushes.kt +++ b/ci/android/src/androidTest/kotlin/rs/pushups/ci/Pushes.kt @@ -30,6 +30,29 @@ object Pushes { request(context, id, JSONObject().put("webpush", webPush).put("payload", payload)) } + /** + * Seals [plaintext] with the keys the registered token reported and sends it to [message]'s + * token as the `pushups` field, [corrupt] when the test wants the open to fail. Fails unless + * the host sent it. + */ + fun sendEncrypted( + context: Context, + id: String, + message: JSONObject, + p256dh: String, + auth: String, + plaintext: String, + corrupt: Boolean = false, + ) { + val keys = JSONObject().put("p256dh", p256dh).put("auth", auth) + val body = JSONObject() + .put("fcm", message) + .put("keys", keys) + .put("plaintext", plaintext) + .put("corrupt", corrupt) + request(context, id, body) + } + private fun request(context: Context, id: String, body: JSONObject) { val dir = File(context.filesDir, "pushups-ci").apply { mkdirs() } val partial = File(dir, "$id.part") diff --git a/ci/apple/harness/Info.plist b/ci/apple/harness/Info.plist index 162e95a..07fb913 100644 --- a/ci/apple/harness/Info.plist +++ b/ci/apple/harness/Info.plist @@ -4,6 +4,8 @@ CFBundleIdentifier rs.pushups.ci.harness + PushupsKeychainAccessGroup + rs.pushups.ci.harness PushupsNotificationFiltering diff --git a/ci/apple/harness/main.swift b/ci/apple/harness/main.swift index d2b8119..6be30bb 100644 --- a/ci/apple/harness/main.swift +++ b/ci/apple/harness/main.swift @@ -1,6 +1,22 @@ import Foundation import UserNotifications +@_silgen_name("__pushups_probe_install") +private func pushupsProbeInstall() + +@_silgen_name("__pushups_probe_rotate") +private func pushupsProbeRotate() -> Int32 + +@_silgen_name("__pushups_probe_seal") +private func pushupsProbeSeal( + _ plaintext: UnsafePointer, + _ length: Int, + _ accessGroup: UnsafePointer +) -> UnsafeMutablePointer? + +@_silgen_name("__pushups_probe_free") +private func pushupsProbeFree(_ sealed: UnsafeMutablePointer?) + final class Settlement: @unchecked Sendable { private let lock = NSLock() private var calls = 0 @@ -80,6 +96,38 @@ if let content = rendered.snapshot.content { expect(content.subtitle == "Kept subtitle" && content.sound == UNNotificationSound.default, "original subtitle and sound preserved") } +// The probe installs the crate, rotates the key in the protected keychain and seals a push the way a sender would. +pushupsProbeInstall() +expect(pushupsProbeRotate() == 0, "key rotation registers") +guard let accessGroup = Bundle.main.object(forInfoDictionaryKey: "PushupsKeychainAccessGroup") as? String else { + failures.append("the harness Info.plist declares no keychain access group") + print("\(failures.count) failures") + exit(1) +} +let sealedPlaintext = Data(#"{"kind":"render","original":"sealed-plaintext"}"#.utf8) +let sealedPointer: UnsafeMutablePointer? = sealedPlaintext.withUnsafeBytes { bytes in + accessGroup.withCString { group in + pushupsProbeSeal(bytes.baseAddress!, bytes.count, group) + } +} +expect(sealedPointer != nil, "the sender sealed the plaintext") +if let sealedPointer { + let sealedText = String(cString: sealedPointer) + pushupsProbeFree(sealedPointer) + let opened = receive(service, ["pushups": sealedText, "original": "ciphertext"]) + wait(opened) + expect(outcome(opened) == "rendered", "the encrypted push renders the plaintext") + expect(opened.snapshot.content?.title == "Rendered title" && opened.snapshot.content?.body == "Decrypted body", "the rendered alert is the plaintext's") + expect(opened.snapshot.content?.userInfo["pushups.notification-data"] as? String == "sealed-plaintext", "the decrypted tap payload") + var corrupted = Array(sealedText) + corrupted[0] = corrupted[0] == "A" ? "B" : "A" + let corruptedPush = receive(service, ["pushups": String(corrupted), "original": "ciphertext"]) + wait(corruptedPush) + expect(outcome(corruptedPush) == "failed", "the corrupted envelope settles failed") + expect(corruptedPush.snapshot.content?.title == "Safe title" && corruptedPush.snapshot.content?.body == "Safe body", "the corrupted envelope keeps the safe alert") + expect(corruptedPush.snapshot.content?.userInfo["original"] as? String == "ciphertext", "the corrupted envelope keeps the sender payload") +} + let cleared = receive(service, ["kind": "clear"]) wait(cleared) if let content = cleared.snapshot.content { diff --git a/ci/apple/probe/Cargo.lock b/ci/apple/probe/Cargo.lock index 0b57dc7..f74717e 100644 --- a/ci/apple/probe/Cargo.lock +++ b/ci/apple/probe/Cargo.lock @@ -37,6 +37,43 @@ dependencies = [ "ghash", ] +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "android-native-keyring-store" +version = "1.0.0" +source = "git+https://github.com/open-source-cooperative/android-native-keyring-store?rev=56f0c537a7c1#56f0c537a7c13eee1715c8a77b0301ab6cd44b32" +dependencies = [ + "base64", + "jni", + "keyring-core", + "log", + "ndk-context", + "regex", + "serde", + "serde_json", + "thiserror 2.0.21", + "tracing", +] + +[[package]] +name = "apple-native-keyring-store" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b350bfd03649e07aa05c0a81b3e15934374e585c98204a57e20b9d49f49bb9a" +dependencies = [ + "keyring-core", + "log", + "security-framework", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -186,6 +223,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "base64ct" version = "1.8.3" @@ -235,6 +278,12 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.12.1" @@ -295,6 +344,22 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpubits" version = "0.1.1" @@ -722,6 +787,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keyring-core" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29fbf4b551298c67925f38cf341a1ada797a75f1837bdf8ae1fc8e2ebc6efb59" +dependencies = [ + "log", +] + [[package]] name = "libc" version = "0.2.191" @@ -784,6 +858,12 @@ dependencies = [ "autocfg", ] +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + [[package]] name = "num-traits" version = "0.2.19" @@ -1024,6 +1104,8 @@ name = "pushups" version = "0.1.0" dependencies = [ "aes-gcm", + "android-native-keyring-store", + "apple-native-keyring-store", "block2", "dispatch2", "futures-channel", @@ -1031,6 +1113,7 @@ dependencies = [ "hkdf", "jni", "js-sys", + "keyring-core", "libc", "linkme", "objc2", @@ -1041,6 +1124,7 @@ dependencies = [ "p256", "parking_lot", "pushups-macros", + "pushups-token", "sha2", "thiserror 2.0.21", "wasm-bindgen", @@ -1051,6 +1135,7 @@ dependencies = [ "windows-core", "windows-future", "windows-link", + "windows-native-keyring-store", "zbus", ] @@ -1058,8 +1143,13 @@ dependencies = [ name = "pushups-apple-probe" version = "0.0.0" dependencies = [ + "apple-native-keyring-store", + "keyring-core", + "p256", "pushups", + "pushups-sender", "serde_json", + "thiserror 2.0.21", ] [[package]] @@ -1073,6 +1163,26 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "pushups-sender" +version = "0.1.0" +dependencies = [ + "aes-gcm", + "getrandom", + "hkdf", + "p256", + "pushups-token", + "sha2", + "thiserror 2.0.21", +] + +[[package]] +name = "pushups-token" +version = "0.1.0" +dependencies = [ + "serde", +] + [[package]] name = "quote" version = "1.0.47" @@ -1103,6 +1213,35 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "rfc6979" version = "0.6.0" @@ -1161,6 +1300,29 @@ dependencies = [ "zeroize", ] +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "serde" version = "1.0.229" @@ -1631,6 +1793,19 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-native-keyring-store" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8" +dependencies = [ + "byteorder", + "keyring-core", + "regex", + "windows-sys 0.61.2", + "zeroize", +] + [[package]] name = "windows-numerics" version = "0.3.1" diff --git a/ci/apple/probe/Cargo.toml b/ci/apple/probe/Cargo.toml index 6ab5a2f..ac93a98 100644 --- a/ci/apple/probe/Cargo.toml +++ b/ci/apple/probe/Cargo.toml @@ -10,5 +10,10 @@ publish = false crate-type = ["staticlib"] [dependencies] -pushups = { path = "../../.." } +pushups = { path = "../../..", features = ["encryption"] } +pushups-sender = { path = "../../../pushups-sender" } serde_json = "1" +thiserror = "2" +p256 = "0.14" +keyring-core = "1" +apple-native-keyring-store = { version = "1.0.2", features = ["protected"] } diff --git a/ci/apple/probe/src/lib.rs b/ci/apple/probe/src/lib.rs index 97206fe..a712224 100644 --- a/ci/apple/probe/src/lib.rs +++ b/ci/apple/probe/src/lib.rs @@ -1,5 +1,10 @@ //! The consumer transform linked into the native completion harness and iOS extension. +use std::collections::HashMap; +use std::ffi::{c_char, CString}; + +use keyring_core::api::CredentialStoreApi; +use p256::elliptic_curve::point::AffineCoordinates; use pushups::{Error, Message, Notification, Rendering, RenderingContext}; #[pushups::rendering_handler] @@ -28,3 +33,118 @@ fn render(_context: RenderingContext, message: Message) -> Result i32 { + match pushups::rotate_keys() { + Ok(()) => 0, + Err(_) => 1, + } +} + +/// Seals `plaintext` as a sender would seal it, with the key the protected keychain holds for +/// `access_group`. +/// +/// # Safety +/// +/// `plaintext` must point to `len` readable bytes, and `access_group` to a NUL-terminated +/// string, both live for the call. The returned pointer, when non-null, must be released +/// with `__pushups_probe_free`, exactly once. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn __pushups_probe_seal( + plaintext: *const u8, + len: usize, + access_group: *const c_char, +) -> *mut c_char { + // SAFETY: the caller passed a live field of `len` bytes and a live, NUL-terminated group. + let plaintext = unsafe { std::slice::from_raw_parts(plaintext, len) }.to_vec(); + let group = unsafe { std::ffi::CStr::from_ptr(access_group) } + .to_str() + .ok() + .map(str::to_owned); + seal(plaintext, group.as_deref()) +} + +/// Releases a sealed field from `__pushups_probe_seal`, exactly once. +/// +/// # Safety +/// +/// `sealed` must be null or a pointer `__pushups_probe_seal` returned, released once. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn __pushups_probe_free(sealed: *mut c_char) { + // SAFETY: the caller passed its own pointer or null, and releases it once. + unsafe { + if !sealed.is_null() { + drop(CString::from_raw(sealed)); + } + } +} + +/// The sealed field for `plaintext`, the null pointer when it cannot be sealed. +fn seal(plaintext: Vec, access_group: Option<&str>) -> *mut c_char { + let Ok(field) = sealed_field(&plaintext, access_group) else { + return std::ptr::null_mut(); + }; + CString::new(field) + .map(CString::into_raw) + .unwrap_or(std::ptr::null_mut()) +} + +/// A failure to seal the probe's plaintext. +#[derive(Debug, thiserror::Error)] +enum SealError { + /// The keyring store failed. + #[error("the keyring store failed")] + Keyring(#[source] keyring_core::Error), + /// The store's key is not the 48-byte key pair. + #[error("the store's key is not 48 bytes")] + KeyLength, + /// The stored key is not a valid P-256 scalar. + #[error("the stored key is not a valid P-256 scalar")] + Scalar(#[source] p256::elliptic_curve::Error), + /// The sealing failed. + #[error("sealing failed")] + Seal(#[source] pushups_sender::SealError), +} + +/// The base64url field a sender would seal for `plaintext`, with the key the store holds for +/// `access_group`. +fn sealed_field(plaintext: &[u8], access_group: Option<&str>) -> Result { + let mut configuration = HashMap::new(); + if let Some(group) = access_group { + configuration.insert("access-group", group); + } + let store = + apple_native_keyring_store::protected::Store::new_with_configuration(&configuration) + .map_err(SealError::Keyring)?; + let entry = store + .build("rs.pushups", "keys", None) + .map_err(SealError::Keyring)?; + let secret = entry.get_secret().map_err(SealError::Keyring)?; + let secret: [u8; 48] = secret.try_into().map_err(|_| SealError::KeyLength)?; + let (seed, auth) = secret.split_at(32); + let seed: [u8; 32] = seed.try_into().unwrap(); + let auth: [u8; 16] = auth.try_into().unwrap(); + let secret_key = p256::SecretKey::from_bytes(&seed.into()).map_err(SealError::Scalar)?; + let public = secret_key.public_key(); + let point = public.as_affine(); + let mut p256dh = [0u8; 65]; + p256dh[0] = 0x04; + p256dh[1..33].copy_from_slice(point.x().as_slice()); + p256dh[33..].copy_from_slice(point.y().as_slice()); + let token = pushups::Token::Apns { + device_token: vec![0; 32], + keys: Some(pushups::EncryptionKeys { p256dh, auth }), + }; + Ok(pushups_sender::encrypt(&token, plaintext) + .map_err(SealError::Seal)? + .to_base64url()) +} diff --git a/ci/apple/run.sh b/ci/apple/run.sh index 2f2c7f5..b21f696 100755 --- a/ci/apple/run.sh +++ b/ci/apple/run.sh @@ -24,6 +24,12 @@ xcrun --sdk macosx swiftc \ "$BUILD/rust/debug/$LIB" \ -framework Foundation -framework UserNotifications -framework AppKit \ -Xlinker -sectcreate -Xlinker __TEXT -Xlinker __info_plist -Xlinker "$CI/harness/Info.plist" +# The protected keychain refuses a process without the entitlement for the access group the harness declares. +HARNESS_ENTITLEMENTS="$BUILD/harness.entitlements" +plutil -create xml1 "$HARNESS_ENTITLEMENTS" +/usr/libexec/PlistBuddy -c 'Add :keychain-access-groups array' "$HARNESS_ENTITLEMENTS" +/usr/libexec/PlistBuddy -c 'Add :keychain-access-groups:0 string rs.pushups.ci.harness' "$HARNESS_ENTITLEMENTS" +codesign --force --sign - --entitlements "$HARNESS_ENTITLEMENTS" "$BUILD/harness" "$BUILD/harness" build_extension() { diff --git a/ci/linux/probe/Cargo.lock b/ci/linux/probe/Cargo.lock index 59c2f4d..0ce0506 100644 --- a/ci/linux/probe/Cargo.lock +++ b/ci/linux/probe/Cargo.lock @@ -2043,7 +2043,7 @@ dependencies = [ "p256 0.14.0", "parking_lot", "pushups-macros", - "serde", + "pushups-token", "sha2 0.11.0", "thiserror 2.0.21", "wasm-bindgen", @@ -2063,6 +2063,8 @@ version = "0.0.0" dependencies = [ "pushups", "pushups-probe-support", + "pushups-sender", + "pushups-token", "serde_json", "tokio", "web-push", @@ -2087,6 +2089,26 @@ dependencies = [ "pushups", ] +[[package]] +name = "pushups-sender" +version = "0.1.0" +dependencies = [ + "aes-gcm", + "getrandom 0.4.3", + "hkdf 0.13.0", + "p256 0.14.0", + "pushups-token", + "sha2 0.11.0", + "thiserror 2.0.21", +] + +[[package]] +name = "pushups-token" +version = "0.1.0" +dependencies = [ + "serde", +] + [[package]] name = "quote" version = "1.0.47" diff --git a/ci/linux/probe/Cargo.toml b/ci/linux/probe/Cargo.toml index a1ae9fe..8417ce4 100644 --- a/ci/linux/probe/Cargo.toml +++ b/ci/linux/probe/Cargo.toml @@ -16,6 +16,11 @@ path = "src/main.rs" name = "send" path = "src/send.rs" +# Seals the Android test's encrypted FCM pushes with the token's keys, the app server's side. +[[bin]] +name = "seal" +path = "src/seal.rs" + # Stands in for a desktop's notification server, which the test bed lacks. [[bin]] name = "notifier" @@ -27,4 +32,6 @@ pushups-probe-support = { path = "../../probe-support" } serde_json = "1" tokio = { version = "1", features = ["macros", "rt", "time"] } web-push = "0.11" +pushups-sender = { path = "../../../pushups-sender" } +pushups-token = { path = "../../../pushups-token" } zbus = "5" diff --git a/ci/linux/probe/src/seal.rs b/ci/linux/probe/src/seal.rs new file mode 100644 index 0000000..39f1b32 --- /dev/null +++ b/ci/linux/probe/src/seal.rs @@ -0,0 +1,42 @@ +//! `seal ` seals `plaintext` with the token's keys, base64url +//! encoded like the FCM data map carries them, and prints the sealed envelope's base64url. +//! It exits with failure when a key does not decode or the plaintext exceeds the bound. + +use std::process::ExitCode; + +use pushups_sender::{SealError, encrypt}; +use pushups_token::{EncryptionKeys, Token, base64url}; + +fn main() -> ExitCode { + let args: Vec<String> = std::env::args().collect(); + let [_, p256dh, auth, plaintext] = args.as_slice() else { + eprintln!("usage: seal <p256dh> <auth> <plaintext>"); + return ExitCode::from(2); + }; + let (p256dh, auth) = ( + base64url::decode(p256dh).and_then(|bytes| <[u8; 65]>::try_from(bytes).ok()), + base64url::decode(auth).and_then(|bytes| <[u8; 16]>::try_from(bytes).ok()), + ); + let (Some(p256dh), Some(auth)) = (p256dh, auth) else { + eprintln!("the keys are not unpadded base64url of 65 and 16 bytes"); + return ExitCode::from(2); + }; + let token = Token::Fcm { + token: String::new(), + keys: Some(EncryptionKeys { p256dh, auth }), + }; + match encrypt(&token, plaintext.as_bytes()) { + Ok(sealed) => { + println!("{}", sealed.to_base64url()); + ExitCode::SUCCESS + } + Err(SealError::TooLarge { .. }) => { + eprintln!("the plaintext exceeds the sealed bound"); + ExitCode::from(2) + } + Err(error) => { + eprintln!("the seal failed: {error}"); + ExitCode::FAILURE + } + } +} diff --git a/ci/probe-support/Cargo.lock b/ci/probe-support/Cargo.lock index 2536ea6..2ee3ca7 100644 --- a/ci/probe-support/Cargo.lock +++ b/ci/probe-support/Cargo.lock @@ -1041,6 +1041,7 @@ dependencies = [ "p256", "parking_lot", "pushups-macros", + "pushups-token", "sha2", "thiserror 2.0.21", "wasm-bindgen", @@ -1072,6 +1073,10 @@ dependencies = [ "pushups", ] +[[package]] +name = "pushups-token" +version = "0.1.0" + [[package]] name = "quote" version = "1.0.47" diff --git a/ci/web/probe/Cargo.lock b/ci/web/probe/Cargo.lock index 70a3f8f..688040b 100644 --- a/ci/web/probe/Cargo.lock +++ b/ci/web/probe/Cargo.lock @@ -1067,6 +1067,7 @@ dependencies = [ "p256", "parking_lot", "pushups-macros", + "pushups-token", "sha2", "thiserror 2.0.21", "wasm-bindgen", @@ -1091,6 +1092,10 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "pushups-token" +version = "0.1.0" + [[package]] name = "pushups-web-probe" version = "0.0.0" diff --git a/ci/windows/w1/Cargo.lock b/ci/windows/w1/Cargo.lock index 9d044d4..907a93c 100644 --- a/ci/windows/w1/Cargo.lock +++ b/ci/windows/w1/Cargo.lock @@ -1041,6 +1041,7 @@ dependencies = [ "p256", "parking_lot", "pushups-macros", + "pushups-token", "sha2", "thiserror 2.0.21", "wasm-bindgen", @@ -1072,6 +1073,10 @@ dependencies = [ "pushups", ] +[[package]] +name = "pushups-token" +version = "0.1.0" + [[package]] name = "pushups-w1-probe" version = "0.0.0" diff --git a/docs/encryption.md b/docs/encryption.md new file mode 100644 index 0000000..6db1eb5 --- /dev/null +++ b/docs/encryption.md @@ -0,0 +1,38 @@ +# End-to-end encrypted pushes + +With the `encryption` feature, pushups creates a P-256 key pair and auth secret on the device, keeps them in the platform key store, and reports their public half in the token's `keys`. The app's server encrypts each push for those keys with RFC 8291 (`aes128gcm`, the Web Push encryption), so Google, Apple and Microsoft relay ciphertext only. pushups decrypts before any callback runs, and `Message.payload` is exactly the plaintext the server encrypted, in whatever serialisation the app chose. + +| Platform | Key store | +|---|---| +| Android | Android Keystore through `android-native-keyring-store` | +| iOS, macOS | the data protection keychain, readable after first unlock, never synced | +| Windows | Credential Manager | +| Linux, web | the UnifiedPush and Web Push keys pushups already uses | + +## On the server + +The app sends its token to the server as JSON, with the `serde` feature on both sides. [`pushups-sender`](https://crates.io/crates/pushups-sender) reads it and encrypts. + +```rust +let token: pushups_sender::Token = serde_json::from_str(&json)?; +let sealed = pushups_sender::encrypt(&token, b"any bytes the app understands")?; +``` + +The ciphertext travels where the transport allows it. + +| Transport | Where `sealed` goes | +|---|---| +| FCM | data message key `pushups`, the value `sealed.to_base64url()` | +| APNs | top-level payload key `pushups`, the value `sealed.to_base64url()`, beside an `aps` alert with `mutable-content: 1` that shows if the device cannot decrypt | +| WNS | the raw push body `sealed.wns_body()` | +| Web Push, UnifiedPush | the request body `sealed.as_bytes()`, with `Content-Encoding: aes128gcm` | + +A server in another language encrypts with any RFC 8291 implementation for the token's `p256dh` and `auth` and places the result the same way, base64url without padding. A push without the `pushups` field reaches the app as it did before. + +## On the device + +Android, Windows and Linux need nothing beyond the feature. On iOS the app and its notification service extension share the keys through a keychain access group. Both targets carry the same `keychain-access-groups` entitlement, and both `Info.plist` files name it under `PushupsKeychainAccessGroup`. + +A push that fails to decrypt never reaches the app's callbacks. On Android, macOS, Windows and in the iOS app it is dropped with a log line. In the iOS extension the render settles `RenderingOutcome::Failed`, and the server's `aps` alert shows. + +A registration that cannot read or create the keys reports `Event::RegistrationFailed`, so the server never receives a token it would have to send plaintext to. `pushups::rotate_keys()` replaces the keys and registers again, and the new keys arrive with the next `Event::Token`. Pushes sealed for the old keys fail from then on. diff --git a/docs/mobile-notifications.md b/docs/mobile-notifications.md index d791569..9ac39ba 100644 --- a/docs/mobile-notifications.md +++ b/docs/mobile-notifications.md @@ -55,7 +55,7 @@ The `.appex` belongs in the application's `PlugIns` directory. Its property list Device builds require a distinct extension bundle identifier and matching provisioning profile. Sign the extension before its containing application with compatible identities and entitlements. The extension does not inherit the application's entitlements or its container. -Shared files require the same App Group entitlement on both targets. Shared keys require an explicitly shared keychain access group and a key accessibility policy that permits locked-device notification processing. A biometric-protected replica key remains outside the extension's rendering path. +Shared files require the same App Group entitlement on both targets. Shared keys require the same keychain access group entitlement on both targets, named to the crate by the `PushupsKeychainAccessGroup` string key of each `Info.plist`, and a key accessibility policy that permits locked-device notification processing. A biometric-protected replica key remains outside the extension's rendering path. [Dioxus app-extension tooling](https://github.com/DioxusLabs/dioxus/pull/5896) supplies `[[ios.app_extensions]]`, `rust_crate`, `frameworks`, module-qualified principal classes and nested signing. Point `source` at the Swift extension sources, `rust_crate` at the consumer's static-library crate, `extension_point` at `com.apple.usernotifications.service`, and `principal_class` at `PushupsNotificationService`. This capability depends on that tooling contribution, rather than released `dx` support being assumed. diff --git a/examples/leptos/Cargo.lock b/examples/leptos/Cargo.lock index 2a0020e..02a2b62 100644 --- a/examples/leptos/Cargo.lock +++ b/examples/leptos/Cargo.lock @@ -2175,7 +2175,7 @@ dependencies = [ "p256", "parking_lot", "pushups-macros", - "serde", + "pushups-token", "sha2 0.11.0", "thiserror 2.0.21", "wasm-bindgen", @@ -2226,6 +2226,13 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "pushups-token" +version = "0.1.0" +dependencies = [ + "serde", +] + [[package]] name = "quote" version = "1.0.47" diff --git a/examples/tao/Cargo.lock b/examples/tao/Cargo.lock index 47339c3..5b8cdac 100644 --- a/examples/tao/Cargo.lock +++ b/examples/tao/Cargo.lock @@ -2040,7 +2040,7 @@ dependencies = [ "p256", "parking_lot", "pushups-macros", - "serde", + "pushups-token", "sha2", "thiserror 2.0.21", "wasm-bindgen", @@ -2087,6 +2087,13 @@ dependencies = [ "tao", ] +[[package]] +name = "pushups-token" +version = "0.1.0" +dependencies = [ + "serde", +] + [[package]] name = "quick-xml" version = "0.41.0" diff --git a/examples/tauri/Cargo.lock b/examples/tauri/Cargo.lock index 4d1377c..62f4e36 100644 --- a/examples/tauri/Cargo.lock +++ b/examples/tauri/Cargo.lock @@ -3011,7 +3011,7 @@ dependencies = [ "p256", "parking_lot", "pushups-macros", - "serde", + "pushups-token", "sha2 0.11.0", "thiserror 2.0.21", "wasm-bindgen", @@ -3059,6 +3059,13 @@ dependencies = [ "tauri-plugin-pushups", ] +[[package]] +name = "pushups-token" +version = "0.1.0" +dependencies = [ + "serde", +] + [[package]] name = "quick-xml" version = "0.42.0" diff --git a/examples/winit/Cargo.lock b/examples/winit/Cargo.lock index f473f71..35325c9 100644 --- a/examples/winit/Cargo.lock +++ b/examples/winit/Cargo.lock @@ -1557,7 +1557,7 @@ dependencies = [ "p256", "parking_lot", "pushups-macros", - "serde", + "pushups-token", "sha2", "thiserror 2.0.21", "wasm-bindgen", @@ -1593,6 +1593,13 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "pushups-token" +version = "0.1.0" +dependencies = [ + "serde", +] + [[package]] name = "pushups-winit-example" version = "0.0.0" diff --git a/examples/yew/Cargo.lock b/examples/yew/Cargo.lock index 6e23c3c..89f1bdc 100644 --- a/examples/yew/Cargo.lock +++ b/examples/yew/Cargo.lock @@ -1435,7 +1435,7 @@ dependencies = [ "p256", "parking_lot", "pushups-macros", - "serde", + "pushups-token", "sha2", "thiserror 2.0.21", "wasm-bindgen", @@ -1471,6 +1471,13 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "pushups-token" +version = "0.1.0" +dependencies = [ + "serde", +] + [[package]] name = "pushups-yew-example" version = "0.0.0" diff --git a/ios/PushupsNotificationService.swift b/ios/PushupsNotificationService.swift index e588d95..d8ee638 100644 --- a/ios/PushupsNotificationService.swift +++ b/ios/PushupsNotificationService.swift @@ -97,6 +97,8 @@ private func pushupsReceive( _ service: UnsafeMutableRawPointer, _ userInfo: UnsafePointer<UInt8>, _ userInfoLength: Int, + _ pushups: UnsafePointer<UInt8>?, + _ pushupsLength: Int, _ budgetNanoseconds: UInt64, _ finish: PushupsFinish, _ token: UnsafeMutableRawPointer @@ -136,12 +138,28 @@ public final class PushupsNotificationService: UNNotificationServiceExtension { return } let completion = PushupsCompletion(content: content, handler: handler) + let sealed = request.content.userInfo["pushups"] as? String json.withUnsafeBytes { buffer in let pointer = buffer.baseAddress!.assumingMemoryBound(to: UInt8.self) let token = Unmanaged.passRetained(completion).toOpaque() let elapsed = DispatchTime.now().uptimeNanoseconds - started let remaining = elapsed < limit ? limit - elapsed : 0 - pushupsReceive(service, pointer, buffer.count, remaining, pushupsFinish, token) + if let sealed { + Data(sealed.utf8).withUnsafeBytes { pushupsBuffer in + pushupsReceive( + service, + pointer, + buffer.count, + pushupsBuffer.bindMemory(to: UInt8.self).baseAddress, + pushupsBuffer.count, + remaining, + pushupsFinish, + token + ) + } + } else { + pushupsReceive(service, pointer, buffer.count, nil, 0, remaining, pushupsFinish, token) + } } } diff --git a/pushups-macros/src/lib.rs b/pushups-macros/src/lib.rs index 77921a4..7070414 100644 --- a/pushups-macros/src/lib.rs +++ b/pushups-macros/src/lib.rs @@ -299,6 +299,8 @@ fn apple_exports(name: &syn::Ident, pushups: &TokenStream2) -> TokenStream2 { service: *const ::core::ffi::c_void, user_info: *const u8, user_info_len: usize, + pushups: *const u8, + pushups_len: usize, budget_nanos: u64, finish: #pushups::__private::ios::Finish, token: *mut ::core::ffi::c_void, @@ -307,8 +309,14 @@ fn apple_exports(name: &syn::Ident, pushups: &TokenStream2) -> TokenStream2 { unsafe { #pushups::__private::ios::receive( service, - user_info, - user_info_len, + &#pushups::__private::ios::Field { + pointer: user_info, + length: user_info_len, + }, + &#pushups::__private::ios::Field { + pointer: pushups, + length: pushups_len, + }, budget_nanos, finish, token, @@ -354,6 +362,8 @@ fn apple_exports(name: &syn::Ident, pushups: &TokenStream2) -> TokenStream2 { *const ::core::ffi::c_void, *const u8, usize, + *const u8, + usize, u64, #pushups::__private::ios::Finish, *mut ::core::ffi::c_void, diff --git a/src/android.rs b/src/android.rs index 8c1e105..0003071 100644 --- a/src/android.rs +++ b/src/android.rs @@ -1,9 +1,10 @@ //! The FCM and UnifiedPush backend, driven by the Kotlin module in `android/`. //! //! `PushupsProvider` loads the app's library at process start and calls `Native.init`, which -//! hands Rust the `JavaVM` and the module's `Pushups` class. Kotlin reports every push and -//! every UI session change through the other `Native` methods, and Rust routes each event as -//! the plan's Android delivery table says. +//! hands Rust the `JavaVM` and the module's `Pushups` class, and builds the key store the +//! `encryption` feature keeps the device's keys in. Kotlin reports every push and every UI +//! session change through the other `Native` methods, and Rust routes each event as the plan's +//! Android delivery table says. use std::collections::BTreeMap; use std::path::PathBuf; @@ -22,6 +23,8 @@ use crate::dispatch::DISPATCHER; use crate::inbox::{self, Inbox}; use crate::queue::Entry; use crate::{Config, Error, Event, Message, Notification, Permission, Token}; +#[cfg(feature = "encryption")] +use crate::{EncryptionKeys, envelope, key_store}; /// What `Native.init` hands over, once per process. struct Runtime { @@ -133,6 +136,27 @@ impl Backend for Android { DISPATCHER.flush(); } } + + /// Replaces the stored keys and registers again, so the new keys arrive with the token. + /// The UnifiedPush distributor owns the keys, so it keeps the trait's refusal. + #[cfg(feature = "encryption")] + fn rotate_keys() -> Result<(), Error> { + let runtime = runtime()?; + let transport = with_attached(&runtime.vm, |env| -> jni::errors::Result<String> { + let value = env + .call_static_method(&runtime.pushups, "transport", "()Ljava/lang/String;", &[])? + .l()?; + Ok(env.get_string(&JString::from(value))?.into()) + }) + .map_err(Error::platform)?; + if transport != "fcm" { + return Err(Error::Unsupported); + } + let store = key_store::installed() + .ok_or_else(|| Error::Platform("the key store was not installed".to_owned()))?; + store.rotate()?; + Self::register() + } } /// Shows `notification` through the module's presentation, without opening an Activity. @@ -190,6 +214,50 @@ fn receive(entry: Entry) { DISPATCHER.flush(); } +/// Builds and installs the key store the `encryption` feature keeps the device's keys in, and +/// leaves none on a failure, so registration fails and the envelopes drop as a store failure. +#[cfg(feature = "encryption")] +fn install_key_store(env: &mut JNIEnv<'_>, context: &JObject<'_>) { + let android_context = android_native_keyring_store::AndroidContext::new(env, context); + let store = match android_context { + Ok(android_context) => android_native_keyring_store::Store::new_with_context( + android_context, + android_native_keyring_store::by_store::store::StoreConfig::default(), + ), + Err(error) => { + eprintln!("the pushups key store could not be built: {error}"); + return; + } + }; + match store { + Ok(store) => key_store::install(store, "rs.pushups"), + Err(error) => eprintln!("the pushups key store could not be built: {error}"), + } +} + +/// The keys a FCM token reports, from the installed key store, creating and storing fresh keys +/// when the entry is missing, as after an reinstall or a cleared store. +/// +/// # Errors +/// +/// [`Error::Platform`] when the key store was not installed or the keys could not be read or +/// created. +#[cfg(feature = "encryption")] +fn fcm_keys() -> Result<EncryptionKeys, Error> { + let store = key_store::installed() + .ok_or_else(|| Error::Platform("the key store was not installed".to_owned()))?; + let keys = store.load_or_create()?; + Ok(envelope::token_keys(&keys)) +} + +/// The pending `RuntimeException` naming why an envelope could not be opened, which the module +/// logs and drops the push with. +#[cfg(feature = "encryption")] +fn open_failed(env: &mut JNIEnv<'_>, reason: &str) -> jni::errors::Result<jni::sys::jbyteArray> { + env.throw_new("java/lang/RuntimeException", reason)?; + Err(jni::errors::Error::JavaException) +} + crate::__pushups_android_exports! { /// `Native.handshake(moduleVersion)`, from `PushupsProvider.onCreate` before any other export. /// Whether the Kotlin module is this crate's version. Its signature never changes, so a module @@ -227,6 +295,8 @@ crate::__pushups_android_exports! { pushups, inbox: Mutex::new(Inbox::headless(files_dir.join(QUEUE_FILE))), }); + #[cfg(feature = "encryption")] + install_key_store(env, &_context); Ok(()) } @@ -249,7 +319,23 @@ crate::__pushups_android_exports! { token: JString<'local>, ) -> () { let token: String = env.get_string(&token)?.into(); - receive(Entry::FcmToken(token)); + #[cfg(feature = "encryption")] + let entry = match fcm_keys() { + Ok(keys) => Entry::FcmToken { + token, + keys: Some(keys), + }, + Err(error) => Entry::RegistrationFailed(match error { + Error::Platform(reason) => reason, + error => error.to_string(), + }), + }; + #[cfg(not(feature = "encryption"))] + let entry = Entry::FcmToken { + token, + keys: None, + }; + receive(entry); Ok(()) } @@ -327,6 +413,17 @@ crate::__pushups_android_exports! { Event::registration_failed(reason) } else { let token: String = env.get_string(&token)?.into(); + #[cfg(feature = "encryption")] + { + match fcm_keys() { + Ok(keys) => Event::Token(Token::Fcm { + token, + keys: Some(keys), + }), + Err(error) => Event::RegistrationFailed(error), + } + } + #[cfg(not(feature = "encryption"))] Event::Token(Token::Fcm { token, keys: None }) }; DISPATCHER.emit(event); @@ -346,6 +443,37 @@ crate::__pushups_android_exports! { } Ok(()) } + + /// `Native.encrypts()`, whether the `encryption` feature was on for this build, which the + /// module asks once when the first push arrives. + fn Java_rs_pushups_Native_encrypts<'local>( + _env: JNIEnv<'local>, + _class: JClass<'local>, + ) -> jboolean { + #[cfg(feature = "encryption")] + let encrypts = JNI_TRUE; + #[cfg(not(feature = "encryption"))] + let encrypts = JNI_FALSE; + Ok(encrypts) + } + + /// `Native.openEnvelope(sealed)`: the plaintext of the sealed `pushups` field, or a + /// `RuntimeException` naming the failure. + #[cfg(feature = "encryption")] + fn Java_rs_pushups_Native_openEnvelope<'local>( + env: JNIEnv<'local>, + _class: JClass<'local>, + sealed: JString<'local>, + ) -> jni::sys::jbyteArray { + let sealed: String = env.get_string(&sealed)?.into(); + let Some(store) = key_store::installed() else { + return open_failed(env, "the device has no stored keys"); + }; + match envelope::open(store, &sealed) { + Ok(plaintext) => Ok(env.byte_array_from_slice(&plaintext)?.as_raw()), + Err(error) => open_failed(env, &error.to_string()), + } + } } /// Maps the status the module's `permissionStatus` returns to the permission the app sees, and diff --git a/src/apple.rs b/src/apple.rs index f4ed99f..e1455c0 100644 --- a/src/apple.rs +++ b/src/apple.rs @@ -2,6 +2,8 @@ //! //! `install` runs on the main thread before the toolkit's loop. It takes over the notification center's delegate at once, since Apple requires it before launch finishes, and the app delegate when `didFinishLaunching` posts, since a toolkit may create it only inside `UIApplicationMain`. +#[cfg(feature = "encryption")] +mod keychain; mod runtime; pub(crate) mod service; @@ -69,6 +71,8 @@ impl Backend for Apple { if STATE.set(Mutex::new(state)).is_err() { return Ok(()); } + #[cfg(feature = "encryption")] + keychain::install(); runtime::take_over_notification_delegate(mtm); runtime::observe_launch(mtm); Ok(()) @@ -161,6 +165,15 @@ impl Backend for Apple { DISPATCHER.flush(); } } + + /// Replaces the stored keys with fresh ones and registers again. + #[cfg(feature = "encryption")] + fn rotate_keys() -> Result<(), Error> { + let store = crate::key_store::installed() + .ok_or_else(|| Error::Platform("the key store is not installed".to_owned()))?; + store.rotate()?; + Self::register() + } } /// The Application Support directory of this app, where the queue and the delivered set live. @@ -218,6 +231,21 @@ fn became_active() { } fn token(token: Vec<u8>) { + #[cfg(feature = "encryption")] + { + let keys = match crate::key_store::installed() { + Some(store) => store.load_or_create(), + None => Err(Error::Platform("the key store is not installed".to_owned())), + }; + match keys { + Ok(keys) => DISPATCHER.emit(Event::Token(Token::Apns { + device_token: token, + keys: Some(crate::envelope::token_keys(&keys)), + })), + Err(error) => DISPATCHER.emit(Event::registration_failed(error.to_string())), + } + } + #[cfg(not(feature = "encryption"))] DISPATCHER.emit(Event::Token(Token::Apns { device_token: token, keys: None, diff --git a/src/apple/keychain.rs b/src/apple/keychain.rs new file mode 100644 index 0000000..5126844 --- /dev/null +++ b/src/apple/keychain.rs @@ -0,0 +1,89 @@ +//! The protected keychain the device's keys live in, under the app's access group. +//! +//! The app and the extension each install it in their own process, whichever runs first, so +//! both read the same entry from the group they share, and the extension finds it after first +//! unlock on a locked device. + +use std::collections::HashMap; +use std::sync::Arc; + +use keyring_core::api::CredentialStoreApi; +use keyring_core::{CredentialStore, Entry, Error as StoreError}; +use objc2::msg_send; +use objc2::rc::Retained; +use objc2::runtime::AnyObject; +use objc2_foundation::{NSBundle, NSString}; + +/// The `Info.plist` key of the keychain access group the app and the extension share. +const ACCESS_GROUP_KEY: &str = "PushupsKeychainAccessGroup"; + +/// The access policy of the keys' entry, which the extension reads after first unlock. +const ACCESS_POLICY: &str = "AfterFirstUnlockThisDeviceOnly"; + +/// The protected store with the crate's access policy on every entry it builds, since the +/// `KeyStore` builds its entries without modifiers. +struct PolicyStore(Arc<CredentialStore>); + +impl CredentialStoreApi for PolicyStore { + fn vendor(&self) -> String { + self.0.vendor() + } + + fn id(&self) -> String { + self.0.id() + } + + fn build( + &self, + service: &str, + user: &str, + modifiers: Option<&HashMap<&str, &str>>, + ) -> Result<Entry, StoreError> { + let mut modifiers = modifiers.cloned().unwrap_or_default(); + modifiers.insert("access-policy", ACCESS_POLICY); + self.0.build(service, user, Some(&modifiers)) + } + + fn search(&self, spec: &HashMap<&str, &str>) -> Result<Vec<Entry>, StoreError> { + self.0.search(spec) + } + + fn as_any(&self) -> &dyn std::any::Any { + self.0.as_any() + } + + fn persistence(&self) -> keyring_core::api::CredentialPersistence { + self.0.persistence() + } + + fn debug_fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.debug_fmt(f) + } +} + +/// The keychain access group the main bundle declares, none when it declares none. +fn access_group() -> Option<String> { + let info = NSBundle::mainBundle().infoDictionary()?; + // SAFETY: `objectForKey:` accepts any object as a key and returns nil for an absent one. + let key = NSString::from_str(ACCESS_GROUP_KEY); + let value: Option<Retained<AnyObject>> = unsafe { msg_send![&*info, objectForKey: &*key] }; + value + .and_then(|value| value.downcast::<NSString>().ok()) + .map(|string| string.to_string()) +} + +/// Installs the process-wide key store on the protected keychain, under the bundle's access +/// group, in whichever process runs first, the app or the extension. +pub(super) fn install() { + let group = access_group(); + let mut config: HashMap<&str, &str> = HashMap::new(); + if let Some(group) = &group { + config.insert("access-group", group.as_str()); + } + let Ok(store) = apple_native_keyring_store::protected::Store::new_with_configuration(&config) + else { + return; + }; + let store: Arc<CredentialStore> = store; + crate::key_store::install(Arc::new(PolicyStore(store)), "rs.pushups"); +} diff --git a/src/apple/runtime.rs b/src/apple/runtime.rs index 54c1184..f658555 100644 --- a/src/apple/runtime.rs +++ b/src/apple/runtime.rs @@ -19,6 +19,7 @@ use objc2_user_notifications::{UNNotificationPresentationOptions, UNUserNotifica #[cfg(target_os = "ios")] use parking_lot::Mutex; +use crate::Error; use crate::started::Arrival; /// Starts the name of every class the crate creates, so the crate recognises an object it already extended. @@ -343,6 +344,44 @@ fn json(object: &AnyObject) -> Option<Vec<u8>> { data.ok().map(|data| data.to_vec()) } +/// The push's `pushups` field, opened with the device's keys. +/// +/// `None` when the feature is off or the field is absent, the opening's answer otherwise. +fn open_field(info: &AnyObject) -> Option<Result<Vec<u8>, Error>> { + #[cfg(feature = "encryption")] + { + // SAFETY: `objectForKey:` accepts any object as a key and returns nil for an absent one. + let key = NSString::from_str(pushups_token::ENCRYPTED_FIELD); + let value: Option<Retained<AnyObject>> = unsafe { msg_send![info, objectForKey: &*key] }; + let string = value + .and_then(|value| value.downcast::<NSString>().ok()) + .map(|string| string.to_string())?; + let open = match crate::key_store::installed() { + Some(store) => crate::envelope::open(store, &string), + None => Err(crate::envelope::OpenError::NoKeys), + }; + Some(open.map_err(|error| Error::Platform(error.to_string()))) + } + #[cfg(not(feature = "encryption"))] + { + let _ = info; + None + } +} + +/// The push's payload from `info`, the envelope's plaintext when it opens, the `userInfo` JSON +/// otherwise, and nothing when the envelope's field could not be opened. +fn push_payload(info: &AnyObject) -> Option<Vec<u8>> { + match open_field(info) { + Some(Ok(payload)) => Some(payload), + Some(Err(error)) => { + eprintln!("pushups: the push envelope could not be opened: {error}"); + None + } + None => json(info), + } +} + unsafe extern "C-unwind" fn did_register( this: &AnyObject, cmd: Sel, @@ -446,7 +485,7 @@ unsafe extern "C-unwind" fn did_receive_remote_notification( } } // SAFETY: the system passes an `NSDictionary` or null, valid for this call. - let payload = unsafe { info.as_ref() }.and_then(|info| json(info)); + let payload = unsafe { info.as_ref() }.and_then(|info| push_payload(info)); let message = payload.and_then(|payload| super::receive(payload, Arrival::Push)); let Some(mtm) = MainThreadMarker::new() else { gate.done(FETCH_NO_DATA); @@ -474,7 +513,7 @@ unsafe extern "C-unwind" fn did_receive_remote_notification( } } // SAFETY: the system passes an `NSDictionary` or null, valid for this call. - let payload = unsafe { info.as_ref() }.and_then(|info| json(info)); + let payload = unsafe { info.as_ref() }.and_then(|info| push_payload(info)); let message = payload.and_then(|payload| super::receive(payload, Arrival::Push)); if let Some(mtm) = MainThreadMarker::new() { super::finish_after_background_work(mtm, message, Box::new(|| {})); @@ -495,9 +534,9 @@ unsafe extern "C-unwind" fn did_receive_response( let notification: Retained<AnyObject> = unsafe { msg_send![response, notification] }; if let Some(info) = remote_info(&notification) { #[cfg(target_os = "ios")] - let payload = tap_payload(&info).or_else(|| json(&info)); + let payload = tap_payload(&info).or_else(|| push_payload(&info)); #[cfg(target_os = "macos")] - let payload = json(&info); + let payload = push_payload(&info); if let Some(payload) = payload { let _ = super::receive(payload, Arrival::Tap); } @@ -525,9 +564,9 @@ fn tap_payload(info: &AnyObject) -> Option<Vec<u8>> { } } -/// The `userInfo` of a remote `UNNotification` as JSON, or `None` for a local one. +/// The push's payload of a remote `UNNotification`, or `None` for a local one. fn remote_payload(notification: &AnyObject) -> Option<Vec<u8>> { - remote_info(notification).and_then(|info| json(&info)) + remote_info(notification).and_then(|info| push_payload(&info)) } /// Returns user info only for a remote notification. diff --git a/src/apple/service.rs b/src/apple/service.rs index 4e3e5a2..81f0bac 100644 --- a/src/apple/service.rs +++ b/src/apple/service.rs @@ -4,7 +4,7 @@ use std::ffi::c_void; use std::time::{Duration, Instant}; -use crate::__private::ios::Finish; +use crate::__private::ios::{Field, Finish}; use crate::rendering::{ CompletionCallback, RenderingOutcome, RenderingService, Settled, mapped_fields, }; @@ -23,6 +23,8 @@ const NANOSECONDS_PER_SECOND: u64 = 1_000_000_000; /// /// The handle is released exactly once, by `deallocated`. pub(crate) fn create(filtering_declared: bool) -> *mut c_void { + #[cfg(feature = "encryption")] + super::keychain::install(); let suppression = if filtering_declared { Suppression::Declared } else { @@ -34,13 +36,15 @@ pub(crate) fn create(filtering_declared: bool) -> *mut c_void { /// Starts the request for the alert the adapter received, superseding a pending one, and /// runs the render within the budget the adapter measured from the alert's entry. /// -/// # Safety +/// The render sees the envelope's plaintext when the adapter carried a `pushups` field and it +/// opens, the `userInfo` JSON otherwise, and a field that does not open settles `Failed` +/// without running the render, so the original alert stands. /// -/// `service` is a live, aligned handle from `create` that cannot be destroyed during this call. `user_info` points to `user_info_len` readable bytes. `finish` may run on any thread and owns the retained `token` until its single call. +/// `service` is a live, aligned handle from `create` that cannot be destroyed during this call. `user_info` and `pushups` point to readable fields of their declared lengths, the `pushups` pointer null when its length is zero. `finish` may run on any thread and owns the retained `token` until its single call. pub(crate) unsafe fn receive( service: *const c_void, - user_info: *const u8, - user_info_len: usize, + user_info: &Field, + pushups: &Field, budget_nanos: u64, finish: Finish, token: *mut c_void, @@ -51,20 +55,65 @@ pub(crate) unsafe fn receive( + Duration::from_nanos( budget_nanos.min(RENDERING_BOUND.as_secs() * NANOSECONDS_PER_SECOND), ); - // SAFETY: the caller passed a live handle and a payload of `user_info_len` bytes. + // SAFETY: the caller passed a live handle and live fields of their declared lengths. unsafe { - let service = &*service.cast::<RenderingService>(); - let user_info = std::slice::from_raw_parts(user_info, user_info_len); - service.begin( - deadline, - finish_callback(finish, SendToken(token)), - handler, - mapped_fields, - Message { - payload: user_info.to_vec(), - started_app: false, - }, - ); + let user_info = std::slice::from_raw_parts(user_info.pointer, user_info.length); + match payload(user_info, pushups.pointer, pushups.length) { + Alert::Data(payload) => { + let service = &*service.cast::<RenderingService>(); + service.begin( + deadline, + finish_callback(finish, SendToken(token)), + handler, + mapped_fields, + Message { + payload, + started_app: false, + }, + ); + } + #[cfg(feature = "encryption")] + Alert::Failed => { + settle(finish, SendToken(token), RenderingOutcome::Failed, None); + } + } + } +} + +/// The alert's payload once its envelope is opened, the `userInfo` JSON when there is no +/// envelope, or the failure when the envelope could not be opened. +enum Alert { + /// The payload the render sees, the envelope's plaintext or the `userInfo` JSON. + Data(Vec<u8>), + /// The envelope's field could not be opened, so the original alert stands. + #[cfg(feature = "encryption")] + Failed, +} + +/// The alert's payload, opening its `pushups` field with the device's keys when it has one. +fn payload(user_info: &[u8], pushups: *const u8, pushups_len: usize) -> Alert { + #[cfg(feature = "encryption")] + { + if pushups.is_null() || pushups_len == 0 { + return Alert::Data(user_info.to_vec()); + } + // SAFETY: the adapter passed a live field of `pushups_len` bytes. + let field = unsafe { std::slice::from_raw_parts(pushups, pushups_len) }; + let Ok(field) = core::str::from_utf8(field) else { + return Alert::Failed; + }; + let Some(store) = crate::key_store::installed() else { + return Alert::Failed; + }; + match crate::envelope::open(store, field) { + Ok(payload) => Alert::Data(payload), + Err(_) => Alert::Failed, + } + } + #[cfg(not(feature = "encryption"))] + { + let _ = (pushups, pushups_len); + Alert::Data(user_info.to_vec()) } } @@ -108,43 +157,46 @@ impl SendToken { /// The completion callback that hands the settled result to the adapter's thunk. fn finish_callback(finish: Finish, token: SendToken) -> CompletionCallback { - Box::new(move |outcome, content, _context| { - let (title, body, tag, data, navigate) = match &content { - Some(Settled::Fields(fields)) => ( - Some(fields.title.as_str()), - fields.body.as_deref(), - fields.tag.as_deref(), - fields.data.as_deref(), - fields.navigate.as_deref(), - ), - _ => (None, None, None, None, None), - }; - // SAFETY: the machine settles the request exactly once, and this is its one call, with - // the strings alive for the call and `finish` the adapter's thunk. - unsafe { - let (title, title_len) = field_bytes(title); - let (body, body_len) = field_bytes(body); - let (tag, tag_len) = field_bytes(tag); - let (data, data_len) = field_bytes(data); - let (navigate, navigate_len) = field_bytes(navigate); - finish( - token.into_ptr(), - outcome_code(outcome), - outcome.marker().as_ptr(), - outcome.marker().len(), - title, - title_len, - body, - body_len, - tag, - tag_len, - data, - data_len, - navigate, - navigate_len, - ); - } - }) + Box::new(move |outcome, content, _context| settle(finish, token, outcome, content.as_ref())) +} + +/// Settles the adapter's thunk with `outcome` and the settled `content`'s fields. +fn settle(finish: Finish, token: SendToken, outcome: RenderingOutcome, content: Option<&Settled>) { + let (title, body, tag, data, navigate) = match content { + Some(Settled::Fields(fields)) => ( + Some(fields.title.as_str()), + fields.body.as_deref(), + fields.tag.as_deref(), + fields.data.as_deref(), + fields.navigate.as_deref(), + ), + _ => (None, None, None, None, None), + }; + // SAFETY: the machine settles the request exactly once, and this is its one call, with + // the strings alive for the call and `finish` the adapter's thunk. + unsafe { + let (title, title_len) = field_bytes(title); + let (body, body_len) = field_bytes(body); + let (tag, tag_len) = field_bytes(tag); + let (data, data_len) = field_bytes(data); + let (navigate, navigate_len) = field_bytes(navigate); + finish( + token.into_ptr(), + outcome_code(outcome), + outcome.marker().as_ptr(), + outcome.marker().len(), + title, + title_len, + body, + body_len, + tag, + tag_len, + data, + data_len, + navigate, + navigate_len, + ); + } } /// The pointer and length of a settled field for the adapter's thunk, or null and zero. diff --git a/src/envelope.rs b/src/envelope.rs index c3b234e..ed9ed9d 100644 --- a/src/envelope.rs +++ b/src/envelope.rs @@ -1,7 +1,9 @@ //! The encrypted push envelope the transports carry, and its opening with the device's keys. +#[cfg(any(target_os = "windows", test))] +use pushups_token::ENCRYPTED_FIELD; +use pushups_token::EncryptionKeys; use pushups_token::base64url; -use pushups_token::{ENCRYPTED_FIELD, EncryptionKeys}; use crate::error::Error; use crate::key_store::KeyStore; @@ -33,18 +35,6 @@ pub(crate) enum OpenError { /// [`OpenError::NotBase64url`] when `sealed` is not unpadded base64url, [`OpenError::NoKeys`] /// when the store holds no keys, [`OpenError::Store`] when the key store fails, and /// [`OpenError::Decrypt`] when the body fails to decrypt. -#[cfg_attr( - all( - not(test), - any( - target_os = "android", - target_os = "ios", - target_os = "macos", - target_os = "windows" - ) - ), - expect(dead_code, reason = "the platform ingress calls this") -)] pub(crate) fn open(store: &KeyStore, sealed: &str) -> Result<Vec<u8>, OpenError> { let body = base64url::decode(sealed).ok_or(OpenError::NotBase64url)?; let keys = store.load().map_err(OpenError::Store)?; @@ -57,18 +47,7 @@ pub(crate) fn open(store: &KeyStore, sealed: &str) -> Result<Vec<u8>, OpenError> /// The value of the `pushups` field when `body` is exactly the WNS envelope, /// `{"pushups":"<base64url>"}` with no whitespace, or none for anything else. #[must_use] -#[cfg_attr( - all( - not(test), - any( - target_os = "android", - target_os = "ios", - target_os = "macos", - target_os = "windows" - ) - ), - expect(dead_code, reason = "the Windows `deliver` calls this") -)] +#[cfg(any(target_os = "windows", test))] pub(crate) fn wns_sealed(body: &[u8]) -> Option<&str> { let prefix = format!("{{\"{ENCRYPTED_FIELD}\":\""); let prefix = prefix.as_bytes(); @@ -91,18 +70,6 @@ pub(crate) fn wns_sealed(body: &[u8]) -> Option<&str> { /// The public keys `keys` decrypts its pushes with, the token's `keys`. #[must_use] -#[cfg_attr( - all( - not(test), - any( - target_os = "android", - target_os = "ios", - target_os = "macos", - target_os = "windows" - ) - ), - expect(dead_code, reason = "the platform `register` calls this") -)] pub(crate) fn token_keys(keys: &Keys) -> EncryptionKeys { EncryptionKeys { p256dh: keys.public_key(), diff --git a/src/inbox.rs b/src/inbox.rs index 4fc336b..94bc3c4 100644 --- a/src/inbox.rs +++ b/src/inbox.rs @@ -123,7 +123,10 @@ mod tests { let sink = Arc::clone(&seen); DISPATCHER.set_handler(Arc::new(move |event| sink.lock().push(event))); inbox.lock().handler_set(); - inbox.lock().receive(Entry::FcmToken("queued".to_owned())); + inbox.lock().receive(Entry::FcmToken { + token: "queued".to_owned(), + keys: None, + }); assert!( seen.lock().is_empty(), "a headless process persists its events" diff --git a/src/key_store.rs b/src/key_store.rs index cac58b0..c2a408e 100644 --- a/src/key_store.rs +++ b/src/key_store.rs @@ -46,21 +46,6 @@ impl KeyStore { /// # Errors /// /// [`Error::Platform`] when the key store fails. - #[cfg_attr( - all( - not(test), - any( - target_os = "android", - target_os = "ios", - target_os = "macos", - target_os = "windows" - ) - ), - expect( - dead_code, - reason = "the platform ingress calls this through `envelope::open`" - ) - )] pub(crate) fn load(&self) -> Result<Option<Keys>, Error> { let entry = self.entry()?; match entry.get_secret() { @@ -70,18 +55,6 @@ impl KeyStore { } } - #[cfg_attr( - all( - not(test), - any( - target_os = "android", - target_os = "ios", - target_os = "macos", - target_os = "windows" - ) - ), - expect(dead_code, reason = "the platform `register` calls this") - )] /// The stored keys, creating and storing fresh ones when there is no entry or it does not /// hold a valid key pair. /// @@ -95,18 +68,6 @@ impl KeyStore { } } - #[cfg_attr( - all( - not(test), - any( - target_os = "android", - target_os = "ios", - target_os = "macos", - target_os = "windows" - ) - ), - expect(dead_code, reason = "the platform `rotate_keys` calls this") - )] /// Fresh keys, replacing whatever the entry held. /// /// # Errors @@ -127,18 +88,6 @@ impl KeyStore { .map_err(|error| store_error(&error)) } - #[cfg_attr( - all( - not(test), - any( - target_os = "android", - target_os = "ios", - target_os = "macos", - target_os = "windows" - ) - ), - expect(dead_code, reason = "used by `load_or_create` and `rotate`") - )] /// Fresh keys, stored in the entry. /// /// # Errors @@ -154,38 +103,11 @@ impl KeyStore { } /// Installs the process-wide key store. The first install wins. -#[cfg_attr( - all( - not(test), - any( - target_os = "android", - target_os = "ios", - target_os = "macos", - target_os = "windows" - ) - ), - expect(dead_code, reason = "the platform `install` calls this") -)] pub(crate) fn install(store: Arc<CredentialStore>, service: impl Into<String>) { let _ = KEY_STORE.set(KeyStore::new(store, service)); } /// The installed key store, or none when no platform installed one. -#[cfg_attr( - all( - not(test), - any( - target_os = "android", - target_os = "ios", - target_os = "macos", - target_os = "windows" - ) - ), - expect( - dead_code, - reason = "the platform `register` and `rotate_keys` call this" - ) -)] pub(crate) fn installed() -> Option<&'static KeyStore> { KEY_STORE.get() } @@ -195,18 +117,6 @@ fn store_error(error: &StoreError) -> Error { Error::Platform(format!("the key store failed: {error}")) } -#[cfg_attr( - all( - not(test), - any( - target_os = "android", - target_os = "ios", - target_os = "macos", - target_os = "windows" - ) - ), - expect(dead_code, reason = "used by `store_new`") -)] /// A failed randomness source while creating fresh keys. fn randomness_error() -> Error { Error::Platform("could not read random bytes for the key pair".to_owned()) diff --git a/src/linux.rs b/src/linux.rs index a475c77..0a27e3a 100644 --- a/src/linux.rs +++ b/src/linux.rs @@ -58,6 +58,15 @@ struct Runtime { vapid: Option<String>, /// The session bus started this process for a push or a tap, so it has no UI. headless: bool, + /// The file the device's keys are stored in, which `rotate_keys` replaces. + #[cfg_attr( + not(feature = "encryption"), + expect( + dead_code, + reason = "rotate_keys is the only reader and it needs the feature" + ) + )] + keys_path: PathBuf, state: Mutex<State>, activity: Mutex<Activity>, notifier: Mutex<notification::Notifier>, @@ -72,6 +81,9 @@ struct State { inbox: Inbox, keys: Keys, token: String, + /// The endpoint the app's server sends to, set by the distributor's `new_endpoint`. + /// `rotate_keys` re-emits the token with it, or the next `new_endpoint` carries the new keys. + endpoint: Option<String>, /// The payloads already delivered, so a tap on a push the app received, or a second tap, /// delivers nothing. delivered: Delivered, @@ -144,6 +156,37 @@ impl Backend for Linux { .map_err(Error::platform) } + /// Replaces the stored keys with fresh ones and reports the token again with them, so the + /// new keys arrive as an [`Event::Token`](crate::Event::Token). + #[cfg(feature = "encryption")] + fn rotate_keys() -> Result<(), Error> { + let runtime = RUNTIME + .get() + .filter(|runtime| runtime.owns_name.load(Ordering::Acquire)) + .ok_or(Error::NotConfigured)?; + let keys = Keys::generate().map_err(|_| { + Error::Platform("could not read random bytes for the key pair".to_owned()) + })?; + // A write failure leaves the old keys in place, in the file and in memory. + write_private(&runtime.keys_path, &keys.to_bytes()) + .map_err(|error| Error::Platform(format!("the keys could not be written: {error}")))?; + let endpoint = { + let mut state = runtime.state.lock(); + state.keys = keys; + state.endpoint.clone() + }; + if let Some(endpoint) = endpoint { + let state = runtime.state.lock(); + state.inbox.receive(Entry::WebPushToken { + endpoint, + p256dh: state.keys.public_key(), + auth: state.keys.auth(), + }); + } + DISPATCHER.flush(); + Ok(()) + } + fn request_permission() -> impl Future<Output = Result<Permission, Error>> { let permission = match Connection::session() { Ok(connection) if find_distributor(&connection).is_ok() => Permission::Granted, @@ -201,7 +244,8 @@ impl Runtime { .mode(0o700) .create(&dir) .map_err(Error::platform)?; - let keys = load_keys(&dir.join("keys")).map_err(Error::platform)?; + let keys_path = dir.join("keys"); + let keys = load_keys(&keys_path).map_err(Error::platform)?; let token = load_token(&dir.join("token")).map_err(Error::platform)?; write_activation_file(app_id).map_err(Error::platform)?; // A desktop entry that cannot be written leaves the portal refusing the app id, so @@ -228,10 +272,12 @@ impl Runtime { notifications, vapid: unified_push.map(|unified_push| unified_push.vapid_public_key.as_base64url()), headless, + keys_path, state: Mutex::new(State { inbox, keys, token, + endpoint: None, delivered: Delivered::new(dir.join("delivered")), started_app: headless, }), @@ -413,8 +459,9 @@ impl Connector { .and_then(|endpoint| String::try_from(endpoint).ok()) .ok_or_else(|| zbus::fdo::Error::InvalidArgs("no endpoint".to_owned()))?; { - let state = call.0.state.lock(); + let mut state = call.0.state.lock(); if known_token(&state, &args) { + state.endpoint = Some(endpoint.clone()); let entry = Entry::WebPushToken { endpoint, p256dh: state.keys.public_key(), diff --git a/src/private.rs b/src/private.rs index a6b84e4..b7848bf 100644 --- a/src/private.rs +++ b/src/private.rs @@ -88,28 +88,38 @@ pub mod ios { crate::apple::service::create(filtering_declared) } + /// One of the byte fields the adapter passed beside the `userInfo` JSON, the field's + /// pointer and length. + pub struct Field { + /// The field's bytes, or null when `length` is zero. + pub pointer: *const u8, + /// The field's length in bytes. + pub length: usize, + } + /// Starts the rendering request for the alert the adapter received, superseding a pending - /// one, under the remaining budget the adapter measured, and runs the app's render. + /// one, under the remaining budget the adapter measured, and runs the app's render with the + /// envelope's plaintext when the adapter carried a `pushups` field and it opens. /// /// # Safety /// - /// `service` is a live, aligned handle from `create` that cannot be destroyed during this call. `user_info` points to `user_info_len` readable bytes. `finish` may run on any thread and owns the retained `token` until its single call. + /// `service` is a live, aligned handle from `create` that cannot be destroyed during this call. `user_info` and `pushups` point to readable fields of their declared lengths, the `pushups` pointer null when its length is zero. `finish` may run on any thread and owns the retained `token` until its single call. pub unsafe fn receive( service: *const c_void, - user_info: *const u8, - user_info_len: usize, + user_info: &Field, + pushups: &Field, budget_nanos: u64, finish: Finish, token: *mut c_void, handler: fn(RenderingContext, Message) -> Result<Rendering, Error>, ) { - // SAFETY: the caller passed a live handle, a live thunk and a payload of - // `user_info_len` bytes. + // SAFETY: the caller passed a live handle, live fields of their declared lengths and + // a live thunk. unsafe { crate::apple::service::receive( service, user_info, - user_info_len, + pushups, budget_nanos, finish, token, diff --git a/src/queue.rs b/src/queue.rs index 49061ec..16a7097 100644 --- a/src/queue.rs +++ b/src/queue.rs @@ -5,11 +5,12 @@ //! //! | Tag | Entry | Fields | //! |---|---|---| -//! | 1 | message | `started_app` (0 or 1), payload length, payload | -//! | 2 | FCM token | length, UTF-8 token | +//! | 2 | FCM token, without keys | length, UTF-8 token | //! | 3 | messages dropped | none | //! | 4 | Web Push token | endpoint length, UTF-8 endpoint, 65-byte `p256dh`, 16-byte `auth` | //! | 5 | registration failed | length, UTF-8 reason | +//! | 6 | unregistered | none | +//! | 7 | FCM token | length, UTF-8 token, 65-byte `p256dh`, 16-byte `auth` | //! //! One process owns the file at a time, holding an exclusive lock on [`QueueFile::lock_path`] //! from its first operation until it exits. A process killed mid-append leaves a short last @@ -22,6 +23,8 @@ use std::path::PathBuf; use std::sync::OnceLock; use std::time::{Duration, Instant}; +use pushups_token::EncryptionKeys; + use crate::{Event, Message, Token}; /// How long a process waits for the queue's previous owner, such as a headless process that is @@ -39,12 +42,17 @@ const MESSAGES_DROPPED: u8 = 3; const WEB_PUSH_TOKEN: u8 = 4; const REGISTRATION_FAILED: u8 = 5; const UNREGISTERED: u8 = 6; +const FCM_TOKEN_KEYS: u8 = 7; /// An event waiting in the queue. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum Entry { Message(Message), - FcmToken(String), + FcmToken { + token: String, + /// The keys the token's pushes are sealed for, when the crate built them. + keys: Option<EncryptionKeys>, + }, MessagesDropped, WebPushToken { endpoint: String, @@ -60,7 +68,7 @@ impl Entry { pub(crate) fn into_event(self) -> Event { match self { Self::Message(message) => Event::Message(message), - Self::FcmToken(token) => Event::Token(Token::Fcm { token, keys: None }), + Self::FcmToken { token, keys } => Event::Token(Token::Fcm { token, keys }), Self::MessagesDropped => Event::MessagesDropped, Self::WebPushToken { endpoint, @@ -85,9 +93,17 @@ impl Entry { out.push(u8::from(message.started_app)); push_bytes(out, &message.payload) } - Self::FcmToken(token) => { - out.push(FCM_TOKEN); - push_bytes(out, token.as_bytes()) + Self::FcmToken { token, keys } => { + if let Some(keys) = keys { + out.push(FCM_TOKEN_KEYS); + push_bytes(out, token.as_bytes())?; + out.extend_from_slice(&keys.p256dh); + out.extend_from_slice(&keys.auth); + Ok(()) + } else { + out.push(FCM_TOKEN); + push_bytes(out, token.as_bytes()) + } } Self::MessagesDropped => { out.push(MESSAGES_DROPPED); @@ -154,6 +170,11 @@ enum Record<'bytes> { payload: &'bytes [u8], }, FcmToken(&'bytes str), + FcmTokenKeys { + token: &'bytes str, + p256dh: &'bytes [u8; 65], + auth: &'bytes [u8; 16], + }, MessagesDropped, WebPushToken { endpoint: &'bytes str, @@ -190,6 +211,19 @@ impl<'bytes> Record<'bytes> { let (token, rest) = take_text(rest)?; Some((Self::FcmToken(token), rest)) } + FCM_TOKEN_KEYS => { + let (token, rest) = take_text(rest)?; + let (p256dh, rest) = rest.split_first_chunk::<65>()?; + let (auth, rest) = rest.split_first_chunk::<16>()?; + Some(( + Self::FcmTokenKeys { + token, + p256dh, + auth, + }, + rest, + )) + } MESSAGES_DROPPED => Some((Self::MessagesDropped, rest)), WEB_PUSH_TOKEN => { let (endpoint, rest) = take_text(rest)?; @@ -222,7 +256,21 @@ impl<'bytes> Record<'bytes> { payload: payload.to_vec(), started_app, }), - Self::FcmToken(token) => Entry::FcmToken(token.to_owned()), + Self::FcmToken(token) => Entry::FcmToken { + token: token.to_owned(), + keys: None, + }, + Self::FcmTokenKeys { + token, + p256dh, + auth, + } => Entry::FcmToken { + token: token.to_owned(), + keys: Some(EncryptionKeys { + p256dh: *p256dh, + auth: *auth, + }), + }, Self::MessagesDropped => Entry::MessagesDropped, Self::WebPushToken { endpoint, @@ -417,6 +465,20 @@ mod tests { }) } + fn fcm_token(token: &str, keys: Option<EncryptionKeys>) -> Entry { + Entry::FcmToken { + token: token.to_owned(), + keys, + } + } + + fn fcm_keys() -> EncryptionKeys { + EncryptionKeys { + p256dh: [3; 65], + auth: [5; 16], + } + } + fn encoded(entries: &[Entry]) -> Vec<u8> { let mut bytes = HEADER.to_vec(); for entry in entries { @@ -428,8 +490,8 @@ mod tests { #[test] fn entries_round_trip_in_order() { let entries = [ - Entry::FcmToken("token-1".to_owned()), - message(br#"{"seq":"1"}"#, true), + fcm_token("token-1", Some(fcm_keys())), + fcm_token("token-2", None), Entry::MessagesDropped, web_push_token("https://ntfy.sh/up123?up=1"), Entry::RegistrationFailed("unregistered by the distributor".to_owned()), @@ -439,13 +501,39 @@ mod tests { assert_eq!(decode(&encoded(&entries)), entries); } + #[test] + fn an_fcm_token_without_keys_keeps_the_0_1_0_record() { + let mut bytes = HEADER.to_vec(); + bytes.push(FCM_TOKEN); + bytes.extend_from_slice(&4u32.to_le_bytes()); + bytes.extend_from_slice(b"abcd"); + assert_eq!( + decode(&bytes), + [Entry::FcmToken { + token: "abcd".to_owned(), + keys: None + }] + ); + assert_eq!(encoded(&[fcm_token("abcd", None)]), bytes); + } + + #[test] + fn an_fcm_token_with_keys_round_trips_through_its_record() { + let entry = fcm_token("token", Some(fcm_keys())); + let mut bytes = HEADER.to_vec(); + entry.encode(&mut bytes).unwrap(); + assert_eq!(bytes[HEADER.len()], FCM_TOKEN_KEYS); + assert_eq!(decode(&bytes), [entry]); + } + #[test] fn a_record_cut_short_by_a_killed_process_is_dropped_and_the_rest_kept() { - let complete = [Entry::FcmToken("t".to_owned()), message(b"abc", false)]; + let complete = [fcm_token("t", None), message(b"abc", false)]; let unfinished = [ message(b"never fully written", true), web_push_token("https://push.example/never-fully-written"), Entry::RegistrationFailed("never fully written".to_owned()), + fcm_token("never fully written", Some(fcm_keys())), ]; for last in unfinished { let mut bytes = encoded(&complete); @@ -477,7 +565,7 @@ mod tests { #[test] fn a_token_that_is_not_utf8_ends_the_readable_part() { let mut bytes = encoded(&[Entry::MessagesDropped]); - Entry::FcmToken("xx".to_owned()).encode(&mut bytes).unwrap(); + fcm_token("xx", None).encode(&mut bytes).unwrap(); let last = bytes.len() - 1; bytes[last] = 0xff; assert_eq!(decode(&bytes), [Entry::MessagesDropped]); @@ -486,7 +574,14 @@ mod tests { #[test] fn entries_become_the_matching_events() { assert_eq!( - Entry::FcmToken("t".to_owned()).into_event(), + fcm_token("t", Some(fcm_keys())).into_event(), + Event::Token(Token::Fcm { + token: "t".to_owned(), + keys: Some(fcm_keys()) + }) + ); + assert_eq!( + fcm_token("t", None).into_event(), Event::Token(Token::Fcm { token: "t".to_owned(), keys: None @@ -536,14 +631,14 @@ mod tests { let directory = tempfile::tempdir().unwrap(); let queue = QueueFile::new(directory.path().join("queue")); assert_eq!(queue.take_all().unwrap(), []); - queue.append(&Entry::FcmToken("a".to_owned())).unwrap(); + queue.append(&fcm_token("a", None)).unwrap(); queue.append(&message(b"b", true)).unwrap(); // The next process to own the file finds the entries. drop(queue); let reopened = QueueFile::new(directory.path().join("queue")); assert_eq!( reopened.take_all().unwrap(), - [Entry::FcmToken("a".to_owned()), message(b"b", true)] + [fcm_token("a", None), message(b"b", true)] ); assert_eq!(reopened.take_all().unwrap(), []); reopened.append(&Entry::MessagesDropped).unwrap(); @@ -571,7 +666,7 @@ mod tests { let directory = tempfile::tempdir().unwrap(); let path = directory.path().join("queue"); let mut unfinished = Vec::new(); - Entry::FcmToken("never fully written".to_owned()) + fcm_token("never fully written", Some(fcm_keys())) .encode(&mut unfinished) .unwrap(); for cut in 1..unfinished.len() { @@ -579,10 +674,10 @@ mod tests { bytes.extend_from_slice(&unfinished[..cut]); std::fs::write(&path, &bytes).unwrap(); let queue = QueueFile::new(path.clone()); - queue.append(&Entry::FcmToken("later".to_owned())).unwrap(); + queue.append(&fcm_token("later", None)).unwrap(); assert_eq!( queue.take_all().unwrap(), - [Entry::MessagesDropped, Entry::FcmToken("later".to_owned())], + [Entry::MessagesDropped, fcm_token("later", None)], "cut at {cut}" ); } @@ -594,7 +689,7 @@ mod tests { let directory = tempfile::tempdir().unwrap(); let path = directory.path().join("queue"); let first = QueueFile::new(path.clone()); - first.append(&Entry::FcmToken("first".to_owned())).unwrap(); + first.append(&fcm_token("first", None)).unwrap(); let second = QueueFile::new(path); assert!( second.claim_within(Duration::from_millis(200)).is_err(), @@ -605,7 +700,7 @@ mod tests { second.append(&Entry::MessagesDropped).unwrap(); assert_eq!( second.take_all().unwrap(), - [Entry::FcmToken("first".to_owned()), Entry::MessagesDropped] + [fcm_token("first", None), Entry::MessagesDropped] ); } } diff --git a/src/webpush_crypto.rs b/src/webpush_crypto.rs index 34fdd6d..e06923f 100644 --- a/src/webpush_crypto.rs +++ b/src/webpush_crypto.rs @@ -48,18 +48,6 @@ impl core::fmt::Debug for Keys { /// A failure to decrypt a Web Push message body. #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub(crate) enum DecryptError { - #[cfg_attr( - all( - not(test), - any( - target_os = "android", - target_os = "ios", - target_os = "macos", - target_os = "windows" - ) - ), - expect(dead_code, reason = "constructed by `Keys::generate`") - )] /// The randomness source failed, so no fresh key pair could be built. #[error("could not read random bytes")] Randomness, @@ -101,18 +89,6 @@ impl Keys { } } - #[cfg_attr( - all( - not(test), - any( - target_os = "android", - target_os = "ios", - target_os = "macos", - target_os = "windows" - ) - ), - expect(dead_code, reason = "the key store's `store_new` calls this") - )] /// Generate a fresh P-256 secret key and authentication secret. /// /// The secret scalar is drawn again while it is not below the group order, rejection @@ -144,18 +120,6 @@ impl Keys { Some(Self::new(secret, auth)) } - #[cfg_attr( - all( - not(test), - any( - target_os = "android", - target_os = "ios", - target_os = "macos", - target_os = "windows" - ) - ), - expect(dead_code, reason = "the key store's `store_new` calls this") - )] /// Serialize the key pair as the 32-byte secret scalar and the 16-byte authentication /// secret. pub(crate) fn to_bytes(&self) -> [u8; KEY_LEN] { diff --git a/src/windows.rs b/src/windows.rs index e615d38..88425a6 100644 --- a/src/windows.rs +++ b/src/windows.rs @@ -74,6 +74,10 @@ use crate::config::AppNotifications; use crate::delivered::Delivered; #[cfg(target_os = "windows")] use crate::dispatch::DISPATCHER; +#[cfg(feature = "encryption")] +use crate::envelope; +#[cfg(feature = "encryption")] +use crate::key_store; #[cfg(target_os = "windows")] use crate::{Config, Error, Event, Message, Notification, Token}; @@ -194,10 +198,15 @@ fn deliver( ) -> Result<(), Error> { let deferral = push.GetDeferral().ok(); let payload = push.Payload().map_err(Error::platform).map(|payload| { - let payload: Vec<u8> = payload.to_vec(); - let first = delivered().first_delivery(&payload); + let body: Vec<u8> = payload.to_vec(); + #[cfg(feature = "encryption")] + let Some(payload) = wns_payload(key_store::installed(), &body) else { + return; + }; + #[cfg(not(feature = "encryption"))] + let payload = body; // A delivered set that cannot be read or written lets the push through, since a repeat beats a loss. - if !first.unwrap_or(true) { + if !delivered().first_delivery(&payload).unwrap_or(true) { return; } let message = Message { @@ -215,6 +224,27 @@ fn deliver( payload } +/// The payload one WNS push body carries for the app's callbacks, the plaintext the body's +/// envelope opened to or the body itself when it carries no envelope, or none when the +/// envelope failed to open, which the caller drops with a log line. +#[cfg(feature = "encryption")] +fn wns_payload(store: Option<&key_store::KeyStore>, body: &[u8]) -> Option<Vec<u8>> { + let Some(sealed) = envelope::wns_sealed(body) else { + return Some(body.to_vec()); + }; + let Some(store) = store else { + eprintln!("pushups dropped a push it cannot decrypt, the key store is not installed"); + return None; + }; + match envelope::open(store, sealed) { + Ok(plaintext) => Some(plaintext), + Err(error) => { + eprintln!("pushups dropped a push it cannot decrypt: {error}"); + None + } + } +} + /// The `PushReceived` handler, from the `WinRT` thread the push arrives on. #[cfg(target_os = "windows")] // The callback shape is fixed by `TypedEventHandler::new`, which hands the `Ref`s over by value. @@ -253,6 +283,16 @@ fn delivered_path(remote_id: &str) -> Result<PathBuf, Error> { .join("delivered")) } +/// The service name the device's keys are stored under, the remote id in its canonical form. +#[cfg(all(target_os = "windows", feature = "encryption"))] +fn key_store_service(remote_id: &windows_core::GUID) -> String { + let [b0, b1, b2, b3, b4, b5, b6, b7] = remote_id.data4; + format!( + "rs.pushups/{:08x}-{:04x}-{:04x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}", + remote_id.data1, remote_id.data2, remote_id.data3, b0, b1, b2, b3, b4, b5, b6, b7 + ) +} + /// Delivers one tap on a notification, from `NotificationInvoked` or from a tap activation. /// /// A tap on a notification without `data` emits no event, the Apple local-notification @@ -277,6 +317,36 @@ fn deliver_tap( Ok(()) } +/// The push or tap the session bus started this process for, delivered once the runtime is up. +#[cfg(target_os = "windows")] +fn activate_from_args() -> Result<(), Error> { + let args = bindings::AppInstance::GetCurrent() + .map_err(Error::platform)? + .GetActivatedEventArgs() + .map_err(Error::platform)?; + match args.Kind().map_err(Error::platform)? { + bindings::ExtendedActivationKind::Push => { + let push = args + .Data() + .map_err(Error::platform)? + .cast::<bindings::PushNotificationReceivedEventArgs>() + .map_err(Error::platform)?; + deliver(&push, true)?; + } + // The tap that started the process carries its notification's `data`. + bindings::ExtendedActivationKind::AppNotification => { + let tap = args + .Data() + .map_err(Error::platform)? + .cast::<bindings::AppNotificationActivatedEventArgs>() + .map_err(Error::platform)?; + deliver_tap(&tap, true)?; + } + _ => {} + } + Ok(()) +} + /// The `NotificationInvoked` handler, from the thread the tap arrives on. #[cfg(target_os = "windows")] // The callback shape is fixed by `TypedEventHandler::new`, which hands the `Ref`s over by value. @@ -406,6 +476,13 @@ impl Backend for Windows { wns.remote_id )) })?; + #[cfg(feature = "encryption")] + { + let store = windows_native_keyring_store::Store::new().map_err(|error| { + Error::Platform(format!("the key store could not be opened: {error}")) + })?; + key_store::install(store, key_store_service(&remote_id)); + } // No COM apartment here, as tao's drag and drop needs this thread single-threaded and `windows-core` joins the implicit multithreaded one itself. let initialize = bootstrapper()?; // SAFETY: The 2.5 channel, null tag, zeroed minimum version and NOOP flag are the documented caller values. @@ -471,30 +548,7 @@ impl Backend for Windows { delivered: Delivered::new(delivered_path), }); drop(guard); - let args = bindings::AppInstance::GetCurrent() - .map_err(Error::platform)? - .GetActivatedEventArgs() - .map_err(Error::platform)?; - match args.Kind().map_err(Error::platform)? { - bindings::ExtendedActivationKind::Push => { - let push = args - .Data() - .map_err(Error::platform)? - .cast::<bindings::PushNotificationReceivedEventArgs>() - .map_err(Error::platform)?; - deliver(&push, true)?; - } - // The tap that started the process carries its notification's `data`. - bindings::ExtendedActivationKind::AppNotification => { - let tap = args - .Data() - .map_err(Error::platform)? - .cast::<bindings::AppNotificationActivatedEventArgs>() - .map_err(Error::platform)?; - deliver_tap(&tap, true)?; - } - _ => {} - } + activate_from_args()?; Ok(()) } @@ -538,11 +592,32 @@ impl Backend for Windows { None => Err(Error::NotConfigured), }) } + + /// Replaces the stored keys with fresh ones and registers again, so the new keys arrive as + /// an [`Event::Token`](crate::Event::Token). + #[cfg(feature = "encryption")] + fn rotate_keys() -> Result<(), Error> { + if RUNTIME.get().is_none() { + return Err(Error::NotConfigured); + } + let Some(store) = key_store::installed() else { + return Err(Error::NotConfigured); + }; + store.rotate()?; + Self::register() + } } /// Creates the fresh channel, waiting for WNS's answer. #[cfg(target_os = "windows")] fn create_channel(remote_id: windows_core::GUID) -> Result<Token, Error> { + #[cfg(feature = "encryption")] + let keys = match key_store::installed() { + Some(store) => Some(envelope::token_keys(&store.load_or_create()?)), + None => return Err(Error::Platform("the key store is not installed".to_owned())), + }; + #[cfg(not(feature = "encryption"))] + let keys = None; let create = bindings::PushNotificationManager::Default() .and_then(|manager| manager.CreateChannelAsync(remote_id)) .and_then(|operation| operation.join()) @@ -569,13 +644,14 @@ fn create_channel(remote_id: windows_core::GUID) -> Result<Token, Error> { Ok(Token::Wns { channel_uri, expires, - keys: None, + keys, }) } #[cfg(test)] mod tests { use super::*; + use keyring_core::api::CredentialStoreApi; #[test] fn an_icon_path_becomes_a_file_uri_with_three_slashes() { @@ -680,4 +756,100 @@ mod tests { assert_eq!(channel_expiry(i64::MAX), None); assert_eq!(channel_expiry(i64::MIN), None); } + + /// The service name `install` installs the key store under, for the tests' store. + const SERVICE: &str = "rs.pushups/6f9619ff-8b86-d011-b42d-00c04fc964ff"; + + /// A key store holding `keys` under the tests' service, or an empty one. + #[cfg(feature = "encryption")] + fn store_with(keys: Option<&crate::webpush_crypto::Keys>) -> crate::key_store::KeyStore { + let store = keyring_core::mock::Store::new().expect("the mock store starts"); + if let Some(keys) = keys { + store + .build(SERVICE, "keys", None) + .expect("the entry builds") + .set_secret(&keys.to_bytes()) + .expect("the secret stores"); + } + crate::key_store::KeyStore::new(store, SERVICE) + } + + /// One RFC 8291 body sealed for `keys` as the WNS envelope, the exact body WNS would carry. + #[cfg(feature = "encryption")] + fn sealed(keys: &crate::webpush_crypto::Keys, plaintext: &[u8]) -> String { + let token = crate::Token::WebPush { + endpoint: "https://push.example/app".to_owned(), + p256dh: keys.public_key(), + auth: keys.auth(), + expires: None, + }; + pushups_sender::encrypt(&token, plaintext) + .expect("the seal encrypts") + .wns_body() + } + + #[test] + #[cfg(feature = "encryption")] + fn an_envelope_body_opens_to_its_plaintext() { + let keys = crate::webpush_crypto::Keys::generate().expect("fresh keys"); + let store = store_with(Some(&keys)); + let body = sealed(&keys, b"the plaintext"); + assert_eq!( + wns_payload(Some(&store), body.as_bytes()), + Some(b"the plaintext".to_vec()) + ); + } + + #[test] + #[cfg(feature = "encryption")] + fn an_envelope_body_the_keys_cannot_open_is_dropped() { + let keys = crate::webpush_crypto::Keys::generate().expect("fresh keys"); + let other = crate::webpush_crypto::Keys::generate().expect("fresh keys"); + let store = store_with(Some(&other)); + let body = sealed(&keys, b"the plaintext"); + assert_eq!(wns_payload(Some(&store), body.as_bytes()), None); + } + + #[test] + #[cfg(feature = "encryption")] + fn an_envelope_body_without_stored_keys_is_dropped() { + let keys = crate::webpush_crypto::Keys::generate().expect("fresh keys"); + let store = store_with(None); + let body = sealed(&keys, b"the plaintext"); + assert_eq!(wns_payload(Some(&store), body.as_bytes()), None); + } + + #[test] + #[cfg(feature = "encryption")] + fn an_envelope_body_without_a_key_store_is_dropped() { + let keys = crate::webpush_crypto::Keys::generate().expect("fresh keys"); + let body = sealed(&keys, b"the plaintext"); + assert_eq!(wns_payload(None, body.as_bytes()), None); + } + + #[test] + #[cfg(feature = "encryption")] + fn a_body_without_the_envelope_delivers_as_is() { + for body in [ + b"plain".to_vec(), + b"{}".to_vec(), + b"{\"pushups\":\"not base64url!!\"}".to_vec(), + b"{\"pushups\": \"x\"}".to_vec(), + ] { + assert_eq!( + wns_payload(None, &body), + Some(body.clone()), + "body {body:?}" + ); + } + } + + /// The key store service names the remote id in its canonical form. + #[test] + #[cfg(all(target_os = "windows", feature = "encryption"))] + fn the_key_store_service_names_the_remote_id() { + let remote_id = windows_core::GUID::try_from("6f9619ff-8b86-d011-b42d-00c04fc964ff") + .expect("the id is a guid"); + assert_eq!(key_store_service(&remote_id), SERVICE); + } } diff --git a/tauri-plugin-pushups/Cargo.lock b/tauri-plugin-pushups/Cargo.lock index b06414a..98167d7 100644 --- a/tauri-plugin-pushups/Cargo.lock +++ b/tauri-plugin-pushups/Cargo.lock @@ -2627,6 +2627,7 @@ dependencies = [ "p256", "parking_lot", "pushups-macros", + "pushups-token", "sha2 0.11.0", "thiserror 2.0.21", "wasm-bindgen", @@ -2651,6 +2652,10 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "pushups-token" +version = "0.1.0" + [[package]] name = "quick-xml" version = "0.42.0" diff --git a/tests/linux.rs b/tests/linux.rs index 51a5730..04a2cbd 100644 --- a/tests/linux.rs +++ b/tests/linux.rs @@ -15,19 +15,22 @@ fn main() {} mod linux { use std::collections::HashMap; use std::io::{BufRead, BufReader}; - use std::path::Path; + use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::mpsc; use std::time::{Duration, Instant}; - use pushups::{Config, Error, Event, LinuxConfig, Permission, UnifiedPushConfig}; + use p256::elliptic_curve::point::AffineCoordinates; + use pushups::{Config, Error, Event, LinuxConfig, Permission, Token, UnifiedPushConfig}; use zbus::zvariant::{OwnedValue, Value}; const CHILD: &str = "PUSHUPS_TEST_CHILD"; /// The file the child writes once `register` returned, holding the milliseconds it took. const MARK: &str = "PUSHUPS_TEST_MARK"; + /// The directory the child writes its scenario marks in. + const MARKS: &str = "PUSHUPS_TEST_MARKS"; const HOLD: &str = "hold"; const REFUSE: &str = "refuse"; const UNREGISTER: &str = "unregister"; @@ -35,7 +38,12 @@ mod linux { const DATA_IS_FILE: &str = "data-is-file"; const NAME_TAKEN: &str = "name-taken"; const REREGISTER: &str = "reregister"; + const ROTATE: &str = "rotate"; + const ROTATE_FIRST: &str = "rotate-first"; + const ROTATE_WRITE_FAILED: &str = "rotate-write-failed"; const APP_ID: &str = "rs.pushups.LinuxTest"; + /// The endpoint the rotation scenarios register with, carried by the token events. + const ENDPOINT: &str = "https://push.example/rotate"; /// How long a scenario may wait on the child, generous for a loaded runner. const BOUND: Duration = Duration::from_secs(20); @@ -52,6 +60,9 @@ mod linux { DATA_IS_FILE, NAME_TAKEN, REREGISTER, + ROTATE, + ROTATE_FIRST, + ROTATE_WRITE_FAILED, ] { run(scenario); println!("linux scenario {scenario}: ok"); @@ -103,6 +114,12 @@ mod linux { owned("REGISTRATION_SUCCEEDED"), )]); } + ROTATE | ROTATE_FIRST | ROTATE_WRITE_FAILED => { + return HashMap::from([( + "success".to_owned(), + owned("REGISTRATION_SUCCEEDED"), + )]); + } _ => {} } HashMap::from([ @@ -175,6 +192,8 @@ mod linux { }); let data = tempfile::tempdir().expect("a temporary data directory"); let mark = data.path().join("register-returned"); + let marks = data.path().join("marks"); + std::fs::create_dir_all(&marks).expect("the marks directory is created"); let data_home = if scenario == DATA_IS_FILE { let file = data.path().join("not-a-directory"); std::fs::write(&file, b"").expect("the blocking file is written"); @@ -186,12 +205,16 @@ mod linux { Command::new(std::env::current_exe().expect("this binary's path")) .env(CHILD, scenario) .env(MARK, &mark) + .env(MARKS, &marks) .env("DBUS_SESSION_BUS_ADDRESS", &address) .env("XDG_DATA_HOME", &data_home) .env_remove("UNIFIEDPUSH_DISTRIBUTOR") .spawn() .expect("the app process starts"), ); + if matches!(scenario, ROTATE | ROTATE_FIRST | ROTATE_WRITE_FAILED) { + drive_rotation(scenario, &address, data.path()); + } if matches!(scenario, HOLD | REFUSE | UNREGISTER) { assert!( wait_until(|| calls.load(Ordering::SeqCst) == 1 && mark.exists()), @@ -238,6 +261,10 @@ mod linux { name_taken(); return; } + if matches!(scenario, ROTATE | ROTATE_FIRST | ROTATE_WRITE_FAILED) { + rotate_child(scenario); + return; + } if scenario == REREGISTER { reregister(); return; @@ -361,4 +388,280 @@ mod linux { "the other connection still owns the name" ); } + + /// The app's state directory under `home`, holding the queue, keys and token. + fn state_dir(home: &Path) -> PathBuf { + home.join(APP_ID).join("pushups") + } + + /// The public key the 48-byte `keys` file's scalar signs with, the 65-byte `p256dh` form. + fn public_key(keys: &[u8]) -> [u8; 65] { + let scalar: [u8; 32] = keys[..32].try_into().expect("the scalar is 32 bytes"); + let scalar = p256::SecretKey::from_bytes(&scalar.into()).expect("the scalar is in range"); + let public = scalar.public_key(); + let point = public.as_affine(); + let mut p256dh = [0u8; 65]; + p256dh[0] = 0x04; + for (slot, coordinate) in p256dh[1..] + .iter_mut() + .zip(point.x().iter().chain(point.y().iter())) + { + *slot = *coordinate; + } + p256dh + } + + /// The 16-byte auth secret of the `keys` file contents. + fn auth_secret(keys: &[u8]) -> [u8; 16] { + keys[32..48] + .try_into() + .expect("the auth secret is 16 bytes") + } + + /// Seals `plaintext` for the `keys` file contents, one RFC 8291 body. + fn seal(keys: &[u8], plaintext: &[u8]) -> Vec<u8> { + let token = pushups::Token::WebPush { + endpoint: ENDPOINT.to_owned(), + p256dh: public_key(keys), + auth: auth_secret(keys), + expires: None, + }; + pushups_sender::encrypt(&token, plaintext) + .expect("the seal encrypts") + .into_bytes() + } + + /// Reports the endpoint to the connector, so the app reports the token with its current keys. + fn new_endpoint_call(connection: &zbus::blocking::Connection, token: &str) { + let args = HashMap::from([ + ("endpoint", Value::from(ENDPOINT)), + ("token", Value::from(token)), + ]); + connection + .call_method( + Some(APP_ID), + "/org/unifiedpush/Connector", + Some("org.unifiedpush.Connector2"), + "NewEndpoint", + &args, + ) + .expect("the endpoint reaches the connector"); + } + + /// Sends one sealed push to the connector, returning the bus's answer or its refusal. + fn push_call( + connection: &zbus::blocking::Connection, + token: &str, + sealed: &[u8], + ) -> Result<(), zbus::Error> { + let args = HashMap::from([ + ("token", Value::from(token)), + ("message", Value::from(sealed)), + ("id", Value::from("rotate-1")), + ]); + connection + .call_method( + Some(APP_ID), + "/org/unifiedpush/Connector", + Some("org.unifiedpush.Connector2"), + "Message", + &args, + ) + .map(|_| ()) + } + + /// Drives a rotation scenario. The parent reports the endpoint, the child rotates the keys, + /// and the pushes sealed with `pushups-sender` are refused for the old keys and arrive for + /// the new ones. + fn drive_rotation(scenario: &str, address: &str, data: &Path) { + let dir = state_dir(data); + let marks = data.join("marks"); + assert!( + wait_until(|| marks.join("ready").exists()), + "{scenario}: the child never became ready" + ); + let token = std::fs::read_to_string(dir.join("token")).expect("the token file is readable"); + let before = std::fs::read(dir.join("keys")).expect("the keys file is readable"); + assert_eq!(before.len(), 48, "{scenario}: the keys file"); + let connection = zbus::blocking::connection::Builder::address(address) + .expect("the bus address parses") + .build() + .expect("a connection for the pushes"); + if scenario == ROTATE_WRITE_FAILED { + // The keys path holds a directory, so the rotation's write fails. + std::fs::remove_file(dir.join("keys")).expect("the keys file is removed"); + std::fs::create_dir(dir.join("keys")).expect("the keys path is a directory"); + std::fs::write(marks.join("rotate"), b"").expect("the rotate mark is written"); + assert!( + wait_until(|| marks.join("rotated-failed").exists()), + "{scenario}: the child never reported the failed rotate" + ); + let sealed = seal(&before, b"old-keys-still-work"); + push_call(&connection, &token, sealed.as_slice()) + .expect("the old-keys push reaches the connector"); + assert!( + wait_until(|| marks.join("delivered").exists()), + "{scenario}: the old-keys push never arrived" + ); + return; + } + if scenario == ROTATE { + // The endpoint arrives before the rotation, so the token the child sees carries the + // old keys. + new_endpoint_call(&connection, &token); + } else { + // The rotation happens before any endpoint, so it reports no token. + std::fs::write(marks.join("rotate"), b"").expect("the rotate mark is written"); + } + assert!( + wait_until(|| marks.join("rotated").exists()), + "{scenario}: the child never rotated" + ); + if scenario == ROTATE_FIRST { + // The rotation preceded the first endpoint, so it reported no token, and the + // endpoint's token carries the new keys. + new_endpoint_call(&connection, &token); + } + let after = std::fs::read(dir.join("keys")).expect("the keys file is readable"); + assert_eq!(after.len(), 48, "{scenario}: the keys file"); + assert_ne!( + &before[..], + &after[..], + "{scenario}: the keys did not change" + ); + let stale = seal(&before, b"stale"); + let error = push_call(&connection, &token, stale.as_slice()) + .expect_err("{scenario}: a push sealed for the old keys still decrypts"); + assert!( + error + .to_string() + .contains("the record failed authentication"), + "{scenario}: the stale push was refused with {error}" + ); + let fresh = seal(&after, b"rotated"); + push_call(&connection, &token, fresh.as_slice()) + .expect("the new-keys push reaches the connector"); + assert!( + wait_until(|| marks.join("delivered").exists()), + "{scenario}: the new-keys push never arrived" + ); + } + + /// The app side of the rotation scenarios, run in the child process on the private bus. + fn rotate_child(scenario: &str) { + pushups::install(config()).expect("install connects to the private bus"); + let (events, received) = mpsc::channel(); + pushups::set_handler(move |event| { + let _ = events.send(event); + }); + pushups::register().expect("register starts"); + let home = std::env::var_os("XDG_DATA_HOME").expect("the parent names the data home"); + let dir = state_dir(Path::new(&home)); + let marks = std::env::var_os(MARKS).expect("the parent names the marks"); + let marks = Path::new(&marks); + std::fs::write(marks.join("ready"), b"").expect("the ready mark is written"); + let first = if scenario == ROTATE { + // The endpoint's token arrives first, so the child rotates after seeing it carry the + // old keys. + let event = received + .recv_timeout(BOUND) + .unwrap_or_else(|_| panic!("{scenario}: no first token within {BOUND:?}")); + match event { + Event::Token(Token::WebPush { + endpoint, + p256dh, + auth, + .. + }) => { + assert_eq!(endpoint, ENDPOINT, "{scenario}: the endpoint"); + let (stored_p256dh, stored_auth) = stored_keys(&dir); + assert_eq!(p256dh, stored_p256dh, "{scenario}: the first token"); + assert_eq!(auth, stored_auth, "{scenario}: the first token"); + pushups::rotate_keys().expect("the keys rotate"); + std::fs::write(marks.join("rotated"), b"").expect("the mark is written"); + Some((p256dh, auth)) + } + other => panic!("{scenario}: no first token, got {other:?}"), + } + } else { + // Before the first endpoint the rotation reports no token. + assert!( + wait_until(|| marks.join("rotate").exists()), + "{scenario}: the parent never triggered the rotation" + ); + let rotated = pushups::rotate_keys(); + if scenario == ROTATE_WRITE_FAILED { + assert!( + matches!(rotated, Err(Error::Platform(_))), + "the failing rotate gave {rotated:?}" + ); + std::fs::write(marks.join("rotated-failed"), b"").expect("the mark is written"); + wait_for_message(scenario, &received, marks, b"old-keys-still-work"); + return; + } + rotated.expect("the keys rotate"); + std::fs::write(marks.join("rotated"), b"").expect("the mark is written"); + None + }; + let event = received + .recv_timeout(BOUND) + .unwrap_or_else(|_| panic!("{scenario}: no token after the rotation within {BOUND:?}")); + match event { + Event::Token(Token::WebPush { + endpoint, + p256dh, + auth, + .. + }) => { + assert_eq!(endpoint, ENDPOINT, "{scenario}: the endpoint"); + let (stored_p256dh, stored_auth) = stored_keys(&dir); + assert_eq!( + p256dh, stored_p256dh, + "{scenario}: the token after the rotation" + ); + assert_eq!( + auth, stored_auth, + "{scenario}: the token after the rotation" + ); + if let Some((old_p256dh, old_auth)) = first { + assert_ne!( + p256dh, old_p256dh, + "{scenario}: the token keeps the old keys" + ); + assert_ne!(auth, old_auth, "{scenario}: the token keeps the old auth"); + } + } + other => panic!("{scenario}: no token after the rotation, got {other:?}"), + } + wait_for_message(scenario, &received, marks, b"rotated"); + } + + /// The child's ground truth, the public key and auth secret of the current `keys` file. + fn stored_keys(dir: &Path) -> ([u8; 65], [u8; 16]) { + let keys = std::fs::read(dir.join("keys")).expect("the keys file is readable"); + assert_eq!(keys.len(), 48, "the keys file"); + (public_key(&keys), auth_secret(&keys)) + } + + /// Waits for the rotation scenario's delivered push, checks its payload, and marks it. + fn wait_for_message( + scenario: &str, + received: &mpsc::Receiver<pushups::Event>, + marks: &Path, + payload: &[u8], + ) { + let event = received + .recv_timeout(BOUND) + .unwrap_or_else(|_| panic!("{scenario}: no push within {BOUND:?}")); + match event { + Event::Message(message) => { + assert_eq!(message.payload, payload, "{scenario}: the payload"); + std::fs::write(marks.join("delivered"), b"").expect("the mark is written"); + // The process exits once this returns, so give the connector's executor the + // reply to the parent's `Message` call before it goes. + std::thread::sleep(Duration::from_millis(500)); + } + other => panic!("{scenario}: no delivered push, got {other:?}"), + } + } } diff --git a/tests/linux_notifications.rs b/tests/linux_notifications.rs index 47f0811..f37aeda 100644 --- a/tests/linux_notifications.rs +++ b/tests/linux_notifications.rs @@ -52,7 +52,6 @@ fn on_background(_context: pushups::Context, message: pushups::Message) { #[cfg(target_os = "linux")] mod linux { - use base64::Engine; use p256::elliptic_curve::point::AffineCoordinates; use std::collections::HashMap; use std::io::{BufRead, BufReader}; @@ -701,14 +700,12 @@ mod linux { { *slot = *coordinate; } - let token: pushups::Token = serde_json::from_value(serde_json::json!({ - "transport": "web_push", - "endpoint": "https://push.example/app", - "p256dh": base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(p256dh), - "auth": base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(auth), - "expires": serde_json::Value::Null, - })) - .expect("the token parses"); + let token = pushups::Token::WebPush { + endpoint: "https://push.example/app".to_owned(), + p256dh, + auth, + expires: None, + }; let sender = connect(address) .build() .expect("a connection for the pushes"); diff --git a/tests/windows.rs b/tests/windows.rs index 8ccb44a..010c6e5 100644 --- a/tests/windows.rs +++ b/tests/windows.rs @@ -48,3 +48,11 @@ fn install_without_the_bootstrapper_names_it_and_the_app_still_runs() { // Nothing was set up, so a registration is refused rather than sent. assert_eq!(pushups::register(), Err(Error::NotConfigured)); } + +/// Without a successful `install`, `rotate_keys` is refused rather than run, since there is no +/// key store to rotate and no channel to register. +#[cfg(feature = "encryption")] +#[test] +fn rotate_keys_before_a_successful_install_is_not_configured() { + assert_eq!(pushups::rotate_keys(), Err(Error::NotConfigured)); +} From e2d09df4386265e1ff6930bc35541842bd05778f Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 <cappelletti.luca94@gmail.com> Date: Mon, 26 Oct 2026 09:16:07 +0100 Subject: [PATCH 4/9] Gate the rotation tests on the encryption feature and fix the harness seal call --- ci/apple/harness/main.swift | 2 +- src/windows.rs | 2 ++ tests/linux.rs | 38 ++++++++++++++++++++++++++++++------- 3 files changed, 34 insertions(+), 8 deletions(-) diff --git a/ci/apple/harness/main.swift b/ci/apple/harness/main.swift index 6be30bb..ee8a476 100644 --- a/ci/apple/harness/main.swift +++ b/ci/apple/harness/main.swift @@ -107,7 +107,7 @@ guard let accessGroup = Bundle.main.object(forInfoDictionaryKey: "PushupsKeychai let sealedPlaintext = Data(#"{"kind":"render","original":"sealed-plaintext"}"#.utf8) let sealedPointer: UnsafeMutablePointer<CChar>? = sealedPlaintext.withUnsafeBytes { bytes in accessGroup.withCString { group in - pushupsProbeSeal(bytes.baseAddress!, bytes.count, group) + pushupsProbeSeal(bytes.bindMemory(to: UInt8.self).baseAddress!, bytes.count, group) } } expect(sealedPointer != nil, "the sender sealed the plaintext") diff --git a/src/windows.rs b/src/windows.rs index 88425a6..9ea77d7 100644 --- a/src/windows.rs +++ b/src/windows.rs @@ -651,6 +651,7 @@ fn create_channel(remote_id: windows_core::GUID) -> Result<Token, Error> { #[cfg(test)] mod tests { use super::*; + #[cfg(feature = "encryption")] use keyring_core::api::CredentialStoreApi; #[test] @@ -758,6 +759,7 @@ mod tests { } /// The service name `install` installs the key store under, for the tests' store. + #[cfg(feature = "encryption")] const SERVICE: &str = "rs.pushups/6f9619ff-8b86-d011-b42d-00c04fc964ff"; /// A key store holding `keys` under the tests' service, or an empty one. diff --git a/tests/linux.rs b/tests/linux.rs index 04a2cbd..c4f2b95 100644 --- a/tests/linux.rs +++ b/tests/linux.rs @@ -15,15 +15,20 @@ fn main() {} mod linux { use std::collections::HashMap; use std::io::{BufRead, BufReader}; - use std::path::{Path, PathBuf}; + use std::path::Path; + #[cfg(feature = "encryption")] + use std::path::PathBuf; use std::process::{Child, Command, Stdio}; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::mpsc; use std::time::{Duration, Instant}; + #[cfg(feature = "encryption")] use p256::elliptic_curve::point::AffineCoordinates; - use pushups::{Config, Error, Event, LinuxConfig, Permission, Token, UnifiedPushConfig}; + #[cfg(feature = "encryption")] + use pushups::Token; + use pushups::{Config, Error, Event, LinuxConfig, Permission, UnifiedPushConfig}; use zbus::zvariant::{OwnedValue, Value}; const CHILD: &str = "PUSHUPS_TEST_CHILD"; @@ -38,11 +43,15 @@ mod linux { const DATA_IS_FILE: &str = "data-is-file"; const NAME_TAKEN: &str = "name-taken"; const REREGISTER: &str = "reregister"; + #[cfg(feature = "encryption")] const ROTATE: &str = "rotate"; + #[cfg(feature = "encryption")] const ROTATE_FIRST: &str = "rotate-first"; + #[cfg(feature = "encryption")] const ROTATE_WRITE_FAILED: &str = "rotate-write-failed"; const APP_ID: &str = "rs.pushups.LinuxTest"; /// The endpoint the rotation scenarios register with, carried by the token events. + #[cfg(feature = "encryption")] const ENDPOINT: &str = "https://push.example/rotate"; /// How long a scenario may wait on the child, generous for a loaded runner. const BOUND: Duration = Duration::from_secs(20); @@ -52,7 +61,7 @@ mod linux { child(&scenario); return; } - for scenario in [ + let scenarios = [ HOLD, REFUSE, UNREGISTER, @@ -60,10 +69,12 @@ mod linux { DATA_IS_FILE, NAME_TAKEN, REREGISTER, - ROTATE, - ROTATE_FIRST, - ROTATE_WRITE_FAILED, - ] { + ]; + #[cfg(feature = "encryption")] + let scenarios = scenarios + .into_iter() + .chain([ROTATE, ROTATE_FIRST, ROTATE_WRITE_FAILED]); + for scenario in scenarios { run(scenario); println!("linux scenario {scenario}: ok"); } @@ -114,6 +125,7 @@ mod linux { owned("REGISTRATION_SUCCEEDED"), )]); } + #[cfg(feature = "encryption")] ROTATE | ROTATE_FIRST | ROTATE_WRITE_FAILED => { return HashMap::from([( "success".to_owned(), @@ -212,6 +224,7 @@ mod linux { .spawn() .expect("the app process starts"), ); + #[cfg(feature = "encryption")] if matches!(scenario, ROTATE | ROTATE_FIRST | ROTATE_WRITE_FAILED) { drive_rotation(scenario, &address, data.path()); } @@ -261,6 +274,7 @@ mod linux { name_taken(); return; } + #[cfg(feature = "encryption")] if matches!(scenario, ROTATE | ROTATE_FIRST | ROTATE_WRITE_FAILED) { rotate_child(scenario); return; @@ -390,11 +404,13 @@ mod linux { } /// The app's state directory under `home`, holding the queue, keys and token. + #[cfg(feature = "encryption")] fn state_dir(home: &Path) -> PathBuf { home.join(APP_ID).join("pushups") } /// The public key the 48-byte `keys` file's scalar signs with, the 65-byte `p256dh` form. + #[cfg(feature = "encryption")] fn public_key(keys: &[u8]) -> [u8; 65] { let scalar: [u8; 32] = keys[..32].try_into().expect("the scalar is 32 bytes"); let scalar = p256::SecretKey::from_bytes(&scalar.into()).expect("the scalar is in range"); @@ -412,6 +428,7 @@ mod linux { } /// The 16-byte auth secret of the `keys` file contents. + #[cfg(feature = "encryption")] fn auth_secret(keys: &[u8]) -> [u8; 16] { keys[32..48] .try_into() @@ -419,6 +436,7 @@ mod linux { } /// Seals `plaintext` for the `keys` file contents, one RFC 8291 body. + #[cfg(feature = "encryption")] fn seal(keys: &[u8], plaintext: &[u8]) -> Vec<u8> { let token = pushups::Token::WebPush { endpoint: ENDPOINT.to_owned(), @@ -432,6 +450,7 @@ mod linux { } /// Reports the endpoint to the connector, so the app reports the token with its current keys. + #[cfg(feature = "encryption")] fn new_endpoint_call(connection: &zbus::blocking::Connection, token: &str) { let args = HashMap::from([ ("endpoint", Value::from(ENDPOINT)), @@ -449,6 +468,7 @@ mod linux { } /// Sends one sealed push to the connector, returning the bus's answer or its refusal. + #[cfg(feature = "encryption")] fn push_call( connection: &zbus::blocking::Connection, token: &str, @@ -473,6 +493,7 @@ mod linux { /// Drives a rotation scenario. The parent reports the endpoint, the child rotates the keys, /// and the pushes sealed with `pushups-sender` are refused for the old keys and arrive for /// the new ones. + #[cfg(feature = "encryption")] fn drive_rotation(scenario: &str, address: &str, data: &Path) { let dir = state_dir(data); let marks = data.join("marks"); @@ -548,6 +569,7 @@ mod linux { } /// The app side of the rotation scenarios, run in the child process on the private bus. + #[cfg(feature = "encryption")] fn rotate_child(scenario: &str) { pushups::install(config()).expect("install connects to the private bus"); let (events, received) = mpsc::channel(); @@ -637,6 +659,7 @@ mod linux { } /// The child's ground truth, the public key and auth secret of the current `keys` file. + #[cfg(feature = "encryption")] fn stored_keys(dir: &Path) -> ([u8; 65], [u8; 16]) { let keys = std::fs::read(dir.join("keys")).expect("the keys file is readable"); assert_eq!(keys.len(), 48, "the keys file"); @@ -644,6 +667,7 @@ mod linux { } /// Waits for the rotation scenario's delivered push, checks its payload, and marks it. + #[cfg(feature = "encryption")] fn wait_for_message( scenario: &str, received: &mpsc::Receiver<pushups::Event>, From 702b158cef8f7b363a1e4c51a56606b917ced79d Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 <cappelletti.luca94@gmail.com> Date: Mon, 26 Oct 2026 10:13:25 +0100 Subject: [PATCH 5/9] Format the Apple probe and the examples --- ci/apple/probe/src/lib.rs | 2 +- examples/tao/src/main.rs | 1 - examples/tauri/src/lib.rs | 1 - examples/winit/src/app.rs | 1 - 4 files changed, 1 insertion(+), 4 deletions(-) diff --git a/ci/apple/probe/src/lib.rs b/ci/apple/probe/src/lib.rs index a712224..11bc2cd 100644 --- a/ci/apple/probe/src/lib.rs +++ b/ci/apple/probe/src/lib.rs @@ -1,7 +1,7 @@ //! The consumer transform linked into the native completion harness and iOS extension. use std::collections::HashMap; -use std::ffi::{c_char, CString}; +use std::ffi::{CString, c_char}; use keyring_core::api::CredentialStoreApi; use p256::elliptic_curve::point::AffineCoordinates; diff --git a/examples/tao/src/main.rs b/examples/tao/src/main.rs index 5f07eda..eeb5e33 100644 --- a/examples/tao/src/main.rs +++ b/examples/tao/src/main.rs @@ -139,7 +139,6 @@ fn render( Ok(support::rendered(&message).into()) } - fn log(line: &str) { support::log("pushups-tao-example", line); } diff --git a/examples/tauri/src/lib.rs b/examples/tauri/src/lib.rs index 1427dcf..a300d2b 100644 --- a/examples/tauri/src/lib.rs +++ b/examples/tauri/src/lib.rs @@ -109,7 +109,6 @@ fn render( Ok(support::rendered(&message).into()) } - fn log(line: &str) { support::log("pushups-tauri-example", line); } diff --git a/examples/winit/src/app.rs b/examples/winit/src/app.rs index eb42b7e..ed6e568 100644 --- a/examples/winit/src/app.rs +++ b/examples/winit/src/app.rs @@ -255,7 +255,6 @@ fn render( Ok(support::rendered(&message).into()) } - /// Runs in the service worker for every push, and decides what the user sees. #[cfg(target_arch = "wasm32")] pub(crate) async fn notification_for(message: Message) -> pushups::Notification { From d618f03403e874a9c3376f843d1e4cc062be5d76 Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 <cappelletti.luca94@gmail.com> Date: Mon, 26 Oct 2026 10:21:11 +0100 Subject: [PATCH 6/9] Sign the macOS harness with a provisioning profile for its keychain cases --- .github/workflows/ci.yml | 30 +++++++++++++++++++++ ci/apple/harness/Info.plist | 2 -- ci/apple/harness/main.swift | 2 ++ ci/apple/run.sh | 52 +++++++++++++++++++++++++++++++------ 4 files changed, 76 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d47d510..4176b8c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -529,6 +529,36 @@ jobs: - run: | rustup toolchain install stable --profile minimal rustup target add --toolchain stable aarch64-apple-ios-sim aarch64-apple-ios + # The harness's keychain cases need the Developer ID identity and the profile for + # rs.pushups.ci.harness, which neither a fork's pull request nor a Dependabot run can read. + - name: Install the harness's signing identity + if: >- + github.event_name == 'push' || + (github.event.pull_request.head.repo.full_name == github.repository && + github.event.pull_request.user.login != 'dependabot[bot]') + env: + P12: ${{ secrets.MACOS_HARNESS_P12 }} + P12_PASSWORD: ${{ secrets.MACOS_HARNESS_P12_PASSWORD }} + PROFILE: ${{ secrets.MACOS_HARNESS_PROFILE }} + run: | + keychain="$RUNNER_TEMP/harness.keychain-db" + password="$(openssl rand -hex 16)" + security create-keychain -p "$password" "$keychain" + security set-keychain-settings -lut 3600 "$keychain" + security unlock-keychain -p "$password" "$keychain" + printf '%s' "$P12" | base64 --decode > "$RUNNER_TEMP/harness.p12" + security import "$RUNNER_TEMP/harness.p12" -k "$keychain" -P "$P12_PASSWORD" -T /usr/bin/codesign + rm "$RUNNER_TEMP/harness.p12" + security set-key-partition-list -S apple-tool:,apple: -s -k "$password" "$keychain" > /dev/null + printf '%s' "$PROFILE" | base64 --decode > "$RUNNER_TEMP/harness.provisionprofile" + identity="$(security find-identity -v -p codesigning "$keychain" | awk '/Developer ID Application/ { print $2; exit }')" + test -n "$identity" + { + echo "PUSHUPS_HARNESS_IDENTITY=$identity" + echo "PUSHUPS_HARNESS_KEYCHAIN=$keychain" + echo "PUSHUPS_HARNESS_PROFILE=$RUNNER_TEMP/harness.provisionprofile" + echo "PUSHUPS_HARNESS_TEAM=7W8527FJJE" + } >> "$GITHUB_ENV" - name: Exercise the native service and link the simulator extension run: bash ci/apple/run.sh diff --git a/ci/apple/harness/Info.plist b/ci/apple/harness/Info.plist index 07fb913..162e95a 100644 --- a/ci/apple/harness/Info.plist +++ b/ci/apple/harness/Info.plist @@ -4,8 +4,6 @@ <dict> <key>CFBundleIdentifier</key> <string>rs.pushups.ci.harness</string> - <key>PushupsKeychainAccessGroup</key> - <string>rs.pushups.ci.harness</string> <key>PushupsNotificationFiltering</key> <true/> </dict> diff --git a/ci/apple/harness/main.swift b/ci/apple/harness/main.swift index ee8a476..b51344d 100644 --- a/ci/apple/harness/main.swift +++ b/ci/apple/harness/main.swift @@ -96,6 +96,7 @@ if let content = rendered.snapshot.content { expect(content.subtitle == "Kept subtitle" && content.sound == UNNotificationSound.default, "original subtitle and sound preserved") } +#if PUSHUPS_KEYCHAIN // The probe installs the crate, rotates the key in the protected keychain and seals a push the way a sender would. pushupsProbeInstall() expect(pushupsProbeRotate() == 0, "key rotation registers") @@ -127,6 +128,7 @@ if let sealedPointer { expect(corruptedPush.snapshot.content?.title == "Safe title" && corruptedPush.snapshot.content?.body == "Safe body", "the corrupted envelope keeps the safe alert") expect(corruptedPush.snapshot.content?.userInfo["original"] as? String == "ciphertext", "the corrupted envelope keeps the sender payload") } +#endif let cleared = receive(service, ["kind": "clear"]) wait(cleared) diff --git a/ci/apple/run.sh b/ci/apple/run.sh index b21f696..99621e2 100755 --- a/ci/apple/run.sh +++ b/ci/apple/run.sh @@ -17,20 +17,56 @@ cargo +stable build --manifest-path "$PROBE" --target-dir "$BUILD/rust" cargo +stable build --manifest-path "$PROBE" --target aarch64-apple-ios-sim --target-dir "$BUILD/rust" cargo +stable build --manifest-path "$PROBE" --target aarch64-apple-ios --target-dir "$BUILD/rust" +# Without a signing identity the harness runs ad-hoc signed and skips the keychain cases, since +# macOS kills a process that claims a keychain access group no provisioning profile grants. With +# PUSHUPS_HARNESS_IDENTITY, PUSHUPS_HARNESS_PROFILE and PUSHUPS_HARNESS_TEAM it runs as an app +# bundle the profile entitles to its access group, keychain cases included. +HARNESS_BUNDLE_ID="${PUSHUPS_HARNESS_BUNDLE_ID:-rs.pushups.ci.harness}" +HARNESS_PLIST="$BUILD/harness-Info.plist" +mkdir -p "$BUILD" +cp "$CI/harness/Info.plist" "$HARNESS_PLIST" +/usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier $HARNESS_BUNDLE_ID" "$HARNESS_PLIST" +SWIFT_DEFINES=() +if [ -n "${PUSHUPS_HARNESS_IDENTITY:-}" ]; then + : "${PUSHUPS_HARNESS_PROFILE:?the provisioning profile for $HARNESS_BUNDLE_ID}" + : "${PUSHUPS_HARNESS_TEAM:?the team identifier of the signing identity}" + HARNESS_GROUP="$PUSHUPS_HARNESS_TEAM.$HARNESS_BUNDLE_ID" + /usr/libexec/PlistBuddy -c "Add :PushupsKeychainAccessGroup string $HARNESS_GROUP" "$HARNESS_PLIST" + /usr/libexec/PlistBuddy -c "Add :CFBundleExecutable string harness" "$HARNESS_PLIST" + SWIFT_DEFINES=(-D PUSHUPS_KEYCHAIN) +fi + xcrun --sdk macosx swiftc \ -o "$BUILD/harness" \ + ${SWIFT_DEFINES[@]+"${SWIFT_DEFINES[@]}"} \ "$CI/harness/main.swift" \ "$ROOT/ios/PushupsNotificationService.swift" \ "$BUILD/rust/debug/$LIB" \ -framework Foundation -framework UserNotifications -framework AppKit \ - -Xlinker -sectcreate -Xlinker __TEXT -Xlinker __info_plist -Xlinker "$CI/harness/Info.plist" -# The protected keychain refuses a process without the entitlement for the access group the harness declares. -HARNESS_ENTITLEMENTS="$BUILD/harness.entitlements" -plutil -create xml1 "$HARNESS_ENTITLEMENTS" -/usr/libexec/PlistBuddy -c 'Add :keychain-access-groups array' "$HARNESS_ENTITLEMENTS" -/usr/libexec/PlistBuddy -c 'Add :keychain-access-groups:0 string rs.pushups.ci.harness' "$HARNESS_ENTITLEMENTS" -codesign --force --sign - --entitlements "$HARNESS_ENTITLEMENTS" "$BUILD/harness" -"$BUILD/harness" + -Xlinker -sectcreate -Xlinker __TEXT -Xlinker __info_plist -Xlinker "$HARNESS_PLIST" +if [ -n "${PUSHUPS_HARNESS_IDENTITY:-}" ]; then + HARNESS_APP="$BUILD/Harness.app" + mkdir -p "$HARNESS_APP/Contents/MacOS" + cp "$BUILD/harness" "$HARNESS_APP/Contents/MacOS/harness" + cp "$HARNESS_PLIST" "$HARNESS_APP/Contents/Info.plist" + cp "$PUSHUPS_HARNESS_PROFILE" "$HARNESS_APP/Contents/embedded.provisionprofile" + HARNESS_ENTITLEMENTS="$BUILD/harness.entitlements" + plutil -create xml1 "$HARNESS_ENTITLEMENTS" + /usr/libexec/PlistBuddy \ + -c "Add :com.apple.application-identifier string $HARNESS_GROUP" \ + -c "Add :com.apple.developer.team-identifier string $PUSHUPS_HARNESS_TEAM" \ + -c "Add :keychain-access-groups array" \ + -c "Add :keychain-access-groups:0 string $HARNESS_GROUP" \ + "$HARNESS_ENTITLEMENTS" + codesign --force --sign "$PUSHUPS_HARNESS_IDENTITY" \ + ${PUSHUPS_HARNESS_KEYCHAIN:+--keychain "$PUSHUPS_HARNESS_KEYCHAIN"} \ + --entitlements "$HARNESS_ENTITLEMENTS" "$HARNESS_APP" + codesign --verify --strict --verbose=2 "$HARNESS_APP" + "$HARNESS_APP/Contents/MacOS/harness" +else + codesign --force --sign - "$BUILD/harness" + "$BUILD/harness" +fi build_extension() { local sdk="$1" swift_target="$2" rust_target="$3" From 38a25a666b0f39cbcf742e7069daf5f4c0be54f5 Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 <cappelletti.luca94@gmail.com> Date: Mon, 26 Oct 2026 14:16:25 +0100 Subject: [PATCH 7/9] Fix the Android encryption and rendering variants, the key store race and the wasm build --- .github/workflows/ci.yml | 2 + Cargo.lock | 2 + Cargo.toml | 3 +- ci/android/run.sh | 5 +- .../rs/pushups/ci/EncryptedPushesTest.kt | 4 +- pushups-sender/Cargo.toml | 5 + src/key_store.rs | 97 +++++++++++++++++++ 7 files changed, 114 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4176b8c..a5a3948 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -550,6 +550,8 @@ jobs: security import "$RUNNER_TEMP/harness.p12" -k "$keychain" -P "$P12_PASSWORD" -T /usr/bin/codesign rm "$RUNNER_TEMP/harness.p12" security set-key-partition-list -S apple-tool:,apple: -s -k "$password" "$keychain" > /dev/null + # codesign finds an identity only in a keychain on the user's search list. + security list-keychains -d user -s "$keychain" $(security list-keychains -d user | tr -d '"') printf '%s' "$PROFILE" | base64 --decode > "$RUNNER_TEMP/harness.provisionprofile" identity="$(security find-identity -v -p codesigning "$keychain" | awk '/Developer ID Application/ { print $2; exit }')" test -n "$identity" diff --git a/Cargo.lock b/Cargo.lock index 50f1349..8fe4699 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -694,9 +694,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "rand_core", + "wasm-bindgen", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index bd98ead..f425abb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,7 +43,8 @@ exclude = ["tauri-plugin-pushups"] # The RFC 8291 crypto, optional here and unconditional on Linux, which keeps its own copies for # the `UnifiedPush` connector. aes-gcm = { version = "0.11", optional = true } -getrandom = { version = "0.4", optional = true } +# Its `wasm_js` backend, so the `encryption` feature builds on a bare wasm target. +getrandom = { version = "0.4", optional = true, features = ["wasm_js"] } hkdf = { version = "0.13", optional = true } p256 = { version = "0.14", optional = true, features = ["ecdh"] } sha2 = { version = "0.11", optional = true } diff --git a/ci/android/run.sh b/ci/android/run.sh index 3e4bbca..5116c9d 100755 --- a/ci/android/run.sh +++ b/ci/android/run.sh @@ -116,9 +116,10 @@ run_variant jni-boundary "config-real" default NativeBoundaryTest run_variant full "config-real background-handler" default PushupsTest run_variant fallback-name config-real fallback-name FallbackNameTest run_variant no-background-handler config-real default NoBackgroundHandlerTest -run_variant rendering "config-real rendering-handler" default RenderingHandlerTest +# The rendering handler presents through the launcher activity, which only the presentation manifest declares. +run_variant rendering "config-real rendering-handler" presentation RenderingHandlerTest # The crate's `encryption` feature: the token reports its keys and encrypted pushes arrive as the plaintext. -run_variant encryption "config-real encryption background-handler rendering-handler" default EncryptedPushesTest +run_variant encryption "config-real encryption background-handler rendering-handler" presentation EncryptedPushesTest run_variant unconfigured "" default RegistrationFailsTest expect "FCM is not configured" run_variant mismatched config-mismatched default RegistrationFailsTest expect "FCM is not configured" run_variant rejected config-fixture default RegistrationFailsTest expect "Please set a valid API key" diff --git a/ci/android/src/androidTest/kotlin/rs/pushups/ci/EncryptedPushesTest.kt b/ci/android/src/androidTest/kotlin/rs/pushups/ci/EncryptedPushesTest.kt index 125a1f5..9e3558c 100644 --- a/ci/android/src/androidTest/kotlin/rs/pushups/ci/EncryptedPushesTest.kt +++ b/ci/android/src/androidTest/kotlin/rs/pushups/ci/EncryptedPushesTest.kt @@ -122,9 +122,11 @@ class EncryptedPushesTest { val id = "headless-${UUID.randomUUID()}" val plaintext = """{"encrypted":"$id"}""" Pushes.sendEncrypted(context, id, message(id), p256dh, auth, plaintext) - assertPlaintextLine(awaitLine(Probe::events, id), plaintext) + // The headless event waits in the queue until the next session drains it. assertPlaintextLine(awaitLine(Probe::rendered, id), plaintext) assertPlaintextLine(awaitLine(Probe::handled, id), plaintext) + openUi() + assertPlaintextLine(awaitLine(Probe::events, id), plaintext) } @Test diff --git a/pushups-sender/Cargo.toml b/pushups-sender/Cargo.toml index 0391e88..c8cad1a 100644 --- a/pushups-sender/Cargo.toml +++ b/pushups-sender/Cargo.toml @@ -19,6 +19,11 @@ pushups-token = { version = "0.1.0", path = "../pushups-token", features = ["ser sha2 = "0.11" thiserror = "2" +# A server running as JavaScript's wasm, such as a Cloudflare Worker, draws its salts and keys +# from `crypto.getRandomValues`. +[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies] +getrandom = { version = "0.4", features = ["wasm_js"] } + [dev-dependencies] # The base64url the README doctest reads the RFC 8291 section 5 keys with. pushups-token = { version = "0.1.0", path = "../pushups-token" } diff --git a/src/key_store.rs b/src/key_store.rs index c2a408e..693a547 100644 --- a/src/key_store.rs +++ b/src/key_store.rs @@ -5,6 +5,7 @@ //! runs, and `pushups` never sets `keyring_core`'s default store, so an app's own keyring use //! is untouched. +use parking_lot::Mutex; use std::sync::{Arc, OnceLock}; use keyring_core::{CredentialStore, Entry, Error as StoreError}; @@ -21,6 +22,9 @@ static KEY_STORE: OnceLock<KeyStore> = OnceLock::new(); pub(crate) struct KeyStore { store: Arc<CredentialStore>, service: String, + /// Serializes the read-then-write of `load_or_create` and `rotate` against a parallel + /// create, so every caller reports the keys the entry ends up holding. + writes: Mutex<()>, } impl core::fmt::Debug for KeyStore { @@ -37,6 +41,7 @@ impl KeyStore { Self { store, service: service.into(), + writes: Mutex::new(()), } } @@ -62,6 +67,7 @@ impl KeyStore { /// /// [`Error::Platform`] when the keys cannot be created or the key store fails. pub(crate) fn load_or_create(&self) -> Result<Keys, Error> { + let _writes = self.writes.lock(); match self.load()? { Some(keys) => Ok(keys), None => self.store_new(), @@ -74,6 +80,7 @@ impl KeyStore { /// /// [`Error::Platform`] when the keys cannot be created or the key store fails. pub(crate) fn rotate(&self) -> Result<Keys, Error> { + let _writes = self.writes.lock(); self.store_new() } @@ -221,4 +228,94 @@ mod tests { let still = installed().expect("the first store installs"); assert_eq!(still.service, "rs.pushups"); } + /// A credential whose `get_secret` sleeps, so a check-then-create running in a parallel + /// thread reads the entry before the first write lands. + struct SlowCred { + specifiers: (String, String), + secret: Arc<Mutex<Option<Vec<u8>>>>, + } + + impl keyring_core::api::CredentialApi for SlowCred { + fn set_secret(&self, secret: &[u8]) -> keyring_core::Result<()> { + *self.secret.lock() = Some(secret.to_vec()); + Ok(()) + } + + fn get_secret(&self) -> keyring_core::Result<Vec<u8>> { + std::thread::sleep(std::time::Duration::from_millis(20)); + self.secret + .lock() + .clone() + .ok_or(keyring_core::Error::NoEntry) + } + + fn delete_credential(&self) -> keyring_core::Result<()> { + *self.secret.lock() = None; + Ok(()) + } + + fn get_credential(&self) -> keyring_core::Result<Option<Arc<keyring_core::Credential>>> { + Ok(None) + } + + fn get_specifiers(&self) -> Option<(String, String)> { + Some(self.specifiers.clone()) + } + + fn as_any(&self) -> &dyn core::any::Any { + self + } + } + + /// A store that shares one entry between its `build` calls, the one the keys live in. + struct SlowStore { + secret: Arc<Mutex<Option<Vec<u8>>>>, + } + + impl keyring_core::api::CredentialStoreApi for SlowStore { + fn vendor(&self) -> String { + String::from("the slow store") + } + + fn id(&self) -> String { + String::from("slow") + } + + fn build( + &self, + service: &str, + user: &str, + _modifiers: Option<&std::collections::HashMap<&str, &str>>, + ) -> keyring_core::Result<Entry> { + Ok(Entry::new_with_credential(Arc::new(SlowCred { + specifiers: (service.to_owned(), user.to_owned()), + secret: self.secret.clone(), + }))) + } + + fn as_any(&self) -> &dyn core::any::Any { + self + } + } + + #[test] + fn concurrent_load_or_create_stores_one_key_set() { + let store: Arc<CredentialStore> = Arc::new(SlowStore { + secret: Arc::new(Mutex::new(None)), + }); + let shared = Arc::new(KeyStore::new(store, "rs.pushups")); + let handles = (0..8) + .map(|_| { + let shared = shared.clone(); + std::thread::spawn(move || shared.load_or_create().expect("fresh keys store")) + }) + .collect::<Vec<_>>(); + let keys = handles + .into_iter() + .map(|handle| handle.join().expect("the thread ends").to_bytes()) + .collect::<Vec<_>>(); + for key in &keys[1..] { + assert_eq!(*key, keys[0]); + } + } } From eaac37a985b36fceb7b9bc7e75c9c02253d9934d Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 <cappelletti.luca94@gmail.com> Date: Mon, 26 Oct 2026 15:12:25 +0100 Subject: [PATCH 8/9] Open a UI session in each encrypted push step and corrupt a data-bearing character --- ci/android/probe/Cargo.lock | 2 ++ ci/android/send.py | 6 ++-- .../rs/pushups/ci/EncryptedPushesTest.kt | 30 +++++++++++++++---- 3 files changed, 29 insertions(+), 9 deletions(-) diff --git a/ci/android/probe/Cargo.lock b/ci/android/probe/Cargo.lock index e4e2128..432aa0f 100644 --- a/ci/android/probe/Cargo.lock +++ b/ci/android/probe/Cargo.lock @@ -646,9 +646,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "rand_core", + "wasm-bindgen", ] [[package]] diff --git a/ci/android/send.py b/ci/android/send.py index 8e22b2b..654def5 100644 --- a/ci/android/send.py +++ b/ci/android/send.py @@ -126,9 +126,9 @@ def send_encrypted_fcm(sealer: Path, key: dict, token: str, request: dict) -> st return f"502 {done.stdout.strip()} {done.stderr.strip()}" sealed = done.stdout.strip() if request.get("corrupt"): - # A single flipped base64url character, which the device's decoder keeps and the decrypt - # refuses. - sealed = sealed[:-1] + ("B" if sealed[-1] == "A" else "A") + # A flipped first base64url character, inside the salt, where every bit is data, so the + # decoder keeps the change and the decrypt refuses. + sealed = ("B" if sealed[0] == "A" else "A") + sealed[1:] message = request["fcm"] message["data"]["pushups"] = sealed return send(key, token, message) diff --git a/ci/android/src/androidTest/kotlin/rs/pushups/ci/EncryptedPushesTest.kt b/ci/android/src/androidTest/kotlin/rs/pushups/ci/EncryptedPushesTest.kt index 9e3558c..06e272a 100644 --- a/ci/android/src/androidTest/kotlin/rs/pushups/ci/EncryptedPushesTest.kt +++ b/ci/android/src/androidTest/kotlin/rs/pushups/ci/EncryptedPushesTest.kt @@ -24,7 +24,8 @@ import java.util.concurrent.TimeUnit * The plan's Android encrypted push lines with real FCM pushes: the token reports the keys its * envelopes are sealed against, an encrypted push reaches every job as the plaintext, in the UI * and headless, a corrupt one reaches none, and an unencrypted push arrives as the data map. - * The steps share the process, so they run in name order. + * The steps share the process, so they run in name order. The runner finishes every Activity + * after each step, which ends the UI session, so a step that needs a session opens its own. */ @RunWith(AndroidJUnit4::class) @FixMethodOrder(MethodSorters.NAME_ASCENDING) @@ -85,6 +86,16 @@ class EncryptedPushesTest { assertFalse(line, line.contains("\"pushups\"")) } + /** The first line of `Probe.events()` containing [id], with every job's log on a failure. */ + private fun awaitEvent(id: String): String = + waitFor(DELIVERY_MS) { lines(Probe.events()).firstOrNull { it.contains(id) } } + ?: error( + "no line with $id within ${DELIVERY_MS / 1000} s, got:\n" + + "events:\n${Probe.events()}\n" + + "rendered:\n${Probe.rendered()}\n" + + "handled:\n${Probe.handled()}" + ) + private fun openUi() { scenario = ActivityScenario.launch(TapActivity::class.java) } @@ -111,7 +122,7 @@ class EncryptedPushesTest { val id = "ui-${UUID.randomUUID()}" val plaintext = """{"encrypted":"$id"}""" Pushes.sendEncrypted(context, id, message(id), p256dh, auth, plaintext) - assertPlaintextLine(awaitLine(Probe::events, id), plaintext) + assertPlaintextLine(awaitEvent(id), plaintext) assertPlaintextLine(awaitLine(Probe::rendered, id), plaintext) assertPlaintextLine(awaitLine(Probe::handled, id), plaintext) } @@ -126,7 +137,7 @@ class EncryptedPushesTest { assertPlaintextLine(awaitLine(Probe::rendered, id), plaintext) assertPlaintextLine(awaitLine(Probe::handled, id), plaintext) openUi() - assertPlaintextLine(awaitLine(Probe::events, id), plaintext) + assertPlaintextLine(awaitEvent(id), plaintext) } @Test @@ -147,15 +158,22 @@ class EncryptedPushesTest { @Test fun a05_anUnencryptedPushArrivesAsTheDataMap() { + openUi() val id = "plain-${UUID.randomUUID()}" + val data = """{"id":"$id"}""" Pushes.send(context, id, message(id)) - val line = awaitLine(Probe::events, id) + val line = awaitEvent(id) assertTrue(line, line.startsWith("message started_app=")) - assertTrue(line, line.contains("""{"id":"$id"}""")) + assertTrue(line, line.contains(data)) + val rendered = awaitLine(Probe::rendered, id) + assertTrue(rendered, rendered.contains(data)) + val handled = awaitLine(Probe::handled, id) + assertTrue(handled, handled.contains(data)) } @Test fun a06_rotatedKeysReportAgainAndOpenTheNewPushes() { + openUi() val old = p256dh assertNull(Probe.rotateKeys()) val fresh = awaitNewKeys() @@ -163,7 +181,7 @@ class EncryptedPushesTest { val id = "rotated-${UUID.randomUUID()}" val plaintext = """{"encrypted":"$id"}""" Pushes.sendEncrypted(context, id, message(id), p256dh, auth, plaintext) - assertPlaintextLine(awaitLine(Probe::events, id), plaintext) + assertPlaintextLine(awaitEvent(id), plaintext) assertPlaintextLine(awaitLine(Probe::rendered, id), plaintext) assertPlaintextLine(awaitLine(Probe::handled, id), plaintext) } From 78ef8cb44d94bb18303ad4bbbaa8b122e26fa70a Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 <cappelletti.luca94@gmail.com> Date: Mon, 26 Oct 2026 15:55:18 +0100 Subject: [PATCH 9/9] Declare the extension's package type as XPC!, which iOS installs --- ci/apple/appex/Info.plist | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ci/apple/appex/Info.plist b/ci/apple/appex/Info.plist index 18dc32b..9467a6c 100644 --- a/ci/apple/appex/Info.plist +++ b/ci/apple/appex/Info.plist @@ -11,7 +11,7 @@ <key>CFBundleName</key> <string>PushupsNotificationService</string> <key>CFBundlePackageType</key> - <string>XPCS</string> + <string>XPC!</string> <key>CFBundleShortVersionString</key> <string>1.0</string> <key>CFBundleVersion</key>