Skip to content
This repository was archived by the owner on Aug 19, 2025. It is now read-only.

Commit 193dde5

Browse files
committed
encrypted client hello (ECH) config support
This commit updates pemfile to support reading PEM encoded encrypted client hello (ECH) items from PEM files. We identify these based on the "ECHCONFIG" header value proposed in draft-farrell-tls-pemesni[0]. For convenience, a `server_ech_configs` fn is added that expects the exact format described in draft-farrell-tls-pemesni for servers; a PEM encoded PKCS#8 encoded private key followed by the PEM encoded TLS encoded ECH config list. Both items are mandatory and an error is raised if missing. In some cases (e.g. testing client support) it may be sufficient to only read an iterator of the ECH config list items from a PEM file, skipping private keys or other items. For this the `ech_configs` fn is added, matching similar helpers for other item types. [0]: https://github.com/sftcd/pemesni/blob/44bcf7259f204a60421ea05be02a1e2859cadaa9/draft-farrell-tls-pemesni.txt
1 parent c6aafcc commit 193dde5

File tree

4 files changed

+110
-13
lines changed

4 files changed

+110
-13
lines changed

src/lib.rs

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,15 +61,16 @@ mod tests;
6161

6262
/// --- Main crate APIs:
6363
mod pemfile;
64+
6465
#[cfg(feature = "std")]
6566
pub use pemfile::{read_all, read_one};
6667
pub use pemfile::{read_one_from_slice, Error, Item};
6768
#[cfg(feature = "std")]
6869
use pki_types::PrivateKeyDer;
6970
#[cfg(feature = "std")]
7071
use pki_types::{
71-
CertificateDer, CertificateRevocationListDer, CertificateSigningRequestDer, PrivatePkcs1KeyDer,
72-
PrivatePkcs8KeyDer, PrivateSec1KeyDer,
72+
CertificateDer, CertificateRevocationListDer, CertificateSigningRequestDer, EchConfigListBytes,
73+
PrivatePkcs1KeyDer, PrivatePkcs8KeyDer, PrivateSec1KeyDer,
7374
};
7475

7576
#[cfg(feature = "std")]
@@ -104,7 +105,9 @@ pub fn private_key(rd: &mut dyn io::BufRead) -> Result<Option<PrivateKeyDer<'sta
104105
Item::Pkcs1Key(key) => return Ok(Some(key.into())),
105106
Item::Pkcs8Key(key) => return Ok(Some(key.into())),
106107
Item::Sec1Key(key) => return Ok(Some(key.into())),
107-
Item::X509Certificate(_) | Item::Crl(_) | Item::Csr(_) => continue,
108+
Item::X509Certificate(_) | Item::Crl(_) | Item::Csr(_) | Item::EchConfigs(_) => {
109+
continue
110+
}
108111
}
109112
}
110113

@@ -126,7 +129,8 @@ pub fn csr(
126129
| Item::Pkcs8Key(_)
127130
| Item::Sec1Key(_)
128131
| Item::X509Certificate(_)
129-
| Item::Crl(_) => continue,
132+
| Item::Crl(_)
133+
| Item::EchConfigs(_) => continue,
130134
}
131135
}
132136

