From 07f30feaaed2f5b7c297d63fd101c8e4e4dde70b Mon Sep 17 00:00:00 2001 From: Jack Grigg Date: Mon, 5 Jan 2026 06:51:35 +0000 Subject: [PATCH] age-core: Add Bech32 helpers These implement the correct-for-age behaviour of ignoring Bech32 length limits. --- Cargo.lock | 1 + age-core/CHANGELOG.md | 6 ++ age-core/Cargo.toml | 1 + age-core/src/primitives.rs | 84 +++++++++++++++++++++++++- age-plugin/src/identity.rs | 45 +++++++------- age-plugin/src/lib.rs | 27 +++++---- age-plugin/src/recipient.rs | 22 +++---- age/src/plugin.rs | 114 +++++++++++++++++++----------------- age/src/util.rs | 8 --- age/src/x25519.rs | 70 +++++++++++----------- 10 files changed, 234 insertions(+), 144 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a95242b1..f4f126fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -113,6 +113,7 @@ name = "age-core" version = "0.11.0" dependencies = [ "base64", + "bech32", "chacha20poly1305", "cookie-factory", "hkdf", diff --git a/age-core/CHANGELOG.md b/age-core/CHANGELOG.md index e48b0e63..3a2e3c7d 100644 --- a/age-core/CHANGELOG.md +++ b/age-core/CHANGELOG.md @@ -8,6 +8,12 @@ to 1.0.0 are beta releases. ## [Unreleased] +### Added +- `age_core::primitives`: + - `bech32_encode` + - `bech32_encode_to_fmt` + - `bech32_decode` + ### Changed - MSRV is now 1.70.0. diff --git a/age-core/Cargo.toml b/age-core/Cargo.toml index 131a54dd..00914bfd 100644 --- a/age-core/Cargo.toml +++ b/age-core/Cargo.toml @@ -19,6 +19,7 @@ maintenance = { status = "experimental" } [dependencies] # Dependencies exposed in a public API: # (Breaking upgrades to these require a breaking upgrade to this crate.) +bech32.workspace = true chacha20poly1305.workspace = true cookie-factory.workspace = true io_tee = "0.1.1" diff --git a/age-core/src/primitives.rs b/age-core/src/primitives.rs index 75571873..97de0c69 100644 --- a/age-core/src/primitives.rs +++ b/age-core/src/primitives.rs @@ -1,5 +1,8 @@ //! Primitive cryptographic operations used across various `age` components. +use core::fmt; + +use bech32::primitives::decode::CheckedHrpstring; use chacha20poly1305::{ aead::{self, generic_array::typenum::Unsigned, Aead, AeadCore, KeyInit}, ChaCha20Poly1305, @@ -53,9 +56,73 @@ pub fn hkdf(salt: &[u8], label: &[u8], ikm: &[u8]) -> [u8; 32] { okm } +/// The bech32 checksum algorithm, defined in [BIP-173]. +/// +/// This is identical to [`bech32::Bech32`] except it does not enforce the length +/// restriction, allowing for a reduction in error-correcting properties. +/// +/// [BIP-173]: +#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +enum Bech32Long {} +impl bech32::Checksum for Bech32Long { + type MidstateRepr = u32; + const CODE_LENGTH: usize = usize::MAX; + const CHECKSUM_LENGTH: usize = bech32::Bech32::CHECKSUM_LENGTH; + const GENERATOR_SH: [u32; 5] = bech32::Bech32::GENERATOR_SH; + const TARGET_RESIDUE: u32 = bech32::Bech32::TARGET_RESIDUE; +} + +/// Encodes data as a Bech32-encoded string with the given HRP. +/// +/// This implements Bech32 as defined in [BIP-173], except it does not enforce the length +/// restriction, allowing for a reduction in error-correcting properties. +/// +/// [BIP-173]: https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki +pub fn bech32_encode(hrp: bech32::Hrp, data: &[u8]) -> String { + bech32::encode_lower::(hrp, data).expect("we don't enforce the Bech32 length limit") +} + +/// Encodes data to a format writer as a Bech32-encoded string with the given HRP. +/// +/// This implements Bech32 as defined in [BIP-173], except it does not enforce the length +/// restriction, allowing for a reduction in error-correcting properties. +/// +/// [BIP-173]: https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki +pub fn bech32_encode_to_fmt(f: &mut impl fmt::Write, hrp: bech32::Hrp, data: &[u8]) -> fmt::Result { + bech32::encode_lower_to_fmt::(f, hrp, data).map_err(|e| match e { + bech32::EncodeError::Fmt(error) => error, + bech32::EncodeError::TooLong(_) => unreachable!("we don't enforce the Bech32 length limit"), + _ => panic!("Unexpected error: {e}"), + }) +} + +/// Decodes a Bech32-encoded string, checks its HRP, and returns its contained data. +/// +/// This implements Bech32 as defined in [BIP-173], except it does not enforce the length +/// restriction, allowing for a reduction in error-correcting properties. +/// +/// [BIP-173]: https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki +pub fn bech32_decode( + s: &str, + parse_err: F, + hrp_filter: G, + data_parse: H, +) -> Result +where + F: FnOnce(bech32::primitives::decode::CheckedHrpstringError) -> E, + G: FnOnce(bech32::Hrp) -> Result<(), E>, + H: FnOnce(bech32::Hrp, bech32::primitives::decode::ByteIter) -> Result, +{ + CheckedHrpstring::new::(s) + .map_err(parse_err) + .and_then(|parsed| { + hrp_filter(parsed.hrp()).and_then(|()| data_parse(parsed.hrp(), parsed.byte_iter())) + }) +} + #[cfg(test)] mod tests { - use super::{aead_decrypt, aead_encrypt}; + use super::{aead_decrypt, aead_encrypt, bech32_decode, bech32_encode}; #[test] fn aead_round_trip() { @@ -65,4 +132,19 @@ mod tests { let decrypted = aead_decrypt(&key, plaintext.len(), &encrypted).unwrap(); assert_eq!(decrypted, plaintext); } + + #[test] + fn bech32_round_trip() { + let hrp = bech32::Hrp::parse_unchecked("12345678"); + let data = [14; 32]; + let encoded = bech32_encode(hrp, &data); + let decoded = bech32_decode( + &encoded, + |_| (), + |parsed_hrp| (parsed_hrp == hrp).then_some(()).ok_or(()), + |_, bytes| Ok(bytes.collect::>()), + ) + .unwrap(); + assert_eq!(decoded, data); + } } diff --git a/age-plugin/src/identity.rs b/age-plugin/src/identity.rs index 6f54878b..ff448f8a 100644 --- a/age-plugin/src/identity.rs +++ b/age-plugin/src/identity.rs @@ -3,10 +3,10 @@ use age_core::{ format::{FileKey, Stanza}, plugin::{self, BidirSend, Connection}, + primitives::bech32_decode, secrecy::{ExposeSecret, SecretString}, }; use base64::{prelude::BASE64_STANDARD_NO_PAD, Engine}; -use bech32::{primitives::decode::CheckedHrpstring, Bech32}; use std::collections::HashMap; use std::convert::Infallible; @@ -265,29 +265,28 @@ pub(crate) fn run_v1(mut plugin: P) -> io::Result<()> { .into_iter() .enumerate() .map(|(index, item)| { - CheckedHrpstring::new::(&item) - .ok() - .and_then(|parsed| { - let hrp = parsed.hrp(); - if hrp.as_str().starts_with(PLUGIN_IDENTITY_PREFIX) - && hrp.as_str().ends_with('-') - { - Some((hrp, parsed.byte_iter().collect::>())) - } else { - None - } - }) - .ok_or_else(|| Error::Identity { + bech32_decode( + &item, + |_| (), + |hrp| { + (hrp.as_str().starts_with(PLUGIN_IDENTITY_PREFIX) + && hrp.as_str().ends_with('-')) + .then_some(()) + .ok_or(()) + }, + |hrp, bytes| Ok((hrp, bytes.collect::>())), + ) + .map_err(|()| Error::Identity { + index, + message: "Invalid identity encoding".to_owned(), + }) + .and_then(|(hrp, bytes)| { + plugin.add_identity( index, - message: "Invalid identity encoding".to_owned(), - }) - .and_then(|(hrp, bytes)| { - plugin.add_identity( - index, - &hrp.as_str()[PLUGIN_IDENTITY_PREFIX.len()..hrp.len() - 1], - &bytes, - ) - }) + &hrp.as_str()[PLUGIN_IDENTITY_PREFIX.len()..hrp.len() - 1], + &bytes, + ) + }) }) .filter_map(|res| res.err()) .collect(); diff --git a/age-plugin/src/lib.rs b/age-plugin/src/lib.rs index d4231dc7..cc09445f 100644 --- a/age-plugin/src/lib.rs +++ b/age-plugin/src/lib.rs @@ -178,8 +178,11 @@ #![deny(rustdoc::broken_intra_doc_links)] #![deny(missing_docs)] -use age_core::secrecy::SecretString; -use bech32::{Bech32, Hrp}; +use age_core::{ + primitives::bech32_encode, + secrecy::{zeroize::Zeroize, SecretString}, +}; +use bech32::Hrp; use std::io; pub mod identity; @@ -193,27 +196,25 @@ const PLUGIN_IDENTITY_PREFIX: &str = "age-plugin-"; /// /// A "created" time is included in the output, set to the current local time. pub fn print_new_identity(plugin_name: &str, identity: &[u8], recipient: &[u8]) { + let mut identity_lower = bech32_encode( + Hrp::parse_unchecked(&format!("{}{}-", PLUGIN_IDENTITY_PREFIX, plugin_name)), + identity, + ); + println!( "# created: {}", chrono::Local::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true) ); println!( "# recipient: {}", - bech32::encode::( + bech32_encode( Hrp::parse_unchecked(&format!("{}{}", PLUGIN_RECIPIENT_PREFIX, plugin_name)), recipient, ) - .expect("HRP is valid") - ); - println!( - "{}", - bech32::encode::( - Hrp::parse_unchecked(&format!("{}{}-", PLUGIN_IDENTITY_PREFIX, plugin_name)), - identity, - ) - .expect("HRP is valid") - .to_uppercase() ); + println!("{}", identity_lower.to_uppercase()); + + identity_lower.zeroize(); } /// Runs the plugin state machine defined by `state_machine`. diff --git a/age-plugin/src/recipient.rs b/age-plugin/src/recipient.rs index bd656bc1..b67c4a7e 100644 --- a/age-plugin/src/recipient.rs +++ b/age-plugin/src/recipient.rs @@ -3,10 +3,10 @@ use age_core::{ format::{is_arbitrary_string, FileKey, Stanza}, plugin::{self, BidirSend, Connection}, + primitives::bech32_decode, secrecy::SecretString, }; use base64::{prelude::BASE64_STANDARD_NO_PAD, Engine}; -use bech32::{primitives::decode::CheckedHrpstring, Bech32}; use std::collections::HashSet; use std::convert::Infallible; @@ -341,16 +341,18 @@ pub(crate) fn run_v1(mut plugin: P) -> io::Result<()> { .into_iter() .enumerate() .map(|(index, item)| { - let decoded = CheckedHrpstring::new::(&item).ok(); - decoded - .map(|parsed| (parsed.hrp(), parsed)) - .as_ref() - .and_then(|(hrp, parsed)| { + bech32_decode( + &item, + |_| (), + |_| Ok(()), + |hrp, bytes| { plugin_name(hrp.as_str()) - .map(|plugin_name| (plugin_name, parsed.byte_iter().collect())) - }) - .ok_or_else(|| error(index)) - .and_then(|(plugin_name, bytes)| adder(index, plugin_name, bytes)) + .map(|plugin_name| (plugin_name.to_string(), bytes.collect())) + .ok_or(()) + }, + ) + .map_err(|()| error(index)) + .and_then(|(plugin_name, bytes)| adder(index, &plugin_name, bytes)) }) .filter_map(|res| res.err()) .collect(); diff --git a/age/src/plugin.rs b/age/src/plugin.rs index 1c9e8374..d47a024a 100644 --- a/age/src/plugin.rs +++ b/age/src/plugin.rs @@ -4,10 +4,11 @@ use age_core::{ format::{FileKey, Stanza}, io::{DebugReader, DebugWriter}, plugin::{Connection, Reply, Response, UnidirSend, IDENTITY_V1, RECIPIENT_V1}, + primitives::{bech32_decode, bech32_encode}, secrecy::ExposeSecret, }; use base64::{prelude::BASE64_STANDARD_NO_PAD, Engine}; -use bech32::{Bech32, Hrp}; +use bech32::Hrp; use std::borrow::Borrow; use std::collections::HashSet; @@ -22,9 +23,7 @@ use std::time::{Duration, SystemTime}; use crate::{ error::{DecryptError, EncryptError, PluginError}, - fl, - util::parse_bech32, - wfl, wlnfl, Callbacks, + fl, wfl, wlnfl, Callbacks, }; // Plugin HRPs are age1[name] and AGE-PLUGIN-[NAME]- @@ -106,25 +105,31 @@ impl std::str::FromStr for Recipient { /// Parses a plugin recipient from a string. fn from_str(s: &str) -> Result { - parse_bech32(s) - .ok_or("invalid Bech32 encoding") - .and_then(|(hrp, _)| { - if hrp.len() > PLUGIN_RECIPIENT_PREFIX.len() - && hrp.starts_with(PLUGIN_RECIPIENT_PREFIX) - { - let name = hrp.split_at(PLUGIN_RECIPIENT_PREFIX.len()).1.to_owned(); - if valid_plugin_name(&name) { - Ok(Recipient { - name, - recipient: s.to_owned(), - }) - } else { - Err("invalid plugin name") - } + bech32_decode( + s, + |_| "invalid Bech32 encoding", + |hrp| { + (hrp.len() > PLUGIN_RECIPIENT_PREFIX.len() + && hrp.as_str().starts_with(PLUGIN_RECIPIENT_PREFIX)) + .then_some(()) + .ok_or("invalid HRP") + }, + |hrp, _| { + let name = hrp + .as_str() + .split_at(PLUGIN_RECIPIENT_PREFIX.len()) + .1 + .to_owned(); + if valid_plugin_name(&name) { + Ok(Recipient { + name, + recipient: s.to_owned(), + }) } else { - Err("invalid HRP") + Err("invalid plugin name") } - }) + }, + ) } } @@ -155,30 +160,33 @@ impl std::str::FromStr for Identity { /// Parses a plugin identity from a string. fn from_str(s: &str) -> Result { - parse_bech32(s) - .ok_or("invalid Bech32 encoding") - .and_then(|(hrp, _)| { - if hrp.len() > PLUGIN_IDENTITY_PREFIX.len() - && hrp.starts_with(PLUGIN_IDENTITY_PREFIX) - { - // TODO: Decide whether to allow plugin names to end in - - let name = hrp - .split_at(PLUGIN_IDENTITY_PREFIX.len()) - .1 - .trim_end_matches('-') - .to_owned(); - if valid_plugin_name(&name) { - Ok(Identity { - name, - identity: s.to_owned(), - }) - } else { - Err("invalid plugin name") - } + bech32_decode( + s, + |_| "invalid Bech32 encoding", + |hrp| { + (hrp.len() > PLUGIN_IDENTITY_PREFIX.len() + && hrp.as_str().starts_with(PLUGIN_IDENTITY_PREFIX)) + .then_some(()) + .ok_or("invalid HRP") + }, + |hrp, _| { + // TODO: Decide whether to allow plugin names to end in - + let name = hrp + .as_str() + .split_at(PLUGIN_IDENTITY_PREFIX.len()) + .1 + .trim_end_matches('-') + .to_owned(); + if valid_plugin_name(&name) { + Ok(Identity { + name, + identity: s.to_owned(), + }) } else { - Err("invalid HRP") + Err("invalid plugin name") } - }) + }, + ) } } @@ -196,11 +204,10 @@ impl Identity { /// Panics if `plugin_name` contains invalid characters. pub fn default_for_plugin(plugin_name: &str) -> Self { if valid_plugin_name(plugin_name) { - bech32::encode::( + bech32_encode( Hrp::parse_unchecked(&format!("{}{}-", PLUGIN_IDENTITY_PREFIX, plugin_name)), &[], ) - .expect("HRP is valid") .to_uppercase() .parse() .unwrap() @@ -736,7 +743,8 @@ impl crate::Identity for IdentityPluginV1 { #[cfg(test)] mod tests { - use bech32::{Bech32, Hrp}; + use age_core::primitives::bech32_encode; + use bech32::Hrp; use crate::{DecryptError, EncryptError, NoCallbacks}; @@ -757,45 +765,41 @@ mod tests { #[test] fn recipient_rejects_empty_name() { - let invalid_recipient = - bech32::encode::(Hrp::parse_unchecked(PLUGIN_RECIPIENT_PREFIX), &[]).unwrap(); + let invalid_recipient = bech32_encode(Hrp::parse_unchecked(PLUGIN_RECIPIENT_PREFIX), &[]); assert!(invalid_recipient.parse::().is_err()); } #[test] fn recipient_rejects_invalid_chars() { - let invalid_recipient = bech32::encode::( + let invalid_recipient = bech32_encode( Hrp::parse_unchecked(&format!( "{}{}", PLUGIN_RECIPIENT_PREFIX, INVALID_PLUGIN_NAME )), &[], - ) - .unwrap(); + ); assert!(invalid_recipient.parse::().is_err()); } #[test] fn identity_rejects_empty_name() { - let invalid_identity = bech32::encode::( + let invalid_identity = bech32_encode( Hrp::parse_unchecked(&format!("{}-", PLUGIN_IDENTITY_PREFIX)), &[], ) - .expect("HRP is valid") .to_uppercase(); assert!(invalid_identity.parse::().is_err()); } #[test] fn identity_rejects_invalid_chars() { - let invalid_identity = bech32::encode::( + let invalid_identity = bech32_encode( Hrp::parse_unchecked(&format!( "{}{}-", PLUGIN_IDENTITY_PREFIX, INVALID_PLUGIN_NAME )), &[], ) - .expect("HRP is valid") .to_uppercase(); assert!(invalid_identity.parse::().is_err()); } diff --git a/age/src/util.rs b/age/src/util.rs index 08948f6b..faa88f32 100644 --- a/age/src/util.rs +++ b/age/src/util.rs @@ -1,16 +1,8 @@ -use bech32::{primitives::decode::CheckedHrpstring, Bech32}; - #[cfg(all(any(feature = "armor", feature = "cli-common"), windows))] pub(crate) const LINE_ENDING: &str = "\r\n"; #[cfg(all(any(feature = "armor", feature = "cli-common"), not(windows)))] pub(crate) const LINE_ENDING: &str = "\n"; -pub(crate) fn parse_bech32(s: &str) -> Option<(String, Vec)> { - CheckedHrpstring::new::(s) - .ok() - .map(|parsed| (parsed.hrp().as_str().into(), parsed.byte_iter().collect())) -} - pub(crate) mod read { use std::str::FromStr; diff --git a/age/src/x25519.rs b/age/src/x25519.rs index f686c9ff..3d0f0682 100644 --- a/age/src/x25519.rs +++ b/age/src/x25519.rs @@ -5,11 +5,12 @@ use std::fmt; use age_core::{ format::{FileKey, Stanza, FILE_KEY_BYTES}, - primitives::{aead_decrypt, aead_encrypt, hkdf}, + primitives::{ + aead_decrypt, aead_encrypt, bech32_decode, bech32_encode, bech32_encode_to_fmt, hkdf, + }, secrecy::{ExposeSecret, SecretString}, }; use base64::{prelude::BASE64_STANDARD_NO_PAD, Engine}; -use bech32::{Bech32, Hrp}; use rand::rngs::OsRng; use subtle::ConstantTimeEq; use x25519_dalek::{EphemeralSecret, PublicKey, StaticSecret}; @@ -17,11 +18,11 @@ use zeroize::Zeroize; use crate::{ error::{DecryptError, EncryptError}, - util::{parse_bech32, read::base64_arg}, + util::read::base64_arg, }; -const SECRET_KEY_PREFIX: &str = "AGE-SECRET-KEY-"; -const PUBLIC_KEY_PREFIX: &str = "age"; +const SECRET_KEY_PREFIX: bech32::Hrp = bech32::Hrp::parse_unchecked("AGE-SECRET-KEY-"); +const PUBLIC_KEY_PREFIX: bech32::Hrp = bech32::Hrp::parse_unchecked("age"); pub(super) const X25519_RECIPIENT_TAG: &str = "X25519"; const X25519_RECIPIENT_KEY_LABEL: &[u8] = b"age-encryption.org/v1/X25519"; @@ -39,18 +40,21 @@ impl std::str::FromStr for Identity { /// Parses an X25519 identity from a string. fn from_str(s: &str) -> Result { - parse_bech32(s) - .ok_or("invalid Bech32 encoding") - .and_then(|(hrp, bytes)| { - if hrp == SECRET_KEY_PREFIX { - TryInto::<[u8; 32]>::try_into(&bytes[..]) - .map_err(|_| "incorrect identity length") - .map(StaticSecret::from) - .map(Identity) - } else { - Err("incorrect HRP") - } - }) + bech32_decode( + s, + |_| "invalid Bech32 encoding", + |hrp| { + (hrp == SECRET_KEY_PREFIX) + .then_some(()) + .ok_or("incorrect HRP") + }, + |_, bytes| { + TryInto::<[u8; 32]>::try_into(bytes.collect::>()) + .map_err(|_| "incorrect identity length") + .map(StaticSecret::from) + .map(Identity) + }, + ) } } @@ -64,9 +68,7 @@ impl Identity { /// Serializes this secret key as a string. pub fn to_string(&self) -> SecretString { let mut sk_bytes = self.0.to_bytes(); - let mut encoded = - bech32::encode::(Hrp::parse_unchecked(SECRET_KEY_PREFIX), &sk_bytes) - .expect("HRP is valid"); + let mut encoded = bech32_encode(SECRET_KEY_PREFIX, &sk_bytes); let ret = SecretString::from(encoded.to_uppercase()); // Clear intermediates @@ -154,29 +156,29 @@ impl std::str::FromStr for Recipient { /// Parses a recipient key from a string. fn from_str(s: &str) -> Result { - parse_bech32(s) - .ok_or("invalid Bech32 encoding") - .and_then(|(hrp, bytes)| { + bech32_decode( + s, + |_| "invalid Bech32 encoding", + |hrp| { if hrp == PUBLIC_KEY_PREFIX { - TryInto::<[u8; 32]>::try_into(&bytes[..]) - .map_err(|_| "incorrect pubkey length") - .map(PublicKey::from) - .map(Recipient) + Ok(()) } else { Err("incorrect HRP") } - }) + }, + |_, bytes| { + TryInto::<[u8; 32]>::try_into(bytes.collect::>()) + .map_err(|_| "incorrect pubkey length") + .map(PublicKey::from) + .map(Recipient) + }, + ) } } impl fmt::Display for Recipient { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!( - f, - "{}", - bech32::encode::(Hrp::parse_unchecked(PUBLIC_KEY_PREFIX), self.0.as_bytes(),) - .expect("HRP is valid") - ) + bech32_encode_to_fmt(f, PUBLIC_KEY_PREFIX, self.0.as_bytes()) } }