git.delta.rocks / jrsonnet / refs/commits / 76e09f985e80

difftreelog

source

cmds/generator-helper/src/main.rs9.1 KiBsourcehistory
1use std::{2	env,3	fs::{File, OpenOptions},4	io::{self, copy, stdin, stdout, Read, Write},5	str::FromStr,6};78use age::{9	ssh::{ParseRecipientKeyError, Recipient as SshRecipient},10	Encryptor, Recipient,11};12use anyhow::{anyhow, bail, ensure, Context, Result};13use clap::{Parser, ValueEnum};14use fleet_shared::SecretData;15use rand::{16	distributions::{Alphanumeric, DistString, Distribution, Uniform},17	thread_rng, RngCore,18};1920fn write_output_file(out: &str) -> Result<File> {21	let file = OpenOptions::new()22		.create_new(true)23		.write(true)24		.open(out)25		.with_context(|| format!("failed to open output {out:?}"))?;26	Ok(file)27}28fn write_public(out: &str, mut input: impl Read, encoding: OutputEncoding) -> Result<()> {29	let mut output = write_output_file(out)?;3031	let mut data = Vec::new();32	copy(&mut input, &mut wrap_encoder(&mut data, encoding))?;3334	output.write_all(35		SecretData {36			data,37			encrypted: false,38		}39		.to_string()40		.as_bytes(),41	)?;42	Ok(())43}44fn write_private(45	identities: &Identities,46	out: &str,47	mut input: impl Read,48	encoding: OutputEncoding,49) -> Result<()> {50	let mut output = write_output_file(out)?;51	let encryptor = make_encryptor(identities)?;5253	let mut data = Vec::new();54	{55		let mut encrypted_writer = encryptor.wrap_output(&mut data)?;56		copy(57			&mut input,58			&mut wrap_encoder(&mut encrypted_writer, encoding),59		)?;60		encrypted_writer.finish()?;61	};6263	output.write_all(64		SecretData {65			data,66			encrypted: true,67		}68		.to_string()69		.as_bytes(),70	)?;71	Ok(())72}7374type Identities = Vec<SshRecipient>;75fn load_identities() -> Result<Identities> {76	let list = env::var("GENERATOR_HELPER_IDENTITIES");77	let list = match list {78		Ok(v) => v,79		Err(env::VarError::NotPresent) => {80			bail!("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");81		}82		Err(e) => bail!("somehow, identities list is not utf-8: {e}"),83	};84	let list = list.trim();85	ensure!(!list.is_empty(), "no identities passed, can't encrypt data");86	list.lines()87		.map(age::ssh::Recipient::from_str)88		.collect::<Result<Identities, ParseRecipientKeyError>>()89		.map_err(|e| anyhow!("parse recipients: {e:?}"))90}91fn make_encryptor(r: &Identities) -> Result<Encryptor> {92	Ok(Encryptor::with_recipients(93		r.iter()94			.map(|v| {95				let coerced: Box<dyn Recipient + Send> = Box::new(v.clone());96				coerced97			})98			.collect(),99	)100	.expect("list is not empty"))101}102fn wrap_encoder<'t>(w: impl Write + 't, encoding: OutputEncoding) -> impl Write + 't {103	fn coerce<'t>(w: impl Write + 't) -> Box<dyn Write + 't> {104		Box::new(w)105	}106	match encoding {107		OutputEncoding::Raw => coerce(w),108		OutputEncoding::Base64 => {109			use base64::{engine::general_purpose::STANDARD, write::EncoderWriter};110111			let writer = EncoderWriter::new(w, &STANDARD);112			coerce(writer)113		}114		OutputEncoding::Hex => {115			struct HexWriter<W>(W);116			impl<W> Write for HexWriter<W>117			where118				W: Write,119			{120				fn write(&mut self, buf: &[u8]) -> io::Result<usize> {121					let encoded = hex::encode(buf);122					self.0.write_all(encoded.as_bytes())?;123					Ok(buf.len())124				}125126				fn flush(&mut self) -> io::Result<()> {127					self.0.flush()128				}129			}130			coerce(HexWriter(w))131		}132	}133}134135#[derive(Clone, Copy, ValueEnum, Default)]136enum OutputEncoding {137	/// Do not encode data, store as is.138	#[default]139	Raw,140	/// Encode as base64 (with padding).141	Base64,142	/// Encode as hex (without leading 0x)143	Hex,144}145146#[derive(Parser)]147enum Generate {148	/// Generate public, private keys without wrapping, in standard ed25519 schema149	/// (64 bytes private (due to merge with private), 32 bytes public)150	Ed25519 {151		#[arg(long, short = 'p')]152		public: String,153		#[arg(long, short = 's')]154		private: String,155		/// Private key should be just the private key (32 bytes), not standard private+public.156		#[arg(long)]157		no_embed_public: bool,158		#[arg(long, short = 'e', value_enum, default_value_t)]159		encoding: OutputEncoding,160	},161	/// Generate public, private keys without wrapping, in standard x25519 schema162	/// (32 bytes private, 32 bytes public)163	X25519 {164		#[arg(long, short = 'p')]165		public: String,166		#[arg(long, short = 's')]167		private: String,168		#[arg(long, short = 'e', value_enum, default_value_t)]169		encoding: OutputEncoding,170	},171	Password {172		#[arg(long, short = 'o')]173		output: String,174		#[arg(long)]175		size: usize,176		#[arg(long, short = 'n')]177		no_symbols: bool,178		#[arg(long, short = 'e', value_enum, default_value_t)]179		encoding: OutputEncoding,180	},181	Bytes {182		#[arg(long, short = 'o')]183		output: String,184		#[arg(long, short = 'c')]185		count: usize,186		/// Ensure there is no NULs in bytestring.187		#[arg(long)]188		no_nuls: bool,189		#[arg(long, short = 'e', value_enum, default_value_t)]190		encoding: OutputEncoding,191	},192}193194#[derive(Parser)]195enum Opts {196	/// Encode public part from stdin.197	Public {198		#[arg(long, short = 'o')]199		output: String,200		#[arg(long, short = 'e', value_enum, default_value_t)]201		encoding: OutputEncoding,202	},203	/// Encrypt private part from stdin.204	Private {205		#[arg(long, short = 'o')]206		output: String,207		#[arg(long, short = 'e', value_enum, default_value_t)]208		encoding: OutputEncoding,209	},210	/// Sometimes you also need to reencode secret, this command allows you to get raw data from211	/// secret encoded using `gh public`... I would like if I knew a better design for some sort of212	/// such thing. Ideally there should be no need to decode secrets back, but garage wants both213	/// raw pubkey and raw secret key, yet also requires node id which is hex-reencoded public key.214	Decode {215		#[arg(long, short = 'i')]216		input: String,217	},218	/// Generate keys in well-known schemas.219	///220	/// Note that this command is only intended to be used in fleet secret generator,221	/// otherwise you should ensure noone is able to read generated files, they don't have any mode set by default.222	///223	/// Fleet also doesn't zeroize memory/assumes good OsRng/makes other assumptions, which makes it only suitable to224	/// be used in nix sandbox.225	#[command(subcommand)]226	Generate(Generate),227}228229fn main() -> Result<()> {230	let opts = Opts::parse();231	// Assumed to be secure, seeded from secure OsRng+reseeded.232	let mut rng = thread_rng();233234	match opts {235		Opts::Public { output, encoding } => {236			write_public(&output, stdin(), encoding)?;237		}238		Opts::Private { output, encoding } => {239			let recipients = load_identities()?;240			write_private(&recipients, &output, stdin(), encoding)?;241		}242		Opts::Generate(gen) => {243			match gen {244				Generate::Ed25519 {245					public,246					private,247					no_embed_public,248					encoding,249				} => {250					use ed25519_dalek::SigningKey;251252					let recipients = load_identities()?;253					let key = SigningKey::generate(&mut rng).to_keypair_bytes();254					write_public(&public, &key[32..], encoding)?;255					write_private(256						&recipients,257						&private,258						&key[..{259							if no_embed_public {260								32261							} else {262								64263							}264						}],265						encoding,266					)?;267				}268				Generate::X25519 {269					public,270					private,271					encoding,272				} => {273					use x25519_dalek::{PublicKey, StaticSecret};274275					let recipients = load_identities()?;276					let key = StaticSecret::random_from_rng(rng);277					let public_key: PublicKey = (&key).into();278					write_public(&public, public_key.as_bytes().as_slice(), encoding)?;279					write_private(&recipients, &private, key.as_bytes().as_slice(), encoding)?;280				}281				Generate::Password {282					size,283					no_symbols,284					output,285					encoding,286				} => {287					ensure!(288						size >= 6,289						"misconfiguration? password is shorter than 6 chars"290					);291					let recipients = load_identities()?;292					let out = if no_symbols {293						Alphanumeric.sample_string(&mut rng, size)294					} else {295						// Alphabet of Alphanumberic + symbols296						const GEN_ASCII_SYMBOLS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~";297						let uniform = Uniform::new(0, GEN_ASCII_SYMBOLS.len());298						(0..size)299							.map(|_| uniform.sample(&mut rng))300							.map(|i| GEN_ASCII_SYMBOLS[i] as char)301							.collect::<String>()302					};303					write_private(&recipients, &output, out.as_bytes(), encoding)?;304				}305				Generate::Bytes {306					output,307					count,308					no_nuls,309					encoding,310				} => {311					ensure!(312						count >= 6,313						"misconfiguration? bytestring is shorter than 6 chars"314					);315					let recipients = load_identities()?;316					let mut bytes = vec![0u8; count];317					if no_nuls {318						let rand = Uniform::new_inclusive(0x1u8, 0xffu8).sample_iter(&mut rng);319						for (byte, rand) in bytes.iter_mut().zip(rand) {320							*byte = rand;321						}322					} else {323						rng.fill_bytes(&mut bytes);324					};325					write_private(&recipients, &output, bytes.as_slice(), encoding)?;326				}327			}328		}329		Opts::Decode { input } => {330			let mut data = Vec::new();331			File::open(input)?.read_to_end(&mut data)?;332			let data = String::from_utf8(data).context(333				"encoded data is always utf-8, you are trying to use decode the wrong way.",334			)?;335			let data =336				SecretData::from_str(&data).map_err(|e| anyhow!("failed to decode data: {e}"))?;337			ensure!(338				!data.encrypted,339				"you can not decrypt secret data, only decode public."340			);341			stdout().write_all(&data.data)?;342		}343	}344	Ok(())345}