@@ -192,3 +196,59 @@ pub fn ec_private_keys(
192196
_ => None,
193197
})
194198
}
199+
200+
/// Return a PKCS#8 private key and Encrypted Client Hello (ECH) config list from `rd`.
201+
///
202+
/// Both are mandatory and must be present in the input. The file should begin with the PEM
203+
/// encoded PKCS#8 private key, followed by the PEM encoded ECH config list.
204+
///
205+
/// See [draft-farrell-tls-pemesni.txt] and [draft-ietf-tls-esni §4][draft-ietf-tls-esni]
206+
/// for more information.
207+
///
208+
/// [draft-farrell-tls-pemesni.txt]: https://github.com/sftcd/pemesni/blob/44bcf7259f204a60421ea05be02a1e2859cadaa9/draft-farrell-tls-pemesni.txt
209+
/// [draft-ietf-tls-esni]: https://datatracker.ietf.org/doc/html/draft-ietf-tls-esni-18#section-4
210+
#[cfg(feature = "std")]
211+
pub fn server_ech_configs(
212+
rd: &mut dyn io::BufRead,
213+
) -> Result<(PrivatePkcs8KeyDer<'static>, EchConfigListBytes<'static>), io::Error> {
214+
// draft-farrell-tls-pemesni specifies the PEM format for a server's ECH config as the PEM
215+
// delimited base64 encoding of a PKCS#8 private key, and then subsequently the PEM
216+
// delimited base64 encoding of a TLS encoded ECH config. Both are mandatory.
217+
218+
let Ok(Some(Item::Pkcs8Key(private_key))) = read_one(rd) else {
219+
return Err(io::Error::new(
220+
io::ErrorKind::InvalidData,
221+
"Missing mandatory PKCS#8 private key",
222+
));
223+
};
224+
225+
let Ok(Some(Item::EchConfigs(ech_configs))) = read_one(rd) else {
226+
return Err(io::Error::new(
227+
io::ErrorKind::InvalidData,
228+
"Missing mandatory ECH config",
229+
));
230+
};
231+
232+
Ok((private_key, ech_configs))
233+
}
234+
235+
/// Return an iterator over Encrypted Client Hello (ECH) configs from `rd`.
236+
///
237+
/// Each ECH config is expected to be a PEM-delimited ("-----BEGIN ECH CONFIG-----") BASE64
238+
/// encoding of a TLS encoded ECHConfigList structure, as described in
239+
/// [draft-ietf-tls-esni §4][draft-ietf-tls-esni].
240+
///
241+
/// For server configurations that require both a private key and a config, prefer
242+
/// [server_ech_config].
243+
///
244+
/// [draft-ietf-tls-esni]: https://datatracker.ietf.org/doc/html/draft-ietf-tls-esni-18#section-4
245+
#[cfg(feature = "std")]
246+
pub fn ech_configs(
247+
rd: &mut dyn io::BufRead,
248+
) -> impl Iterator<Item = Result<EchConfigListBytes<'static>, io::Error>> + '_ {
249+
iter::from_fn(move || read_one(rd).transpose()).filter_map(|item| match item {
250+
Ok(Item::EchConfigs(ech_configs)) => Some(Ok(ech_configs)),
251+
Err(err) => Some(Err(err)),
252+
_ => None,
253+
})
254+
}

src/pemfile.rs

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ use core::ops::ControlFlow;
99
use std::io::{self, ErrorKind};
1010

1111
use pki_types::{
12-
CertificateDer, CertificateRevocationListDer, CertificateSigningRequestDer, PrivatePkcs1KeyDer,
13-
PrivatePkcs8KeyDer, PrivateSec1KeyDer,
12+
CertificateDer, CertificateRevocationListDer, CertificateSigningRequestDer, EchConfigListBytes,
13+
PrivatePkcs1KeyDer, PrivatePkcs8KeyDer, PrivateSec1KeyDer,
1414
};
1515

