algae-cli 1.0.0

Lightweight age profile for user-friendly encryption (CLI tool and library)
Documentation
use std::path::PathBuf;

use age::{
	secrecy::{ExposeSecret, SecretString},
	x25519,
};
use clap::Parser;
use itertools::Itertools;
use miette::{Context as _, IntoDiagnostic as _, Result};
use tokio::{fs::File, io::AsyncWriteExt};

use crate::passphrases::{Passphrase, PassphraseArgs};

/// Generate an identity (key pair) to encrypt and decrypt files
///
/// This creates a passphrase-protected identity file which contains both public
/// and secret keys:
///
/// ```identity.txt
/// # created: 2024-12-20T05:36:10.267871872+00:00
/// # public key: age1c3jdepjm05aey2dq9dgkfn4utj9a776zwqzqcar3879smuh04ysqttvmyd
/// AGE-SECRET-KEY-1N84CR29PJTUQA22ALHP4YDL5ZFMXPW5GVETVY3UK58ZD6NPNPDLS4MCZFS
/// ```
///
/// As well as a plaintext public key file which contains just the public key:
///
/// ```identity.pub
/// age1c3jdepjm05aey2dq9dgkfn4utj9a776zwqzqcar3879smuh04ysqttvmyd
/// ```
///
/// The public key is also printed to stdout.
///
/// By default this command prompts for a passphrase. This can be disabled with
/// `--plaintext`; the default path `identity.txt` instead of `identity.txt.age`
/// is used if `--output` isn't given, and the contents will be in plain text
/// (in the format shown above).
///
/// On encrypting machines (e.g. servers uploading backups), you should always
/// prefer to store _just_ the public key, and only upload and use the
/// passphrase-protected identity file as necessary, deleting it afterwards.
///
/// Identity files (both plaintext and passphrase-protected) generated by this
/// command are compatible with the `age` CLI tool. Note that the reverse might
/// not be true (there are age-generated identities that this tool cannot handle).
#[derive(Debug, Clone, Parser)]
#[clap(verbatim_doc_comment)]
pub struct KeygenArgs {
	/// Path to write the identity file to.
	///
	/// Defaults to identity.txt.age, and to identity.txt if --plaintext is given.
	#[arg(short, long)]
	pub output: Option<PathBuf>,

	/// Path to write the public key file to.
	///
	/// Set to a single hyphen (`-`) to disable writing this file; the public key
	/// will be printed to stdout in any case.
	#[arg(long = "public", default_value = "identity.pub")]
	pub public_path: PathBuf,

	/// INSECURE: write a plaintext identity.
	#[arg(long)]
	pub plaintext: bool,

	/// Generate a random passphrase.
	///
	/// Instead of entering a passphrase yourself, this will generate one with
	/// random words (from the Minilock wordlist) and print it out for you.
	#[arg(short = 'R', long, conflicts_with = "plaintext")]
	pub random_passphrase: bool,

	#[command(flatten)]
	#[allow(missing_docs, reason = "don't interfere with clap")]
	pub key: PassphraseArgs,
}

/// CLI command for the `keygen` operation (keypair generation).
pub async fn run(
	KeygenArgs {
		output,
		public_path,
		plaintext,
		random_passphrase,
		key,
	}: KeygenArgs,
) -> Result<()> {
	let secret = x25519::Identity::generate();
	let public = secret.to_public();

	let output = output.unwrap_or_else(|| {
		if plaintext {
			"identity.txt"
		} else {
			"identity.txt.age"
		}
		.into()
	});

	let identity = SecretString::from(format!(
		"# created: {}\n# public key: {public}\n{}\n",
		jiff::Timestamp::now(),
		secret.to_string().expose_secret()
	));

	let identity = if plaintext {
		identity.expose_secret().as_bytes().to_owned()
	} else {
		let key = if random_passphrase {
			use rand::seq::SliceRandom;
			let rng = &mut rand::thread_rng();
			let words = diceware_wordlists::MINILOCK_WORDLIST.choose_multiple(rng, 6);
			let phrase: String = Itertools::intersperse(words.map(|item| *item), "-").collect();
			Passphrase::new(phrase.into())
		} else {
			key.require_with_confirmation().await?
		};
		age::encrypt(&key, identity.expose_secret().as_bytes()).into_diagnostic()?
	};

	File::create_new(&output)
		.await
		.into_diagnostic()
		.wrap_err("opening the identity file")?
		.write_all(&identity)
		.await
		.into_diagnostic()
		.wrap_err("writing the identity")?;

	println!("public key: {public}");
	if public_path.to_string_lossy() != "-" {
		File::create_new(&public_path)
			.await
			.into_diagnostic()
			.wrap_err("opening the public key file")?
			.write_all(public.to_string().as_bytes())
			.await
			.into_diagnostic()
			.wrap_err("writing the public key")?;
	}

	Ok(())
}