Skip to content

Commit 9aca24a

Browse files
committed
feat(x25519-dalek): add PKCS#8 and PEM private key support
Introduce support for encoding and decoding X25519 private keys using the PKCS#8 standard (both DER and PEM formats). This enables interoperability with OpenSSL, TLS stacks, and other crypto systems. Changes: - Add `pkcs8` crate as optional dependency - Implement `EncodePrivateKey` and `TryFrom<PrivateKeyInfoRef>` - Handle nested OCTET STRING structure required by RFC - Add tests for DER/PEM encoding round-trips - Wire up `pem` feature behind feature gate Signed-off-by: Kun Lai <[email protected]>
1 parent 9e04a58 commit 9aca24a

File tree

3 files changed

+201
-1
lines changed

3 files changed

+201
-1
lines changed

x25519-dalek/Cargo.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ serde = { version = "1", default-features = false, optional = true, features = [
4848
"derive",
4949
] }
5050
zeroize = { version = "1", default-features = false, optional = true }
51+
pkcs8 = { version = "0.11.0-rc.8", optional = true }
5152

5253
[dev-dependencies]
5354
bincode = "1"
@@ -63,7 +64,9 @@ default = ["alloc", "precomputed-tables", "zeroize"]
6364
os_rng = ["rand_core/os_rng"]
6465
zeroize = ["dep:zeroize", "curve25519-dalek/zeroize"]
6566
serde = ["dep:serde", "curve25519-dalek/serde"]
66-
alloc = ["curve25519-dalek/alloc", "serde?/alloc", "zeroize?/alloc"]
67+
alloc = ["curve25519-dalek/alloc", "serde?/alloc", "zeroize?/alloc", "pkcs8?/alloc"]
6768
precomputed-tables = ["curve25519-dalek/precomputed-tables"]
6869
reusable_secrets = []
6970
static_secrets = []
71+
pkcs8 = ["dep:pkcs8"]
72+
pem = ["alloc", "pkcs8/pem", "pkcs8"]

x25519-dalek/src/x25519.rs

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@
1616
1717
use curve25519_dalek::{edwards::EdwardsPoint, montgomery::MontgomeryPoint, traits::IsIdentity};
1818

