git.delta.rocks / jrsonnet / refs/commits / 67bf612dbfd3

difftreelog

refactor use generator helper for built-in secret generators

Yaroslav Bolyukin2024-06-28parent: #b56a5a3.patch.diff
in: trunk

11 files changed

modifiedCargo.lockdiffbeforeafterboth
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -808,10 +808,12 @@
 dependencies = [
  "age",
  "anyhow",
+ "base64 0.22.1",
  "clap",
  "ed25519-dalek",
  "fleet-shared",
  "rand",
+ "x25519-dalek",
 ]
 
 [[package]]
modifiedcmds/fleet/src/cmds/secrets/mod.rsdiffbeforeafterboth
before · cmds/fleet/src/cmds/secrets/mod.rs
1use std::{2	collections::{BTreeMap, BTreeSet, HashSet},3	ffi::OsString,4	io::{self, stdin, stdout, Read, Write},5	path::PathBuf,6};78use anyhow::{anyhow, bail, ensure, Context, Result};9use chrono::{DateTime, Utc};10use clap::Parser;11use crossterm::{terminal, tty::IsTty};12use fleet_shared::SecretData;13use itertools::Itertools;14use nix_eval::{nix_go, nix_go_json, Value};15use owo_colors::OwoColorize;16use serde::Deserialize;17use tabled::{Table, Tabled};18use tokio::{fs::read, process::Command};19use tracing::{error, info, info_span, warn, Instrument};2021use crate::{22	fleetdata::{encrypt_secret_data, FleetSecret, FleetSecretPart, FleetSharedSecret},23	host::Config,24};2526#[derive(Parser)]27pub enum Secret {28	/// Force load host keys for all defined hosts29	ForceKeys,30	/// Add secret, data should be provided in stdin31	AddShared {32		/// Secret name33		name: String,34		/// Secret owners35		#[clap(long, short)]36		machines: Vec<String>,37		/// Override secret if already present38		#[clap(long)]39		force: bool,40		/// Secret public part41		#[clap(long)]42		public: Option<String>,43		/// How to name public secret part44		#[clap(long, default_value = "public")]45		public_name: String,46		/// Load public part from specified file47		#[clap(long)]48		public_file: Option<PathBuf>,4950		/// Create a notification on secret expiration51		#[clap(long)]52		expires_at: Option<DateTime<Utc>>,5354		/// Secret with this name already exists, override its value while keeping the same owners.55		#[clap(long)]56		re_add: bool,5758		#[clap(default_value = "secret")]59		part_name: String,60	},61	/// Add secret, data should be provided in stdin62	Add {63		/// Secret name64		name: String,65		/// Secret owners66		machine: String,67		/// Override secret if already present68		#[clap(long)]69		force: bool,70		/// Secret public part71		#[clap(long)]72		public: Option<String>,73		/// How to name public secret part74		#[clap(long, default_value = "public")]75		public_name: String,76		/// Load public part from specified file77		#[clap(long)]78		public_file: Option<PathBuf>,7980		#[clap(default_value = "secret")]81		part_name: String,82	},83	/// Read secret from remote host, requires sudo on said host84	Read {85		name: String,86		machine: String,8788		#[clap(default_value = "secret")]89		part_name: String,90	},91	UpdateShared {92		name: String,9394		#[clap(long)]95		machines: Option<Vec<String>>,9697		#[clap(long)]98		add_machines: Vec<String>,99		#[clap(long)]100		remove_machines: Vec<String>,101102		/// Which host should we use to decrypt103		#[clap(long)]104		prefer_identities: Vec<String>,105106		#[clap(default_value = "secret")]107		part_name: String,108	},109	Regenerate {110		/// Which host should we use to decrypt, in case if reencryption is required, without111		/// regeneration112		#[clap(long)]113		prefer_identities: Vec<String>,114	},115	List {},116	Edit {117		name: String,118		machine: String,119120		#[clap(default_value = "secret")]121		part: String,122123		#[clap(long)]124		add: bool,125	},126}127128#[tracing::instrument(skip(config, secret, field, prefer_identities))]129async fn update_owner_set(130	secret_name: &str,131	config: &Config,132	mut secret: FleetSharedSecret,133	field: Value,134	updated_set: &[String],135	prefer_identities: &[String],136) -> Result<FleetSharedSecret> {137	let original_set = secret.owners.clone();138139	let set = original_set.iter().collect::<BTreeSet<_>>();140	let expected_set = updated_set.iter().collect::<BTreeSet<_>>();141142	if set == expected_set {143		info!("no need to update owner list, it is already correct");144		return Ok(secret);145	}146147	let should_regenerate = if set.difference(&expected_set).next().is_some() {148		// TODO: Remove this warning for revokable secrets.149		warn!("host was removed from secret owners, but until this host rebuild, the secret will still be stored on it.");150		nix_go_json!(field.regenerateOnOwnerRemoved)151	} else if expected_set.difference(&set).next().is_some() {152		nix_go_json!(field.regenerateOnOwnerAdded)153	} else {154		false155	};156157	if should_regenerate {158		info!("secret is owner-dependent, will regenerate");159		let generated = generate_shared(config, secret_name, field, updated_set.to_vec()).await?;160		Ok(generated)161	} else {162		let identity_holder = if !prefer_identities.is_empty() {163			prefer_identities164				.iter()165				.find(|i| original_set.iter().any(|s| s == *i))166		} else {167			secret.owners.first()168		};169		let Some(identity_holder) = identity_holder else {170			bail!("no available holder found");171		};172173		for (part_name, part) in secret.secret.parts.iter_mut() {174			let _span = info_span!("part reencryption", part_name);175			if !part.raw.encrypted {176				continue;177			}178			let host = config.host(identity_holder).await?;179			let encrypted = host180				.reencrypt(part.raw.clone(), updated_set.to_vec())181				.await?;182			part.raw = encrypted;183		}184185		secret.owners = updated_set.to_vec();186		Ok(secret)187	}188}189190#[derive(Deserialize)]191#[serde(rename_all = "camelCase")]192enum GeneratorKind {193	Impure,194	Pure,195}196197async fn generate_pure(198	_config: &Config,199	_display_name: &str,200	_secret: Value,201	_default_generator: Value,202	_owners: &[String],203) -> Result<FleetSecret> {204	bail!("pure generators are broken for now")205}206async fn generate_impure(207	config: &Config,208	_display_name: &str,209	secret: Value,210	default_generator: Value,211	owners: &[String],212) -> Result<FleetSecret> {213	let generator = nix_go!(secret.generator);214	let on: Option<String> = nix_go_json!(default_generator.impureOn);215216	let host = if let Some(on) = &on {217		config.host(on).await?218	} else {219		config.local_host()220	};221	let on_pkgs = host.pkgs().await?;222	let call_package = nix_go!(on_pkgs.callPackage);223	let mk_encrypt_secret = nix_go!(on_pkgs.mkEncryptSecret);224225	let mut recipients = Vec::new();226	for owner in owners {227		let key = config.key(owner).await?;228		recipients.push(key);229	}230	let encrypt = nix_go!(mk_encrypt_secret(Obj {231		recipients: { recipients },232	}));233234	let generator = nix_go!(call_package(generator)(Obj {235		encrypt,236		// rustfmt_please_newline237	}));238239	let generator = generator.build().await?;240	let generator = generator241		.get("out")242		.ok_or_else(|| anyhow!("missing generateImpure out"))?;243	let generator = host.remote_derivation(generator).await?;244245	let out_parent = host.mktemp_dir().await?;246	let out = format!("{out_parent}/out");247248	let mut gen = host.cmd(generator).await?;249	gen.env("out", &out);250	if on.is_none() {251		// This path is local, thus we can feed `OsString` directly to env var... But I don't think that's necessary to handle.252		let project_path: String = config253			.directory254			.clone()255			.into_os_string()256			.into_string()257			.map_err(|s| anyhow!("fleet project path is not utf-8: {s:?}"))?;258		gen.env("FLEET_PROJECT", project_path);259	}260	gen.run().await.context("impure generator")?;261262	{263		let marker = host.read_file_text(format!("{out}/marker")).await?;264		ensure!(marker == "SUCCESS", "generation not succeeded");265	}266267	let mut parts = BTreeMap::new();268	for part in host.read_dir(&out).await? {269		if part == "created_at" || part == "expired_at" || part == "marker" {270			continue;271		}272		let contents: SecretData = host273			.read_file_text(format!("{out}/{part}"))274			.await?275			.parse()276			.map_err(|e| anyhow!("failed to decode secret {out:?} part {part:?}: {e}"))?;277		parts.insert(part.to_owned(), FleetSecretPart { raw: contents });278	}279280	let created_at = host.read_file_value(format!("{out}/created_at")).await?;281	let expires_at = host.read_file_value(format!("{out}/expires_at")).await.ok();282283	Ok(FleetSecret {284		created_at,285		expires_at,286		parts,287	})288}289async fn generate(290	config: &Config,291	display_name: &str,292	secret: Value,293	owners: &[String],294) -> Result<FleetSecret> {295	let generator = nix_go!(secret.generator);296	// Can't properly check on nix module system level297	{298		let gen_ty = generator.type_of().await?;299		if gen_ty == "null" {300			bail!("secret has no generator defined, can't automatically generate it.");301		}302		if gen_ty != "lambda" {303			bail!("generator should be lambda, got {gen_ty}");304		}305	}306	let default_pkgs = &config.default_pkgs;307	let default_call_package = nix_go!(default_pkgs.callPackage);308	// Generators provide additional information in passthru, to access309	// passthru we should call generator, but information about where this generator is supposed to build310	// is located in passthru... Thus evaluating generator on host.311	//312	// Maybe it is also possible to do some magic with __functor?313	//314	// I don't want to make modules always responsible for additional secret data anyway,315	// so it should be in derivation, and not in the secret data itself.316	let default_generator = nix_go!(default_call_package(generator)(Obj {317		encrypt: { "exit 1" },318		// rustfmt_please_newline319	}));320321	let kind: GeneratorKind = nix_go_json!(default_generator.generatorKind);322323	match kind {324		GeneratorKind::Impure => {325			generate_impure(config, display_name, secret, default_generator, owners).await326		}327		GeneratorKind::Pure => {328			generate_pure(config, display_name, secret, default_generator, owners).await329		}330	}331}332async fn generate_shared(333	config: &Config,334	display_name: &str,335	secret: Value,336	expected_owners: Vec<String>,337) -> Result<FleetSharedSecret> {338	// let owners: Vec<String> = nix_go_json!(secret.expectedOwners);339	Ok(FleetSharedSecret {340		secret: generate(config, display_name, secret, &expected_owners).await?,341		owners: expected_owners,342	})343}344345async fn parse_public(346	public: Option<String>,347	public_file: Option<PathBuf>,348) -> Result<Option<SecretData>> {349	Ok(match (public, public_file) {350		(Some(v), None) => Some(SecretData {351			data: v.into(),352			encrypted: false,353		}),354		(None, Some(v)) => Some(SecretData {355			data: read(v).await?,356			encrypted: false,357		}),358		(Some(_), Some(_)) => {359			bail!("only public or public_file should be set")360		}361		(None, None) => None,362	})363}364365async fn parse_secret() -> Result<Option<Vec<u8>>> {366	let mut input = vec![];367	stdin().read_to_end(&mut input)?;368	if input.is_empty() {369		Ok(None)370	} else {371		Ok(Some(input))372	}373}374375fn parse_machines(376	initial: Vec<String>,377	machines: Option<Vec<String>>,378	mut add_machines: Vec<String>,379	mut remove_machines: Vec<String>,380) -> Result<Vec<String>> {381	if machines.is_none() && add_machines.is_empty() && remove_machines.is_empty() {382		bail!("no operation");383	}384385	let initial_machines = initial.clone();386	let mut target_machines = initial;387	info!("Currently encrypted for {initial_machines:?}");388389	// ensure!(machines.is_some() || !add_machines.is_empty() || )390	if let Some(machines) = machines {391		ensure!(392			add_machines.is_empty() && remove_machines.is_empty(),393			"can't combine --machines and --add-machines/--remove-machines"394		);395		let target = initial_machines.iter().collect::<HashSet<_>>();396		let source = machines.iter().collect::<HashSet<_>>();397		for removed in target.difference(&source) {398			remove_machines.push((*removed).clone());399		}400		for added in source.difference(&target) {401			add_machines.push((*added).clone());402		}403	}404405	for machine in &remove_machines {406		let mut removed = false;407		while let Some(pos) = target_machines.iter().position(|m| m == machine) {408			target_machines.swap_remove(pos);409			removed = true;410		}411		if !removed {412			warn!("secret is not enabled for {machine}");413		}414	}415	for machine in &add_machines {416		if target_machines.iter().any(|m| m == machine) {417			warn!("secret is already added to {machine}");418		} else {419			target_machines.push(machine.to_owned());420		}421	}422	if !remove_machines.is_empty() {423		// TODO: maybe force secret regeneration?424		// Not that useful without revokation.425		warn!("secret will not be regenerated for removed machines, and until host rebuild, they will still possess the ability to decode secret");426	}427	Ok(target_machines)428}429impl Secret {430	pub async fn run(self, config: &Config) -> Result<()> {431		match self {432			Secret::ForceKeys => {433				for host in config.list_hosts().await? {434					if config.should_skip(&host.name) {435						continue;436					}437					config.key(&host.name).await?;438				}439			}440			Secret::AddShared {441				mut machines,442				name,443				force,444				public,445				public_name,446				public_file,447				expires_at,448				re_add,449				part_name,450			} => {451				// TODO: Forbid updating secrets with set expectedOwners (= not user-managed).452453				let exists = config.has_shared(&name);454				if exists && !force && !re_add {455					bail!("secret already defined");456				}457				if re_add {458					// Fixme: use clap to limit this usage459					ensure!(!force, "--force and --readd are not compatible");460					ensure!(exists, "secret doesn't exists");461					ensure!(462						machines.is_empty(),463						"you can't use machines argument for --readd"464					);465					let shared = config.shared_secret(&name)?;466					machines = shared.owners;467				}468469				let recipients = config.recipients(machines.clone()).await?;470471				let mut parts = BTreeMap::new();472473				let mut input = vec![];474				io::stdin().read_to_end(&mut input)?;475476				if !input.is_empty() {477					let encrypted = encrypt_secret_data(recipients, input)478						.ok_or_else(|| anyhow!("no recipients provided"))?;479					parts.insert(part_name, FleetSecretPart { raw: encrypted });480				}481482				if let Some(public) = parse_public(public, public_file).await? {483					parts.insert(public_name, FleetSecretPart { raw: public });484				}485486				config.replace_shared(487					name,488					FleetSharedSecret {489						owners: machines,490						secret: FleetSecret {491							created_at: Utc::now(),492							expires_at,493							parts,494						},495					},496				);497			}498			Secret::Add {499				machine,500				name,501				force,502				public,503				public_name,504				public_file,505				part_name,506			} => {507				if config.has_secret(&machine, &name) && !force {508					bail!("secret already defined");509				}510511				let mut parts = BTreeMap::new();512513				if let Some(secret) = parse_secret().await? {514					let recipient = config.recipient(&machine).await?;515					let encrypted =516						encrypt_secret_data(vec![recipient], secret).expect("recipient provided");517					parts.insert(part_name, FleetSecretPart { raw: encrypted });518				}519520				if let Some(public) = parse_public(public, public_file).await? {521					parts.insert(public_name, FleetSecretPart { raw: public });522				};523524				config.insert_secret(525					&machine,526					name,527					FleetSecret {528						created_at: Utc::now(),529						expires_at: None,530						parts,531					},532				);533			}534			#[allow(clippy::await_holding_refcell_ref)]535			Secret::Read {536				name,537				machine,538				part_name,539			} => {540				let secret = config.host_secret(&machine, &name)?;541				let Some(secret) = secret.parts.get(&part_name) else {542					bail!("no part {part_name} in secret {name}");543				};544				let data = if secret.raw.encrypted {545					let host = config.host(&machine).await?;546					host.decrypt(secret.raw.clone()).await?547				} else {548					secret.raw.data.clone()549				};550551				stdout().write_all(&data)?;552			}553			Secret::UpdateShared {554				name,555				machines,556				add_machines,557				remove_machines,558				prefer_identities,559				part_name,560			} => {561				// TODO: Forbid updating secrets with set expectedOwners (= not user-managed).562563				let secret = config.shared_secret(&name)?;564				if secret.secret.parts.get(&part_name).is_none() {565					bail!("no secret");566				}567568				let initial_machines = secret.owners.clone();569				let target_machines = parse_machines(570					initial_machines.clone(),571					machines,572					add_machines,573					remove_machines,574				)?;575576				if target_machines.is_empty() {577					info!("no machines left for secret, removing it");578					config.remove_shared(&name);579					return Ok(());580				}581582				let config_field = &config.config_unchecked_field;583				let field = nix_go!(config_field.sharedSecrets[{ name }]);584585				let updated = update_owner_set(586					&name,587					config,588					secret,589					field,590					&target_machines,591					&prefer_identities,592				)593				.await?;594				config.replace_shared(name, updated);595			}596			Secret::Regenerate { prefer_identities } => {597				info!("checking for secrets to regenerate");598				{599					let _span = info_span!("shared").entered();600					let expected_shared_set = config601						.list_configured_shared()602						.await?603						.into_iter()604						.collect::<HashSet<_>>();605					let shared_set = config.list_shared().into_iter().collect::<HashSet<_>>();606					for missing in expected_shared_set.difference(&shared_set) {607						let config_field = &config.config_unchecked_field;608						let secret = nix_go!(config_field.sharedSecrets[{ missing }]);609						let expected_owners: Option<Vec<String>> =610							nix_go_json!(secret.expectedOwners);611						let Some(expected_owners) = expected_owners else {612							// TODO: Might still need to regenerate613							continue;614						};615						info!("generating secret: {missing}");616						let shared = generate_shared(config, missing, secret, expected_owners)617							.in_current_span()618							.await?;619						config.replace_shared(missing.to_string(), shared)620					}621				}622				for host in config.list_hosts().await? {623					if config.should_skip(&host.name) {624						continue;625					}626627					let _span = info_span!("host", host = host.name).entered();628					let expected_set = host629						.list_configured_secrets()630						.in_current_span()631						.await?632						.into_iter()633						.collect::<HashSet<_>>();634					let stored_set = config635						.list_secrets(&host.name)636						.into_iter()637						.collect::<HashSet<_>>();638					for missing in expected_set.difference(&stored_set) {639						info!("generating secret: {missing}");640						let secret = host.secret_field(missing).in_current_span().await?;641						let generated =642							match generate(config, missing, secret, &[host.name.clone()])643								.in_current_span()644								.await645							{646								Ok(v) => v,647								Err(e) => {648									error!("{e:?}");649									continue;650								}651							};652						config.insert_secret(&host.name, missing.to_string(), generated)653					}654				}655				let mut to_remove = Vec::new();656				for name in &config.list_shared() {657					info!("updating secret: {name}");658					let data = config.shared_secret(name)?;659					let config_field = &config.config_unchecked_field;660					let expected_owners: Vec<String> =661						nix_go_json!(config_field.sharedSecrets[{ name }].expectedOwners);662					if expected_owners.is_empty() {663						warn!("secret was removed from fleet config: {name}, removing from data");664						to_remove.push(name.to_string());665						continue;666					}667668					let secret = nix_go!(config_field.sharedSecrets[{ name }]);669					config.replace_shared(670						name.to_owned(),671						update_owner_set(672							name,673							config,674							data,675							secret,676							&expected_owners,677							&prefer_identities,678						)679						.await?,680					);681				}682				for k in to_remove {683					config.remove_shared(&k);684				}685			}686			Secret::List {} => {687				let _span = info_span!("loading secrets").entered();688				let configured = config.list_configured_shared().await?;689				#[derive(Tabled)]690				struct SecretDisplay {691					#[tabled(rename = "Name")]692					name: String,693					#[tabled(rename = "Owners")]694					owners: String,695				}696				let mut table = vec![];697				for name in configured.iter().cloned() {698					let config = config.clone();699					let expected_owners = config.shared_secret_expected_owners(&name).await?;700					let data = config.shared_secret(&name)?;701					let owners = data702						.owners703						.iter()704						.map(|o| {705							if expected_owners.contains(o) {706								o.green().to_string()707							} else {708								o.red().to_string()709							}710						})711						.collect::<Vec<_>>();712					table.push(SecretDisplay {713						owners: owners.join(", "),714						name,715					})716				}717				info!("loaded\n{}", Table::new(table).to_string())718			}719			Secret::Edit {720				name,721				machine,722				part,723				add,724			} => {725				let secret = config.host_secret(&machine, &name)?;726				if let Some(data) = secret.parts.get(&part) {727					let host = config.host(&machine).await?;728					let secret = host.decrypt(data.raw.clone()).await?;729					String::from_utf8(secret).context("secret is not utf8")?730				} else if add {731					String::new()732				} else {733					bail!("part {part} not found in secret {name}. Did you mean to `--add` it?");734				};735			}736		}737		Ok(())738	}739}740741async fn edit_temp_file(742	builder: tempfile::Builder<'_, '_>,743	r: Vec<u8>,744	header: &str,745	comment: &str,746) -> Result<(Vec<u8>, Option<String>), anyhow::Error> {747	if !stdin().is_tty() {748		// TODO: Also try to open /dev/tty directly?749		bail!("stdin is not tty, can't open editor");750	}751752	use std::fmt::Write;753	let mut file = builder.tempfile()?;754755	let mut full_header = String::new();756	let mut had = false;757	for line in header.trim_end().lines() {758		had = true;759		writeln!(&mut full_header, "{comment}{line}")?;760	}761	if had {762		writeln!(&mut full_header, "{}", comment.trim_end())?;763	}764	writeln!(765		&mut full_header,766		"{comment}Do not touch this header! It will be removed automatically"767	)?;768769	file.write_all(full_header.as_bytes())?;770	file.write_all(&r)?;771772	let abs_path = file.into_temp_path();773	let editor = std::env::var_os("VISUAL")774		.or_else(|| std::env::var_os("EDITOR"))775		.unwrap_or_else(|| "vi".into());776	let editor_args = shlex::bytes::split(editor.as_encoded_bytes())777		.ok_or_else(|| anyhow!("EDITOR env var has wrong syntax"))?;778	let editor_args = editor_args779		.into_iter()780		.map(|v| {781			// Only ASCII subsequences are replaced782			unsafe { OsString::from_encoded_bytes_unchecked(v) }783		})784		.collect_vec();785	let Some((editor, args)) = editor_args.split_first() else {786		bail!("EDITOR env var has no command");787	};788	let mut command = Command::new(editor);789	command.args(args);790791	let path_arg = abs_path.canonicalize()?;792793	// TODO: Save full state, using tcget/_getmode/_setmode794	let was_raw = terminal::is_raw_mode_enabled()?;795	terminal::enable_raw_mode()?;796797	let status = command.arg(path_arg).status().await;798799	if !was_raw {800		terminal::disable_raw_mode()?;801	}802803	let success = match status {804		Ok(s) => s.success(),805		Err(e) if e.kind() == io::ErrorKind::NotFound => {806			bail!("editor not found")807		}808		Err(e) => bail!("editor spawn error: {e}"),809	};810811	let mut file = std::fs::read(&abs_path).context("read editor output")?;812	let Some(v) = file.strip_prefix(full_header.as_bytes()) else {813		todo!();814	};815	todo!();816817	// Ok((success, abs_path))818}
modifiedcmds/fleet/src/host.rsdiffbeforeafterboth
--- a/cmds/fleet/src/host.rs
+++ b/cmds/fleet/src/host.rs
@@ -95,7 +95,7 @@
 		let out = cmd.run_string().await?;
 		let mut lines = out.split('\n');
 		if let Some(last) = lines.next_back() {
-			ensure!(last == "", "output of ls should end with newline");
+			ensure!(last.is_empty(), "output of ls should end with newline");
 		}
 		Ok(lines.map(ToOwned::to_owned).collect())
 	}
