use std::io::IsTerminal;
use rand::seq::SliceRandom;
use serde::Serialize;
use void_core::collab::Identity;
use crate::context::{
identity_exists, load_identity_cached, load_public_identity, save_identity,
validate_identity_username,
};
use crate::output::{run_command, CliError, CliOptions};
#[derive(Debug)]
pub struct IdentityArgs {
pub subcommand: IdentitySubcommand,
}
#[derive(Debug)]
pub enum IdentitySubcommand {
Init {
force: bool,
username: Option<String>,
},
Show,
Export,
Recover {
force: bool,
username: Option<String>,
},
Lock,
Unlock,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct InitOutput {
pub created: bool,
pub path: String,
pub signing_pubkey: String,
pub recipient_pubkey: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub nostr_pubkey: Option<String>,
pub mnemonic: String,
pub username: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub signal: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ShowOutput {
pub username: Option<String>,
pub signing_pubkey: String,
pub recipient_pubkey: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub nostr_pubkey: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ExportOutput {
pub identity: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RecoverOutput {
pub recovered: bool,
pub path: String,
pub signing_pubkey: String,
pub recipient_pubkey: String,
pub username: String,
}
#[derive(Debug, Serialize)]
pub struct LockOutput {
pub locked: bool,
}
#[derive(Debug, Serialize)]
pub struct UnlockOutput {
pub unlocked: bool,
}
pub fn run(args: IdentityArgs, opts: &CliOptions) -> Result<(), CliError> {
match args.subcommand {
IdentitySubcommand::Init {
force,
username,
} => run_init(force, username, opts),
IdentitySubcommand::Show => run_show(opts),
IdentitySubcommand::Export => run_export(opts),
IdentitySubcommand::Recover { force, username } => run_recover(force, username, opts),
IdentitySubcommand::Lock => run_lock(opts),
IdentitySubcommand::Unlock => run_unlock(opts),
}
}
fn run_init(
force: bool,
username: Option<String>,
opts: &CliOptions,
) -> Result<(), CliError> {
run_command("identity init", opts, |ctx| {
ctx.progress("Checking for existing identity...");
if identity_exists() {
if !force {
return Err(CliError::conflict(
"Identity already exists. Use --force to overwrite.",
));
}
ctx.info("Overwriting existing identity (--force).");
}
let is_interactive = !ctx.use_json() && std::io::stdin().is_terminal();
let username = if let Some(u) = username.clone() {
u
} else if is_interactive {
let system_user = std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.unwrap_or_else(|_| "user".to_string());
dialoguer::Input::new()
.with_prompt("Choose your username")
.default(system_user)
.validate_with(|input: &String| -> std::result::Result<(), String> {
validate_identity_username(input).map_err(|e| e.to_string())
})
.interact_text()
.map_err(|e| CliError::internal(format!("prompt failed: {e}")))?
} else {
std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.unwrap_or_else(|_| "user".to_string())
};
validate_identity_username(&username)?;
let (email, signal) = if is_interactive {
let email: String = dialoguer::Input::new()
.with_prompt("Email (optional, press ENTER to skip)")
.allow_empty(true)
.interact_text()
.map_err(|e| CliError::internal(format!("prompt failed: {e}")))?;
let signal: String = dialoguer::Input::new()
.with_prompt("Signal (optional, press ENTER to skip)")
.allow_empty(true)
.interact_text()
.map_err(|e| CliError::internal(format!("prompt failed: {e}")))?;
let email = if email.is_empty() { None } else { Some(email) };
let signal = if signal.is_empty() { None } else { Some(signal) };
(email, signal)
} else {
(None, None)
};
ctx.progress("Generating new identity with seed phrase...");
let (identity, mnemonic) = Identity::generate_with_mnemonic()
.map_err(|e| CliError::internal(format!("failed to generate identity: {}", e)))?;
ctx.info("Choose a PIN to protect your identity keys.");
let pin = prompt_pin_with_confirm()?;
ctx.progress("Encrypting and saving identity...");
save_identity(
&identity,
&username,
&pin,
email.as_deref(),
signal.as_deref(),
)?;
let signing_pubkey_hex = identity.signing_pubkey().to_hex();
crate::keyring::cache_keys(&signing_pubkey_hex, &identity);
let path = crate::context::get_identity_dir().display().to_string();
let signing_pubkey = signing_pubkey_hex;
let recipient_pubkey = identity.recipient_pubkey().to_hex();
let nostr_pubkey = identity.nostr_pubkey().map(|k| k.to_hex());
if !ctx.use_json() {
eprintln!();
eprintln!("Identity created for '{}'", username);
eprintln!();
eprintln!(
" Signing key (Ed25519) \u{2014} proves commits are yours"
);
eprintln!(" {}", signing_pubkey);
eprintln!();
eprintln!(
" Encryption key (X25519) \u{2014} lets others share repos with you"
);
eprintln!(" {}", recipient_pubkey);
if let Some(ref npub) = nostr_pubkey {
eprintln!();
eprintln!(
" Nostr key (Secp256k1) \u{2014} links to your Nostr identity"
);
eprintln!(" {}", npub);
}
eprintln!();
eprintln!(" Keys saved to: {}", path);
eprintln!();
let grid = format_mnemonic_grid(&mnemonic);
eprint!("{}", grid);
if std::io::stdin().is_terminal() {
run_mnemonic_verification(&mnemonic, &username, &signing_pubkey)?;
}
}
Ok(InitOutput {
created: true,
path,
signing_pubkey,
recipient_pubkey,
nostr_pubkey,
mnemonic,
username,
email,
signal,
})
})
}
fn run_show(opts: &CliOptions) -> Result<(), CliError> {
run_command("identity show", opts, |ctx| {
ctx.progress("Loading identity...");
let (username, signing_pubkey, recipient_pubkey, nostr_pubkey) = load_public_identity()?;
let signing_hex = hex::encode(signing_pubkey.as_bytes());
let recipient_hex = hex::encode(recipient_pubkey.as_bytes());
let nostr_hex = nostr_pubkey.as_ref().map(|k| k.to_hex());
if !ctx.use_json() {
if let Some(ref name) = username {
ctx.info(format!("Username: {}", name));
}
ctx.info(format!("Signing pubkey: {}", signing_hex));
ctx.info(format!("Recipient pubkey: {}", recipient_hex));
if let Some(ref npub) = nostr_hex {
ctx.info(format!("Nostr pubkey: {}", npub));
}
}
Ok(ShowOutput {
username,
signing_pubkey: signing_hex,
recipient_pubkey: recipient_hex,
nostr_pubkey: nostr_hex,
})
})
}
fn run_export(opts: &CliOptions) -> Result<(), CliError> {
run_command("identity export", opts, |ctx| {
ctx.progress("Loading identity...");
let (username, signing_pubkey, recipient_pubkey, nostr_pubkey) = load_public_identity()?;
let signing_hex = hex::encode(signing_pubkey.as_bytes());
let recipient_hex = hex::encode(recipient_pubkey.as_bytes());
let mut identity_string = if let Some(ref name) = username {
format!(
"void://{}@ed25519:{}/x25519:{}",
name, signing_hex, recipient_hex
)
} else {
format!("void://ed25519:{}/x25519:{}", signing_hex, recipient_hex)
};
if let Some(nostr) = nostr_pubkey {
identity_string.push_str(&format!("/nostr:{}", nostr.to_hex()));
}
if !ctx.use_json() {
ctx.info(identity_string.clone());
}
Ok(ExportOutput {
identity: identity_string,
})
})
}
fn run_recover(force: bool, username: Option<String>, opts: &CliOptions) -> Result<(), CliError> {
run_command("identity recover", opts, |ctx| {
ctx.progress("Recovering identity from seed phrase...");
if identity_exists() {
if force {
ctx.info("Overwriting existing identity (--force).");
} else {
return Err(CliError::conflict(
"Identity already exists. Use --force to overwrite.",
));
}
}
ctx.info("Enter your 24-word recovery phrase:");
let mnemonic = rpassword::prompt_password("Recovery phrase: ")
.map_err(|e| CliError::io_error(format!("failed to read recovery phrase: {}", e)))?;
let mnemonic = mnemonic.trim().to_string();
if mnemonic.is_empty() {
return Err(CliError::invalid_args("recovery phrase must not be empty"));
}
let seed = void_core::collab::mnemonic_to_seed(&mnemonic)
.map_err(|e| CliError::invalid_args(format!("invalid recovery phrase: {}", e)))?;
let identity = Identity::from_seed(&seed)
.map_err(|e| CliError::internal(format!("failed to derive identity: {}", e)))?;
let username = username.clone().unwrap_or_else(|| {
std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.unwrap_or_else(|_| "user".to_string())
});
validate_identity_username(&username)?;
ctx.info("Choose a PIN to protect your recovered identity keys.");
let pin = prompt_pin_with_confirm()?;
ctx.progress("Encrypting and saving recovered identity...");
save_identity(&identity, &username, &pin, None, None)?;
let signing_pubkey_hex = identity.signing_pubkey().to_hex();
crate::keyring::cache_keys(&signing_pubkey_hex, &identity);
let path = crate::context::get_identity_dir().display().to_string();
let signing_pubkey = signing_pubkey_hex;
let recipient_pubkey = identity.recipient_pubkey().to_hex();
if !ctx.use_json() {
ctx.info(format!("Identity recovered for '{}'", username));
ctx.info(format!("Signing pubkey: {}", signing_pubkey));
ctx.info(format!("Recipient pubkey: {}", recipient_pubkey));
ctx.info(format!("Keys saved to: {}", path));
}
Ok(RecoverOutput {
recovered: true,
path,
signing_pubkey,
recipient_pubkey,
username,
})
})
}
fn run_lock(opts: &CliOptions) -> Result<(), CliError> {
run_command("identity lock", opts, |ctx| {
let (_, signing_pubkey, _, _) = load_public_identity()?;
let signing_hex = signing_pubkey.to_hex();
let cleared = crate::keyring::clear_cached_keys(&signing_hex);
if !ctx.use_json() {
if cleared {
ctx.info("Identity keys cleared from OS keyring.");
} else {
ctx.info("No cached keys found in OS keyring.");
}
}
Ok(LockOutput { locked: cleared })
})
}
fn run_unlock(opts: &CliOptions) -> Result<(), CliError> {
run_command("identity unlock", opts, |ctx| {
ctx.progress("Unlocking identity...");
let _identity = load_identity_cached()?;
if !ctx.use_json() {
ctx.info("Identity keys cached in OS keyring.");
}
Ok(UnlockOutput { unlocked: true })
})
}
fn format_mnemonic_grid(mnemonic: &str) -> String {
let words: Vec<&str> = mnemonic.split_whitespace().collect();
let rows = 6;
let cols = 4;
let mut col_widths = [0usize; 4];
for col in 0..cols {
for row in 0..rows {
let idx = col * rows + row;
if idx < words.len() {
col_widths[col] = col_widths[col].max(words[idx].len());
}
}
}
let mut lines = Vec::new();
for row in 0..rows {
let mut parts = Vec::new();
for col in 0..cols {
let idx = col * rows + row;
if idx < words.len() {
let num = idx + 1;
let word = words[idx];
parts.push(format!("{:>2}. {:<width$}", num, word, width = col_widths[col]));
}
}
lines.push(format!("│ {} │", parts.join(" ")));
}
let inner_width = if let Some(first) = lines.first() {
first.chars().count() - 2
} else {
56
};
let title = "RECOVERY PHRASE — 24 WORDS";
let title_pad = inner_width.saturating_sub(title.len());
let title_left = title_pad / 2;
let title_right = title_pad - title_left;
let warning1 = "Write these words down and store them safely.";
let warning1_pad_left = 1;
let warning1_pad_right = inner_width.saturating_sub(warning1.len() + warning1_pad_left);
let warning2 = "This is the ONLY way to recover your identity.";
let warning2_pad_left = 1;
let warning2_pad_right = inner_width.saturating_sub(warning2.len() + warning2_pad_left);
let mut output = String::new();
output.push_str(&format!("┌{}┐\n", "─".repeat(inner_width)));
output.push_str(&format!(
"│{}{}{}│\n",
" ".repeat(title_left),
title,
" ".repeat(title_right)
));
output.push_str(&format!("│{}│\n", " ".repeat(inner_width)));
for line in &lines {
output.push_str(line);
output.push('\n');
}
output.push_str(&format!("│{}│\n", " ".repeat(inner_width)));
output.push_str(&format!(
"│{}{}{}│\n",
" ".repeat(warning1_pad_left),
warning1,
" ".repeat(warning1_pad_right)
));
output.push_str(&format!(
"│{}{}{}│\n",
" ".repeat(warning2_pad_left),
warning2,
" ".repeat(warning2_pad_right)
));
output.push_str(&format!("└{}┘\n", "─".repeat(inner_width)));
output
}
fn run_mnemonic_verification(
mnemonic: &str,
username: &str,
signing_pubkey: &str,
) -> Result<(), CliError> {
use console::{Key, Term};
let words: Vec<&str> = mnemonic.split_whitespace().collect();
if words.len() != 24 {
return Ok(()); }
eprintln!();
eprint!("Press ENTER to continue, or [p] to print recovery phrase...");
let term = Term::stderr();
match term.read_key() {
Ok(Key::Char('p') | Key::Char('P')) => {
eprintln!();
print_recovery_phrase(mnemonic, username, signing_pubkey)?;
}
_ => {
eprintln!(); }
}
let mut rng = rand::thread_rng();
let mut positions: Vec<usize> = (0..24).collect();
positions.shuffle(&mut rng);
let check_positions = &positions[..2];
let max_attempts = 3;
for &pos in check_positions {
let expected = words[pos];
let mut verified = false;
for attempt in 0..max_attempts {
let answer: String = dialoguer::Input::new()
.with_prompt(format!("Verify word #{}", pos + 1))
.interact_text()
.map_err(|e| CliError::internal(format!("prompt failed: {e}")))?;
if answer.trim().eq_ignore_ascii_case(expected) {
verified = true;
break;
}
let remaining = max_attempts - attempt - 1;
if remaining > 0 {
eprintln!(
"Incorrect. {} {} remaining.",
remaining,
if remaining == 1 { "attempt" } else { "attempts" }
);
}
}
if !verified {
eprintln!("Warning: verification failed. Make sure you have the correct phrase written down.");
let grid = format_mnemonic_grid(mnemonic);
eprint!("{}", grid);
return Ok(());
}
}
eprintln!("✓ Recovery phrase confirmed.");
Ok(())
}
fn discover_printers() -> Vec<String> {
let output = match std::process::Command::new("lpstat")
.arg("-a")
.output()
{
Ok(o) if o.status.success() => o,
_ => return Vec::new(),
};
let stdout = String::from_utf8_lossy(&output.stdout);
stdout
.lines()
.filter_map(|line| {
line.split_whitespace().next().map(String::from)
})
.collect()
}
fn print_recovery_phrase(mnemonic: &str, username: &str, signing_pubkey: &str) -> Result<(), CliError> {
let words: Vec<&str> = mnemonic.split_whitespace().collect();
let rows = 6;
let cols = 4;
let mut col_widths = [0usize; 4];
for col in 0..cols {
for row in 0..rows {
let idx = col * rows + row;
if idx < words.len() {
col_widths[col] = col_widths[col].max(words[idx].len());
}
}
}
let date = chrono::Local::now().format("%Y-%m-%d").to_string();
let key_short = if signing_pubkey.len() >= 8 {
format!("{}...{}", &signing_pubkey[..4], &signing_pubkey[signing_pubkey.len() - 4..])
} else {
signing_pubkey.to_string()
};
let sep = "═".repeat(47);
let mut doc = String::new();
doc.push_str(&format!("{}\n", sep));
doc.push_str(" VOID IDENTITY — RECOVERY PHRASE\n");
doc.push_str(&format!("{}\n", sep));
doc.push_str(&format!(" Generated: {}\n", date));
doc.push_str(&format!(" Username: {}\n", username));
doc.push_str(&format!(" Key ID: {}\n", key_short));
doc.push('\n');
for row in 0..rows {
let mut parts = Vec::new();
for col in 0..cols {
let idx = col * rows + row;
if idx < words.len() {
parts.push(format!("{:>2}. {:<width$}", idx + 1, words[idx], width = col_widths[col]));
}
}
doc.push_str(&format!(" {}\n", parts.join(" ")));
}
doc.push('\n');
doc.push_str(" KEEP THIS DOCUMENT SECURE.\n");
doc.push_str(" Destroy after transferring to durable storage.\n");
doc.push_str(&format!("{}\n", sep));
let printers = discover_printers();
let printer_arg: Option<String> = if printers.len() > 1 {
let selection = dialoguer::Select::new()
.with_prompt("Select printer")
.items(&printers)
.default(0)
.interact()
.map_err(|e| CliError::internal(format!("prompt failed: {e}")))?;
Some(printers[selection].clone())
} else if printers.len() == 1 {
eprintln!("Printing to: {}", printers[0]);
Some(printers[0].clone())
} else {
None
};
let temp_dir = std::env::temp_dir();
let temp_path = temp_dir.join(format!("void-recovery-{}.txt", uuid::Uuid::new_v4()));
std::fs::write(&temp_path, &doc)
.map_err(|e| CliError::io_error(format!("failed to write temp file: {e}")))?;
let mut cmd = std::process::Command::new("lp");
if let Some(ref printer) = printer_arg {
cmd.arg("-d").arg(printer);
}
cmd.arg(&temp_path);
let status = cmd
.status()
.map_err(|e| CliError::io_error(format!("failed to run lp: {e}")))?;
let _ = std::fs::remove_file(&temp_path);
if status.success() {
let dest = printer_arg.as_deref().unwrap_or("default printer");
eprintln!("Recovery phrase sent to {}.", dest);
} else {
eprintln!("Warning: print command exited with status {}. Check printer.", status);
}
Ok(())
}
fn prompt_pin_with_confirm() -> Result<String, CliError> {
let pin = rpassword::prompt_password("Enter PIN: ")
.map_err(|e| CliError::io_error(format!("failed to read PIN: {}", e)))?;
if pin.is_empty() {
return Err(CliError::invalid_args("PIN must not be empty"));
}
let confirm = rpassword::prompt_password("Confirm PIN: ")
.map_err(|e| CliError::io_error(format!("failed to read PIN: {}", e)))?;
if pin != confirm {
return Err(CliError::invalid_args("PINs do not match"));
}
Ok(pin)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::PathBuf;
use tempfile::tempdir;
fn save_identity_to(
identity: &Identity,
identity_dir: &PathBuf,
username: &str,
pin: &str,
) -> Result<(), CliError> {
fs::create_dir_all(identity_dir).map_err(|e| CliError::io_error(e.to_string()))?;
fs::write(
identity_dir.join("signing.pub"),
identity.signing_pubkey().to_hex(),
)
.map_err(|e| CliError::io_error(e.to_string()))?;
fs::write(
identity_dir.join("recipient.pub"),
identity.recipient_pubkey().to_hex(),
)
.map_err(|e| CliError::io_error(e.to_string()))?;
let profile = serde_json::json!({ "username": username });
fs::write(
identity_dir.join("profile.json"),
serde_json::to_string_pretty(&profile).unwrap(),
)
.map_err(|e| CliError::io_error(e.to_string()))?;
let signing_secret = identity.signing_key_bytes();
let recipient_secret = identity.recipient_key_bytes();
let nostr_secret = identity
.nostr_key_bytes()
.unwrap_or_else(|| void_core::collab::NostrSecretKey::from_bytes([0xbb; 32]));
let encrypted = void_core::collab::encrypt_identity_keys(
&signing_secret,
&recipient_secret,
&nostr_secret,
pin,
)
.unwrap();
fs::write(identity_dir.join("keys.enc"), &encrypted)
.map_err(|e| CliError::io_error(e.to_string()))?;
Ok(())
}
fn load_identity_from(identity_dir: &PathBuf, pin: &str) -> Result<Identity, CliError> {
let keys_path = identity_dir.join("keys.enc");
if !keys_path.exists() {
return Err(CliError::not_found(
"identity not initialized, run 'void identity init'",
));
}
let encrypted = fs::read(&keys_path).map_err(|e| CliError::io_error(e.to_string()))?;
let (signing_secret, recipient_secret, nostr_secret) =
void_core::collab::decrypt_identity_keys(&encrypted, pin)
.map_err(|e| CliError::internal(format!("failed to decrypt identity: {}", e)))?;
Ok(match nostr_secret {
Some(nostr) => {
Identity::from_bytes_with_nostr(&signing_secret, &recipient_secret, nostr)
}
None => Identity::from_bytes(&signing_secret, &recipient_secret),
})
}
#[test]
fn test_identity_not_found() {
let temp_dir = tempdir().unwrap();
let identity_dir = temp_dir.path().join("identity");
let result = load_identity_from(&identity_dir, "pin");
assert!(result.is_err());
}
#[test]
fn test_identity_roundtrip() {
let temp_dir = tempdir().unwrap();
let identity_dir = temp_dir.path().join("identity");
let identity = Identity::generate();
let pin = "test-pin";
save_identity_to(&identity, &identity_dir, "alice", pin).unwrap();
let loaded = load_identity_from(&identity_dir, pin).unwrap();
assert_eq!(identity.signing_pubkey(), loaded.signing_pubkey());
assert_eq!(identity.recipient_pubkey(), loaded.recipient_pubkey());
}
#[test]
fn test_identity_exists() {
let temp_dir = tempdir().unwrap();
let identity_dir = temp_dir.path().join("identity");
let keys_enc_path = identity_dir.join("keys.enc");
assert!(!keys_enc_path.exists());
let identity = Identity::generate();
save_identity_to(&identity, &identity_dir, "bob", "pin123").unwrap();
assert!(keys_enc_path.exists());
assert!(identity_dir.join("signing.pub").exists());
assert!(identity_dir.join("recipient.pub").exists());
assert!(identity_dir.join("profile.json").exists());
}
#[test]
fn test_wrong_pin_fails() {
let temp_dir = tempdir().unwrap();
let identity_dir = temp_dir.path().join("identity");
let identity = Identity::generate();
save_identity_to(&identity, &identity_dir, "alice", "correct-pin").unwrap();
let result = load_identity_from(&identity_dir, "wrong-pin");
assert!(result.is_err());
}
#[test]
fn test_profile_json_contains_username() {
let temp_dir = tempdir().unwrap();
let identity_dir = temp_dir.path().join("identity");
let identity = Identity::generate();
save_identity_to(&identity, &identity_dir, "charlie", "pin").unwrap();
let profile_content = fs::read_to_string(identity_dir.join("profile.json")).unwrap();
let profile: serde_json::Value = serde_json::from_str(&profile_content).unwrap();
assert_eq!(profile["username"], "charlie");
}
}