Skip to content

Commit dfbea00

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

File tree

8 files changed

+171
-3
lines changed

8 files changed

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

age/src/native/p256tag.rs

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
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 uncompressed encoding, for computing the tag.
40+
uncompressed: EncodedPoint,
41+
/// Cached in-memory representation, for HPKE.
42+
pk_recip: <Kem as hpke::Kem>::PublicKey,
43+
}
44+
45+
impl std::str::FromStr for Recipient {
46+
type Err = &'static str;
47+
48+
/// Parses a recipient key from a string.
49+
fn from_str(s: &str) -> Result<Self, Self::Err> {
50+
let (hrp, bytes) = parse_bech32(s).ok_or("invalid Bech32 encoding")?;
51+
52+
if hrp != RECIPIENT_PREFIX {
53+
return Err("incorrect HRP");
54+
}
55+
56+
let encoded = EncodedPoint::from_bytes(bytes).map_err(|_| "invalid SEC-1 encoding")?;
57+
if !encoded.is_compressed() {
58+
return Err("not a compressed SEC-1 encoding");
59+
}
60+
61+
let point = PublicKey::from_encoded_point(&encoded)
62+
.into_option()
63+
.ok_or("invalid P-256 point")?;
64+
65+
let uncompressed = point.to_encoded_point(false);
66+
let pk_recip =
67+
<Kem as hpke::Kem>::PublicKey::from_bytes(uncompressed.as_bytes()).expect("valid");
68+
69+
Ok(Self {
70+
compressed: encoded,
71+
uncompressed,
72+
pk_recip,
73+
})
74+
}
75+
}
76+
77+
impl fmt::Display for Recipient {
78+
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
79+
write!(
80+
f,
81+
"{}",
82+
bech32::encode(
83+
RECIPIENT_PREFIX,
84+
self.compressed.as_bytes().to_base32(),
85+
Variant::Bech32
86+
)
87+
.expect("HRP is valid")
88+
)
89+
}
90+
}
91+
92+
impl fmt::Debug for Recipient {
93+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94+
write!(f, "{}", self)
95+
}
96+
}
97+
98+
impl crate::Recipient for Recipient {
99+
fn wrap_file_key(
100+
&self,
101+
file_key: &FileKey,
102+
) -> Result<(Vec<Stanza>, HashSet<String>), EncryptError> {
103+
let mut rng = OsRng;
104+
105+
let (enc, ct) = hpke_seal::<Kem, _>(
106+
&self.pk_recip,
107+
P256TAG_SALT.as_bytes(),
108+
file_key.expose_secret(),
109+
&mut rng,
110+
);
111+
112+
let ikm = enc
113+
.to_bytes()
114+
.into_iter()
115+
.chain(self.uncompressed.as_bytes().iter().copied())
116+
.collect::<Vec<u8>>();
117+
let tag = super::tag(&ikm, P256TAG_SALT);
118+
119+
let encoded_tag = BASE64_STANDARD_NO_PAD.encode(tag);
120+
let encoded_enc = BASE64_STANDARD_NO_PAD.encode(enc.to_bytes());
121+
122+
Ok((
123+
vec![Stanza {
124+
tag: P256TAG_RECIPIENT_TAG.to_owned(),
125+
args: vec![encoded_tag, encoded_enc],
126+
body: ct,
127+
}],
128+
HashSet::new(),
129+
))
130+
}
131+
}
132+
133+
#[cfg(test)]
134+
pub(crate) mod tests {
135+
use super::Recipient;
136+
137+
pub(crate) const TEST_RECIPIENT: &str =
138+
"age1tag1qt8lw0ual6avlwmwatk888yqnmdamm7xfd0wak53ut6elz5c4swx2yqdj4e";
139+
140+
#[test]
141+
fn recipient_encoding() {
142+
let recipient: Recipient = TEST_RECIPIENT.parse().unwrap();
143+
assert_eq!(recipient.to_string(), TEST_RECIPIENT);
144+
}
145+
}

0 commit comments

Comments
 (0)