git.delta.rocks / jrsonnet / refs/commits / 837e795f702e

difftreelog

feat reencrypt secret on remote server

Yaroslav Bolyukin2023-05-01parent: #d5e1b5f.patch.diff
in: trunk

8 files changed

modifiedcmds/fleet/src/cmds/info.rsdiffbeforeafterboth
--- a/cmds/fleet/src/cmds/info.rs
+++ b/cmds/fleet/src/cmds/info.rs
@@ -1,15 +1,8 @@
-use std::{collections::BTreeSet, time::Duration};
+use std::collections::BTreeSet;
 
-use crate::{command::CommandExt, host::Config};
-use anyhow::{bail, ensure, Result};
+use crate::host::Config;
+use anyhow::{ensure, Result};
 use clap::Parser;
-use nixlike::format_nix;
-use serde_json::{json, Value};
-use tokio::{
-	fs::{self, File},
-	io::AsyncWriteExt,
-	process::Command,
-};
 
 #[derive(Parser)]
 pub struct Info {
modifiedcmds/fleet/src/cmds/secrets/mod.rsdiffbeforeafterboth
before · cmds/fleet/src/cmds/secrets/mod.rs
1use crate::{2	fleetdata::{FleetSecret, FleetSharedSecret},3	host::Config,4};5use age::{Decryptor, Encryptor};6use anyhow::{bail, ensure, Context, Result};7use clap::Parser;8use futures::{StreamExt, TryStreamExt};9use std::{10	collections::HashSet,11	io::{self, Cursor, Read, Write},12	iter,13	path::PathBuf,14};15use tracing::{info, warn};1617#[derive(Parser)]18pub enum Secrets {19	/// Force load keys for all defined hosts20	ForceKeys,21	/// Add secret, data should be provided in stdin22	AddShared {23		/// Secret name24		name: String,25		/// Secret owners26		machines: Vec<String>,27		/// Override secret if already present28		#[clap(long)]29		force: bool,30		#[clap(long)]31		public: Option<String>,32		#[clap(long)]33		public_file: Option<PathBuf>,34	},35	/// Add secret, data should be provided in stdin36	Add {37		/// Secret name38		name: String,39		/// Secret owners40		machine: String,41		/// Override secret if already present42		#[clap(long)]43		force: bool,44		#[clap(long)]45		public: Option<String>,46		#[clap(long)]47		public_file: Option<PathBuf>,48	},49	/// Read secret from remote host, requires sudo on said host50	Read {51		name: String,52		machine: String,53	},54	UpdateShared {55		name: String,5657		machines: Option<Vec<String>>,5859		add_machines: Vec<String>,60		remove_machines: Vec<String>,6162		/// Which host should we use to decrypt63		prefer_identities: Vec<String>,64	},65	Regenerate,66}6768impl Secrets {69	pub async fn run(self, config: &Config) -> Result<()> {70		match self {71			Secrets::ForceKeys => {72				for host in config.list_hosts().await? {73					if config.should_skip(&host) {74						continue;75					}76					config.key(&host).await?;77				}78			}79			Secrets::AddShared {80				machines,81				name,82				force,83				public,84				public_file,85			} => {86				let recipients = futures::stream::iter(machines.iter())87					.then(|m| config.recipient(m))88					.try_collect::<Vec<_>>()89					.await?;9091				let secret = {92					let mut input = vec![];93					io::stdin().read_to_end(&mut input)?;9495					if input.is_empty() {96						input97					} else {98						let mut encrypted = vec![];99						let recipients = recipients100							.iter()101							.cloned()102							.map(|r| Box::new(r) as Box<dyn age::Recipient + Send>)103							.collect();104						let mut encryptor = age::Encryptor::with_recipients(recipients)105							.expect("recipients provided")106							.wrap_output(&mut encrypted)?;107						io::copy(&mut Cursor::new(input), &mut encryptor)?;108						encryptor.finish()?;109						encrypted110					}111				};112113				let mut data = config.data_mut();114				if data.shared_secrets.contains_key(&name) && !force {115					bail!("secret already defined");116				}117				data.shared_secrets.insert(118					name,119					FleetSharedSecret {120						owners: machines,121						secret: FleetSecret {122							expire_at: None,123							secret,124							public: match (public, public_file) {125								(Some(v), None) => Some(v),126								(None, Some(v)) => Some(std::fs::read_to_string(v)?),127								(Some(_), Some(_)) => {128									bail!("only public or public_file should be set")129								}130								(None, None) => None,131							},132						},133					},134				);135			}136			Secrets::Add {137				machine,138				name,139				force,140				public,141				public_file,142			} => {143				let recipient = config.recipient(&machine).await?;144145				let secret = {146					let mut input = vec![];147					io::stdin().read_to_end(&mut input)?;148					if input.is_empty() {149						bail!("no data provided")150					}151152					let mut encrypted = vec![];153					let recipient = Box::new(recipient) as Box<dyn age::Recipient + Send>;154					let mut encryptor = age::Encryptor::with_recipients(vec![recipient])155						.expect("recipients provided")156						.wrap_output(&mut encrypted)?;157					io::copy(&mut Cursor::new(input), &mut encryptor)?;158					encryptor.finish()?;159					encrypted160				};161162				let mut data = config.data_mut();163				let host_secrets = data.host_secrets.entry(machine).or_default();164				if host_secrets.contains_key(&name) && !force {165					bail!("secret already defined");166				}167				host_secrets.insert(168					name,169					FleetSecret {170						expire_at: None,171						secret,172						public: match (public, public_file) {173							(Some(v), None) => Some(v),174							(None, Some(v)) => Some(std::fs::read_to_string(v)?),175							(Some(_), Some(_)) => bail!("only public or public_file should be set"),176							(None, None) => None,177						},178					},179				);180			}181			// TODO: Instead of using sudo, decode secret on remote machine182			#[allow(clippy::await_holding_refcell_ref)]183			Secrets::Read { name, machine } => {184				let data = config.data();185186				let Some(host_secrets) = data.host_secrets.get(&machine) else {187                    bail!("no secrets for machine {machine}");188                };189				let Some(secret) = host_secrets.get(&name) else {190                    bail!("machine {machine} has no secret {name}");191                };192				if secret.secret.is_empty() {193					bail!("no secret {name}");194				}195				let identity = config.identity(&machine).await?;196				let decryptor = Decryptor::new(Cursor::new(&secret.secret))?;197				let decryptor = match decryptor {198					Decryptor::Recipients(r) => r,199					Decryptor::Passphrase(_) => bail!("should be recipients"),200				};201				let mut decryptor = decryptor202					.decrypt(iter::once(&identity as &dyn age::Identity))203					.context("failed to decrypt, wrong key?")?;204205				let mut decrypted = Vec::new();206				decryptor207					.read_to_end(&mut decrypted)208					.context("failed to decrypt")?;209				// secret.secret210				std::io::stdout().lock().write_all(&decrypted)?;211			}212			Secrets::UpdateShared {213				name,214				machines,215				mut add_machines,216				mut remove_machines,217				prefer_identities,218			} => {219				let mut data = config.data_mut();220				if machines.is_none() && add_machines.is_empty() && remove_machines.is_empty() {221					bail!("no operation");222				}223224				let Some(mut secret) = data.shared_secrets.get_mut(&name) else {225                    bail!("no shared secret {name}");226                };227				if secret.secret.secret.is_empty() {228					bail!("no secret");229				}230231				let initial_machines = secret.owners.clone();232				let mut target_machines = secret.owners.clone();233234				// ensure!(machines.is_some() || !add_machines.is_empty() || )235				if let Some(machines) = machines {236					ensure!(237						add_machines.is_empty() && remove_machines.is_empty(),238						"can't combine --machines and --add-machines/--remove-machines"239					);240					let target = initial_machines.iter().collect::<HashSet<_>>();241					let source = machines.iter().collect::<HashSet<_>>();242					for removed in target.difference(&source) {243						remove_machines.push((*removed).clone());244					}245					for added in source.difference(&target) {246						add_machines.push((*added).clone());247					}248				}249250				for machine in &remove_machines {251					let mut removed = false;252					while let Some(pos) = target_machines.iter().position(|m| m == machine) {253						target_machines.swap_remove(pos);254						removed = true;255					}256					if !removed {257						bail!("secret is not enabled for {machine}");258					}259				}260				for machine in &add_machines {261					if target_machines.iter().any(|m| m == machine) {262						warn!("secret is already added to {machine}");263					}264				}265				if remove_machines.is_empty() {266					warn!("secret will not be regenerated for removed machines, and until host rebuild, they will still possess the ability to decode secret");267				}268				if target_machines.is_empty() {269					info!("no machines left for secret, removing it");270					data.shared_secrets.remove(&name);271					return Ok(());272				}273274				let identity_holder = if !prefer_identities.is_empty() {275					prefer_identities276						.iter()277						.find(|i| initial_machines.iter().any(|s| s == *i))278				} else {279					secret.owners.first()280				};281				let Some(identity_holder) = identity_holder else {282                    bail!("no available holder found");283                };284				let target_recipients = futures::stream::iter(&target_machines)285					.flat_map(|m| futures::stream::once(config.recipient(m)))286					.collect::<Vec<_>>()287					.await288					.into_iter()289					.map(|v| v.map(|v| Box::new(v) as Box<dyn age::Recipient + Send>))290					.collect::<Result<Vec<_>>>()?;291292				let identity = config.identity(identity_holder).await?;293				let decryptor = Decryptor::new(Cursor::new(&secret.secret.secret))?;294				let decryptor = match decryptor {295					Decryptor::Recipients(r) => r,296					Decryptor::Passphrase(_) => bail!("should be recipients"),297				};298				let mut decryptor = decryptor299					.decrypt(iter::once(&identity as &dyn age::Identity))300					.context("failed to decrypt, wrong key?")?;301302				let mut decrypted = Vec::new();303				decryptor304					.read_to_end(&mut decrypted)305					.context("failed to decrypt")?;306307				let mut encrypted = vec![];308				let mut encryptor = Encryptor::with_recipients(target_recipients)309					.expect("recipients provided")310					.wrap_output(&mut encrypted)?;311				io::copy(&mut Cursor::new(decrypted), &mut encryptor)?;312				encryptor.finish()?;313314				secret.secret.secret = encrypted;315			}316			Secrets::Regenerate => {317				// config.data_mut().shared_secrets318				{319					let expected_shared_set =320						config.shared_config_attr_names("sharedSecrets").await?;321					let expected_shared_set = expected_shared_set.iter().collect::<HashSet<_>>();322					let shared_set = config.data();323					let shared_set = shared_set.shared_secrets.keys().collect::<HashSet<_>>();324					for removed in expected_shared_set.difference(&shared_set) {325						warn!("secret needs to be generated: {removed}")326					}327				}328				let mut to_remove = Vec::new();329				for (name, data) in &config.data().shared_secrets {330					let expected_owners: Vec<String> = config331						.shared_config_attr(&format!("sharedSecrets.\"{name}\".expectedOwners"))332						.await?;333					if expected_owners.is_empty() {334						warn!("secret was removed from fleet config: {name}, removing from data");335						to_remove.push(name.to_string());336						continue;337					}338					let set = data.owners.iter().collect::<HashSet<_>>();339					let expected_set = expected_owners.iter().collect::<HashSet<_>>();340					if set != expected_set {341						warn!("reconfiguring owners for {name}");342					}343				}344				for k in to_remove {345					config.data_mut().shared_secrets.remove(&k);346				}347			}348		}349		Ok(())350	}351}
modifiedcmds/fleet/src/fleetdata.rsdiffbeforeafterboth
--- a/cmds/fleet/src/fleetdata.rs
+++ b/cmds/fleet/src/fleetdata.rs
@@ -1,8 +1,7 @@
-use anyhow::{bail, Result};
+use anyhow::Result;
 use chrono::{DateTime, Utc};
 use nixlike::format_nix;
 use serde::{Deserialize, Deserializer, Serialize, Serializer};
-use serde_json::{json, Value};
 use std::collections::BTreeMap;
 use tempfile::TempDir;
 use tokio::{
@@ -11,8 +10,6 @@
 	process::Command,
 };
 
-use crate::command::CommandExt;
-
 #[derive(Serialize, Deserialize, Default)]
 #[serde(rename_all = "camelCase")]
 pub struct HostData {
@@ -34,16 +31,18 @@
 	pub host_secrets: BTreeMap<String, BTreeMap<String, FleetSecret>>,
 }
 
-#[derive(Serialize, Deserialize)]
+#[derive(Serialize, Deserialize, Clone)]
 #[serde(rename_all = "camelCase")]
+#[must_use]
 pub struct FleetSharedSecret {
 	pub owners: Vec<String>,
 	#[serde(flatten)]
 	pub secret: FleetSecret,
 }
 
-#[derive(Serialize, Deserialize)]
+#[derive(Serialize, Deserialize, Clone)]
 #[serde(rename_all = "camelCase")]
+#[must_use]
 pub struct FleetSecret {
 	#[serde(default)]
 	#[serde(skip_serializing_if = "Option::is_none")]
@@ -75,6 +74,8 @@
 		.and_then(|string| z85::decode(string).map_err(|err| Error::custom(err.to_string())))
 }
 
+/// Isn't used yet
+#[allow(dead_code)]
 pub async fn dummy_flake() -> Result<TempDir> {
 	let data_str = fs::read_to_string("fleet.nix").await?;
 
modifiedcmds/fleet/src/host.rsdiffbeforeafterboth
--- a/cmds/fleet/src/host.rs
+++ b/cmds/fleet/src/host.rs
@@ -8,15 +8,15 @@
 	sync::Arc,
 };
 
-use anyhow::Result;
+use anyhow::{Result, bail, Context};
 use clap::{ArgGroup, Parser};
 use serde::de::DeserializeOwned;
-use tempfile::{NamedTempFile, TempDir};
+use tempfile::NamedTempFile;
 use tokio::process::Command;
 
 use crate::{
 	command::CommandExt,
-	fleetdata::{dummy_flake, FleetData},
+	fleetdata::{FleetData, FleetSecret, FleetSharedSecret},
 };
 
 pub struct FleetConfigInternals {
@@ -125,13 +125,93 @@
 			.await
 	}
 
-	pub fn data(&self) -> Ref<FleetData> {
+	pub(super) fn data(&self) -> Ref<FleetData> {
 		self.data.borrow()
 	}
-	pub fn data_mut(&self) -> RefMut<FleetData> {
+	pub(super) fn data_mut(&self) -> RefMut<FleetData> {
 		self.data.borrow_mut()
 	}
 
+	pub fn list_shared(&self) -> Vec<String> {
+		let data = self.data();
+		data.shared_secrets.keys().cloned().collect()
+	}
+	pub fn has_shared(&self, name: &str) -> bool {
+		let data = self.data();
+		data.shared_secrets.contains_key(name)
+	}
+	pub fn replace_shared(&self, name: String, shared: FleetSharedSecret) {
+		let mut data = self.data_mut();
+		data.shared_secrets.insert(name.to_owned(), shared);
+	}
+	pub fn remove_shared(&self, secret: &str) {
+		let mut data = self.data_mut();
+		data.shared_secrets.remove(secret);
+	}
+
+	pub fn list_secrets(&self, host: &str) -> Vec<String> {
+		let data = self.data();
+		let Some(host_secrets) = data.host_secrets.get(host) else {
+			return Vec::new(); 
+		};
+		host_secrets.keys().cloned().collect()
+	}
+	pub fn has_secret(&self, host: &str, secret: &str) -> bool {
+		let data = self.data();
+		let Some(host_secrets) = data.host_secrets.get(host) else {
+			return false; 
+		};
+		host_secrets.contains_key(secret)
+	}
+	pub fn insert_secret(&self, host: &str, secret: String, value: FleetSecret) {
+		let mut data = self.data_mut();
+		let host_secrets = data.host_secrets.entry(host.to_owned()).or_default();
+		host_secrets.insert(secret, value);
+	}
+
+	pub async fn decrypt_on_host(&self, host: &str, data: Vec<u8>) -> Result<Vec<u8>>{
+		let data = z85::encode(&data);
+		let encoded = self.command_on(host, "fleet-install-secrets", true)
+			.arg("decrypt")
+			.arg("--secret")
+			.arg(data).run_string().await.context("failed to call remote host for decrypt")?.trim().to_owned();
+		Ok(z85::decode(encoded).context("bad encoded data? outdated host?")?)
+	}
+	pub async fn reencrypt_on_host(&self, host: &str, data: Vec<u8>, targets: Vec<String>) -> Result<Vec<u8>>{
+		let data = z85::encode(&data);
+		let mut recmd = self.command_on(host, "fleet-install-secrets", true);
+		recmd
+			.arg("reencrypt")
+			.arg("--secret")
+			.arg(format!("\"{}\"", data.replace('$', "\\$")));
+		for target in targets {
+			recmd.arg("--targets");
+			recmd.arg(format!("\"{target}\""));
+		}
+		let encoded = recmd.run_string().await.context("failed to call remote host for decrypt")?.trim().to_owned();
+		Ok(z85::decode(encoded).context("bad encoded data? outdated host?")?)
+	}
+
+	#[must_use]
+	pub fn host_secret(&self, host: &str, secret: &str) -> Result<FleetSecret> {
+		let data = self.data();
+		let Some(host_secrets) = data.host_secrets.get(host) else {
+            bail!("no secrets for machine {host}");
+        };
+		let Some(secret) = host_secrets.get(secret) else {
+            bail!("machine {host} has no secret {secret}");
+        };
+		Ok(secret.clone())
+	}
+	#[must_use]
+	pub fn shared_secret(&self, secret: &str) -> Result<FleetSharedSecret> {
+		let data = self.data();
+		let Some(secret) = data.shared_secrets.get(secret) else {
+			bail!("no shared secret {secret}");
+		};
+		Ok(secret.clone())
+	}
+
 	pub fn save(&self) -> Result<()> {
 		let mut tempfile = NamedTempFile::new_in(self.directory.clone())?;
 		let data = nixlike::serialize(&self.data() as &FleetData)?;
modifiedcmds/fleet/src/keys.rsdiffbeforeafterboth
--- a/cmds/fleet/src/keys.rs
+++ b/cmds/fleet/src/keys.rs
@@ -36,15 +36,6 @@
 		}
 	}
 	/// Insecure, requires root
-	pub async fn identity(&self, host: &str) -> anyhow::Result<age::ssh::Identity> {
-		warn!("Loading private key for {host}");
-		let key = self
-			.command_on(host, "cat", true)
-			.arg("/etc/ssh/ssh_host_ed25519_key")
-			.run_string()
-			.await?;
-		Ok(age::ssh::Identity::from_buffer(key.as_bytes(), None)?)
-	}
 	pub async fn recipient(&self, host: &str) -> anyhow::Result<age::ssh::Recipient> {
 		let key = self.key(host).await?;
 		age::ssh::Recipient::from_str(&key).map_err(|e| anyhow!("parse recipient error: {:?}", e))
modifiedcmds/install-secrets/src/main.rsdiffbeforeafterboth
--- a/cmds/install-secrets/src/main.rs
+++ b/cmds/install-secrets/src/main.rs
@@ -1,21 +1,66 @@
-use age::Decryptor;
+use age::{ssh::Identity as SshIdentity, ssh::Recipient as SshRecipient, Decryptor};
+use age::{Encryptor, Identity, Recipient};
 use anyhow::{anyhow, bail, Context, Result};
 use clap::Parser;
 use log::{error, info, warn};
 use nix::sys::stat::Mode;
 use nix::unistd::{chown, Group, User};
 use serde::{Deserialize, Deserializer};
+use std::fmt::{self, Display};
 use std::fs::{self, File};
 use std::io::{self, Cursor, Read, Write};
 use std::iter;
 use std::os::unix::prelude::PermissionsExt;
-use std::str::from_utf8;
+use std::path::Path;
+use std::str::{from_utf8, FromStr};
 use std::{collections::HashMap, path::PathBuf};
 
+#[derive(Clone, Debug)]
+struct SecretWrapper(Vec<u8>);
+impl Display for SecretWrapper {
+	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+		let encoded = z85::encode(&self.0);
+		write!(f, "{encoded}")
+	}
+}
+impl FromStr for SecretWrapper {
+	type Err = z85::DecodeError;
+
+	fn from_str(s: &str) -> Result<Self, Self::Err> {
+		z85::decode(s).map(Self)
+	}
+}
+impl<'de> Deserialize<'de> for SecretWrapper {
+	fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+	where
+		D: Deserializer<'de>,
+	{
+		let v = String::deserialize(deserializer)?;
+		let de = z85::decode(v).map_err(|err| serde::de::Error::custom(err.to_string()))?;
+		Ok(Self(de))
+	}
+}
+
 #[derive(Parser)]
 #[clap(author)]
-struct Opts {
-	data: PathBuf,
+enum Opts {
+	/// Install secrets from json specification
+	Install { data: PathBuf },
+	/// Reencrypt secret using host key, outputting in z85 encoded string
+	Reencrypt {
+		#[clap(long)]
+		secret: SecretWrapper,
+		#[clap(long)]
+		targets: Vec<String>,
+	},
+	/// Decrypt secret using host key, outputting in z85 encoded string
+	Decrypt {
+		#[clap(long)]
+		secret: SecretWrapper,
+		/// Shoult decoded output be printed as plaintext, instead of z85?
+		#[clap(long)]
+		plaintext: bool,
+	},
 }
 
 #[derive(Deserialize)]
@@ -25,8 +70,7 @@
 	mode: String,
 	owner: String,
 
-	#[serde(deserialize_with = "from_z85")]
-	secret: Option<Vec<u8>>,
+	secret: Option<SecretWrapper>,
 	public: Option<String>,
 
 	public_path: PathBuf,
@@ -36,21 +80,45 @@
 	stable_secret_path: PathBuf,
 }
 
-fn from_z85<'de, D>(deserializer: D) -> Result<Option<Vec<u8>>, D::Error>
-where
-	D: Deserializer<'de>,
-{
-	use serde::de::Error;
-	if let Some(v) = <Option<String>>::deserialize(deserializer)? {
-		Ok(Some(
-			z85::decode(v).map_err(|err| Error::custom(err.to_string()))?,
-		))
-	} else {
-		Ok(None)
-	}
+type Data = HashMap<String, DataItem>;
+
+fn decrypt(input: &SecretWrapper, identity: &dyn Identity) -> Result<Vec<u8>> {
+	let mut input = Cursor::new(&input.0);
+	let decryptor = Decryptor::new(&mut input).context("failed to init decryptor")?;
+	let decryptor = match decryptor {
+		Decryptor::Recipients(r) => r,
+		Decryptor::Passphrase(_) => bail!("should be recipients"),
+	};
+	let mut decryptor = decryptor
+		.decrypt(iter::once(identity as &dyn age::Identity))
+		.context("failed to decrypt, wrong key?")?;
+
+	let mut decrypted = Vec::new();
+	decryptor
+		.read_to_end(&mut decrypted)
+		.context("failed to decrypt")?;
+	Ok(decrypted)
+}
+fn encrypt(input: &[u8], targets: Vec<String>) -> Result<SecretWrapper> {
+	let recipients = targets
+		.into_iter()
+		.map(|t| {
+			SshRecipient::from_str(&t).map_err(|e| anyhow!("failed to parse recipient: {e:?}"))
+		})
+		.collect::<Result<Vec<SshRecipient>>>()?;
+	let recipients = recipients
+		.into_iter()
+		.map(|v| Box::new(v) as Box<dyn Recipient + Send>)
+		.collect::<Vec<_>>();
+	let mut encrypted = vec![];
+	let mut encryptor = Encryptor::with_recipients(recipients)
+		.expect("recipients provided")
+		.wrap_output(&mut encrypted)
+		.expect("constructor should not fail");
+	io::copy(&mut Cursor::new(input), &mut encryptor).expect("copy should not fail");
+	encryptor.finish().context("failed to finish encryption")?;
+	Ok(SecretWrapper(encrypted))
 }
-
-type Data = HashMap<String, DataItem>;
 
 fn init_secret(identity: &age::ssh::Identity, value: DataItem) -> Result<()> {
 	if let Some(public) = &value.public {
@@ -93,23 +161,7 @@
 	let mut hashed = File::create(&value.secret_path)?;
 
 	// File is owned by root, and only root can modify it
-	let decrypted = {
-		let mut input = Cursor::new(&secret);
-		let decryptor = Decryptor::new(&mut input).context("failed to init decryptor")?;
-		let decryptor = match decryptor {
-			Decryptor::Recipients(r) => r,
-			Decryptor::Passphrase(_) => bail!("should be recipients"),
-		};
-		let mut decryptor = decryptor
-			.decrypt(iter::once(identity as &dyn age::Identity))
-			.context("failed to decrypt, wrong key?")?;
-
-		let mut decrypted = Vec::new();
-		decryptor
-			.read_to_end(&mut decrypted)
-			.context("failed to decrypt")?;
-		decrypted
-	};
+	let decrypted = decrypt(&secret, identity)?;
 	if decrypted.is_empty() {
 		warn!("secret is decoded as empty, something is broken?");
 	}
@@ -132,13 +184,19 @@
 	Ok(())
 }
 
-fn main() -> anyhow::Result<()> {
-	env_logger::Builder::new()
-		.filter_level(log::LevelFilter::Info)
-		.init();
+fn host_identity() -> anyhow::Result<SshIdentity> {
+	let identity = SshIdentity::from_buffer(
+		&mut Cursor::new(
+			fs::read("/etc/ssh/ssh_host_ed25519_key").context("failed to read host private key")?,
+		),
+		None,
+	)
+	.context("failed to parse identity")?;
+	Ok(identity)
+}
 
-	let opts = Opts::parse();
-	let data = fs::read(&opts.data).context("failed to read secrets data")?;
+fn install(data: &Path) -> anyhow::Result<()> {
+	let data = fs::read(data).context("failed to read secrets data")?;
 	let data_str = from_utf8(&data).context("failed to read data to string")?;
 	let data: Data = serde_json::from_str(data_str).context("failed to parse data")?;
 
@@ -149,13 +207,7 @@
 		fs::create_dir("/run/secrets").context("failed to create secrets directory")?;
 	}
 
-	let identity = age::ssh::Identity::from_buffer(
-		&mut Cursor::new(
-			fs::read("/etc/ssh/ssh_host_ed25519_key").context("failed to read host private key")?,
-		),
-		None,
-	)
-	.context("failed to parse identity")?;
+	let identity = host_identity()?;
 
 	let mut failed = false;
 	for (name, value) in data {
@@ -174,3 +226,35 @@
 
 	Ok(())
 }
+
+fn main() -> anyhow::Result<()> {
+	env_logger::Builder::new()
+		.filter_level(log::LevelFilter::Info)
+		.init();
+
+	let opts = Opts::parse();
+
+	match opts {
+		Opts::Install { data } => install(&data),
+		Opts::Reencrypt { secret, targets } => {
+			let identity = host_identity()?;
+			let decrypted = decrypt(&secret, &identity).context("during decryption")?;
+			let encrypted = encrypt(&decrypted, targets).context("during re-encryption")?;
+
+			println!("{encrypted}");
+			Ok(())
+		}
+		Opts::Decrypt { secret, plaintext } => {
+			let identity = host_identity()?;
+			let decrypted = decrypt(&secret, &identity).context("during decryption")?;
+
+			if plaintext {
+				let s = String::from_utf8(decrypted).context("output is not utf8")?;
+				print!("{}", s);
+			} else {
+				println!("{}", SecretWrapper(decrypted));
+			}
+			Ok(())
+		}
+	}
+}
modifiedmodules/fleet/secrets.nixdiffbeforeafterboth
--- a/modules/fleet/secrets.nix
+++ b/modules/fleet/secrets.nix
@@ -20,9 +20,18 @@
         '';
         default = [ ];
       };
+      ownerDependent = mkOption {
+        type = bool;
+        description = "Is this secret owner-dependent, and needs to be regenerated on ownership set change, or it may be just reencrypted";
+      };
       generator = mkOption {
-        type = package;
-        description = "Derivation to execute for secret generation";
+        type = nullOr package;
+        description = ''
+          Derivation to execute for secret generation
+
+          If null - may only be created manually
+        '';
+        default = null;
       };
       expireIn = mkOption {
         type = nullOr int;
modifiednixos/secrets.nixdiffbeforeafterboth
--- a/nixos/secrets.nix
+++ b/nixos/secrets.nix
@@ -89,9 +89,10 @@
     };
   };
   config = {
+    environment.systemPackages = with pkgs; [pkgs.fleet-install-secrets];
     system.activationScripts.decryptSecrets = stringAfter [ "users" "groups" "specialfs" ] ''
       1>&2 echo "setting up secrets"
-      ${pkgs.fleet-install-secrets}/bin/fleet-install-secrets ${secretsFile}
+      ${pkgs.fleet-install-secrets}/bin/fleet-install-secrets install ${secretsFile}
     '';
   };
 }