diff --git a/Cargo.lock b/Cargo.lock index 437fb01..db9d194 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3348,7 +3348,6 @@ dependencies = [ "dotenvy", "libsql", "nanoid", - "p256", "postcard", "serde", "thiserror 2.0.3", @@ -3358,6 +3357,7 @@ dependencies = [ "tracing", "tracing-subscriber", "uuid", + "web-shove", ] [[package]] @@ -4042,7 +4042,6 @@ dependencies = [ "libcrux-hkdf", "libcrux-hmac", "p256", - "rand", "rand_chacha", "ring", "thiserror 2.0.3", diff --git a/supernova/Cargo.toml b/supernova/Cargo.toml index 3daf68a..2a41039 100644 --- a/supernova/Cargo.toml +++ b/supernova/Cargo.toml @@ -6,7 +6,9 @@ edition = "2021" [dependencies] askama = "0.12.1" askama_axum = "0.4.0" -axum = { version = "0.7.9", features = [] } +axum = { version = "0.7.9", features = [ + #"macros" +] } axum-extra = { version = "0.9.6", features = ["cookie", "cookie-private"] } base64 = "0.22.1" bitwarden = "1.0.0" @@ -14,7 +16,6 @@ cookie = "0.18.1" dotenvy = "0.15.7" libsql = "0.6.0" nanoid = "0.4.0" -p256 = { version = "0.13.2", features = ["ecdh"] } postcard = { version = "1.1.0", features = ["alloc"], default-features = false } serde = { version = "1.0.215", features = ["derive"] } thiserror = "2.0.3" @@ -24,3 +25,4 @@ tower-http = { version = "0.6.2", features = ["fs"] } tracing = "0.1.40" tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } uuid = "1.11.0" +web-shove = { path = "../web-shove" } diff --git a/supernova/public/notifications.js b/supernova/public/notifications.js new file mode 100644 index 0000000..6d691e9 --- /dev/null +++ b/supernova/public/notifications.js @@ -0,0 +1,22 @@ +/** + * + * @param {string} applicationServerKey - The public key of the VAPID key pair from the server encoded as base64 url safe. + */ +async function subscribe(applicationServerKey) { + console.debug("ASK", applicationServerKey); + const registration = await navigator.serviceWorker.getRegistration(); + const subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey, + }); + + const json = subscription.toJSON(); + + const response = await fetch("/notifications/subscriptions", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(json), + }); +} diff --git a/supernova/public/service-worker.js b/supernova/public/service-worker.js new file mode 100644 index 0000000..1a46790 --- /dev/null +++ b/supernova/public/service-worker.js @@ -0,0 +1,26 @@ +self.addEventListener("install", (event) => event.waitUntil(onInstall(event))); +self.addEventListener("activate", (event) => + event.waitUntil(onActivate(event)), +); +self.addEventListener("push", function (event) { + const promiseChain = self.registration.showNotification(event.data.text()); + event.waitUntil(promiseChain); +}); +self.addEventListener("pushsubscriptionchange", (event) => { + //TODO https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope/pushsubscriptionchange_event + console.log("Push subscription change", event); +}); + +async function onInstall(event) { + console.info("Service worker: Install"); +} + +async function onActivate(event) { + console.info("Service worker: Activate"); +} + +async function onPush(event) { + console.info("Service worker: Push notification"); + const title = event.data.text(); + self.registration.showNotification(title); +} diff --git a/supernova/src/index.rs b/supernova/src/index.rs index 9d3ded1..7afa83d 100644 --- a/supernova/src/index.rs +++ b/supernova/src/index.rs @@ -1,12 +1,25 @@ +use std::rc::Rc; + use askama_axum::Template; +use axum::extract::State; +use base64::{prelude::BASE64_URL_SAFE_NO_PAD, Engine}; -use crate::auth::AuthenticatedUser; +use crate::{auth::AuthenticatedUser, AppState}; #[derive(Template)] #[template(path = "index.html")] -pub(super) struct IndexTemplate; +pub(super) struct IndexTemplate { + /// Base64 url safe encoded public key for the application server from the VAPID keys + application_server_public_key: Rc, +} -pub(super) async fn get(user: Option) -> IndexTemplate { +pub(super) async fn get<'a>( + State(state): State, + user: Option, +) -> IndexTemplate { tracing::debug!("User is authenticated? {}", user.is_some()); - IndexTemplate + let public_key = BASE64_URL_SAFE_NO_PAD.encode(state.vapid.public_key.as_ref()); + IndexTemplate { + application_server_public_key: public_key.into(), + } } diff --git a/supernova/src/main.rs b/supernova/src/main.rs index b2d1009..c646e14 100644 --- a/supernova/src/main.rs +++ b/supernova/src/main.rs @@ -8,19 +8,23 @@ use std::net::Ipv4Addr; use std::path::Path; use auth::cookie::{self, Key}; -use axum::routing::get; +use axum::routing::{get, post}; use axum::Router; +use base64::prelude::BASE64_URL_SAFE_NO_PAD; +use base64::Engine; use dotenvy::dotenv; use secrets::Secrets; use tower_http::services::ServeDir; use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::registry; use tracing_subscriber::util::SubscriberInitExt; +use web_shove::vapid::Vapid; #[derive(Clone)] pub(crate) struct AppState { pub(crate) secrets: Secrets, pub(crate) key: cookie::Key, + pub(crate) vapid: Vapid, } #[tokio::main] @@ -40,23 +44,34 @@ async fn main() { } else { std::env::current_exe().unwrap_or_else(|error| { tracing::warn!( - "Could not get current executable path to find public directory to serve files. Files will likely be served from relative public folder from current working directory. Causing Error: {}", + "Could not get current executable path. Will serve static files from relative \"public\" directory. Causing Error: {}", error ); "public".into() }) }; - // tracing::info!("Directory {:?} {:?}", public_path,); let secrets = secrets::setup().await.unwrap(); + let key_bytes = BASE64_URL_SAFE_NO_PAD + .decode(secrets.vapid_private_key.as_ref()) + .unwrap(); + let key_bytes = &key_bytes.try_into().unwrap(); + //TODO email + let vapid = Vapid::with_private_key("example@example.com", key_bytes); + let state = AppState { secrets, key: Key::new().expect("Error accessing random"), + vapid, }; let app = Router::new() .route("/", get(index::get)) .route("/signin", get(auth::get_sign_in).post(auth::create_sign_in)) + .route( + "/notifications/subscriptions", + post(notification::create_subscription), + ) .fallback_service(ServeDir::new(public_path)) .with_state(state); diff --git a/supernova/src/notification.rs b/supernova/src/notification.rs new file mode 100644 index 0000000..2bcee51 --- /dev/null +++ b/supernova/src/notification.rs @@ -0,0 +1,73 @@ +use std::sync::Arc; + +use axum::extract::Json; +use serde::Deserialize; +use time::OffsetDateTime; + +mod public_key { + use std::sync::Arc; + + use base64::{prelude::BASE64_URL_SAFE_NO_PAD, Engine}; + use serde::Deserialize; + + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let string = String::deserialize(deserializer)?; + let decoded = BASE64_URL_SAFE_NO_PAD + .decode(string) + .map_err(serde::de::Error::custom)?; + + let decoded: [u8; 65] = decoded + .try_into() + .map_err(|decoded: Vec| serde::de::Error::invalid_length(decoded.len(), &"65"))?; + + Ok(Arc::new(decoded)) + } +} + +mod authentication_secret { + use std::sync::Arc; + + use base64::{prelude::BASE64_URL_SAFE_NO_PAD, Engine}; + use serde::Deserialize; + + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let string = String::deserialize(deserializer)?; + let decoded = BASE64_URL_SAFE_NO_PAD + .decode(string) + .map_err(serde::de::Error::custom)?; + + let decoded: [u8; 16] = decoded + .try_into() + .map_err(|decoded: Vec| serde::de::Error::invalid_length(decoded.len(), &"16"))?; + + Ok(Arc::new(decoded)) + } +} + +#[derive(Deserialize)] +struct Keys { + #[serde(rename = "p256dh", deserialize_with = "public_key::deserialize")] + p256PublicKey: Arc<[u8; 65]>, + #[serde( + rename = "auth", + deserialize_with = "authentication_secret::deserialize" + )] + authentication_secret: Arc<[u8; 16]>, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct Subscription { + endpoint: String, + #[serde(with = "time::serde::timestamp::milliseconds::option")] + expiration_time: Option, + keys: Keys, +} + +pub(super) async fn create_subscription(Json(subscription): Json) {} diff --git a/supernova/src/secrets.rs b/supernova/src/secrets.rs index d43853e..1a9b4e0 100644 --- a/supernova/src/secrets.rs +++ b/supernova/src/secrets.rs @@ -1,11 +1,42 @@ -use std::{env, sync::Arc}; +use std::{ + collections::HashMap, + env::{self, var}, + rc::Rc, + sync::Arc, +}; use bitwarden::{ auth::login::AccessTokenLoginRequest, - secrets_manager::{secrets::SecretGetRequest, ClientSecretsExt}, + secrets_manager::{ + secrets::{SecretGetRequest, SecretsGetRequest}, + ClientSecretsExt, + }, Client, }; use thiserror::Error; +use uuid::Uuid; + +#[derive(Error, Debug)] +pub(super) enum ErrorType { + #[error("Error loading secret id from environment variables: {0}")] + VarError(#[from] env::VarError), + #[error("Error parsing user secret id: {0}")] + ParseError(#[from] uuid::Error), +} + +#[derive(Debug)] +enum Secret { + UserSecret, + VapidPrivateKey, +} + +#[derive(Error, Debug)] +#[error("Error loading secret id {variable}: {source}")] +pub(super) struct LoadSecretIdError { + variable: Rc, + #[source] + source: ErrorType, +} #[derive(Error, Debug)] pub(super) enum Error { @@ -15,15 +46,39 @@ pub(super) enum Error { BwsError(#[from] bitwarden::Error), #[error("Error authenticating with Bitwarden")] BwsAuthenticationFailed, - #[error("Error loading user secret id")] - LoadUserSecretIdError(#[source] env::VarError), - #[error("Error parsing user secret id")] - ParseIdError(#[from] uuid::Error), + #[error("Error loading secret id from environment variables: {0}")] + LoadSecretIdError(#[from] LoadSecretIdError), + #[error("Secret not provided by Bitwarden: {0:?}")] + SecretNotProvided(Secret), } #[derive(Clone)] pub(crate) struct Secrets { pub(crate) user_secret: Arc, + pub(crate) vapid_private_key: Arc, +} + +const USER_SECRET_ID_VARIABLE: &str = "USER_SECRET_ID"; +const VAPID_PRIVATE_KEY_ID_VARIABLE: &str = "VAPID_PRIVATE_KEY_ID"; +const SECRET_ID_VARIABLES: &[&str] = &[USER_SECRET_ID_VARIABLE, VAPID_PRIVATE_KEY_ID_VARIABLE]; + +fn load_secret_ids() -> Result, LoadSecretIdError> { + let mut secret_ids = HashMap::with_capacity(SECRET_ID_VARIABLES.len()); + for variable in SECRET_ID_VARIABLES { + let value = env::var(variable).map_err(|error| LoadSecretIdError { + variable: (*variable).into(), + source: ErrorType::VarError(error), + })?; + + let id = value.parse().map_err(|error| LoadSecretIdError { + variable: (*variable).into(), + source: ErrorType::ParseError(error), + })?; + + secret_ids.insert(id, *variable); + } + + Ok(secret_ids) } pub(super) async fn setup() -> Result { @@ -40,14 +95,47 @@ pub(super) async fn setup() -> Result { return Err(Error::BwsAuthenticationFailed); } - let request = SecretGetRequest { - id: env::var("USER_SECRET_ID") - .map_err(Error::LoadUserSecretIdError)? - .parse()?, + let ids_by_variable = load_secret_ids()?; + let request = SecretsGetRequest { + ids: ids_by_variable.keys().copied().collect(), }; - let response = client.secrets().get(&request).await?; + + let responses = client.secrets().get_by_ids(request).await?; + + let mut user_secret = None; + let mut vapid_private_key = None; + for secret in responses.data { + let Some(variable) = ids_by_variable.get(&secret.id) else { + tracing::warn!( + "Received secret with id {} that was not requested", + secret.id + ); + continue; + }; + + match *variable { + USER_SECRET_ID_VARIABLE => user_secret = Some(secret.value), + VAPID_PRIVATE_KEY_ID_VARIABLE => vapid_private_key = Some(secret.value), + //TODO make ids an enum to check compile time because this branch should not be reachable + _ => { + tracing::warn!( + "Received unknown secret with id {} and variable {}", + secret.id, + variable + ); + } + } + } + + let user_secret = user_secret + .ok_or_else(|| Error::SecretNotProvided(Secret::UserSecret))? + .into(); + let vapid_private_key = vapid_private_key + .ok_or_else(|| Error::SecretNotProvided(Secret::VapidPrivateKey))? + .into(); Ok(Secrets { - user_secret: response.value.into(), + user_secret, + vapid_private_key, }) } diff --git a/supernova/templates/index.html b/supernova/templates/index.html index 418fc54..74a3133 100644 --- a/supernova/templates/index.html +++ b/supernova/templates/index.html @@ -1,7 +1,14 @@ {% extends "layout.html" %} + {% block content %}

Supernova

A time tracking tool for yourself

Sign in + {% endblock %} -{% call super() %} \ No newline at end of file + +{% block scripts %} + +{% endblock %} + +{% call super() %} diff --git a/supernova/templates/layout.html b/supernova/templates/layout.html index 39a9fe5..db6621e 100644 --- a/supernova/templates/layout.html +++ b/supernova/templates/layout.html @@ -9,6 +9,10 @@ {% block content %} {% endblock %} + + {% block scripts %} + {% endblock %} + ([u8; LENGTH]); - impl KeyId { - pub fn new(key_id: [u8; LENGTH]) -> Self { - const { - assert!( - LENGTH <= u8::MAX as usize, - "Key id length is greater than 255" - ) - }; - Self(key_id) - } - - #[inline] - pub const fn length() -> usize { - LENGTH - } +/// Restrict key id to length of <=255 as defined by the specification but do it at compile time +struct KeyId([u8; LENGTH]); +impl KeyId { + pub fn new(key_id: [u8; LENGTH]) -> Self { + const { + assert!( + LENGTH <= u8::MAX as usize, + "Key id length is greater than 255" + ) + }; + Self(key_id) } - fn impossible() { - let key = KeyId::<259>::new([0; 259]); + + #[inline] + pub const fn length() -> usize { + LENGTH } +} +fn impossible() { + let key = KeyId::<259>::new([0; 259]); +} diff --git a/web-shove/src/lib.rs b/web-shove/src/lib.rs index 1b954f3..36d57aa 100644 --- a/web-shove/src/lib.rs +++ b/web-shove/src/lib.rs @@ -4,6 +4,7 @@ use aes_gcm::{ }; mod experiments; +pub mod vapid; const KEY_INFO: &[u8; 13] = b"WebPush: info"; const CONTENT_ENCODING_KEY_INFO: &[u8; 29] = b"Content-Encoding: aes128gcm\x00\x01"; @@ -98,7 +99,7 @@ fn encrypt_plain_text(key: &[u8; 16], plaintext: &[u8; 42], nonce: &[u8; 12]) -> #[cfg(test)] mod test { - /// Using the base64 url encoded values as the RFC uses them and they are subjectively easier to compare + /// Using the base64 url encoded values as the RFC uses them, and they are subjectively easier to compare mod rfc8291 { use super::super::*; use base64::prelude::*; diff --git a/web-shove/src/main.rs b/web-shove/src/main.rs new file mode 100644 index 0000000..c3be23d --- /dev/null +++ b/web-shove/src/main.rs @@ -0,0 +1,9 @@ +use base64::{prelude::BASE64_URL_SAFE_NO_PAD, Engine}; +use vapid::Vapid; + +mod vapid; +fn main() { + let vapid = Vapid::generate("example@example.com"); + let private_key = BASE64_URL_SAFE_NO_PAD.encode(vapid.private_key); + println!("{}", private_key); +} diff --git a/web-shove/src/vapid.rs b/web-shove/src/vapid.rs new file mode 100644 index 0000000..c010b4c --- /dev/null +++ b/web-shove/src/vapid.rs @@ -0,0 +1,62 @@ +use std::{rc::Rc, sync::Arc}; + +///! Voluntary Application Server Identification for Web Push (VAPID) based on RFC8292. +use aes_gcm::aead::OsRng; +use p256::ecdsa::SigningKey; + +#[derive(Clone)] +pub struct Vapid { + pub subject: Arc, + pub private_key: Arc<[u8; 32]>, + pub public_key: Arc<[u8; 65]>, +} + +impl Vapid { + pub fn generate(email: &str) -> Self { + let private_key = SigningKey::random(&mut OsRng); + let public_key = private_key.verifying_key(); + + let private_bytes = private_key.to_bytes(); + assert_eq!(32, private_bytes.len()); + let private_bytes = private_bytes.try_into().unwrap(); + let point = public_key.to_encoded_point(false); + let public_bytes: &[u8] = point.as_bytes(); + assert_eq!(65, public_bytes.len()); + assert_eq!(0x04, public_bytes[0]); + let public_bytes: [u8; 65] = public_bytes.try_into().unwrap(); + + Self { + subject: format!("emailto:{}", email).into(), + private_key: Arc::new(private_bytes), + public_key: Arc::new(public_bytes), + } + } + + pub fn with_private_key(email: &str, private_key: &[u8; 32]) -> Self { + let private_key = SigningKey::from_bytes(private_key.into()).unwrap(); + let public_key = private_key.verifying_key(); + let private_bytes = private_key.to_bytes(); + let private_bytes = private_bytes.try_into().unwrap(); + let point = public_key.to_encoded_point(false); + let public_bytes = point.as_bytes(); + assert_eq!(65, public_bytes.len()); + assert_eq!(0x04, public_bytes[0]); + let public_bytes: [u8; 65] = public_bytes.try_into().unwrap(); + + Self { + subject: format!("emailto:{}", email).into(), + private_key: Arc::new(private_bytes), + public_key: Arc::new(public_bytes), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_create_keys() { + Vapid::generate("emailto:example@example.com"); + } +}