difftreelog
feat hex secret encoding
in: trunk
3 files changed
Cargo.lockdiffbeforeafterboth--- a/Cargo.lock
+++ b/Cargo.lock
@@ -812,6 +812,7 @@
"clap",
"ed25519-dalek",
"fleet-shared",
+ "hex",
"rand",
"x25519-dalek",
]
@@ -1049,6 +1050,12 @@
checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024"
[[package]]
+name = "hex"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+
+[[package]]
name = "hkdf"
version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
cmds/generator-helper/Cargo.tomldiffbeforeafterboth--- a/cmds/generator-helper/Cargo.toml
+++ b/cmds/generator-helper/Cargo.toml
@@ -10,5 +10,6 @@
clap.workspace = true
ed25519-dalek = { version = "2.1", features = ["rand_core"] }
fleet-shared.workspace = true
+hex = "0.4.3"
rand = "0.8.5"
x25519-dalek = "2.0.1"
cmds/generator-helper/src/main.rsdiffbeforeafterboth1use std::{2 env,3 fs::{File, OpenOptions},4 io::{copy, 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,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;110 let writer = base64::write::EncoderWriter::new(w, &STANDARD);111 coerce(writer)112 }113 }114}115116#[derive(Clone, Copy, ValueEnum, Default)]117enum OutputEncoding {118 /// Do not encode data, store as is.119 #[default]120 Raw,121 /// Encode as base64 (with padding).122 Base64,123}124125#[derive(Parser)]126enum Generate {127 /// Generate public, private keys without wrapping, in standard ed25519 schema128 /// (64 bytes private (due to merge with private), 32 bytes public)129 Ed25519 {130 #[arg(long, short = 'p')]131 public: String,132 #[arg(long, short = 's')]133 private: String,134 /// Private key should be just the private key (32 bytes), not standard private+public.135 #[arg(long)]136 no_embed_public: bool,137 #[arg(long, short = 'e', value_enum, default_value_t)]138 encoding: OutputEncoding,139 },140 /// Generate public, private keys without wrapping, in standard x25519 schema141 /// (32 bytes private, 32 bytes public)142 X25519 {143 #[arg(long, short = 'p')]144 public: String,145 #[arg(long, short = 's')]146 private: String,147 #[arg(long, short = 'e', value_enum, default_value_t)]148 encoding: OutputEncoding,149 },150 Password {151 #[arg(long, short = 'o')]152 output: String,153 #[arg(long)]154 size: usize,155 #[arg(long, short = 'n')]156 no_symbols: bool,157 #[arg(long, short = 'e', value_enum, default_value_t)]158 encoding: OutputEncoding,159 },160}161162#[derive(Parser)]163enum Opts {164 /// Encode public part from stdin.165 Public {166 #[arg(long, short = 'o')]167 output: String,168 #[arg(long, short = 'e', value_enum, default_value_t)]169 encoding: OutputEncoding,170 },171 /// Encrypt private part from stdin.172 Private {173 #[arg(long, short = 'o')]174 output: String,175 #[arg(long, short = 'e', value_enum, default_value_t)]176 encoding: OutputEncoding,177 },178 /// Generate keys in well-known schemas.179 ///180 /// Note that this command is only intended to be used in fleet secret generator,181 /// otherwise you should ensure noone is able to read generated files, they don't have any mode set by default.182 #[command(subcommand)]183 Generate(Generate),184}185186fn main() -> Result<()> {187 let opts = Opts::parse();188 // Assumed to be secure, seeded from secure OsRng+reseeded.189 let mut rng = thread_rng();190191 match opts {192 Opts::Public { output, encoding } => {193 write_public(&output, std::io::stdin(), encoding)?;194 }195 Opts::Private { output, encoding } => {196 let recipients = load_identities()?;197 write_private(&recipients, &output, std::io::stdin(), encoding)?;198 }199 Opts::Generate(gen) => {200 match gen {201 Generate::Ed25519 {202 public,203 private,204 no_embed_public,205 encoding,206 } => {207 let recipients = load_identities()?;208 let key = ed25519_dalek::SigningKey::generate(&mut rng).to_keypair_bytes();209 write_public(&public, &key[32..], encoding)?;210 write_private(211 &recipients,212 &private,213 &key[..{214 if no_embed_public {215 32216 } else {217 64218 }219 }],220 encoding,221 )?;222 }223 Generate::X25519 {224 public,225 private,226 encoding,227 } => {228 let recipients = load_identities()?;229 let key = x25519_dalek::StaticSecret::random_from_rng(rng);230 let public_key: x25519_dalek::PublicKey = (&key).into();231 write_public(&public, public_key.as_bytes().as_slice(), encoding)?;232 write_private(&recipients, &private, key.as_bytes().as_slice(), encoding)?;233 }234 Generate::Password {235 size,236 no_symbols,237 output,238 encoding,239 } => {240 ensure!(241 size >= 6,242 "misconfiguration? password is shorter than 6 chars"243 );244 let recipients = load_identities()?;245 let out = if no_symbols {246 Alphanumeric.sample_string(&mut rng, size)247 } else {248 // Alphabet of Alphanumberic + symbols249 const GEN_ASCII_SYMBOLS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~";250 let uniform = Uniform::new(0, GEN_ASCII_SYMBOLS.len());251 (0..size)252 .map(|_| uniform.sample(&mut rng))253 .map(|i| GEN_ASCII_SYMBOLS[i] as char)254 .collect::<String>()255 };256 write_private(&recipients, &output, out.as_bytes(), encoding)?;257 }258 }259 }260 }261 Ok(())262}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,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}182183#[derive(Parser)]184enum Opts {185 /// Encode public part from stdin.186 Public {187 #[arg(long, short = 'o')]188 output: String,189 #[arg(long, short = 'e', value_enum, default_value_t)]190 encoding: OutputEncoding,191 },192 /// Encrypt private part from stdin.193 Private {194 #[arg(long, short = 'o')]195 output: String,196 #[arg(long, short = 'e', value_enum, default_value_t)]197 encoding: OutputEncoding,198 },199 /// Sometimes you also need to reencode secret, this command allows you to get raw data from200 /// secret encoded using `gh public`... I would like if I knew a better design for some sort of201 /// such thing. Ideally there should be no need to decode secrets back, but garage wants both202 /// raw pubkey and raw secret key, yet also requires node id which is hex-reencoded public key.203 Decode {204 #[arg(long, short = 'i')]205 input: String,206 },207 /// Generate keys in well-known schemas.208 ///209 /// Note that this command is only intended to be used in fleet secret generator,210 /// otherwise you should ensure noone is able to read generated files, they don't have any mode set by default.211 #[command(subcommand)]212 Generate(Generate),213}214215fn main() -> Result<()> {216 let opts = Opts::parse();217 // Assumed to be secure, seeded from secure OsRng+reseeded.218 let mut rng = thread_rng();219220 match opts {221 Opts::Public { output, encoding } => {222 write_public(&output, stdin(), encoding)?;223 }224 Opts::Private { output, encoding } => {225 let recipients = load_identities()?;226 write_private(&recipients, &output, stdin(), encoding)?;227 }228 Opts::Generate(gen) => {229 match gen {230 Generate::Ed25519 {231 public,232 private,233 no_embed_public,234 encoding,235 } => {236 use ed25519_dalek::SigningKey;237238 let recipients = load_identities()?;239 let key = SigningKey::generate(&mut rng).to_keypair_bytes();240 write_public(&public, &key[32..], encoding)?;241 write_private(242 &recipients,243 &private,244 &key[..{245 if no_embed_public {246 32247 } else {248 64249 }250 }],251 encoding,252 )?;253 }254 Generate::X25519 {255 public,256 private,257 encoding,258 } => {259 use x25519_dalek::{PublicKey, StaticSecret};260261 let recipients = load_identities()?;262 let key = StaticSecret::random_from_rng(rng);263 let public_key: PublicKey = (&key).into();264 write_public(&public, public_key.as_bytes().as_slice(), encoding)?;265 write_private(&recipients, &private, key.as_bytes().as_slice(), encoding)?;266 }267 Generate::Password {268 size,269 no_symbols,270 output,271 encoding,272 } => {273 ensure!(274 size >= 6,275 "misconfiguration? password is shorter than 6 chars"276 );277 let recipients = load_identities()?;278 let out = if no_symbols {279 Alphanumeric.sample_string(&mut rng, size)280 } else {281 // Alphabet of Alphanumberic + symbols282 const GEN_ASCII_SYMBOLS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~";283 let uniform = Uniform::new(0, GEN_ASCII_SYMBOLS.len());284 (0..size)285 .map(|_| uniform.sample(&mut rng))286 .map(|i| GEN_ASCII_SYMBOLS[i] as char)287 .collect::<String>()288 };289 write_private(&recipients, &output, out.as_bytes(), encoding)?;290 }291 }292 }293 Opts::Decode { input } => {294 let mut data = Vec::new();295 File::open(input)?.read_to_end(&mut data)?;296 let data = String::from_utf8(data).context(297 "encoded data is always utf-8, you are trying to use decode the wrong way.",298 )?;299 let data =300 SecretData::from_str(&data).map_err(|e| anyhow!("failed to decode data: {e}"))?;301 ensure!(302 !data.encrypted,303 "you can not decrypt secret data, only decode public."304 );305 stdout().write_all(&data.data)?;306 }307 }308 Ok(())309}