Skip to content

Commit 86962ed

Browse files
committed
age: Add tagpq::Recipient
1 parent 31b7d91 commit 86962ed

File tree

14 files changed

+2637
-5
lines changed

14 files changed

+2637
-5
lines changed

Cargo.lock

Lines changed: 52 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: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@ base64 = "0.22"
2525
# - ChaCha20-Poly1305 from RFC 7539
2626
chacha20poly1305 = { version = "0.10", default-features = false, features = ["alloc"] }
2727

28+
# - SHA3-256 from FIPS 202
29+
sha3 = "0.10"
30+
31+
# - ML-KEM from FIPS 203
32+
ml-kem = { version = "0.2", features = ["deterministic"] }
33+
2834
# - X25519 from RFC 7748
2935
x25519-dalek = { version = "2", features = ["static_secrets"] }
3036

age/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ to 1.0.0 are beta releases.
1313
- `age::encrypted::EncryptedIdentity`
1414
- Support for the new native age recipient types:
1515
- `age::tag::Recipient` (encryption-only)
16+
- `age::tagpq::Recipient` (encryption-only)
1617

1718
### Changed
1819
- MSRV is now 1.74.0.

age/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,13 @@ hkdf.workspace = true
4747
hpke = { workspace = true, features = ["p256"] }
4848
i18n-embed-fl.workspace = true
4949
lazy_static.workspace = true
50+
ml-kem.workspace = true
5051
nom.workspace = true
5152
p256.workspace = true
5253
rust-embed.workspace = true
5354
scrypt.workspace = true
5455
sha2.workspace = true
56+
sha3.workspace = true
5557
subtle.workspace = true
5658
x25519-dalek.workspace = true
5759
zeroize.workspace = true

age/src/cli_common/recipients.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +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, tag, x25519, Recipient};
5+
use crate::{identity::RecipientsAccumulator, tag, tagpq, x25519, Recipient};
66