modifiedcmds/generator-helper/Cargo.tomldiffbeforeafterboth
--- a/cmds/generator-helper/Cargo.toml
+++ b/cmds/generator-helper/Cargo.toml
@@ -6,7 +6,9 @@
 [dependencies]
 age.workspace = true
 anyhow.workspace = true
+base64 = "0.22.1"
 clap.workspace = true
 ed25519-dalek = { version = "2.1", features = ["rand_core"] }
 fleet-shared.workspace = true
 rand = "0.8.5"
+x25519-dalek = "2.0.1"
modifiedcmds/generator-helper/src/main.rsdiffbeforeafterboth
--- a/cmds/generator-helper/src/main.rs
+++ b/cmds/generator-helper/src/main.rs
@@ -1,52 +1,161 @@
 use std::{
-	fs,
-	io::{self, stdout, Cursor, Read, Write},
-	path::PathBuf,
+	env,
+	fs::{File, OpenOptions},
+	io::{copy, Read, Write},
 	str::FromStr,
 };
 
-use age::Recipient;
+use age::{
+	ssh::{ParseRecipientKeyError, Recipient as SshRecipient},
+	Encryptor, Recipient,
+};
 use anyhow::{anyhow, bail, ensure, Context, Result};
-use clap::Parser;
-use ed25519_dalek::SigningKey;
+use clap::{Parser, ValueEnum};
 use fleet_shared::SecretData;
 use rand::{
 	distributions::{Alphanumeric, DistString, Distribution, Uniform},
-	rngs::OsRng,
-	thread_rng, Rng,
+	thread_rng,
 };
 
