Skip to content

Commit cdfa279

Browse files
committed
age: Add p256tag::Recipient
1 parent cf04ea7 commit cdfa279

File tree

9 files changed

+175
-3
lines changed

9 files changed

+175
-3
lines changed

Cargo.lock

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

Cargo.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ chacha20poly1305 = { version = "0.10", default-features = false, features = ["al
2828
# - X25519 from RFC 7748
2929
x25519-dalek = { version = "2", features = ["static_secrets"] }
3030

31+
# - P-256
32+
p256 = { version = "0.13", default-features = false, features = ["arithmetic"] }
33+
3134
# - HKDF from RFC 5869 with SHA-256
3235
# - HMAC from RFC 2104 with SHA-256
3336
hkdf = "0.12"
@@ -53,7 +56,7 @@ nom = { version = "7", default-features = false, features = ["alloc"] }
5356
# Secret management
5457
pinentry = "0.6"
5558
secrecy = "0.10"
56-
subtle = "2"
59+
subtle = "2.6"
5760
zeroize = "1"
5861

5962
# Localization

age/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ to 1.0.0 are beta releases.
1111
## [Unreleased]
1212
### Added
1313
- `age::encrypted::EncryptedIdentity`
14+
- Support for the new native age recipient types:
15+
- `age::p256tag`
1416

1517
### Changed
1618
- MSRV is now 1.70.0.

age/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,12 @@ pinentry = { workspace = true, optional = true }
4343
# (Breaking upgrades to these are usually backwards-compatible, but check MSRVs.)
4444
bech32.workspace = true
4545
cookie-factory.workspace = true
46+
hkdf.workspace = true
47+
hpke = { workspace = true, features = ["p256"] }
4648
i18n-embed-fl.workspace = true
4749
lazy_static.workspace = true
4850
nom.workspace = true
51+
p256.workspace = true
4952
rust-embed.workspace = true
5053
scrypt.workspace = true
5154
sha2.workspace = true

age/src/cli_common/recipients.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@ use std::io::{self, BufReader};
22

33
use super::StdinGuard;
44
use super::{identities::parse_identity_files, ReadError};
5-
use crate::identity::RecipientsAccumulator;
6-
use crate::{x25519, Recipient};
5+
use crate::{identity::RecipientsAccumulator, p256tag, x25519, Recipient};
76

87
#[cfg(feature = "plugin")]
98
use crate::{cli_common::UiCallbacks, plugin};
@@ -57,6 +56,8 @@ fn parse_recipient(
5756
) -> Result<(), ReadError> {
5857
if let Ok(pk) = s.parse::<x25519::Recipient>() {
5958
recipients.push(Box::new(pk));
59+
} else if let Ok(pk) = s.parse::<p256tag::Recipient>() {
60+
recipients.push(Box::new(pk));
6061
} else if let Some(pk) = {
6162
#[cfg(feature = "ssh")]
6263
{

age/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,7 @@ pub use simple::encrypt_and_armor;
250250
//
251251

252252
mod native;
253+
pub use native::p256tag;
253254
pub use native::scrypt;
254255
pub use native::x25519;
255256

age/src/native.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,18 @@
1+
use hkdf::Hkdf;
2+
use sha2::{Digest, Sha256};
3+
4+
pub mod p256tag;
15
pub mod scrypt;
26
pub mod x25519;
7+
8+
fn static_tag(pk: &[u8]) -> [u8; 4] {
9+
Sha256::digest(pk)[..4]
10+
.try_into()
11+
.expect("length is correct")
12+
}
13+
14+
/// Derives a tag for the tagged age recipient formats.
15+
fn stanza_tag(ikm: &[u8], salt: &str) -> [u8; 4] {
16+
let (tag, _) = Hkdf::<Sha256>::extract(Some(salt.as_bytes()), ikm);
17+
tag[..4].try_into().expect("correct length")
18+
}

age/src/native/p256tag.rs

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
//! The "tag" recipient type, native to age.
2+
3+
use std::collections::HashSet;
4+
use std::fmt;
5+
6+
use age_core::{
7+
format::{FileKey, Stanza},
8+
primitives::hpke_seal,
9+
secrecy::ExposeSecret,
10+
};
11+
use base64::{prelude::BASE64_STANDARD_NO_PAD, Engine};
12+
use bech32::{ToBase32, Variant};
13+
use hpke::{Deserializable, Serializable};
14+
use p256::{
15+
elliptic_curve::sec1::{FromEncodedPoint, ToEncodedPoint},
16+
EncodedPoint, PublicKey,
17+
};
18+
use rand::rngs::OsRng;
19+
20+
use crate::{util::parse_bech32, EncryptError};
21+
22+
const RECIPIENT_PREFIX: &str = "age1tag";
23+
24+
const P256TAG_RECIPIENT_TAG: &str = "p256tag";
25+
const P256TAG_SALT: &str = "age-encryption.org/p256tag";
26+
27+
type Kem = hpke::kem::DhP256HkdfSha256;
28+
29+
/// The non-hybrid tagged age recipient type, designed for hardware keys where decryption
30+
/// potentially requires user presence.
31+
///
32+
/// With knowledge of the recipient, it is possible to check if a stanza was addressed to
33+
/// a specific recipient before attempting decryption. This offers less privacy than the
34+
/// untagged recipient types.
35+
#[derive(Clone, PartialEq, Eq)]
36+
pub struct Recipient {
37+
/// Compressed encoding of the recipient public key.
38+
compressed: EncodedPoint,
39+
/// Cached in-memory representation, for HPKE.
40+
pk_recip: <Kem as hpke::Kem>::PublicKey,
41+
}
42+
43+
impl std::str::FromStr for Recipient {
44+
type Err = &'static str;
45+
46+
/// Parses a recipient key from a string.
47+
fn from_str(s: &str) -> Result<Self, Self::Err> {
48+
let (hrp, bytes) = parse_bech32(s).ok_or("invalid Bech32 encoding")?;
49+
50+
if hrp != RECIPIENT_PREFIX {
51+
return Err("incorrect HRP");
52+
}
53+
54+
let encoded = EncodedPoint::from_bytes(bytes).map_err(|_| "invalid SEC-1 encoding")?;
55+
if !encoded.is_compressed() {
56+
return Err("not a compressed SEC-1 encoding");
57+
}
58+
59+
let point = PublicKey::from_encoded_point(&encoded)
60+
.into_option()
61+
.ok_or("invalid P-256 point")?;
62+
63+
let pk_recip =
64+
<Kem as hpke::Kem>::PublicKey::from_bytes(point.to_encoded_point(false).as_bytes())
65+
.expect("valid");
66+
67+
Ok(Self {
68+
compressed: encoded,
69+
pk_recip,
70+
})
71+
}
72+
}
73+
74+
impl fmt::Display for Recipient {
75+
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
76+
write!(
77+
f,
78+
"{}",
79+
bech32::encode(
80+
RECIPIENT_PREFIX,
81+
self.compressed.as_bytes().to_base32(),
82+
Variant::Bech32
83+
)
84+
.expect("HRP is valid")
85+
)
86+
}
87+
}
88+
89+
impl fmt::Debug for Recipient {
90+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91+
write!(f, "{}", self)
92+
}
93+
}
94+
95+
impl crate::Recipient for Recipient {
96+
fn wrap_file_key(
97+
&self,
98+
file_key: &FileKey,
99+
) -> Result<(Vec<Stanza>, HashSet<String>), EncryptError> {
100+
let (enc, ct) = hpke_seal::<Kem, _>(
101+
&self.pk_recip,
102+
P256TAG_SALT.as_bytes(),
103+
file_key.expose_secret(),
104+
&mut OsRng,
105+
);
106+
107+
let ikm = enc
108+
.to_bytes()
109+
.into_iter()
110+
.chain(super::static_tag(self.compressed.as_bytes()))
111+
.collect::<Vec<u8>>();
112+
let tag = super::stanza_tag(&ikm, P256TAG_SALT);
113+
114+
let encoded_tag = BASE64_STANDARD_NO_PAD.encode(tag);
115+
let encoded_enc = BASE64_STANDARD_NO_PAD.encode(enc.to_bytes());
116+
117+
Ok((
118+
vec![Stanza {
119+
tag: P256TAG_RECIPIENT_TAG.to_owned(),
120+
args: vec![encoded_tag, encoded_enc],
121+
body: ct,
122+
}],
123+
HashSet::new(),
124+
))
125+
}
126+
}
127+
128+
#[cfg(test)]
129+
pub(crate) mod tests {
130+
use super::Recipient;
131+
132+
pub(crate) const TEST_RECIPIENT: &str =
133+
"age1tag1qt8lw0ual6avlwmwatk888yqnmdamm7xfd0wak53ut6elz5c4swx2yqdj4e";
134+
135+
#[test]
136+
fn recipient_encoding() {
137+
let recipient: Recipient = TEST_RECIPIENT.parse().unwrap();
138+
assert_eq!(recipient.to_string(), TEST_RECIPIENT);
139+
}
140+
}

rage/CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ and this project adheres to Rust's notion of
99
to 1.0.0 are beta releases.
1010

1111
## [Unreleased]
12+
### Added
13+
- Support for the new native age recipient types:
14+
- `age1tag1..`
1215

1316
### Changed
1417
- MSRV is now 1.70.0.

0 commit comments

Comments
 (0)