git.delta.rocks / jrsonnet / refs/commits / 5a5b360a3403

difftreelog

feat unify shared and host secret handling

xwkwvyrvYaroslav Bolyukin2026-01-06parent: #20a41a3.patch.diff
in: trunk

8 files changed

modifiedcmds/fleet/src/cmds/secrets/mod.rsdiffbeforeafterboth
before · cmds/fleet/src/cmds/secrets/mod.rs
1use std::{2	collections::{BTreeMap, BTreeSet, HashSet},3	io::{self, Read, Write, stdin, stdout},4	path::PathBuf,5};67use anyhow::{Context, Result, anyhow, bail, ensure};8use chrono::{DateTime, Utc};9use clap::Parser;10use fleet_base::{11	fleetdata::{12		FleetHostSecret, FleetSecretData, FleetSecretPart, FleetSharedSecret, encrypt_secret_data,13	},14	host::Config,15	opts::FleetOpts,16	secret::{Expectations, RegenerationReason, SharedSecretDefinition, secret_needs_regeneration},17};18use fleet_shared::SecretData;19use nix_eval::{NixType, Value, nix_go, nix_go_json};20use owo_colors::OwoColorize;21use serde::Deserialize;22use tabled::{Table, Tabled};23use tokio::{fs::read, task::spawn_blocking};24use tracing::{Instrument, error, info, info_span, warn};2526#[derive(Parser)]27pub enum Secret {28	AddManager,29	/// Force load host keys for all defined hosts30	ForceKeys,31	/// Add secret, data should be provided in stdin32	AddShared {33		/// Secret name34		name: String,35		/// Secret owners36		#[clap(long, short)]37		machines: Vec<String>,38		/// Override secret if already present39		#[clap(long)]40		force: bool,41		/// Secret public part42		#[clap(long)]43		public: Option<String>,44		/// Load public part from specified file45		#[clap(long)]46		public_file: Option<PathBuf>,4748		/// Create a notification on secret expiration49		#[clap(long)]50		expires_at: Option<DateTime<Utc>>,5152		/// Secret with this name already exists, override its value while keeping the same owners.53		#[clap(long)]54		re_add: bool,5556		/// How to name public secret part57		#[clap(long, short = 'p', default_value = "public")]58		public_part: String,59		/// How to name private secret part60		#[clap(short = 's', long, default_value = "secret")]61		part: String,62	},63	/// Add secret, data should be provided in stdin64	Add {65		/// Secret name66		name: String,67		/// Secret owner68		#[clap(short = 'm', long)]69		machine: String,70		/// Replace secret if already present71		#[clap(long)]72		replace: bool,73		/// Add new parts to existing secret74		#[clap(long)]75		merge: bool,76		/// Secret public part77		#[clap(long)]78		public: Option<String>,79		/// Load public part from specified file80		#[clap(long)]81		public_file: Option<PathBuf>,8283		/// How to name public secret part84		#[clap(short = 'p', long, default_value = "public")]85		public_part: String,86		/// How to name private secret part87		#[clap(short = 's', long, default_value = "secret")]88		part: String,89	},90	/// Read secret from remote host, requires sudo on said host91	Read {92		name: String,93		#[clap(short = 'm', long)]94		machine: String,9596		/// Which private secret part to read97		#[clap(short = 'p', long, default_value = "secret")]98		part: String,99	},100	/// Read secret from remote host, requires sudo on said host101	ReadShared {102		name: String,103		/// Which private secret part to read104		#[clap(short = 'p', long, default_value = "secret")]105		part: String,106		/// Which host should we use to decrypt, in case if reencryption is required, without107		/// regeneration108		#[clap(long)]109		prefer_identities: Vec<String>,110	},111	UpdateShared {112		name: String,113114		#[clap(short = 'm', long)]115		machine: Option<Vec<String>>,116117		#[clap(long)]118		add_machine: Vec<String>,119		#[clap(long)]120		remove_machine: Vec<String>,121122		/// Which host should we use to decrypt123		#[clap(long)]124		prefer_identities: Vec<String>,125	},126	Regenerate {127		/// Which host should we use to decrypt, in case if reencryption is required, without128		/// regeneration129		#[clap(long)]130		prefer_identities: Vec<String>,131		/// Only regenerate shared secrets132		#[clap(long)]133		skip_hosts: bool,134	},135	List {},136	Edit {137		name: String,138		#[clap(short = 'm', long)]139		machine: String,140141		#[clap(long)]142		add: bool,143144		/// Which private secret part to read145		#[clap(short = 'p', long, default_value = "secret")]146		part: String,147	},148}149150#[allow(clippy::too_many_arguments)]151#[tracing::instrument(skip(config, secret, definition, prefer_identities))]152async fn maybe_regenerate_shared_secret(153	secret_name: &str,154	config: &Config,155	mut secret: FleetSharedSecret,156	definition: SharedSecretDefinition,157	prefer_identities: &[String],158	expectations: &Expectations,159) -> Result<FleetSharedSecret> {160	let reason = secret_needs_regeneration(&secret.secret, &secret.owners, expectations);161	let value = definition.definition_value();162163	let (should_reencrypt, reason) = match reason {164		Some(RegenerationReason::OwnersAdded(_)) => {165			// Secret always needs to be reencrypted for new owners to be able to read it166			(167				true,168				if nix_go_json!(value.regenerateOnOwnerAdded) {169					reason170				} else {171					None172				},173			)174		}175		Some(RegenerationReason::OwnersRemoved(_)) => {176			// No need to reencrypt, we can just leave stanzas in place.177			if nix_go_json!(value.regenerateOnOwnerRemoved) {178				(true, reason)179			} else {180				(false, None)181			}182		}183		Some(_) => (true, reason),184		None => (false, None),185	};186187	if let Some(reason) = reason {188		info!("secret needs to be regenerated: {reason}");189		let generated = generate_shared(config, secret_name, definition, expectations).await?;190		Ok(generated)191	} else if should_reencrypt {192		info!("secret needs to be reencrypted");193		let identity_holder = if !prefer_identities.is_empty() {194			prefer_identities195				.iter()196				.find(|i| secret.owners.iter().any(|s| s == *i))197		} else {198			secret.owners.first()199		};200		let Some(identity_holder) = identity_holder else {201			bail!("no available holder found");202		};203204		for (part_name, part) in secret.secret.parts.iter_mut() {205			let _span = info_span!("part reencryption", part_name);206			if !part.raw.encrypted {207				continue;208			}209			let host = config.host(identity_holder).await?;210			let encrypted = host211				.reencrypt(212					part.raw.clone(),213					expectations.owners.iter().cloned().collect(),214				)215				.await?;216			part.raw = encrypted;217		}218		secret.owners = expectations.owners.clone();219		Ok(secret)220	} else {221		Ok(secret)222	}223}224225#[derive(Deserialize)]226#[serde(rename_all = "camelCase")]227enum GeneratorKind {228	Impure,229	Pure,230}231232async fn generate_pure(233	_config: &Config,234	_display_name: &str,235	_secret: Value,236	_default_generator: Value,237	_expectations: &Expectations,238) -> Result<FleetSecretData> {239	bail!("pure generators are broken for now")240}241async fn generate_impure(242	config: &Config,243	_display_name: &str,244	secret: Value,245	default_generator: Value,246	expectations: &Expectations,247) -> Result<FleetSecretData> {248	let generator = nix_go!(secret.generator);249	let on: Option<String> = nix_go_json!(default_generator.impureOn);250251	let nixpkgs = &config.nixpkgs;252253	let host = if let Some(on) = &on {254		config.host(on).await?255	} else {256		config.local_host()257	};258	let on_pkgs = host.pkgs().await?;259	let mk_secret_generators = nix_go!(on_pkgs.mkSecretGenerators);260261	let mut recipients = Vec::new();262	for owner in &expectations.owners {263		let key = config.key(owner).await?;264		recipients.push(key);265	}266	let generators = nix_go!(mk_secret_generators(Obj { recipients }));267	let pkgs_and_generators = on_pkgs.attrs_update(generators)?;268269	let call_package = nix_go!(nixpkgs.lib.callPackageWith(pkgs_and_generators));270271	let generator = nix_go!(call_package(generator)(Obj {}));272273	let generator = spawn_blocking(move || generator.build("out"))274		.await275		.expect("nix build shouldn't fail")?;276	let generator = host.remote_derivation(&generator).await?;277278	let out_parent = host.mktemp_dir().await?;279	let out = format!("{out_parent}/out");280281	let mut r#gen = host.cmd(generator).await?;282	r#gen.env("out", &out);283	if on.is_none() {284		// This path is local, thus we can feed `OsString` directly to env var... But I don't think that's necessary to handle.285		let project_path: String = config286			.directory287			.clone()288			.into_os_string()289			.into_string()290			.map_err(|s| anyhow!("fleet project path is not utf-8: {s:?}"))?;291		r#gen.env("FLEET_PROJECT", project_path);292	}293	r#gen.run().await.context("impure generator")?;294295	{296		let marker = host.read_file_text(format!("{out}/marker")).await?;297		ensure!(marker == "SUCCESS", "generation not succeeded");298	}299300	let mut parts = BTreeMap::new();301	for part in host.read_dir(&out).await? {302		if part == "created_at" || part == "expires_at" || part == "marker" {303			continue;304		}305		let contents: SecretData = host306			.read_file_text(format!("{out}/{part}"))307			.await?308			.parse()309			.map_err(|e| anyhow!("failed to decode secret {out:?} part {part:?}: {e}"))?;310		parts.insert(part.to_owned(), FleetSecretPart { raw: contents });311	}312313	let created_at = host.read_file_value(format!("{out}/created_at")).await?;314	let expires_at = host.read_file_value(format!("{out}/expires_at")).await.ok();315316	let new_data = FleetSecretData {317		created_at,318		expires_at,319		parts,320		generation_data: expectations.generation_data.clone(),321	};322323	if let Some(reason) = secret_needs_regeneration(&new_data, &expectations.owners, expectations) {324		bail!("newly generated secret needs to be regenerated: {reason}")325	}326327	Ok(new_data)328}329330async fn generate(331	config: &Config,332	display_name: &str,333	secret: Value,334	expectations: &Expectations,335) -> Result<FleetSecretData> {336	let generator = nix_go!(secret.generator);337	// Can't properly check on nix module system level338	{339		let gen_ty = generator.type_of();340		if matches!(gen_ty, NixType::Null) {341			bail!("secret has no generator defined, can't automatically generate it.");342		}343		if matches!(gen_ty, NixType::Attrs) {344			if !generator.has_field("__functor")? {345				bail!("generator should be functor, got {gen_ty:?}");346			}347		} else if matches!(gen_ty, NixType::Function) {348			bail!("generator should be functor, got {gen_ty:?}");349		}350	}351	let nixpkgs = &config.nixpkgs;352	let default_pkgs = &config.default_pkgs;353	let default_mk_secret_generators = nix_go!(default_pkgs.mkSecretGenerators);354	// Generators provide additional information in passthru, to access355	// passthru we should call generator, but information about where this generator is supposed to build356	// is located in passthru... Thus evaluating generator on host.357	//358	// Maybe it is also possible to do some magic with __functor?359	//360	// I don't want to make modules always responsible for additional secret data anyway,361	// so it should be in derivation, and not in the secret data itself.362	let generators = nix_go!(default_mk_secret_generators(Obj {363		recipients: <Vec<String>>::new(),364	}));365	let pkgs_and_generators = default_pkgs.clone().attrs_update(generators)?;366367	let call_package = nix_go!(nixpkgs.lib.callPackageWith(pkgs_and_generators));368	let default_generator = nix_go!(call_package(generator)(Obj {}));369370	let kind: GeneratorKind = nix_go_json!(default_generator.generatorKind);371372	match kind {373		GeneratorKind::Impure => {374			generate_impure(375				config,376				display_name,377				secret,378				default_generator,379				expectations,380			)381			.await382		}383		GeneratorKind::Pure => {384			generate_pure(385				config,386				display_name,387				secret,388				default_generator,389				expectations,390			)391			.await392		}393	}394}395async fn generate_shared(396	config: &Config,397	display_name: &str,398	secret: SharedSecretDefinition,399	expectations: &Expectations,400) -> Result<FleetSharedSecret> {401	// let owners: Vec<String> = nix_go_json!(secret.expectedOwners);402	Ok(FleetSharedSecret {403		managed: Some(true),404		secret: generate(405			config,406			display_name,407			secret.definition_value(),408			expectations,409		)410		.await?,411		owners: expectations.owners.clone(),412	})413}414415async fn parse_public(416	public: Option<String>,417	public_file: Option<PathBuf>,418) -> Result<Option<SecretData>> {419	Ok(match (public, public_file) {420		(Some(v), None) => Some(SecretData {421			data: v.into(),422			encrypted: false,423		}),424		(None, Some(v)) => Some(SecretData {425			data: read(v).await?,426			encrypted: false,427		}),428		(Some(_), Some(_)) => {429			bail!("only public or public_file should be set")430		}431		(None, None) => None,432	})433}434435async fn parse_secret() -> Result<Option<Vec<u8>>> {436	let mut input = vec![];437	stdin().read_to_end(&mut input)?;438	if input.is_empty() {439		Ok(None)440	} else {441		Ok(Some(input))442	}443}444445fn parse_machines(446	initial: BTreeSet<String>,447	machines: Option<Vec<String>>,448	mut add_machines: Vec<String>,449	mut remove_machines: Vec<String>,450) -> Result<BTreeSet<String>> {451	if machines.is_none() && add_machines.is_empty() && remove_machines.is_empty() {452		bail!("no operation");453	}454455	let initial_machines = initial.clone();456	let mut target_machines = initial;457	info!("Currently encrypted for {initial_machines:?}");458459	if let Some(machines) = machines {460		ensure!(461			add_machines.is_empty() && remove_machines.is_empty(),462			"can't combine --machines and --add-machines/--remove-machines"463		);464		let target = initial_machines.iter().collect::<HashSet<_>>();465		let source = machines.iter().collect::<HashSet<_>>();466		for removed in target.difference(&source) {467			remove_machines.push((*removed).clone());468		}469		for added in source.difference(&target) {470			add_machines.push((*added).clone());471		}472	}473474	for machine in &remove_machines {475		if !target_machines.remove(machine) {476			warn!("secret is not enabled for {machine}");477		}478	}479	for machine in &add_machines {480		if !target_machines.insert(machine.to_owned()) {481			warn!("secret is already added to {machine}");482		}483	}484	if !remove_machines.is_empty() {485		// TODO: maybe force secret regeneration?486		// Not that useful without revokation.487		warn!(488			"secret will not be regenerated for removed machines, and until host rebuild, they will still possess the ability to decode secret"489		);490	}491	Ok(target_machines)492}493impl Secret {494	pub async fn run(self, config: &Config, opts: &FleetOpts) -> Result<()> {495		match self {496			Secret::AddManager => {497				todo!("part of fleet-pusher")498			}499			Secret::ForceKeys => {500				for host in config.list_hosts().await? {501					if opts.should_skip(&host).await? {502						continue;503					}504					config.key(&host.name).await?;505				}506			}507			Secret::AddShared {508				machines,509				name,510				force,511				public,512				public_part: public_name,513				public_file,514				expires_at,515				re_add,516				part: part_name,517			} => {518				let mut machines: BTreeSet<String> = machines.into_iter().collect();519				// TODO: Forbid updating secrets with set expectedOwners (= not user-managed).520521				if let Some(old_shared) = config.shared_secret(&name)? {522					if !force && !re_add {523						bail!("secret already defined");524					};525					if old_shared.managed.unwrap_or(false) {526						bail!("secret is marked as managed, should not be updated manually");527					};528					if re_add {529						// Fixme: use clap to limit this usage530						ensure!(!force, "--force and --readd are not compatible");531						ensure!(532							machines.is_empty(),533							"you can't use machines argument for --readd"534						);535						machines = old_shared.owners;536					}537				} else if re_add {538					bail!("secret doesn't exists");539				};540541				let recipients = config542					.recipients(machines.iter().cloned().collect())543					.await?;544545				let mut parts = BTreeMap::new();546547				let mut input = vec![];548				io::stdin().read_to_end(&mut input)?;549550				if !input.is_empty() {551					let encrypted = encrypt_secret_data(recipients.iter(), input)552						.ok_or_else(|| anyhow!("no recipients provided"))?;553					parts.insert(part_name, FleetSecretPart { raw: encrypted });554				}555556				if let Some(public) = parse_public(public, public_file).await? {557					parts.insert(public_name, FleetSecretPart { raw: public });558				}559560				config.replace_shared(561					name,562					FleetSharedSecret {563						managed: Some(false),564						owners: machines,565						secret: FleetSecretData {566							created_at: Utc::now(),567							expires_at,568							parts,569							generation_data: serde_json::Value::Null,570						},571					},572				);573			}574			Secret::Add {575				machine,576				name,577				replace,578				merge,579				public,580				public_part: public_name,581				public_file,582				part: part_name,583			} => {584				if config.has_secret(&machine, &name) && !replace && !merge {585					bail!(586						"secret already defined.\nUse --replace to override, or --merge to add new parts to existing secret"587					);588				}589590				let mut out = if merge && !replace {591					config592						.host_secret(&machine, &name)593						.context("failed to read existing secret for --merge")?594				} else {595					FleetHostSecret {596						managed: Some(false),597						secret: FleetSecretData {598							created_at: Utc::now(),599							expires_at: None,600							parts: BTreeMap::new(),601							generation_data: serde_json::Value::Null,602						},603					}604				};605				if out.managed.unwrap_or(false) {606					bail!("secret is managed by fleet and should not be updated manually");607				}608				out.managed = Some(false);609610				if let Some(secret) = parse_secret().await? {611					let recipient = config.recipient(&machine).await?;612					let encrypted =613						encrypt_secret_data([&recipient], secret).expect("recipient provided");614					if out615						.secret616						.parts617						.insert(part_name.clone(), FleetSecretPart { raw: encrypted })618						.is_some() && !replace619					{620						bail!(621							"part {part_name:?} is already defined, use --replace if you wish to replace it"622						);623					}624				}625626				if let Some(public) = parse_public(public, public_file).await? {627					if out628						.secret629						.parts630						.insert(public_name.clone(), FleetSecretPart { raw: public })631						.is_some() && !replace632					{633						bail!(634							"part {public_name:?} is already defined, use --replace if you wish to replace it"635						);636					}637				};638639				config.insert_secret(&machine, name, out);640			}641			#[allow(clippy::await_holding_refcell_ref)]642			Secret::Read {643				name,644				machine,645				part: part_name,646			} => {647				let secret = config.host_secret(&machine, &name)?;648				let Some(secret) = secret.secret.parts.get(&part_name) else {649					bail!("no part {part_name} in secret {name}");650				};651				let data = if secret.raw.encrypted {652					let host = config.host(&machine).await?;653					host.decrypt(secret.raw.clone()).await?654				} else {655					secret.raw.data.clone()656				};657658				stdout().write_all(&data)?;659			}660			Secret::ReadShared {661				name,662				part: part_name,663				prefer_identities,664			} => {665				let Some(secret) = config.shared_secret(&name)? else {666					bail!("secret doesn't exists");667				};668				let Some(part) = secret.secret.parts.get(&part_name) else {669					bail!("no part {part_name} in secret {name}");670				};671				let data = if part.raw.encrypted {672					let identity_holder = if !prefer_identities.is_empty() {673						prefer_identities674							.iter()675							.find(|i| secret.owners.iter().any(|s| s == *i))676					} else {677						secret.owners.first()678					};679					let Some(identity_holder) = identity_holder else {680						bail!("no available holder found");681					};682					let host = config.host(identity_holder).await?;683					host.decrypt(part.raw.clone()).await?684				} else {685					part.raw.data.clone()686				};687				stdout().write_all(&data)?;688			}689			Secret::UpdateShared {690				name,691				machine,692				add_machine,693				remove_machine,694				prefer_identities,695			} => {696				// TODO: Forbid updating secrets with set expectedOwners (= not user-managed).697698				let Some(secret) = config.shared_secret(&name)? else {699					bail!("secret doesn't exists");700				};701				if secret.secret.parts.values().all(|v| !v.raw.encrypted) {702					bail!("no secret");703				}704705				let initial_machines = secret.owners.clone();706				let target_machines = parse_machines(707					initial_machines.clone(),708					machine,709					add_machine,710					remove_machine,711				)?;712713				if target_machines.is_empty() {714					info!("no machines left for secret, removing it");715					config.remove_shared(&name);716					return Ok(());717				}718719				let definition = config.shared_secret_definition(&name)?;720				let expectations = definition721					.expectations()722					.with_context(|| format!("expectations for shared {name:?}"))?;723724				let updated = maybe_regenerate_shared_secret(725					&name,726					config,727					secret,728					definition,729					&prefer_identities,730					&expectations,731				)732				.await?;733				config.replace_shared(name, updated);734			}735			Secret::Regenerate {736				prefer_identities,737				skip_hosts,738			} => {739				info!("checking for secrets to regenerate");740				let expected_shared_set = config741					.list_configured_shared()742					.await?743					.into_iter()744					.collect::<HashSet<_>>();745				let stored_shared_set = config.list_shared().into_iter().collect::<HashSet<_>>();746				{747					// Generate missing shared748					let _span = info_span!("shared").entered();749					for missing in expected_shared_set.difference(&stored_shared_set) {750						let definition = config.shared_secret_definition(missing)?;751						if !definition.is_managed()? {752							info!("skipping unmanaged secret: {missing}");753							continue;754						}755						let expectations = definition756							.expectations()757							.with_context(|| format!("expectations for shared {missing:?}"))?;758						info!("generating secret: {missing}");759						let shared = generate_shared(config, missing, definition, &expectations)760							.in_current_span()761							.await?;762						config.replace_shared(missing.to_string(), shared)763					}764				}765				if !skip_hosts {766					for host in config.list_hosts().await? {767						if opts.should_skip(&host).await? {768							continue;769						}770771						let _span = info_span!("host", host = host.name).entered();772						let expected_set = host773							.list_defined_secrets()?774							.into_iter()775							.collect::<HashSet<_>>();776						let stored_set = config777							.list_secrets(&host.name)778							.into_iter()779							.collect::<HashSet<_>>();780						for missing_secret in expected_set.difference(&stored_set) {781							let secret = host.secret_definition(missing_secret)?;782							if secret.is_shared()? {783								continue;784							}785							info!("generating missing secret: {missing_secret}");786							let expectations = secret.expectations().with_context(|| {787								format!("expectations for {missing_secret:?} of {:?}", host.name)788							})?;789							let generated = match generate(790								config,791								missing_secret,792								secret.definition_value()?,793								&expectations,794							)795							.in_current_span()796							.await797							{798								Ok(v) => v,799								Err(e) => {800									error!("{e:?}");801									continue;802								}803							};804							config.insert_secret(805								&host.name,806								missing_secret.to_string(),807								FleetHostSecret {808									managed: Some(true),809									secret: generated,810								},811							)812						}813						for known_secret in stored_set.intersection(&expected_set) {814							let secret = host.secret_definition(known_secret)?;815							if secret.is_shared()? {816								continue;817							}818							info!("updating secret: {known_secret}");819							let data = config.host_secret(&host.name, known_secret)?;820							let expectations = secret.expectations()?;821							if let Some(regen_reason) = data.needs_regeneration(&expectations) {822								info!("needs regeneration: {regen_reason}");823								let generated = match generate(824									config,825									known_secret,826									secret.definition_value()?,827									&expectations,828								)829								.in_current_span()830								.await831								{832									Ok(v) => v,833									Err(e) => {834										error!("{e:?}");835										continue;836									}837								};838								config.insert_secret(839									&host.name,840									known_secret.to_string(),841									FleetHostSecret {842										managed: Some(true),843										secret: generated,844									},845								)846							}847						}848						for removed_secret in stored_set.difference(&expected_set) {849							let definition = host.secret_definition(removed_secret)?;850							if definition.is_shared()? {851								continue;852							}853							info!("removing secret: {removed_secret}");854							config.remove_secret(&host.name, removed_secret);855						}856					}857				}858				for known_secret in stored_shared_set.intersection(&expected_shared_set) {859					info!("updating shared secret: {known_secret}");860					let data = config.shared_secret(known_secret)?.expect("exists");861862					let definition = config.shared_secret_definition(known_secret)?;863					let expectations = definition.expectations()?;864					config.replace_shared(865						known_secret.to_owned(),866						maybe_regenerate_shared_secret(867							known_secret,868							config,869							data,870							definition,871							&prefer_identities,872							&expectations,873						)874						.await?,875					);876				}877				for removed_secret in stored_shared_set.difference(&expected_shared_set) {878					info!("removing shared secret: {removed_secret}");879					config.remove_shared(removed_secret);880				}881			}882			Secret::List {} => {883				let _span = info_span!("loading secrets").entered();884				let configured = config.list_configured_shared().await?;885				#[derive(Tabled)]886				struct SecretDisplay {887					#[tabled(rename = "Name")]888					name: String,889					#[tabled(rename = "Owners")]890					owners: String,891				}892				let mut table = vec![];893				for name in configured.iter().cloned() {894					let config = config.clone();895					let data = config.shared_secret(&name)?.expect("exists");896					let definition = config.shared_secret_definition(&name)?;897					let expectations = definition.expectations()?;898					let owners = data899						.owners900						.iter()901						.map(|o| {902							if expectations.owners.contains(o) {903								o.green().to_string()904							} else {905								o.red().to_string()906							}907						})908						.collect::<Vec<_>>();909					table.push(SecretDisplay {910						owners: owners.join(", "),911						name,912					})913				}914				info!("loaded\n{}", Table::new(table).to_string())915			}916			Secret::Edit {917				name,918				machine,919				part,920				add,921			} => {922				let secret = config.host_secret(&machine, &name)?;923				if let Some(data) = secret.secret.parts.get(&part) {924					let host = config.host(&machine).await?;925					let secret = host.decrypt(data.raw.clone()).await?;926					String::from_utf8(secret).context("secret is not utf8")?927				} else if add {928					String::new()929				} else {930					bail!("part {part} not found in secret {name}. Did you mean to `--add` it?");931				};932			}933		}934		Ok(())935	}936}937938/*939async fn edit_temp_file(940	builder: tempfile::Builder<'_, '_>,941	r: Vec<u8>,942	header: &str,943	comment: &str,944) -> Result<(Vec<u8>, Option<String>), anyhow::Error> {945	if !stdin().is_tty() {946		// TODO: Also try to open /dev/tty directly?947		bail!("stdin is not tty, can't open editor");948	}949950	use std::fmt::Write;951	let mut file = builder.tempfile()?;952953	let mut full_header = String::new();954	let mut had = false;955	for line in header.trim_end().lines() {956		had = true;957		writeln!(&mut full_header, "{comment}{line}")?;958	}959	if had {960		writeln!(&mut full_header, "{}", comment.trim_end())?;961	}962	writeln!(963		&mut full_header,964		"{comment}Do not touch this header! It will be removed automatically"965	)?;966967	file.write_all(full_header.as_bytes())?;968	file.write_all(&r)?;969970	let abs_path = file.into_temp_path();971	let editor = std::env::var_os("VISUAL")972		.or_else(|| std::env::var_os("EDITOR"))973		.unwrap_or_else(|| "vi".into());974	let editor_args = shlex::bytes::split(editor.as_encoded_bytes())975		.ok_or_else(|| anyhow!("EDITOR env var has wrong syntax"))?;976	let editor_args = editor_args977		.into_iter()978		.map(|v| {979			// Only ASCII subsequences are replaced980			unsafe { OsString::from_encoded_bytes_unchecked(v) }981		})982		.collect_vec();983	let Some((editor, args)) = editor_args.split_first() else {984		bail!("EDITOR env var has no command");985	};986	let mut command = Command::new(editor);987	command.args(args);988989	let path_arg = abs_path.canonicalize()?;990991	// TODO: Save full state, using tcget/_getmode/_setmode992	let was_raw = terminal::is_raw_mode_enabled()?;993	terminal::enable_raw_mode()?;994995	let status = command.arg(path_arg).status().await;996997	if !was_raw {998		terminal::disable_raw_mode()?;999	}10001001	let success = match status {1002		Ok(s) => s.success(),1003		Err(e) if e.kind() == io::ErrorKind::NotFound => {1004			bail!("editor not found")1005		}1006		Err(e) => bail!("editor spawn error: {e}"),1007	};10081009	let mut file = std::fs::read(&abs_path).context("read editor output")?;1010	let Some(v) = file.strip_prefix(full_header.as_bytes()) else {1011		todo!();1012	};1013	todo!();10141015	// Ok((success, abs_path))1016}1017*/
modifiedcrates/fleet-base/src/fleetdata.rsdiffbeforeafterboth
--- a/crates/fleet-base/src/fleetdata.rs
+++ b/crates/fleet-base/src/fleetdata.rs
@@ -1,6 +1,10 @@
 use std::{
-	collections::{BTreeMap, BTreeSet},
+	collections::{
+		BTreeMap, BTreeSet,
+		btree_map::{self, Entry},
+	},
 	io::{self, Cursor},
+	ops::Deref,
 };
 
 use age::Recipient;
