Update code2, and change secret protocol for code 1

This commit is contained in:
2026-10-12 23:20:30 -05:00
parent 8f0b2931e9
commit 1ff23a3004
9 changed files with 114 additions and 106 deletions
+1 -4
View File
@@ -12,12 +12,11 @@ leptos_router = { version = "0.8.0" }
axum = { version = "0.8.0", optional = true } axum = { version = "0.8.0", optional = true }
console_error_panic_hook = { version = "0.1", optional = true } console_error_panic_hook = { version = "0.1", optional = true }
dotenvy = { version = "0.15", optional = true } dotenvy = { version = "0.15", optional = true }
hmac = { version = "0.12", optional = true }
leptos_axum = { version = "0.8.0", optional = true } leptos_axum = { version = "0.8.0", optional = true }
leptos_meta = { version = "0.8.0" } leptos_meta = { version = "0.8.0" }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"], optional = true } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"], optional = true }
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", optional = true } serde_json = { version = "1", optional = true }
sha2 = { version = "0.10", optional = true }
tokio = { version = "1", features = ["rt-multi-thread"], optional = true } tokio = { version = "1", features = ["rt-multi-thread"], optional = true }
wasm-bindgen = { version = "0.2.106", optional = true } wasm-bindgen = { version = "0.2.106", optional = true }
@@ -30,12 +29,10 @@ hydrate = [
ssr = [ ssr = [
"dep:axum", "dep:axum",
"dep:dotenvy", "dep:dotenvy",
"dep:hmac",
"dep:tokio", "dep:tokio",
"dep:leptos_axum", "dep:leptos_axum",
"dep:reqwest", "dep:reqwest",
"dep:serde_json", "dep:serde_json",
"dep:sha2",
"leptos/ssr", "leptos/ssr",
"leptos_meta/ssr", "leptos_meta/ssr",
"leptos_router/ssr", "leptos_router/ssr",
+15 -41
View File
@@ -65,69 +65,43 @@ the notification instead while offline). The result page displays
step when ready. step when ready.
The robot submits code 1 with `POST /event/buzz` and a JSON body containing 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 `delta_ms` (a signed integer) and `secret` (the value of `CODE1_SECRET`).
signature is the HMAC of the UTF-8 decimal representation of `delta_ms`, using Set `CODE1_SECRET` to a unique random secret of at least 32 bytes in
`CODE1_HMAC_SECRET` as the key. Set `CODE1_HMAC_SECRET` to a unique random `.env`. For example, with the server running locally:
secret of at least 32 bytes in `.env`; for example:
```json ```sh
{"delta_ms":-42,"signature":"<64 hex characters>"} curl.exe -X POST http://127.0.0.1:3000/event/buzz -H "Content-Type: application/json" -d '{"delta_ms":-42,"secret":"<your CODE1_SECRET value>"}'
``` ```
The integer `delta_ms` value is passed through to the congratulations message;
a valid request returns HTTP `202`.
### Test code1 with Postman ### Test code1 with Postman
Create a `POST` request to `http://127.0.0.1:3000/event/buzz`, select **Body > Create a `POST` request to `http://127.0.0.1:3000/event/buzz`, select **Body >
raw > JSON**, and use this body: raw > JSON**, and send:
```json ```json
{ {
"delta_ms": {{delta_ms}}, "delta_ms": -42,
"signature": "{{buzz_signature}}" "secret": "<your CODE1_SECRET value>"
} }
``` ```
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 ### Test code1 with Node.js
With the server running and `CODE1_HMAC_SECRET` set in your shell, this sends a With the server running and `CODE1_SECRET` set in your shell, this sends a
single test event: single test event:
```javascript ```javascript
const { createHmac } = require("node:crypto");
async function main() { async function main() {
const deltaMs = -42; const deltaMs = -42;
const secret = process.env.CODE1_HMAC_SECRET; const secret = process.env.CODE1_SECRET;
if (!secret) throw new Error("CODE1_HMAC_SECRET is not set"); if (!secret) throw new Error("CODE1_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", { const response = await fetch("http://127.0.0.1:3000/event/buzz", {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify({ delta_ms: deltaMs, signature }), body: JSON.stringify({ delta_ms: deltaMs, secret }),
}); });
console.log(response.status, await response.text()); console.log(response.status, await response.text());
+1 -1
View File
@@ -4,4 +4,4 @@ TEXTBEE_DEVICE_ID=
RECIPIENT_SELF=+12015550123 RECIPIENT_SELF=+12015550123
RECIPIENT_1=+12015550124 RECIPIENT_1=+12015550124
RECIPIENT_2=+12015550125 RECIPIENT_2=+12015550125
CODE1_HMAC_SECRET=replace-with-a-unique-random-secret CODE1_SECRET=replace-with-a-unique-random-secret
+1
View File
@@ -0,0 +1 @@
Assembly data will be added here.
+9 -46
View File
@@ -1,13 +1,11 @@
use axum::{extract::ConnectInfo, http::StatusCode, Json}; use axum::{extract::ConnectInfo, http::StatusCode, Json};
use hmac::{Hmac, Mac};
use serde_json::Value; use serde_json::Value;
use sha2::Sha256;
use std::net::SocketAddr; use std::net::SocketAddr;
pub const ID: &str = "code1"; pub const ID: &str = "code1";
const SELF_NOTIFICATION: &str = "Code 1 has been submitted while the server is live."; 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_FIRST: &str = "TODO: secded data";
const CHALLENGE_1_SECOND: &str = "SECDED-13"; const CHALLENGE_1_SECOND: &str = "SECDED-13\nMSB-LSB";
pub async fn buzz_event_handler( pub async fn buzz_event_handler(
ConnectInfo(address): ConnectInfo<SocketAddr>, ConnectInfo(address): ConnectInfo<SocketAddr>,
@@ -22,31 +20,21 @@ pub async fn buzz_event_handler(
"delta_ms must be a signed 64-bit integer".to_string(), "delta_ms must be a signed 64-bit integer".to_string(),
) )
})?; })?;
let signature = payload let secret = payload
.get("signature") .get("secret")
.and_then(Value::as_str) .and_then(Value::as_str)
.ok_or_else(|| { .ok_or_else(|| {
( (
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
"signature must be a hexadecimal HMAC-SHA256 string".to_string(), "secret must be a string".to_string(),
) )
})?; })?;
let signature = decode_hex_signature(signature).ok_or_else(|| { if secret != crate::secrets::runtime().code1_secret {
( return Err((
StatusCode::UNAUTHORIZED, StatusCode::UNAUTHORIZED,
"Invalid buzz event signature".to_string(), "Invalid buzz event secret".to_string(),
) ));
})?; }
let mut mac =
Hmac::<Sha256>::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(); let live = crate::manage::server_live_state();
crate::codes::history::record_successful_submission(address.ip(), live, ID) crate::codes::history::record_successful_submission(address.ip(), live, ID)
@@ -62,31 +50,6 @@ pub async fn buzz_event_handler(
Ok(StatusCode::ACCEPTED) Ok(StatusCode::ACCEPTED)
} }
fn decode_hex_signature(signature: &str) -> Option<Vec<u8>> {
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<u8> {
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> { async fn on_submit(live: bool, delta_ms: i64) -> Result<(), String> {
let direction = match delta_ms.cmp(&0) { let direction = match delta_ms.cmp(&0) {
std::cmp::Ordering::Less => "short", std::cmp::Ordering::Less => "short",
+1 -1
View File
@@ -1,6 +1,6 @@
pub const ID: &str = "code2"; pub const ID: &str = "code2";
pub const CODE: &str = "Qj4eAn"; pub const CODE: &str = "Qj4eAn";
pub const NEXT_STEP_TEXT: &str = "TODO: replace this with the next step."; pub const SUCCESS_MESSAGE: &str = "Congratulations on completing step 2.";
const SELF_NOTIFICATION: &str = "Code 2 has been submitted."; const SELF_NOTIFICATION: &str = "Code 2 has been submitted.";
+52 -5
View File
@@ -12,10 +12,24 @@ pub mod code2;
pub mod history; pub mod history;
#[derive(Clone, serde::Deserialize, serde::Serialize)]
struct SubmissionResponse {
message: String,
download: Option<DownloadInfo>,
}
#[derive(Clone, serde::Deserialize, serde::Serialize)]
struct DownloadInfo {
href: String,
filename: String,
title: String,
description: String,
}
#[server] #[server]
async fn submit_code(code: String) -> Result<String, ServerFnError> { async fn submit_code(code: String) -> Result<Option<SubmissionResponse>, ServerFnError> {
if code != crate::codes::code2::CODE { if code != crate::codes::code2::CODE {
return Ok("This code is not recognized.".to_string()); return Ok(None);
} }
let live = crate::manage::server_live_state(); let live = crate::manage::server_live_state();
@@ -26,7 +40,15 @@ async fn submit_code(code: String) -> Result<String, ServerFnError> {
.await .await
.map_err(ServerFnError::new)?; .map_err(ServerFnError::new)?;
Ok(crate::codes::code2::NEXT_STEP_TEXT.to_string()) Ok(Some(SubmissionResponse {
message: crate::codes::code2::SUCCESS_MESSAGE.to_string(),
download: Some(DownloadInfo {
href: "/challenge3.txt".to_string(),
filename: "challenge3.txt".to_string(),
title: "Download challenge 3".to_string(),
description: "TXT file".to_string(),
}),
}))
} }
fn encode_path_segment(value: &str) -> String { fn encode_path_segment(value: &str) -> String {
@@ -78,6 +100,28 @@ pub fn HomePage() -> impl IntoView {
} }
} }
#[component]
fn SubmissionResult(response: SubmissionResponse) -> impl IntoView {
let download = response.download.map(|download| {
view! {
<a
class="download-card"
href=download.href
download=download.filename
>
<span class="download-title">{download.title}</span>
<span class="download-description">{download.description}</span>
</a>
}
});
view! {
<h1>"Submission result"</h1>
<p class="intro">{response.message}</p>
{download}
}
}
#[component] #[component]
pub fn SubmitPage() -> impl IntoView { pub fn SubmitPage() -> impl IntoView {
let params = use_params_map(); let params = use_params_map();
@@ -90,9 +134,12 @@ pub fn SubmitPage() -> impl IntoView {
<Suspense fallback=|| view! { <p class="loading">"Checking code..."</p> }> <Suspense fallback=|| view! { <p class="loading">"Checking code..."</p> }>
{move || Suspend::new(async move { {move || Suspend::new(async move {
match submission.await { match submission.await {
Ok(message) => view! { Ok(Some(response)) => {
view! { <SubmissionResult response/> }.into_any()
},
Ok(None) => view! {
<h1>"Submission result"</h1> <h1>"Submission result"</h1>
<p class="intro">{message}</p> <p class="intro">"This code is not recognized."</p>
}.into_any(), }.into_any(),
Err(error) => view! { Err(error) => view! {
<p class="error-message" role="alert"> <p class="error-message" role="alert">
+6 -8
View File
@@ -6,7 +6,7 @@ pub(crate) const TEXTBEE_DEVICE_ID: &str = "TEXTBEE_DEVICE_ID";
pub(crate) const RECIPIENT_SELF: &str = "RECIPIENT_SELF"; pub(crate) const RECIPIENT_SELF: &str = "RECIPIENT_SELF";
pub(crate) const RECIPIENT_1: &str = "RECIPIENT_1"; pub(crate) const RECIPIENT_1: &str = "RECIPIENT_1";
pub(crate) const RECIPIENT_2: &str = "RECIPIENT_2"; pub(crate) const RECIPIENT_2: &str = "RECIPIENT_2";
pub(crate) const CODE1_HMAC_SECRET: &str = "CODE1_HMAC_SECRET"; pub(crate) const CODE1_SECRET: &str = "CODE1_SECRET";
static RUNTIME_SECRETS: OnceLock<RuntimeSecrets> = OnceLock::new(); static RUNTIME_SECRETS: OnceLock<RuntimeSecrets> = OnceLock::new();
@@ -17,7 +17,7 @@ pub(crate) struct RuntimeSecrets {
pub(crate) recipient_self: String, pub(crate) recipient_self: String,
pub(crate) recipient_1: String, pub(crate) recipient_1: String,
pub(crate) recipient_2: String, pub(crate) recipient_2: String,
pub(crate) code1_hmac_secret: String, pub(crate) code1_secret: String,
} }
pub fn init() -> Result<(), String> { pub fn init() -> Result<(), String> {
@@ -53,12 +53,10 @@ impl RuntimeSecrets {
let recipient_self = required_recipient(RECIPIENT_SELF)?; let recipient_self = required_recipient(RECIPIENT_SELF)?;
let recipient_1 = required_recipient(RECIPIENT_1)?; let recipient_1 = required_recipient(RECIPIENT_1)?;
let recipient_2 = required_recipient(RECIPIENT_2)?; let recipient_2 = required_recipient(RECIPIENT_2)?;
let code1_hmac_secret = required(CODE1_HMAC_SECRET)?; let code1_secret = required(CODE1_SECRET)?;
if code1_hmac_secret.len() < 32 { if code1_secret.len() < 32 {
return Err(format!( return Err(format!("{CODE1_SECRET} must contain at least 32 bytes"));
"{CODE1_HMAC_SECRET} must contain at least 32 bytes"
));
} }
let textbee_device_id = env::var(TEXTBEE_DEVICE_ID) let textbee_device_id = env::var(TEXTBEE_DEVICE_ID)
@@ -72,7 +70,7 @@ impl RuntimeSecrets {
recipient_self, recipient_self,
recipient_1, recipient_1,
recipient_2, recipient_2,
code1_hmac_secret, code1_secret,
}) })
} }
} }
+28
View File
@@ -193,6 +193,34 @@ h1 {
text-decoration: none; text-decoration: none;
} }
.download-card {
display: flex;
flex-direction: column;
gap: 6px;
margin: 24px 0;
padding: 18px 20px;
border: 1px solid rgba(124, 179, 255, 0.35);
border-radius: 14px;
background: rgba(31, 111, 235, 0.12);
color: #e6edf7;
text-decoration: none;
transition: background-color 150ms ease, border-color 150ms ease;
}
.download-card:hover {
border-color: rgba(124, 179, 255, 0.7);
background: rgba(31, 111, 235, 0.2);
}
.download-title {
font-weight: 700;
}
.download-description {
color: #a9b6c9;
font-size: 0.85rem;
}
.page-nav { .page-nav {
display: flex; display: flex;
align-items: center; align-items: center;