19+
#[cfg(all(feature = "alloc", feature = "pkcs8"))]
20+
use pkcs8::{EncodePrivateKey, SecretDocument, der::asn1::OctetStringRef};
21+
#[cfg(feature = "pkcs8")]
22+
use pkcs8::{ObjectIdentifier, PrivateKeyInfoRef};
1923
use rand_core::CryptoRng;
2024
#[cfg(feature = "os_rng")]
2125
use rand_core::TryRngCore;
@@ -392,3 +396,98 @@ pub fn x25519(k: [u8; 32], u: [u8; 32]) -> [u8; 32] {
392396
pub const X25519_BASEPOINT_BYTES: [u8; 32] = [
393397
9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
394398
];
399+
400+
/// Algorithm [`ObjectIdentifier`] for the X25519 digital signature algorithm
401+
/// (`id-X25519`).
402+
///
403+
/// <http://oid-info.com/get/1.3.101.110>
404+
#[cfg(feature = "pkcs8")]
405+
pub const ALGORITHM_OID: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.3.101.110");
406+
407+
/// X25519 Algorithm Identifier.
408+
#[cfg(feature = "pkcs8")]
409+
pub const ALGORITHM_ID: pkcs8::AlgorithmIdentifierRef<'static> = pkcs8::AlgorithmIdentifierRef {
410+
oid: ALGORITHM_OID,
411+
parameters: None,
412+
};
413+
414+
#[cfg(all(feature = "alloc", feature = "pkcs8"))]
415+
impl EncodePrivateKey for EphemeralSecret {
416+
fn to_pkcs8_der(&self) -> Result<SecretDocument, pkcs8::Error> {
417+
to_pkcs8_der(&self.0, &PublicKey::from(self).0.0)
418+
}
419+
}
420+
421+
#[cfg(all(feature = "alloc", feature = "pkcs8", feature = "static_secrets"))]
422+
impl EncodePrivateKey for StaticSecret {
423+
fn to_pkcs8_der(&self) -> Result<SecretDocument, pkcs8::Error> {
424+
to_pkcs8_der(&self.0, &PublicKey::from(self).0.0)
425+
}
426+
}
427+
428+
#[cfg(all(feature = "alloc", feature = "pkcs8"))]
429+
fn to_pkcs8_der(
430+
private_key_bytes: &[u8; 32],
431+
public_key_bytes: &[u8; 32],
432+
) -> Result<SecretDocument, pkcs8::Error> {
433+
// Serialize private key as nested OCTET STRING
434+
let mut private_key = [0u8; 2 + 32];
435+
private_key[0] = 0x04;
436+
private_key[1] = 0x20;
437+
private_key[2..].copy_from_slice(private_key_bytes);
438+
439+
let private_key_info = PrivateKeyInfoRef {
440+
algorithm: ALGORITHM_ID,
441+
private_key: OctetStringRef::new(&private_key)?,
442+
public_key: Some(pkcs8::der::asn1::BitStringRef::new(0, public_key_bytes)?),
443+
};
444+
445+
let result = SecretDocument::encode_msg(&private_key_info)?;
446+
447+
#[cfg(feature = "zeroize")]
448+
private_key.zeroize();
449+
450+
Ok(result)
451+
}
452+
453+
#[cfg(all(feature = "pkcs8"))]
454+
impl TryFrom<PrivateKeyInfoRef<'_>> for EphemeralSecret {
455+
type Error = pkcs8::Error;
456+
457+
fn try_from(private_key: PrivateKeyInfoRef<'_>) -> Result<Self, pkcs8::Error> {
458+
Ok(Self(to_private_key_bytes(private_key)?))
459+
}
460+
}
461+
462+
#[cfg(all(feature = "pkcs8", feature = "static_secrets"))]
463+
impl TryFrom<PrivateKeyInfoRef<'_>> for StaticSecret {
464+
type Error = pkcs8::Error;
465+
466+
fn try_from(private_key: PrivateKeyInfoRef<'_>) -> Result<Self, pkcs8::Error> {
467+
Ok(Self(to_private_key_bytes(private_key)?))
468+
}
469+
}
470+
471+
#[cfg(feature = "pkcs8")]
472+
fn to_private_key_bytes(private_key: PrivateKeyInfoRef<'_>) -> Result<[u8; 32], pkcs8::Error> {
473+
private_key.algorithm.assert_algorithm_oid(ALGORITHM_OID)?;
474+
475+
if private_key.algorithm.parameters.is_some() {
476+
return Err(pkcs8::Error::ParametersMalformed);
477+
}
478+
479+
// X25519 PKCS#8 keys are represented as a nested OCTET STRING
480+
// (i.e. an OCTET STRING within an OCTET STRING).
481+
//
482+
// This match statement checks and removes the inner OCTET STRING
483+
// header value:
484+
//
485+
// - 0x04: OCTET STRING tag
486+
// - 0x20: 32-byte length
487+
let private_key_bytes = match private_key.private_key.as_bytes() {
488+
[0x04, 0x20, rest @ ..] => rest.try_into().map_err(|_| pkcs8::Error::KeyMalformed),
489+
_ => Err(pkcs8::Error::KeyMalformed),
490+
}?;
491+
492+
Ok(private_key_bytes)
493+
}

