From 8f0b2931e98f17e95fe403166f1860288806d38f Mon Sep 17 00:00:00 2001 From: Mitchell M Date: Mon, 12 Oct 2026 15:15:26 -0500 Subject: [PATCH] add code 2 --- Cargo.toml | 8 ++ README.md | 98 ++++++++++++++++++++++- dev.env | 6 ++ src/codes/code1.rs | 183 +++++++++++++++++++++++++++++++++++++++++-- src/codes/code2.rs | 40 ++++++++++ src/codes/history.rs | 4 +- src/codes/mod.rs | 32 ++++---- src/main.rs | 6 +- src/manage.rs | 2 +- src/secrets.rs | 106 ++++++++++++++++++++++--- 10 files changed, 443 insertions(+), 42 deletions(-) create mode 100644 src/codes/code2.rs diff --git a/Cargo.toml b/Cargo.toml index 55c2930..93fdc37 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,8 +12,12 @@ leptos_router = { version = "0.8.0" } axum = { version = "0.8.0", optional = true } console_error_panic_hook = { version = "0.1", optional = true } dotenvy = { version = "0.15", optional = true } +hmac = { version = "0.12", optional = true } leptos_axum = { version = "0.8.0", optional = true } leptos_meta = { version = "0.8.0" } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"], optional = true } +serde_json = { version = "1", optional = true } +sha2 = { version = "0.10", optional = true } tokio = { version = "1", features = ["rt-multi-thread"], optional = true } wasm-bindgen = { version = "0.2.106", optional = true } @@ -26,8 +30,12 @@ hydrate = [ ssr = [ "dep:axum", "dep:dotenvy", + "dep:hmac", "dep:tokio", "dep:leptos_axum", + "dep:reqwest", + "dep:serde_json", + "dep:sha2", "leptos/ssr", "leptos_meta/ssr", "leptos_router/ssr", diff --git a/README.md b/README.md index 6484d36..0ba5bb6 100644 --- a/README.md +++ b/README.md @@ -38,8 +38,9 @@ cargo leptos watch Copy `dev.env` to `.env`, then replace the placeholder with a unique secret before starting the server. The `.env` file is loaded automatically at startup -and is ignored by Git. `DEV_SECRET_KEY` authorizes server status changes and -submission-history access: +and is ignored by Git. Startup validates the required settings and stops with a +clear error if any are missing or malformed. `DEV_SECRET_KEY` authorizes server +status changes and submission-history access: ```powershell Copy-Item dev.env .env @@ -48,6 +49,99 @@ Copy-Item dev.env .env You can also provide `DEV_SECRET_KEY` through the process environment. Do not commit secrets or expose them in client-side code. +Code 1 sends an SMS through TextBee when the server is live. Set +`TEXTBEE_API_KEY`, `RECIPIENT_SELF`, `RECIPIENT_1`, and `RECIPIENT_2` (each a +single E.164 number) in `.env`; `TEXTBEE_DEVICE_ID` is optional and selects a +specific paired device. When live, code 1 sends a notification to self, the +congratulations message to recipients 1 and 2, then `CHALLENGE_1_FIRST` to +recipient 1 and `CHALLENGE_1_SECOND` to recipient 2. The latter two are sent +literally as written. When offline, the planned messages are logged instead. +TextBee acceptance means a message was queued, not necessarily delivered. + +Code 2 uses the former manual code value (`Qj4eAn`). On successful submission, +it sends a notification to `RECIPIENT_SELF` while the server is live (and logs +the notification instead while offline). The result page displays +`NEXT_STEP_TEXT` from `src/codes/code2.rs`; replace its TODO text with the next +step when ready. + +The robot submits code 1 with `POST /event/buzz` and a JSON body containing +`delta_ms` (a signed integer) and `signature` (hex-encoded HMAC-SHA256). The +signature is the HMAC of the UTF-8 decimal representation of `delta_ms`, using +`CODE1_HMAC_SECRET` as the key. Set `CODE1_HMAC_SECRET` to a unique random +secret of at least 32 bytes in `.env`; for example: + +```json +{"delta_ms":-42,"signature":"<64 hex characters>"} +``` + +### Test code1 with Postman + +Create a `POST` request to `http://127.0.0.1:3000/event/buzz`, select **Body > +raw > JSON**, and use this body: + +```json +{ + "delta_ms": {{delta_ms}}, + "signature": "{{buzz_signature}}" +} +``` + +Add a Postman pre-request script to calculate the signature: + +```javascript +const deltaMs = pm.environment.get("delta_ms") || "-42"; +const secret = pm.environment.get("CODE1_HMAC_SECRET"); + +if (!secret) { + throw new Error("Set CODE1_HMAC_SECRET in the active Postman environment"); +} + +pm.environment.set("delta_ms", deltaMs); +pm.environment.set( + "buzz_signature", + CryptoJS.HmacSHA256(String(deltaMs), secret).toString(CryptoJS.enc.Hex) +); +``` + +Set `CODE1_HMAC_SECRET` in the active Postman environment to the same value as +in `.env`. Set `delta_ms` to a signed integer (for example `-42`) to change the +test event. A successful request returns HTTP `202`. + +### Test code1 with Node.js + +With the server running and `CODE1_HMAC_SECRET` set in your shell, this sends a +single test event: + +```javascript +const { createHmac } = require("node:crypto"); + +async function main() { + const deltaMs = -42; + const secret = process.env.CODE1_HMAC_SECRET; + if (!secret) throw new Error("CODE1_HMAC_SECRET is not set"); + + const signature = createHmac("sha256", secret) + .update(String(deltaMs), "utf8") + .digest("hex"); + + const response = await fetch("http://127.0.0.1:3000/event/buzz", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ delta_ms: deltaMs, signature }), + }); + + console.log(response.status, await response.text()); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); +``` + +When the server is live, a successful test event sends the configured TextBee +SMS to the configured recipients. When it is offline, it only logs the message. + ## Installing Additional Tools By default, `cargo-leptos` uses `nightly` Rust, `cargo-generate`, and `sass`. If you run into any trouble, you may need to install one or more of these tools. diff --git a/dev.env b/dev.env index e627afb..7a8c1e6 100644 --- a/dev.env +++ b/dev.env @@ -1 +1,7 @@ DEV_SECRET_KEY=replace-with-a-unique-secret +TEXTBEE_API_KEY=replace-with-your-textbee-api-key +TEXTBEE_DEVICE_ID= +RECIPIENT_SELF=+12015550123 +RECIPIENT_1=+12015550124 +RECIPIENT_2=+12015550125 +CODE1_HMAC_SECRET=replace-with-a-unique-random-secret diff --git a/src/codes/code1.rs b/src/codes/code1.rs index aeff4c2..1606c39 100644 --- a/src/codes/code1.rs +++ b/src/codes/code1.rs @@ -1,6 +1,179 @@ -pub const ID: &str = "code1"; -pub const CODE: &str = "Qj4eAn"; -pub const LIVE_MESSAGE: &str = "Welcome! Your code has been accepted."; -pub const NOT_LIVE_MESSAGE: &str = "Thanks for submitting your code. Please try again later."; +use axum::{extract::ConnectInfo, http::StatusCode, Json}; +use hmac::{Hmac, Mac}; +use serde_json::Value; +use sha2::Sha256; +use std::net::SocketAddr; -pub(super) fn on_submit(_live: bool) {} +pub const ID: &str = "code1"; +const SELF_NOTIFICATION: &str = "Code 1 has been submitted while the server is live."; +const CHALLENGE_1_FIRST: &str = "TODO: secded data"; +const CHALLENGE_1_SECOND: &str = "SECDED-13"; + +pub async fn buzz_event_handler( + ConnectInfo(address): ConnectInfo, + Json(payload): Json, +) -> Result { + let delta_ms = payload + .get("delta_ms") + .and_then(Value::as_i64) + .ok_or_else(|| { + ( + StatusCode::BAD_REQUEST, + "delta_ms must be a signed 64-bit integer".to_string(), + ) + })?; + let signature = payload + .get("signature") + .and_then(Value::as_str) + .ok_or_else(|| { + ( + StatusCode::BAD_REQUEST, + "signature must be a hexadecimal HMAC-SHA256 string".to_string(), + ) + })?; + let signature = decode_hex_signature(signature).ok_or_else(|| { + ( + StatusCode::UNAUTHORIZED, + "Invalid buzz event signature".to_string(), + ) + })?; + let mut mac = + Hmac::::new_from_slice(crate::secrets::runtime().code1_hmac_secret.as_bytes()) + .expect("HMAC accepts secrets of any length"); + mac.update(delta_ms.to_string().as_bytes()); + mac.verify_slice(&signature).map_err(|_| { + ( + StatusCode::UNAUTHORIZED, + "Invalid buzz event signature".to_string(), + ) + })?; + + let live = crate::manage::server_live_state(); + crate::codes::history::record_successful_submission(address.ip(), live, ID) + .map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?; + on_submit(live, delta_ms).await.map_err(|error| { + leptos::logging::log!("Code 1 buzz event failed: {error}"); + ( + StatusCode::BAD_GATEWAY, + "Unable to process buzz event".to_string(), + ) + })?; + + Ok(StatusCode::ACCEPTED) +} + +fn decode_hex_signature(signature: &str) -> Option> { + if signature.len() != 64 { + return None; + } + + signature + .as_bytes() + .chunks_exact(2) + .map(|pair| { + let high = hex_nibble(pair[0])?; + let low = hex_nibble(pair[1])?; + Some((high << 4) | low) + }) + .collect() +} + +fn hex_nibble(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + } +} + +async fn on_submit(live: bool, delta_ms: i64) -> Result<(), String> { + let direction = match delta_ms.cmp(&0) { + std::cmp::Ordering::Less => "short", + std::cmp::Ordering::Greater => "long", + std::cmp::Ordering::Equal => "exactly on time", + }; + let congrats_message = format!( + "Congratulations on completing Challenge 1. It appears that you've figured out what to do, although your buzz was {}ms {}. Consider being more precise in the future. Your sloppiness has been noted.", + delta_ms.abs(), direction, + ); + + if !live { + leptos::logging::log!( + "Code 1 SMS plan (not sent; server is offline): self notification: {SELF_NOTIFICATION}; congratulations to recipients 1 and 2: {congrats_message}; recipient 1: {CHALLENGE_1_FIRST}; recipient 2: {CHALLENGE_1_SECOND}" + ); + return Ok(()); + } + + let secrets = crate::secrets::runtime(); + let client = reqwest::Client::new(); + + send_textbee_sms( + &client, + &secrets.textbee_api_key, + secrets.textbee_device_id.as_deref(), + &[&secrets.recipient_self], + SELF_NOTIFICATION, + ) + .await?; + send_textbee_sms( + &client, + &secrets.textbee_api_key, + secrets.textbee_device_id.as_deref(), + &[&secrets.recipient_1, &secrets.recipient_2], + &congrats_message, + ) + .await?; + send_textbee_sms( + &client, + &secrets.textbee_api_key, + secrets.textbee_device_id.as_deref(), + &[&secrets.recipient_1], + CHALLENGE_1_FIRST, + ) + .await?; + send_textbee_sms( + &client, + &secrets.textbee_api_key, + secrets.textbee_device_id.as_deref(), + &[&secrets.recipient_2], + CHALLENGE_1_SECOND, + ) + .await?; + + Ok(()) +} + +async fn send_textbee_sms( + client: &reqwest::Client, + api_key: &str, + device_id: Option<&str>, + recipients: &[&str], + message: &str, +) -> Result<(), String> { + let mut payload = serde_json::json!({ + "message": message, + "recipients": recipients, + }); + if let Some(device_id) = device_id { + payload["deviceId"] = serde_json::Value::String(device_id.to_string()); + } + + let response = client + .post("https://api.textbee.dev/api/v1/gateway/send-sms") + .header("x-api-key", api_key) + .json(&payload) + .send() + .await + .map_err(|error| format!("TextBee request failed: {error}"))?; + + let status = response.status(); + if !status.is_success() { + let response_body = response.text().await.map_err(|error| { + format!("TextBee returned HTTP {status}; unable to read error: {error}") + })?; + return Err(format!("TextBee returned HTTP {status}: {response_body}")); + } + + Ok(()) +} diff --git a/src/codes/code2.rs b/src/codes/code2.rs new file mode 100644 index 0000000..d38c97f --- /dev/null +++ b/src/codes/code2.rs @@ -0,0 +1,40 @@ +pub const ID: &str = "code2"; +pub const CODE: &str = "Qj4eAn"; +pub const NEXT_STEP_TEXT: &str = "TODO: replace this with the next step."; + +const SELF_NOTIFICATION: &str = "Code 2 has been submitted."; + +pub(super) async fn on_submit(live: bool) -> Result<(), String> { + if !live { + leptos::logging::log!("Code 2 SMS (not sent; server is offline): {SELF_NOTIFICATION}"); + return Ok(()); + } + + let secrets = crate::secrets::runtime(); + + let mut payload = serde_json::json!({ + "message": SELF_NOTIFICATION, + "recipients": [&secrets.recipient_self], + }); + if let Some(device_id) = &secrets.textbee_device_id { + payload["deviceId"] = serde_json::Value::String(device_id.clone()); + } + + let response = reqwest::Client::new() + .post("https://api.textbee.dev/api/v1/gateway/send-sms") + .header("x-api-key", &secrets.textbee_api_key) + .json(&payload) + .send() + .await + .map_err(|error| format!("TextBee request failed: {error}"))?; + + let status = response.status(); + if !status.is_success() { + let response_body = response.text().await.map_err(|error| { + format!("TextBee returned HTTP {status}; unable to read error: {error}") + })?; + return Err(format!("TextBee returned HTTP {status}: {response_body}")); + } + + Ok(()) +} diff --git a/src/codes/history.rs b/src/codes/history.rs index 38edd24..80254ac 100644 --- a/src/codes/history.rs +++ b/src/codes/history.rs @@ -87,7 +87,7 @@ type HistoryViewEntry = (String, String, bool, String, String); async fn get_submission_history( secret_key: String, ) -> Result, ServerFnError> { - if !crate::secrets::validate_dev_secret_key(&secret_key)? { + if !crate::secrets::validate_dev_secret_key(&secret_key) { return Err(ServerFnError::new("Invalid secret key")); } @@ -243,7 +243,7 @@ fn persist_submission(submission: &SuccessfulSubmission) -> Result<(), ServerFnE #[server] async fn clear_submission_history(secret_key: String) -> Result<(), ServerFnError> { - if !crate::secrets::validate_dev_secret_key(&secret_key)? { + if !crate::secrets::validate_dev_secret_key(&secret_key) { return Err(ServerFnError::new("Invalid secret key")); } diff --git a/src/codes/mod.rs b/src/codes/mod.rs index f731042..7af6d37 100644 --- a/src/codes/mod.rs +++ b/src/codes/mod.rs @@ -6,31 +6,27 @@ use leptos_router::{ }; #[cfg(feature = "ssr")] -mod code1; +pub mod code1; +#[cfg(feature = "ssr")] +pub mod code2; pub mod history; #[server] async fn submit_code(code: String) -> Result { - let message = if code == crate::codes::code1::CODE { - let live = crate::manage::server_live_state(); - let axum::extract::ConnectInfo(address): axum::extract::ConnectInfo = - leptos_axum::extract().await?; + if code != crate::codes::code2::CODE { + return Ok("This code is not recognized.".to_string()); + } - history::record_successful_submission(address.ip(), live, crate::codes::code1::ID)?; + let live = crate::manage::server_live_state(); + let axum::extract::ConnectInfo(address): axum::extract::ConnectInfo = + leptos_axum::extract().await?; + history::record_successful_submission(address.ip(), live, crate::codes::code2::ID)?; + crate::codes::code2::on_submit(live) + .await + .map_err(ServerFnError::new)?; - crate::codes::code1::on_submit(live); - - if live { - crate::codes::code1::LIVE_MESSAGE - } else { - crate::codes::code1::NOT_LIVE_MESSAGE - } - } else { - "This code is not recognized." - }; - - Ok(message.to_string()) + Ok(crate::codes::code2::NEXT_STEP_TEXT.to_string()) } fn encode_path_segment(value: &str) -> String { diff --git a/src/main.rs b/src/main.rs index 7eb05c2..e042af8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,7 +7,7 @@ async fn main() { use leptos::prelude::*; use leptos_axum::{generate_route_list, LeptosRoutes}; - code_submission::secrets::load_dotenv().expect("Unable to load .env configuration"); + code_submission::secrets::init().expect("Unable to load .env configuration"); let conf = get_configuration(None).unwrap(); let addr = conf.leptos_options.site_addr; @@ -16,6 +16,10 @@ async fn main() { let routes = generate_route_list(App); let app = Router::new() + .route( + "/event/buzz", + axum::routing::post(code_submission::codes::code1::buzz_event_handler), + ) .leptos_routes(&leptos_options, routes, { let leptos_options = leptos_options.clone(); move || shell(leptos_options.clone()) diff --git a/src/manage.rs b/src/manage.rs index b11458e..d558b0b 100644 --- a/src/manage.rs +++ b/src/manage.rs @@ -19,7 +19,7 @@ async fn get_live_state() -> Result { #[server] async fn set_live_state(live: bool, secret_key: String) -> Result<(), ServerFnError> { - if !crate::secrets::validate_dev_secret_key(&secret_key)? { + if !crate::secrets::validate_dev_secret_key(&secret_key) { return Err(ServerFnError::ServerError("Invalid secret key".to_string())); } diff --git a/src/secrets.rs b/src/secrets.rs index 954567b..1ad7523 100644 --- a/src/secrets.rs +++ b/src/secrets.rs @@ -1,22 +1,102 @@ -use leptos::prelude::ServerFnError; -use std::io::ErrorKind; +use std::{env, io::ErrorKind, sync::OnceLock}; pub(crate) const DEV_SECRET_KEY: &str = "DEV_SECRET_KEY"; +pub(crate) const TEXTBEE_API_KEY: &str = "TEXTBEE_API_KEY"; +pub(crate) const TEXTBEE_DEVICE_ID: &str = "TEXTBEE_DEVICE_ID"; +pub(crate) const RECIPIENT_SELF: &str = "RECIPIENT_SELF"; +pub(crate) const RECIPIENT_1: &str = "RECIPIENT_1"; +pub(crate) const RECIPIENT_2: &str = "RECIPIENT_2"; +pub(crate) const CODE1_HMAC_SECRET: &str = "CODE1_HMAC_SECRET"; -pub fn load_dotenv() -> Result<(), String> { +static RUNTIME_SECRETS: OnceLock = OnceLock::new(); + +pub(crate) struct RuntimeSecrets { + pub(crate) dev_secret_key: String, + pub(crate) textbee_api_key: String, + pub(crate) textbee_device_id: Option, + pub(crate) recipient_self: String, + pub(crate) recipient_1: String, + pub(crate) recipient_2: String, + pub(crate) code1_hmac_secret: String, +} + +pub fn init() -> Result<(), String> { match dotenvy::dotenv() { - Ok(_) => Ok(()), - Err(dotenvy::Error::Io(error)) if error.kind() == ErrorKind::NotFound => Ok(()), - Err(error) => Err(format!("Unable to load .env file: {error}")), + Ok(_) => {} + Err(dotenvy::Error::Io(error)) if error.kind() == ErrorKind::NotFound => {} + Err(error) => return Err(format!("Unable to load .env file: {error}")), + } + + if RUNTIME_SECRETS.get().is_some() { + return Ok(()); + } + + let secrets = RuntimeSecrets::from_environment()?; + let _ = RUNTIME_SECRETS.set(secrets); + Ok(()) +} + +pub(crate) fn runtime() -> &'static RuntimeSecrets { + RUNTIME_SECRETS + .get() + .expect("runtime secrets must be validated before the server starts") +} + +pub(crate) fn validate_dev_secret_key(provided_key: &str) -> bool { + provided_key == runtime().dev_secret_key +} + +impl RuntimeSecrets { + fn from_environment() -> Result { + let dev_secret_key = required(DEV_SECRET_KEY)?; + let textbee_api_key = required(TEXTBEE_API_KEY)?; + let recipient_self = required_recipient(RECIPIENT_SELF)?; + let recipient_1 = required_recipient(RECIPIENT_1)?; + let recipient_2 = required_recipient(RECIPIENT_2)?; + let code1_hmac_secret = required(CODE1_HMAC_SECRET)?; + + if code1_hmac_secret.len() < 32 { + return Err(format!( + "{CODE1_HMAC_SECRET} must contain at least 32 bytes" + )); + } + + let textbee_device_id = env::var(TEXTBEE_DEVICE_ID) + .ok() + .filter(|device_id| !device_id.trim().is_empty()); + + Ok(Self { + dev_secret_key, + textbee_api_key, + textbee_device_id, + recipient_self, + recipient_1, + recipient_2, + code1_hmac_secret, + }) } } -pub(crate) fn validate_dev_secret_key(provided_key: &str) -> Result { - let expected_key = std::env::var(DEV_SECRET_KEY).map_err(|_| { - ServerFnError::new(format!( - "Required server secret {DEV_SECRET_KEY} is not configured" - )) - })?; +fn required(name: &str) -> Result { + let value = + env::var(name).map_err(|_| format!("Required environment variable {name} is missing"))?; + if value.trim().is_empty() { + return Err(format!("Required environment variable {name} is empty")); + } + Ok(value) +} - Ok(provided_key == expected_key) +fn required_recipient(name: &str) -> Result { + let recipient = required(name)?; + if !is_e164_number(&recipient) { + return Err(format!("{name} must be a valid E.164 phone number")); + } + Ok(recipient) +} + +fn is_e164_number(number: &str) -> bool { + let digits = number.strip_prefix('+').unwrap_or_default(); + (8..=15).contains(&digits.len()) + && !digits.starts_with('0') + && digits.bytes().all(|byte| byte.is_ascii_digit()) }