77
#[cfg(feature = "plugin")]
88
use crate::{cli_common::UiCallbacks, plugin};
@@ -58,6 +58,8 @@ fn parse_recipient(
5858
recipients.push(Box::new(pk));
5959
} else if let Ok(pk) = s.parse::<tag::Recipient>() {
6060
recipients.push(Box::new(pk));
61+
} else if let Ok(pk) = s.parse::<tagpq::Recipient>() {
62+
recipients.push(Box::new(pk));
6163
} else if let Some(pk) = {
6264
#[cfg(feature = "ssh")]
6365
{

age/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,7 @@ pub use simple::encrypt_and_armor;
252252
mod native;
253253
pub use native::scrypt;
254254
pub use native::tag;
255+
pub use native::tagpq;
255256
pub use native::x25519;
256257

257258
pub mod encrypted;

age/src/native.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
use std::collections::HashSet;
2+
13
use hkdf::Hkdf;
24
use sha2::{Digest, Sha256};
35

46
pub mod scrypt;
57
pub mod tag;
8+
pub mod tagpq;
69
pub mod x25519;
710

811
fn static_tag(pk: &[u8]) -> [u8; 4] {
@@ -16,3 +19,7 @@ fn stanza_tag(ikm: &[u8], salt: &str) -> [u8; 4] {
1619
let (tag, _) = Hkdf::<Sha256>::extract(Some(salt.as_bytes()), ikm);
1720
tag[..4].try_into().expect("correct length")
1821
}
22+
23+
fn label_pq_only() -> HashSet<String> {
24+
["postquantum".into()].into_iter().collect()
25+
}

age/src/native/tagpq.rs

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
//! The "tagpq" 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::{zeroize::Zeroize, ExposeSecret},
10+
};
11+
use base64::{prelude::BASE64_STANDARD_NO_PAD, Engine};
12+
use bech32::{Bech32, Hrp};
13+
use hpke::{Deserializable, Serializable};
14+
use ml_kem::{KemCore, MlKem768};
15+
use p256::elliptic_curve::sec1::ToEncodedPoint;
16+
use rand::rngs::OsRng;
17+
18+
use crate::{util::parse_bech32, EncryptError};
19+
20+
mod kem;
21+
22+
const RECIPIENT_PREFIX: &str = "age1tagpq";
23+
24+
const MLKEM768P256TAG_RECIPIENT_TAG: &str = "mlkem768p256tag";
25+
const MLKEM768P256TAG_SALT: &str = "age-encryption.org/mlkem768p256tag";
26+
27+
type Kem = kem::MlKem768P256;
28+
29+
pub(crate) fn expand_pq_key(
30+
seed: &[u8; 64],
31+
) -> (
32+
<MlKem768 as KemCore>::DecapsulationKey,
33+
<MlKem768 as KemCore>::EncapsulationKey,
34+
) {
35+
let mut d = [0; 32];
36+
let mut z = [0; 32];
37+
d.copy_from_slice(&seed[..32]);
38+
z.copy_from_slice(&seed[32..]);
39+
40+
let (dk_pq, ek_pq) = MlKem768::generate_deterministic(&d.into(), &z.into());
41+
42+
d.zeroize();
43+
z.zeroize();
44+
45+
(dk_pq, ek_pq)
46+
}
47+
48+
/// The hybrid post-quantum tagged age recipient type, designed for hardware keys where
49+
/// decryption potentially requires user presence.
50+
///
51+
/// With knowledge of the recipient, it is possible to check if a stanza was addressed to
52+
/// a specific recipient before attempting decryption. This offers less privacy than the
53+
/// untagged recipient types.
54+
#[derive(Clone, PartialEq)]
55+
pub struct Recipient(<Kem as hpke::Kem>::PublicKey);
56+
57+
impl std::str::FromStr for Recipient {
58+
type Err = &'static str;
59+
60+
/// Parses a recipient key from a string.
61+
fn from_str(s: &str) -> Result<Self, Self::Err> {
62+
parse_bech32(s)
63+
.ok_or("invalid Bech32 encoding")
64+
.and_then(|(hrp, bytes)| {
65+
if hrp == RECIPIENT_PREFIX {
66+
<Kem as hpke::Kem>::PublicKey::from_bytes(&bytes)
67+
.map_err(|_| "invalid recipient")
68+
.map(Self)
69+
} else {
70+
Err("incorrect HRP")
71+
}
72+
})
73+
}
74+
}
75+
76+
impl fmt::Display for Recipient {
77+
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
78+
write!(
79+
f,
80+
"{}",
81+
bech32::encode::<Bech32>(Hrp::parse_unchecked(RECIPIENT_PREFIX), &self.0.to_bytes())
82+
.expect("HRP is valid")
83+
)
84+
}
85+
}
86+
87+
impl fmt::Debug for Recipient {
88+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89+
write!(f, "{}", self)
90+
}
91+
}
92+
93+
impl crate::Recipient for Recipient {
94+
fn wrap_file_key(
95+
&self,
96+
file_key: &FileKey,
97+
) -> Result<(Vec<Stanza>, HashSet<String>), EncryptError> {
98+
let (enc, ct) = hpke_seal::<Kem, _>(
99+
&self.0,
100+
MLKEM768P256TAG_SALT.as_bytes(),
101+
file_key.expose_secret(),
102+
&mut OsRng,
103+
);
104+
105+
let ikm = enc
106+
.to_bytes()
107+
.into_iter()
108+
.chain(super::static_tag(
109+
self.0.ek_t.to_encoded_point(false).as_bytes(),
110+
))
111+
.collect::<Vec<u8>>();
112+
let tag = super::stanza_tag(&ikm, MLKEM768P256TAG_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: MLKEM768P256TAG_RECIPIENT_TAG.to_owned(),
120+
args: vec![encoded_tag, encoded_enc],
121+
body: ct,
122+
}],
123+
super::label_pq_only(),
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+
"age1tagpq1m3e4wvp6hzcrn9exhy0ae3xfx2sjymp594k3tg7j4dpmj922we65vtnmrt2pyallax8669zqkr2pmfchptr4n38kug2xmcmp3adk2lnjqu00x5kxz5pvhmrltvfh9wuq973pcx35cnq8syn9qd3tzpehgztl4xpzr3tpd67g8af9trnjpc05gh7wu536aq4qt2y8zhsm4tvrfpsfl36qs5fpzysnk3sp9w77qzeg49357xex40v4s2lvt620swyys7u8yxdcnu4rkkwxdmt55gsuc3h5c5swahnegjgqwc60hn085ec3sjztwm45l44y3j2at9t6v9zra4ek3kek6waecqm98yaxl37w0d2zra626nz63jdm5sg59w7lyptw83zm6fntd8d0x03a9z6h9prfgpygzar6zrxjcrt4cdctk2mhf95s4a6v4zklfd49xhpsaeujm57thx2x3e3hwzc86ftfhmq5mkxxz3d6r8ws24xj4qfn73eyezg2wy094e3why592pghz27ruq3vkyegrv80eftnw9wqzwgvnwyseaus0yt84fylzrpzp6x2fguxuqjmgudr8xd33qm30evdpxd3jvjg8qh4q60kyq80jgff369k7nrepdc38grd2dava520excqp0ey0x39khx8ry03yffcatgv84fsx5j49djpapedsy693zute5xv5g2ewzrlj5se7akvkc4g4vmzhputpq8eyj9wz5dz6qtn7g3cfpd95nahw4ytspan0feyye04dcylv24ege7zkaj004gjwcxqxfqu2quawa83sx452jqjn8t48czp0xspwgnmvjyhttzzy6nhq8xzkdwnvsfefkwva6asrqc93zjn4rly5gnlv93xy3uzmr39szvjnf63426qzyeyvguc4vdcquwgsxgq236afcpqz866ny4tn7ckc0umefj242rt5vtvwqzzrvfev2mpvqcufp9pqvefyv4ftyuhgausfzuaadsczeykmft5wv3frzgrcp9ztr93h478ke4t86spp2uhyjkj73mp9g92ddk2fpv7v3njzsqgwhq3789sqrgkskehn0zjscckhwftyq4vet7vrlx2hs5kd9cwnq6t0djffhh3zquh4j3p0yaj9z2rc9wykg0usqw7983rrgur9jg8rnnqypwcz2lyclnnc705fc5g3an93ps60q6mxqp85u0ewtxdjlqcks84yduft0a0g6e7naew3v9u2d08knarvajn8q3gq9pgxde3s7nx94lus48wwvw2xjm7k82tvylec2393jdsuvch2xpe77w8hpv9nvsxfsrs270njpmfvpmgyk2cffl9tjp3qqcc4dfkf5rme2dg0x7ew8g39www5smm705q5da4eqvnqwrkavtq6xje9ss38hnkglz4eddz8f5qruvqmq2ff9l22gwkv8h432rdkysy0grkul8e2fedvkyyapfxt760udcgu92m54wl9yavmj4ga3ph9r5n99cjrq6wj5v33x33fe5vkjvfwnnt40wuv2hyexc9f4ylyqv9ldqq9epd4yuv8vrsfx2qy2kqz08kqhnzspy6s0x8fa5c2xkg5y2q0rvz4vnk7rp0acg6eksc3t7cxnn8y7glkjsqja3p56uz6vvhcw55d3ysad0hvsqxpjnc7svenf2gc5xn5kyr0et2vvyruxlnpqcdpqh9pzplumy5yzjxftyzh9ujfw0jq7ee60zx2x23p0jzyh9dvmly8p9h9ysptlqu7kwnejd65dnr75a0np2fvke8xen38r57w6z3wz3mycjmmn267wwxndfh9jdps7uxtct2wwfgamkpa5ap8s96lhfjztpwcm6fguhphu38yunu2v4vz3syzrvgwtqpemkewzp766nyu6texxvjlaemnhyyqutkcy6a42vqfsz49rw5wr4gt70r4vdaasehqjg46fnyts4sthrxadfllha3avu49wsj2c4jx";
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+
}

0 commit comments

Comments
 (0)