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

difftreelog

feat infer secret parts from generator

uqnzwwslYaroslav Bolyukin2025-10-27parent: #3bff084.patch.diff
in: trunk

3 files changed

modifiedcmds/install-secrets/src/main.rsdiffbeforeafterboth
after · cmds/install-secrets/src/main.rs
1use std::{2	collections::{BTreeMap, HashMap},3	fs::{self, File},4	io::{self, Cursor, Read, Write},5	iter,6	os::unix::prelude::PermissionsExt,7	path::{Path, PathBuf},8	str::{FromStr, from_utf8},9};1011use age::{12	Decryptor, Encryptor, Identity, Recipient,13	ssh::{Identity as SshIdentity, Recipient as SshRecipient},14};15use anyhow::{Context, Result, anyhow, bail, ensure};16use clap::Parser;17use fleet_shared::SecretData;18use nix::unistd::{Group, User, chown};19use serde::Deserialize;20use tracing::{error, info, info_span};21use tracing_subscriber::{EnvFilter, filter::LevelFilter};2223#[derive(Parser)]24#[clap(author)]25enum Opts {26	/// Install secrets from json specification27	Install { data: PathBuf },28	/// Reencrypt secret using host key, outputting in fleet encoded string29	Reencrypt {30		#[clap(long)]31		secret: SecretData,32		#[clap(long)]33		targets: Vec<String>,34	},35	/// Decrypt secret using host key, outputting in fleet encoded string36	Decrypt {37		#[clap(long)]38		secret: SecretData,39		/// Shoult decoded output be printed as plaintext, instead of z85?40		#[clap(long)]41		plaintext: bool,42	},43}4445#[derive(Deserialize)]46#[serde(rename_all = "camelCase")]47struct Part {48	raw: SecretData,49	path: PathBuf,50	stable_path: PathBuf,51}5253#[derive(Deserialize)]54#[serde(rename_all = "camelCase")]55struct DataItem {56	group: String,57	mode: String,58	owner: String,59	root_path: Option<PathBuf>,6061	parts: BTreeMap<String, Part>,62}6364type Data = HashMap<String, DataItem>;6566fn decrypt(input: &SecretData, identity: &dyn Identity) -> Result<Vec<u8>> {67	ensure!(input.encrypted, "passed data is not encrypted!");68	let mut input = Cursor::new(&input.data);69	let decryptor = Decryptor::new(&mut input).context("failed to init decryptor")?;70	if decryptor.is_scrypt() {71		bail!("should be recipients");72	}73	let mut decryptor = decryptor74		.decrypt(iter::once(identity as &dyn age::Identity))75		.context("failed to decrypt, wrong key?")?;7677	let mut decrypted = Vec::new();78	decryptor79		.read_to_end(&mut decrypted)80		.context("failed to decrypt")?;81	Ok(decrypted)82}83fn encrypt(input: &[u8], targets: Vec<String>) -> Result<SecretData> {84	let recipients = targets85		.into_iter()86		.map(|t| {87			SshRecipient::from_str(&t).map_err(|e| anyhow!("failed to parse recipient: {e:?}"))88		})89		.collect::<Result<Vec<SshRecipient>>>()?;90	let recipients = recipients.iter().map(|v| v as &dyn Recipient);91	let mut encrypted = vec![];92	let mut encryptor = Encryptor::with_recipients(recipients)93		.expect("recipients provided")94		.wrap_output(&mut encrypted)95		.expect("constructor should not fail");96	io::copy(&mut Cursor::new(input), &mut encryptor).expect("copy should not fail");97	encryptor.finish().context("failed to finish encryption")?;98	Ok(SecretData {99		data: encrypted,100		encrypted: true,101	})102}103104fn init_part(identity: &dyn Identity, item: &DataItem, value: &Part) -> Result<()> {105	let stable_dir = value.stable_path.parent().expect("not root");106107	// Right now stable & non-stable data are both located in this dir.108	std::fs::create_dir_all(stable_dir)?;109110	let mut stable_temp =111		tempfile::NamedTempFile::new_in(stable_dir).context("failed to create tempfile")?;112	let mut hashed = File::create(&value.path)?;113114	let private = value.raw.encrypted;115	let data = if private {116		decrypt(&value.raw, identity)?117	} else {118		value.raw.data.to_owned()119	};120121	hashed.write_all(&data)?;122	hashed.flush()?;123	stable_temp.write_all(&data)?;124	stable_temp.flush()?;125126	let mode = if private {127		fs::Permissions::from_mode(128			u32::from_str_radix(&item.mode, 8).context("failed to parse mode as octal")?,129		)130	} else {131		fs::Permissions::from_mode(0o444)132	};133	fs::set_permissions(stable_temp.path(), mode.clone()).context("stable temp mode")?;134	fs::set_permissions(&value.path, mode).context("hashed mode")?;135136	// Files are initially owned by root, thus making set mode first inaccessible to user, and then137	// altering user/group.138	if private {139		let user = User::from_name(&item.owner)140			.context("failed to get user")?141			.ok_or_else(|| anyhow!("user not found"))?;142		let group = Group::from_name(&item.group)143			.context("failed to get group")?144			.ok_or_else(|| anyhow!("group not found"))?;145146		chown(stable_temp.path(), Some(user.uid), Some(group.gid))147			.context("failed to apply user/group")?;148		chown(&value.path, Some(user.uid), Some(group.gid))149			.context("failed to apply user/group")?;150	}151152	stable_temp153		.persist(&value.stable_path)154		.context("stable persist")?;155	Ok(())156}157158fn init_secret(identity: &age::ssh::Identity, value: &DataItem) -> Result<()> {159	if let Some(root_path) = &value.root_path {160		if !fs::metadata(root_path).map(|m| m.is_dir()).unwrap_or(false) {161			fs::create_dir(root_path).context("failed to create secret directory")?;162		}163	}164	let mut errored = false;165	for (part_id, part) in value.parts.iter() {166		let _span = info_span!("part", part_id = part_id);167		if let Err(e) = init_part(identity, value, part) {168			error!("failed to init part {part_id:?}: {e}");169			errored = true;170		}171	}172173	ensure!(!errored, "some secret parts have failed to initialize");174	Ok(())175}176177fn host_identity() -> anyhow::Result<SshIdentity> {178	let identity = SshIdentity::from_buffer(179		&mut Cursor::new(180			fs::read("/etc/ssh/ssh_host_ed25519_key").context("failed to read host private key")?,181		),182		None,183	)184	.context("failed to parse identity")?;185	Ok(identity)186}187188fn install(data: &Path) -> anyhow::Result<()> {189	let data = fs::read(data).context("failed to read secrets data")?;190	let data_str = from_utf8(&data).context("failed to read data to string")?;191	let data: Data = serde_json::from_str(data_str).context("failed to parse data")?;192193	if !fs::metadata("/run/secrets")194		.map(|m| m.is_dir())195		.unwrap_or(false)196	{197		fs::create_dir("/run/secrets").context("failed to create secrets directory")?;198	}199200	if data.is_empty() {201		info!("no secrets to install");202		return Ok(());203	}204205	let identity = host_identity()?;206207	let mut failed = false;208	for (name, value) in data {209		let _span = info_span!("init", name = name);210		if let Err(e) = init_secret(&identity, &value) {211			error!("secret {name:?} failed to initialize: {e}");212			failed = true;213		}214	}215	if failed {216		bail!("one or more secrets failed");217	}218219	Ok(())220}221222fn main() -> anyhow::Result<()> {223	tracing_subscriber::fmt()224		.with_env_filter(225			EnvFilter::builder()226				.with_default_directive(LevelFilter::INFO.into())227				.from_env_lossy(),228		)229		.without_time()230		.with_target(false)231		.init();232233	let opts = Opts::parse();234235	match opts {236		Opts::Install { data } => install(&data),237		Opts::Reencrypt { secret, targets } => {238			let identity = host_identity()?;239			let decrypted = decrypt(&secret, &identity).context("during decryption")?;240			let encrypted = encrypt(&decrypted, targets).context("during re-encryption")?;241242			println!("{encrypted}");243			Ok(())244		}245		Opts::Decrypt { secret, plaintext } => {246			let identity = host_identity()?;247			let decrypted = decrypt(&secret, &identity).context("during decryption")?;248249			if plaintext {250				let s = String::from_utf8(decrypted).context("output is not utf8")?;251				print!("{s}");252			} else {253				println!(254					"{}",255					SecretData {256						data: decrypted,257						encrypted: false258					}259				);260			}261			Ok(())262		}263	}264}
modifiedlib/default.nixdiffbeforeafterboth
--- a/lib/default.nix
+++ b/lib/default.nix
@@ -54,170 +54,214 @@
 
   inherit (modules) mkFleetDefault mkFleetGeneratorDefault;
 
