From fd51454e861d572a25474cc04e4a34d52dc44775 Mon Sep 17 00:00:00 2001 From: Aaron Global Date: Tue, 18 Nov 2025 11:41:58 -0500 Subject: [PATCH] feat: message unboxing on the cli --- Cargo.lock | 3 + crates/cli/Cargo.toml | 4 +- crates/cli/src/main.rs | 150 +++++++++++++++++++++++++++++++- crates/message-tools/src/lib.rs | 32 ++++++- crates/server/Cargo.toml | 1 + crates/server/bin/main.rs | 74 ++++++++++++++-- routes/contact/index.html | 38 ++++++-- 7 files changed, 281 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 41e2b67..e380c18 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -243,6 +243,8 @@ version = "0.1.0" dependencies = [ "clap", "message-tools", + "serde", + "serde_json", ] [[package]] @@ -827,6 +829,7 @@ dependencies = [ "getrandom 0.3.4", "lazy_static", "message-tools", + "serde", "serde_json", "tokio", "tower-http", diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 8bd2a05..426aab1 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -8,4 +8,6 @@ default-run = "cli" clap = { version = "4.5.52", features = ["derive"] } # local -message-tools = { path = "../message-tools" } \ No newline at end of file +message-tools = { path = "../message-tools" } +serde = "1.0.228" +serde_json = "1.0.145" diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index f39d50a..aebcdbd 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -1,5 +1,11 @@ use clap::{Parser, Subcommand}; -use std::{os::unix::process::ExitStatusExt, process::{Command, ExitStatus}}; +use message_tools::BoxedMessage; +use std::{ + fs::OpenOptions, + io::{BufRead, Write}, + os::unix::process::ExitStatusExt, + process::{Command, ExitStatus}, +}; const WEB_BUILD_DIR: &str = "web-build"; @@ -43,6 +49,10 @@ enum Commands { #[arg(short, long, default_value = ".local/messages")] source: String, + /// Where to find the secret keys for decrypting boxed messages + #[arg(long, default_value = ".local/secrets.json")] + sk_dir: String, + /// Optional output file path for decoded messages /// Default: stdout #[arg(short, long)] @@ -87,13 +97,143 @@ fn main() { message_tools::gen_keys::generate_keys_to_file(pk_out_dir, sk_out_dir, *number) .expect("write key files"); } - Commands::ReadMessages { .. } => { + Commands::ReadMessages { + source, + sk_dir, + out_dir, + } => { println!("Executing read-messages command..."); - // Add your message reading logic here + let kps = load_kps(sk_dir); + let msgs = { + let ms = fetch_all_messages_from_dir(source).expect("failed to read messages"); + + let mut unboxed: Vec = vec![]; + // for loop go brrr, its less than 1000 keys + for m in ms { + if let Some(sk) = + fetch_sk(&kps, &m.remote_pk().expect("failed to parse pk bytes")) + { + let who = m.tag(); + let message = + message_tools::unbox_message(m, sk).expect("failed to unbox message"); + unboxed.push(UnboxedMessage { who, message }); + } else { + eprintln!("No secret key found for message {m:?}"); + continue; + } + } + + unboxed + }; + + if let Some(dir) = out_dir { + let mut f = OpenOptions::new() + .create(true) + .write(true) + .append(true) + .open(dir) + .expect("failed to create message output file"); + + for msg in msgs { + writeln!(f, "{:?}", msg).expect("failed to write message"); + } + } else { + println!("{msgs:?}"); + } } } } +fn fetch_sk( + kps: &Vec, + pk: &message_tools::PublicKey, +) -> Option { + for kp in kps { + if &kp.pk == pk { + return Some(kp.sk.clone()); + } + } + + return None; +} + +#[allow(dead_code)] // struct for easy display using debug +#[derive(Debug)] +struct UnboxedMessage { + who: String, + message: String, +} + +struct KeyPair { + pub pk: message_tools::PublicKey, + pub sk: message_tools::StaticSecret, +} + +fn load_kps(path: impl AsRef) -> Vec { + let sks_bytes: Vec> = process_json_file(path).expect("failed to parse sk file"); + + sks_bytes + .into_iter() + .map(|sk| { + let arr: [u8; 32] = sk.try_into().expect("invalid secret"); + message_tools::StaticSecret::from(arr) + }) + .map(|sk| KeyPair { + pk: message_tools::PublicKey::from(&sk), + sk, + }) + .collect() +} + +fn fetch_all_messages_from_dir( + dir_path: impl AsRef, +) -> std::io::Result> { + let mut all_messages: Vec = Vec::new(); + + for entry in std::fs::read_dir(dir_path)? { + let entry = entry?; + let path = entry.path(); + + if path.is_file() && path.extension().unwrap_or_default() == "json" { + let file_messages = process_json_file(&path)?; + all_messages.extend(file_messages); + } + } + + Ok(all_messages) +} + +fn process_json_file( + file_path: impl AsRef, +) -> std::io::Result> { + let f = std::fs::OpenOptions::new() + .read(true) + .write(false) + .open(file_path.as_ref()) + .expect(&format!("failed to open file at {:?}", file_path.as_ref())); + + let reader = std::io::BufReader::new(f); + let mut ds: Vec = vec![]; + + for line in reader.lines() { + let line = line.expect("failed to read line"); + let line = line.trim(); + + if line.is_empty() { + continue; + } + + let d: D = serde_json::from_str::(line).expect(&format!( + "failed to parse message from file {:?}", + file_path.as_ref() + )); + + ds.push(d); + } + + Ok(ds) +} + // A helper function to process the Result fn handle_command_status(status_result: std::io::Result, command_name: &str) { match status_result { @@ -147,12 +287,13 @@ fn build() { println!("Copying files..."); // copy relevant files let status = Command::new("cp") - .args(["-R", "routes"]) + .args(["-R", "-f", "routes"]) .arg(format!("{WEB_BUILD_DIR}/routes")) .status(); handle_command_status(status, "cp routes"); let status = Command::new("cp") + .arg("-f") .arg(server_bin) .arg(format!("{WEB_BUILD_DIR}")) .status(); @@ -170,6 +311,7 @@ fn build() { handle_command_status(status, "mkdir web-build"); let status = Command::new("cp") + .arg("-f") .arg("target/public_keys.json") .arg(format!("{WEB_BUILD_DIR}/pubkeys/")) .status(); diff --git a/crates/message-tools/src/lib.rs b/crates/message-tools/src/lib.rs index 9f06860..290571c 100644 --- a/crates/message-tools/src/lib.rs +++ b/crates/message-tools/src/lib.rs @@ -4,10 +4,9 @@ use chacha20poly1305::{ aead::{Aead, OsRng}, }; use serde::{Deserialize, Serialize}; -use x25519_dalek::StaticSecret; +pub use x25519_dalek::{PublicKey, StaticSecret}; pub mod gen_keys; -pub use x25519_dalek::PublicKey; #[cfg(target_arch = "wasm32")] pub mod wasm; @@ -36,6 +35,35 @@ pub struct BoxedMessage { ciphertext: Vec, } +struct MyBytes(Vec); + +impl std::fmt::LowerHex for MyBytes { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // Example: iterate and format each byte + for byte in &self.0 { + write!(f, "{:02x}", byte)?; + } + Ok(()) + } +} + +impl BoxedMessage { + pub fn tag(&self) -> String { + let truncated = { + let mut s = format!("{:x}", MyBytes(self.user_pk.clone())); + s.truncate(16); + s + }; + format!("{}-{}", self.user, truncated) + } + + pub fn remote_pk(&self) -> Result { + let bytes: [u8; 32] = self.remote_pk.clone().try_into().map_err(to_error)?; + + Ok(PublicKey::from(bytes)) + } +} + #[derive(Deserialize, Serialize, Debug, Default)] pub struct MakeKeyBytesArgs { pub name: String, diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index 61e3524..5972592 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -16,3 +16,4 @@ serde_json = "1.0.145" message-tools = { path = "../message-tools" } getrandom = "0.3.4" lazy_static = "1.5.0" +serde = { version = "1.0.228", features = ["derive"] } diff --git a/crates/server/bin/main.rs b/crates/server/bin/main.rs index 0e1e8f2..4abbb7e 100644 --- a/crates/server/bin/main.rs +++ b/crates/server/bin/main.rs @@ -1,8 +1,22 @@ -use axum::{Router, response::Html, routing::get}; -use std::{fs::OpenOptions, io::{BufRead, Read}, path::Path}; +use axum::{ + Json, Router, + http::StatusCode, + response::Html, + routing::{get, post}, +}; +use message_tools::BoxedMessage; +use serde::Serialize; +use std::{ + fs::OpenOptions, + io::{BufRead, Read}, + path::{Path, PathBuf}, +}; +use tokio::io::AsyncWriteExt; use tower_http::services::ServeDir; const PUBKEY_PATH: &str = "./pubkeys/public_keys.json"; +const MESSAGE_STORAGE: &str = "./messages"; + lazy_static::lazy_static! { static ref PUBLIC_KEYS: Vec> = load_public_keys(PUBKEY_PATH).expect("static path"); } @@ -12,10 +26,7 @@ type Result = std::result::Result; fn load_public_keys(path: impl AsRef) -> std::io::Result>> { // Open the file - let f = OpenOptions::new() - .read(true) - .write(false) - .open(path)?; + let f = OpenOptions::new().read(true).write(false).open(path)?; // Wrap it in a BufReader for efficient line-by-line reading let reader = std::io::BufReader::new(f); @@ -54,6 +65,7 @@ async fn main() -> Result<()> { .route("/who", get(serve_path("./routes/who/index.html")?)) .route("/api/pubkey", get(async || select_key(&PUBLIC_KEYS))) .route("/contact", get(serve_path("./routes/contact/index.html")?)) + .route("/api/publish", post(publish_message)) .nest_service("/routes", ServeDir::new("./routes")); // run our app with hyper, listening globally on port 3000 @@ -63,6 +75,56 @@ async fn main() -> Result<()> { Ok(()) } +// Define a struct for the response +#[derive(Serialize)] +struct UserCreated { + tag: String, +} + +// FIXME: sanitation of boxed messages +async fn publish_message(Json(msg): Json) -> (StatusCode, Json) { + let tag = msg.tag(); // Capture tag early + + // 1. Define the full *file* path: e.g., "MESSAGE_STORAGE/some_tag/.json" + let file_path = PathBuf::from(MESSAGE_STORAGE) + .join(format!("{tag}.json")); // The file inside that directory + + // 3. Create all necessary parent directories recursively (async operation) + if let Err(e) = tokio::fs::create_dir_all(&MESSAGE_STORAGE).await { + eprintln!( + "Failed to create directory {}: {}", + MESSAGE_STORAGE, + e + ); + return (StatusCode::INTERNAL_SERVER_ERROR, Json(UserCreated { tag })); + } + + // 4. Open the file asynchronously + let mut f = match tokio::fs::OpenOptions::new() + .create(true) + .write(true) + .append(true) + .open(&file_path) + .await + { + Ok(file) => file, + Err(e) => { + eprintln!("Failed to open file {}: {}", file_path.display(), e); + return (StatusCode::INTERNAL_SERVER_ERROR, Json(UserCreated { tag })); + } + }; + + // 5. Serialize and write the data asynchronously + // We append a newline character for standard text file formatting + let s = serde_json::to_string(&msg).expect("failed to serialize boxed message"); + if let Err(e) = f.write_all(format!("{}\n", s).as_bytes()).await { + eprintln!("Failed to write message to file: {}", e); + return (StatusCode::INTERNAL_SERVER_ERROR, Json(UserCreated { tag })); + } + + (StatusCode::CREATED, Json(UserCreated { tag })) +} + fn select_key(pks: &Vec>) -> String { let idx = getrandom::u64().expect("random integer") as usize % pks.len(); serde_json::to_string(&pks[idx]).expect("serialize vec of bytes") diff --git a/routes/contact/index.html b/routes/contact/index.html index d09eecd..c2ab9e4 100644 --- a/routes/contact/index.html +++ b/routes/contact/index.html @@ -97,7 +97,7 @@ await wasm_bindgen(); await fetchPublicKey(); - console.log(`Server pk: ${serverPublicKey}`) + console.log(`Server pk: ${serverPublicKey}`); const form = document.getElementById("message-form"); form.addEventListener("submit", (event) => { @@ -112,9 +112,9 @@ if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } - + const pubkeyJSON = await response.text(); - serverPublicKey = JSON.parse(pubkeyJSON) + serverPublicKey = JSON.parse(pubkeyJSON); } catch (error) { console.error("Error fetching public key:", error); document.getElementById("status").textContent = @@ -122,7 +122,7 @@ } } - function submit() { + async function submit() { console.log("begin"); const user = document.getElementById("name").value; const password = document.getElementById("password").value; @@ -146,13 +146,35 @@ remote_pk: [...serverPublicKey], plaintext: String(message), }; - console.log("Boxed preflight: ", JSON.stringify(boxMessageArgs)) + console.log("Boxed preflight: ", JSON.stringify(boxMessageArgs)); const boxed = box_message(JSON.stringify(boxMessageArgs)); - console.log(JSON.parse(boxed)); + const boxedObj = JSON.parse(boxed); // Parse the boxed string into a JavaScript object + console.log(boxedObj); + + try { + const response = await fetch("/api/publish", { + method: "POST", // Specify the method as POST + headers: { + "Content-Type": "application/json", // Set the Content-Type header for JSON payload + }, + body: JSON.stringify(boxedObj), // Send the boxed object as a JSON string in the body + }); + + if (!response.ok) { + // Handle HTTP errors (status codes outside the 2xx range) + throw new Error(`HTTP error! status: ${response.status}`); + } + + const result = await response.json(); // Parse the response body as JSON + console.log("Success:", result); + alert("Sent!"); + location.reload(); + } catch (error) { + console.error("Error:", error); + alert("Failed to send message: " + error.message); + } console.log("End"); - alert("Sent!"); - location.reload(); } run();