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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions age-core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
1 change: 1 addition & 0 deletions age-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
84 changes: 83 additions & 1 deletion age-core/src/primitives.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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]: <https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki>
#[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::<Bech32Long>(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::<Bech32Long, _>(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<E, F, G, H, T>(
s: &str,
parse_err: F,
hrp_filter: G,
data_parse: H,
) -> Result<T, E>
where
F: FnOnce(bech32::primitives::decode::CheckedHrpstringError) -> E,
G: FnOnce(bech32::Hrp) -> Result<(), E>,
H: FnOnce(bech32::Hrp, bech32::primitives::decode::ByteIter) -> Result<T, E>,
{
CheckedHrpstring::new::<Bech32Long>(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() {
Expand All @@ -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::<Vec<_>>()),
)
.unwrap();
assert_eq!(decoded, data);
}
}
45 changes: 22 additions & 23 deletions age-plugin/src/identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -265,29 +265,28 @@ pub(crate) fn run_v1<P: IdentityPluginV1>(mut plugin: P) -> io::Result<()> {
.into_iter()
.enumerate()
.map(|(index, item)| {
CheckedHrpstring::new::<Bech32>(&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::<Vec<_>>()))
} 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::<Vec<_>>())),
)
.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();
Expand Down
27 changes: 14 additions & 13 deletions age-plugin/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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>(
bech32_encode(
Hrp::parse_unchecked(&format!("{}{}", PLUGIN_RECIPIENT_PREFIX, plugin_name)),
recipient,
)
.expect("HRP is valid")
);
println!(
"{}",
bech32::encode::<Bech32>(
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`.
Expand Down
22 changes: 12 additions & 10 deletions age-plugin/src/recipient.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -341,16 +341,18 @@ pub(crate) fn run_v1<P: RecipientPluginV1>(mut plugin: P) -> io::Result<()> {
.into_iter()
.enumerate()
.map(|(index, item)| {
let decoded = CheckedHrpstring::new::<Bech32>(&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();
Expand Down
Loading
Loading