-  secrets = {
-    /**
-      Generate a random secret password, 32 ascii characters by default
+  secrets =
+    let
+      describedGenerator =
+        generator: {parts ? {}}:
+        {parts = {};}
+        // {
+          __functor = generator;
+        };
+    in
+    {
+      inherit describedGenerator;
 
-      Options:
-        size: generated password length in ascii characters (bytes).
-        noSymbols: by default, character set includes various special characters ($ , ! + * : ~), and might
-                   not be accepted in some contexts, this option switches charset to just [A-Za-z0-9].
+      /**
+        Generate a random secret password, 32 ascii characters by default
 
-      Output:
-        Resulting secret has only part: secret, which contains encrypted password.
-    */
-    mkPassword =
-      {
-        size ? 32,
-      }:
-      {
-        coreutils,
-        mkSecretGenerator,
-      }:
-      mkSecretGenerator {
-        script = ''
-          mkdir $out
-          gh generate password -o $out/secret --size ${toString size}
-        '';
-      };
+        Options:
+          size: generated password length in ascii characters (bytes).
+          noSymbols: by default, character set includes various special characters ($ , ! + * : ~), and might
+                     not be accepted in some contexts, this option switches charset to just [A-Za-z0-9].
 
-    /**
-      Generate a random ed25519 keypair
+        Output:
+          Resulting secret has only part: secret, which contains encrypted password.
+      */
+      mkPassword =
+        {
+          size ? 32,
+        }:
+        describedGenerator
+          (
+            {
+              coreutils,
+              mkSecretGenerator,
+            }:
+            mkSecretGenerator {
+              script = ''
+                mkdir $out
+                gh generate password -o $out/secret --size ${toString size}
+              '';
+            }
+          )
+          {
+            parts.secret.encrypted = true;
+          };
+
+      /**
+        Generate a random ed25519 keypair
 
-      Options:
-        noEmbedPublic: By default, secret key also embeds public key in itself ("extended" format, 64 bytes)
-                       When noEmbedPublis is enabled - only the private scalar is included.
-        encoding: Encoring of public and secret parts, can be "raw" (default), "base64" or "hex".
+        Options:
+          noEmbedPublic: By default, secret key also embeds public key in itself ("extended" format, 64 bytes)
+                         When noEmbedPublis is enabled - only the private scalar is included.
+          encoding: Encoring of public and secret parts, can be "raw" (default), "base64" or "hex".
 
-      Output:
-        Resulting secret has two parts: public and secret, where the secret part is encrypted.
+        Output:
+          Resulting secret has two parts: public and secret, where the secret part is encrypted.
 
-      This secret format is used by e.g Garage S3 server
-    */
-    mkEd25519 =
-      {
-        noEmbedPublic ? false,
-        encoding ? null,
-      }:
-      { mkSecretGenerator }:
-      mkSecretGenerator {
-        script = ''
-          mkdir $out
-          gh generate ed25519 -p $out/public -s $out/secret \
-            ${optionalString noEmbedPublic "--no-embed-public"} \
-            ${optionalString (encoding != null) "--encoding=${encoding}"}
-        '';
-      };
+        This secret format is used by e.g Garage S3 server
+      */
+      mkEd25519 =
+        {
+          noEmbedPublic ? false,
+          encoding ? null,
+        }:
+        describedGenerator
+          (
+            { mkSecretGenerator }:
+            mkSecretGenerator {
+              script = ''
+                mkdir $out
+                gh generate ed25519 -p $out/public -s $out/secret \
+                  ${optionalString noEmbedPublic "--no-embed-public"} \
+                  ${optionalString (encoding != null) "--encoding=${encoding}"}
+              '';
+            }
+          )
+          {
+            parts.secret.encrypted = true;
+            parts.public.encrypted = false;
+          };
 
-    /**
-      Generate a random x25519 keypair
+      /**
+        Generate a random x25519 keypair
 
-      Options:
-        encoding: Encoring of public and secret parts, can be "raw" (default), "base64" or "hex".
+        Options:
+          encoding: Encoring of public and secret parts, can be "raw" (default), "base64" or "hex".
 
-      Output:
-        Resulting secret has two parts: public and secret, where the secret part is encrypted.
+        Output:
+          Resulting secret has two parts: public and secret, where the secret part is encrypted.
 
-      This secret format is used by e.g Wireguard VPN for peers (base64-encoded)
-    */
-    mkX25519 =
-      {
-        encoding ? null,
-      }:
-      { mkSecretGenerator }:
-      mkSecretGenerator {
-        script = ''
-          mkdir $out
-          gh generate x25519 -p $out/public -s $out/secret \
-            ${optionalString (encoding != null) "--encoding=${encoding}"}
-        '';
-      };
+        This secret format is used by e.g Wireguard VPN for peers (base64-encoded)
+      */
+      mkX25519 =
+        {
+          encoding ? null,
+        }:
+        describedGenerator
+          (
+            { mkSecretGenerator }:
+            mkSecretGenerator {
+              script = ''
+                mkdir $out
+                gh generate x25519 -p $out/public -s $out/secret \
+                  ${optionalString (encoding != null) "--encoding=${encoding}"}
+              '';
+            }
+          )
+          {
+            parts.secret.encrypted = true;
+            parts.public.encrypted = false;
+          };
 
-    /**
-      Generate a random RSA keypair
+      /**
+        Generate a random RSA keypair
 
-      Options:
-        size: RSA key size, 4096 by default
+        Options:
+          size: RSA key size, 4096 by default
 
-      Output:
-        Resulting secret has two parts: public and secret, where the secret part is encrypted.
-        Both parts are PEM encoded.
-    */
-    mkRsa =
-      {
-        size ? 4096,
-      }:
-      {
-        openssl,
-        mkSecretGenerator,
-      }:
-      mkSecretGenerator {
-        script = ''
-          mkdir $out
+        Output:
+          Resulting secret has two parts: public and secret, where the secret part is encrypted.
+          Both parts are PEM encoded.
+      */
+      mkRsa =
+        {
+          size ? 4096,
+        }:
+        describedGenerator
+          (
+            {
+              openssl,
+              mkSecretGenerator,
+            }:
+            mkSecretGenerator {
+              script = ''
+                mkdir $out
 
-          ${openssl}/bin/openssl genrsa -out rsa_private.key ${toString size}
-          ${openssl}/bin/openssl rsa -in rsa_private.key -pubout -out rsa_public.key
+                ${openssl}/bin/openssl genrsa -out rsa_private.key ${toString size}
+                ${openssl}/bin/openssl rsa -in rsa_private.key -pubout -out rsa_public.key
 
-          cat rsa_private.key | gh private -o $out/secret
-          cat rsa_public.key | gh public -o $out/public
-        '';
-      };
+                cat rsa_private.key | gh private -o $out/secret
+                cat rsa_public.key | gh public -o $out/public
+              '';
+            }
+          )
+          {
+            parts.secret.encrypted = true;
+            parts.public.encrypted = false;
+          };
 
-    /**
-      Generate a random byte sequence
+      /**
+        Generate a random byte sequence
 
-      Options:
-        size: generated password length in bytes, 32 by default.
-        encoding: how the generated bytes should be encoded, "raw" (default), "hex" or "base64"
-        noNuls: prevent output byte sequence from containing internal \0, useful for some C applications
-                that can't handle their strings properly.
+        Options:
+          size: generated password length in bytes, 32 by default.
+          encoding: how the generated bytes should be encoded, "raw" (default), "hex" or "base64"
+          noNuls: prevent output byte sequence from containing internal \0, useful for some C applications
+                  that can't handle their strings properly.
 
-      Output:
-        Resulting secret has only part: secret, which contains encrypted bytes.
+        Output:
+          Resulting secret has only part: secret, which contains encrypted bytes.
 
-      Might be used for e.g. Wireguard VPN PSK keys (base64-encoded)
-    */
-    mkBytes =
-      {
-        count ? 32,
-        encoding,
-        noNuls ? false,
-      }:
-      { mkSecretGenerator }:
-      mkSecretGenerator {
-        script = ''
-          mkdir $out
-          gh generate bytes --count=${toString count} --encoding=${encoding} -o $out/secret \
-            ${optionalString noNuls "--no-nuls"}
-        '';
-      };
-    /**
-      Shorthand for `mkBytes`, which defaults to "hex" encoding
-    */
-    mkHexBytes =
-      {
-        count ? 32,
-      }:
-      mkBytes {
-        inherit count;
-        encoding = "hex";
-      };
-    /**
-      Shorthand for `mkBytes`, which defaults to "base64" encoding
-    */
-    mkBase64Bytes =
-      {
-        count ? 32,
-      }:
-      mkBytes {
-        inherit count;
-        encoding = "base64";
-      };
+        Might be used for e.g. Wireguard VPN PSK keys (base64-encoded)
+      */
+      mkBytes =
+        {
+          count ? 32,
+          encoding,
+          noNuls ? false,
+        }:
+        describedGenerator
+          (
+            { mkSecretGenerator }:
+            mkSecretGenerator {
+              script = ''
+                mkdir $out
+                gh generate bytes --count=${toString count} --encoding=${encoding} -o $out/secret \
+                  ${optionalString noNuls "--no-nuls"}
+              '';
+            }
+          )
+          {
+            parts.secret.encrypted = true;
+          };
+      /**
+        Shorthand for `mkBytes`, which defaults to "hex" encoding
+      */
+      mkHexBytes =
+        {
+          count ? 32,
+        }:
+        mkBytes {
+          inherit count;
+          encoding = "hex";
+        };
+      /**
+        Shorthand for `mkBytes`, which defaults to "base64" encoding
+      */
+      mkBase64Bytes =
+        {
+          count ? 32,
+        }:
+        mkBytes {
+          inherit count;
+          encoding = "base64";
+        };
 
-    # Wireguard
-    # mkWireguard = {}: mkX25519 {encoding = "base64";};
-    # mkWireguardPsk = {}: mkBase64Bytes {count = 32;};
-  };
+      # Wireguard
+      # mkWireguard = {}: mkX25519 {encoding = "base64";};
+      # mkWireguardPsk = {}: mkBase64Bytes {count = 32;};
+    };
 
   inherit (secrets)
     mkPassword
