Files
signer/src/main.rs
T

494 lines
15 KiB
Rust

//! signer — attended Nostr signing daemon.
//!
//! Port of `main.c`. Single binary that holds signing key material in
//! locked memory and signs on request via JSON-RPC 2.0.
use clap::{Parser, Subcommand};
use signer::{
alg_cache::AlgorithmKeyCache,
dispatcher::DispatcherContext,
key_store::KeyStore,
mnemonic::MnemonicState,
role_table::{RoleCurve, RolePurpose, RoleTable},
server::{AuthMode, ListenMode, ServerContext},
SignerError,
};
/// Command-line arguments.
#[derive(Parser, Debug)]
#[command(name = "signer", version = signer::VERSION, about = "Attended Nostr signing daemon")]
struct Cli {
/// Socket name (abstract namespace, without @ prefix)
#[arg(long, short = 'n', alias = "name")]
socket_name: Option<String>,
/// Listen mode: unix, stdio, qrexec, tcp:HOST:PORT, http:HOST:PORT
///
/// If omitted in interactive (TUI) mode, the transport selection menu
/// is shown. If omitted in non-interactive mode, defaults to unix.
#[arg(long, short = 'l')]
listen: Option<String>,
/// Register a named path-role non-interactively (repeatable)
#[arg(long, value_name = "SPEC")]
register_role: Vec<String>,
/// Auth envelope policy: off, optional, required
#[arg(long, short = 'a', default_value = "off")]
auth: String,
/// Read mnemonic from stdin (one line) at startup
#[arg(long)]
mnemonic_stdin: bool,
/// Read mnemonic from inherited fd N (one line) at startup
#[arg(long, value_name = "N")]
mnemonic_fd: Option<i32>,
/// Allow unlocked memory (development only)
#[arg(long)]
allow_unlocked_memory: bool,
/// Accept qrexec_source preamble on unix connections (bridge mode)
#[arg(long)]
bridge_source_trusted: bool,
/// OTP pad directory
#[arg(long, value_name = "DIR")]
otp_pad_dir: Option<String>,
/// OTP pad checksum or prefix
#[arg(long, value_name = "SPEC")]
otp_pad: Option<String>,
/// Allow pads on qvm-block (blkback) devices
#[arg(long)]
otp_allow_blkback: bool,
/// Subcommand
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(Subcommand, Debug)]
enum Commands {
/// Send a JSON-RPC request to a running signer
Client {
/// JSON-RPC request string, or "-" to read from stdin
request: String,
},
/// Stateless qrexec → unix-socket relay
Bridge {
/// Target socket name
#[arg(long)]
to: Option<String>,
},
/// List running signer abstract sockets
List,
}
fn main() {
let cli = Cli::parse();
// Handle subcommands first (no mnemonic needed)
if let Some(cmd) = &cli.command {
match cmd {
Commands::Client { request } => {
std::process::exit(client_main(request, &cli));
}
Commands::Bridge { to } => {
std::process::exit(bridge_main(to.as_deref(), &cli));
}
Commands::List => {
std::process::exit(list_main());
}
}
}
// Server mode
match server_main(&cli) {
Ok(()) => {}
Err(e) => {
eprintln!("signer: {}", e);
std::process::exit(1);
}
}
}
/// Server main.
///
/// Two paths:
/// - **Interactive (TUI)**: mnemonic → roles → transport → server → main
/// are all handled by the ratatui `App` (setup screens + main screen).
/// - **Non-interactive** (`--mnemonic-stdin` / `--mnemonic-fd` /
/// `--register-role` / `--listen`): mnemonic and roles are set up here,
/// then the App runs with `listen_override` so it goes straight to the
/// main screen. Headless modes (stdio/qrexec/tcp/http) never show a TUI.
fn server_main(cli: &Cli) -> Result<(), SignerError> {
println!("signer {}", signer::VERSION);
if cli.allow_unlocked_memory {
signer::secure_mem::allow_unlocked();
}
let interactive = !cli.mnemonic_stdin && cli.mnemonic_fd.is_none();
let listen_mode = cli.listen.as_deref().map(parse_listen_mode);
// Non-interactive (--mnemonic-stdin / --mnemonic-fd) always runs headless,
// even for Unix mode — the TUI needs a real TTY.
if !interactive {
return run_headless(cli, listen_mode.unwrap_or(ListenMode::Unix));
}
// Headless modes never show a TUI.
if let Some(mode) = listen_mode {
if mode != ListenMode::Unix {
return run_headless(cli, mode);
}
}
let socket_name = cli
.socket_name
.clone()
.unwrap_or_else(|| {
signer::socket_name::socket_name_random().unwrap_or_default()
});
let auth_mode = parse_auth_mode(&cli.auth);
if interactive {
// ── Fully interactive: App handles everything ────────────
// The seed entry popup is always shown; if --listen was given
// (Unix only — non-Unix modes are headless above), the transport
// is pre-selected on the main screen. Pass the raw --listen string
// so the App can adopt an explicit tcp:/http: bind address.
let mut app = signer::tui::App::new(
RoleTable::new(),
MnemonicState::new(),
KeyStore::new(),
AlgorithmKeyCache::new(),
socket_name,
0,
auth_mode,
cli.listen.clone(),
);
let mut terminal = ratatui::init();
let result = app.run(&mut terminal);
ratatui::restore();
result.map_err(|e| SignerError::IoFailed(e.to_string()))
} else {
// Unreachable: non-interactive modes return headless above.
Ok(())
}
}
/// Run a headless server (stdio, qrexec, tcp, http) — no TUI.
fn run_headless(cli: &Cli, listen_mode: ListenMode) -> Result<(), SignerError> {
let mut mnemonic = MnemonicState::new();
if cli.mnemonic_stdin {
let mut input = String::new();
std::io::stdin()
.read_line(&mut input)
.map_err(|e| SignerError::IoFailed(e.to_string()))?;
let phrase = input.trim().to_string();
mnemonic.load(&phrase)?;
} else if let Some(fd) = cli.mnemonic_fd {
use std::io::Read;
use std::os::unix::io::FromRawFd;
let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
let mut input = String::new();
file.read_to_string(&mut input)
.map_err(|e| SignerError::IoFailed(e.to_string()))?;
let phrase = input.trim().to_string();
mnemonic.load(&phrase)?;
}
let mut role_table = RoleTable::new();
if !cli.register_role.is_empty() {
for spec in &cli.register_role {
register_role_from_spec(&mut role_table, spec)?;
}
} else {
role_table
.register_role_path(
"main",
"m/44'/1237'/0'/0/0",
RolePurpose::Nostr,
RoleCurve::Secp256k1,
-1, -1, -1, &[],
)
.map_err(|e| SignerError::Internal(e.to_string()))?;
}
let mut key_store = KeyStore::new();
let mut alg_key_cache = AlgorithmKeyCache::new();
key_store.derive_all(&mut role_table, &mnemonic)?;
let socket_name = cli
.socket_name
.clone()
.unwrap_or_else(|| "headless".to_string());
let auth_mode = parse_auth_mode(&cli.auth);
let mut server = ServerContext::new(&socket_name, listen_mode, auth_mode);
server.start()?;
if matches!(listen_mode, ListenMode::Stdio | ListenMode::Qrexec) {
let mut dispatcher = DispatcherContext {
role_table: &mut role_table,
mnemonic: &mnemonic,
key_store: &mut key_store,
alg_key_cache: &mut alg_key_cache,
};
let _ = server.handle_one(&mut dispatcher);
server.stop();
} else {
// Tcp / Http poll loop
while server.running {
let mut dispatcher = DispatcherContext {
role_table: &mut role_table,
mnemonic: &mnemonic,
key_store: &mut key_store,
alg_key_cache: &mut alg_key_cache,
};
match server.handle_one(&mut dispatcher) {
Ok(Some(_activity)) => {}
Ok(None) => {
std::thread::sleep(std::time::Duration::from_millis(50));
}
Err(e) => {
eprintln!("server error: {}", e);
break;
}
}
}
}
server.stop();
key_store.wipe();
alg_key_cache.wipe();
mnemonic.unload();
Ok(())
}
/// Client subcommand: send a JSON-RPC request to a running signer.
fn client_main(request: &str, cli: &Cli) -> i32 {
use std::io::Read;
let socket_name = cli.socket_name.as_deref().unwrap_or("signer01");
// Discover single socket if not explicit
let socket_name = if cli.socket_name.is_some() {
socket_name.to_string()
} else {
signer::socket_name::discover_single_socket().unwrap_or_else(|_| socket_name.to_string())
};
let mut stream = match signer::transport::connect_abstract_unix(&socket_name) {
Ok(s) => s,
Err(e) => {
eprintln!("Failed to connect to {}: {}", socket_name, e);
return 1;
}
};
// Read from stdin if "-"
let request = if request == "-" {
let mut input = String::new();
if std::io::stdin().read_to_string(&mut input).is_err() {
eprintln!("Failed to read request from stdin");
return 1;
}
input
} else {
request.to_string()
};
// Send framed request
if signer::transport::send_framed(&mut stream, &request).is_err() {
eprintln!("Failed to send request");
return 1;
}
// Receive framed response
match signer::transport::recv_framed(&mut stream) {
Ok(response) => {
println!("{}", response);
0
}
Err(e) => {
eprintln!("Failed to receive response: {}", e);
1
}
}
}
/// Bridge subcommand: stateless qrexec → unix-socket relay.
fn bridge_main(to: Option<&str>, cli: &Cli) -> i32 {
let target = to.unwrap_or("signer01");
let target = if cli.socket_name.is_some() {
target.to_string()
} else {
signer::socket_name::discover_single_socket().unwrap_or_else(|_| target.to_string())
};
// Read source qube from qrexec environment
let source_qube = std::env::var("QREXEC_REMOTE_DOMAIN").unwrap_or_default();
// Connect to persistent signer via abstract socket
let mut stream = match signer::transport::connect_abstract_unix(&target) {
Ok(s) => s,
Err(e) => {
eprintln!("bridge: cannot connect to {}: {}", target, e);
return 1;
}
};
// Send source-qube preamble
let preamble = format!(r#"{{"qrexec_source":"{}"}}"#, source_qube);
if signer::transport::send_framed(&mut stream, &preamble).is_err() {
eprintln!("bridge: failed to send preamble");
return 1;
}
// Read one framed request from stdin and forward
let mut stdin = std::io::stdin();
let request = match signer::transport::recv_framed(&mut stdin) {
Ok(r) => r,
Err(e) => {
eprintln!("bridge: failed to read request from stdin: {}", e);
return 1;
}
};
if signer::transport::send_framed(&mut stream, &request).is_err() {
eprintln!("bridge: failed to forward request");
return 1;
}
// Relay response to stdout
match signer::transport::recv_framed(&mut stream) {
Ok(response) => {
let mut stdout = std::io::stdout();
if signer::transport::send_framed(&mut stdout, &response).is_err() {
eprintln!("bridge: failed to relay response");
return 1;
}
0
}
Err(e) => {
eprintln!("bridge: failed to read response: {}", e);
1
}
}
}
/// List subcommand: list running signer sockets.
fn list_main() -> i32 {
let sockets = signer::socket_name::list_sockets();
if sockets.is_empty() {
println!("(none)");
} else {
for name in &sockets {
println!("{}", name);
}
}
0
}
/// Parse a --register-role spec: `<name>:<curve>:<path-template>`
fn register_role_from_spec(
role_table: &mut RoleTable,
spec: &str,
) -> Result<(), SignerError> {
let parts: Vec<&str> = spec.splitn(3, ':').collect();
if parts.len() != 3 {
return Err(SignerError::InvalidInput);
}
let name = parts[0];
let curve_str = parts[1];
let path_token = parts[2];
if name.is_empty() || path_token.is_empty() {
return Err(SignerError::InvalidInput);
}
// Resolve curve
let curve = if curve_str.is_empty() {
// Auto-detect from path
match signer::role_table::purpose_from_path(path_token) {
RolePurpose::Ssh => RoleCurve::Ed25519,
RolePurpose::Age => RoleCurve::X25519,
RolePurpose::PqSig => {
if path_token.starts_with("m/44'/102004'") {
RoleCurve::SlhDsa128s
} else {
RoleCurve::MlDsa65
}
}
RolePurpose::PqKem => RoleCurve::MlKem768,
_ => RoleCurve::Secp256k1,
}
} else {
RoleCurve::from_str(curve_str)
};
if curve == RoleCurve::Unknown {
return Err(SignerError::InvalidInput);
}
let purpose = signer::role_table::purpose_from_path(path_token);
// Parse path template
let (template, range_lo, range_hi, allowed_indices) =
signer::role_table::parse_path_template(path_token)
.map_err(|_| SignerError::InvalidInput)?;
role_table
.register_role_path(
name,
&template,
purpose,
curve,
range_lo,
range_hi,
-1, // no default index
&allowed_indices,
)
.map_err(|e| SignerError::Internal(e.to_string()))?;
Ok(())
}
/// Parse listen mode from --listen string.
fn parse_listen_mode(s: &str) -> ListenMode {
if s.starts_with("tcp:") {
ListenMode::Tcp
} else if s.starts_with("http:") {
ListenMode::Http
} else {
match s {
"unix" => ListenMode::Unix,
"stdio" => ListenMode::Stdio,
"qrexec" => ListenMode::Qrexec,
_ => ListenMode::Unix,
}
}
}
/// Parse auth mode from --auth string.
fn parse_auth_mode(s: &str) -> AuthMode {
match s {
"off" => AuthMode::Off,
"optional" => AuthMode::Optional,
"required" => AuthMode::Required,
_ => AuthMode::Off,
}
}