git.delta.rocks / fleet / refs/commits / 0ea4b47ffa04

difftreelog

source

cmds/generator-helper/src/main.rs11.1 KiBsourcehistory
1use std::{2	env,3	fs::{File, OpenOptions},4	io::{self, Read, Write, copy, stdin, stdout},5	str::FromStr,6};78use age::{9	Encryptor, Recipient,10	ssh::{ParseRecipientKeyError, Recipient as SshRecipient},11};12use anyhow::{Context, Result, anyhow, bail, ensure};13use base64::{Engine as _, engine::general_purpose::STANDARD, write::EncoderWriter};14use clap::{Parser, ValueEnum};15use ed25519_dalek::SecretKey;16use fleet_shared::SecretData;17use hmac::Mac as _;18use rand::{19	Rng as _,20	distr::{Alphanumeric, Distribution, SampleString, Uniform},21	rng,22};23use sha2::Digest as _;2425fn gen_password(rng: &mut impl rand::Rng, size: usize, no_symbols: bool) -> String {26	if no_symbols {27		Alphanumeric.sample_string(rng, size)28	} else {29		const GEN_ASCII_SYMBOLS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~";30		let uniform = Uniform::new(0, GEN_ASCII_SYMBOLS.len()).expect("range is valid");31		(0..size)32			.map(|_| uniform.sample(rng))33			.map(|i| GEN_ASCII_SYMBOLS[i] as char)34			.collect::<String>()35	}36}3738fn write_output_file(out: &str) -> Result<File> {39	let file = OpenOptions::new()40		.create_new(true)41		.write(true)42		.open(out)43		.with_context(|| format!("failed to open output {out:?}"))?;44	Ok(file)45}46fn write_public(out: &str, mut input: impl Read, encoding: OutputEncoding) -> Result<()> {47	let mut output = write_output_file(out)?;4849	let mut data = Vec::new();50	copy(&mut input, &mut wrap_encoder(&mut data, encoding))?;5152	output.write_all(53		SecretData {54			data,55			encrypted: false,56		}57		.to_string()58		.as_bytes(),59	)?;60	Ok(())61}62fn write_private(63	identities: &Identities,64	out: &str,65	mut input: impl Read,66	encoding: OutputEncoding,67) -> Result<()> {68	let mut output = write_output_file(out)?;69	let encryptor = make_encryptor(identities)?;7071	let mut data = Vec::new();72	{73		let mut encrypted_writer = encryptor.wrap_output(&mut data)?;74		copy(75			&mut input,76			&mut wrap_encoder(&mut encrypted_writer, encoding),77		)?;78		encrypted_writer.finish()?;79	};8081	output.write_all(82		SecretData {83			data,84			encrypted: true,85		}86		.to_string()87		.as_bytes(),88	)?;89	Ok(())90}9192type Identities = Vec<SshRecipient>;93fn load_identities() -> Result<Identities> {94	let list = env::var("GENERATOR_HELPER_IDENTITIES");95	let list = match list {96		Ok(v) => v,97		Err(env::VarError::NotPresent) => {98			bail!(99				"gh is only intended to be used from secret generator scripts, but if you really want to use it somewhere else - set GENERATOR_HELPER_IDENTITIES to list of newline-delimited ssh identities"100			);101		}102		Err(e) => bail!("somehow, identities list is not utf-8: {e}"),103	};104	let list = list.trim();105	ensure!(!list.is_empty(), "no identities passed, can't encrypt data");106	list.lines()107		.map(age::ssh::Recipient::from_str)108		.collect::<Result<Identities, ParseRecipientKeyError>>()109		.map_err(|e| anyhow!("parse recipients: {e:?}"))110}111fn make_encryptor(r: &Identities) -> Result<Encryptor> {112	Ok(113		Encryptor::with_recipients(r.iter().map(|v| v as &dyn Recipient))114			.expect("list is not empty"),115	)116}117fn wrap_encoder<'t>(w: impl Write + 't, encoding: OutputEncoding) -> impl Write + 't {118	fn coerce<'t>(w: impl Write + 't) -> Box<dyn Write + 't> {119		Box::new(w)120	}121	match encoding {122		OutputEncoding::Raw => coerce(w),123		OutputEncoding::Base64 => {124			let writer = EncoderWriter::new(w, &STANDARD);125			coerce(writer)126		}127		OutputEncoding::Hex => {128			struct HexWriter<W>(W);129			impl<W> Write for HexWriter<W>130			where131				W: Write,132			{133				fn write(&mut self, buf: &[u8]) -> io::Result<usize> {134					let encoded = hex::encode(buf);135					self.0.write_all(encoded.as_bytes())?;136					Ok(buf.len())137				}138139				fn flush(&mut self) -> io::Result<()> {140					self.0.flush()141				}142			}143			coerce(HexWriter(w))144		}145	}146}147148#[derive(Clone, Copy, ValueEnum, Default)]149enum OutputEncoding {150	/// Do not encode data, store as is.151	#[default]152	Raw,153	/// Encode as base64 (with padding).154	Base64,155	/// Encode as hex (without leading 0x)156	Hex,157}158159#[derive(Parser)]160enum Generate {161	/// Generate public, private keys without wrapping, in standard ed25519 schema162	/// (64 bytes private (due to merge with private), 32 bytes public)163	Ed25519 {164		#[arg(long, short = 'p')]165		public: String,166		#[arg(long, short = 's')]167		private: String,168		/// Private key should be just the private key (32 bytes), not standard private+public.169		#[arg(long)]170		no_embed_public: bool,171		#[arg(long, short = 'e', value_enum, default_value_t)]172		encoding: OutputEncoding,173	},174	/// Generate public, private keys without wrapping, in standard x25519 schema175	/// (32 bytes private, 32 bytes public)176	X25519 {177		#[arg(long, short = 'p')]178		public: String,179		#[arg(long, short = 's')]180		private: String,181		#[arg(long, short = 'e', value_enum, default_value_t)]182		encoding: OutputEncoding,183	},184	Password {185		#[arg(long, short = 'o')]186		output: String,187		#[arg(long)]188		size: usize,189		#[arg(long, short = 'n')]190		no_symbols: bool,191		#[arg(long, short = 'e', value_enum, default_value_t)]192		encoding: OutputEncoding,193	},194	PostgresPassword {195		#[arg(long, short = 's')]196		secret: String,197		#[arg(long, short = 'H')]198		hash: String,199		#[arg(long, default_value_t = 24)]200		size: usize,201		#[arg(long, default_value_t = 4096)]202		iterations: u32,203		#[arg(long, short = 'n')]204		no_symbols: bool,205	},206	Bytes {207		#[arg(long, short = 'o')]208		output: String,209		#[arg(long, short = 'c')]210		count: usize,211		/// Ensure there is no NULs in bytestring.212		#[arg(long)]213		no_nuls: bool,214		#[arg(long, short = 'e', value_enum, default_value_t)]215		encoding: OutputEncoding,216	},217}218219#[derive(Parser)]220enum Opts {221	/// Encode public part from stdin.222	Public {223		#[arg(long, short = 'o')]224		output: String,225		#[arg(long, short = 'e', value_enum, default_value_t)]226		encoding: OutputEncoding,227	},228	/// Encrypt private part from stdin.229	Private {230		#[arg(long, short = 'o')]231		output: String,232		#[arg(long, short = 'e', value_enum, default_value_t)]233		encoding: OutputEncoding,234	},235	/// Sometimes you also need to reencode secret, this command allows you to get raw data from236	/// secret encoded using `gh public`... I would like if I knew a better design for some sort of237	/// such thing. Ideally there should be no need to decode secrets back, but garage wants both238	/// raw pubkey and raw secret key, yet also requires node id which is hex-reencoded public key.239	Decode {240		#[arg(long, short = 'i')]241		input: String,242	},243	/// Generate keys in well-known schemas.244	///245	/// Note that this command is only intended to be used in fleet secret generator,246	/// otherwise you should ensure noone is able to read generated files, they don't have any mode set by default.247	///248	/// Fleet also doesn't zeroize memory/assumes good OsRng/makes other assumptions, which makes it only suitable to249	/// be used in nix sandbox.250	#[command(subcommand)]251	Generate(Generate),252}253254fn main() -> Result<()> {255	let opts = Opts::parse();256	// Assumed to be secure, seeded from secure OsRng+reseeded.257	let mut rng = rng();258259	match opts {260		Opts::Public { output, encoding } => {261			write_public(&output, stdin(), encoding)?;262		}263		Opts::Private { output, encoding } => {264			let recipients = load_identities()?;265			write_private(&recipients, &output, stdin(), encoding)?;266		}267		Opts::Generate(generate) => {268			match generate {269				Generate::Ed25519 {270					public,271					private,272					no_embed_public,273					encoding,274				} => {275					use ed25519_dalek::SigningKey;276277					let recipients = load_identities()?;278					let mut secret = SecretKey::default();279					rng.fill_bytes(&mut secret);280					// TODO: Use SigningKey::generate after https://github.com/dalek-cryptography/curve25519-dalek/pull/762281					let key = SigningKey::from_bytes(&secret).to_keypair_bytes();282					write_public(&public, &key[32..], encoding)?;283					write_private(284						&recipients,285						&private,286						&key[..{ if no_embed_public { 32 } else { 64 } }],287						encoding,288					)?;289				}290				Generate::X25519 {291					public,292					private,293					encoding,294				} => {295					use x25519_dalek::{PublicKey, StaticSecret};296297					let recipients = load_identities()?;298					// TODO: Use random_from_rng after https://github.com/dalek-cryptography/curve25519-dalek/pull/762299					let key = StaticSecret::random();300					let public_key: PublicKey = (&key).into();301					write_public(&public, public_key.as_bytes().as_slice(), encoding)?;302					write_private(&recipients, &private, key.as_bytes().as_slice(), encoding)?;303				}304				Generate::Password {305					size,306					no_symbols,307					output,308					encoding,309				} => {310					ensure!(311						size >= 6,312						"misconfiguration? password is shorter than 6 chars"313					);314					let recipients = load_identities()?;315					let out = gen_password(&mut rng, size, no_symbols);316					write_private(&recipients, &output, out.as_bytes(), encoding)?;317				}318				Generate::PostgresPassword {319					secret,320					hash,321					size,322					iterations,323					no_symbols,324				} => {325					ensure!(326						size >= 6,327						"misconfiguration? password is shorter than 6 chars"328					);329					let recipients = load_identities()?;330					let password = gen_password(&mut rng, size, no_symbols);331332					let mut salt = [0u8; 16];333					rng.fill_bytes(&mut salt);334					let salted = pbkdf2::pbkdf2_hmac_array::<sha2::Sha256, 32>(335						password.as_bytes(),336						&salt,337						iterations,338					);339340					type HmacSha256 = hmac::Hmac<sha2::Sha256>;341					let mut mac = <HmacSha256 as hmac::Mac>::new_from_slice(&salted)342						.expect("HMAC accepts any key length");343					mac.update(b"Client Key");344					let client_key = mac.finalize().into_bytes();345346					let mut hasher = sha2::Sha256::new();347					hasher.update(client_key);348					let stored_key = hasher.finalize();349350					let mut mac = <HmacSha256 as hmac::Mac>::new_from_slice(&salted)351						.expect("HMAC accepts any key length");352					mac.update(b"Server Key");353					let server_key = mac.finalize().into_bytes();354355					let hash_str = format!(356						"SCRAM-SHA-256${}:{}${}:{}",357						iterations,358						STANDARD.encode(salt),359						STANDARD.encode(stored_key),360						STANDARD.encode(server_key),361					);362363					write_private(364						&recipients,365						&secret,366						password.as_bytes(),367						OutputEncoding::Raw,368					)?;369					write_public(&hash, hash_str.as_bytes(), OutputEncoding::Raw)?;370				}371				Generate::Bytes {372					output,373					count,374					no_nuls,375					encoding,376				} => {377					ensure!(378						count >= 6,379						"misconfiguration? bytestring is shorter than 6 chars"380					);381					let recipients = load_identities()?;382					let mut bytes = vec![0u8; count];383					if no_nuls {384						let rand = Uniform::new_inclusive(0x1u8, 0xffu8)385							.expect("range is valid")386							.sample_iter(&mut rng);387						for (byte, rand) in bytes.iter_mut().zip(rand) {388							*byte = rand;389						}390					} else {391						rng.fill_bytes(&mut bytes);392					};393					write_private(&recipients, &output, bytes.as_slice(), encoding)?;394				}395			}396		}397		Opts::Decode { input } => {398			let mut data = Vec::new();399			File::open(input)?.read_to_end(&mut data)?;400			let data = String::from_utf8(data).context(401				"encoded data is always utf-8, you are trying to use decode the wrong way.",402			)?;403			let data =404				SecretData::from_str(&data).map_err(|e| anyhow!("failed to decode data: {e}"))?;405			ensure!(406				!data.encrypted,407				"you can not decrypt secret data, only decode public."408			);409			stdout().write_all(&data.data)?;410		}411	}412	Ok(())413}