-fn write_output(out: &str, data: impl AsRef<[u8]>, stdout_marker: &mut bool) -> Result<()> {
-	let data = data.as_ref();
-	if out == "-" {
-		let mut stdout = stdout();
-		if *stdout_marker {
-			stdout.write_all(&[b'\n'])?;
+fn write_output_file(out: &str) -> Result<File> {
+	let file = OpenOptions::new()
+		.create_new(true)
+		.write(true)
+		.open(out)
+		.with_context(|| format!("failed to open output {out:?}"))?;
+	Ok(file)
+}
+fn write_public(out: &str, mut input: impl Read, encoding: OutputEncoding) -> Result<()> {
+	let mut output = write_output_file(out)?;
+
+	let mut data = Vec::new();
+	copy(&mut input, &mut wrap_encoder(&mut data, encoding))?;
+
+	output.write_all(
+		SecretData {
+			data,
+			encrypted: false,
 		}
-		*stdout_marker = true;
-		stdout.write_all(data)?;
-	} else {
-		fs::write(out, data)?;
+		.to_string()
+		.as_bytes(),
+	)?;
+	Ok(())
+}
+fn write_private(
+	identities: &Identities,
+	out: &str,
+	mut input: impl Read,
+	encoding: OutputEncoding,
+) -> Result<()> {
+	let mut output = write_output_file(out)?;
+	let encryptor = make_encryptor(identities)?;
+
+	let mut data = Vec::new();
+	{
+		let mut encrypted_writer = encryptor.wrap_output(&mut data)?;
+		copy(
+			&mut input,
+			&mut wrap_encoder(&mut encrypted_writer, encoding),
+		)?;
+		encrypted_writer.finish()?;
 	};
+
+	output.write_all(
+		SecretData {
+			data,
+			encrypted: true,
+		}
+		.to_string()
+		.as_bytes(),
+	)?;
 	Ok(())
 }
 
+type Identities = Vec<SshRecipient>;
+fn load_identities() -> Result<Identities> {
+	let list = env::var("GENERATOR_HELPER_IDENTITIES");
+	let list = match list {
+		Ok(v) => v,
+		Err(env::VarError::NotPresent) => {
+			bail!("gh is only intended to be used from secret generator scripts, but if you really want to use it somewhere else - set GENERATOR_HELPER_IDENTITIES to list of newline-delimited ssh identities");
+		}
+		Err(e) => bail!("somehow, identities list is not utf-8: {e}"),
+	};
+	let list = list.trim();
+	ensure!(!list.is_empty(), "no identities passed, can't encrypt data");
+	list.lines()
+		.map(age::ssh::Recipient::from_str)
+		.collect::<Result<Identities, ParseRecipientKeyError>>()
+		.map_err(|e| anyhow!("parse recipients: {e:?}"))
+}
+fn make_encryptor(r: &Identities) -> Result<Encryptor> {
+	Ok(Encryptor::with_recipients(
+		r.iter()
+			.map(|v| {
+				let coerced: Box<dyn Recipient + Send> = Box::new(v.clone());
+				coerced
+			})
+			.collect(),
+	)
+	.expect("list is not empty"))
+}
+fn wrap_encoder<'t>(w: impl Write + 't, encoding: OutputEncoding) -> impl Write + 't {
+	fn coerce<'t>(w: impl Write + 't) -> Box<dyn Write + 't> {
+		Box::new(w)
+	}
+	match encoding {
+		OutputEncoding::Raw => coerce(w),
+		OutputEncoding::Base64 => {
+			use base64::engine::general_purpose::STANDARD;
+			let writer = base64::write::EncoderWriter::new(w, &STANDARD);
+			coerce(writer)
+		}
+	}
+}
+
+#[derive(Clone, Copy, ValueEnum, Default)]
+enum OutputEncoding {
+	/// Do not encode data, store as is.
+	#[default]
+	Raw,
+	/// Encode as base64 (with padding).
+	Base64,
+}
+
 #[derive(Parser)]
 enum Generate {
 	/// Generate public, private keys without wrapping, in standard ed25519 schema
 	/// (64 bytes private (due to merge with private), 32 bytes public)
 	Ed25519 {
+		#[arg(long, short = 'p')]
 		public: String,
+		#[arg(long, short = 's')]
 		private: String,
 		/// Private key should be just the private key (32 bytes), not standard private+public.
 		#[arg(long)]
 		no_embed_public: bool,
+		#[arg(long, short = 'e', value_enum, default_value_t)]
+		encoding: OutputEncoding,
+	},
+	/// Generate public, private keys without wrapping, in standard x25519 schema
+	/// (32 bytes private, 32 bytes public)
+	X25519 {
+		#[arg(long, short = 'p')]
+		public: String,
+		#[arg(long, short = 's')]
+		private: String,
+		#[arg(long, short = 'e', value_enum, default_value_t)]
+		encoding: OutputEncoding,
 	},
 	Password {
+		#[arg(long, short = 'o')]
 		output: String,
+		#[arg(long)]
 		size: usize,
 		#[arg(long, short = 'n')]
 		no_symbols: bool,
+		#[arg(long, short = 'e', value_enum, default_value_t)]
+		encoding: OutputEncoding,
 	},
 }
 
