From 992c7b63e98f8a59247cac2de3ab757c3c41f289 Mon Sep 17 00:00:00 2001 From: Yaroslav Bolyukin Date: Wed, 20 May 2026 06:24:32 +0000 Subject: [PATCH] feat: postgres secret generator --- --- a/Cargo.lock +++ b/Cargo.lock @@ -1045,7 +1045,10 @@ "ed25519-dalek", "fleet-shared", "hex", + "hmac", + "pbkdf2", "rand 0.10.1", + "sha2", "x25519-dalek", ] --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ futures = "0.3.31" futures-util = { version = "0.3.31", features = ["sink"] } hex = "0.4.3" +hmac = "0.12" hostname = "0.4.1" human-repr = "1.1" hyper = "1.8.1" @@ -40,11 +41,12 @@ linked-hash-map = "0.5.6" nix = { version = "0.31.2", features = ["fs", "user"] } nom = "8.0.0" +openssh = "0.11.5" opentelemetry = "0.31.0" +opentelemetry-appender-tracing = "0.31.1" opentelemetry-otlp = { version = "0.31.0", features = ["grpc-tonic", "gzip-tonic", "http-json", "reqwest-rustls"] } opentelemetry_sdk = "0.31.0" -opentelemetry-appender-tracing = "0.31.1" -openssh = "0.11.5" +pbkdf2 = "0.12" peg = "0.8.5" pkg-config = "0.3.30" rand = "0.10.0" @@ -52,6 +54,7 @@ serde = { version = "1.0", features = ["derive"] } serde-transcode = "1.1.1" serde_json = "1.0" +sha2 = "0.10" shlex = "1.3" tabled = "0.20.0" tempfile = "3.20" --- a/cmds/fleet/Cargo.toml +++ b/cmds/fleet/Cargo.toml @@ -44,12 +44,12 @@ indicatif = { workspace = true, optional = true } nom.workspace = true opentelemetry.workspace = true +opentelemetry-appender-tracing.workspace = true +opentelemetry-exporter-env.workspace = true opentelemetry_sdk.workspace = true thiserror.workspace = true tracing-indicatif = { workspace = true, optional = true } tracing-opentelemetry.workspace = true -opentelemetry-exporter-env.workspace = true -opentelemetry-appender-tracing.workspace = true [features] default = ["indicatif"] --- a/cmds/generator-helper/Cargo.toml +++ b/cmds/generator-helper/Cargo.toml @@ -13,5 +13,8 @@ base64.workspace = true ed25519-dalek.workspace = true hex.workspace = true +hmac.workspace = true +pbkdf2.workspace = true rand.workspace = true +sha2.workspace = true x25519-dalek.workspace = true --- a/cmds/generator-helper/src/main.rs +++ b/cmds/generator-helper/src/main.rs @@ -10,14 +10,30 @@ ssh::{ParseRecipientKeyError, Recipient as SshRecipient}, }; use anyhow::{Context, Result, anyhow, bail, ensure}; +use base64::{Engine as _, engine::general_purpose::STANDARD, write::EncoderWriter}; use clap::{Parser, ValueEnum}; use ed25519_dalek::SecretKey; use fleet_shared::SecretData; +use hmac::Mac as _; use rand::{ Rng as _, distr::{Alphanumeric, Distribution, SampleString, Uniform}, rng, }; +use sha2::Digest as _; + +fn gen_password(rng: &mut impl rand::Rng, size: usize, no_symbols: bool) -> String { + if no_symbols { + Alphanumeric.sample_string(rng, size) + } else { + const GEN_ASCII_SYMBOLS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"; + let uniform = Uniform::new(0, GEN_ASCII_SYMBOLS.len()).expect("range is valid"); + (0..size) + .map(|_| uniform.sample(rng)) + .map(|i| GEN_ASCII_SYMBOLS[i] as char) + .collect::() + } +} fn write_output_file(out: &str) -> Result { let file = OpenOptions::new() @@ -105,8 +121,6 @@ match encoding { OutputEncoding::Raw => coerce(w), OutputEncoding::Base64 => { - use base64::{engine::general_purpose::STANDARD, write::EncoderWriter}; - let writer = EncoderWriter::new(w, &STANDARD); coerce(writer) } @@ -177,6 +191,18 @@ #[arg(long, short = 'e', value_enum, default_value_t)] encoding: OutputEncoding, }, + PostgresPassword { + #[arg(long, short = 's')] + secret: String, + #[arg(long, short = 'H')] + hash: String, + #[arg(long, default_value_t = 24)] + size: usize, + #[arg(long, default_value_t = 4096)] + iterations: u32, + #[arg(long, short = 'n')] + no_symbols: bool, + }, Bytes { #[arg(long, short = 'o')] output: String, @@ -286,20 +312,62 @@ "misconfiguration? password is shorter than 6 chars" ); let recipients = load_identities()?; - let out = if no_symbols { - Alphanumeric.sample_string(&mut rng, size) - } else { - // Alphabet of Alphanumberic + symbols - const GEN_ASCII_SYMBOLS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"; - let uniform = - Uniform::new(0, GEN_ASCII_SYMBOLS.len()).expect("range is valid"); - (0..size) - .map(|_| uniform.sample(&mut rng)) - .map(|i| GEN_ASCII_SYMBOLS[i] as char) - .collect::() - }; + let out = gen_password(&mut rng, size, no_symbols); write_private(&recipients, &output, out.as_bytes(), encoding)?; } + Generate::PostgresPassword { + secret, + hash, + size, + iterations, + no_symbols, + } => { + ensure!( + size >= 6, + "misconfiguration? password is shorter than 6 chars" + ); + let recipients = load_identities()?; + let password = gen_password(&mut rng, size, no_symbols); + + let mut salt = [0u8; 16]; + rng.fill_bytes(&mut salt); + let salted = pbkdf2::pbkdf2_hmac_array::( + password.as_bytes(), + &salt, + iterations, + ); + + type HmacSha256 = hmac::Hmac; + let mut mac = ::new_from_slice(&salted) + .expect("HMAC accepts any key length"); + mac.update(b"Client Key"); + let client_key = mac.finalize().into_bytes(); + + let mut hasher = sha2::Sha256::new(); + hasher.update(client_key); + let stored_key = hasher.finalize(); + + let mut mac = ::new_from_slice(&salted) + .expect("HMAC accepts any key length"); + mac.update(b"Server Key"); + let server_key = mac.finalize().into_bytes(); + + let hash_str = format!( + "SCRAM-SHA-256${}:{}${}:{}", + iterations, + STANDARD.encode(salt), + STANDARD.encode(stored_key), + STANDARD.encode(server_key), + ); + + write_private( + &recipients, + &secret, + password.as_bytes(), + OutputEncoding::Raw, + )?; + write_public(&hash, hash_str.as_bytes(), OutputEncoding::Raw)?; + } Generate::Bytes { output, count, --- a/crates/nix-eval/src/lib.rs +++ b/crates/nix-eval/src/lib.rs @@ -26,9 +26,8 @@ clear_err, copy_value, err_NIX_ERR_KEY, err_NIX_ERR_NIX_ERROR, err_NIX_ERR_OVERFLOW, err_NIX_ERR_UNKNOWN, err_code, err_info_msg, err_msg, eval_state_build, eval_state_builder_load, eval_state_builder_new, eval_state_builder_set_eval_setting, - expr_eval_from_string, fetchers_settings, - fetchers_settings_free, fetchers_settings_new, flake_lock, flake_lock_flags, - flake_lock_flags_free, flake_lock_flags_new, flake_reference, + expr_eval_from_string, fetchers_settings, fetchers_settings_free, fetchers_settings_new, + flake_lock, flake_lock_flags, flake_lock_flags_free, flake_lock_flags_new, flake_reference, flake_reference_and_fragment_from_string, flake_reference_parse_flags, flake_reference_parse_flags_free, flake_reference_parse_flags_new, flake_reference_parse_flags_set_base_directory, flake_settings, flake_settings_free, @@ -323,7 +322,9 @@ thread_local! { static THREAD_STATE: RefCell = RefCell::new(ThreadState::new().expect("thread state init shouldn't fail")); } -pub(crate) fn with_default_context(f: impl FnOnce(*mut c_context, *mut c_eval_state) -> T) -> Result { +pub(crate) fn with_default_context( + f: impl FnOnce(*mut c_context, *mut c_eval_state) -> T, +) -> Result { let global = &GLOBAL_STATE.state; let (ctx, state) = THREAD_STATE.with_borrow_mut(|w| (w.ctx.0, global.0)); let mut ctx = NixContext(ctx); @@ -439,7 +440,11 @@ } } -pub(crate) unsafe extern "C" fn copy_nix_str(start: *const c_char, n: c_uint, user_data: *mut c_void) { +pub(crate) unsafe extern "C" fn copy_nix_str( + start: *const c_char, + n: c_uint, + user_data: *mut c_void, +) { let s = unsafe { slice::from_raw_parts(start.cast::(), n as usize) }; let s = std::str::from_utf8(s).expect("c string has invalid utf-8"); unsafe { *user_data.cast::() = s.to_owned() }; @@ -1128,8 +1133,8 @@ let test_result: String = nix_go_json!(builtins.uppercaseSuffix2("test")("suffix")); assert_eq!(test_result, "TESTsuffix"); - let drv_path = nix_go!(attrs.packages["x86_64-linux"]["fleet-install-secrets"].drvPath) - .to_string()?; + let drv_path = + nix_go!(attrs.packages["x86_64-linux"]["fleet-install-secrets"].drvPath).to_string()?; let graph = drv::DrvGraph::resolve(&drv_path)?; eprintln!( "fleet-install-secrets dependency graph: {} nodes", --- a/crates/nix-eval/src/logging.rs +++ b/crates/nix-eval/src/logging.rs @@ -351,12 +351,15 @@ if let Some(entry) = drv_graph.get_mut(dep_path) { entry.refcount += 1; } else { - drv_graph.insert(dep_path.clone(), DrvGraphEntry { - name: dep_node.name.clone(), - parent: Some(path.clone()), - span: None, - refcount: 1, - }); + drv_graph.insert( + dep_path.clone(), + DrvGraphEntry { + name: dep_node.name.clone(), + parent: Some(path.clone()), + span: None, + refcount: 1, + }, + ); } paths.push(dep_path.clone()); queue.push_back(dep_path.clone()); --- a/crates/opentelemetry-exporter-env/src/lib.rs +++ b/crates/opentelemetry-exporter-env/src/lib.rs @@ -11,10 +11,7 @@ #[derive(thiserror::Error, Debug)] pub enum Error { #[error("environment variable {env} contains invalid UTF-8: {value:?}")] - InvalidUtf8 { - env: &'static str, - value: OsString, - }, + InvalidUtf8 { env: &'static str, value: OsString }, #[error("environment variable {env}={value:?}: {error}")] EnvParse { env: &'static str, @@ -103,13 +100,30 @@ #[cfg_attr(feature = "clap", derive(clap::Parser))] pub struct SignalExporterSettings { /// Traces exporter to be used. - #[cfg_attr(feature = "clap", arg(long = "otel-traces-exporter", env = "OTEL_TRACES_EXPORTER", value_enum))] + #[cfg_attr( + feature = "clap", + arg( + long = "otel-traces-exporter", + env = "OTEL_TRACES_EXPORTER", + value_enum + ) + )] pub traces: Option, /// Metrics exporter to be used. - #[cfg_attr(feature = "clap", arg(long = "otel-metrics-exporter", env = "OTEL_METRICS_EXPORTER", value_enum))] + #[cfg_attr( + feature = "clap", + arg( + long = "otel-metrics-exporter", + env = "OTEL_METRICS_EXPORTER", + value_enum + ) + )] pub metrics: Option, /// Logs exporter to be used. - #[cfg_attr(feature = "clap", arg(long = "otel-logs-exporter", env = "OTEL_LOGS_EXPORTER", value_enum))] + #[cfg_attr( + feature = "clap", + arg(long = "otel-logs-exporter", env = "OTEL_LOGS_EXPORTER", value_enum) + )] pub logs: Option, } --- a/crates/opentelemetry-exporter-env/src/otlp.rs +++ b/crates/opentelemetry-exporter-env/src/otlp.rs @@ -55,15 +55,13 @@ } builder.build() } - OtlpProtocol::HttpProtobuf | OtlpProtocol::HttpJson => { - <$exporter>::builder() - .with_http() - .with_endpoint(&s.endpoint) - .with_headers(to_hashmap(s.headers.as_deref())) - .with_protocol(s.protocol.into()) - .with_timeout(s.timeout) - .build() - } + OtlpProtocol::HttpProtobuf | OtlpProtocol::HttpJson => <$exporter>::builder() + .with_http() + .with_endpoint(&s.endpoint) + .with_headers(to_hashmap(s.headers.as_deref())) + .with_protocol(s.protocol.into()) + .with_timeout(s.timeout) + .build(), } }}; } --- a/lib/default.nix +++ b/lib/default.nix @@ -89,6 +89,44 @@ ); /** + Generate a random password suitable for PostgreSQL role authentication. + + Options: + size: generated password length in ascii characters (bytes). + iterations: PBKDF2 iteration count (PG default: 4096). + 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]. + + Output: + secret: encrypted password. + hash: SCRAM-SHA-256 hash of the password to be used by postgres. + */ + # It is not possible to extract it into a scram-sha-256 generic generator because there is no estabilished format + # for it, and mongodb for example encodes it differently. + mkPostgresPassword = + { + size ? 32, + iterations ? 4096, + noSymbols ? false, + }: + ( + { mkSecretGenerator }: + mkSecretGenerator { + script = '' + mkdir $out + gh generate postgres-password \ + -s $out/secret \ + -H $out/hash \ + --size ${toString size} \ + --iterations ${toString iterations} \ + ${optionalString noSymbols "--no-symbols"} + ''; + parts.secret.encrypted = true; + parts.hash.encrypted = false; + } + ); + + /** Generate a random ed25519 keypair Options: @@ -205,8 +243,7 @@ mkAskFile { inherit part; header = builtins.concatStringsSep "\n" ( - (map (l: "# ${l}") (lib.splitString "\n" header)) - ++ (map (v: "${v}=") variables) + (map (l: "# ${l}") (lib.splitString "\n" header)) ++ (map (v: "${v}=") variables) ); }; @@ -306,6 +343,7 @@ inherit (secrets) mkPassword + mkPostgresPassword mkEd25519 mkX25519 mkRsa -- gitstuff