@@ -10,10 +14,12 @@
 	distr::{Alphanumeric, SampleString as _},
 	rng,
 };
-use serde::{Deserialize, Serialize, de::Error};
+use serde::{
+	Deserialize, Serialize,
+	de::{self, Error},
+};
 use serde_json::Value;
-
-use crate::secret::{Expectations, RegenerationReason, secret_needs_regeneration};
+use tracing::info;
 
 #[derive(Serialize, Deserialize, Default)]
 #[serde(rename_all = "camelCase")]
@@ -72,17 +78,29 @@
 
 	#[serde(default)]
 	pub hosts: BTreeMap<String, HostData>,
+
+	#[serde(default, alias = "shared_secrets")]
+	pub secrets: FleetSecrets,
+
+	// extra_name => anything
 	#[serde(default)]
 	#[serde(skip_serializing_if = "BTreeMap::is_empty")]
-	pub shared_secrets: BTreeMap<String, FleetSharedSecret>,
-	#[serde(default)]
-	#[serde(skip_serializing_if = "BTreeMap::is_empty")]
-	pub host_secrets: BTreeMap<String, BTreeMap<String, FleetHostSecret>>,
+	pub extra: BTreeMap<String, Value>,
 
-	// extra_name => anything
 	#[serde(default)]
 	#[serde(skip_serializing_if = "BTreeMap::is_empty")]
