Update code2, and change secret protocol for code 1
This commit is contained in:
+1
-4
@@ -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",
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
Assembly data will be added here.
|
||||||
+9
-46
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user