@@ -54,15 +163,17 @@
 enum Opts {
 	/// Encode public part from stdin.
 	Public {
-		#[arg(long)]
-		allow_empty: bool,
+		#[arg(long, short = 'o')]
+		output: String,
+		#[arg(long, short = 'e', value_enum, default_value_t)]
+		encoding: OutputEncoding,
 	},
 	/// Encrypt private part from stdin.
 	Private {
-		#[arg(long)]
-		allow_empty: bool,
-		#[arg(short = 'r')]
-		recipient: Vec<String>,
+		#[arg(long, short = 'o')]
+		output: String,
+		#[arg(long, short = 'e', value_enum, default_value_t)]
+		encoding: OutputEncoding,
 	},
 	/// Generate keys in well-known schemas.
 	///
@@ -70,99 +181,34 @@
 	/// otherwise you should ensure noone is able to read generated files, they don't have any mode set by default.
 	#[command(subcommand)]
 	Generate(Generate),
-	// Generate {
-	// 	kind: GenerateKind,
-	// 	/// Different generators generate different number of files, you need to specify number of outputs corresponding to the generator.
-	// 	#[arg(short = 'o')]
-	// 	outputs: Vec<String>,
-	// },
 }
 
-fn parse_stdin() -> Result<Option<Vec<u8>>> {
-	let mut input = vec![];
-	io::stdin().read_to_end(&mut input)?;
-	if input.is_empty() {
-		Ok(None)
-	} else {
-		Ok(Some(input))
-	}
-}
-pub fn encrypt_secret_data(
-	recipients: impl IntoIterator<Item = impl Recipient + Send + 'static>,
-	data: Vec<u8>,
-) -> Option<SecretData> {
-	let mut encrypted = vec![];
-	let recipients = recipients
-		.into_iter()
-		.map(|v| Box::new(v) as Box<dyn Recipient + Send>)
-		.collect::<Vec<_>>();
-	let mut encryptor = age::Encryptor::with_recipients(recipients)?
-		.wrap_output(&mut encrypted)
-		.expect("in memory write");
-	io::copy(&mut Cursor::new(data), &mut encryptor).expect("in memory copy");
-	encryptor.finish().expect("in memory flush");
-	Some(SecretData {
-		data: encrypted,
-		encrypted: true,
-	})
-}
-
 fn main() -> Result<()> {
 	let opts = Opts::parse();
 	// Assumed to be secure, seeded from secure OsRng+reseeded.
 	let mut rng = thread_rng();
 
 	match opts {
-		Opts::Public { allow_empty } => {
-			let stdin = parse_stdin()?;
-			if stdin.is_none() && !allow_empty {
-				bail!("empty stdin input is not allowed unless --allow-empty is set");
-			}
-			let stdin = stdin.unwrap_or_default();
-			io::stdout().write_all(
-				SecretData {
-					data: stdin,
-					encrypted: false,
-				}
-				.to_string()
-				.as_bytes(),
-			)?;
+		Opts::Public { output, encoding } => {
+			write_public(&output, std::io::stdin(), encoding)?;
 		}
-		Opts::Private {
-			allow_empty,
-			recipient,
-		} => {
-			let stdin = parse_stdin()?;
-			if stdin.is_none() && !allow_empty {
-				bail!("empty stdin input is not allowed unless --allow-empty is set");
-			}
-			let stdin = stdin.unwrap_or_default();
-			if recipient.is_empty() {
-				bail!("recipient list is empty");
-			}
-			let out = encrypt_secret_data(
-				recipient
-					.into_iter()
-					.map(|r| age::ssh::Recipient::from_str(&r))
-					.collect::<Result<Vec<age::ssh::Recipient>, age::ssh::ParseRecipientKeyError>>()
-					.map_err(|e| anyhow!("parse recipients: {e:?}"))?,
-				stdin,
-			)
-			.expect("got recipients");
-			io::stdout().write_all(out.to_string().as_bytes())?;
+		Opts::Private { output, encoding } => {
+			let recipients = load_identities()?;
+			write_private(&recipients, &output, std::io::stdin(), encoding)?;
 		}
 		Opts::Generate(gen) => {
-			let mut stdout_marker: bool = false;
 			match gen {
 				Generate::Ed25519 {
 					public,
 					private,
 					no_embed_public,
+					encoding,
 				} => {
-					let key = SigningKey::generate(&mut rng).to_keypair_bytes();
-
-					write_output(&public, &key[32..], &mut stdout_marker).context("public")?;
-					write_output(
+					let recipients = load_identities()?;
+					let key = ed25519_dalek::SigningKey::generate(&mut rng).to_keypair_bytes();
+					write_public(&public, &key[32..], encoding)?;
+					write_private(
+						&recipients,
 						&private,
 						&key[..{
 							if no_embed_public {
@@ -171,19 +217,31 @@
 								64
 							}
 						}],
-						&mut stdout_marker,
-					)
-					.context("private")?;
+						encoding,
+					)?;
+				}
+				Generate::X25519 {
+					public,
+					private,
+					encoding,
+				} => {
+					let recipients = load_identities()?;
+					let key = x25519_dalek::StaticSecret::random_from_rng(rng);
+					let public_key: x25519_dalek::PublicKey = (&key).into();
+					write_public(&public, public_key.as_bytes().as_slice(), encoding)?;
+					write_private(&recipients, &private, key.as_bytes().as_slice(), encoding)?;
 				}
 				Generate::Password {
 					size,
 					no_symbols,
 					output,
+					encoding,
 				} => {
 					ensure!(
 						size >= 6,
 						"misconfiguration? password is shorter than 6 chars"
 					);
+					let recipients = load_identities()?;
 					let out = if no_symbols {
 						Alphanumeric.sample_string(&mut rng, size)
 					} else {
@@ -195,7 +253,7 @@
 							.map(|i| GEN_ASCII_SYMBOLS[i] as char)
 							.collect::<String>()
 					};
-					write_output(&output, out, &mut stdout_marker)?;
+					write_private(&recipients, &output, out.as_bytes(), encoding)?;
 				}
 			}
 		}
modifiedflake.nixdiffbeforeafterboth
--- a/flake.nix
+++ b/flake.nix
@@ -67,6 +67,7 @@
       perSystem = {
         config,
         system,
+        pkgs,
         ...
       }: let
         # Can also be built for darwin, through it is not usual to deploy nixos systems from macos machines.
@@ -75,14 +76,14 @@
         # It is not possible to deploy any host from armv6/armv7 hardware, and I don't think it even makes sense.
         deployerSystems = ["aarch64-linux" "x86_64-linux"];
         deployerSystem = builtins.elem system deployerSystems;
-        pkgs = import nixpkgs {
-          inherit system;
-          overlays = [(rust-overlay.overlays.default)];
-        };
         lib = pkgs.lib;
         rust = pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml;
         craneLib = (crane.mkLib pkgs).overrideToolchain rust;
       in {
+        _module.args.pkgs = import nixpkgs {
+          inherit system;
+          overlays = [(rust-overlay.overlays.default)];
+        };
         # Reference fleet package should be built with nightly rust, specified in rust-toolchain.toml.
         packages = lib.mkIf deployerSystem (let
           packages = import ./pkgs {
modifiedlib/fleetLib.nixdiffbeforeafterboth
--- a/lib/fleetLib.nix
+++ b/lib/fleetLib.nix
@@ -42,23 +42,46 @@
 
   mkPassword = {size ? 32}: {
     coreutils,
-    encrypt,
     mkSecretGenerator,
+    ...
   }:
     mkSecretGenerator {
       script = ''
         mkdir $out
+        gh generate password -o $out/secret --size ${toString size}
+      '';
+    };
 
-        ${coreutils}/bin/tr -dc 'A-Za-z0-9!?%=' < /dev/random \
-          | ${coreutils}/bin/head -c ${toString size} \
-          | ${encrypt} > $out/secret
+  mkEd25519 = {
+    noEmbedPublic ? false,
+    encoding ? null,
+  }: {mkSecretGenerator, ...}:
+    mkSecretGenerator {
+      script = ''
+        mkdir $out
+        gh generate ed25519 -p $out/public -s $out/secret \
+          ${lib.optionalString noEmbedPublic "--no-embed-public"} \
+          ${lib.optionalString (encoding != null) "--encoding=${encoding}"}
       '';
     };
 
+  mkGarage = {}: mkEd25519 {noEmbedPublic = true;};
+
+  mkX25519 = {encoding ? null}: {mkSecretGenerator, ...}:
+    mkSecretGenerator {
+      script = ''
+        mkdir $out
+        gh generate x25519 -p $out/public -s $out/secret \
+          ${lib.optionalString (encoding != null) "--encoding=${encoding}"}
+      '';
+    };
+
+  mkWireguard = {}: mkX25519 {encoding = "base64";};
+
   mkRsa = {size ? 4096}: {
     openssl,
-    encrypt,
     mkSecretGenerator,
+    ...
   }:
     mkSecretGenerator {
       script = ''
@@ -67,8 +90,8 @@
         ${openssl}/bin/openssl genrsa -out rsa_private.key ${toString size}
         ${openssl}/bin/openssl rsa -in rsa_private.key -pubout -out rsa_public.key
 
-        sudo cat rsa_private.key | ${encrypt} > $out/secret
-        sudo cat rsa_public.key > $out/public
+        cat rsa_private.key | gh private -o $out/secret
+        cat rsa_public.key | gh public -o $out/public
       '';
     };
 }
modifiedmodules/fleet/secrets.nixdiffbeforeafterboth
--- a/modules/fleet/secrets.nix
+++ b/modules/fleet/secrets.nix
@@ -130,85 +130,81 @@
     overlays = [
       (final: prev: let
         lib = final.lib;
-        inherit (lib) strings concatMap;
-        inherit (strings) escapeShellArgs;
+        inherit (lib) strings;
+        inherit (strings) concatStringsSep;
       in {
-        mkEncryptSecret = {
-          rage ? prev.rage,
-          recipients,
-        }:
-          prev.writeShellScript "encryptor" ''
-            #!/bin/sh
-            exec ${rage}/bin/rage ${escapeShellArgs (concatMap (r: ["-r" r]) recipients)} -e "$@"
-          '';
-        # TODO: Move to fleet
-        # TODO: Merge both generators to one with consistent options syntax?
-        # Impure generator is built on local machine, then built closure is copied to remote machine,
-        # and then it is ran in inpure context, so that this generator may access HSMs and other things.
-        mkImpureSecretGenerator = {
-          script,
-          # If set - script will be run on remote machine, otherwise it will be run with fleet project in CWD
-          # (Some secrets-encryption-in-git/managed PKI solution is expected)
-          impureOn ? null,
-        }:
-          (prev.writeShellScript "impureGenerator.sh" ''
-            #!/bin/sh
-            set -eu
+        mkSecretGenerators = {recipients}: rec {
+          # TODO: Merge both generators to one with consistent options syntax?
+          # Impure generator is built on local machine, then built closure is copied to remote machine,
+          # and then it is ran in inpure context, so that this generator may access HSMs and other things.
+          mkImpureSecretGenerator = {
+            script,
+            # If set - script will be run on remote machine, otherwise it will be run with fleet project in CWD
+            # (Some secrets-encryption-in-git/managed PKI solution is expected)
+            impureOn ? null,
+          }:
+            (prev.writeShellScript "impureGenerator.sh" ''
+              #!/bin/sh
+              set -eu
+
+              export GENERATOR_HELPER_IDENTITIES="${concatStringsSep "\n" recipients}";
+              export PATH=${final.fleet-generator-helper}/bin:$PATH
 
-            # TODO: Provide tempdir from outside, to make it securely erasurable as needed?
-            tmp=$(mktemp -d)
-            cd $tmp
-            # cd /var/empty
+              # TODO: Provide tempdir from outside, to make it securely erasurable as needed?
+              tmp=$(mktemp -d)
+              cd $tmp
+              # cd /var/empty
 
-            created_at=$(date -u +"%Y-%m-%dT%H:%M:%S.%NZ")
+              created_at=$(date -u +"%Y-%m-%dT%H:%M:%S.%NZ")
 
-            ${script}
+              ${script}
 
-            if ! test -d $out; then
-              echo "impure generator script did not produce expected \$out output"
-              exit 1
-            fi
+              if ! test -d $out; then
+                echo "impure generator script did not produce expected \$out output"
+                exit 1
+              fi
 
-            echo -n $created_at > $out/created_at
-            echo -n SUCCESS > $out/marker
-          '')
-          .overrideAttrs (old: {
-            passthru = {
-              inherit impureOn;
-              generatorKind = "impure";
-            };
-          });
-        # Pure generators are disabled for now
-        mkSecretGenerator = {script}: final.mkImpureSecretGenerator {inherit script;};
+              echo -n $created_at > $out/created_at
+              echo -n SUCCESS > $out/marker
+            '')
+            .overrideAttrs (old: {
+              passthru = {
+                inherit impureOn;
+                generatorKind = "impure";
+              };
+            });
+          # Pure generators are disabled for now
+          mkSecretGenerator = {script}: mkImpureSecretGenerator {inherit script;};
 
-        # TODO: Implement consistent naming
-        # Pure secret generator is supposed to be run entirely by nix, using `__impure` derivation type...
-        # But for now, it is ran the same way as `impureSecretGenerator`, but on the local machine.
-        # mkSecretGenerator = {script}:
-        #   (prev.writeShellScript "generator.sh" ''
-        #     #!/bin/sh
-        #     set -eu
-        #     # TODO: make nix daemon build secret, not just the script.
-        #     cd /var/empty
-        #
-        #     created_at=$(date -u +"%Y-%m-%dT%H:%M:%S.%NZ")
-        #
-        #     ${script}
-        #     if ! test -d $out; then
-        #       echo "impure generator script did not produce expected \$out output"
-        #       exit 1
-        #     fi
-        #
-        #     echo -n $created_at > $out/created_at
-        #     echo -n SUCCESS > $out/marker
-        #   '')
-        #   .overrideAttrs (old: {
-        #     passthru = {
-        #       generatorKind = "pure";
-        #     };
-        #     # TODO: make nix daemon build secret, not just the script.
-        #     # __impure = true;
-        #   });
+          # TODO: Implement consistent naming
+          # Pure secret generator is supposed to be run entirely by nix, using `__impure` derivation type...
+          # But for now, it is ran the same way as `impureSecretGenerator`, but on the local machine.
+          # mkSecretGenerator = {script}:
+          #   (prev.writeShellScript "generator.sh" ''
+          #     #!/bin/sh
+          #     set -eu
+          #     # TODO: make nix daemon build secret, not just the script.
+          #     cd /var/empty
+          #
+          #     created_at=$(date -u +"%Y-%m-%dT%H:%M:%S.%NZ")
+          #
+          #     ${script}
+          #     if ! test -d $out; then
+          #       echo "impure generator script did not produce expected \$out output"
+          #       exit 1
+          #     fi
+          #
+          #     echo -n $created_at > $out/created_at
+          #     echo -n SUCCESS > $out/marker
+          #   '')
+          #   .overrideAttrs (old: {
+          #     passthru = {
+          #       generatorKind = "pure";
+          #     };
+          #     # TODO: make nix daemon build secret, not just the script.
+          #     # __impure = true;
+          #   });
+        };
       })
     ];
   };
modifiedpkgs/default.nixdiffbeforeafterboth
--- a/pkgs/default.nix
+++ b/pkgs/default.nix
@@ -2,6 +2,7 @@
   callPackage,
   craneLib,
 }: {
+  fleet = callPackage ./fleet.nix {inherit craneLib;};
   fleet-install-secrets = callPackage ./fleet-install-secrets.nix {inherit craneLib;};
-  fleet = callPackage ./fleet.nix {inherit craneLib;};
+  fleet-generator-helper = callPackage ./fleet-generator-helper.nix {inherit craneLib;};
 }
addedpkgs/fleet-generator-helper.nixdiffbeforeafterboth
--- /dev/null
+++ b/pkgs/fleet-generator-helper.nix
@@ -0,0 +1,13 @@
+{craneLib}:
+craneLib.buildPackage rec {
+  pname = "fleet-generator-helper";
+
+  src = craneLib.cleanCargoSource (craneLib.path ../.);
+  strictDeps = true;
+
+  cargoExtraArgs = "--locked -p ${pname}";
+
+  postInstall = ''
+    ln -s $out/bin/${pname} $out/bin/gh
+  '';
+}
deletedpkgs/generator-helper.nixdiffbeforeafterboth
--- a/pkgs/generator-helper.nix
+++ /dev/null
@@ -1,13 +0,0 @@
-{craneLib}:
-craneLib.buildPackage rec {
-  pname = "fleet-generator-helper";
-
-  src = craneLib.cleanCargoSource (craneLib.path ../.);
-  strictDeps = true;
-
-  cargoExtraArgs = "--locked -p ${pname}";
-
-  postInstall = ''
-    mv bin/${pname} bin/genhelper
-  '';
-}