x25519-dalek/tests/x25519_tests.rs

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,3 +223,101 @@ mod os_rng {
223223
StaticSecret::random();
224224
}
225225
}
226+
227+
#[cfg(feature = "pkcs8")]
228+
mod pkcs8 {
229+
230+
use ::pkcs8::DecodePrivateKey;
231+
use ::pkcs8::EncodePrivateKey;
232+
use ::pkcs8::LineEnding;
233+
234+
#[cfg(feature = "pem")]
235+
const PEM_DATA: &str = "-----BEGIN PRIVATE KEY-----
236+
MC4CAQAwBQYDK2VuBCIEIAgFWQfJv7DnZqs7W/aHM+aa5kXnFTlLQAso2qIAJyVT
237+
-----END PRIVATE KEY-----
238+
";
239+
240+
#[cfg(feature = "pem")]
241+
const PEM_DATA_WITH_PUBLIC_KEY: &str = "-----BEGIN PRIVATE KEY-----
242+
MFECAQEwBQYDK2VuBCIEIAgFWQfJv7DnZqs7W/aHM+aa5kXnFTlLQAso2qIAJyVT
243+
gSEAtKtoeuz21PdNOS7LH5srafvb2Hio7LaogF8aUZ+yrA4=
244+
-----END PRIVATE KEY-----
245+
";
246+
247+
#[test]
248+
#[cfg(feature = "pem")]
249+
fn decode_encode_pem() {
250+
let private_key = x25519_dalek::EphemeralSecret::from_pkcs8_pem(PEM_DATA).unwrap();
251+
assert_eq!(
252+
PEM_DATA_WITH_PUBLIC_KEY,
253+
private_key.to_pkcs8_pem(LineEnding::LF).unwrap().as_str()
254+
);
255+
let private_key =
256+
x25519_dalek::EphemeralSecret::from_pkcs8_pem(PEM_DATA_WITH_PUBLIC_KEY).unwrap();
257+
assert_eq!(
258+
PEM_DATA_WITH_PUBLIC_KEY,
259+
private_key.to_pkcs8_pem(LineEnding::LF).unwrap().as_str()
260+
);
261+
262+
#[cfg(feature = "static_secrets")]
263+
let private_key = x25519_dalek::StaticSecret::from_pkcs8_pem(PEM_DATA).unwrap();
264+
#[cfg(feature = "static_secrets")]
265+
assert_eq!(
266+
PEM_DATA_WITH_PUBLIC_KEY,
267+
private_key.to_pkcs8_pem(LineEnding::LF).unwrap().as_str()
268+
);
269+
#[cfg(feature = "static_secrets")]
270+
let private_key =
271+
x25519_dalek::StaticSecret::from_pkcs8_pem(PEM_DATA_WITH_PUBLIC_KEY).unwrap();
272+
#[cfg(feature = "static_secrets")]
273+
assert_eq!(
274+
PEM_DATA_WITH_PUBLIC_KEY,
275+
private_key.to_pkcs8_pem(LineEnding::LF).unwrap().as_str()
276+
);
277+
}
278+
279+
const DER_DATA: &[u8] = &[
280+
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x6e, 0x04, 0x22, 0x04,
281+
0x20, 0x08, 0x05, 0x59, 0x07, 0xc9, 0xbf, 0xb0, 0xe7, 0x66, 0xab, 0x3b, 0x5b, 0xf6, 0x87,
282+
0x33, 0xe6, 0x9a, 0xe6, 0x45, 0xe7, 0x15, 0x39, 0x4b, 0x40, 0x0b, 0x28, 0xda, 0xa2, 0x00,
283+
0x27, 0x25, 0x53,
284+
];
285+
286+
const DER_DATA_WITH_PUBLIC_KEY: &[u8] = &[
287+
0x30, 0x51, 0x02, 0x01, 0x01, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x6e, 0x04, 0x22, 0x04,
288+
0x20, 0x08, 0x05, 0x59, 0x07, 0xc9, 0xbf, 0xb0, 0xe7, 0x66, 0xab, 0x3b, 0x5b, 0xf6, 0x87,
289+
0x33, 0xe6, 0x9a, 0xe6, 0x45, 0xe7, 0x15, 0x39, 0x4b, 0x40, 0x0b, 0x28, 0xda, 0xa2, 0x00,
290+
0x27, 0x25, 0x53, 0x81, 0x21, 0x00, 0xb4, 0xab, 0x68, 0x7a, 0xec, 0xf6, 0xd4, 0xf7, 0x4d,
291+
0x39, 0x2e, 0xcb, 0x1f, 0x9b, 0x2b, 0x69, 0xfb, 0xdb, 0xd8, 0x78, 0xa8, 0xec, 0xb6, 0xa8,
292+
0x80, 0x5f, 0x1a, 0x51, 0x9f, 0xb2, 0xac, 0x0e,
293+
];
294+
295+
#[test]
296+
fn decode_encode_der() {
297+
let private_key = x25519_dalek::EphemeralSecret::from_pkcs8_der(DER_DATA).unwrap();
298+
assert_eq!(
299+
DER_DATA_WITH_PUBLIC_KEY,
300+
private_key.to_pkcs8_der().unwrap().as_bytes()
301+
);
302+
let private_key =
303+
x25519_dalek::EphemeralSecret::from_pkcs8_der(DER_DATA_WITH_PUBLIC_KEY).unwrap();
304+
assert_eq!(
305+
DER_DATA_WITH_PUBLIC_KEY,
306+
private_key.to_pkcs8_der().unwrap().as_bytes()
307+
);
308+
309+
#[cfg(feature = "static_secrets")]
310+
let private_key = x25519_dalek::StaticSecret::from_pkcs8_der(DER_DATA).unwrap();
311+
assert_eq!(
312+
DER_DATA_WITH_PUBLIC_KEY,
313+
private_key.to_pkcs8_der().unwrap().as_bytes()
314+
);
315+
#[cfg(feature = "static_secrets")]
316+
let private_key =
317+
x25519_dalek::StaticSecret::from_pkcs8_der(DER_DATA_WITH_PUBLIC_KEY).unwrap();
318+
assert_eq!(
319+
DER_DATA_WITH_PUBLIC_KEY,
320+
private_key.to_pkcs8_der().unwrap().as_bytes()
321+
);
322+
}
323+
}

0 commit comments

Comments
 (0)