-	pub extra: BTreeMap<String, Value>,
+	host_secrets: BTreeMap<String, BTreeMap<String, FleetSecretDistribution>>,
+}
+impl FleetData {
+	pub fn from_str(s: &str) -> anyhow::Result<Self> {
+		let mut data: Self = nixlike::parse_str(s)?;
+		if !data.host_secrets.is_empty() {
+			info!("migrating host secrets into shared secrets structure");
+			data.secrets
+				.merge_from_hosts(std::mem::take(&mut data.host_secrets));
+		}
+		Ok(data)
+	}
 }
 
 /// Returns None if recipients.is_empty()
@@ -129,27 +147,276 @@
 #[derive(Serialize, Deserialize, Clone)]
 #[serde(rename_all = "camelCase")]
 #[must_use]
-pub struct FleetHostSecret {
+pub struct FleetSecretDistribution {
 	#[serde(default)]
 	#[serde(skip_serializing_if = "Option::is_none")]
 	pub managed: Option<bool>,
+	#[serde(default)]
+	pub owners: BTreeSet<String>,
 	#[serde(flatten)]
 	pub secret: FleetSecretData,
 }
-impl FleetHostSecret {
-	pub fn needs_regeneration(&self, expectations: &Expectations) -> Option<RegenerationReason> {
-		secret_needs_regeneration(&self.secret, &expectations.owners, expectations)
+
+#[derive(Clone)]
+#[must_use]
+pub struct FleetSecretDistributions(Vec<FleetSecretDistribution>);
+
+impl Deref for FleetSecretDistributions {
+	type Target = [FleetSecretDistribution];
+
+	fn deref(&self) -> &Self::Target {
+		self.0.as_slice()
+	}
+}
+
+impl FleetSecretDistributions {
+	pub fn owners(&self) -> impl Iterator<Item = &String> {
+		self.0.iter().flat_map(|v| v.owners.iter())
+	}
+	#[allow(
+		clippy::len_without_is_empty,
+		reason = "should not be empty for a long time"
+	)]
+	pub fn len(&self) -> usize {
+		self.0.len()
+	}
+
+	pub fn get(&self, owner: &str) -> Option<&FleetSecretDistribution> {
+		self.0.iter().find(|d| d.owners.contains(owner))
+	}
+	fn entry(&mut self, owner: String) -> DistEntry<'_> {
+		let Some(idx) = self.0.iter().position(|d| d.owners.contains(&owner)) else {
+			return DistEntry::Vacant(VacantDistEntry {
+				distributions: self,
+				owner,
+			});
+		};
+		DistEntry::Occupied(OccupiedDistEntry {
+			distributions: self,
+			idx,
+			owner,
+		})
+	}
+	fn extend(&mut self, dist: FleetSecretDistribution) {
+		for owner in &dist.owners {
+			self.entry(owner.to_owned()).remove();
+		}
+		self.0.push(dist);
+	}
+	pub fn contains(&self, owner: &str) -> bool {
+		self.0.iter().any(|d| d.owners.contains(owner))
+	}
+}
+
+struct OccupiedDistEntry<'d> {
+	distributions: &'d mut FleetSecretDistributions,
+	idx: usize,
+	owner: String,
+}
+impl<'d> OccupiedDistEntry<'d> {
+	fn remove(self) -> VacantDistEntry<'d> {
+		let dist = &mut self.distributions.0[self.idx];
+		assert!(
+			dist.owners.remove(&self.owner),
+			"entry exists, as we have its reference"
+		);
+		if dist.owners.is_empty() {
+			self.distributions.0.remove(self.idx);
+		}
+		VacantDistEntry {
+			distributions: self.distributions,
+			owner: self.owner,
+		}
+	}
+	fn set(self, secret: FleetSecretData) -> Self {
+		self.remove().set(secret)
 	}
 }
