git.delta.rocks / fleet / refs/commits / 992c7b63e98f

difftreelog

feat postgres secret generator

xmvqpnskYaroslav Bolyukin2026-05-20parent: #7fa1963.patch.diff

10 files changed

modifiedCargo.lockdiffbeforeafterboth
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1045,7 +1045,10 @@
  "ed25519-dalek",
  "fleet-shared",
  "hex",
+ "hmac",
+ "pbkdf2",
  "rand 0.10.1",
+ "sha2",
  "x25519-dalek",
 ]
 
modifiedCargo.tomldiffbeforeafterboth
--- 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"
modifiedcmds/fleet/Cargo.tomldiffbeforeafterboth
--- 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"]
modifiedcmds/generator-helper/Cargo.tomldiffbeforeafterboth
--- 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
modifiedcmds/generator-helper/src/main.rsdiffbeforeafterboth
--- 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::<String>()
+	}
+}
 
 fn write_output_file(out: &str) -> Result<File> {
 	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::<String>()
-					};
+					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::<sha2::Sha256, 32>(
+						password.as_bytes(),
+						&salt,
+						iterations,
+					);
+
+					type HmacSha256 = hmac::Hmac<sha2::Sha256>;
+					let mut mac = <HmacSha256 as hmac::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 = <HmacSha256 as hmac::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,
modifiedcrates/nix-eval/src/lib.rsdiffbeforeafterboth
--- 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<ThreadState> = RefCell::new(ThreadState::new().expect("thread state init shouldn't fail"));
 }
-pub(crate) fn with_default_context<T>(f: impl FnOnce(*mut c_context, *mut c_eval_state) -> T) -> Result<T> {
+pub(crate) fn with_default_context<T>(
+	f: impl FnOnce(*mut c_context, *mut c_eval_state) -> T,
+) -> Result<T> {
 	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::<u8>(), n as usize) };
 	let s = std::str::from_utf8(s).expect("c string has invalid utf-8");
 	unsafe { *user_data.cast::<String>() = 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",
modifiedcrates/nix-eval/src/logging.rsdiffbeforeafterboth
--- 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());
modifiedcrates/opentelemetry-exporter-env/src/lib.rsdiffbeforeafterboth
--- 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<ExporterKind>,
 	/// 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<ExporterKind>,
 	/// 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<ExporterKind>,
 }
 
modifiedcrates/opentelemetry-exporter-env/src/otlp.rsdiffbeforeafterboth
--- 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(),
 		}
 	}};
 }
