From 3dc48358e85f8cc25498530b3ccc85e7c45465f9 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Wed, 14 Jan 2026 10:32:56 -0800 Subject: [PATCH 1/2] Require lowercase keys and signatures --- src/functions.rs | 27 ++++++++++++++++++++++++++ src/lib.rs | 42 +++++++++++++++++++++++++---------------- src/private_key.rs | 20 ++++++++++++++++++++ src/public_key.rs | 19 +++++++++++++++++++ src/public_key_error.rs | 2 ++ src/signature.rs | 26 +++++++++++++++++++++++-- 6 files changed, 118 insertions(+), 18 deletions(-) create mode 100644 src/functions.rs diff --git a/src/functions.rs b/src/functions.rs new file mode 100644 index 00000000..6165a479 --- /dev/null +++ b/src/functions.rs @@ -0,0 +1,27 @@ +use super::*; + +pub(crate) fn current_dir() -> Result { + Utf8PathBuf::from_path_buf(env::current_dir().context(error::CurrentDir)?) + .map_err(|path| error::PathUnicode { path }.build()) +} + +pub(crate) fn decode_path(path: &Path) -> Result<&Utf8Path> { + Utf8Path::from_path(path).context(error::PathUnicode { path }) +} + +pub(crate) fn is_lowercase_hex(s: &str) -> bool { + s.chars() + .all(|c| c.is_digit(16) && (c.is_numeric() || c.is_lowercase())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lowercase_hex() { + assert!(is_lowercase_hex("0123456789abcdef")); + assert!(!is_lowercase_hex("0123456789ABCDEF")); + assert!(!is_lowercase_hex("xyz")); + } +} diff --git a/src/lib.rs b/src/lib.rs index 51032827..00119c7c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,13 +18,31 @@ use { self::{ - arguments::Arguments, component::Component, count::Count, display_path::DisplayPath, - display_secret::DisplaySecret, entries::Entries, fingerprint_hasher::FingerprintHasher, - fingerprint_prefix::FingerprintPrefix, key_identifier::KeyIdentifier, key_name::KeyName, - lint::Lint, lint_group::LintGroup, message::Message, metadata::Metadata, mode::Mode, - options::Options, owo_colorize_ext::OwoColorizeExt, path_error::PathError, - public_key_error::PublicKeyError, signature_error::SignatureError, style::Style, - subcommand::Subcommand, template::Template, utf8_path_ext::Utf8PathExt, + arguments::Arguments, + component::Component, + count::Count, + display_path::DisplayPath, + display_secret::DisplaySecret, + entries::Entries, + fingerprint_hasher::FingerprintHasher, + fingerprint_prefix::FingerprintPrefix, + functions::{current_dir, decode_path, is_lowercase_hex}, + key_identifier::KeyIdentifier, + key_name::KeyName, + lint::Lint, + lint_group::LintGroup, + message::Message, + metadata::Metadata, + mode::Mode, + options::Options, + owo_colorize_ext::OwoColorizeExt, + path_error::PathError, + public_key_error::PublicKeyError, + signature_error::SignatureError, + style::Style, + subcommand::Subcommand, + template::Template, + utf8_path_ext::Utf8PathExt, }, blake3::Hasher, camino::{Utf8Component, Utf8Path, Utf8PathBuf}, @@ -87,6 +105,7 @@ mod file; mod filesystem; mod fingerprint_hasher; mod fingerprint_prefix; +mod functions; mod hash; mod key_identifier; mod key_name; @@ -116,15 +135,6 @@ const SEPARATORS: [char; 2] = ['/', '\\']; type Result = std::result::Result; -fn current_dir() -> Result { - Utf8PathBuf::from_path_buf(env::current_dir().context(error::CurrentDir)?) - .map_err(|path| error::PathUnicode { path }.build()) -} - -fn decode_path(path: &Path) -> Result<&Utf8Path> { - Utf8Path::from_path(path).context(error::PathUnicode { path }) -} - pub fn run() { if let Err(err) = Arguments::parse().run() { let style = Style::stderr(); diff --git a/src/private_key.rs b/src/private_key.rs index 56d02c98..3b2ef881 100644 --- a/src/private_key.rs +++ b/src/private_key.rs @@ -3,6 +3,8 @@ use super::*; #[derive(Debug, Snafu)] #[snafu(context(suffix(Error)))] pub enum Error { + #[snafu(display("private keys must be lowercase hex"))] + Case, #[snafu(display("invalid private key hex"))] Hex { source: hex::FromHexError }, #[snafu(display("invalid private key byte length {length}"))] @@ -74,6 +76,10 @@ impl FromStr for PrivateKey { fn from_str(s: &str) -> Result { let bytes = hex::decode(s).context(HexError)?; + if !is_lowercase_hex(s) { + return Err(CaseError.build()); + } + let secret: [u8; Self::LEN] = bytes.as_slice().try_into().ok().context(LengthError { length: bytes.len(), })?; @@ -151,6 +157,20 @@ mod tests { .unwrap_err(); } + #[test] + fn uppercase_is_forbidden() { + let key = "0e56ae8b43aa93fd4c179ceaff96f729522622d26b4b5357bc959e476e59e107"; + key.parse::().unwrap(); + assert_eq!( + key + .to_uppercase() + .parse::() + .unwrap_err() + .to_string(), + "private keys must be lowercase hex", + ); + } + #[test] fn whitespace_is_trimmed_when_loading_from_disk() { let dir = tempdir(); diff --git a/src/public_key.rs b/src/public_key.rs index efb9467b..fe35c0c8 100644 --- a/src/public_key.rs +++ b/src/public_key.rs @@ -47,6 +47,10 @@ impl FromStr for PublicKey { fn from_str(key: &str) -> Result { let bytes = hex::decode(key).context(public_key_error::HexError { key })?; + if !is_lowercase_hex(key) { + return Err(public_key_error::CaseError { key }.build()); + } + let array: [u8; Self::LEN] = bytes .as_slice() @@ -124,6 +128,21 @@ mod tests { ); } + #[test] + fn uppercase_is_forbidden() { + let key = "0f6d444f09eb336d3cc94d66cc541fea0b70b36be291eb3ecf5b49113f34c8d3"; + key.parse::().unwrap(); + assert_eq!( + key + .to_uppercase() + .parse::() + .unwrap_err() + .to_string(), + "public keys must be lowercase hex: \ + `0F6D444F09EB336D3CC94D66CC541FEA0B70B36BE291EB3ECF5B49113F34C8D3`" + ); + } + #[test] fn weak_public_keys_are_forbidden() { assert!(matches!( diff --git a/src/public_key_error.rs b/src/public_key_error.rs index d0b5f5ca..650d9d39 100644 --- a/src/public_key_error.rs +++ b/src/public_key_error.rs @@ -3,6 +3,8 @@ use super::*; #[derive(Debug, Snafu)] #[snafu(context(suffix(Error)), visibility(pub(crate)))] pub enum PublicKeyError { + #[snafu(display("public keys must be lowercase hex: `{key}`"))] + Case { key: String }, #[snafu(display("invalid public key hex: `{key}`"))] Hex { key: String, diff --git a/src/signature.rs b/src/signature.rs index 9c3f4f10..e0ddee94 100644 --- a/src/signature.rs +++ b/src/signature.rs @@ -3,6 +3,8 @@ use super::*; #[derive(Debug, Snafu)] #[snafu(context(suffix(Error)))] pub enum Error { + #[snafu(display("signatures must be lowercase hex: `{signature}`"))] + Case { signature: String }, #[snafu(display("invalid signature hex: `{signature}`"))] Hex { signature: String, @@ -56,6 +58,10 @@ impl FromStr for Signature { fn from_str(signature: &str) -> Result { let bytes = hex::decode(signature).context(HexError { signature })?; + if !is_lowercase_hex(signature) { + return Err(CaseError { signature }.build()); + } + let array: [u8; Self::LEN] = bytes.as_slice().try_into().context(LengthError { signature, length: bytes.len(), @@ -72,8 +78,7 @@ mod tests { #[test] fn display_is_lowercase_hex() { let s = "0f6d444f09eb336d3cc94d66cc541fea0b70b36be291eb3ecf5b49113f34c8d3\ - 0f6d444f09eb336d3cc94d66cc541fea0b70b36be291eb3ecf5b49113f34c8d3"; - + 0f6d444f09eb336d3cc94d66cc541fea0b70b36be291eb3ecf5b49113f34c8d3"; assert_eq!(s.parse::().unwrap().to_string(), s); } @@ -98,4 +103,21 @@ mod tests { signature ); } + + #[test] + fn uppercase_is_forbidden() { + let signature = "0f6d444f09eb336d3cc94d66cc541fea0b70b36be291eb3ecf5b49113f34c8d3\ + 0f6d444f09eb336d3cc94d66cc541fea0b70b36be291eb3ecf5b49113f34c8d3"; + signature.parse::().unwrap(); + assert_eq!( + signature + .to_uppercase() + .parse::() + .unwrap_err() + .to_string(), + "signatures must be lowercase hex: \ + `0F6D444F09EB336D3CC94D66CC541FEA0B70B36BE291EB3ECF5B49113F34C8D3\ + 0F6D444F09EB336D3CC94D66CC541FEA0B70B36BE291EB3ECF5B49113F34C8D3`", + ); + } } From 6b46ea8ba88b03decdb83c8d7489682285fe70e3 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Wed, 14 Jan 2026 10:41:33 -0800 Subject: [PATCH 2/2] Adjust --- src/functions.rs | 2 +- src/private_key.rs | 22 +++++++++++----------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/functions.rs b/src/functions.rs index 6165a479..52a47bda 100644 --- a/src/functions.rs +++ b/src/functions.rs @@ -11,7 +11,7 @@ pub(crate) fn decode_path(path: &Path) -> Result<&Utf8Path> { pub(crate) fn is_lowercase_hex(s: &str) -> bool { s.chars() - .all(|c| c.is_digit(16) && (c.is_numeric() || c.is_lowercase())) + .all(|c| c.is_ascii_hexdigit() && (c.is_numeric() || c.is_lowercase())) } #[cfg(test)] diff --git a/src/private_key.rs b/src/private_key.rs index 3b2ef881..17ad2c5f 100644 --- a/src/private_key.rs +++ b/src/private_key.rs @@ -146,17 +146,6 @@ mod tests { key.parse::().unwrap_err(); } - #[test] - fn whitespace_is_not_trimmed_when_parsing_from_string() { - "0e56ae8b43aa93fd4c179ceaff96f729522622d26b4b5357bc959e476e59e107" - .parse::() - .unwrap(); - - " 0e56ae8b43aa93fd4c179ceaff96f729522622d26b4b5357bc959e476e59e107" - .parse::() - .unwrap_err(); - } - #[test] fn uppercase_is_forbidden() { let key = "0e56ae8b43aa93fd4c179ceaff96f729522622d26b4b5357bc959e476e59e107"; @@ -171,6 +160,17 @@ mod tests { ); } + #[test] + fn whitespace_is_not_trimmed_when_parsing_from_string() { + "0e56ae8b43aa93fd4c179ceaff96f729522622d26b4b5357bc959e476e59e107" + .parse::() + .unwrap(); + + " 0e56ae8b43aa93fd4c179ceaff96f729522622d26b4b5357bc959e476e59e107" + .parse::() + .unwrap_err(); + } + #[test] fn whitespace_is_trimmed_when_loading_from_disk() { let dir = tempdir();