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
--- a/cmds/fleet/src/cmds/secrets/mod.rs
+++ b/cmds/fleet/src/cmds/secrets/mod.rs
@@ -40,9 +40,6 @@
 		/// Secret public part
 		#[clap(long)]
 		public: Option<String>,
-		/// How to name public secret part
-		#[clap(long, default_value = "public")]
-		public_name: String,
 		/// Load public part from specified file
 		#[clap(long)]
 		public_file: Option<PathBuf>,
@@ -55,14 +52,19 @@
 		#[clap(long)]
 		re_add: bool,
 
-		#[clap(default_value = "secret")]
-		part_name: String,
+		/// How to name public secret part
+		#[clap(long, short = 'p', default_value = "public")]
+		public_part: String,
+		/// How to name private secret part
+		#[clap(short = 's', long, default_value = "secret")]
+		part: String,
 	},
 	/// Add secret, data should be provided in stdin
 	Add {
 		/// Secret name
 		name: String,
-		/// Secret owners
+		/// Secret owner
+		#[clap(short = 'm', long)]
 		machine: String,
 		/// Override secret if already present
 		#[clap(long)]
@@ -70,41 +72,41 @@
 		/// Secret public part
 		#[clap(long)]
 		public: Option<String>,
-		/// How to name public secret part
-		#[clap(long, default_value = "public")]
-		public_name: String,
 		/// Load public part from specified file
 		#[clap(long)]
 		public_file: Option<PathBuf>,
 
-		#[clap(default_value = "secret")]
-		part_name: String,
+		/// How to name public secret part
+		#[clap(short = 'p', long, default_value = "public")]
+		public_part: String,
+		/// How to name private secret part
+		#[clap(short = 's', long, default_value = "secret")]
+		part: String,
 	},
 	/// Read secret from remote host, requires sudo on said host
 	Read {
 		name: String,
+		#[clap(short = 'm', long)]
 		machine: String,
 
-		#[clap(default_value = "secret")]
-		part_name: String,
+		/// Which private secret part to read
+		#[clap(short = 'p', long, default_value = "secret")]
+		part: String,
 	},
 	UpdateShared {
 		name: String,
 
-		#[clap(long)]
-		machines: Option<Vec<String>>,
+		#[clap(short = 'm', long)]
+		machine: Option<Vec<String>>,
 
 		#[clap(long)]
-		add_machines: Vec<String>,
+		add_machine: Vec<String>,
 		#[clap(long)]
-		remove_machines: Vec<String>,
+		remove_machine: Vec<String>,
 
 		/// Which host should we use to decrypt
 		#[clap(long)]
 		prefer_identities: Vec<String>,
-
-		#[clap(default_value = "secret")]
-		part_name: String,
 	},
 	Regenerate {
 		/// Which host should we use to decrypt, in case if reencryption is required, without
@@ -115,13 +117,15 @@
 	List {},
 	Edit {
 		name: String,
+		#[clap(short = 'm', long)]
 		machine: String,
-
-		#[clap(default_value = "secret")]
-		part: String,
 
 		#[clap(long)]
 		add: bool,
+
+		/// Which private secret part to read
+		#[clap(short = 'p', long, default_value = "secret")]
+		part: String,
 	},
 }
 
@@ -220,21 +224,18 @@
 	};
 	let on_pkgs = host.pkgs().await?;
 	let call_package = nix_go!(on_pkgs.callPackage);
-	let mk_encrypt_secret = nix_go!(on_pkgs.mkEncryptSecret);
+	let mk_secret_generators = nix_go!(on_pkgs.mkSecretGenerators);
 
 	let mut recipients = Vec::new();
 	for owner in owners {
 		let key = config.key(owner).await?;
 		recipients.push(key);
 	}
