From 2e5d81a7df5483a48045b6d6c0035bb31da6c755 Mon Sep 17 00:00:00 2001 From: Mud <44410798+MudDev@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:33:21 -0600 Subject: [PATCH] Add siteSignMessage so proving xite ownership never exposes the key Answering an ownership challenge previously meant passing the xite's private key as a command argument, which puts it in shell history and makes it visible to any local process through ps. siteSignMessage signs an arbitrary message with the bound xite's own key instead. The CLI resolves the key in the order that keeps it out of reach: a running node signs with the key it already holds, then this data dir's stored key, and only if neither has it does the command ask, on stdin with terminal echo off. The key is never an argument. The websocket command is admin gated. Signing as the xite is the same authority that signs its content, so an ordinary page must never be able to ask for it. --- crates/epix-server/src/actions.rs | 76 +++++++++++++++++++++++++++++++ crates/epix-server/src/main.rs | 3 +- crates/epix-ui/src/command.rs | 30 ++++++++++++ 3 files changed, 108 insertions(+), 1 deletion(-) diff --git a/crates/epix-server/src/actions.rs b/crates/epix-server/src/actions.rs index 1d92079..b95b7e6 100644 --- a/crates/epix-server/src/actions.rs +++ b/crates/epix-server/src/actions.rs @@ -22,6 +22,7 @@ pub fn is_action(name: &str) -> bool { | "siteList" | "siteDelete" | "siteDownload" + | "siteSignMessage" | "dbRebuild" | "dbQuery" | "importBundle" @@ -45,6 +46,41 @@ pub async fn run(action: &str, args: &[String], data_root: &std::path::Path, ver } } +/// Read one line from stdin with a prompt on stderr, so the value itself can +/// still be piped or redirected. +fn prompt_line(prompt: &str) -> Result { + use std::io::{BufRead, Write}; + eprint!("{prompt}"); + std::io::stderr().flush().ok(); + let mut line = String::new(); + std::io::stdin().lock().read_line(&mut line).map_err(|error| error.to_string())?; + Ok(line.trim().to_string()) +} + +/// Like [`prompt_line`], with terminal echo off while typing so the secret +/// never appears on screen. Falls back to a visible read when stdin is not a +/// terminal (a pipe), which is still better than an argument: nothing is +/// recorded in shell history or exposed through `ps`. +fn prompt_secret(prompt: &str) -> Result { + let echo_off = std::process::Command::new("stty") + .arg("-echo") + .stdin(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::null()) + .status() + .map(|status| status.success()) + .unwrap_or(false); + let read = prompt_line(prompt); + if echo_off { + let _ = std::process::Command::new("stty") + .arg("echo") + .stdin(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::null()) + .status(); + eprintln!(); + } + read +} + async fn dispatch( action: &str, args: &[String], @@ -290,6 +326,46 @@ async fn dispatch( Ok(()) } + // Sign an arbitrary message AS THE XITE, proving control of its + // address (answering an ownership challenge, say). Resolution order + // keeps the key out of reach: a running node signs with the key it + // already holds, then this data dir's stored key, and only if neither + // has it does it ask - on stdin, never as an argument, because + // arguments land in shell history and are visible in `ps`. + "siteSignMessage" => { + let [address, rest @ ..] = args else { + return Err("usage: siteSignMessage
[message]".into()); + }; + let message = match rest.first() { + Some(m) if !m.is_empty() => m.clone(), + _ => prompt_line("Message to sign: ")?, + }; + let params = serde_json::json!({ "message": message }); + match admin_call(data_root, "siteSignMessage", Some(address), params).await { + Ok(Some(reply)) => { + println!("{}", reply.as_str().unwrap_or_default()); + return Ok(()); + } + Ok(None) => { + let state = open_state(data_root, version).await; + if let Some(key) = state.xite_privatekey(address).await { + println!("{}", epix_crypt::sign(&message, &key).map_err(|e| e.to_string())?); + return Ok(()); + } + } + // Node is up but holds no key for this xite: ask for one. + Err(_) => {} + } + let key = prompt_secret( + "Xite private key (hidden, not saved to shell history): ", + )?; + if key.is_empty() { + return Err("no private key given".into()); + } + println!("{}", epix_crypt::sign(&message, &key).map_err(|e| e.to_string())?); + Ok(()) + } + // --- key operations (no node, no data dir) ------------------------- "cryptSign" => { let [message, privatekey] = args else { diff --git a/crates/epix-server/src/main.rs b/crates/epix-server/src/main.rs index 385f9d2..a3b87c4 100644 --- a/crates/epix-server/src/main.rs +++ b/crates/epix-server/src/main.rs @@ -56,7 +56,8 @@ async fn main() { println!(" epix-server [args...]"); println!(); println!("Actions: siteCreate, siteSign, sitePublish, siteVerify, siteList,"); - println!(" siteDelete, siteDownload, dbRebuild, dbQuery, importBundle,"); + println!(" siteDelete, siteDownload, siteSignMessage, dbRebuild, dbQuery,"); + println!(" importBundle,"); println!(" cryptSign, cryptVerify, cryptGetPrivatekey,"); println!(" cryptPrivatekeyToAddress, peerPing, siteCmd"); println!(); diff --git a/crates/epix-ui/src/command.rs b/crates/epix-ui/src/command.rs index db115e0..ff8aa05 100644 --- a/crates/epix-ui/src/command.rs +++ b/crates/epix-ui/src/command.rs @@ -65,6 +65,7 @@ const ADMIN_COMMANDS: &[&str] = &[ "siteList", "sitePause", "siteRecoverPrivatekey", + "siteSignMessage", "siteReload", "siteResume", "siteSetAutodownloadBigfileLimit", @@ -531,6 +532,7 @@ fn default_commands() -> Vec> { Arc::new(AesDecrypt), Arc::new(EcdsaVerify), Arc::new(EcdsaSign), + Arc::new(SiteSignMessage), Arc::new(RecordSign), // Chain: Vrf randomness + XidResolver. Arc::new(VrfGetBeacon), @@ -2366,6 +2368,34 @@ impl WsCommand for EcdsaSign { } } +/// `siteSignMessage(message)` - sign an arbitrary message with the BOUND +/// XITE's own private key, proving control of the xite address itself. +/// +/// This is how a xite owner answers an ownership challenge (a directory +/// listing claim, say) without their key ever being typed, pasted, or passed +/// on a command line where a shell would record it. The key never leaves the +/// node: only the signature comes back. +/// +/// Admin-gated on purpose. Signing as the xite is exactly the authority that +/// signs its content, so an ordinary page must never be able to ask for it. +struct SiteSignMessage; +#[async_trait] +impl WsCommand for SiteSignMessage { + fn name(&self) -> &'static str { + "siteSignMessage" + } + async fn handle(&self, s: &WsSession, p: &Value) -> Result { + let message = arg_str(p, "message", 0).ok_or("siteSignMessage: message required")?; + let address = s.address()?.to_string(); + let key = s + .state + .xite_privatekey(&address) + .await + .ok_or("No stored private key for this xite on this node")?; + Ok(Value::from(epix_crypt::sign(message, &key)?)) + } +} + /// `recordSign(record)` - sign one merge-file post record (a `posts.json` /// entry) with the current user's CERT-AWARE auth key, over the canonical /// record payload. Returns the record with `sign` embedded. Canonicalization