1616
/// The contents of a single recognised block in a PEM file.
@@ -46,6 +46,15 @@ pub enum Item {
4646
///
4747
/// Appears as "CERTIFICATE REQUEST" in PEM files.
4848
Csr(CertificateSigningRequestDer<'static>),
49+
50+
/// Encrypted client hello (ECH) configs; as specified in
51+
/// [draft-ietf-tls-esni-18 §4][draft-ietf-tls-esni-18].
52+
///
53+
/// PEM encoding specified in [draft-farrell-tls-pemesni.txt].
54+
///
55+
/// [draft-ietf-tls-esni-18]: <https://datatracker.ietf.org/doc/html/draft-ietf-tls-esni-18#section-4>
56+
/// [draft-farrell-tls-pemesni.txt]: <https://github.com/sftcd/pemesni/blob/44bcf7259f204a60421ea05be02a1e2859cadaa9/draft-farrell-tls-pemesni.txt>
57+
EchConfigs(EchConfigListBytes<'static>),
4958
}
5059

5160
/// Errors that may arise when parsing the contents of a PEM file
@@ -190,17 +199,18 @@ fn read_one_impl(
190199

191200
if let Some((section_type, end_marker)) = section.as_ref() {
192201
if line.starts_with(end_marker) {
193-
let der = base64::ENGINE
202+
let raw = base64::ENGINE
194203
.decode(&b64buf)
195204
.map_err(|err| Error::Base64Decode(format!("{err:?}")))?;
196205

197206
let item = match section_type.as_slice() {
198-
b"CERTIFICATE" => Some(Item::X509Certificate(der.into())),
199-
b"RSA PRIVATE KEY" => Some(Item::Pkcs1Key(der.into())),
200-
b"PRIVATE KEY" => Some(Item::Pkcs8Key(der.into())),
201-
b"EC PRIVATE KEY" => Some(Item::Sec1Key(der.into())),
202-
b"X509 CRL" => Some(Item::Crl(der.into())),
203-
b"CERTIFICATE REQUEST" => Some(Item::Csr(der.into())),
207+
b"CERTIFICATE" => Some(Item::X509Certificate(raw.into())),
208+
b"RSA PRIVATE KEY" => Some(Item::Pkcs1Key(raw.into())),
209+
b"PRIVATE KEY" => Some(Item::Pkcs8Key(raw.into())),
210+
b"EC PRIVATE KEY" => Some(Item::Sec1Key(raw.into())),
211+
b"X509 CRL" => Some(Item::Crl(raw.into())),
212+
b"CERTIFICATE REQUEST" => Some(Item::Csr(raw.into())),
213+
b"ECHCONFIG" => Some(Item::EchConfigs(raw.into())),
204214
_ => {
205215
*section = None;
206216
b64buf.clear();
@@ -303,6 +313,7 @@ mod base64 {
303313
GeneralPurposeConfig::new().with_decode_padding_mode(DecodePaddingMode::Indifferent),
304314
);
305315
}
316+
306317
use self::base64::Engine;
307318

308319
#[cfg(test)]

tests/data/server_ech_config.pem

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
-----BEGIN PRIVATE KEY-----
2+
MC4CAQAwBQYDK2VuBCIEICjd4yGRdsoP9gU7YT7My8DHx1Tjme8GYDXrOMCi8v1V
3+
-----END PRIVATE KEY-----
4+
-----BEGIN ECHCONFIG-----
5+
AD7+DQA65wAgACA8wVN2BtscOl3vQheUzHeIkVmKIiydUhDCliA4iyQRCwAEAAEA
6+
AQALZXhhbXBsZS5jb20AAA==
7+
-----END ECHCONFIG-----

tests/integration.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,3 +208,22 @@ fn whitespace_prefix() {
208208
assert_eq!(items.len(), 1);
209209
assert!(matches!(items[0], rustls_pemfile::Item::X509Certificate(_)));
210210
}
211+
212+
#[test]
213+
fn server_ech_configs() {
214+
let (_, _) = rustls_pemfile::server_ech_configs(&mut BufReader::new(
215+
&include_bytes!("data/server_ech_config.pem")[..],
216+
))
217+
.unwrap();
218+
}
219+
220+
#[test]
221+
fn ech_configs() {
222+
let items = rustls_pemfile::ech_configs(&mut BufReader::new(
223+
&include_bytes!("data/server_ech_config.pem")[..],
224+
))
225+
.collect::<Result<Vec<_>, _>>()
226+
.unwrap();
227+
228+
assert_eq!(items.len(), 1);
229+
}

0 commit comments

Comments
 (0)