difftreelog
feat infer secret parts from generator
in: trunk
3 files changed
cmds/install-secrets/src/main.rsdiffbeforeafterboth1use 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 #[serde(flatten)]62 parts: BTreeMap<String, Part>,63}6465type Data = HashMap<String, DataItem>;6667fn decrypt(input: &SecretData, identity: &dyn Identity) -> Result<Vec<u8>> {68 ensure!(input.encrypted, "passed data is not encrypted!");69 let mut input = Cursor::new(&input.data);70 let decryptor = Decryptor::new(&mut input).context("failed to init decryptor")?;71 if decryptor.is_scrypt() {72 bail!("should be recipients");73 }74 let mut decryptor = decryptor75 .decrypt(iter::once(identity as &dyn age::Identity))76 .context("failed to decrypt, wrong key?")?;7778 let mut decrypted = Vec::new();79 decryptor80 .read_to_end(&mut decrypted)81 .context("failed to decrypt")?;82 Ok(decrypted)83}84fn encrypt(input: &[u8], targets: Vec<String>) -> Result<SecretData> {85 let recipients = targets86 .into_iter()87 .map(|t| {88 SshRecipient::from_str(&t).map_err(|e| anyhow!("failed to parse recipient: {e:?}"))89 })90 .collect::<Result<Vec<SshRecipient>>>()?;91 let recipients = recipients.iter().map(|v| v as &dyn Recipient);92 let mut encrypted = vec![];93 let mut encryptor = Encryptor::with_recipients(recipients)94 .expect("recipients provided")95 .wrap_output(&mut encrypted)96 .expect("constructor should not fail");97 io::copy(&mut Cursor::new(input), &mut encryptor).expect("copy should not fail");98 encryptor.finish().context("failed to finish encryption")?;99 Ok(SecretData {100 data: encrypted,101 encrypted: true,102 })103}104105fn init_part(identity: &dyn Identity, item: &DataItem, value: &Part) -> Result<()> {106 let stable_dir = value.stable_path.parent().expect("not root");107108 // Right now stable & non-stable data are both located in this dir.109 std::fs::create_dir_all(stable_dir)?;110111 let mut stable_temp =112 tempfile::NamedTempFile::new_in(stable_dir).context("failed to create tempfile")?;113 let mut hashed = File::create(&value.path)?;114115 let private = value.raw.encrypted;116 let data = if private {117 decrypt(&value.raw, identity)?118 } else {119 value.raw.data.to_owned()120 };121122 hashed.write_all(&data)?;123 hashed.flush()?;124 stable_temp.write_all(&data)?;125 stable_temp.flush()?;126127 let mode = if private {128 fs::Permissions::from_mode(129 u32::from_str_radix(&item.mode, 8).context("failed to parse mode as octal")?,130 )131 } else {132 fs::Permissions::from_mode(0o444)133 };134 fs::set_permissions(stable_temp.path(), mode.clone()).context("stable temp mode")?;135 fs::set_permissions(&value.path, mode).context("hashed mode")?;136137 // Files are initially owned by root, thus making set mode first inaccessible to user, and then138 // altering user/group.139 if private {140 let user = User::from_name(&item.owner)141 .context("failed to get user")?142 .ok_or_else(|| anyhow!("user not found"))?;143 let group = Group::from_name(&item.group)144 .context("failed to get group")?145 .ok_or_else(|| anyhow!("group not found"))?;146147 chown(stable_temp.path(), Some(user.uid), Some(group.gid))148 .context("failed to apply user/group")?;149 chown(&value.path, Some(user.uid), Some(group.gid))150 .context("failed to apply user/group")?;151 }152153 stable_temp154 .persist(&value.stable_path)155 .context("stable persist")?;156 Ok(())157}158159fn init_secret(identity: &age::ssh::Identity, value: &DataItem) -> Result<()> {160 if let Some(root_path) = &value.root_path {161 if !fs::metadata(root_path).map(|m| m.is_dir()).unwrap_or(false) {162 fs::create_dir(root_path).context("failed to create secret directory")?;163 }164 }165 let mut errored = false;166 for (part_id, part) in value.parts.iter() {167 let _span = info_span!("part", part_id = part_id);168 if let Err(e) = init_part(identity, value, part) {169 error!("failed to init part {part_id:?}: {e}");170 errored = true;171 }172 }173174 ensure!(!errored, "some secret parts have failed to initialize");175 Ok(())176}177178fn host_identity() -> anyhow::Result<SshIdentity> {179 let identity = SshIdentity::from_buffer(180 &mut Cursor::new(181 fs::read("/etc/ssh/ssh_host_ed25519_key").context("failed to read host private key")?,182 ),183 None,184 )185 .context("failed to parse identity")?;186 Ok(identity)187}188189fn install(data: &Path) -> anyhow::Result<()> {190 let data = fs::read(data).context("failed to read secrets data")?;191 let data_str = from_utf8(&data).context("failed to read data to string")?;192 let data: Data = serde_json::from_str(data_str).context("failed to parse data")?;193194 if !fs::metadata("/run/secrets")195 .map(|m| m.is_dir())196 .unwrap_or(false)197 {198 fs::create_dir("/run/secrets").context("failed to create secrets directory")?;199 }200201 if data.is_empty() {202 info!("no secrets to install");203 return Ok(());204 }205206 let identity = host_identity()?;207208 let mut failed = false;209 for (name, value) in data {210 let _span = info_span!("init", name = name);211 if let Err(e) = init_secret(&identity, &value) {212 error!("secret {name:?} failed to initialize: {e}");213 failed = true;214 }215 }216 if failed {217 bail!("one or more secrets failed");218 }219220 Ok(())221}222223fn main() -> anyhow::Result<()> {224 tracing_subscriber::fmt()225 .with_env_filter(226 EnvFilter::builder()227 .with_default_directive(LevelFilter::INFO.into())228 .from_env_lossy(),229 )230 .without_time()231 .with_target(false)232 .init();233234 let opts = Opts::parse();235236 match opts {237 Opts::Install { data } => install(&data),238 Opts::Reencrypt { secret, targets } => {239 let identity = host_identity()?;240 let decrypted = decrypt(&secret, &identity).context("during decryption")?;241 let encrypted = encrypt(&decrypted, targets).context("during re-encryption")?;242243 println!("{encrypted}");244 Ok(())245 }246 Opts::Decrypt { secret, plaintext } => {247 let identity = host_identity()?;248 let decrypted = decrypt(&secret, &identity).context("during decryption")?;249250 if plaintext {251 let s = String::from_utf8(decrypted).context("output is not utf8")?;252 print!("{s}");253 } else {254 println!(255 "{}",256 SecretData {257 data: decrypted,258 encrypted: false259 }260 );261 }262 Ok(())263 }264 }265}lib/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
modules/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" ];