modifiedmodules/nixos/secrets.nixdiffbeforeafterboth
--- a/modules/nixos/secrets.nix
+++ b/modules/nixos/secrets.nix
@@ -6,12 +6,18 @@
   ...
 }:
 let
-  inherit (builtins) hashString elemAt length toJSON filter;
+  inherit (builtins)
+    hashString
+    elemAt
+    length
+    toJSON
+    filter
+    ;
   inherit (lib.stringsWithDeps) stringAfter;
   inherit (lib.options) mkOption literalExpression;
   inherit (lib.lists) optional;
   inherit (lib.attrsets) mapAttrs mapAttrsToList;
-  inherit (lib.modules) mkIf;
+  inherit (lib.modules) mkIf mkMerge;
   inherit (lib.types)
     submodule
     str
@@ -23,6 +29,7 @@
     functionTo
     package
     listOf
+    bool
     ;
   inherit (fleetLib.strings) decodeRawSecret;
 
@@ -54,6 +61,11 @@
       in
       {
         options = {
+          encrypted = mkOption {
+            type = bool;
+            description = "Is this secret part supposed to be encrypted?";
+          };
+
           hash = mkOption {
             type = str;
             description = "Hash of secret in encoded format";
@@ -86,28 +98,18 @@
   secretType = submodule (
     {
       config,
-      loc,
-      options,
       ...
     }:
     let
-      secretName =
-        # Due to config definition for freeformType, we can't just use _module.args due to infinite recursion, instead
-        # extract the secret name the ugly way...
-        let
-          saLoc = options._module.specialArgs.loc;
-          comp = elemAt saLoc;
-        in
-        assert
-          (length saLoc == 2 ||
-          length saLoc == 4 &&
-          comp 0 == "secrets" && comp 2 == "_module" && comp 3 == "specialArgs") ||
-          throw "Unexpected module structure ${toJSON saLoc}";
-        if length saLoc == 2 then "documentation generator stub" else comp 1;
+      secretName = config._module.args.name;
     in
     {
-      freeformType = lazyAttrsOf (secretPartType secretName);
       options = {
+        parts = mkOption {
+          type = lazyAttrsOf (secretPartType secretName);
+          description = "Definition of secret parts";
+          default = {};
+        };
         generator = mkOption {
           type = uniq (nullOr (functionTo package));
           description = "Derivation to evaluate for secret generation";
@@ -134,18 +136,11 @@
           description = "Data that gets embedded into secret part";
           default = null;
         };
-        expectedPrivateParts = mkOption {
-          type = listOf str;
-          default = [ ];
-          description = "List of parts that are expected to be encrypted";
-        };
-        expectedPublicParts = mkOption {
-          type = listOf str;
-          default = [ ];
-          description = "List of parts that are expected to be public";
-        };
       };
-      config = mapAttrs (_: _: { }) (removeAttrs (sysConfig.data.secrets.${secretName} or {}) [ "shared" ]);
+      config.parts = mkMerge [
+        (mkIf (config.generator != null && config.generator ? parts) config.generator.parts)
+        (mapAttrs (_: _: {}) (removeAttrs sysConfig.data.secrets.${secretName} ["shared"]))
+      ];
     }
   );
   processPart = secretName: partName: part: {
@@ -155,20 +150,11 @@
   processSecret =
     secretName: secret:
     {
-      inherit (secret) group mode owner;
-    }
-    // (mapAttrs (processPart secretName) (
-      removeAttrs secret [
-        "shared"
-        "generator"
-        "mode"
-        "group"
-        "owner"
-        "expectedGenerationData"
-        "expectedPrivateParts"
-        "expectedPublicParts"
-      ]
-    ));
+      inherit (secret.definition) group mode owner;
+      parts = (mapAttrs (processPart secretName) (
+        secret.definition.parts
+      ));
+    };
   secretsData = (mapAttrs (processSecret) config.secrets);
   secretsFile = pkgs.writeTextFile {
     name = "secrets.json";
@@ -188,29 +174,18 @@
     secrets = mkOption {
       type = attrsOf secretType;
       default = { };
+      apply = v: (mapAttrs (_: secret: secret.parts // {definition = secret;}) v);
       description = "Host-local secrets";
     };
     system.secretsData = mkOption {
       type = unspecified;
-      default = {};
+      default = { };
       description = "secrets.json contents";
     };
   };
   config = {
-    system = {inherit secretsData;};
+    system = { inherit secretsData; };
     environment.systemPackages = [ pkgs.fleet-install-secrets ];
-
-    warnings = filter (v: v!=null) (mapAttrsToList (
-      name: secret:
-      if
-        secret.expectedPrivateParts == [ ]
-        && secret.expectedPublicParts == [ ]
-        && !(config.data.secrets.${name} or { shared = false; }).shared
-      then
-        "Secret ${name} has no expected parts defined, this is deprecated for better visibility"
-      else
-        null
-    ) config.secrets);
 
     systemd.services.fleet-install-secrets = mkIf useSysusers {
       wantedBy = [ "sysinit.target" ];