modifiedlib/default.nixdiffbeforeafterboth
after · lib/default.nix
1# Shared functions for fleet configuration, available as `fleet` module argument2{ lib }:3let4  inherit (lib.trivial) isFunction functionArgs;5  inherit (lib.options) mkOption mergeOneOption;6  inherit (lib.modules) mkOverride;7  inherit (lib.types)8    listOf9    submodule10    attrsOf11    mkOptionType12    ;13  inherit (lib.strings) optionalString hasPrefix removePrefix;14in15rec {16  types = {17    overlay = mkOptionType {18      name = "nixpkgs-overlay";19      description = "nixpkgs overlay";20      check = {21        __functor = _self: isFunction;22        isV2MergeCoherent = true;23      };24      merge = mergeOneOption;25    };26    listOfOverlay = listOf types.overlay;2728    mkHostsType = module: attrsOf (submodule module);29    mkDataType = module: submodule module;30  };3132  options = {33    mkHostsOption =34      module:35      mkOption {36        type = types.mkHostsType module;37      };38    mkDataOption =39      module:40      mkOption {41        type = types.mkDataType module;42      };43  };4445  inherit (options) mkHostsOption;4647  modules = {48    /**49      Use in places, where fleet might know better than nixpkgs defaults to50    */51    mkFleetDefault = mkOverride 999;52    /**53      Some generators use mkDefault, but optionDefault is set by nixpkgs.54    */55    mkFleetGeneratorDefault = mkOverride 1001;56  };5758  inherit (modules) mkFleetDefault mkFleetGeneratorDefault;5960  secrets = {6162    /**63      Generate a random secret password, 32 ascii characters by default6465      Options:66        size: generated password length in ascii characters (bytes).67        noSymbols: by default, character set includes various special characters ($ , ! + * : ~), and might68                   not be accepted in some contexts, this option switches charset to just [A-Za-z0-9].6970      Output:71        Resulting secret has only part: secret, which contains encrypted password.72    */73    mkPassword =74      {75        size ? 32,76      }:77      (78        {79          coreutils,80          mkSecretGenerator,81        }:82        mkSecretGenerator {83          script = ''84            mkdir $out85            gh generate password -o $out/secret --size ${toString size}86          '';87          parts.secret.encrypted = true;88        }89      );9091    /**92      Generate a random password suitable for PostgreSQL role authentication.9394      Options:95        size: generated password length in ascii characters (bytes).96        iterations: PBKDF2 iteration count (PG default: 4096).97        noSymbols: by default, character set includes various special characters ($ , ! + * : ~), and might98                   not be accepted in some contexts, this option switches charset to just [A-Za-z0-9].99100      Output:101        secret: encrypted password.102        hash: SCRAM-SHA-256 hash of the password to be used by postgres.103    */104    # It is not possible to extract it into a scram-sha-256 generic generator because there is no estabilished format105    # for it, and mongodb for example encodes it differently.106    mkPostgresPassword =107      {108        size ? 32,109        iterations ? 4096,110        noSymbols ? false,111      }:112      (113        { mkSecretGenerator }:114        mkSecretGenerator {115          script = ''116            mkdir $out117            gh generate postgres-password \118              -s $out/secret \119              -H $out/hash \120              --size ${toString size} \121              --iterations ${toString iterations} \122              ${optionalString noSymbols "--no-symbols"}123          '';124          parts.secret.encrypted = true;125          parts.hash.encrypted = false;126        }127      );128129    /**130      Generate a random ed25519 keypair131132      Options:133        noEmbedPublic: By default, secret key also embeds public key in itself ("extended" format, 64 bytes)134                       When noEmbedPublis is enabled - only the private scalar is included.135        encoding: Encoring of public and secret parts, can be "raw" (default), "base64" or "hex".136137      Output:138        Resulting secret has two parts: public and secret, where the secret part is encrypted.139140      This secret format is used by e.g Garage S3 server141    */142    mkEd25519 =143      {144        noEmbedPublic ? false,145        encoding ? null,146      }:147      (148        { mkSecretGenerator }:149        mkSecretGenerator {150          script = ''151            mkdir $out152            gh generate ed25519 -p $out/public -s $out/secret \153              ${optionalString noEmbedPublic "--no-embed-public"} \154              ${optionalString (encoding != null) "--encoding=${encoding}"}155          '';156          parts.secret.encrypted = true;157          parts.public.encrypted = false;158        }159      );160161    /**162      Generate a random x25519 keypair163164      Options:165        encoding: Encoring of public and secret parts, can be "raw" (default), "base64" or "hex".166167      Output:168        Resulting secret has two parts: public and secret, where the secret part is encrypted.169170      This secret format is used by e.g Wireguard VPN for peers (base64-encoded)171    */172    mkX25519 =173      {174        encoding ? null,175      }:176      (177        { mkSecretGenerator }:178        mkSecretGenerator {179          script = ''180            mkdir $out181            gh generate x25519 -p $out/public -s $out/secret \182              ${optionalString (encoding != null) "--encoding=${encoding}"}183          '';184185          parts.secret.encrypted = true;186          parts.public.encrypted = false;187        }188      );189190    mkAskPass =191      {192        prompt ? "Secret value",193        part ? "secret",194      }:195      (196        {197          kdePackages,198          mkImpureSecretGenerator,199        }:200        mkImpureSecretGenerator {201          script = ''202            mkdir $out203            ${kdePackages.kdialog}/bin/kdialog --inputbox "${prompt}" | gh private -o $out/${part}204          '';205206          parts.${part}.encrypted = true;207        }208      );209210    mkAskFile =211      {212        header ? "",213        part ? "secret",214      }:215      (216        {217          kdePackages,218          coreutils,219          mkImpureSecretGenerator,220        }:221        mkImpureSecretGenerator {222          script = ''223            mkdir $out224            tmpfile=$(${coreutils}/bin/mktemp)225            trap "${coreutils}/bin/rm -f $tmpfile" EXIT226            cat > "$tmpfile" <<'HEADER'227            ${header}228            HEADER229            ${kdePackages.kate}/bin/kate --startanon --new --block "$tmpfile"230            gh private -o $out/${part} < "$tmpfile"231          '';232233          parts.${part}.encrypted = true;234        }235      );236237    mkAskEnv =238      {239        header ? "",240        variables ? [ ],241        part ? "secret",242      }:243      mkAskFile {244        inherit part;245        header = builtins.concatStringsSep "\n" (246          (map (l: "# ${l}") (lib.splitString "\n" header)) ++ (map (v: "${v}=") variables)247        );248      };249250    /**251      Generate a random RSA keypair252253      Options:254        size: RSA key size, 4096 by default255256      Output:257        Resulting secret has two parts: public and secret, where the secret part is encrypted.258        Both parts are PEM encoded.259    */260    mkRsa =261      {262        size ? 4096,263      }:264      (265        {266          openssl,267          mkSecretGenerator,268        }:269        mkSecretGenerator {270          script = ''271            mkdir $out272273            ${openssl}/bin/openssl genrsa -out rsa_private.key ${toString size}274            ${openssl}/bin/openssl rsa -in rsa_private.key -pubout -out rsa_public.key275276            cat rsa_private.key | gh private -o $out/secret277            cat rsa_public.key | gh public -o $out/public278          '';279280          parts.secret.encrypted = true;281          parts.public.encrypted = false;282        }283      );284285    /**286      Generate a random byte sequence287288      Options:289        size: generated password length in bytes, 32 by default.290        encoding: how the generated bytes should be encoded, "raw" (default), "hex" or "base64"291        noNuls: prevent output byte sequence from containing internal \0, useful for some C applications292                that can't handle their strings properly.293294      Output:295        Resulting secret has only part: secret, which contains encrypted bytes.296297      Might be used for e.g. Wireguard VPN PSK keys (base64-encoded)298    */299    mkBytes =300      {301        count ? 32,302        encoding,303        noNuls ? false,304      }:305      (306        { mkSecretGenerator }:307        mkSecretGenerator {308          script = ''309            mkdir $out310            gh generate bytes --count=${toString count} --encoding=${encoding} -o $out/secret \311              ${optionalString noNuls "--no-nuls"}312          '';313          parts.secret.encrypted = true;314        }315      );316    /**317      Shorthand for `mkBytes`, which defaults to "hex" encoding318    */319    mkHexBytes =320      {321        count ? 32,322      }:323      mkBytes {324        inherit count;325        encoding = "hex";326      };327    /**328      Shorthand for `mkBytes`, which defaults to "base64" encoding329    */330    mkBase64Bytes =331      {332        count ? 32,333      }:334      mkBytes {335        inherit count;336        encoding = "base64";337      };338339    # Wireguard340    # mkWireguard = {}: mkX25519 {encoding = "base64";};341    # mkWireguardPsk = {}: mkBase64Bytes {count = 32;};342  };343344  inherit (secrets)345    mkPassword346    mkPostgresPassword347    mkEd25519348    mkX25519349    mkRsa350    mkBytes351    mkHexBytes352    mkBase64Bytes353    mkAskPass354    mkAskFile355    mkAskEnv356    ;357358  strings =359    let360      plaintextPrefix = "<PLAINTEXT>";361      plaintextNewlinePrefix = "<PLAINTEXT-NL>";362    in363    {364      /**365        Decode public secret part into string366      */367      decodeRawSecret =368        raw:369        if hasPrefix plaintextPrefix raw then370          removePrefix plaintextPrefix raw371        else if hasPrefix plaintextNewlinePrefix raw then372          removePrefix plaintextNewlinePrefix raw373        else374          throw "decodeRawSecret only works with plaintext-encoded secret public parts, got ${raw}";375    };376377  inherit (strings) decodeRawSecret;378}