Skip to content

Commit 07f30fe

Browse files
committed
age-core: Add Bech32 helpers
These implement the correct-for-age behaviour of ignoring Bech32 length limits.
1 parent c266bfa commit 07f30fe

File tree

10 files changed

+234
-144
lines changed

10 files changed

+234
-144
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

age-core/CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ to 1.0.0 are beta releases.
88

99
## [Unreleased]
1010

11+
### Added
12+
- `age_core::primitives`:
13+
- `bech32_encode`
14+
- `bech32_encode_to_fmt`
15+
- `bech32_decode`
16+
1117
### Changed
1218
- MSRV is now 1.70.0.
1319

age-core/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ maintenance = { status = "experimental" }
1919
[dependencies]
2020
# Dependencies exposed in a public API:
2121
# (Breaking upgrades to these require a breaking upgrade to this crate.)
22+
bech32.workspace = true
2223
chacha20poly1305.workspace = true
2324
cookie-factory.workspace = true
2425
io_tee = "0.1.1"

age-core/src/primitives.rs

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
//! Primitive cryptographic operations used across various `age` components.
22
3+
use core::fmt;
4+
5+
use bech32::primitives::decode::CheckedHrpstring;
36
use chacha20poly1305::{
47
aead::{self, generic_array::typenum::Unsigned, Aead, AeadCore, KeyInit},
58
ChaCha20Poly1305,
@@ -53,9 +56,73 @@ pub fn hkdf(salt: &[u8], label: &[u8], ikm: &[u8]) -> [u8; 32] {
5356
okm
5457
}
5558

59+
/// The bech32 checksum algorithm, defined in [BIP-173].
60+
///
61+
/// This is identical to [`bech32::Bech32`] except it does not enforce the length
62+
/// restriction, allowing for a reduction in error-correcting properties.
63+
///
64+
/// [BIP-173]: <https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki>
65+
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
66+
enum Bech32Long {}
67+
impl bech32::Checksum for Bech32Long {
68+
type MidstateRepr = u32;
69+
const CODE_LENGTH: usize = usize::MAX;
70+
const CHECKSUM_LENGTH: usize = bech32::Bech32::CHECKSUM_LENGTH;
71+
const GENERATOR_SH: [u32; 5] = bech32::Bech32::GENERATOR_SH;
72+
const TARGET_RESIDUE: u32 = bech32::Bech32::TARGET_RESIDUE;
73+
}
74+
75+
/// Encodes data as a Bech32-encoded string with the given HRP.
76+
///
77+
/// This implements Bech32 as defined in [BIP-173], except it does not enforce the length
78+
/// restriction, allowing for a reduction in error-correcting properties.
79+
///
80+
/// [BIP-173]: https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki
81+
pub fn bech32_encode(hrp: bech32::Hrp, data: &[u8]) -> String {
82+
bech32::encode_lower::<Bech32Long>(hrp, data).expect("we don't enforce the Bech32 length limit")
83+
}
84+
85+
/// Encodes data to a format writer as a Bech32-encoded string with the given HRP.
86+
///
87+
/// This implements Bech32 as defined in [BIP-173], except it does not enforce the length
88+
/// restriction, allowing for a reduction in error-correcting properties.
89+
///
90+
/// [BIP-173]: https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki
91+
pub fn bech32_encode_to_fmt(f: &mut impl fmt::Write, hrp: bech32::Hrp, data: &[u8]) -> fmt::Result {
92+
bech32::encode_lower_to_fmt::<Bech32Long, _>(f, hrp, data).map_err(|e| match e {
93+
bech32::EncodeError::Fmt(error) => error,
94+
bech32::EncodeError::TooLong(_) => unreachable!("we don't enforce the Bech32 length limit"),
95+
_ => panic!("Unexpected error: {e}"),
96+
})
97+
}
98+
99+
/// Decodes a Bech32-encoded string, checks its HRP, and returns its contained data.
100+
///
101+
/// This implements Bech32 as defined in [BIP-173], except it does not enforce the length
102+
/// restriction, allowing for a reduction in error-correcting properties.
103+
///
104+
/// [BIP-173]: https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki
105+
pub fn bech32_decode<E, F, G, H, T>(
106+
s: &str,
107+
parse_err: F,
108+
hrp_filter: G,
109+
data_parse: H,
110+
) -> Result<T, E>
111+
where
112+
F: FnOnce(bech32::primitives::decode::CheckedHrpstringError) -> E,
113+
G: FnOnce(bech32::Hrp) -> Result<(), E>,
114+
H: FnOnce(bech32::Hrp, bech32::primitives::decode::ByteIter) -> Result<T, E>,
115+
{
116+
CheckedHrpstring::new::<Bech32Long>(s)
117+
.map_err(parse_err)
118+
.and_then(|parsed| {
119+
hrp_filter(parsed.hrp()).and_then(|()| data_parse(parsed.hrp(), parsed.byte_iter()))
120+
})
121+
}
122+
56123
#[cfg(test)]
57124
mod tests {
58-
use super::{aead_decrypt, aead_encrypt};
125+
use super::{aead_decrypt, aead_encrypt, bech32_decode, bech32_encode};
59126

60127
#[test]
61128
fn aead_round_trip() {
@@ -65,4 +132,19 @@ mod tests {
65132
let decrypted = aead_decrypt(&key, plaintext.len(), &encrypted).unwrap();
66133
assert_eq!(decrypted, plaintext);
67134
}
135+
136+
#[test]
137+
fn bech32_round_trip() {
138+
let hrp = bech32::Hrp::parse_unchecked("12345678");
139+
let data = [14; 32];
140+
let encoded = bech32_encode(hrp, &data);
141+
let decoded = bech32_decode(
142+
&encoded,
143+
|_| (),
144+
|parsed_hrp| (parsed_hrp == hrp).then_some(()).ok_or(()),
145+
|_, bytes| Ok(bytes.collect::<Vec<_>>()),
146+
)
147+
.unwrap();
148+
assert_eq!(decoded, data);
149+
}
68150
}

age-plugin/src/identity.rs

Lines changed: 22 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@
33
use age_core::{
44
format::{FileKey, Stanza},
55
plugin::{self, BidirSend, Connection},
6+
primitives::bech32_decode,
67
secrecy::{ExposeSecret, SecretString},
78
};
89
use base64::{prelude::BASE64_STANDARD_NO_PAD, Engine};
9-
use bech32::{primitives::decode::CheckedHrpstring, Bech32};
1010

1111
use std::collections::HashMap;
1212
use std::convert::Infallible;
@@ -265,29 +265,28 @@ pub(crate) fn run_v1<P: IdentityPluginV1>(mut plugin: P) -> io::Result<()> {
265265
.into_iter()
266266
.enumerate()
267267
.map(|(index, item)| {
268-
CheckedHrpstring::new::<Bech32>(&item)
269-
.ok()
270-
.and_then(|parsed| {
271-
let hrp = parsed.hrp();
272-
if hrp.as_str().starts_with(PLUGIN_IDENTITY_PREFIX)
273-
&& hrp.as_str().ends_with('-')
274-
{
275-
Some((hrp, parsed.byte_iter().collect::<Vec<_>>()))
276-
} else {
277-
None
278-
}
279-
})
280-
.ok_or_else(|| Error::Identity {
268+
bech32_decode(
269+
&item,
270+
|_| (),
271+
|hrp| {
272+
(hrp.as_str().starts_with(PLUGIN_IDENTITY_PREFIX)
273+
&& hrp.as_str().ends_with('-'))
274+
.then_some(())
275+
.ok_or(())
276+
},
277+
|hrp, bytes| Ok((hrp, bytes.collect::<Vec<_>>())),
278+
)
279+
.map_err(|()| Error::Identity {
280+
index,
281+
message: "Invalid identity encoding".to_owned(),
282+
})
283+
.and_then(|(hrp, bytes)| {
284+
plugin.add_identity(
281285
index,
282-
message: "Invalid identity encoding".to_owned(),
283-
})
284-
.and_then(|(hrp, bytes)| {
285-
plugin.add_identity(
286-
index,
287-
&hrp.as_str()[PLUGIN_IDENTITY_PREFIX.len()..hrp.len() - 1],
288-
&bytes,
289-
)
290-
})
286+
&hrp.as_str()[PLUGIN_IDENTITY_PREFIX.len()..hrp.len() - 1],
287+
&bytes,
288+
)
289+
})
291290
})
292291
.filter_map(|res| res.err())
293292
.collect();

age-plugin/src/lib.rs

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -178,8 +178,11 @@
178178
#![deny(rustdoc::broken_intra_doc_links)]
179179
#![deny(missing_docs)]
180180

181-
use age_core::secrecy::SecretString;
182-
use bech32::{Bech32, Hrp};
181+
use age_core::{
182+
primitives::bech32_encode,
183+
secrecy::{zeroize::Zeroize, SecretString},
184+
};
185+
use bech32::Hrp;
183186
use std::io;
184187

185188
pub mod identity;
@@ -193,27 +196,25 @@ const PLUGIN_IDENTITY_PREFIX: &str = "age-plugin-";
193196
///
194197
/// A "created" time is included in the output, set to the current local time.
195198
pub fn print_new_identity(plugin_name: &str, identity: &[u8], recipient: &[u8]) {
199+
let mut identity_lower = bech32_encode(
200+
Hrp::parse_unchecked(&format!("{}{}-", PLUGIN_IDENTITY_PREFIX, plugin_name)),
201+
identity,
202+
);
203+
196204
println!(
197205
"# created: {}",
198206
chrono::Local::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
199207
);
200208
println!(
201209
"# recipient: {}",
202-
bech32::encode::<Bech32>(
210+
bech32_encode(
203211
Hrp::parse_unchecked(&format!("{}{}", PLUGIN_RECIPIENT_PREFIX, plugin_name)),
204212
recipient,
205213
)
206-
.expect("HRP is valid")
207-
);
208-
println!(
209-
"{}",
210-
bech32::encode::<Bech32>(
211-
Hrp::parse_unchecked(&format!("{}{}-", PLUGIN_IDENTITY_PREFIX, plugin_name)),
212-
identity,
213-
)
214-
.expect("HRP is valid")
215-
.to_uppercase()
216214
);
215+
println!("{}", identity_lower.to_uppercase());
216+
217+
identity_lower.zeroize();
217218
}
218219

219220
/// Runs the plugin state machine defined by `state_machine`.

age-plugin/src/recipient.rs

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@
33
use age_core::{
44
format::{is_arbitrary_string, FileKey, Stanza},
55
plugin::{self, BidirSend, Connection},
6+
primitives::bech32_decode,
67
secrecy::SecretString,
78
};
89
use base64::{prelude::BASE64_STANDARD_NO_PAD, Engine};
9-
use bech32::{primitives::decode::CheckedHrpstring, Bech32};
1010

1111
use std::collections::HashSet;
1212
use std::convert::Infallible;
@@ -341,16 +341,18 @@ pub(crate) fn run_v1<P: RecipientPluginV1>(mut plugin: P) -> io::Result<()> {
341341
.into_iter()
342342
.enumerate()
343343
.map(|(index, item)| {
344-
let decoded = CheckedHrpstring::new::<Bech32>(&item).ok();
345-
decoded
346-
.map(|parsed| (parsed.hrp(), parsed))
347-
.as_ref()
348-
.and_then(|(hrp, parsed)| {
344+
bech32_decode(
345+
&item,
346+
|_| (),
347+
|_| Ok(()),
348+
|hrp, bytes| {
349349
plugin_name(hrp.as_str())
350-
.map(|plugin_name| (plugin_name, parsed.byte_iter().collect()))
351-
})
352-
.ok_or_else(|| error(index))
353-
.and_then(|(plugin_name, bytes)| adder(index, plugin_name, bytes))
350+
.map(|plugin_name| (plugin_name.to_string(), bytes.collect()))
351+
.ok_or(())
352+
},
353+
)
354+
.map_err(|()| error(index))
355+
.and_then(|(plugin_name, bytes)| adder(index, &plugin_name, bytes))
354356
})
355357
.filter_map(|res| res.err())
356358
.collect();

0 commit comments

Comments
 (0)