Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
632 changes: 530 additions & 102 deletions Cargo.lock

Large diffs are not rendered by default.

9 changes: 7 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ repository = "https://github.com/lmammino/jwtinfo"
homepage = "https://github.com/lmammino/jwtinfo"
documentation = "https://github.com/lmammino/jwtinfo"
readme = "README.md"
version = "0.6.1"
version = "0.7.0"
authors = ["Luciano Mammino", "Stefano Abalsamo"]
edition = "2018"
license = "MIT"
Expand All @@ -26,9 +26,14 @@ eula = false

[dependencies]
clap = "4.4.7"
base64 = "0.21"
base64 = "0.22"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
rsa = "0.9.9"
sha2 = "0.10.9"
aes-gcm = "0.10.3"
sha1 = "0.10.6"
thiserror = "2.0"

[dev-dependencies]
assert_cmd = "2.0"
Expand Down
21 changes: 19 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ A command line tool to get information about
- **Multiple display modes**: view body only (default), header only (`--header`), or both (`--full`)
- **Pretty printing** with `--pretty` flag for readable JSON output
- **Stdin support** - pipe tokens directly or use as command argument
- **JWE token detection** - gracefully handles encrypted JWT tokens with clear messaging
- **JWE decryption** - decrypt encrypted JWTs with `--key` (supports `dir`, `RSA-OAEP`, `RSA-OAEP-256` + `A128GCM`/`A256GCM`)
- **Composable** - works seamlessly with tools like `jq` for advanced JSON processing

