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