+struct VacantDistEntry<'d> {
+	distributions: &'d mut FleetSecretDistributions,
+	owner: String,
+}
+impl<'d> VacantDistEntry<'d> {
+	fn set(self, secret: FleetSecretData) -> OccupiedDistEntry<'d> {
+		let Self {
+			distributions,
+			owner,
+		} = self;
+		let idx = distributions.0.len();
+		distributions.0.push(FleetSecretDistribution {
+			managed: None,
+			owners: BTreeSet::from_iter([owner.clone()]),
+			secret,
+		});
+		OccupiedDistEntry {
+			distributions,
+			owner,
+			idx,
+		}
+	}
+}
 
-#[derive(Serialize, Deserialize, Clone)]
-#[serde(rename_all = "camelCase")]
-#[must_use]
-pub struct FleetSharedSecret {
-	#[serde(default)]
-	#[serde(skip_serializing_if = "Option::is_none")]
-	pub managed: Option<bool>,
-	pub owners: BTreeSet<String>,
-	#[serde(flatten)]
-	pub secret: FleetSecretData,
+enum DistEntry<'d> {
+	Vacant(VacantDistEntry<'d>),
+	Occupied(OccupiedDistEntry<'d>),
+}
+impl DistEntry<'_> {
+	fn remove(self) -> Self {
+		match self {
+			DistEntry::Vacant(_) => self,
+			DistEntry::Occupied(o) => Self::Vacant(o.remove()),
+		}
+	}
+	fn set(self, secret: FleetSecretData) -> Self {
+		Self::Occupied(match self {
+			DistEntry::Vacant(e) => e.set(secret),
+			DistEntry::Occupied(e) => e.set(secret),
+		})
+	}
+}
+
+impl Serialize for FleetSecretDistributions {
+	fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+	where
+		S: serde::Serializer,
+	{
+		let mut found_hosts = BTreeSet::new();
+		for ele in self.0.iter() {
+			if ele.owners.is_empty() {
+				panic!("consistency: secret distribution has no defined owners");
+			}
+			for ele in ele.owners.iter() {
+				if !found_hosts.insert(ele) {
+					panic!(
+						"consistency: secret distribution contains duplicate entry for the same host",
+					);
+				}
+			}
+		}
+		match self.0.len() {
+			0 => panic!("consistency: empty distributions"),
+			1 => self.0[0].serialize(serializer),
+			_ => self.0.serialize(serializer),
+		}
+	}
+}
+impl<'de> Deserialize<'de> for FleetSecretDistributions {
+	fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+	where
+		D: serde::Deserializer<'de>,
+	{
+		#[derive(Deserialize)]
+		#[serde(untagged)]
+		enum Distributions {
+			One(FleetSecretDistribution),
+			Many(Vec<FleetSecretDistribution>),
+		}
+		let d = Distributions::deserialize(deserializer)?;
+		let ds = match d {
+			Distributions::One(d) => vec![d],
+			Distributions::Many(ds) => ds,
+		};
+		if ds.is_empty() {
+			return Err(de::Error::custom("consistency: empty distributions"));
+		}
+		let mut found_hosts = BTreeSet::new();
+		for ele in ds.iter() {
+			if ele.owners.is_empty() {
+				return Err(de::Error::custom(
+					"consistency: secret distribution has no defined owners",
+				));
+			}
+			for ele in ele.owners.iter() {
+				if !found_hosts.insert(ele) {
+					return Err(de::Error::custom(
+						"consistency: secret distribution contains duplicate entry for the same host",
+					));
+				}
+			}
+		}
+		Ok(Self(ds))
+	}
+}
+
+#[derive(Serialize, Deserialize, Default)]
+pub struct FleetSecrets(BTreeMap<String, FleetSecretDistributions>);
+
+impl FleetSecrets {
+	pub fn keys(&self) -> btree_map::Keys<String, FleetSecretDistributions> {
+		self.0.keys()
+	}
+
+	pub fn keys_for_owner(&self, owner: &str) -> impl Iterator<Item = &String> {
+		self.0
+			.iter()
+			.filter(|(_, d)| d.contains(owner))
+			.map(|(n, _)| n)
+	}
+
+	pub fn drop_owner_no_reencrypt(&mut self, secret: &str, owner: &str) -> bool {
+		let Entry::Occupied(mut dists) = self.0.entry(secret.to_owned()) else {
+			return false;
+		};
+		let DistEntry::Occupied(dist) = dists.get_mut().entry(owner.to_owned()) else {
+			return false;
+		};
+
+		dist.remove();
+
+		if dists.get().0.is_empty() {
+			dists.remove();
+		};
+
+		true
+	}
+	pub fn set_single_data(&mut self, secret: String, owner: String, data: FleetSecretData) {
+		let e = self
+			.0
+			.entry(secret.to_owned())
+			.or_insert_with(|| FleetSecretDistributions(Default::default()));
+		e.entry(owner.to_owned()).set(data);
+	}
+	pub fn set_data(&mut self, secret: String, data: FleetSecretDistribution) {
+		match self.0.entry(secret) {
+			Entry::Vacant(e) => {
+				e.insert(FleetSecretDistributions(vec![data]));
+			}
+			Entry::Occupied(mut e) => {
+				let dists = e.get_mut();
+				dists.extend(data)
+			}
+		}
+	}
+	pub fn get_single(&self, secret: &str, owner: &str) -> Option<&FleetSecretDistribution> {
+		let secret = self.0.get(secret)?;
+		secret.get(owner)
+	}
+	pub fn get(&self, secret: &str) -> Option<&FleetSecretDistributions> {
+		self.0.get(secret)
+	}
+
+	pub fn contains_for_owner(&self, secret: &str, owner: &str) -> bool {
+		let Some(secret) = self.0.get(secret) else {
+			return false;
+		};
+		secret.contains(owner)
+	}
+	pub fn contains(&self, secret: &str) -> bool {
+		self.0.contains_key(secret)
+	}
+	pub fn remove(&mut self, secret: &str) {
+		self.0.remove(secret);
+	}
+
+	fn merge_from_hosts(
+		&mut self,
+		host_secrets: BTreeMap<String, BTreeMap<String, FleetSecretDistribution>>,
+	) {
+		for (host, host_secrets) in host_secrets {
+			for (secret_name, mut secret_data) in host_secrets {
+				secret_data.owners.insert(host.clone());
+				self.set_data(secret_name, secret_data);
+			}
+		}
+	}
 }
modifiedcrates/fleet-base/src/host.rsdiffbeforeafterboth
--- a/crates/fleet-base/src/host.rs
+++ b/crates/fleet-base/src/host.rs
@@ -22,7 +22,7 @@
 
 use crate::{
 	command::MyCommand,
-	fleetdata::{FleetData, FleetHostSecret, FleetSharedSecret},
+	fleetdata::{FleetData, FleetSecretData, FleetSecretDistribution, FleetSecretDistributions},
 	secret::{HostSecretDefinition, SharedSecretDefinition},
 };
 
@@ -623,80 +623,48 @@
 		let config_field = &self.config_field;
 		nix_go!(config_field.sharedSecrets).list_fields()
 	}