### Rust Library
Expand Down Expand Up @@ -103,8 +103,25 @@ You can combine the tool with other command line utilities, for instance
jwtinfo eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c | jq .
```

### JWE decryption

If the token is encrypted (JWE), and you own the decryption key, you can see the decrypted payload by providing the key file:

```bash
jwtinfo --key /path/to/private.pem "$(cat /path/to/jwe.txt)"
```

Supported algorithms:

- **Key management (`alg`)**: `dir`, `RSA-OAEP`, `RSA-OAEP-256`
- **Content encryption (`enc`)**: `A128GCM`, `A256GCM`

For `dir`, the key file must contain the raw content-encryption key (CEK) bytes.
For RSA-based algorithms, the key file must be a PEM-encoded private key in PKCS#1 or PKCS#8 format.
At the moment only `.pem` keys are supported; additional formats will be added in the future.

> [!NOTE]
> **Encrypted [JWE](https://datatracker.ietf.org/doc/html/rfc7516) Tokens**: If you provide an encrypted JWE token (JSON Web Encryption), the tool will detect it by checking for the `enc` field in the header. Since JWE tokens are encrypted, the claims/body cannot be read without decryption. In this case, `jwtinfo` will display the special placeholder string `"<encrypted JWE body>"` instead of the actual claims. The header can still be inspected normally using the `--header` flag.
> **Encrypted [JWE](https://datatracker.ietf.org/doc/html/rfc7516) Tokens**: If you provide an encrypted JWE token without a key, `jwtinfo` will show a placeholder message indicating that a private key is required. Use `-K/--key` to decrypt it. The header can still be inspected normally using the `--header` flag.

## Install

Expand Down
76 changes: 36 additions & 40 deletions src/cli.rs
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the changes proposed here come with some inconsistencies that might be nice to address. Here's a list of examples that showcase how using certain combinations of tokens and flags can lead to confusing or unexpected results for the user:

Scenario 1: JWE with plaintext payload "works"...

... but the flow is probably not what we want (also considering a previous comment about swallowing a possible error):

cargo run -- --key src/jwe/examples/example_priv.pem "$(cat src/jwe/examples/example_token.txt)"

Output:

Questo e' un messaggio super segreto!

Exit code: 0

This looks correct but the control flow is:

  • decrypt JWE
  • get plaintext string
  • jwt::parse() fails because it's not a JWT
  • error is silently discarded
  • raw string is printed.

It works only because of the error-swallowing Err(_) => println!("{}", token) discussed before.

Scenario 2: Display flags (--pretty, --full, --header) are silently ignored for non-JWT payloads

Already hinted at this possible problem in a previous comment, but here are a few practical examples that illustrate the discrepancies:

cargo run -- --pretty --key src/jwe/examples/example_priv.pem "$(cat src/jwe/examples/example_token.txt)"
cargo run -- --full   --key src/jwe/examples/example_priv.pem "$(cat src/jwe/examples/example_token.txt)"
cargo run -- --header --key src/jwe/examples/example_priv.pem "$(cat src/jwe/examples/example_token.txt)"

All three produce the exact same output: (Questo e' un messaggio super segreto! (exit 0)). The --pretty, --full, and --header flags are completely ignored because they only apply inside the Ok branch, but we always land in the Err branch for non-JWT payloads.

Scenario 3: Regular JWT with --key gives a cryptic error

cargo run -- --key src/jwe/examples/example_priv.pem "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIifQ.dtxWM6MIcgoeMgH87tGvsNDY6cHWL6MGW4LeYvnm1JA"

Output:

Error: ParseError(MissingParts)

Exit code: 1

The user gets an opaque JWE parsing error instead of something helpful like "This is a JWT, not a JWE. The --key flag is only applicable to JWE tokens."

Perhaps this is something we can address with a more generic parser that can automatically distinguish between JWS (what we call JWT here) and JWE and give us the result in some kind of JWToken enum (that can be either JWS or JWT). This would allow us to then check if specific options apply (given the detected token type) and probably help us to come up with more generic ways to address the flags discrepancies mentioned above.

Scenario 4: Invalid input succeeds silently (the point 1 regression)

cargo run -- "not-a-token-at-all"

Output:

not-a-token-at-all

Exit code: 0 (wrong!)

Previously this printed an error to stderr and exited with code 1. Now it echoes back the garbage input as if it were valid, with a success exit code. Any script using jwtinfo in a pipeline can no longer detect failures.

Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
pub mod jw_error;
pub mod jw_parser;
pub mod jwe;
pub mod jwt;
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate module tree between the binary and the library crate

Both src/cli.rs (the binary) and src/main.rs (the library) declare the same module tree:

  pub mod jw_error;
  pub mod jw_parser;
  pub mod jwe;
  pub mod jwt;

Rust compiles these as separate crates, this means every module (jw_error, jw_parser, jwe, jwt) is compiled twice into two completely independent copies. The binary no longer imports anything from the library crate... it was previously doing use jwtinfo::jwt which is a better approach here...

Suggested change
pub mod jwt;

Suggestion: remove the mod declarations here


use crate::jwe::handle_jwe;
use crate::jwt::stringify_token;
Comment on lines +6 to +7
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Based on the previous comment, you should now import directly from the lib with:

Suggested change
use crate::jwe::handle_jwe;
use crate::jwt::stringify_token;
use jwtinfo::{
jwe::handle_jwe,
jwt::{self, stringify_token},
};

use clap::{Arg, ArgAction, Command};
use jwtinfo::jwt;
use serde_json::to_string_pretty;
use std::io::{self, Read};
use std::process;
use std::{
error::Error,
fs,
io::{self, Read},
};

#[doc(hidden)]
fn main() -> io::Result<()> {
fn main() -> Result<(), Box<dyn Error>> {
let matches = Command::new("jwtinfo")
.version(env!("CARGO_PKG_VERSION"))
.about("Shows information about a JWT (Json Web Token)")
Expand All @@ -31,12 +39,17 @@ fn main() -> io::Result<()> {
.index(1)
.allow_hyphen_values(true)
.required(true)
.help("the JWT as a string (use \"-\" to read from stdin)"),
.help("the JWT/JWE as a string (use \"-\" to read from stdin)"),
Arg::new("key")
.short('K')
.long("key")
.help("the path to the private key for the cek decryption in case of JWE"),
])
.get_matches();

let full_flag = matches.get_flag("full");
let should_pretty_print = matches.get_flag("pretty");

let header_flag = matches.get_flag("header");
let mut token = matches.get_one::<String>("token").unwrap().clone();
let mut buffer = String::new();

Expand All @@ -46,40 +59,23 @@ fn main() -> io::Result<()> {
token = (*buffer.trim()).to_string();
}

let jwt_token = match jwt::parse(token) {
Ok(t) => t,
Err(e) => {
eprintln!("Error: {}", e);
process::exit(1);
}
};
// if there is a key must be a JWE
if let Some(key_path) = matches.get_one::<String>("key") {
let key = fs::read(key_path)?;
// handle_jwe returns the JWE payload, which could be a UTF-8 string or a
// JWT to decode (currently we don't handle payload as byte arrays)
token = handle_jwe(token, key)?;
}

let stringified = if matches.get_flag("full") {
// Show both header and claims
let full_output = serde_json::json!({
"header": jwt_token.header,
"claims": jwt_token.body
});
if should_pretty_print {
to_string_pretty(&full_output)?
} else {
full_output.to_string()
// if the token is a JWT, jwt::parse will handle it correctly, otherwise
// the raw string will be printed
match jwt::parse(&token) {
Ok(jwt_token) => {
let stringified =
stringify_token(jwt_token, full_flag, should_pretty_print, header_flag)?;
println!("{}", stringified);
}
} else {
// Show either header or body
let part = if matches.get_flag("header") {
jwt_token.header
} else {
jwt_token.body
};
if should_pretty_print {
to_string_pretty(&part)?
} else {
part.to_string()
}
};

println!("{}", stringified);

Err(_) => println!("{}", token),
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think here we are swallowing a possible error. Not sure this is the best option...

We should probably print the error to stderr and exist with an error status code

}
Ok(())
}
98 changes: 98 additions & 0 deletions src/jw_error.rs
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking that there was something a bit off here and I did a couple of AI reviews with a few different models. I really liked this suggestion which I think is worth considering:

Error type inconsistencies

  • JweError::StringError(String) is a catch-all that loses type information. It's used as a From<String> impl, which means any String can become a JweError... this is overly broad.
  • JweError::Internal(Box<dyn Error + Send + Sync>) is another opaque catch-all.
  • JWTParseError no longer implements Error (only Display via thiserror), but JWTParsePartError also only derives Error through thiserror without being explicitly listed. This works but the old impl Error for JWTParsePartError {} was removed without verifying all downstream consumers. (note: this one is my fault when I quickly added ThisError 😅 )
  • MissingSection() and MissingParts() use empty tuple variant syntax () when unit variants would be cleaner.

Suggested approach to address these issues

The root cause is a two-layer error design that doesn't quite fit together. The crypto internals (algorithms.rs) use CryptoResult<T> = Result<T, Box<dyn Error + Send + Sync>>, producing opaque boxed errors. Then JweError needs to accept those, so it has StringError(String) and Internal(Box<dyn Error>) as catch-all buckets, plus From<String> and From<Box<dyn Error>> blanket conversions. The result is that most error context is erased by the time it reaches the caller.

I'd suggest one of two approaches:

Option A: Typed errors all the way down (cleaner, more work)

Replace CryptoResult with a dedicated JweCryptoError enum that covers the actual failure modes:

  #[derive(Debug, Error)]
  pub enum JweCryptoError {
      #[error("CEK length mismatch: expected {expected} bytes, got {actual}")]
      CekLengthMismatch { expected: usize, actual: usize },
      #[error("IV length invalid: expected 12 bytes")]
      InvalidIvLength,
      #[error("Unsupported key length: {0}")]
      UnsupportedKeyLength(usize),
      #[error("Decryption failed: {0}")]
      DecryptionFailed(String),
      #[error("Invalid RSA key: {0}")]
      InvalidRsaKey(String),
      #[error("Unsupported algorithm: {0}")]
      UnsupportedAlgorithm(String),
  }

Then have the traits return Result<Vec<u8>, JweCryptoError>, and add a JweError::Crypto(#[from] JweCryptoError) variant. This removes StringError, Internal, and both From blanket impls entirely.

Option B: Just use Box<dyn Error> in JweError (pragmatic, less work)

If you want to keep the crypto layer using Box<dyn Error + Send + Sync> for flexibility, then simplify JweError to have a single opaque variant instead of two:

  #[derive(Debug, Error)]
  pub enum JweError {
      #[error("{0}")]
      Parse(#[from] JweParseError),
      #[error("JSON error: {0}")]
      Json(#[from] serde_json::Error),
      #[error("Invalid UTF-8: {0}")]
      InvalidUtf8(#[from] string::FromUtf8Error),
      #[error("{0}")]
      Crypto(Box<dyn Error + Send + Sync + 'static>),
  }

Then replace the From<String> and From<Box<dyn Error>> impls with a single explicit conversion:

  impl From<Box<dyn Error + Send + Sync + 'static>> for JweError {
      fn from(e: Box<dyn Error + Send + Sync + 'static>) -> Self {
          JweError::Crypto(e)
      }
  }

This removes StringError and the From<String> impl (which is the most dangerous one. Any stray String silently becoming a JweError is a footgun). The AlgorithmFactory::get_key_decryptor / get_content_decryptor methods that currently return Result<_, String> would need to return Result<_, JweError> or Result<_, Box<dyn Error + Send + Sync>> instead.

Two smaller things either approach should also fix:

  • MissingSection(), MissingParts(), TooManyParts(), UnexpectedPart(): drop the empty parens and use unit variants (MissingSection, MissingParts, etc.)
  • #[error("not serialized error")] on JsonError: this is a misleading hardcoded message. It should be #[error("JSON error: {0}")] like the JWT version.

Recommendation

I'd recommend Option A if this is heading toward a proper release — the failure modes in the crypto layer are well-known and finite, so a typed enum is worth it. Option B is fine if you just want to clean up the obvious issues quickly.

Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
use std::{error::Error, str, string};
use thiserror::Error;

#[derive(Debug, Error)]
pub enum ParseError {
/// Indicates that a given section was not correctly Base64-encoded
#[error("Base64 error, {0}")]
InvalidBase64(#[from] base64::DecodeError),
/// Indicates that a section did not contain a valid utf8 string
#[error("UTF8 error, {0}")]
InvalidStrUtf8(#[from] str::Utf8Error),
#[error("UTF8 error, {0}")]
InvalidStringUtf8(#[from] string::FromUtf8Error),
}

/// Represents an error while parsing a JWT
#[derive(Debug, Error)]
pub enum JWTParseError {
/// Indicates that an expected section (Header, Body or Signature) was not found
#[error("Missing token section")]
MissingSection(),
#[error("{0}")]
InvalidFormat(#[from] ParseError),
/// Indicates that a given section did not contain a valid JSON string
#[error("JSON error, {0}")]
InvalidJSON(#[from] serde_json::error::Error),
}

impl From<base64::DecodeError> for JWTParseError {
fn from(err: base64::DecodeError) -> JWTParseError {
JWTParseError::InvalidFormat(ParseError::InvalidBase64(err))
}
}

/// Represents an error while parsing a given part of a JWT
#[derive(Debug, Error)]
pub enum JWTParsePartError {
/// Error while parsing the Header part
#[error("Invalid Header: {0}")]
Header(JWTParseError),
/// Error while parsing the Body part
#[error("Invalid Body: {0}")]
Body(JWTParseError),
/// Error while parsing the Signature part
#[error("Invalid Signature: {0}")]
Signature(JWTParseError),
/// Error because an additional part was found after the Signature part
#[error("Error: Unexpected fragment after signature")]
UnexpectedPart(),
}

#[derive(Debug, Error)]
pub enum JweParseError {
#[error("Missing JWE section")]
MissingParts(),
#[error("Unexpected section")]
TooManyParts(),
#[error("{0}")]
InvalidFormat(#[from] ParseError),
}

impl From<base64::DecodeError> for JweParseError {
fn from(err: base64::DecodeError) -> JweParseError {
JweParseError::InvalidFormat(ParseError::InvalidBase64(err))
}
}

#[derive(Debug, Error)]
pub enum JweError {
#[error("{0}")]
ParseError(#[from] JweParseError),
#[error("{0}")]
StringError(String),
#[error("{0}")]
Internal(Box<dyn Error + Send + Sync + 'static>),
#[error("not serialized error")]
JsonError(#[from] serde_json::Error),
#[error("Invalid UTF-8 string: {0}")]
InvalidUtf8Error(#[from] string::FromUtf8Error),
}

impl From<String> for JweError {
fn from(e: String) -> Self {
JweError::StringError(e)
}
}

impl From<Box<dyn Error + Send + Sync + 'static>> for JweError {
fn from(e: Box<dyn Error + Send + Sync + 'static>) -> Self {
JweError::Internal(e)
}
}

impl From<ParseError> for JweError {
fn from(e: ParseError) -> Self {
JweError::ParseError(JweParseError::InvalidFormat(e))
}
}
61 changes: 61 additions & 0 deletions src/jw_parser.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
use base64::{
alphabet,
engine::{self, general_purpose},
Engine as _,
};
use std::{convert::TryInto, sync::OnceLock};
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor: I think we can get rid of TryInto since it should be auto-imported in recent editions of Rust. Worth double checking


use crate::jw_error::JweParseError;
use crate::jw_error::ParseError;
use crate::jwe::jwe_handler::JweToken;

static BASE64_ENGINE: OnceLock<engine::GeneralPurpose> = OnceLock::new();

#[inline]
pub fn get_base64() -> &'static engine::GeneralPurpose {
BASE64_ENGINE
.get_or_init(|| engine::GeneralPurpose::new(&alphabet::URL_SAFE, general_purpose::NO_PAD))
}

#[doc(hidden)]
fn parse_base64_string(string_to_parse: &str) -> Result<String, ParseError> {
let bytes = get_base64().decode(string_to_parse)?;
let string = String::from_utf8(bytes)?;
Ok(string)
}
Comment on lines +20 to +25
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't this the same as the one we already have in the jwt module? Does it make sense to have only one in a shared place?


pub fn split_jwe(token: &str) -> Result<[&str; 5], JweParseError> {
token
.split('.')
.collect::<Vec<&str>>()
.try_into()
.map_err(|vec: Vec<&str>| {
if vec.len() < 5 {
JweParseError::MissingParts()
} else {
JweParseError::TooManyParts()
}
})
}

pub fn parse_jwe(token: &str) -> Result<JweToken, JweParseError> {
let [b64_header, b64_key, b64_iv, b64_cipher, b64_tag] = split_jwe(token)?;

let decode = |s: &str| get_base64().decode(s);

let aad = b64_header.as_bytes().to_vec();
let header = parse_base64_string(b64_header)?;
let key_encrypted = decode(b64_key)?;
let iv = decode(b64_iv)?;
let ciphertext = decode(b64_cipher)?;
let tag = decode(b64_tag)?;

Ok(JweToken::new(
header,
aad,
key_encrypted,
iv,
ciphertext,
tag,
))
}
21 changes: 21 additions & 0 deletions src/jwe.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
pub mod jwe_handler;

use crate::jwe::jwe_handler::{AlgorithmFactory, JweHeader};

use crate::jw_error::JweError;
use crate::jw_parser::parse_jwe;

pub fn handle_jwe(token: String, key: Vec<u8>) -> Result<String, JweError> {
let jwe_token = parse_jwe(token.as_str())?;
let jwe_header: JweHeader = serde_json::from_str(&jwe_token.header)?;
let key_decryptor = AlgorithmFactory::get_key_decryptor(jwe_header.alg.as_str())?;
let key_decrypted = key_decryptor.decrypt_cek(&key, &jwe_token.key_encrypted)?;
let content_decryptor = AlgorithmFactory::get_content_decryptor(jwe_header.enc.as_str())?;
let cipher = jwe_token.decrypt_content(&*content_decryptor, &key_decrypted)?;
let payload_string = String::from_utf8(cipher)?;

Ok(payload_string)
}

#[cfg(test)]
mod test;
4 changes: 4 additions & 0 deletions src/jwe/jwe_handler.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
mod algorithms;
mod jwe_token;
pub use algorithms::AlgorithmFactory;
pub use jwe_token::{JweHeader, JweToken};
Loading