diff --git a/Cargo.lock b/Cargo.lock index 5e85717..1cdd132 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -161,9 +161,9 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.16.3" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" dependencies = [ "aws-lc-fips-sys", "aws-lc-sys", @@ -173,9 +173,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.40.0" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" dependencies = [ "bindgen", "cc", @@ -691,9 +691,9 @@ dependencies = [ [[package]] name = "ed25519-dalek" -version = "3.0.0-pre.6" +version = "3.0.0-pre.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053618a4c3d3bc24f188aa660ae75a46eeab74ef07fb415c61431e5e7cd4749b" +checksum = "20449acd54b660981ae5caa2bcb56d1fe7f25f2e37a38ec507400fab034d4bb6" dependencies = [ "curve25519-dalek", "ed25519", @@ -1579,6 +1579,7 @@ dependencies = [ "base64", "blake3", "confique", + "itertools 0.14.0", "jiff", "k8s-openapi", "kube", @@ -1945,18 +1946,18 @@ dependencies = [ [[package]] name = "pin-project" -version = "1.1.12" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbf0d9e68100b3a7989b4901972f265cd542e560a3a8a724e1e20322f4d06ce9" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.12" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a990e22f43e84855daf260dded30524ef4a9021cc7541c26540500a50b624389" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", @@ -2672,9 +2673,9 @@ dependencies = [ [[package]] name = "ssh-cipher" -version = "0.3.0-rc.8" +version = "0.3.0-rc.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20540e2cbcf285a8e0172717b3ae77ccc2bbf63f3967263ea71e8048173b09ff" +checksum = "10db6f219196a8528f9ec904d9d45cdad692d65b0e57e72be4dedd1c5fddce36" dependencies = [ "aes", "aes-gcm", @@ -2688,23 +2689,24 @@ dependencies = [ [[package]] name = "ssh-encoding" -version = "0.3.0-rc.8" +version = "0.3.0-rc.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af0ddb05d9c6034911bbdc541170b3068a2c019c7a10824e92921151563fb5af" +checksum = "7abf34aa716da5d5b4c496936d042ea282ab392092cd68a72ef6a8863ff8c96a" dependencies = [ "base64ct", + "ctutils", "digest 0.11.3", "pem-rfc7468", - "subtle", "zeroize", ] [[package]] name = "ssh-key" -version = "0.7.0-rc.9" +version = "0.7.0-rc.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae7221717f89c8629a83ba265a004cb864df267485656f790444fff1b69fa36" +checksum = "45735ce3dea95690e4a9e414c4cfde7f79835063c3dcd35881df85a84118e74b" dependencies = [ + "ctutils", "ed25519-dalek", "rand_core 0.10.1", "sec1", @@ -2712,7 +2714,6 @@ dependencies = [ "signature", "ssh-cipher", "ssh-encoding", - "subtle", "zeroize", ] @@ -3585,9 +3586,9 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] diff --git a/Cargo.toml b/Cargo.toml index a4a25f3..2898c1a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,7 @@ jiff.version = "^0.2" scopeguard.version = "^1" smallvec.version = "^1" confique.version = "^0.4" +itertools.version = "^0.14" ## Marshal schemars.version = "^1" base64.version = "^0.22" diff --git a/kuyaseg-controller/Cargo.toml b/kuyaseg-controller/Cargo.toml index 511573e..0e6e153 100644 --- a/kuyaseg-controller/Cargo.toml +++ b/kuyaseg-controller/Cargo.toml @@ -33,6 +33,7 @@ jiff.workspace = true scopeguard.workspace = true smallvec.workspace = true confique.workspace = true +itertools.workspace = true ## Marshal schemars.workspace = true base64.workspace = true diff --git a/kuyaseg-controller/src/generator.rs b/kuyaseg-controller/src/generator.rs new file mode 100644 index 0000000..ad6a384 --- /dev/null +++ b/kuyaseg-controller/src/generator.rs @@ -0,0 +1,291 @@ +use itertools::Itertools; +use rand::prelude::{ + CryptoRng, + RngExt, +}; +use rand::{ + TryCryptoRng, + TryRng, +}; +use zeroize::{ + Zeroize, + ZeroizeOnDrop, + Zeroizing, +}; + +#[derive(Debug, thiserror::Error)] +pub enum GeneratorErr { + #[error("Distribution to sample from was empty")] + EmptyDistribution, + #[error("RNG stuck during generation")] + RngStuck { reason: String }, + #[error("Failed to generate SSH pair")] + SshPair(#[from] ssh_key::Error), +} + +pub fn bytes(rng: &mut G, size: usize) -> Vec { + let mut buf = vec![0u8; size]; + rng.fill_bytes(&mut buf); + + buf +} + +pub fn str_bytes(rng: &mut G, length: usize, dictionary: &[char]) -> Result, GeneratorErr> { + let mut buf = String::with_capacity(length); + + let dictionary = Zeroizing::new(dictionary.iter().unique().cloned().collect::>()); + let distribution = rand::distr::slice::Choose::new(&dictionary).map_err(|_| GeneratorErr::EmptyDistribution)?; + + let dict_size = dictionary.len(); + if dict_size > 3 { + let mut last_chars = Zeroizing::new(['\0'; 3]); + for l in 0..length { + // est for repetitions + let max_attempts = (dict_size * 4).max(64); + for _ in 0..max_attempts { + let sample = rng.sample(distribution); + if !last_chars.contains(sample) { + (last_chars[0], last_chars[1], last_chars[2]) = (last_chars[1], last_chars[2], *sample); + buf.push(*sample); + break; + } + } + if buf.len() != l + 1 { + return Err(GeneratorErr::RngStuck { + reason: format!( + "Unable to sample {length} characters from dictionary without 3-n repetitions for {max_attempts} attempts" + ), + }); + } + } + } else { + for _ in 0..length { + buf.push(*rng.sample(distribution)); + } + } + + if buf.len() != length { + Err(GeneratorErr::RngStuck { + reason: format!("Unable to sample {length} characters"), + }) + } else { + Ok(buf.into_bytes()) + } +} + +/// Generate SSH keypair, encoded for OpenSSH +pub fn ssh_pair(rng: &mut G, algorithm: SshAlgorithm) -> Result { + let privkey = ssh_key::PrivateKey::random( + rng, + match algorithm { + SshAlgorithm::Ed25519 => ssh_key::Algorithm::Ed25519, + }, + )?; + let pubkey = privkey.public_key(); + + let privkey_encoded = privkey.to_openssh(ssh_key::LineEnding::LF)?; + let pubkey_encoded = pubkey.to_openssh()?; + + Ok(SshPair { + pubkey: pubkey_encoded.into_bytes(), + privkey: privkey_encoded.as_bytes().to_vec(), + }) +} + +pub enum SshAlgorithm { + Ed25519, +} + +#[derive(Zeroize, ZeroizeOnDrop)] +pub struct SshPair { + pub pubkey: Vec, + pub privkey: Vec, +} + +pub struct AwsRng(aws_lc_rs::rand::SystemRandom); + +impl AwsRng { + pub fn new() -> Self { + Self(aws_lc_rs::rand::SystemRandom::new()) + } +} + +impl Default for AwsRng { + fn default() -> Self { + Self::new() + } +} + +impl TryRng for AwsRng { + type Error = core::convert::Infallible; + + fn try_next_u32(&mut self) -> Result { + let mut buf = [0u8; 4]; + self.try_fill_bytes(&mut buf)?; + Ok(u32::from_le_bytes(buf)) + } + + fn try_next_u64(&mut self) -> Result { + let mut buf = [0u8; 8]; + self.try_fill_bytes(&mut buf)?; + Ok(u64::from_le_bytes(buf)) + } + + fn try_fill_bytes(&mut self, dest: &mut [u8]) -> Result<(), Self::Error> { + aws_lc_rs::rand::fill(dest).expect("aws-lc-rs fill failed"); + Ok(()) + } +} + +impl TryCryptoRng for AwsRng {} + +#[cfg(test)] +mod test { + use rand::prelude::SeedableRng; + use rand_hc::Hc128Rng; + + use super::*; + + fn assert_no_3_peat(bytes: &[u8]) { + let s = core::str::from_utf8(bytes).expect("not valid utf8"); + let chars: Vec = s.chars().collect(); + for win in chars.windows(3) { + assert!(win.iter().all_unique(), "3-peat found: {:?}", win); + } + } + + fn assert_dict_chars(bytes: &[u8], dict: &[char]) { + let s = core::str::from_utf8(bytes).expect("not valid utf8"); + for c in s.chars() { + assert!(dict.contains(&c), "char {c} not in dict"); + } + } + + fn verify_ssh_pair(pair: &SshPair) { + let pubkey = ssh_key::PublicKey::from_openssh(core::str::from_utf8(&pair.pubkey).unwrap()).unwrap(); + let privkey = ssh_key::PrivateKey::from_openssh(core::str::from_utf8(&pair.privkey).unwrap()).unwrap(); + assert_eq!(privkey.public_key(), &pubkey); + } + + fn dict_alnum(size: usize) -> Vec { + "a1b2c3d4e5f6g7h8i9j0klmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + .chars() + .take(size) + .collect() + } + + #[test] + fn random_bytes() { + let mut rng = AwsRng::new(); + + // length + assert_eq!(bytes(&mut rng, 0).len(), 0); + assert_eq!(bytes(&mut rng, 1).len(), 1); + assert_eq!(bytes(&mut rng, 32).len(), 32); + assert_eq!(bytes(&mut rng, 128).len(), 128); + assert_eq!(bytes(&mut rng, 1024).len(), 1024); + + // rand + assert_ne!(bytes(&mut rng, 1024), bytes(&mut rng, 1024)); + } + + #[test] + fn random_str_bytes() { + let mut rng = AwsRng::new(); + let dict = dict_alnum(32); + + let gen0 = str_bytes(&mut rng, 0, &dict).unwrap(); + let gen1 = str_bytes(&mut rng, 1, &dict).unwrap(); + let gen27 = str_bytes(&mut rng, 27, &dict).unwrap(); + let gen256 = str_bytes(&mut rng, 256, &dict).unwrap(); + assert_ne!(str_bytes(&mut rng, 256, &dict).unwrap(), gen256); + + // length + assert_eq!(gen0.len(), 0); + assert_eq!(gen1.len(), 1); + assert_eq!(gen27.len(), 27); + assert_eq!(gen256.len(), 256); + + // dict + assert_dict_chars(&gen0, &dict); + assert_dict_chars(&gen1, &dict); + assert_dict_chars(&gen27, &dict); + assert_dict_chars(&gen256, &dict); + + // reps + assert_no_3_peat(&gen27); + assert_no_3_peat(&gen256); + } + + #[test] + fn random_str_bytes_dict_3() { + let mut rng = AwsRng::new(); + let dict: Vec = dict_alnum(3); + + let gen16 = str_bytes(&mut rng, 16, &dict).unwrap(); + + assert_eq!(gen16.len(), 16); + assert_dict_chars(&gen16, &dict); + } + + #[test] + fn random_ssh_pair_ed25519() { + let mut rng = AwsRng::new(); + let pair = ssh_pair(&mut rng, SshAlgorithm::Ed25519).unwrap(); + verify_ssh_pair(&pair); + } + + // ====== Deterministic (Hc128Rng) ====== + + #[test] + fn deterministic_bytes() { + // we believe that generation is the same as for random, so our concern here is only value stability + + assert_eq!( + bytes(&mut Hc128Rng::seed_from_u64(42), 256), + bytes(&mut Hc128Rng::seed_from_u64(42), 256) + ); + assert_ne!( + bytes(&mut Hc128Rng::seed_from_u64(42), 256), + bytes(&mut Hc128Rng::seed_from_u64(43), 256) + ); + } + + #[test] + fn deterministic_str_bytes() { + // we believe that generation is the same as for random, so our concern here is only value stability + + let dict = dict_alnum(32); + + assert_eq!( + str_bytes(&mut Hc128Rng::seed_from_u64(42), 256, &dict).unwrap(), + str_bytes(&mut Hc128Rng::seed_from_u64(42), 256, &dict).unwrap() + ); + assert_ne!( + str_bytes(&mut Hc128Rng::seed_from_u64(42), 256, &dict).unwrap(), + str_bytes(&mut Hc128Rng::seed_from_u64(43), 256, &dict).unwrap() + ); + } + + #[test] + fn deterministic_ssh_pair_ed25519() { + // we believe that generation is the same as for random, so our concern here is only value stability + + assert_eq!( + ssh_pair(&mut Hc128Rng::seed_from_u64(42), SshAlgorithm::Ed25519) + .unwrap() + .privkey, + ssh_pair(&mut Hc128Rng::seed_from_u64(42), SshAlgorithm::Ed25519) + .unwrap() + .privkey + ); + assert_ne!( + ssh_pair(&mut Hc128Rng::seed_from_u64(42), SshAlgorithm::Ed25519) + .unwrap() + .privkey, + ssh_pair(&mut Hc128Rng::seed_from_u64(43), SshAlgorithm::Ed25519) + .unwrap() + .privkey + ); + } +} diff --git a/kuyaseg-controller/src/lib.rs b/kuyaseg-controller/src/lib.rs index 3d2a6b5..fed3523 100644 --- a/kuyaseg-controller/src/lib.rs +++ b/kuyaseg-controller/src/lib.rs @@ -1,4 +1,5 @@ #![feature(sync_nonpoison, nonpoison_rwlock)] +pub mod generator; pub mod reporting; pub mod utils;