diff --git a/CHANGELOG.md b/CHANGELOG.md index c727659..8ef87cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Unreleased +#### New features + +- Add support for NATS nkeys (Ed25519) for EdDSA JWT encoding and decoding + # 6.2.0 > 2024-11-27 diff --git a/Cargo.lock b/Cargo.lock index 9301ec6..ce6a668 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -97,12 +97,27 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" + [[package]] name = "bitflags" version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.11.1" @@ -230,7 +245,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.10", + "syn 2.0.106", ] [[package]] @@ -274,12 +289,64 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "core-foundation-sys" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5827cebf4670468b8772dd191856768aedcb1b0278a04f989f7766351917b9dc" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "cxx" version = "1.0.80" @@ -324,12 +391,80 @@ dependencies = [ "syn 1.0.103", ] +[[package]] +name = "data-encoding" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "signature", + "subtle", + "zeroize", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "fuchsia-cprng" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a06f77d526c1a601b7c4cdd98f54b5eaabffc14d5f2f0296febdc7f357c6d3ba" +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.12" @@ -433,13 +568,16 @@ name = "jwt-cli" version = "6.2.0" dependencies = [ "atty", + "base64 0.21.7", "bunt", "chrono", "clap 4.5.48", "clap_complete 4.5.57", "clap_complete_nushell", "clap_generate", + "ed25519-dalek", "jsonwebtoken", + "nkeys", "parse_duration", "serde", "serde_derive", @@ -455,9 +593,9 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" [[package]] name = "libc" -version = "0.2.152" +version = "0.2.177" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13e3bf6590cbc649f4d1a3eefc9d5d6eb746f5200ffb04e5e142700b8faa56e7" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" [[package]] name = "link-cplusplus" @@ -492,6 +630,21 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" +[[package]] +name = "nkeys" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879011babc47a1c7fdf5a935ae3cfe94f34645ca0cac1c7f6424b36fc743d1bf" +dependencies = [ + "data-encoding", + "ed25519", + "ed25519-dalek", + "getrandom", + "log", + "rand 0.8.5", + "signatory", +] + [[package]] name = "num" version = "0.2.1" @@ -613,20 +766,48 @@ dependencies = [ "serde", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro2" -version = "1.0.78" +version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.26" +version = "1.0.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4424af4bf778aae2051a77b60283332f386554255d722233d09fbfc7e30da2fc" +checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" dependencies = [ "proc-macro2", ] @@ -644,6 +825,27 @@ dependencies = [ "winapi", ] +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + [[package]] name = "rand_core" version = "0.3.1" @@ -659,6 +861,15 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c33a3c44ca05fa6f1807d8e6743f3824e8509beca625669633be0acbdf509dc" +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + [[package]] name = "rdrand" version = "0.4.0" @@ -708,6 +919,15 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "ryu" version = "1.0.11" @@ -720,6 +940,12 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8132065adcfd6e02db789d9285a0deb2f3fcb04002865ab67d5fb103533898" +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + [[package]] name = "serde" version = "1.0.147" @@ -751,6 +977,39 @@ dependencies = [ "serde", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "signatory" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1e303f8205714074f6068773f0e29527e0453937fe837c9717d066635b65f31" +dependencies = [ + "pkcs8", + "rand_core 0.6.4", + "signature", + "zeroize", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + [[package]] name = "simple_asn1" version = "0.6.2" @@ -769,12 +1028,28 @@ version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "strsim" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ee073c9e4cd00e28217186dbe12796d692868f432bf2e97ee73bed0c56dfa01" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "1.0.103" @@ -788,9 +1063,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.10" +version = "2.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5aad1363ed6d37b84299588d62d3a7d95b5a5c2d9aad5c85609fda12afaa1f40" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" dependencies = [ "proc-macro2", "quote", @@ -803,7 +1078,7 @@ version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "15f2b5fb00ccdf689e0149d1b1b3c03fead81c2b37735d812fa8bddbbf41b6d8" dependencies = [ - "rand", + "rand 0.4.6", "remove_dir_all", ] @@ -869,6 +1144,12 @@ dependencies = [ "time-core", ] +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + [[package]] name = "unicode-ident" version = "1.0.5" @@ -899,6 +1180,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" @@ -1061,3 +1348,29 @@ name = "windows_x86_64_msvc" version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a" + +[[package]] +name = "zerocopy" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" diff --git a/Cargo.toml b/Cargo.toml index 8f1b4eb..057b097 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,9 @@ atty = "0.2" clap_generate = "3.0.3" clap_complete = "4.5.57" clap_complete_nushell = "4.5" +nkeys = "0.4" +ed25519-dalek = { version = "2.2", features = ["pkcs8"] } +base64 = "0.21" [dev-dependencies] tempdir = "0.3.7" diff --git a/README.md b/README.md index 2235e11..51a543f 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,26 @@ curl | jq -r .access_token | jwt decode - Currently the underlying token encoding and decoding library, [`jsonwebtoken`](https://github.com/Keats/jsonwebtoken), doesn't support the SEC1 private key format and requires a conversion to the PKCS8 type. You can read more from [their own README](https://github.com/Keats/jsonwebtoken/blob/8fba79b25459eacc33a80e1ee37ff8eba64079ca/README.md#convert-sec1-private-key-to-pkcs8). +## Using NATS nkeys + +`jwt-cli` supports NATS nkeys (Ed25519 keys in NATS-specific format) for EdDSA JWTs. Nkeys can be provided as strings or files: + +```sh +# Encode with nkey seed (starts with S for user/account seeds, P for private keys) +jwt encode --alg EdDSA --secret SUAJUNSMQK7AHNZ5HRGV5UI2A24O2DDSWVWIOWP6CVBVBW652GDCM54JNY '{"sub":"user123"}' + +# Encode using nkey from file +jwt encode --alg EdDSA --secret @./user.nk '{"sub":"user123"}' + +# Decode and verify with nkey seed +jwt decode --alg EdDSA --secret SUAJUNSMQK7AHNZ5HRGV5UI2A24O2DDSWVWIOWP6CVBVBW652GDCM54JNY + +# Decode and verify with nkey public key (starts with A, U, O, N, C, M, V, X) +jwt decode --alg EdDSA --secret UBXGBSBR3U4IK6NCKOTND74FYER3BCVCXIB7IYUBDEPYOD6UGRTIBJAV +``` + +The tool automatically detects nkey format based on the prefix character and handles the conversion to Ed25519 keys internally. NATS JWTs that use the non-standard `ed25519-nkey` algorithm are automatically normalized to the standard `EdDSA` algorithm during decoding. + ## Shell completion `jwt-cli` supports shell completion for `bash`, `elvish`, `fish`, `powershell`, and `zsh`. To enable it, run the following command: diff --git a/src/translators/decode.rs b/src/translators/decode.rs index 65acfbe..63742af 100644 --- a/src/translators/decode.rs +++ b/src/translators/decode.rs @@ -4,10 +4,12 @@ use crate::utils::{ decoding_key_from_jwks_secret, get_secret_from_file_or_input, write_file, JWTError, JWTResult, SecretType, }; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +use ed25519_dalek::pkcs8::EncodePublicKey; use jsonwebtoken::errors::ErrorKind; use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Header, TokenData, Validation}; use serde_derive::{Deserialize, Serialize}; -use serde_json::to_string_pretty; +use serde_json::{to_string_pretty, Value}; use std::collections::HashSet; use std::io; use std::path::PathBuf; @@ -34,6 +36,35 @@ impl TokenOutput { } } +/// Normalize NATS JWTs that use "ed25519-nkey" algorithm to "EdDSA" +fn normalize_nats_jwt(jwt: &str) -> String { + let parts: Vec<&str> = jwt.split('.').collect(); + if parts.len() != 3 { + return jwt.to_string(); + } + + let Ok(header_bytes) = URL_SAFE_NO_PAD.decode(parts[0]) else { + return jwt.to_string(); + }; + + let Ok(mut header_json) = serde_json::from_slice::(&header_bytes) else { + return jwt.to_string(); + }; + + if header_json.get("alg").and_then(|v| v.as_str()) != Some("ed25519-nkey") { + return jwt.to_string(); + } + + header_json["alg"] = Value::String("EdDSA".to_string()); + + let Ok(new_header_json) = serde_json::to_vec(&header_json) else { + return jwt.to_string(); + }; + + let new_header_b64 = URL_SAFE_NO_PAD.encode(&new_header_json); + format!("{}.{}.{}", new_header_b64, parts[1], parts[2]) +} + pub fn decoding_key_from_secret( alg: &Algorithm, secret_string: &str, @@ -81,6 +112,36 @@ pub fn decoding_key_from_secret( } SecretType::Der => Ok(DecodingKey::from_ed_der(&secret)), SecretType::Jwks => decoding_key_from_jwks_secret(&secret, header), + SecretType::Nkey => { + let secret_str = from_utf8(&secret)?; + let trimmed = secret_str.trim(); + + use ed25519_dalek::{SigningKey, VerifyingKey}; + + let verifying_key = if trimmed.starts_with('S') || trimmed.starts_with('P') { + let seed_bytes = crate::utils::nkey_to_ed25519_seed(trimmed)?; + let signing_key = + SigningKey::from_bytes(&seed_bytes.try_into().map_err(|_| { + JWTError::Internal("Invalid seed length for Ed25519".to_string()) + })?); + signing_key.verifying_key() + } else { + let public_bytes = crate::utils::nkey_to_ed25519_public(trimmed)?; + VerifyingKey::from_bytes(&public_bytes.try_into().map_err(|_| { + JWTError::Internal("Invalid public key length for Ed25519".to_string()) + })?) + .map_err(|e| JWTError::Internal(format!("Invalid Ed25519 public key: {}", e)))? + }; + + let spki_pem = verifying_key + .to_public_key_pem(Default::default()) + .map_err(|e| { + JWTError::Internal(format!("Failed to convert to SPKI PEM: {}", e)) + })?; + + DecodingKey::from_ed_pem(spki_pem.as_bytes()) + .map_err(jsonwebtoken::errors::Error::into) + } _ => Err(JWTError::Internal(format!( "Invalid secret file type for {alg:?}" ))), @@ -110,6 +171,7 @@ pub fn decode_token( .trim() .to_owned(); + let jwt = normalize_nats_jwt(&jwt); let header = decode_header(&jwt).ok(); let algorithm = if arguments.algorithm.is_some() { diff --git a/src/translators/encode.rs b/src/translators/encode.rs index bf934d9..7589a60 100644 --- a/src/translators/encode.rs +++ b/src/translators/encode.rs @@ -3,6 +3,7 @@ use crate::translators::{Claims, Payload, PayloadItem}; use crate::utils::{get_secret_from_file_or_input, write_file, JWTError, JWTResult, SecretType}; use atty::Stream; use chrono::Utc; +use ed25519_dalek::pkcs8::EncodePrivateKey; use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; use serde_json::{from_str, Value}; use std::io; @@ -60,6 +61,22 @@ pub fn encoding_key_from_secret(alg: &Algorithm, secret_string: &str) -> JWTResu EncodingKey::from_ed_pem(&secret).map_err(jsonwebtoken::errors::Error::into) } SecretType::Der => Ok(EncodingKey::from_ed_der(&secret)), + SecretType::Nkey => { + let secret_str = std::str::from_utf8(&secret)?; + let seed_bytes = crate::utils::nkey_to_ed25519_seed(secret_str.trim())?; + + use ed25519_dalek::SigningKey; + let signing_key = SigningKey::from_bytes(&seed_bytes.try_into().map_err(|_| { + JWTError::Internal("Invalid seed length for Ed25519".to_string()) + })?); + + let pkcs8_pem = signing_key.to_pkcs8_pem(Default::default()).map_err(|e| { + JWTError::Internal(format!("Failed to convert to PKCS#8 PEM: {}", e)) + })?; + + EncodingKey::from_ed_pem(pkcs8_pem.as_bytes()) + .map_err(jsonwebtoken::errors::Error::into) + } _ => Err(JWTError::Internal(format!( "Invalid secret file type for {alg:?}" ))), diff --git a/src/utils.rs b/src/utils.rs index bb136c0..dc7bff1 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -76,6 +76,7 @@ pub enum SecretType { Jwks, B64, Plain, + Nkey, } pub fn get_secret_from_file_or_input( @@ -107,6 +108,57 @@ pub fn get_secret_from_file_or_input( (secret_string.as_bytes().to_owned(), SecretType::Plain) } } + Algorithm::EdDSA => { + if secret_string.starts_with('@') { + let content = slurp_file(strip_leading_symbol(secret_string)); + let content_str = std::str::from_utf8(&content).unwrap_or(""); + let trimmed = content_str.trim(); + + // Check if file contains nkey format (starts with S or P for seeds/private keys, + // or single letter for public keys: A, U, O, N, C, M, V, X) + if trimmed.starts_with('S') + || trimmed.starts_with('P') + || (trimmed.len() > 0 + && trimmed.len() < 100 + && (trimmed.starts_with('A') + || trimmed.starts_with('U') + || trimmed.starts_with('O') + || trimmed.starts_with('N') + || trimmed.starts_with('C') + || trimmed.starts_with('M') + || trimmed.starts_with('V') + || trimmed.starts_with('X'))) + { + (content, SecretType::Nkey) + } else { + (content, get_secret_file_type(secret_string)) + } + } else if secret_string.starts_with('S') || secret_string.starts_with('P') { + // Direct nkey string (seed or private key) + (secret_string.as_bytes().to_vec(), SecretType::Nkey) + } else if secret_string.len() > 0 + && secret_string.len() < 100 + && (secret_string.starts_with('A') + || secret_string.starts_with('U') + || secret_string.starts_with('O') + || secret_string.starts_with('N') + || secret_string.starts_with('C') + || secret_string.starts_with('M') + || secret_string.starts_with('V') + || secret_string.starts_with('X')) + { + // Public key format (single letter prefix) + (secret_string.as_bytes().to_vec(), SecretType::Nkey) + } else if secret_string.starts_with('@') { + ( + slurp_file(strip_leading_symbol(secret_string)), + get_secret_file_type(secret_string), + ) + } else { + // Fall back to JWKS for other formats + (secret_string.as_bytes().to_vec(), SecretType::Jwks) + } + } _ => { if secret_string.starts_with('@') { ( @@ -174,6 +226,50 @@ fn parse_jwks(secret: &[u8]) -> Option { serde_json::from_slice(secret).ok() } +/// Converts an nkey seed or private key to raw Ed25519 seed bytes (32 bytes) +/// for use with EncodingKey +pub fn nkey_to_ed25519_seed(nkey_str: &str) -> JWTResult> { + let trimmed = nkey_str.trim(); + // Decode the nkey seed + match nkeys::decode_seed(trimmed) { + Ok((_prefix, seed_bytes)) => { + // seed_bytes should be 32 bytes for Ed25519 + if seed_bytes.len() != 32 { + return Err(JWTError::Internal(format!( + "Invalid nkey seed length: expected 32 bytes, got {}", + seed_bytes.len() + ))); + } + Ok(seed_bytes.to_vec()) + } + Err(e) => Err(JWTError::Internal(format!( + "Failed to decode nkey seed: {}", + e + ))), + } +} + +/// Converts an nkey public key to raw Ed25519 public key bytes (32 bytes) +/// for use with DecodingKey +pub fn nkey_to_ed25519_public(nkey_str: &str) -> JWTResult> { + let trimmed = nkey_str.trim(); + match nkeys::from_public_key(trimmed) { + Ok((_prefix, public_bytes)) => { + if public_bytes.len() != 32 { + return Err(JWTError::Internal(format!( + "Invalid nkey public key length: expected 32 bytes, got {}", + public_bytes.len() + ))); + } + Ok(public_bytes.to_vec()) + } + Err(e) => Err(JWTError::Internal(format!( + "Failed to decode nkey public key: {}", + e + ))), + } +} + #[cfg(test)] mod tests { use super::*; @@ -188,4 +284,50 @@ mod tests { 60 * 60 * 24 * -2 ); } + + #[test] + fn converts_nkey_user_seed_to_ed25519() { + let seed = "SUAJUNSMQK7AHNZ5HRGV5UI2A24O2DDSWVWIOWP6CVBVBW652GDCM54JNY"; + let result = nkey_to_ed25519_seed(seed); + + assert!(result.is_ok()); + let bytes = result.unwrap(); + assert_eq!(bytes.len(), 32); // Ed25519 seeds are 32 bytes + } + + #[test] + fn converts_nkey_public_key_to_ed25519() { + let public_key = "UBXGBSBR3U4IK6NCKOTND74FYER3BCVCXIB7IYUBDEPYOD6UGRTIBJAV"; + let result = nkey_to_ed25519_public(public_key); + + assert!(result.is_ok()); + let bytes = result.unwrap(); + assert_eq!(bytes.len(), 32); // Ed25519 public keys are 32 bytes + } + + #[test] + fn returns_error_for_invalid_nkey_seed() { + let invalid = "INVALID_NKEY"; + let result = nkey_to_ed25519_seed(invalid); + + assert!(result.is_err()); + } + + #[test] + fn returns_error_for_invalid_nkey_public_key() { + let invalid = "INVALID_NKEY"; + let result = nkey_to_ed25519_public(invalid); + + assert!(result.is_err()); + } + + #[test] + fn converts_nkey_account_seed_to_ed25519() { + let seed = "SAAEPHF7MDRHVD2XWAHRRII766ZTLVCSX7CAX4DLXFDMKPJAOGFPYJNVLM"; + let result = nkey_to_ed25519_seed(seed); + + assert!(result.is_ok()); + let bytes = result.unwrap(); + assert_eq!(bytes.len(), 32); + } } diff --git a/tests/account_public.nk b/tests/account_public.nk new file mode 100644 index 0000000..1aed1b8 --- /dev/null +++ b/tests/account_public.nk @@ -0,0 +1 @@ +AD7MLUUYZAZ6AWGGBIJ4XXRA4KSDSRABAB3IVW4AEYZA2QNILQOKQYDI diff --git a/tests/account_seed.nk b/tests/account_seed.nk new file mode 100644 index 0000000..6fc81a0 --- /dev/null +++ b/tests/account_seed.nk @@ -0,0 +1 @@ +SAAEPHF7MDRHVD2XWAHRRII766ZTLVCSX7CAX4DLXFDMKPJAOGFPYJNVLM diff --git a/tests/main_test.rs b/tests/main_test.rs index 1d74f22..2dfd194 100644 --- a/tests/main_test.rs +++ b/tests/main_test.rs @@ -1248,4 +1248,210 @@ mod tests { let encoded_token = encode_token(&encode_arguments).unwrap(); assert_eq!(encoded_token.as_str(), "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ6IjoxMjMsImEiOjEyM30.kvofE3KpCVQWpvrgx87u9LxjV-AK9bsVmS-Oddbz1Qg") } + + #[test] + fn encodes_and_decodes_an_eddsa_token_using_nkey_seed_string() { + let seed = "SUAJUNSMQK7AHNZ5HRGV5UI2A24O2DDSWVWIOWP6CVBVBW652GDCM54JNY"; + let body: String = "{\"field\":\"value\"}".to_string(); + let encode_matcher = App::command() + .try_get_matches_from(vec![ + "jwt", "encode", "-A", "EDDSA", "--exp", "-S", seed, &body, + ]) + .unwrap(); + let encode_matches = encode_matcher.subcommand_matches("encode").unwrap(); + let encode_arguments = EncodeArgs::from_arg_matches(encode_matches).unwrap(); + let encoded_token = encode_token(&encode_arguments).unwrap(); + + let decode_matcher = App::command() + .try_get_matches_from(vec![ + "jwt", + "decode", + "-S", + seed, + "-A", + "EDDSA", + &encoded_token, + ]) + .unwrap(); + let decode_matches = decode_matcher.subcommand_matches("decode").unwrap(); + let decode_arguments = DecodeArgs::from_arg_matches(decode_matches).unwrap(); + let (result, _, _) = decode_token(&decode_arguments); + + assert!(result.is_ok()); + } + + #[test] + fn encodes_and_decodes_an_eddsa_token_using_nkey_seed_from_file() { + let body: String = "{\"field\":\"value\"}".to_string(); + let encode_matcher = App::command() + .try_get_matches_from(vec![ + "jwt", + "encode", + "-A", + "EDDSA", + "--exp", + "-S", + "@./tests/user_seed.nk", + &body, + ]) + .unwrap(); + let encode_matches = encode_matcher.subcommand_matches("encode").unwrap(); + let encode_arguments = EncodeArgs::from_arg_matches(encode_matches).unwrap(); + let encoded_token = encode_token(&encode_arguments).unwrap(); + + let decode_matcher = App::command() + .try_get_matches_from(vec![ + "jwt", + "decode", + "-S", + "@./tests/user_seed.nk", + "-A", + "EDDSA", + &encoded_token, + ]) + .unwrap(); + let decode_matches = decode_matcher.subcommand_matches("decode").unwrap(); + let decode_arguments = DecodeArgs::from_arg_matches(decode_matches).unwrap(); + let (result, _, _) = decode_token(&decode_arguments); + + assert!(result.is_ok()); + } + + #[test] + fn encodes_with_nkey_seed_decodes_with_nkey_public_key() { + let seed = "SUAJUNSMQK7AHNZ5HRGV5UI2A24O2DDSWVWIOWP6CVBVBW652GDCM54JNY"; + let public_key = "UBXGBSBR3U4IK6NCKOTND74FYER3BCVCXIB7IYUBDEPYOD6UGRTIBJAV"; + let body: String = "{\"field\":\"value\"}".to_string(); + + let encode_matcher = App::command() + .try_get_matches_from(vec![ + "jwt", "encode", "-A", "EDDSA", "--exp", "-S", seed, &body, + ]) + .unwrap(); + let encode_matches = encode_matcher.subcommand_matches("encode").unwrap(); + let encode_arguments = EncodeArgs::from_arg_matches(encode_matches).unwrap(); + let encoded_token = encode_token(&encode_arguments).unwrap(); + + let decode_matcher = App::command() + .try_get_matches_from(vec![ + "jwt", + "decode", + "-S", + public_key, + "-A", + "EDDSA", + &encoded_token, + ]) + .unwrap(); + let decode_matches = decode_matcher.subcommand_matches("decode").unwrap(); + let decode_arguments = DecodeArgs::from_arg_matches(decode_matches).unwrap(); + let (result, _, _) = decode_token(&decode_arguments); + + assert!(result.is_ok()); + } + + #[test] + fn encodes_and_decodes_with_nkey_account_seed() { + let seed = "SAAEPHF7MDRHVD2XWAHRRII766ZTLVCSX7CAX4DLXFDMKPJAOGFPYJNVLM"; + let body: String = "{\"field\":\"value\"}".to_string(); + + let encode_matcher = App::command() + .try_get_matches_from(vec![ + "jwt", "encode", "-A", "EDDSA", "--exp", "-S", seed, &body, + ]) + .unwrap(); + let encode_matches = encode_matcher.subcommand_matches("encode").unwrap(); + let encode_arguments = EncodeArgs::from_arg_matches(encode_matches).unwrap(); + let encoded_token = encode_token(&encode_arguments).unwrap(); + + let decode_matcher = App::command() + .try_get_matches_from(vec![ + "jwt", + "decode", + "-S", + seed, + "-A", + "EDDSA", + &encoded_token, + ]) + .unwrap(); + let decode_matches = decode_matcher.subcommand_matches("decode").unwrap(); + let decode_arguments = DecodeArgs::from_arg_matches(decode_matches).unwrap(); + let (result, _, _) = decode_token(&decode_arguments); + + assert!(result.is_ok()); + } + + #[test] + fn decodes_with_nkey_public_key_from_file() { + let seed = "SUAJUNSMQK7AHNZ5HRGV5UI2A24O2DDSWVWIOWP6CVBVBW652GDCM54JNY"; + let body: String = "{\"field\":\"value\"}".to_string(); + + let encode_matcher = App::command() + .try_get_matches_from(vec![ + "jwt", "encode", "-A", "EDDSA", "--exp", "-S", seed, &body, + ]) + .unwrap(); + let encode_matches = encode_matcher.subcommand_matches("encode").unwrap(); + let encode_arguments = EncodeArgs::from_arg_matches(encode_matches).unwrap(); + let encoded_token = encode_token(&encode_arguments).unwrap(); + + let decode_matcher = App::command() + .try_get_matches_from(vec![ + "jwt", + "decode", + "-S", + "@./tests/user_public.nk", + "-A", + "EDDSA", + &encoded_token, + ]) + .unwrap(); + let decode_matches = decode_matcher.subcommand_matches("decode").unwrap(); + let decode_arguments = DecodeArgs::from_arg_matches(decode_matches).unwrap(); + let (result, _, _) = decode_token(&decode_arguments); + + assert!(result.is_ok()); + } + + #[test] + fn returns_error_for_invalid_nkey_format() { + let invalid_seed = "INVALID_NKEY_STRING_FORMAT"; + let body: String = "{\"field\":\"value\"}".to_string(); + + let encode_matcher = App::command() + .try_get_matches_from(vec![ + "jwt", + "encode", + "-A", + "EDDSA", + "--exp", + "-S", + invalid_seed, + &body, + ]) + .unwrap(); + let encode_matches = encode_matcher.subcommand_matches("encode").unwrap(); + let encode_arguments = EncodeArgs::from_arg_matches(encode_matches).unwrap(); + let encoded_token = encode_token(&encode_arguments); + + assert!(encoded_token.is_err()); + } + + #[test] + fn decodes_nats_jwt_with_ed25519_nkey_algorithm() { + // This is a JWT with "ed25519-nkey" as the algorithm (NATS format) + // It uses the same payload and signature as our standard EdDSA test + let nats_jwt = "eyJhbGciOiJlZDI1NTE5LW5rZXkiLCJ0eXAiOiJKV1QifQ.eyJleHAiOjE3NjAwNjY2MDQsImZpZWxkIjoidmFsdWUiLCJpYXQiOjE3NjAwNjQ4MDR9.z5aTExIuCzNDigCX8rXD1LPNGWsVTjoBWYwpvzQ7sx3jIMBVxXJC-WO5y9BYU9mk-Mhp7l2QjLcFxjaKTvsGCQ"; + + let decode_matcher = App::command() + .try_get_matches_from(vec!["jwt", "decode", nats_jwt]) + .unwrap(); + let decode_matches = decode_matcher.subcommand_matches("decode").unwrap(); + let decode_arguments = DecodeArgs::from_arg_matches(decode_matches).unwrap(); + let (result, _, _) = decode_token(&decode_arguments); + + // Should decode successfully even though algorithm is "ed25519-nkey" + assert!(result.is_ok()); + } } diff --git a/tests/user_public.nk b/tests/user_public.nk new file mode 100644 index 0000000..3ca95da --- /dev/null +++ b/tests/user_public.nk @@ -0,0 +1 @@ +UBXGBSBR3U4IK6NCKOTND74FYER3BCVCXIB7IYUBDEPYOD6UGRTIBJAV diff --git a/tests/user_seed.nk b/tests/user_seed.nk new file mode 100644 index 0000000..6881b15 --- /dev/null +++ b/tests/user_seed.nk @@ -0,0 +1 @@ +SUAJUNSMQK7AHNZ5HRGV5UI2A24O2DDSWVWIOWP6CVBVBW652GDCM54JNY