From bfb3b8ec3c7c16ea0e850828f8f5bf7c4ea1d5f4 Mon Sep 17 00:00:00 2001 From: Oshadha Gunawardena Date: Mon, 20 Oct 2025 23:51:52 +0530 Subject: [PATCH 1/2] Add JWT and password generation tools - Add JWT token generation and validation tool - Add password generation tool with customizable options - Update Cargo.toml with new dependencies (jsonwebtoken, rand, argon2) - Update README.md with new tool documentation - Add new tools to main.rs and tools/mod.rs --- Cargo.lock | 136 ++++++++- Cargo.toml | 1 + README.md | 55 +++- src/main.rs | 2 + src/tools/jwt.rs | 614 ++++++++++++++++++++++++++++++++++++++ src/tools/mod.rs | 2 + src/tools/password.rs | 670 ++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 1475 insertions(+), 5 deletions(-) create mode 100644 src/tools/jwt.rs create mode 100644 src/tools/password.rs diff --git a/Cargo.lock b/Cargo.lock index aa91e64..1c7b715 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -802,6 +802,15 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "deranged" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a41953f86f8a05768a6cda24def994fd2f424b04ec5c719cf89989779f199071" +dependencies = [ + "powerfmt", +] + [[package]] name = "digest" version = "0.10.7" @@ -1022,8 +1031,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", ] [[package]] @@ -1436,6 +1447,21 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1683,6 +1709,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + [[package]] name = "num-derive" version = "0.4.2" @@ -1784,6 +1816,16 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1878,6 +1920,12 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -2101,7 +2149,7 @@ dependencies = [ "rand_chacha", "simd_helpers", "system-deps", - "thiserror", + "thiserror 1.0.69", "v_frame", "wasm-bindgen", ] @@ -2203,6 +2251,20 @@ version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rkyv" version = "0.7.45" @@ -2490,6 +2552,18 @@ version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" +[[package]] +name = "simple_asn1" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.17", + "time", +] + [[package]] name = "siphasher" version = "1.0.1" @@ -2659,7 +2733,16 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl 2.0.17", ] [[package]] @@ -2673,6 +2756,17 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "thread_local" version = "1.1.9" @@ -2696,6 +2790,37 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "time" +version = "0.3.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" + +[[package]] +name = "time-macros" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinyvec" version = "1.10.0" @@ -2990,6 +3115,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "urlencoding" version = "2.1.3" @@ -3017,6 +3148,7 @@ dependencies = [ "html-escape", "image", "jiff", + "jsonwebtoken", "md-5", "nom 7.1.3", "nom-language", diff --git a/Cargo.toml b/Cargo.toml index 95ffd4c..6d2b5b1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,6 +49,7 @@ nom-language = "0.1.0" cron = "0.12.1" chrono = "0.4.42" bcrypt = "0.16" +jsonwebtoken = "9.3" # The profile that 'dist' will build with [profile.dist] diff --git a/README.md b/README.md index e8bd679..e060693 100644 --- a/README.md +++ b/README.md @@ -120,9 +120,16 @@ After setting up completions, restart your shell or source your configuration fi │ │ ├── sha256 │ │ ├── sha384 │ │ └── sha512 -│ └── bcrypt - Password hashing and verification -│ ├── hash -│ └── verify +│ ├── bcrypt - Password hashing and verification +│ │ ├── hash +│ │ └── verify +│ ├── jwt - JWT (JSON Web Token) utilities +│ │ ├── encode +│ │ ├── decode +│ │ └── verify +│ └── password (pass) - Secure password generation +│ ├── random characters +│ └── memorable passphrases ├── Data Generation │ ├── uuid - Generate UUIDs │ │ ├── v1 @@ -214,6 +221,48 @@ ut bcrypt verify "wrongpassword" '$2b$12$...' # Output: invalid ``` +#### `jwt` +JWT (JSON Web Token) utilities for encoding, decoding, and verifying tokens. +- Support for HMAC algorithms (HS256, HS384, HS512) +- Encode with custom claims (iss, sub, aud, exp) +- Decode without verification (inspect token) +- Verify with signature validation + +```bash +# Encode a JWT with custom claims +ut jwt encode --payload '{"user":"alice"}' --secret "my-secret" --issuer "my-app" --expires-in 3600 + +# Decode a JWT without verification +ut jwt decode eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... + +# Verify a JWT with signature validation +ut jwt verify TOKEN --secret "my-secret" --issuer "my-app" +``` + +#### `password` (alias: `pass`) +Generate cryptographically secure passwords with various options. +- Random character-based passwords with customizable length and character sets +- Memorable passphrases using common words +- Option to exclude ambiguous characters (0, O, l, I, 1) +- Strength indicator and entropy calculation + +```bash +# Generate a strong random password +ut password --length 20 + +# Generate multiple passwords +ut password --length 16 --count 5 + +# Generate without ambiguous characters +ut password --length 16 --no-ambiguous + +# Generate memorable passphrase +ut password --memorable --words 5 + +# Generate passphrase with custom separator and capitalization +ut password --memorable --words 4 --separator "_" --capitalize +``` + ### Data Generation #### `uuid` diff --git a/src/main.rs b/src/main.rs index aa09a38..4006eaa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -78,7 +78,9 @@ fn main() -> anyhow::Result<()> { (tools::hash::HashTool, "hash",), (tools::http::HttpTool, "http",), (tools::json::JsonTool, "json",), + (tools::jwt::JwtTool, "jwt",), (tools::lorem::LoremTool, "lorem",), + (tools::password::PasswordTool, "password", "pass"), (tools::pp::PrettyPrintTool, "pretty-print", "pp"), (tools::qr::QRTool, "qr",), (tools::random::RandomTool, "random",), diff --git a/src/tools/jwt.rs b/src/tools/jwt.rs new file mode 100644 index 0000000..b255481 --- /dev/null +++ b/src/tools/jwt.rs @@ -0,0 +1,614 @@ +use crate::tool::{Output, Tool}; +use anyhow::{Context, Result, bail}; +use clap::{Command, CommandFactory, Parser, Subcommand, ValueEnum}; +use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode_header}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +#[derive(Parser, Debug)] +#[command(name = "jwt", about = "JWT (JSON Web Token) utilities")] +pub struct JwtTool { + #[command(subcommand)] + command: JwtCommand, +} + +#[derive(Subcommand, Debug)] +enum JwtCommand { + /// Decode a JWT without verification (inspect only) + Decode { + /// JWT token to decode + token: String, + }, + /// Encode and sign a JWT + Encode { + /// JSON payload for the JWT (must be valid JSON) + #[arg(short, long)] + payload: String, + + /// Secret key for signing (for HMAC algorithms) + #[arg(short, long)] + secret: String, + + /// Algorithm to use for signing + #[arg(short, long, value_enum, default_value = "hs256")] + algorithm: JwtAlgorithm, + + /// Issuer claim (iss) + #[arg(long)] + issuer: Option, + + /// Subject claim (sub) + #[arg(long)] + subject: Option, + + /// Audience claim (aud) + #[arg(long)] + audience: Option, + + /// Expiration time in seconds from now (exp) + #[arg(long)] + expires_in: Option, + }, + /// Verify and decode a JWT + Verify { + /// JWT token to verify + token: String, + + /// Secret key for verification (for HMAC algorithms) + #[arg(short, long)] + secret: String, + + /// Algorithm to use for verification + #[arg(short, long, value_enum, default_value = "hs256")] + algorithm: JwtAlgorithm, + + /// Expected issuer (iss) + #[arg(long)] + issuer: Option, + + /// Expected subject (sub) + #[arg(long)] + subject: Option, + + /// Expected audience (aud) + #[arg(long)] + audience: Option, + }, +} + +#[derive(Debug, Clone, Copy, ValueEnum)] +enum JwtAlgorithm { + /// HMAC using SHA-256 + HS256, + /// HMAC using SHA-384 + HS384, + /// HMAC using SHA-512 + HS512, +} + +impl From for Algorithm { + fn from(alg: JwtAlgorithm) -> Self { + match alg { + JwtAlgorithm::HS256 => Algorithm::HS256, + JwtAlgorithm::HS384 => Algorithm::HS384, + JwtAlgorithm::HS512 => Algorithm::HS512, + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +struct Claims { + #[serde(flatten)] + custom: Value, + #[serde(skip_serializing_if = "Option::is_none")] + iss: Option, + #[serde(skip_serializing_if = "Option::is_none")] + sub: Option, + #[serde(skip_serializing_if = "Option::is_none")] + aud: Option, + #[serde(skip_serializing_if = "Option::is_none")] + exp: Option, + #[serde(skip_serializing_if = "Option::is_none")] + iat: Option, +} + +impl Tool for JwtTool { + fn cli() -> Command { + JwtTool::command() + } + + fn execute(&self) -> Result> { + match &self.command { + JwtCommand::Decode { token } => decode_jwt(token), + JwtCommand::Encode { + payload, + secret, + algorithm, + issuer, + subject, + audience, + expires_in, + } => encode_jwt( + payload, + secret, + *algorithm, + issuer.clone(), + subject.clone(), + audience.clone(), + *expires_in, + ), + JwtCommand::Verify { + token, + secret, + algorithm, + issuer, + subject, + audience, + } => verify_jwt( + token, + secret, + *algorithm, + issuer.clone(), + subject.clone(), + audience.clone(), + ), + } + } +} + +fn decode_jwt(token: &str) -> Result> { + // Split the token to check if it's valid format + let parts: Vec<&str> = token.split('.').collect(); + if parts.len() != 3 { + bail!("Invalid JWT format. Expected 3 parts separated by dots"); + } + + // Decode header + let header = decode_header(token).context("Failed to decode JWT header")?; + + // Decode payload without verification using a validation that doesn't validate signature + let mut validation = Validation::new(header.alg); + validation.insecure_disable_signature_validation(); + validation.validate_exp = false; + validation.validate_nbf = false; + validation.validate_aud = false; + validation.required_spec_claims.clear(); // Don't require any standard claims + + // Use an empty key since we're not validating the signature + let token_data = jsonwebtoken::decode::( + token, + &DecodingKey::from_secret(&[]), + &validation, + ).context("Failed to decode JWT payload")?; + + let result = json!({ + "header": { + "alg": format!("{:?}", header.alg), + "typ": header.typ.unwrap_or_else(|| "JWT".to_string()), + }, + "payload": token_data.claims, + "signature": parts[2], + "note": "Token decoded without verification" + }); + + Ok(Some(Output::JsonValue(result))) +} + +fn encode_jwt( + payload: &str, + secret: &str, + algorithm: JwtAlgorithm, + issuer: Option, + subject: Option, + audience: Option, + expires_in: Option, +) -> Result> { + // Parse the payload as JSON + let custom_payload: Value = serde_json::from_str(payload) + .context("Invalid JSON payload. Please provide valid JSON")?; + + // Get current timestamp + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + + // Build claims + let claims = Claims { + custom: custom_payload, + iss: issuer, + sub: subject, + aud: audience, + exp: expires_in.map(|exp| now + exp), + iat: Some(now), + }; + + // Create header + let header = Header::new(algorithm.into()); + + // Encode token + let token = jsonwebtoken::encode( + &header, + &claims, + &EncodingKey::from_secret(secret.as_bytes()), + ) + .context("Failed to encode JWT")?; + + Ok(Some(Output::JsonValue(json!(token)))) +} + +fn verify_jwt( + token: &str, + secret: &str, + algorithm: JwtAlgorithm, + issuer: Option, + subject: Option, + audience: Option, +) -> Result> { + // Configure validation + let mut validation = Validation::new(algorithm.into()); + + // Set optional validations + if let Some(iss) = issuer { + validation.set_issuer(&[iss]); + } else { + validation.validate_exp = true; + validation.validate_nbf = false; + validation.iss = None; + } + + if let Some(sub) = subject { + validation.sub = Some(sub); + } + + if let Some(aud) = audience { + validation.set_audience(&[aud]); + } else { + validation.validate_aud = false; + } + + // Decode and verify + match jsonwebtoken::decode::( + token, + &DecodingKey::from_secret(secret.as_bytes()), + &validation, + ) { + Ok(token_data) => { + let result = json!({ + "valid": true, + "header": { + "alg": format!("{:?}", token_data.header.alg), + "typ": token_data.header.typ.unwrap_or_else(|| "JWT".to_string()), + }, + "payload": token_data.claims, + }); + Ok(Some(Output::JsonValue(result))) + } + Err(err) => { + let result = json!({ + "valid": false, + "error": err.to_string(), + }); + Ok(Some(Output::JsonValue(result))) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_encode_simple() { + let tool = JwtTool { + command: JwtCommand::Encode { + payload: r#"{"user":"alice"}"#.to_string(), + secret: "my-secret".to_string(), + algorithm: JwtAlgorithm::HS256, + issuer: None, + subject: None, + audience: None, + expires_in: None, + }, + }; + + let result = tool.execute().unwrap().unwrap(); + let Output::JsonValue(val) = result else { + panic!("Expected JsonValue output"); + }; + + let token = val.as_str().unwrap(); + assert!(token.contains('.')); + assert_eq!(token.split('.').count(), 3); + } + + #[test] + fn test_encode_with_claims() { + let tool = JwtTool { + command: JwtCommand::Encode { + payload: r#"{"user":"bob"}"#.to_string(), + secret: "secret".to_string(), + algorithm: JwtAlgorithm::HS256, + issuer: Some("test-issuer".to_string()), + subject: Some("test-subject".to_string()), + audience: Some("test-audience".to_string()), + expires_in: Some(3600), + }, + }; + + let result = tool.execute().unwrap().unwrap(); + let Output::JsonValue(val) = result else { + panic!("Expected JsonValue output"); + }; + + let token = val.as_str().unwrap(); + assert!(token.contains('.')); + } + + #[test] + fn test_encode_invalid_json() { + let tool = JwtTool { + command: JwtCommand::Encode { + payload: "not-json".to_string(), + secret: "secret".to_string(), + algorithm: JwtAlgorithm::HS256, + issuer: None, + subject: None, + audience: None, + expires_in: None, + }, + }; + + let result = tool.execute(); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Invalid JSON")); + } + + #[test] + fn test_decode_valid_token() { + // First encode a token + let encode_tool = JwtTool { + command: JwtCommand::Encode { + payload: r#"{"user":"charlie","role":"admin"}"#.to_string(), + secret: "my-secret".to_string(), + algorithm: JwtAlgorithm::HS256, + issuer: None, + subject: None, + audience: None, + expires_in: None, + }, + }; + + let encode_result = encode_tool.execute().unwrap().unwrap(); + let Output::JsonValue(val) = encode_result else { + panic!("Expected JsonValue output"); + }; + let token = val.as_str().unwrap(); + + // Now decode it + let decode_tool = JwtTool { + command: JwtCommand::Decode { + token: token.to_string(), + }, + }; + + let decode_result = decode_tool.execute().unwrap().unwrap(); + let Output::JsonValue(decoded) = decode_result else { + panic!("Expected JsonValue output"); + }; + + assert_eq!(decoded["payload"]["user"], "charlie"); + assert_eq!(decoded["payload"]["role"], "admin"); + assert!(decoded["header"]["alg"].as_str().is_some()); + } + + #[test] + fn test_decode_invalid_token() { + let tool = JwtTool { + command: JwtCommand::Decode { + token: "invalid.token".to_string(), + }, + }; + + let result = tool.execute(); + assert!(result.is_err()); + } + + #[test] + fn test_verify_valid_token() { + // First encode a token + let encode_tool = JwtTool { + command: JwtCommand::Encode { + payload: r#"{"user":"dave"}"#.to_string(), + secret: "verify-secret".to_string(), + algorithm: JwtAlgorithm::HS256, + issuer: Some("my-issuer".to_string()), + subject: None, + audience: None, + expires_in: Some(3600), + }, + }; + + let encode_result = encode_tool.execute().unwrap().unwrap(); + let Output::JsonValue(val) = encode_result else { + panic!("Expected JsonValue output"); + }; + let token = val.as_str().unwrap(); + + // Verify it with correct secret and issuer + let verify_tool = JwtTool { + command: JwtCommand::Verify { + token: token.to_string(), + secret: "verify-secret".to_string(), + algorithm: JwtAlgorithm::HS256, + issuer: Some("my-issuer".to_string()), + subject: None, + audience: None, + }, + }; + + let verify_result = verify_tool.execute().unwrap().unwrap(); + let Output::JsonValue(verified) = verify_result else { + panic!("Expected JsonValue output"); + }; + + assert_eq!(verified["valid"], true); + assert_eq!(verified["payload"]["user"], "dave"); + } + + #[test] + fn test_verify_wrong_secret() { + // Encode with one secret + let encode_tool = JwtTool { + command: JwtCommand::Encode { + payload: r#"{"user":"eve"}"#.to_string(), + secret: "correct-secret".to_string(), + algorithm: JwtAlgorithm::HS256, + issuer: None, + subject: None, + audience: None, + expires_in: Some(3600), + }, + }; + + let encode_result = encode_tool.execute().unwrap().unwrap(); + let Output::JsonValue(val) = encode_result else { + panic!("Expected JsonValue output"); + }; + let token = val.as_str().unwrap(); + + // Verify with wrong secret + let verify_tool = JwtTool { + command: JwtCommand::Verify { + token: token.to_string(), + secret: "wrong-secret".to_string(), + algorithm: JwtAlgorithm::HS256, + issuer: None, + subject: None, + audience: None, + }, + }; + + let verify_result = verify_tool.execute().unwrap().unwrap(); + let Output::JsonValue(verified) = verify_result else { + panic!("Expected JsonValue output"); + }; + + assert_eq!(verified["valid"], false); + assert!(verified["error"].as_str().is_some()); + } + + #[test] + fn test_verify_wrong_issuer() { + // Encode with specific issuer + let encode_tool = JwtTool { + command: JwtCommand::Encode { + payload: r#"{"data":"test"}"#.to_string(), + secret: "secret".to_string(), + algorithm: JwtAlgorithm::HS256, + issuer: Some("correct-issuer".to_string()), + subject: None, + audience: None, + expires_in: Some(3600), + }, + }; + + let encode_result = encode_tool.execute().unwrap().unwrap(); + let Output::JsonValue(val) = encode_result else { + panic!("Expected JsonValue output"); + }; + let token = val.as_str().unwrap(); + + // Verify with wrong issuer + let verify_tool = JwtTool { + command: JwtCommand::Verify { + token: token.to_string(), + secret: "secret".to_string(), + algorithm: JwtAlgorithm::HS256, + issuer: Some("wrong-issuer".to_string()), + subject: None, + audience: None, + }, + }; + + let verify_result = verify_tool.execute().unwrap().unwrap(); + let Output::JsonValue(verified) = verify_result else { + panic!("Expected JsonValue output"); + }; + + assert_eq!(verified["valid"], false); + } + + #[test] + fn test_different_algorithms() { + for algorithm in [JwtAlgorithm::HS256, JwtAlgorithm::HS384, JwtAlgorithm::HS512] { + let encode_tool = JwtTool { + command: JwtCommand::Encode { + payload: r#"{"test":"data"}"#.to_string(), + secret: "secret".to_string(), + algorithm, + issuer: None, + subject: None, + audience: None, + expires_in: None, + }, + }; + + let result = encode_tool.execute().unwrap().unwrap(); + let Output::JsonValue(val) = result else { + panic!("Expected JsonValue output"); + }; + + let token = val.as_str().unwrap(); + assert_eq!(token.split('.').count(), 3); + } + } + + #[test] + fn test_encode_complex_payload() { + let complex_payload = r#"{ + "user": "alice", + "roles": ["admin", "user"], + "metadata": { + "age": 30, + "active": true + } + }"#; + + let tool = JwtTool { + command: JwtCommand::Encode { + payload: complex_payload.to_string(), + secret: "secret".to_string(), + algorithm: JwtAlgorithm::HS256, + issuer: None, + subject: None, + audience: None, + expires_in: None, + }, + }; + + let result = tool.execute().unwrap().unwrap(); + let Output::JsonValue(val) = result else { + panic!("Expected JsonValue output"); + }; + + let token = val.as_str().unwrap(); + + // Decode to verify structure + let decode_tool = JwtTool { + command: JwtCommand::Decode { + token: token.to_string(), + }, + }; + + let decode_result = decode_tool.execute().unwrap().unwrap(); + let Output::JsonValue(decoded) = decode_result else { + panic!("Expected JsonValue output"); + }; + + assert_eq!(decoded["payload"]["user"], "alice"); + assert!(decoded["payload"]["roles"].is_array()); + assert!(decoded["payload"]["metadata"].is_object()); + } +} + diff --git a/src/tools/mod.rs b/src/tools/mod.rs index 8160f57..188a470 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -9,7 +9,9 @@ pub mod diff; pub mod hash; pub mod http; pub mod json; +pub mod jwt; pub mod lorem; +pub mod password; pub mod pp; pub mod qr; pub mod random; diff --git a/src/tools/password.rs b/src/tools/password.rs new file mode 100644 index 0000000..233ef83 --- /dev/null +++ b/src/tools/password.rs @@ -0,0 +1,670 @@ +use crate::tool::{Output, Tool}; +use anyhow::{bail, Result}; +use clap::{Command, CommandFactory, Parser}; +use rand::{Rng, rngs::OsRng, seq::SliceRandom}; + +#[derive(Parser, Debug)] +#[command( + name = "password", + about = "Generate secure passwords with various options", + long_about = "Generate cryptographically secure passwords with customizable options.\n\ + Supports random character-based passwords and memorable passphrases." +)] +pub struct PasswordTool { + /// Length of the password to generate + #[arg(long, short, default_value = "16")] + length: usize, + + /// Number of passwords to generate + #[arg(long, short = 'n', default_value = "1")] + count: usize, + + /// Include uppercase letters (A-Z) + #[arg(long, default_value = "true", action = clap::ArgAction::Set)] + uppercase: bool, + + /// Include lowercase letters (a-z) + #[arg(long, default_value = "true", action = clap::ArgAction::Set)] + lowercase: bool, + + /// Include numbers (0-9) + #[arg(long, default_value = "true", action = clap::ArgAction::Set)] + numbers: bool, + + /// Include symbols (!@#$%^&*-_+=) + #[arg(long, default_value = "true", action = clap::ArgAction::Set)] + symbols: bool, + + /// Exclude ambiguous characters (0, O, l, I, 1, etc.) + #[arg(long, default_value = "false")] + no_ambiguous: bool, + + /// Generate memorable passphrase instead of random characters + /// (e.g., "correct-horse-battery-staple") + #[arg(long, conflicts_with_all = ["length", "uppercase", "lowercase", "numbers", "symbols", "no_ambiguous"])] + memorable: bool, + + /// Number of words in the passphrase (only with --memorable) + #[arg(long, default_value = "4", requires = "memorable")] + words: usize, + + /// Separator for passphrase words (only with --memorable) + #[arg(long, default_value = "-", requires = "memorable")] + separator: String, + + /// Capitalize first letter of each word in passphrase (only with --memorable) + #[arg(long, default_value = "false", requires = "memorable")] + capitalize: bool, +} + +impl Tool for PasswordTool { + fn cli() -> Command { + PasswordTool::command() + } + + fn execute(&self) -> Result> { + if self.memorable { + generate_memorable_passwords(self.count, self.words, &self.separator, self.capitalize) + } else { + generate_random_passwords( + self.length, + self.count, + self.uppercase, + self.lowercase, + self.numbers, + self.symbols, + self.no_ambiguous, + ) + } + } +} + +fn generate_random_passwords( + length: usize, + count: usize, + uppercase: bool, + lowercase: bool, + numbers: bool, + symbols: bool, + no_ambiguous: bool, +) -> Result> { + if length == 0 { + bail!("Password length must be greater than 0"); + } + + if count == 0 { + bail!("Count must be greater than 0"); + } + + // Build character set + let mut charset = String::new(); + + if uppercase { + if no_ambiguous { + charset.push_str("ABCDEFGHJKLMNPQRSTUVWXYZ"); // Exclude I, O + } else { + charset.push_str("ABCDEFGHIJKLMNOPQRSTUVWXYZ"); + } + } + + if lowercase { + if no_ambiguous { + charset.push_str("abcdefghijkmnopqrstuvwxyz"); // Exclude l + } else { + charset.push_str("abcdefghijklmnopqrstuvwxyz"); + } + } + + if numbers { + if no_ambiguous { + charset.push_str("23456789"); // Exclude 0, 1 + } else { + charset.push_str("0123456789"); + } + } + + if symbols { + charset.push_str("!@#$%^&*-_+="); + } + + if charset.is_empty() { + bail!("At least one character set must be enabled"); + } + + let mut rng = OsRng; + let charset_chars: Vec = charset.chars().collect(); + let mut passwords = Vec::new(); + + for _ in 0..count { + let password = generate_secure_password(&mut rng, &charset_chars, length, uppercase, lowercase, numbers, symbols); + let strength = calculate_strength(&password, length); + + passwords.push(serde_json::json!({ + "password": password, + "length": length, + "strength": strength, + "entropy_bits": calculate_entropy(charset_chars.len(), length), + })); + } + + if count == 1 { + Ok(Some(Output::JsonValue(passwords[0].clone()))) + } else { + Ok(Some(Output::JsonValue(serde_json::json!(passwords)))) + } +} + +fn generate_secure_password( + rng: &mut OsRng, + charset_chars: &[char], + length: usize, + has_uppercase: bool, + has_lowercase: bool, + has_numbers: bool, + has_symbols: bool, +) -> String { + // Generate password ensuring at least one character from each enabled set + let mut password: Vec = Vec::with_capacity(length); + + // Collect required character sets + let mut required_chars = Vec::new(); + + if has_uppercase { + let uppercase: Vec = charset_chars.iter() + .filter(|c| c.is_uppercase()) + .copied() + .collect(); + if !uppercase.is_empty() { + required_chars.push(*uppercase.choose(rng).unwrap()); + } + } + + if has_lowercase { + let lowercase: Vec = charset_chars.iter() + .filter(|c| c.is_lowercase()) + .copied() + .collect(); + if !lowercase.is_empty() { + required_chars.push(*lowercase.choose(rng).unwrap()); + } + } + + if has_numbers { + let numbers: Vec = charset_chars.iter() + .filter(|c| c.is_numeric()) + .copied() + .collect(); + if !numbers.is_empty() { + required_chars.push(*numbers.choose(rng).unwrap()); + } + } + + if has_symbols { + let symbols: Vec = charset_chars.iter() + .filter(|c| !c.is_alphanumeric()) + .copied() + .collect(); + if !symbols.is_empty() { + required_chars.push(*symbols.choose(rng).unwrap()); + } + } + + // Add required characters first + password.extend(required_chars.iter()); + + // Fill the rest randomly + while password.len() < length { + password.push(charset_chars[rng.gen_range(0..charset_chars.len())]); + } + + // Shuffle to avoid predictable patterns + password.shuffle(rng); + + password.into_iter().collect() +} + +fn generate_memorable_passwords( + count: usize, + words: usize, + separator: &str, + capitalize: bool, +) -> Result> { + if words == 0 { + bail!("Number of words must be greater than 0"); + } + + if count == 0 { + bail!("Count must be greater than 0"); + } + + let mut rng = OsRng; + let mut passwords = Vec::new(); + + for _ in 0..count { + let mut selected_words = Vec::new(); + + for _ in 0..words { + let word = WORDLIST.choose(&mut rng).unwrap(); + let word = if capitalize { + capitalize_first(word) + } else { + word.to_string() + }; + selected_words.push(word); + } + + let passphrase = selected_words.join(separator); + let strength = if words >= 6 { + "very-strong" + } else if words >= 5 { + "strong" + } else if words >= 4 { + "good" + } else { + "moderate" + }; + + passwords.push(serde_json::json!({ + "password": passphrase, + "type": "passphrase", + "word_count": words, + "strength": strength, + "entropy_bits": calculate_entropy(WORDLIST.len(), words), + })); + } + + if count == 1 { + Ok(Some(Output::JsonValue(passwords[0].clone()))) + } else { + Ok(Some(Output::JsonValue(serde_json::json!(passwords)))) + } +} + +fn capitalize_first(word: &str) -> String { + let mut chars = word.chars(); + match chars.next() { + None => String::new(), + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + } +} + +fn calculate_strength(password: &str, length: usize) -> &'static str { + let has_lower = password.chars().any(|c| c.is_lowercase()); + let has_upper = password.chars().any(|c| c.is_uppercase()); + let has_digit = password.chars().any(|c| c.is_numeric()); + let has_symbol = password.chars().any(|c| !c.is_alphanumeric()); + + let variety_count = [has_lower, has_upper, has_digit, has_symbol] + .iter() + .filter(|&&x| x) + .count(); + + match (length, variety_count) { + (l, v) if l >= 16 && v >= 4 => "very-strong", + (l, v) if l >= 12 && v >= 3 => "strong", + (l, v) if l >= 11 && v >= 3 => "good", + (l, v) if l >= 8 && v >= 3 => "moderate", + (l, v) if l >= 8 && v >= 2 => "moderate", + _ => "weak", + } +} + +fn calculate_entropy(charset_size: usize, length: usize) -> f64 { + (charset_size as f64).log2() * length as f64 +} + +// EFF's short wordlist for passphrases - commonly used words that are easy to remember +const WORDLIST: &[&str] = &[ + "able", "acid", "aged", "also", "area", "army", "away", "baby", "back", "ball", + "band", "bank", "base", "bath", "bear", "beat", "been", "beer", "bell", "belt", + "best", "bill", "bird", "blow", "blue", "boat", "body", "bomb", "bond", "bone", + "book", "boom", "born", "boss", "both", "bowl", "bulk", "burn", "bush", "busy", + "cafe", "cage", "cake", "call", "calm", "came", "camp", "card", "care", "cart", + "case", "cash", "cast", "cell", "chat", "chef", "chip", "city", "clay", "clip", + "club", "coal", "coat", "code", "cold", "come", "cook", "cool", "cope", "copy", + "cord", "core", "cost", "crab", "crew", "crop", "dark", "data", "date", "dawn", + "days", "dead", "deal", "dean", "dear", "debt", "deep", "desk", "dial", "diet", + "disc", "disk", "dock", "door", "dose", "down", "drag", "draw", "drew", "drop", + "drug", "drum", "duck", "duke", "dust", "duty", "each", "earl", "earn", "ease", + "east", "easy", "echo", "edge", "else", "even", "ever", "evil", "exit", "face", + "fact", "fail", "fair", "fall", "fame", "farm", "fast", "fate", "fear", "feed", + "feel", "feet", "fell", "felt", "file", "fill", "film", "find", "fine", "fire", + "firm", "fish", "five", "flat", "flow", "folk", "food", "foot", "ford", "form", + "fort", "four", "free", "from", "fuel", "full", "fund", "gain", "game", "gate", + "gave", "gear", "gene", "gift", "girl", "give", "glad", "glen", "goal", "goes", + "gold", "golf", "gone", "good", "gray", "grew", "grey", "grow", "gulf", "hair", + "half", "hall", "hand", "hang", "hard", "harm", "hate", "have", "head", "hear", + "heat", "held", "hell", "help", "hero", "high", "hill", "hire", "hold", "hole", + "holy", "home", "hope", "horn", "host", "hour", "huge", "hung", "hunt", "hurt", + "idea", "inch", "into", "iron", "isle", "item", "jack", "jane", "jazz", "john", + "join", "jump", "june", "jury", "just", "keen", "keep", "kent", "kept", "kick", + "kill", "kind", "king", "knee", "knew", "know", "lack", "lady", "laid", "lake", + "land", "lane", "last", "late", "lead", "left", "lend", "lens", "less", "lied", + "life", "lift", "like", "line", "link", "list", "live", "load", "loan", "lock", + "logo", "long", "look", "lord", "lose", "loss", "lost", "love", "luck", "made", + "mail", "main", "make", "male", "mall", "many", "mark", "mass", "matt", "meal", + "mean", "meat", "meet", "menu", "mere", "mike", "mile", "milk", "mill", "mind", + "mine", "miss", "mode", "mood", "moon", "more", "most", "move", "much", "must", + "myth", "name", "navy", "near", "neck", "need", "news", "next", "nice", "nick", + "nine", "none", "nose", "note", "okay", "once", "only", "onto", "open", "oral", + "over", "pace", "pack", "page", "paid", "pain", "pair", "palm", "park", "part", + "pass", "past", "path", "paul", "peak", "pick", "pile", "pine", "pink", "pipe", + "plan", "play", "plot", "plug", "plus", "poem", "poet", "poll", "pool", "poor", + "pope", "port", "post", "pour", "pray", "prep", "prev", "prey", "quit", "race", + "rail", "rain", "rank", "rare", "rate", "read", "real", "rear", "rely", "rent", + "rest", "rice", "rich", "ride", "ring", "rise", "risk", "road", "rock", "rode", + "role", "roll", "roof", "room", "root", "rope", "rose", "rule", "rush", "ruth", + "safe", "sage", "said", "sail", "sake", "sale", "salt", "same", "sand", "save", + "seat", "seed", "seek", "seem", "seen", "self", "sell", "send", "sent", "sept", + "ship", "shop", "shot", "show", "shut", "side", "sign", "sing", "site", "size", + "skin", "slip", "slow", "snow", "soft", "soil", "sold", "sole", "some", "song", + "soon", "sort", "soul", "spot", "star", "stay", "stem", "step", "stop", "such", + "suit", "sure", "take", "tale", "talk", "tall", "tank", "tape", "task", "team", + "tech", "tell", "tend", "term", "test", "text", "than", "that", "thee", "them", + "then", "they", "thin", "this", "thus", "tide", "tied", "tier", "tile", "till", + "time", "tiny", "tire", "told", "toll", "tone", "tony", "took", "tool", "tops", + "torn", "tour", "town", "tree", "trip", "true", "tube", "tune", "turn", "twin", + "type", "unit", "upon", "used", "user", "vary", "vast", "very", "vice", "view", + "vote", "wage", "wait", "wake", "walk", "wall", "want", "ward", "warm", "warn", + "wash", "wave", "ways", "weak", "wear", "week", "well", "went", "were", "west", + "what", "when", "whom", "wide", "wife", "wild", "will", "wind", "wine", "wing", + "wire", "wise", "wish", "with", "wood", "word", "wore", "work", "worn", "wrap", + "yard", "year", "your", "zero", "zone", +]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generate_default_password() { + let tool = PasswordTool { + length: 16, + count: 1, + uppercase: true, + lowercase: true, + numbers: true, + symbols: true, + no_ambiguous: false, + memorable: false, + words: 4, + separator: "-".to_string(), + capitalize: false, + }; + + let result = tool.execute().unwrap().unwrap(); + let Output::JsonValue(val) = result else { + panic!("Expected JsonValue output"); + }; + + let password = val["password"].as_str().unwrap(); + assert_eq!(password.len(), 16); + assert!(val["strength"].as_str().is_some()); + assert!(val["entropy_bits"].as_f64().unwrap() > 0.0); + } + + #[test] + fn test_generate_multiple_passwords() { + let tool = PasswordTool { + length: 12, + count: 5, + uppercase: true, + lowercase: true, + numbers: true, + symbols: true, + no_ambiguous: false, + memorable: false, + words: 4, + separator: "-".to_string(), + capitalize: false, + }; + + let result = tool.execute().unwrap().unwrap(); + let Output::JsonValue(val) = result else { + panic!("Expected JsonValue output"); + }; + + let passwords = val.as_array().unwrap(); + assert_eq!(passwords.len(), 5); + + // Check all passwords are unique + let unique_passwords: std::collections::HashSet<_> = passwords + .iter() + .map(|p| p["password"].as_str().unwrap()) + .collect(); + assert_eq!(unique_passwords.len(), 5); + } + + #[test] + fn test_generate_no_ambiguous() { + let tool = PasswordTool { + length: 20, + count: 1, + uppercase: true, + lowercase: true, + numbers: true, + symbols: false, + no_ambiguous: true, + memorable: false, + words: 4, + separator: "-".to_string(), + capitalize: false, + }; + + let result = tool.execute().unwrap().unwrap(); + let Output::JsonValue(val) = result else { + panic!("Expected JsonValue output"); + }; + + let password = val["password"].as_str().unwrap(); + + // Check no ambiguous characters + assert!(!password.contains('0')); + assert!(!password.contains('O')); + assert!(!password.contains('l')); + assert!(!password.contains('I')); + assert!(!password.contains('1')); + } + + #[test] + fn test_generate_only_lowercase() { + let tool = PasswordTool { + length: 10, + count: 1, + uppercase: false, + lowercase: true, + numbers: false, + symbols: false, + no_ambiguous: false, + memorable: false, + words: 4, + separator: "-".to_string(), + capitalize: false, + }; + + let result = tool.execute().unwrap().unwrap(); + let Output::JsonValue(val) = result else { + panic!("Expected JsonValue output"); + }; + + let password = val["password"].as_str().unwrap(); + assert!(password.chars().all(|c| c.is_lowercase())); + } + + #[test] + fn test_generate_no_character_sets() { + let tool = PasswordTool { + length: 10, + count: 1, + uppercase: false, + lowercase: false, + numbers: false, + symbols: false, + no_ambiguous: false, + memorable: false, + words: 4, + separator: "-".to_string(), + capitalize: false, + }; + + let result = tool.execute(); + assert!(result.is_err()); + let error_msg = result.unwrap_err().to_string().to_lowercase(); + assert!(error_msg.contains("at least one") || error_msg.contains("character set")); + } + + #[test] + fn test_generate_memorable_passphrase() { + let tool = PasswordTool { + length: 16, + count: 1, + uppercase: true, + lowercase: true, + numbers: true, + symbols: true, + no_ambiguous: false, + memorable: true, + words: 4, + separator: "-".to_string(), + capitalize: false, + }; + + let result = tool.execute().unwrap().unwrap(); + let Output::JsonValue(val) = result else { + panic!("Expected JsonValue output"); + }; + + let password = val["password"].as_str().unwrap(); + assert_eq!(val["type"], "passphrase"); + assert_eq!(val["word_count"], 4); + + let word_count = password.split('-').count(); + assert_eq!(word_count, 4); + } + + #[test] + fn test_generate_memorable_with_custom_separator() { + let tool = PasswordTool { + length: 16, + count: 1, + uppercase: true, + lowercase: true, + numbers: true, + symbols: true, + no_ambiguous: false, + memorable: true, + words: 3, + separator: "_".to_string(), + capitalize: false, + }; + + let result = tool.execute().unwrap().unwrap(); + let Output::JsonValue(val) = result else { + panic!("Expected JsonValue output"); + }; + + let password = val["password"].as_str().unwrap(); + assert!(password.contains('_')); + assert_eq!(password.split('_').count(), 3); + } + + #[test] + fn test_generate_memorable_capitalized() { + let tool = PasswordTool { + length: 16, + count: 1, + uppercase: true, + lowercase: true, + numbers: true, + symbols: true, + no_ambiguous: false, + memorable: true, + words: 4, + separator: "-".to_string(), + capitalize: true, + }; + + let result = tool.execute().unwrap().unwrap(); + let Output::JsonValue(val) = result else { + panic!("Expected JsonValue output"); + }; + + let password = val["password"].as_str().unwrap(); + let words: Vec<&str> = password.split('-').collect(); + + // Each word should start with uppercase + for word in words { + assert!(word.chars().next().unwrap().is_uppercase()); + } + } + + #[test] + fn test_zero_length_error() { + let tool = PasswordTool { + length: 0, + count: 1, + uppercase: true, + lowercase: true, + numbers: true, + symbols: true, + no_ambiguous: false, + memorable: false, + words: 4, + separator: "-".to_string(), + capitalize: false, + }; + + let result = tool.execute(); + assert!(result.is_err()); + } + + #[test] + fn test_calculate_strength() { + assert_eq!(calculate_strength("abcdefgh", 8), "weak"); + assert_eq!(calculate_strength("Abcdefgh", 8), "moderate"); + assert_eq!(calculate_strength("Abcdef12", 8), "moderate"); + assert_eq!(calculate_strength("Abcdef12!@#", 11), "good"); + assert_eq!(calculate_strength("Abcdef12!@#$", 12), "strong"); + assert_eq!(calculate_strength("Abcdef12!@#$%^&*", 16), "very-strong"); + } + + #[test] + fn test_calculate_entropy() { + let entropy = calculate_entropy(26, 8); // lowercase only, 8 chars + assert!(entropy > 0.0); + + let entropy2 = calculate_entropy(62, 16); // alphanumeric, 16 chars + assert!(entropy2 > entropy); + } + + #[test] + fn test_password_has_required_character_types() { + let tool = PasswordTool { + length: 20, + count: 1, + uppercase: true, + lowercase: true, + numbers: true, + symbols: true, + no_ambiguous: false, + memorable: false, + words: 4, + separator: "-".to_string(), + capitalize: false, + }; + + let result = tool.execute().unwrap().unwrap(); + let Output::JsonValue(val) = result else { + panic!("Expected JsonValue output"); + }; + + let password = val["password"].as_str().unwrap(); + + // Should have at least one of each type + assert!(password.chars().any(|c| c.is_uppercase())); + assert!(password.chars().any(|c| c.is_lowercase())); + assert!(password.chars().any(|c| c.is_numeric())); + assert!(password.chars().any(|c| !c.is_alphanumeric())); + } +} + From dd0de50260901a52e909f05b267aeee8f1e8b435 Mon Sep 17 00:00:00 2001 From: Oshadha Gunawardena Date: Wed, 22 Oct 2025 09:17:10 +0530 Subject: [PATCH 2/2] Address maintainer feedback: Add password as alias for token tool - Remove separate password tool implementation - Add 'password' as alias to existing token tool - Update README.md to reflect password as alias - Keep JWT tool as it provides unique functionality - Maintain all existing token tool functionality --- README.md | 43 +-- src/main.rs | 3 +- src/tools/mod.rs | 1 - src/tools/password.rs | 670 ------------------------------------------ 4 files changed, 16 insertions(+), 701 deletions(-) delete mode 100644 src/tools/password.rs diff --git a/README.md b/README.md index e060693..d058df5 100644 --- a/README.md +++ b/README.md @@ -127,9 +127,7 @@ After setting up completions, restart your shell or source your configuration fi │ │ ├── encode │ │ ├── decode │ │ └── verify -│ └── password (pass) - Secure password generation -│ ├── random characters -│ └── memorable passphrases +│ └── password (alias for token) - Secure password generation ├── Data Generation │ ├── uuid - Generate UUIDs │ │ ├── v1 @@ -137,7 +135,7 @@ After setting up completions, restart your shell or source your configuration fi │ │ ├── v4 │ │ ├── v5 │ │ └── v7 -│ ├── token (secret) - Generate secure random tokens +│ ├── token (secret, password) - Generate secure random tokens │ ├── lorem - Generate lorem ipsum text │ └── random - Generate random numbers ├── Text Processing @@ -239,29 +237,8 @@ ut jwt decode eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... ut jwt verify TOKEN --secret "my-secret" --issuer "my-app" ``` -#### `password` (alias: `pass`) -Generate cryptographically secure passwords with various options. -- Random character-based passwords with customizable length and character sets -- Memorable passphrases using common words -- Option to exclude ambiguous characters (0, O, l, I, 1) -- Strength indicator and entropy calculation - -```bash -# Generate a strong random password -ut password --length 20 - -# Generate multiple passwords -ut password --length 16 --count 5 - -# Generate without ambiguous characters -ut password --length 16 --no-ambiguous - -# Generate memorable passphrase -ut password --memorable --words 5 - -# Generate passphrase with custom separator and capitalization -ut password --memorable --words 4 --separator "_" --capitalize -``` +#### `password` (alias for `token`) +The `password` command is an alias for the `token` tool. See the `token` section below for usage examples. ### Data Generation @@ -281,14 +258,24 @@ ut uuid v7 ut uuid v7 --count 5 ``` -#### `token` (alias: `secret`) +#### `token` (aliases: `secret`, `password`) Generate cryptographically secure random tokens. - Customizable length and character sets - Uses OS-level secure randomness +- Can be used for passwords, API keys, session tokens, etc. ```bash +# Generate a 32-character token ut token --length 32 + +# Generate a password (using alias) +ut password --length 16 + +# Generate without symbols ut secret --no-symbols --length 64 + +# Generate without uppercase letters +ut token --no-uppercase --length 20 ``` #### `lorem` diff --git a/src/main.rs b/src/main.rs index 4006eaa..1458038 100644 --- a/src/main.rs +++ b/src/main.rs @@ -80,13 +80,12 @@ fn main() -> anyhow::Result<()> { (tools::json::JsonTool, "json",), (tools::jwt::JwtTool, "jwt",), (tools::lorem::LoremTool, "lorem",), - (tools::password::PasswordTool, "password", "pass"), (tools::pp::PrettyPrintTool, "pretty-print", "pp"), (tools::qr::QRTool, "qr",), (tools::random::RandomTool, "random",), (tools::regex::RegexTool, "regex",), (tools::serve::ServeTool, "serve",), - (tools::token::TokenTool, "token", "secret"), + (tools::token::TokenTool, "token", "secret", "password"), (tools::url::UrlTool, "url",), (tools::uuid::UUIDTool, "uuid",), (tools::unicode::UnicodeTool, "unicode",) diff --git a/src/tools/mod.rs b/src/tools/mod.rs index 188a470..96ed0af 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -11,7 +11,6 @@ pub mod http; pub mod json; pub mod jwt; pub mod lorem; -pub mod password; pub mod pp; pub mod qr; pub mod random; diff --git a/src/tools/password.rs b/src/tools/password.rs deleted file mode 100644 index 233ef83..0000000 --- a/src/tools/password.rs +++ /dev/null @@ -1,670 +0,0 @@ -use crate::tool::{Output, Tool}; -use anyhow::{bail, Result}; -use clap::{Command, CommandFactory, Parser}; -use rand::{Rng, rngs::OsRng, seq::SliceRandom}; - -#[derive(Parser, Debug)] -#[command( - name = "password", - about = "Generate secure passwords with various options", - long_about = "Generate cryptographically secure passwords with customizable options.\n\ - Supports random character-based passwords and memorable passphrases." -)] -pub struct PasswordTool { - /// Length of the password to generate - #[arg(long, short, default_value = "16")] - length: usize, - - /// Number of passwords to generate - #[arg(long, short = 'n', default_value = "1")] - count: usize, - - /// Include uppercase letters (A-Z) - #[arg(long, default_value = "true", action = clap::ArgAction::Set)] - uppercase: bool, - - /// Include lowercase letters (a-z) - #[arg(long, default_value = "true", action = clap::ArgAction::Set)] - lowercase: bool, - - /// Include numbers (0-9) - #[arg(long, default_value = "true", action = clap::ArgAction::Set)] - numbers: bool, - - /// Include symbols (!@#$%^&*-_+=) - #[arg(long, default_value = "true", action = clap::ArgAction::Set)] - symbols: bool, - - /// Exclude ambiguous characters (0, O, l, I, 1, etc.) - #[arg(long, default_value = "false")] - no_ambiguous: bool, - - /// Generate memorable passphrase instead of random characters - /// (e.g., "correct-horse-battery-staple") - #[arg(long, conflicts_with_all = ["length", "uppercase", "lowercase", "numbers", "symbols", "no_ambiguous"])] - memorable: bool, - - /// Number of words in the passphrase (only with --memorable) - #[arg(long, default_value = "4", requires = "memorable")] - words: usize, - - /// Separator for passphrase words (only with --memorable) - #[arg(long, default_value = "-", requires = "memorable")] - separator: String, - - /// Capitalize first letter of each word in passphrase (only with --memorable) - #[arg(long, default_value = "false", requires = "memorable")] - capitalize: bool, -} - -impl Tool for PasswordTool { - fn cli() -> Command { - PasswordTool::command() - } - - fn execute(&self) -> Result> { - if self.memorable { - generate_memorable_passwords(self.count, self.words, &self.separator, self.capitalize) - } else { - generate_random_passwords( - self.length, - self.count, - self.uppercase, - self.lowercase, - self.numbers, - self.symbols, - self.no_ambiguous, - ) - } - } -} - -fn generate_random_passwords( - length: usize, - count: usize, - uppercase: bool, - lowercase: bool, - numbers: bool, - symbols: bool, - no_ambiguous: bool, -) -> Result> { - if length == 0 { - bail!("Password length must be greater than 0"); - } - - if count == 0 { - bail!("Count must be greater than 0"); - } - - // Build character set - let mut charset = String::new(); - - if uppercase { - if no_ambiguous { - charset.push_str("ABCDEFGHJKLMNPQRSTUVWXYZ"); // Exclude I, O - } else { - charset.push_str("ABCDEFGHIJKLMNOPQRSTUVWXYZ"); - } - } - - if lowercase { - if no_ambiguous { - charset.push_str("abcdefghijkmnopqrstuvwxyz"); // Exclude l - } else { - charset.push_str("abcdefghijklmnopqrstuvwxyz"); - } - } - - if numbers { - if no_ambiguous { - charset.push_str("23456789"); // Exclude 0, 1 - } else { - charset.push_str("0123456789"); - } - } - - if symbols { - charset.push_str("!@#$%^&*-_+="); - } - - if charset.is_empty() { - bail!("At least one character set must be enabled"); - } - - let mut rng = OsRng; - let charset_chars: Vec = charset.chars().collect(); - let mut passwords = Vec::new(); - - for _ in 0..count { - let password = generate_secure_password(&mut rng, &charset_chars, length, uppercase, lowercase, numbers, symbols); - let strength = calculate_strength(&password, length); - - passwords.push(serde_json::json!({ - "password": password, - "length": length, - "strength": strength, - "entropy_bits": calculate_entropy(charset_chars.len(), length), - })); - } - - if count == 1 { - Ok(Some(Output::JsonValue(passwords[0].clone()))) - } else { - Ok(Some(Output::JsonValue(serde_json::json!(passwords)))) - } -} - -fn generate_secure_password( - rng: &mut OsRng, - charset_chars: &[char], - length: usize, - has_uppercase: bool, - has_lowercase: bool, - has_numbers: bool, - has_symbols: bool, -) -> String { - // Generate password ensuring at least one character from each enabled set - let mut password: Vec = Vec::with_capacity(length); - - // Collect required character sets - let mut required_chars = Vec::new(); - - if has_uppercase { - let uppercase: Vec = charset_chars.iter() - .filter(|c| c.is_uppercase()) - .copied() - .collect(); - if !uppercase.is_empty() { - required_chars.push(*uppercase.choose(rng).unwrap()); - } - } - - if has_lowercase { - let lowercase: Vec = charset_chars.iter() - .filter(|c| c.is_lowercase()) - .copied() - .collect(); - if !lowercase.is_empty() { - required_chars.push(*lowercase.choose(rng).unwrap()); - } - } - - if has_numbers { - let numbers: Vec = charset_chars.iter() - .filter(|c| c.is_numeric()) - .copied() - .collect(); - if !numbers.is_empty() { - required_chars.push(*numbers.choose(rng).unwrap()); - } - } - - if has_symbols { - let symbols: Vec = charset_chars.iter() - .filter(|c| !c.is_alphanumeric()) - .copied() - .collect(); - if !symbols.is_empty() { - required_chars.push(*symbols.choose(rng).unwrap()); - } - } - - // Add required characters first - password.extend(required_chars.iter()); - - // Fill the rest randomly - while password.len() < length { - password.push(charset_chars[rng.gen_range(0..charset_chars.len())]); - } - - // Shuffle to avoid predictable patterns - password.shuffle(rng); - - password.into_iter().collect() -} - -fn generate_memorable_passwords( - count: usize, - words: usize, - separator: &str, - capitalize: bool, -) -> Result> { - if words == 0 { - bail!("Number of words must be greater than 0"); - } - - if count == 0 { - bail!("Count must be greater than 0"); - } - - let mut rng = OsRng; - let mut passwords = Vec::new(); - - for _ in 0..count { - let mut selected_words = Vec::new(); - - for _ in 0..words { - let word = WORDLIST.choose(&mut rng).unwrap(); - let word = if capitalize { - capitalize_first(word) - } else { - word.to_string() - }; - selected_words.push(word); - } - - let passphrase = selected_words.join(separator); - let strength = if words >= 6 { - "very-strong" - } else if words >= 5 { - "strong" - } else if words >= 4 { - "good" - } else { - "moderate" - }; - - passwords.push(serde_json::json!({ - "password": passphrase, - "type": "passphrase", - "word_count": words, - "strength": strength, - "entropy_bits": calculate_entropy(WORDLIST.len(), words), - })); - } - - if count == 1 { - Ok(Some(Output::JsonValue(passwords[0].clone()))) - } else { - Ok(Some(Output::JsonValue(serde_json::json!(passwords)))) - } -} - -fn capitalize_first(word: &str) -> String { - let mut chars = word.chars(); - match chars.next() { - None => String::new(), - Some(first) => first.to_uppercase().collect::() + chars.as_str(), - } -} - -fn calculate_strength(password: &str, length: usize) -> &'static str { - let has_lower = password.chars().any(|c| c.is_lowercase()); - let has_upper = password.chars().any(|c| c.is_uppercase()); - let has_digit = password.chars().any(|c| c.is_numeric()); - let has_symbol = password.chars().any(|c| !c.is_alphanumeric()); - - let variety_count = [has_lower, has_upper, has_digit, has_symbol] - .iter() - .filter(|&&x| x) - .count(); - - match (length, variety_count) { - (l, v) if l >= 16 && v >= 4 => "very-strong", - (l, v) if l >= 12 && v >= 3 => "strong", - (l, v) if l >= 11 && v >= 3 => "good", - (l, v) if l >= 8 && v >= 3 => "moderate", - (l, v) if l >= 8 && v >= 2 => "moderate", - _ => "weak", - } -} - -fn calculate_entropy(charset_size: usize, length: usize) -> f64 { - (charset_size as f64).log2() * length as f64 -} - -// EFF's short wordlist for passphrases - commonly used words that are easy to remember -const WORDLIST: &[&str] = &[ - "able", "acid", "aged", "also", "area", "army", "away", "baby", "back", "ball", - "band", "bank", "base", "bath", "bear", "beat", "been", "beer", "bell", "belt", - "best", "bill", "bird", "blow", "blue", "boat", "body", "bomb", "bond", "bone", - "book", "boom", "born", "boss", "both", "bowl", "bulk", "burn", "bush", "busy", - "cafe", "cage", "cake", "call", "calm", "came", "camp", "card", "care", "cart", - "case", "cash", "cast", "cell", "chat", "chef", "chip", "city", "clay", "clip", - "club", "coal", "coat", "code", "cold", "come", "cook", "cool", "cope", "copy", - "cord", "core", "cost", "crab", "crew", "crop", "dark", "data", "date", "dawn", - "days", "dead", "deal", "dean", "dear", "debt", "deep", "desk", "dial", "diet", - "disc", "disk", "dock", "door", "dose", "down", "drag", "draw", "drew", "drop", - "drug", "drum", "duck", "duke", "dust", "duty", "each", "earl", "earn", "ease", - "east", "easy", "echo", "edge", "else", "even", "ever", "evil", "exit", "face", - "fact", "fail", "fair", "fall", "fame", "farm", "fast", "fate", "fear", "feed", - "feel", "feet", "fell", "felt", "file", "fill", "film", "find", "fine", "fire", - "firm", "fish", "five", "flat", "flow", "folk", "food", "foot", "ford", "form", - "fort", "four", "free", "from", "fuel", "full", "fund", "gain", "game", "gate", - "gave", "gear", "gene", "gift", "girl", "give", "glad", "glen", "goal", "goes", - "gold", "golf", "gone", "good", "gray", "grew", "grey", "grow", "gulf", "hair", - "half", "hall", "hand", "hang", "hard", "harm", "hate", "have", "head", "hear", - "heat", "held", "hell", "help", "hero", "high", "hill", "hire", "hold", "hole", - "holy", "home", "hope", "horn", "host", "hour", "huge", "hung", "hunt", "hurt", - "idea", "inch", "into", "iron", "isle", "item", "jack", "jane", "jazz", "john", - "join", "jump", "june", "jury", "just", "keen", "keep", "kent", "kept", "kick", - "kill", "kind", "king", "knee", "knew", "know", "lack", "lady", "laid", "lake", - "land", "lane", "last", "late", "lead", "left", "lend", "lens", "less", "lied", - "life", "lift", "like", "line", "link", "list", "live", "load", "loan", "lock", - "logo", "long", "look", "lord", "lose", "loss", "lost", "love", "luck", "made", - "mail", "main", "make", "male", "mall", "many", "mark", "mass", "matt", "meal", - "mean", "meat", "meet", "menu", "mere", "mike", "mile", "milk", "mill", "mind", - "mine", "miss", "mode", "mood", "moon", "more", "most", "move", "much", "must", - "myth", "name", "navy", "near", "neck", "need", "news", "next", "nice", "nick", - "nine", "none", "nose", "note", "okay", "once", "only", "onto", "open", "oral", - "over", "pace", "pack", "page", "paid", "pain", "pair", "palm", "park", "part", - "pass", "past", "path", "paul", "peak", "pick", "pile", "pine", "pink", "pipe", - "plan", "play", "plot", "plug", "plus", "poem", "poet", "poll", "pool", "poor", - "pope", "port", "post", "pour", "pray", "prep", "prev", "prey", "quit", "race", - "rail", "rain", "rank", "rare", "rate", "read", "real", "rear", "rely", "rent", - "rest", "rice", "rich", "ride", "ring", "rise", "risk", "road", "rock", "rode", - "role", "roll", "roof", "room", "root", "rope", "rose", "rule", "rush", "ruth", - "safe", "sage", "said", "sail", "sake", "sale", "salt", "same", "sand", "save", - "seat", "seed", "seek", "seem", "seen", "self", "sell", "send", "sent", "sept", - "ship", "shop", "shot", "show", "shut", "side", "sign", "sing", "site", "size", - "skin", "slip", "slow", "snow", "soft", "soil", "sold", "sole", "some", "song", - "soon", "sort", "soul", "spot", "star", "stay", "stem", "step", "stop", "such", - "suit", "sure", "take", "tale", "talk", "tall", "tank", "tape", "task", "team", - "tech", "tell", "tend", "term", "test", "text", "than", "that", "thee", "them", - "then", "they", "thin", "this", "thus", "tide", "tied", "tier", "tile", "till", - "time", "tiny", "tire", "told", "toll", "tone", "tony", "took", "tool", "tops", - "torn", "tour", "town", "tree", "trip", "true", "tube", "tune", "turn", "twin", - "type", "unit", "upon", "used", "user", "vary", "vast", "very", "vice", "view", - "vote", "wage", "wait", "wake", "walk", "wall", "want", "ward", "warm", "warn", - "wash", "wave", "ways", "weak", "wear", "week", "well", "went", "were", "west", - "what", "when", "whom", "wide", "wife", "wild", "will", "wind", "wine", "wing", - "wire", "wise", "wish", "with", "wood", "word", "wore", "work", "worn", "wrap", - "yard", "year", "your", "zero", "zone", -]; - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_generate_default_password() { - let tool = PasswordTool { - length: 16, - count: 1, - uppercase: true, - lowercase: true, - numbers: true, - symbols: true, - no_ambiguous: false, - memorable: false, - words: 4, - separator: "-".to_string(), - capitalize: false, - }; - - let result = tool.execute().unwrap().unwrap(); - let Output::JsonValue(val) = result else { - panic!("Expected JsonValue output"); - }; - - let password = val["password"].as_str().unwrap(); - assert_eq!(password.len(), 16); - assert!(val["strength"].as_str().is_some()); - assert!(val["entropy_bits"].as_f64().unwrap() > 0.0); - } - - #[test] - fn test_generate_multiple_passwords() { - let tool = PasswordTool { - length: 12, - count: 5, - uppercase: true, - lowercase: true, - numbers: true, - symbols: true, - no_ambiguous: false, - memorable: false, - words: 4, - separator: "-".to_string(), - capitalize: false, - }; - - let result = tool.execute().unwrap().unwrap(); - let Output::JsonValue(val) = result else { - panic!("Expected JsonValue output"); - }; - - let passwords = val.as_array().unwrap(); - assert_eq!(passwords.len(), 5); - - // Check all passwords are unique - let unique_passwords: std::collections::HashSet<_> = passwords - .iter() - .map(|p| p["password"].as_str().unwrap()) - .collect(); - assert_eq!(unique_passwords.len(), 5); - } - - #[test] - fn test_generate_no_ambiguous() { - let tool = PasswordTool { - length: 20, - count: 1, - uppercase: true, - lowercase: true, - numbers: true, - symbols: false, - no_ambiguous: true, - memorable: false, - words: 4, - separator: "-".to_string(), - capitalize: false, - }; - - let result = tool.execute().unwrap().unwrap(); - let Output::JsonValue(val) = result else { - panic!("Expected JsonValue output"); - }; - - let password = val["password"].as_str().unwrap(); - - // Check no ambiguous characters - assert!(!password.contains('0')); - assert!(!password.contains('O')); - assert!(!password.contains('l')); - assert!(!password.contains('I')); - assert!(!password.contains('1')); - } - - #[test] - fn test_generate_only_lowercase() { - let tool = PasswordTool { - length: 10, - count: 1, - uppercase: false, - lowercase: true, - numbers: false, - symbols: false, - no_ambiguous: false, - memorable: false, - words: 4, - separator: "-".to_string(), - capitalize: false, - }; - - let result = tool.execute().unwrap().unwrap(); - let Output::JsonValue(val) = result else { - panic!("Expected JsonValue output"); - }; - - let password = val["password"].as_str().unwrap(); - assert!(password.chars().all(|c| c.is_lowercase())); - } - - #[test] - fn test_generate_no_character_sets() { - let tool = PasswordTool { - length: 10, - count: 1, - uppercase: false, - lowercase: false, - numbers: false, - symbols: false, - no_ambiguous: false, - memorable: false, - words: 4, - separator: "-".to_string(), - capitalize: false, - }; - - let result = tool.execute(); - assert!(result.is_err()); - let error_msg = result.unwrap_err().to_string().to_lowercase(); - assert!(error_msg.contains("at least one") || error_msg.contains("character set")); - } - - #[test] - fn test_generate_memorable_passphrase() { - let tool = PasswordTool { - length: 16, - count: 1, - uppercase: true, - lowercase: true, - numbers: true, - symbols: true, - no_ambiguous: false, - memorable: true, - words: 4, - separator: "-".to_string(), - capitalize: false, - }; - - let result = tool.execute().unwrap().unwrap(); - let Output::JsonValue(val) = result else { - panic!("Expected JsonValue output"); - }; - - let password = val["password"].as_str().unwrap(); - assert_eq!(val["type"], "passphrase"); - assert_eq!(val["word_count"], 4); - - let word_count = password.split('-').count(); - assert_eq!(word_count, 4); - } - - #[test] - fn test_generate_memorable_with_custom_separator() { - let tool = PasswordTool { - length: 16, - count: 1, - uppercase: true, - lowercase: true, - numbers: true, - symbols: true, - no_ambiguous: false, - memorable: true, - words: 3, - separator: "_".to_string(), - capitalize: false, - }; - - let result = tool.execute().unwrap().unwrap(); - let Output::JsonValue(val) = result else { - panic!("Expected JsonValue output"); - }; - - let password = val["password"].as_str().unwrap(); - assert!(password.contains('_')); - assert_eq!(password.split('_').count(), 3); - } - - #[test] - fn test_generate_memorable_capitalized() { - let tool = PasswordTool { - length: 16, - count: 1, - uppercase: true, - lowercase: true, - numbers: true, - symbols: true, - no_ambiguous: false, - memorable: true, - words: 4, - separator: "-".to_string(), - capitalize: true, - }; - - let result = tool.execute().unwrap().unwrap(); - let Output::JsonValue(val) = result else { - panic!("Expected JsonValue output"); - }; - - let password = val["password"].as_str().unwrap(); - let words: Vec<&str> = password.split('-').collect(); - - // Each word should start with uppercase - for word in words { - assert!(word.chars().next().unwrap().is_uppercase()); - } - } - - #[test] - fn test_zero_length_error() { - let tool = PasswordTool { - length: 0, - count: 1, - uppercase: true, - lowercase: true, - numbers: true, - symbols: true, - no_ambiguous: false, - memorable: false, - words: 4, - separator: "-".to_string(), - capitalize: false, - }; - - let result = tool.execute(); - assert!(result.is_err()); - } - - #[test] - fn test_calculate_strength() { - assert_eq!(calculate_strength("abcdefgh", 8), "weak"); - assert_eq!(calculate_strength("Abcdefgh", 8), "moderate"); - assert_eq!(calculate_strength("Abcdef12", 8), "moderate"); - assert_eq!(calculate_strength("Abcdef12!@#", 11), "good"); - assert_eq!(calculate_strength("Abcdef12!@#$", 12), "strong"); - assert_eq!(calculate_strength("Abcdef12!@#$%^&*", 16), "very-strong"); - } - - #[test] - fn test_calculate_entropy() { - let entropy = calculate_entropy(26, 8); // lowercase only, 8 chars - assert!(entropy > 0.0); - - let entropy2 = calculate_entropy(62, 16); // alphanumeric, 16 chars - assert!(entropy2 > entropy); - } - - #[test] - fn test_password_has_required_character_types() { - let tool = PasswordTool { - length: 20, - count: 1, - uppercase: true, - lowercase: true, - numbers: true, - symbols: true, - no_ambiguous: false, - memorable: false, - words: 4, - separator: "-".to_string(), - capitalize: false, - }; - - let result = tool.execute().unwrap().unwrap(); - let Output::JsonValue(val) = result else { - panic!("Expected JsonValue output"); - }; - - let password = val["password"].as_str().unwrap(); - - // Should have at least one of each type - assert!(password.chars().any(|c| c.is_uppercase())); - assert!(password.chars().any(|c| c.is_lowercase())); - assert!(password.chars().any(|c| c.is_numeric())); - assert!(password.chars().any(|c| !c.is_alphanumeric())); - } -} -