494 lines
15 KiB
Rust
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,
|
|
}
|
|
}
|
|
|