-	/// Shared secrets configured in fleet.nix
-	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)
+		data.secrets.contains(name)
 	}
-	pub fn replace_shared(&self, name: String, shared: FleetSharedSecret) {
+	pub fn replace_shared(&self, name: String, shared: FleetSecretDistribution) {
 		let mut data = self.data_mut();
-		data.shared_secrets.insert(name.to_owned(), shared);
+		data.secrets.set_data(name, shared);
 	}
 	pub fn remove_shared(&self, secret: &str) {
 		let mut data = self.data_mut();
-		data.shared_secrets.remove(secret);
+		data.secrets.remove(secret);
 	}
 
-	pub fn list_secrets(&self, host: &str) -> Vec<String> {
-		let data = self.data();
-		let mut out = data
-			.host_secrets
-			.get(host)
-			.map(|s| s.keys().cloned().collect::<Vec<String>>())
-			.unwrap_or_default();
-
-		for (name, shared) in data.shared_secrets.iter() {
-			if shared.owners.contains(host) {
-				out.push(name.clone());
-			}
-		}
-
-		out
+	pub fn list_secrets_for_owner(&self, host: &str) -> Vec<String> {
+		let data = self.data_mut();
+		data.secrets.keys_for_owner(host).cloned().collect()
+	}
+	pub fn list_secrets(&self) -> Vec<String> {
+		let data = self.data_mut();
+		data.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)
+		data.secrets.contains_for_owner(secret, host)
 	}
-	pub fn insert_secret(&self, host: &str, secret: String, value: FleetHostSecret) {
+	pub fn insert_secret(&self, host: String, secret: String, value: FleetSecretData) {
 		let mut data = self.data_mut();
-		let host_secrets = data.host_secrets.entry(host.to_owned()).or_default();
-		host_secrets.insert(secret, value);
+		data.secrets.set_single_data(secret, host, value);
 	}
 	pub fn remove_secret(&self, host: &str, secret: &str) {
 		let mut data = self.data_mut();
-		let host_secrets = data.host_secrets.entry(host.to_owned()).or_default();
-		host_secrets.remove(secret);
+		data.secrets.drop_owner_no_reencrypt(secret, host);
 	}
 
-	pub fn host_secret(&self, host: &str, secret: &str) -> Result<FleetHostSecret> {
+	pub fn host_secret(&self, host: &str, secret: &str) -> Option<FleetSecretDistribution> {
 		let data = self.data();
-		if let Some(host_secrets) = data.host_secrets.get(host) {
-			if let Some(secret) = host_secrets.get(secret) {
-				return Ok(secret.clone());
-			}
-		};
-		let Some(shared) = data.shared_secrets.get(secret) else {
-			bail!("machine {host} has no secret {secret}");
-		};
-		if !shared.owners.contains(host) {
-			bail!("shared secret {secret} is not owned by {host}");
-		};
-		Ok(FleetHostSecret {
-			managed: shared.managed,
-			secret: shared.secret.clone(),
-		})
+		data.secrets.get_single(secret, host).cloned()
 	}
-	pub fn shared_secret(&self, secret: &str) -> Result<Option<FleetSharedSecret>> {
+	pub fn shared_secret(&self, secret: &str) -> Option<FleetSecretDistributions> {
 		let data = self.data();
-		Ok(data.shared_secrets.get(secret).cloned())
+		data.secrets.get(secret).cloned()
 	}
 	pub fn shared_secret_definition(&self, secret: &str) -> Result<SharedSecretDefinition> {
 		let config_field = &self.config_field;
modifiedcrates/fleet-base/src/opts.rsdiffbeforeafterboth
--- a/crates/fleet-base/src/opts.rs
+++ b/crates/fleet-base/src/opts.rs
@@ -211,7 +211,7 @@
 		}
 		let bytes =
 			std::fs::read_to_string(&fleet_data_path).context("reading fleet state (fleet.nix)")?;
-		let data: Mutex<FleetData> = nixlike::parse_str(&bytes)?;
+		let data = Mutex::new(FleetData::from_str(&bytes)?);
 
 		let mut fetch_settings = FetchSettings::new();
 		fetch_settings.set(c"warn-dirty", c"false");
modifiedflake.lockdiffbeforeafterboth
--- a/flake.lock
+++ b/flake.lock
@@ -2,10 +2,10 @@
   "nodes": {
     "crane": {
       "locked": {
-        "lastModified": 1766181779,
+        "lastModified": 1767461147,
         "owner": "ipetkov",
         "repo": "crane",
-        "rev": "0263f510ba38bee5b7f817498066adaad694e50b",
+        "rev": "7d59256814085fd9666a2ae3e774dc5ee216b630",
         "type": "github"
       },
       "original": {
@@ -37,10 +37,10 @@
         ]
       },
       "locked": {
-        "lastModified": 1765835352,
+        "lastModified": 1767609335,
         "owner": "hercules-ci",
         "repo": "flake-parts",
-        "rev": "a34fae9c08a15ad73f295041fec82323541400a9",
+        "rev": "250481aafeb741edfe23d29195671c19b36b6dca",
         "type": "github"
       },
       "original": {
@@ -126,10 +126,10 @@
     },
     "nixpkgs": {
       "locked": {
-        "lastModified": 1766181714,
+        "lastModified": 1767657734,
         "owner": "nixos",
         "repo": "nixpkgs",
-        "rev": "ff2da5fee8b3248cac330f14eac98228620beab0",
+        "rev": "d4ccebf51ee4dbeb9df364dce1fe9848635c1258",
         "type": "github"
       },
       "original": {
@@ -190,10 +190,10 @@
         ]
       },
       "locked": {
-        "lastModified": 1766112155,
+        "lastModified": 1767667566,
         "owner": "oxalica",
         "repo": "rust-overlay",
-        "rev": "2a6db3fc1c27ae77f9caa553d7609b223cb770b5",
+        "rev": "056ce5b125ab32ffe78c7d3e394d9da44733c95e",
         "type": "github"
       },
       "original": {
@@ -223,10 +223,10 @@
         ]
       },
       "locked": {
-        "lastModified": 1766000401,
+        "lastModified": 1767468822,
         "owner": "numtide",
         "repo": "treefmt-nix",
-        "rev": "42d96e75aa56a3f70cab7e7dc4a32868db28e8fd",
+        "rev": "d56486eb9493ad9c4777c65932618e9c2d0468fc",
         "type": "github"
       },
       "original": {
modifiedflake.nixdiffbeforeafterboth
--- a/flake.nix
+++ b/flake.nix
@@ -128,11 +128,6 @@
               overlays = [
                 (inputs.rust-overlay.overlays.default)
                 (final: prev: {
-                  boehmgc = prev.boehmgc.overrideAttrs (prevAttrs: {
-                    configureFlags = prevAttrs.configureFlags ++ [
-                      "--enable-gc-assertions"
-                    ];
-                  });
                   # Libsecret is stupidly huge
                   # https://github.com/oxalica/rust-overlay/issues/211
                   libsecret = final.stdenv.mkDerivation {
modifiedmodules/secrets-data.nixdiffbeforeafterboth
--- a/modules/secrets-data.nix
+++ b/modules/secrets-data.nix
@@ -1,7 +1,6 @@
 {
   lib,
   fleetLib,
-  config,
   ...
 }:
 let
@@ -15,15 +14,7 @@
     submodule
     bool
     unspecified
-    ;
-  inherit (lib.attrsets)
-    mapAttrsToList
-    mapAttrs
-    filterAttrs
-    genAttrs
     ;
-  inherit (lib.lists) sort unique concatLists;
-  inherit (lib.strings) toJSON;
 
   secretDataValue = {
     options = {
@@ -71,35 +62,8 @@
         default = null;
       };
     };
-    config = { };
   };
 
-  hostSecretData = {
-    freeformType = attrsOf (submodule secretDataValue);
-    options = {
-      createdAt = mkOption {
-        type = str;
-        description = "Timestamp of secret generation/last rotation.";
-        default = null;
-      };
-      expiresAt = mkOption {
-        type = nullOr str;
-        description = "Expiration timestamp triggering mandatory secret rotation.";
-        default = null;
-      };
-      shared = mkOption {
-        type = bool;
-        description = "Indicates if secret is a shared secret, so other hosts might have the same piece of secret data.";
-        default = false;
-      };
-      generationData = mkOption {
-        type = unspecified;
-        description = "Contextual metadata associated with secret part.";
-        default = null;
-      };
-    };
-    config = { };
-  };
   managerKey = {
     options = {
       name = mkOption {
@@ -121,49 +85,11 @@
         managerKeys = mkOption {
           type = listOf (submodule managerKey);
         };
-        sharedSecrets = mkOption {
-          type = attrsOf (submodule sharedSecretData);
+        secrets = mkOption {
+          type = attrsOf (listOf submodule sharedSecretData);
           default = { };
           description = "Shared secret data.";
-        };
-        hostSecrets = mkOption {
-          type = attrsOf (attrsOf (submodule hostSecretData));
-          default = { };
-          description = "Host-specific secrets.";
-          internal = true;
         };
       };
-      config.hostSecrets =
-        let
-          hostsWithSharedSecrets = unique (
-            concatLists (mapAttrsToList (_: s: s.owners) config.sharedSecrets)
-          );
-          secretsHavingHost = host: filterAttrs (_: secret: lib.elem host secret.owners) config.sharedSecrets;
-          toHostSecret = _: secret: (removeAttrs secret [ "owners" ]) // { shared = true; };
-        in
-        genAttrs hostsWithSharedSecrets (host: mapAttrs toHostSecret (secretsHavingHost host));
     });
-  config = {
-    assertions =
-      (mapAttrsToList (name: secret: {
-        assertion =
-          secret.expectedOwners == null
-          ||
-            sort (a: b: a < b) (config.data.sharedSecrets.${name} or { owners = [ ]; }).owners
-            == sort (a: b: a < b) secret.expectedOwners;
-        message = "Shared secret ${name} is expected to be encrypted for ${toJSON secret.expectedOwners}, but it is encrypted for ${
-          toJSON (config.data.sharedSecrets.${name} or { owners = [ ]; }).owners
-        }. Run fleet secrets regenerate to fix";
-      }) config.sharedSecrets)
-
-      ++ (mapAttrsToList (name: secret: {
-        # TODO: Same assertion should be in host secrets
-        assertion =
-          (config.data.sharedSecrets.${name} or { generationData = null; }).generationData
-          == secret.expectedGenerationData;
-        message = "Shared secret ${name} has unexpected generation data ${toJSON secret.expectedGenerationData} != ${
-          toJSON (config.data.sharedSecrets.${name} or { generationData = null; }).generationData
-        }. Run fleet secrets regenerate to fix";
-      }) config.sharedSecrets);
-  };
 }
modifiedmodules/secrets.nixdiffbeforeafterboth
--- a/modules/secrets.nix
+++ b/modules/secrets.nix
@@ -1,6 +1,5 @@
 {
   lib,
-  config,
   ...
 }:
 let
@@ -18,7 +17,6 @@
     uniq
     ;
   inherit (lib.strings) concatStringsSep;
-  inherit (lib.attrsets) mapAttrs;
 
   sharedSecret =
     { config, ... }:
@@ -54,6 +52,12 @@
             Set to false if host permissions are revoked through alternative mechanisms like firewall rules.
           '';
         };
+        allowDifferent = mkOption {
+          type = bool;
+          description = ''
+            When adding owner, do not update secret value for other owners, instead creating a new distribution
+          '';
+        };
         generator = mkOption {
           type = uniq (nullOr (functionTo package));
           description = ''
@@ -84,32 +88,13 @@
 in
 {
   options = {
-    sharedSecrets = mkOption {
+    secrets = mkOption {
       type = attrsOf (submodule sharedSecret);
       default = { };
       description = "Collection of secrets shared across multiple hosts with configurable ownership";
     };
   };
   config = {
-    hosts = mapAttrs (
-      _: secretMap:
-      let
-        partsOf =
-          s:
-          removeAttrs s [
-            "createdAt"
-            "expiresAt"
-            "generationData"
-          ];
-
-      in
-      {
-        nixos.data.secrets = mapAttrs (_: s: partsOf s) secretMap;
-        # nixos.secrets = mapAttrs (
-        #   _: s: mapAttrs (_: _: {}) (partsOf s)
-        # ) secretMap;
-      }
-    ) config.data.hostSecrets;
     nixpkgs.overlays = [
       (final: prev: {
         mkSecretGenerators =