feat: message unboxing on the cli

This commit is contained in:
Aaron Global 2025-11-18 11:41:58 -05:00
parent 9ed30871c1
commit fd51454e86
7 changed files with 281 additions and 21 deletions

3
Cargo.lock generated
View file

@ -243,6 +243,8 @@ version = "0.1.0"
dependencies = [ dependencies = [
"clap", "clap",
"message-tools", "message-tools",
"serde",
"serde_json",
] ]
[[package]] [[package]]
@ -827,6 +829,7 @@ dependencies = [
"getrandom 0.3.4", "getrandom 0.3.4",
"lazy_static", "lazy_static",
"message-tools", "message-tools",
"serde",
"serde_json", "serde_json",
"tokio", "tokio",
"tower-http", "tower-http",

View file

@ -9,3 +9,5 @@ clap = { version = "4.5.52", features = ["derive"] }
# local # local
message-tools = { path = "../message-tools" } message-tools = { path = "../message-tools" }
serde = "1.0.228"
serde_json = "1.0.145"

View file

@ -1,5 +1,11 @@
use clap::{Parser, Subcommand}; 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"; const WEB_BUILD_DIR: &str = "web-build";
@ -43,6 +49,10 @@ enum Commands {
#[arg(short, long, default_value = ".local/messages")] #[arg(short, long, default_value = ".local/messages")]
source: String, 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 /// Optional output file path for decoded messages
/// Default: stdout /// Default: stdout
#[arg(short, long)] #[arg(short, long)]
@ -87,11 +97,141 @@ fn main() {
message_tools::gen_keys::generate_keys_to_file(pk_out_dir, sk_out_dir, *number) message_tools::gen_keys::generate_keys_to_file(pk_out_dir, sk_out_dir, *number)
.expect("write key files"); .expect("write key files");
} }
Commands::ReadMessages { .. } => { Commands::ReadMessages {
source,
sk_dir,
out_dir,
} => {
println!("Executing read-messages command..."); 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<UnboxedMessage> = 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<KeyPair>,
pk: &message_tools::PublicKey,
) -> Option<message_tools::StaticSecret> {
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<std::path::Path>) -> Vec<KeyPair> {
let sks_bytes: Vec<Vec<u8>> = 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::path::Path>,
) -> std::io::Result<Vec<BoxedMessage>> {
let mut all_messages: Vec<BoxedMessage> = 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<D: serde::de::DeserializeOwned>(
file_path: impl AsRef<std::path::Path>,
) -> std::io::Result<Vec<D>> {
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<D> = 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::<D>(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<ExitStatus, io::Error> // A helper function to process the Result<ExitStatus, io::Error>
@ -147,12 +287,13 @@ fn build() {
println!("Copying files..."); println!("Copying files...");
// copy relevant files // copy relevant files
let status = Command::new("cp") let status = Command::new("cp")
.args(["-R", "routes"]) .args(["-R", "-f", "routes"])
.arg(format!("{WEB_BUILD_DIR}/routes")) .arg(format!("{WEB_BUILD_DIR}/routes"))
.status(); .status();
handle_command_status(status, "cp routes"); handle_command_status(status, "cp routes");
let status = Command::new("cp") let status = Command::new("cp")
.arg("-f")
.arg(server_bin) .arg(server_bin)
.arg(format!("{WEB_BUILD_DIR}")) .arg(format!("{WEB_BUILD_DIR}"))
.status(); .status();
@ -170,6 +311,7 @@ fn build() {
handle_command_status(status, "mkdir web-build"); handle_command_status(status, "mkdir web-build");
let status = Command::new("cp") let status = Command::new("cp")
.arg("-f")
.arg("target/public_keys.json") .arg("target/public_keys.json")
.arg(format!("{WEB_BUILD_DIR}/pubkeys/")) .arg(format!("{WEB_BUILD_DIR}/pubkeys/"))
.status(); .status();

View file

@ -4,10 +4,9 @@ use chacha20poly1305::{
aead::{Aead, OsRng}, aead::{Aead, OsRng},
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use x25519_dalek::StaticSecret;
pub use x25519_dalek::{PublicKey, StaticSecret};
pub mod gen_keys; pub mod gen_keys;
pub use x25519_dalek::PublicKey;
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
pub mod wasm; pub mod wasm;
@ -36,6 +35,35 @@ pub struct BoxedMessage {
ciphertext: Vec<u8>, ciphertext: Vec<u8>,
} }
struct MyBytes(Vec<u8>);
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<PublicKey> {
let bytes: [u8; 32] = self.remote_pk.clone().try_into().map_err(to_error)?;
Ok(PublicKey::from(bytes))
}
}
#[derive(Deserialize, Serialize, Debug, Default)] #[derive(Deserialize, Serialize, Debug, Default)]
pub struct MakeKeyBytesArgs { pub struct MakeKeyBytesArgs {
pub name: String, pub name: String,

View file

@ -16,3 +16,4 @@ serde_json = "1.0.145"
message-tools = { path = "../message-tools" } message-tools = { path = "../message-tools" }
getrandom = "0.3.4" getrandom = "0.3.4"
lazy_static = "1.5.0" lazy_static = "1.5.0"
serde = { version = "1.0.228", features = ["derive"] }

View file

@ -1,8 +1,22 @@
use axum::{Router, response::Html, routing::get}; use axum::{
use std::{fs::OpenOptions, io::{BufRead, Read}, path::Path}; 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; use tower_http::services::ServeDir;
const PUBKEY_PATH: &str = "./pubkeys/public_keys.json"; const PUBKEY_PATH: &str = "./pubkeys/public_keys.json";
const MESSAGE_STORAGE: &str = "./messages";
lazy_static::lazy_static! { lazy_static::lazy_static! {
static ref PUBLIC_KEYS: Vec<Vec<u8>> = load_public_keys(PUBKEY_PATH).expect("static path"); static ref PUBLIC_KEYS: Vec<Vec<u8>> = load_public_keys(PUBKEY_PATH).expect("static path");
} }
@ -12,10 +26,7 @@ type Result<T> = std::result::Result<T, Error>;
fn load_public_keys(path: impl AsRef<Path>) -> std::io::Result<Vec<Vec<u8>>> { fn load_public_keys(path: impl AsRef<Path>) -> std::io::Result<Vec<Vec<u8>>> {
// Open the file // Open the file
let f = OpenOptions::new() let f = OpenOptions::new().read(true).write(false).open(path)?;
.read(true)
.write(false)
.open(path)?;
// Wrap it in a BufReader for efficient line-by-line reading // Wrap it in a BufReader for efficient line-by-line reading
let reader = std::io::BufReader::new(f); 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("/who", get(serve_path("./routes/who/index.html")?))
.route("/api/pubkey", get(async || select_key(&PUBLIC_KEYS))) .route("/api/pubkey", get(async || select_key(&PUBLIC_KEYS)))
.route("/contact", get(serve_path("./routes/contact/index.html")?)) .route("/contact", get(serve_path("./routes/contact/index.html")?))
.route("/api/publish", post(publish_message))
.nest_service("/routes", ServeDir::new("./routes")); .nest_service("/routes", ServeDir::new("./routes"));
// run our app with hyper, listening globally on port 3000 // run our app with hyper, listening globally on port 3000
@ -63,6 +75,56 @@ async fn main() -> Result<()> {
Ok(()) 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<BoxedMessage>) -> (StatusCode, Json<UserCreated>) {
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<Vec<u8>>) -> String { fn select_key(pks: &Vec<Vec<u8>>) -> String {
let idx = getrandom::u64().expect("random integer") as usize % pks.len(); let idx = getrandom::u64().expect("random integer") as usize % pks.len();
serde_json::to_string(&pks[idx]).expect("serialize vec of bytes") serde_json::to_string(&pks[idx]).expect("serialize vec of bytes")

View file

@ -97,7 +97,7 @@
await wasm_bindgen(); await wasm_bindgen();
await fetchPublicKey(); await fetchPublicKey();
console.log(`Server pk: ${serverPublicKey}`) console.log(`Server pk: ${serverPublicKey}`);
const form = document.getElementById("message-form"); const form = document.getElementById("message-form");
form.addEventListener("submit", (event) => { form.addEventListener("submit", (event) => {
@ -114,7 +114,7 @@
} }
const pubkeyJSON = await response.text(); const pubkeyJSON = await response.text();
serverPublicKey = JSON.parse(pubkeyJSON) serverPublicKey = JSON.parse(pubkeyJSON);
} catch (error) { } catch (error) {
console.error("Error fetching public key:", error); console.error("Error fetching public key:", error);
document.getElementById("status").textContent = document.getElementById("status").textContent =
@ -122,7 +122,7 @@
} }
} }
function submit() { async function submit() {
console.log("begin"); console.log("begin");
const user = document.getElementById("name").value; const user = document.getElementById("name").value;
const password = document.getElementById("password").value; const password = document.getElementById("password").value;
@ -146,13 +146,35 @@
remote_pk: [...serverPublicKey], remote_pk: [...serverPublicKey],
plaintext: String(message), plaintext: String(message),
}; };
console.log("Boxed preflight: ", JSON.stringify(boxMessageArgs)) console.log("Boxed preflight: ", JSON.stringify(boxMessageArgs));
const boxed = box_message(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);
console.log("End"); 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!"); alert("Sent!");
location.reload(); location.reload();
} catch (error) {
console.error("Error:", error);
alert("Failed to send message: " + error.message);
}
console.log("End");
} }
run(); run();