diff --git a/Cargo.lock b/Cargo.lock index 15ee9ec..9c5a069 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -220,6 +220,12 @@ dependencies = [ "rustc-demangle", ] +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.0" @@ -737,8 +743,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94b22e06ecb0110981051723910cbf0b5f5e09a2062dd7663334ee79a9d1286c" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -930,6 +938,21 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "9.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ae10193d25051e74945f1ea2d0b42e03cc3b890f7e4cc5faa44997d808193f" +dependencies = [ + "base64 0.21.7", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + [[package]] name = "lazy_static" version = "1.4.0" @@ -1015,12 +1038,41 @@ dependencies = [ "winapi", ] +[[package]] +name = "num-bigint" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "608e7659b5c3d7cba262d894801b9ec9d00de989e8a82bd4bef91d08da45cdc0" +dependencies = [ + "autocfg", + "num-integer", + "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-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0df0e5185db44f69b44f26786fe401b6c293d1907744beaa7fa62b2e5a517a" +dependencies = [ + "autocfg", +] + [[package]] name = "num_cpus" version = "1.16.0" @@ -1058,6 +1110,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" +[[package]] +name = "pem" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e459365e590736a54c3fa561947c84837534b8e9af6fc5bf781307e82658fae" +dependencies = [ + "base64 0.22.0", + "serde", +] + [[package]] name = "percent-encoding" version = "2.3.1" @@ -1201,7 +1263,7 @@ version = "0.1.0" dependencies = [ "anyhow", "axum", - "base64", + "base64 0.22.0", "blake3", "chacha20poly1305", "clap", @@ -1209,7 +1271,10 @@ dependencies = [ "ed25519", "ed25519-dalek", "enum_dispatch", + "jsonwebtoken", "rand", + "regex", + "ring", "serde", "serde_json", "serde_yaml", @@ -1264,6 +1329,21 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adad44e29e4c806119491a7f06f03de4d1af22c3a680dd47f1e6e179439d1f56" +[[package]] +name = "ring" +version = "0.17.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" +dependencies = [ + "cc", + "cfg-if", + "getrandom", + "libc", + "spin", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustc-demangle" version = "0.1.23" @@ -1392,6 +1472,18 @@ dependencies = [ "rand_core", ] +[[package]] +name = "simple_asn1" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc4e5204eb1910f40f9cfa375f6f05b68c3abac4b6fd879c8ff5e7ae8a0a085" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror", + "time", +] + [[package]] name = "slab" version = "0.4.9" @@ -1417,6 +1509,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + [[package]] name = "spki" version = "0.7.3" @@ -1479,6 +1577,26 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7065abeca94b6a8a577f9bd45aa0867a2238b74e8eb67cf10d492bc39351394" +[[package]] +name = "thiserror" +version = "1.0.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0126ad08bff79f29fc3ae6a55cc72352056dfff61e3ff8bb7129476d44b23aa" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1cd413b5d558b4c5bf3680e324a6fa5014e7b7c067a51e69dbdf47eb7148b66" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.60", +] + [[package]] name = "thread_local" version = "1.1.8" @@ -1496,10 +1614,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" dependencies = [ "deranged", + "itoa", "num-conv", "powerfmt", "serde", "time-core", + "time-macros", ] [[package]] @@ -1508,6 +1628,16 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" +[[package]] +name = "time-macros" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tokio" version = "1.37.0" @@ -1704,6 +1834,12 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "utf8parse" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 32369d3..b89ab91 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,10 @@ csv = "1.3.0" ed25519 = "2.2.3" ed25519-dalek = { version = "2.1.1", features = ["rand_core"] } enum_dispatch = "0.3.13" +jsonwebtoken = "9.3.0" rand = "0.8.5" +regex = "1.10.4" +ring = "0.17.8" serde = { version = "1.0.198", features = ["derive"] } serde_json = "1.0.116" serde_yaml = "0.9.34" diff --git a/deny.toml b/deny.toml index da00ab7..02cc9bf 100644 --- a/deny.toml +++ b/deny.toml @@ -94,6 +94,8 @@ allow = [ "Unicode-DFS-2016", "BSD-2-Clause", "BSD-3-Clause", + "ISC", + "OpenSSL" #"Apache-2.0 WITH LLVM-exception", ] # The confidence threshold for detecting a license from license text. @@ -112,20 +114,19 @@ exceptions = [ # Some crates don't have (easily) machine readable licensing information, # adding a clarification entry for it allows you to manually specify the # licensing information -#[[licenses.clarify]] +[[licenses.clarify]] # The package spec the clarification applies to -#crate = "ring" +crate = "ring" # The SPDX expression for the license requirements of the crate -#expression = "MIT AND ISC AND OpenSSL" +expression = "MIT AND ISC AND OpenSSL" # One or more files in the crate's source used as the "source of truth" for # the license expression. If the contents match, the clarification will be used # when running the license check, otherwise the clarification will be ignored # and the crate will be checked normally, which may produce warnings or errors # depending on the rest of your configuration -#license-files = [ -# Each entry is a crate relative path, and the (opaque) hash of its contents -#{ path = "LICENSE", hash = 0xbd0eed23 } -#] +license-files = [ +{ path = "LICENSE", hash = 0xbd0eed23 } +] [licenses.private] # If true, ignores workspace crates that aren't published, or are only diff --git a/fixtures/ed25519_pkcs8.pk b/fixtures/ed25519_pkcs8.pk new file mode 100644 index 0000000..47cda46 Binary files /dev/null and b/fixtures/ed25519_pkcs8.pk differ diff --git a/fixtures/ed25519_pkcs8.sk b/fixtures/ed25519_pkcs8.sk new file mode 100644 index 0000000..b94cbae Binary files /dev/null and b/fixtures/ed25519_pkcs8.sk differ diff --git a/src/cli/base64_opts.rs b/src/cli/base64_opts.rs index 0f3b1e9..826720f 100644 --- a/src/cli/base64_opts.rs +++ b/src/cli/base64_opts.rs @@ -1,5 +1,6 @@ use anyhow::Result; use clap::Parser; +use enum_dispatch::enum_dispatch; use std::{fmt::Display, str::FromStr}; use crate::{parse_input_file, process_b64decode, process_b64encode, CmdExcutor}; @@ -10,6 +11,7 @@ pub struct Base64Opts { } #[derive(Debug, Parser)] +#[enum_dispatch(CmdExcutor)] pub enum Base64SubCommand { #[command(name = "encode", about = "base64 encode")] Encode(Base64EncodeOpts), @@ -87,9 +89,6 @@ impl CmdExcutor for Base64DecodeOpts { impl CmdExcutor for Base64Opts { async fn execute(self) -> Result<()> { - match self.subcmd { - Base64SubCommand::Encode(opts) => opts.execute().await, - Base64SubCommand::Decode(opts) => opts.execute().await, - } + self.subcmd.execute().await } } diff --git a/src/cli/jwt.rs b/src/cli/jwt.rs new file mode 100644 index 0000000..4aba188 --- /dev/null +++ b/src/cli/jwt.rs @@ -0,0 +1,146 @@ +use std::str::FromStr; + +use anyhow::Result; +use clap::Parser; +use enum_dispatch::enum_dispatch; +use jsonwebtoken::{get_current_timestamp, Algorithm, Validation}; +use serde_json::json; +use std::collections::HashSet; + +use crate::{ + get_content, parse_duration, parse_input_file, process_jwt_sign, process_jwt_verify, CmdExcutor, +}; +#[derive(Debug, Parser)] +pub struct JwtOpts { + #[command(subcommand)] + pub subcmd: JwtSubCommand, +} + +#[derive(Debug, Parser)] +#[enum_dispatch(CmdExcutor)] +pub enum JwtSubCommand { + #[command(name = "sign", about = "jwt sign")] + Sign(JwtSignOpts), + #[command(name = "verify", about = "jwt verify")] + Verify(JwtVerifyOpts), +} + +#[derive(Debug, Parser)] +pub struct JwtSignOpts { + // jwt sign --sub acme --aud device1 --exp 14d --key key.pem + #[arg(long, default_value = "HS384", value_parser = Algorithm::from_str, help = "claims algorithm")] + pub alg: Algorithm, + #[arg(long, value_parser=parse_input_file, default_value="-", help = "sign key file path, or '-' for stdin")] + pub key: String, + // jwt claims + #[arg(long, help = "subject")] + pub sub: Option, + #[arg(long, help = "audience")] + pub aud: Option, + #[arg(long, help = "jwt issuer")] + pub iss: Option, + #[arg(long, default_value = "1d", help = "jwt expiration time", value_parser=parse_duration)] + pub exp: u64, + #[arg(long, default_value = "1d", help = "jwt nbf time", value_parser=parse_duration)] + pub nbf: Option, + #[arg(long, default_value_t = false, help = "generate jwt iat or not")] + pub iat: bool, +} + +#[derive(Debug, Parser)] +pub struct JwtVerifyOpts { + // token and alg verified key + #[arg(short, long, help = "jwt token", required = true, help = "jwt token")] + pub token: String, + #[arg(long, value_parser=parse_input_file, default_value="-", help = "key file path, or '-' for stdin")] + pub key: String, + + #[arg(long, value_delimiter = ',', help = "required claims")] + pub required_claims: Option>, + + #[arg(long, value_delimiter = ',', help = "audiences")] + pub aud: Option>, + #[arg(long, value_delimiter = ',', help = "issuers")] + pub iss: Option>, + #[arg(long, help = "jwt subject")] + pub sub: Option, + + #[arg(long, help = "validate expiration time")] + pub validate_exp: Option, + #[arg(long, help = "validate nbf time")] + pub validate_nbf: Option, + #[arg(long, help = "validate audience")] + pub validate_aud: Option, + #[arg( + long, + default_value_t = false, + help = "show token claims, if verified successfully" + )] + pub silent: bool, + #[arg(long, default_value_t = false, help = "show self options")] + pub show_self: bool, +} + +impl CmdExcutor for JwtSignOpts { + async fn execute(self) -> Result<()> { + let key = get_content(&self.key)?; + let iat = get_current_timestamp(); + let exp = iat + self.exp; + let nbf = self.nbf.map(|nbf| iat + nbf); + // claims init + let mut claims = json!({"exp": exp}); + for (k, v) in [("sub", self.sub), ("aud", self.aud), ("iss", self.iss)] { + if let Some(v) = v { + claims[k] = serde_json::Value::String(v); + } + } + if self.iat { + claims["iat"] = serde_json::Value::Number(iat.into()); + } + if let Some(nbf) = nbf { + claims["nbf"] = serde_json::Value::Number(nbf.into()); + } + // sign jwt + let token = process_jwt_sign(self.alg, &claims, key)?; + println!("{}", token); + Ok(()) + } +} + +impl CmdExcutor for JwtVerifyOpts { + async fn execute(self) -> Result<()> { + if self.show_self { + println!("{:?}", self); + } + let key = get_content(&self.key)?; + let mut validation = Validation::default(); + validation.required_spec_claims = self + .required_claims + .map_or(HashSet::from_iter(["exp".to_owned()]), HashSet::from_iter); + + validation.validate_aud = self.validate_aud.map_or(true, |v| v); + validation.validate_exp = self.validate_exp.map_or(true, |v| v); + validation.validate_nbf = self.validate_nbf.map_or(false, |v| v); + validation.iss = self.iss.map(HashSet::from_iter); + validation.aud = self.aud.map(HashSet::from_iter); + validation.sub = self.sub; + + process_jwt_verify(&self.token, key, validation) + .inspect(|token| { + println!("✓ jwt token verified"); + if !self.silent { + println!("{:?}", token) + } + }) + .inspect_err(|e| { + println!("⚠ Signature not verified, with Err: {}", e); + }) + .map(|_| ()) + } +} + +impl CmdExcutor for JwtOpts { + async fn execute(self) -> Result<()> { + self.subcmd.execute().await + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index b057209..e8abf59 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -2,17 +2,23 @@ mod base64_opts; mod csv_opts; mod genpass_opts; mod http_serve; +mod jwt; mod text; use anyhow::Result; use enum_dispatch::enum_dispatch; +use regex::Regex; use std::fs; use std::path::{Path, PathBuf}; +use std::time::Duration; -pub use self::base64_opts::{Base64Format, Base64Opts}; +pub use self::base64_opts::{ + Base64DecodeOpts, Base64EncodeOpts, Base64Format, Base64Opts, Base64SubCommand, +}; pub use self::csv_opts::{CsvOpts, OutputFormat}; pub use self::genpass_opts::GenpassOpts; pub use self::http_serve::{HttpOpts, HttpServeOpts, HttpSubCommand}; +pub use self::jwt::{JwtOpts, JwtSignOpts, JwtSubCommand, JwtVerifyOpts}; pub use self::text::{ TextDecryptOpts, TextEncryptOpts, TextKeyGenerateOpts, TextOpts, TextSignFormat, TextSignOpts, TextSubCommand, TextVerifyOpts, @@ -45,6 +51,9 @@ pub enum SubCommand { // rcli http serve . --port 8080 #[command(name = "http", about = "http server")] HttpServe(HttpOpts), + + #[command(name = "jwt", about = "jwt token sign/verify")] + Jwt(JwtOpts), } pub fn parse_input_file(path: &str) -> Result { @@ -64,6 +73,25 @@ pub fn verify_dir(path: &str) -> Result { } } +pub fn parse_duration(s: &str) -> Result { + let re = Regex::new(r"^\d+[smhd]$").unwrap(); + if re.is_match(s) { + let num = s[..s.len() - 1].parse::().unwrap(); + let unit = &s[s.len() - 1..]; + + let timestamp = match unit { + "s" => (Duration::from_secs(num as u64)).as_secs(), + "m" => (Duration::from_secs(num as u64 * 60)).as_secs(), + "h" => (Duration::from_secs(num as u64 * 60 * 60)).as_secs(), + "d" => (Duration::from_secs(num as u64 * 60 * 60 * 24)).as_secs(), + _ => unreachable!(), + }; + Ok(timestamp as u64) + } else { + Err("invalid duration") + } +} + #[cfg(test)] mod tests { use super::*; @@ -74,4 +102,13 @@ mod tests { assert_eq!(parse_input_file("*"), Err("file not found: *".to_string())); assert_eq!(parse_input_file("Cargo.toml"), Ok("Cargo.toml".to_string())); } + + #[test] + fn test_parse_duration() { + assert_eq!(parse_duration("1s"), Ok(1)); + assert_eq!(parse_duration("1m"), Ok(60)); + assert_eq!(parse_duration("1h"), Ok(60 * 60)); + assert_eq!(parse_duration("1d"), Ok(60 * 60 * 24)); + assert_eq!(parse_duration("1x"), Err("invalid duration")); + } } diff --git a/src/process/base64_processor.rs b/src/process/base64_processor.rs index 2bc97c4..922f54f 100644 --- a/src/process/base64_processor.rs +++ b/src/process/base64_processor.rs @@ -1,13 +1,10 @@ -use crate::{cli::Base64Format, utils::get_reader}; +use crate::{cli::Base64Format, utils::get_content}; use anyhow::Result; use base64::prelude::*; // base64 encoder, pub fn process_encode(input: &str, format: Base64Format) -> Result { - let mut reader = get_reader(input)?; - // 读取所有的数据 - let mut data = Vec::new(); - reader.read_to_end(&mut data)?; + let data = get_content(input)?; let encoded = match format { Base64Format::Standard => BASE64_STANDARD.encode(&data), Base64Format::UrlSafe => BASE64_URL_SAFE.encode(&data), @@ -17,11 +14,7 @@ pub fn process_encode(input: &str, format: Base64Format) -> Result { } pub fn process_decode(input: &str, format: Base64Format) -> Result { - let mut reader = get_reader(input)?; - // 读取所有的数据 - let mut data = String::new(); - reader.read_to_string(&mut data)?; - let data = data.trim_end(); + let data = get_content(input)?; let decode = match format { Base64Format::Standard => BASE64_STANDARD.decode(data)?, Base64Format::UrlSafe => BASE64_URL_SAFE.decode(data)?, diff --git a/src/process/jwt.rs b/src/process/jwt.rs new file mode 100644 index 0000000..5032f90 --- /dev/null +++ b/src/process/jwt.rs @@ -0,0 +1,77 @@ +use anyhow::Result; +use jsonwebtoken::{ + decode, decode_header, encode, Algorithm, DecodingKey, EncodingKey, Header, TokenData, + Validation, +}; + +pub fn process_sign( + alg: Algorithm, + claims: &serde_json::Value, + key: impl AsRef<[u8]>, +) -> Result { + let header = Header::new(alg); + let encodingkey = match alg { + Algorithm::HS256 | Algorithm::HS384 | Algorithm::HS512 => { + EncodingKey::from_secret(key.as_ref()) + } + Algorithm::RS256 + | Algorithm::RS384 + | Algorithm::RS512 + | Algorithm::PS256 + | Algorithm::PS384 + | Algorithm::PS512 => EncodingKey::from_rsa_der(key.as_ref()), + Algorithm::ES256 | Algorithm::ES384 => EncodingKey::from_ec_der(key.as_ref()), + Algorithm::EdDSA => EncodingKey::from_ed_der(key.as_ref()), + }; + encode(&header, &claims, &encodingkey).map_err(|e| e.into()) +} + +pub fn process_verify( + token: &str, + key: impl AsRef<[u8]>, + validation: Validation, +) -> Result> { + let header = decode_header(token)?; + let mut validation = validation; + validation.algorithms = vec![header.alg]; + let decoding_key = match header.alg { + Algorithm::HS256 | Algorithm::HS384 | Algorithm::HS512 => { + DecodingKey::from_secret(key.as_ref()) + } + Algorithm::RS256 + | Algorithm::RS384 + | Algorithm::RS512 + | Algorithm::PS256 + | Algorithm::PS384 + | Algorithm::PS512 => DecodingKey::from_rsa_der(key.as_ref()), + Algorithm::ES256 | Algorithm::ES384 => DecodingKey::from_ec_der(key.as_ref()), + Algorithm::EdDSA => DecodingKey::from_ed_der(key.as_ref()), + }; + decode(token, &decoding_key, &validation).map_err(|e| e.into()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + const PKCS8_SK: &[u8] = include_bytes!("../../fixtures/ed25519_pkcs8.sk"); + const PKCS8_PK: &[u8] = include_bytes!("../../fixtures/ed25519_pkcs8.pk"); + #[test] + fn test_jwt_eddsa() { + let claims = json!( + { + "exp": 1000, + "sub": "test_sub", + "aud": "test_aud" + } + ); + let mut validation = Validation::default(); + validation.validate_exp = false; + validation.set_audience(&["test_aud"]); + + let token = process_sign(Algorithm::EdDSA, &claims, PKCS8_SK).unwrap(); + let token_data = process_verify(&token, PKCS8_PK, validation).unwrap(); + assert_eq!(token_data.claims, claims); + } +} diff --git a/src/process/mod.rs b/src/process/mod.rs index 6989fe2..8556ed4 100644 --- a/src/process/mod.rs +++ b/src/process/mod.rs @@ -2,6 +2,7 @@ mod base64_processor; mod csv_processor; mod genpass_processor; mod http_serve; +mod jwt; mod text; pub use base64_processor::process_decode as process_b64decode; @@ -9,4 +10,5 @@ pub use base64_processor::process_encode as process_b64encode; pub use csv_processor::process as process_csv; pub use genpass_processor::process as process_genpass; pub use http_serve::process_http_serve; +pub use jwt::{process_sign as process_jwt_sign, process_verify as process_jwt_verify}; pub use text::{process_decrypt, process_encrypt, process_generate, process_sign, process_verify}; diff --git a/src/process/text.rs b/src/process/text.rs index 409593f..38d2e73 100644 --- a/src/process/text.rs +++ b/src/process/text.rs @@ -128,7 +128,8 @@ impl Ed25519Verifier { pub fn try_new(key: impl AsRef<[u8]>) -> Result { let key = key.as_ref(); VerifyingKey::from_bytes(key.try_into()?) - .map_or_else(|_| Err(anyhow!("invalid key")), |key| Ok(Self::new(key))) + .map(Self::new) + .map_err(|e| e.into()) } }