-	let encrypt = nix_go!(mk_encrypt_secret(Obj {
+	let generators = nix_go!(mk_secret_generators(Obj {
 		recipients: { recipients },
 	}));
 
-	let generator = nix_go!(call_package(generator)(Obj {
-		encrypt,
-		// rustfmt_please_newline
-	}));
+	let generator = nix_go!(call_package(generator)(generators));
 
 	let generator = generator.build().await?;
 	let generator = generator
@@ -305,6 +306,7 @@
 	}
 	let default_pkgs = &config.default_pkgs;
 	let default_call_package = nix_go!(default_pkgs.callPackage);
+	let default_mk_secret_generators = nix_go!(default_pkgs.mkSecretGenerators);
 	// Generators provide additional information in passthru, to access
 	// passthru we should call generator, but information about where this generator is supposed to build
 	// is located in passthru... Thus evaluating generator on host.
@@ -313,10 +315,10 @@
 	//
 	// I don't want to make modules always responsible for additional secret data anyway,
 	// so it should be in derivation, and not in the secret data itself.
-	let default_generator = nix_go!(default_call_package(generator)(Obj {
-		encrypt: { "exit 1" },
-		// rustfmt_please_newline
+	let generators = nix_go!(default_mk_secret_generators(Obj {
+		recipients: { <Vec<String>>::new() },
 	}));
+	let default_generator = nix_go!(default_call_package(generator)(generators));
 
 	let kind: GeneratorKind = nix_go_json!(default_generator.generatorKind);
 
@@ -442,11 +444,11 @@
 				name,
 				force,
 				public,
-				public_name,
+				public_part: public_name,
 				public_file,
 				expires_at,
 				re_add,
-				part_name,
+				part: part_name,
 			} => {
 				// TODO: Forbid updating secrets with set expectedOwners (= not user-managed).
 
@@ -500,9 +502,9 @@
 				name,
 				force,
 				public,
-				public_name,
+				public_part: public_name,
 				public_file,
-				part_name,
+				part: part_name,
 			} => {
 				if config.has_secret(&machine, &name) && !force {
 					bail!("secret already defined");
@@ -535,7 +537,7 @@
 			Secret::Read {
 				name,
 				machine,
-				part_name,
+				part: part_name,
 			} => {
 				let secret = config.host_secret(&machine, &name)?;
 				let Some(secret) = secret.parts.get(&part_name) else {
@@ -552,25 +554,24 @@
 			}
 			Secret::UpdateShared {
 				name,
-				machines,
-				add_machines,
-				remove_machines,
+				machine,
+				add_machine,
+				remove_machine,
 				prefer_identities,
-				part_name,
 			} => {
 				// TODO: Forbid updating secrets with set expectedOwners (= not user-managed).
 
 				let secret = config.shared_secret(&name)?;
-				if secret.secret.parts.get(&part_name).is_none() {
+				if secret.secret.parts.values().all(|v| !v.raw.encrypted) {
 					bail!("no secret");
 				}
 
 				let initial_machines = secret.owners.clone();
 				let target_machines = parse_machines(
 					initial_machines.clone(),
-					machines,
-					add_machines,
-					remove_machines,
+					machine,
+					add_machine,
+					remove_machine,
 				)?;
 
 				if target_machines.is_empty() {
modifiedcmds/fleet/src/host.rsdiffbeforeafterboth
before · cmds/fleet/src/host.rs
1use std::{2	env::current_dir,3	ffi::{OsStr, OsString},4	fmt::Display,5	io::Write,6	ops::Deref,7	path::PathBuf,8	str::FromStr,9	sync::{Arc, Mutex, MutexGuard, OnceLock},10};1112use anyhow::{anyhow, bail, ensure, Context, Result};13use clap::{ArgGroup, Parser};14use fleet_shared::SecretData;15use nix_eval::{nix_go, nix_go_json, NixSessionPool, Value};16use openssh::SessionBuilder;17use serde::de::DeserializeOwned;18use tempfile::NamedTempFile;1920use crate::{21	command::MyCommand,22	fleetdata::{FleetData, FleetSecret, FleetSharedSecret},23};2425pub struct FleetConfigInternals {26	pub local_system: String,27	pub directory: PathBuf,28	pub opts: FleetOpts,29	pub data: Mutex<FleetData>,30	pub nix_args: Vec<OsString>,31	/// fleet_config.config32	pub config_field: Value,33	/// fleet_config.unchecked.config34	pub config_unchecked_field: Value,3536	/// import nixpkgs {system = local};37	pub default_pkgs: Value,38}3940#[derive(Clone)]41pub struct Config(Arc<FleetConfigInternals>);4243impl Deref for Config {44	type Target = FleetConfigInternals;4546	fn deref(&self) -> &Self::Target {47		&self.048	}49}5051pub struct ConfigHost {52	config: Config,53	pub name: String,54	pub local: bool,55	pub session: OnceLock<Arc<openssh::Session>>,5657	pub nixos_config: Option<Value>,58}59impl ConfigHost {60	async fn open_session(&self) -> Result<Arc<openssh::Session>> {61		assert!(!self.local, "do not open ssh connection to local session");62		// FIXME: TOCTOU63		if let Some(session) = &self.session.get() {64			return Ok((*session).clone());65		};66		let session = SessionBuilder::default();6768		let session = session69			.connect(&self.name)70			.await71			.map_err(|e| anyhow!("ssh error while connecting to {}: {e}", self.name))?;72		let session = Arc::new(session);73		self.session.set(session.clone()).expect("TOCTOU happened");74		Ok(session)75	}76	pub async fn mktemp_dir(&self) -> Result<String> {77		let mut cmd = self.cmd("mktemp").await?;78		cmd.arg("-d");79		let path = cmd.run_string().await?;80		Ok(path.trim_end().to_owned())81	}82	pub async fn read_file_bin(&self, path: impl AsRef<OsStr>) -> Result<Vec<u8>> {83		let mut cmd = self.cmd("cat").await?;84		cmd.arg(path);85		cmd.run_bytes().await86	}87	pub async fn read_file_text(&self, path: impl AsRef<OsStr>) -> Result<String> {88		let mut cmd = self.cmd("cat").await?;89		cmd.arg(path);90		cmd.run_string().await91	}92	pub async fn read_dir(&self, path: impl AsRef<OsStr>) -> Result<Vec<String>> {93		let mut cmd = self.cmd("ls").await?;94		cmd.arg(path);95		let out = cmd.run_string().await?;96		let mut lines = out.split('\n');97		if let Some(last) = lines.next_back() {98			ensure!(last == "", "output of ls should end with newline");99		}100		Ok(lines.map(ToOwned::to_owned).collect())101	}102	#[allow(dead_code)]103	pub async fn read_file_json<D: DeserializeOwned>(&self, path: impl AsRef<OsStr>) -> Result<D> {104		let text = self.read_file_text(path).await?;105		Ok(serde_json::from_str(&text)?)106	}107	pub async fn read_file_value<D: FromStr>(&self, path: impl AsRef<OsStr>) -> Result<D>108	where109		<D as FromStr>::Err: Display,110	{111		let text = self.read_file_text(path).await?;112		D::from_str(&text).map_err(|e| anyhow!("failed to parse value: {e}"))113	}114	pub async fn cmd(&self, cmd: impl AsRef<OsStr>) -> Result<MyCommand> {115		if self.local {116			Ok(MyCommand::new(cmd))117		} else {118			let session = self.open_session().await?;119			Ok(MyCommand::new_on(cmd, session))120		}121	}122123	pub async fn decrypt(&self, data: SecretData) -> Result<Vec<u8>> {124		ensure!(data.encrypted, "secret is not encrypted");125		let mut cmd = self.cmd("fleet-install-secrets").await?;126		cmd.arg("decrypt").eqarg("--secret", data.to_string());127		let encoded = cmd128			.sudo()129			.run_string()130			.await131			.context("failed to call remote host for decrypt")?;132		let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;133		ensure!(!data.encrypted, "didn't decrypted secret");134		Ok(data.data)135	}136	pub async fn reencrypt(&self, data: SecretData, targets: Vec<String>) -> Result<SecretData> {137		ensure!(data.encrypted, "secret is not encrypted");138		let mut cmd = self.cmd("fleet-install-secrets").await?;139		cmd.arg("reencrypt").eqarg("--secret", data.to_string());140		for target in targets {141			let key = self.config.key(&target).await?;142			cmd.eqarg("--targets", key);143		}144		let encoded = cmd145			.sudo()146			.run_string()147			.await148			.context("failed to call remote host for decrypt")?;149		let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;150		ensure!(!data.encrypted, "didn't decrypted secret");151		Ok(data)152	}153	/// Returns path for futureproofing, as path might change i.e on conversion to CA154	pub async fn remote_derivation(&self, path: &PathBuf) -> Result<PathBuf> {155		if self.local {156			// Path is located locally, thus already trusted.157			return Ok(path.to_owned());158		}159		let mut nix = MyCommand::new("nix");160		nix.arg("copy")161			.arg("--substitute-on-destination")162			.comparg("--to", format!("ssh-ng://{}", self.name))163			.arg(path);164		nix.run_nix().await.context("nix copy")?;165		Ok(path.to_owned())166	}167	pub async fn systemctl_stop(&self, name: &str) -> Result<()> {168		let mut cmd = self.cmd("systemctl").await?;169		cmd.arg("stop").arg(name);170		cmd.sudo().run().await171	}172	pub async fn systemctl_start(&self, name: &str) -> Result<()> {173		let mut cmd = self.cmd("systemctl").await?;174		cmd.arg("start").arg(name);175		cmd.sudo().run().await176	}177178	pub async fn rm_file(&self, path: impl AsRef<OsStr>, sudo: bool) -> Result<()> {179		let mut cmd = self.cmd("rm").await?;180		cmd.arg("-f").arg(path);181		if sudo {182			cmd = cmd.sudo()183		}184		cmd.run().await185	}186187	pub async fn list_configured_secrets(&self) -> Result<Vec<String>> {188		let Some(nixos) = &self.nixos_config else {189			return Ok(vec![]);190		};191		let secrets = nix_go!(nixos.secrets);192		let mut out = Vec::new();193		for name in secrets.list_fields().await? {194			let secret = nix_go!(secrets[{ name }]);195			let is_shared: bool = nix_go_json!(secret.shared);196			if is_shared {197				continue;198			}199			out.push(name);200		}201		Ok(out)202	}203	pub async fn secret_field(&self, name: &str) -> Result<Value> {204		let Some(nixos) = &self.nixos_config else {205			bail!("host is virtual and has no secrets");206		};207		Ok(nix_go!(nixos.secrets[{ name }]))208	}209210	/// Packages for this host, resolved with nixpkgs overlays211	pub async fn pkgs(&self) -> Result<Value> {212		let Some(nixos) = &self.nixos_config else {213			return Ok(self.config.default_pkgs.clone());214		};215		Ok(nix_go!(nixos.nixpkgs.resolvedPkgs))216	}217}218219impl Config {220	pub fn should_skip(&self, host: &str) -> bool {221		if !self.opts.skip.is_empty() {222			self.opts.skip.iter().any(|h| h as &str == host)223		} else if !self.opts.only.is_empty() {224			!self.opts.only.iter().any(|h| h as &str == host)225		} else {226			false227		}228	}229	pub fn is_local(&self, host: &str) -> bool {230		self.opts.localhost.as_ref().map(|s| s as &str) == Some(host)231	}232233	pub fn local_host(&self) -> ConfigHost {234		ConfigHost {235			config: self.clone(),236			name: "<virtual localhost>".to_owned(),237			local: true,238			session: OnceLock::new(),239			nixos_config: None,240		}241	}242243	pub async fn host(&self, name: &str) -> Result<ConfigHost> {244		let config = &self.config_unchecked_field;245		let nixos_config = nix_go!(config.hosts[{ name }].nixosSystem.config);246		Ok(ConfigHost {247			config: self.clone(),248			name: name.to_owned(),249			local: self.is_local(name),250			session: OnceLock::new(),251			nixos_config: Some(nixos_config),252		})253	}254	pub async fn list_hosts(&self) -> Result<Vec<ConfigHost>> {255		let config = &self.config_unchecked_field;256		let names = nix_go!(config.hosts).list_fields().await?;257		let mut out = vec![];258		for name in names {259			out.push(self.host(&name).await?);260		}261		Ok(out)262	}263	pub async fn system_config(&self, host: &str) -> Result<Value> {264		let fleet_field = &self.config_unchecked_field;265		Ok(nix_go!(fleet_field.hosts[{ host }].nixosSystem.config))266	}267268	pub(super) fn data(&self) -> MutexGuard<FleetData> {269		self.data.lock().unwrap()270	}271	pub(super) fn data_mut(&self) -> MutexGuard<FleetData> {272		self.data.lock().unwrap()273	}274	/// Shared secrets configured in fleet.nix or in flake275	pub async fn list_configured_shared(&self) -> Result<Vec<String>> {276		let config_field = &self.config_unchecked_field;277		Ok(nix_go!(config_field.sharedSecrets).list_fields().await?)278	}279	/// Shared secrets configured in fleet.nix280	pub fn list_shared(&self) -> Vec<String> {281		let data = self.data();282		data.shared_secrets.keys().cloned().collect()283	}284	pub fn has_shared(&self, name: &str) -> bool {285		let data = self.data();286		data.shared_secrets.contains_key(name)287	}288	pub fn replace_shared(&self, name: String, shared: FleetSharedSecret) {289		let mut data = self.data_mut();290		data.shared_secrets.insert(name.to_owned(), shared);291	}292	pub fn remove_shared(&self, secret: &str) {293		let mut data = self.data_mut();294		data.shared_secrets.remove(secret);295	}296297	pub fn list_secrets(&self, host: &str) -> Vec<String> {298		let data = self.data();299		let Some(secrets) = data.host_secrets.get(host) else {300			return Vec::new();301		};302		secrets.keys().cloned().collect()303	}304305	pub fn has_secret(&self, host: &str, secret: &str) -> bool {306		let data = self.data();307		let Some(host_secrets) = data.host_secrets.get(host) else {308			return false;309		};310		host_secrets.contains_key(secret)311	}312	pub fn insert_secret(&self, host: &str, secret: String, value: FleetSecret) {313		let mut data = self.data_mut();314		let host_secrets = data.host_secrets.entry(host.to_owned()).or_default();315		host_secrets.insert(secret, value);316	}317318	pub fn host_secret(&self, host: &str, secret: &str) -> Result<FleetSecret> {319		let data = self.data();320		let Some(host_secrets) = data.host_secrets.get(host) else {321			bail!("no secrets for machine {host}");322		};323		let Some(secret) = host_secrets.get(secret) else {324			bail!("machine {host} has no secret {secret}");325		};326		Ok(secret.clone())327	}328	pub fn shared_secret(&self, secret: &str) -> Result<FleetSharedSecret> {329		let data = self.data();330		let Some(secret) = data.shared_secrets.get(secret) else {331			bail!("no shared secret {secret}");332		};333		Ok(secret.clone())334	}335	pub async fn shared_secret_expected_owners(&self, secret: &str) -> Result<Vec<String>> {336		let config_field = &self.config_unchecked_field;337		Ok(nix_go_json!(338			config_field.sharedSecrets[{ secret }].expectedOwners339		))340	}341342	pub fn save(&self) -> Result<()> {343		let mut tempfile = NamedTempFile::new_in(self.directory.clone()).context("failed to create updated version of fleet.nix in the same directory as original.\nDo you have write access to it? Access only to the fleet.nix won't be enough, the directory is used for atomic overwrite operation.\nIt is not recommended to use fleet by root anyway, move fleet project to your home directory.")?;344		let data = nixlike::serialize(&self.data() as &FleetData)?;345		tempfile.write_all(346			format!(347				"# This file contains fleet state and shouldn't be edited by hand\n\n{}\n\n# vim: ts=2 et nowrap\n",348				data349			)350			.as_bytes(),351		)?;352		let mut fleet_data_path = self.directory.clone();353		fleet_data_path.push("fleet.nix");354		tempfile.persist(fleet_data_path)?;355		Ok(())356	}357}358359#[derive(Parser, Clone)]360#[clap(group = ArgGroup::new("target_hosts"))]361pub struct FleetOpts {362	/// All hosts except those would be skipped363	#[clap(long, number_of_values = 1, group = "target_hosts")]364	only: Vec<String>,365366	/// Hosts to skip367	#[clap(long, number_of_values = 1, group = "target_hosts")]368	skip: Vec<String>,369370	/// Host, which should be threaten as current machine371	#[clap(long)]372	pub localhost: Option<String>,373374	/// Override detected system for host, to perform builds via375	/// binfmt-declared qemu instead of trying to crosscompile376	#[clap(long, default_value = "detect")]377	pub local_system: String,378}379380impl FleetOpts {381	pub async fn build(mut self, nix_args: Vec<OsString>) -> Result<Config> {382		if self.localhost.is_none() {383			self.localhost384				.replace(hostname::get().unwrap().to_str().unwrap().to_owned());385		}386		let directory = current_dir()?;387388		let pool = NixSessionPool::new(directory.as_os_str().to_owned(), nix_args.clone()).await?;389		let root_field = pool.get().await?;390391		let builtins_field = Value::binding(root_field.clone(), "builtins").await?;392		if self.local_system == "detect" {393			self.local_system = nix_go_json!(builtins_field.currentSystem);394		}395		let local_system = self.local_system.clone();396397		let fleet_root = Value::binding(root_field, "fleetConfigurations").await?;398		let fleet_field = nix_go!(fleet_root.default);399400		let config_field = nix_go!(fleet_field.config);401		let config_unchecked_field = nix_go!(fleet_field.unchecked.config);402403		let import = nix_go!(builtins_field.import);404		let overlays = nix_go!(config_unchecked_field.overlays);405		let nixpkgs = nix_go!(fleet_field.nixpkgs | import);406407		let default_pkgs = nix_go!(nixpkgs(Obj {408			overlays,409			system: { self.local_system.clone() },410		}));411412		let mut fleet_data_path = directory.clone();413		fleet_data_path.push("fleet.nix");414		let bytes = std::fs::read_to_string(fleet_data_path)?;415		let data = nixlike::parse_str(&bytes)?;416417		Ok(Config(Arc::new(FleetConfigInternals {418			opts: self,419			directory,420			data,421			local_system,422			nix_args,423			config_field,424			config_unchecked_field,425			default_pkgs,426		})))427	}428}
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
-  '';
-}