Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions crates/epix-server/src/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ pub fn is_action(name: &str) -> bool {
| "siteList"
| "siteDelete"
| "siteDownload"
| "siteSignMessage"
| "dbRebuild"
| "dbQuery"
| "importBundle"
Expand All @@ -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<String, String> {
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<String, String> {
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],
Expand Down Expand Up @@ -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 <address> [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 {
Expand Down
3 changes: 2 additions & 1 deletion crates/epix-server/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ async fn main() {
println!(" epix-server <action> [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!();
Expand Down
30 changes: 30 additions & 0 deletions crates/epix-ui/src/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ const ADMIN_COMMANDS: &[&str] = &[
"siteList",
"sitePause",
"siteRecoverPrivatekey",
"siteSignMessage",
"siteReload",
"siteResume",
"siteSetAutodownloadBigfileLimit",
Expand Down Expand Up @@ -531,6 +532,7 @@ fn default_commands() -> Vec<Arc<dyn WsCommand>> {
Arc::new(AesDecrypt),
Arc::new(EcdsaVerify),
Arc::new(EcdsaSign),
Arc::new(SiteSignMessage),
Arc::new(RecordSign),
// Chain: Vrf randomness + XidResolver.
Arc::new(VrfGetBeacon),
Expand Down Expand Up @@ -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<Value, String> {
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
Expand Down