Skip to main content

cert_helper/certificate/
key.rs

1use foreign_types::ForeignType;
2use openssl::ec::{EcGroup, EcKey};
3use openssl::error::ErrorStack;
4use openssl::nid::Nid;
5use openssl::pkey::{Id, PKey, Private};
6use openssl::rsa::Rsa;
7use openssl::x509::{X509, X509Req};
8
9unsafe extern "C" {
10    pub fn X509_sign(
11        x: *mut openssl_sys::X509,
12        pkey: *mut openssl_sys::EVP_PKEY,
13        md: *const openssl_sys::EVP_MD,
14    ) -> ::std::os::raw::c_int;
15    pub fn X509_sign_ctx(
16        x: *mut openssl_sys::X509,
17        ctx: *mut openssl_sys::EVP_MD_CTX,
18    ) -> ::std::os::raw::c_int;
19}
20
21unsafe extern "C" {
22    pub fn X509_REQ_sign(
23        req: *mut openssl_sys::X509_REQ,
24        pkey: *mut openssl_sys::EVP_PKEY,
25        md: *const openssl_sys::EVP_MD,
26    ) -> ::std::os::raw::c_int;
27    pub fn X509_REQ_sign_ctx(
28        req: *mut openssl_sys::X509_REQ,
29        ctx: *mut openssl_sys::EVP_MD_CTX,
30    ) -> ::std::os::raw::c_int;
31}
32/// Sign a just-built `X509` in-place with a digest-less key (Ed25519 or PQC).
33///
34/// Ed25519 uses the plain `X509_sign(x, pkey, NULL)` path that has always
35/// worked. PQC keys (ML-DSA / SLH-DSA) need a workaround on OpenSSL 3.5+:
36/// `X509_sign(_, _, NULL)` triggers default-digest inference in
37/// `do_sigver_init`, and the PQC providers then reject the inferred digest
38/// with "Explicit digest not supported". We instead initialise an `EVP_MD_CTX`
39/// with an *empty* C string as `mdname` — that bypasses the default-digest
40/// lookup inside OpenSSL while still satisfying the provider's
41/// `mdname[0] != '\0'` guard — and hand the ctx to `X509_sign_ctx`.
42pub(crate) fn sign_certificate_digestless(
43    cert: &X509,
44    pkey: &PKey<openssl::pkey::Private>,
45) -> Result<(), String> {
46    if !is_digestless_key(pkey) {
47        return Err("sign_certificate_digestless called with non-digestless key".to_string());
48    }
49    let cert_ptr = cert.as_ptr();
50    let pkey_ptr = pkey.as_ptr();
51
52    if pkey.id() == Id::ED25519 {
53        let result = unsafe { X509_sign(cert_ptr, pkey_ptr, std::ptr::null()) };
54        return if result > 0 {
55            Ok(())
56        } else {
57            Err("Failed to sign certificate with Ed25519".to_string())
58        };
59    }
60
61    // PQC path: EVP_DigestSignInit (non-ex) with NULL mdname + X509_sign_ctx.
62    // `Signer::new_without_digest` in the openssl crate uses this exact call and
63    // it works for ML-DSA/SLH-DSA whereas `EVP_DigestSignInit_ex` does not.
64    // SAFETY: `ctx` is owned by `MdCtx` and freed on every path (early return or
65    // scope exit). The internal EVP_PKEY_CTX created by EVP_DigestSignInit (NULL
66    // pctx arg) is owned by `ctx` and released with it. `pkey_ptr`/`cert_ptr` are
67    // borrows from live wrappers and are not freed here.
68    let ctx = MdCtx(unsafe { openssl_sys::EVP_MD_CTX_new() });
69    if ctx.0.is_null() {
70        return Err("EVP_MD_CTX_new returned NULL".to_string());
71    }
72    let init = unsafe {
73        openssl_sys::EVP_DigestSignInit(
74            ctx.0,
75            std::ptr::null_mut(),
76            std::ptr::null(),
77            std::ptr::null_mut(),
78            pkey_ptr,
79        )
80    };
81    if init <= 0 {
82        return Err("EVP_DigestSignInit failed for PQC key".to_string());
83    }
84    let result = unsafe { X509_sign_ctx(cert_ptr, ctx.0) };
85
86    if result > 0 {
87        Ok(())
88    } else {
89        Err("X509_sign_ctx failed for PQC key".to_string())
90    }
91}
92
93/// Same as `sign_certificate_digestless` but for `X509Req`. See the
94/// `sign_certificate_digestless` docstring for why Ed25519 and PQC take
95/// different OpenSSL paths.
96pub(crate) fn sign_x509_req_digestless(req: &X509Req, pkey: &PKey<Private>) -> Result<(), String> {
97    if !is_digestless_key(pkey) {
98        return Err("sign_x509_req_digestless called with non-digestless key".to_string());
99    }
100    let req_ptr = req.as_ptr();
101    let pkey_ptr = pkey.as_ptr();
102
103    if pkey.id() == Id::ED25519 {
104        let result = unsafe { X509_REQ_sign(req_ptr, pkey_ptr, std::ptr::null()) };
105        return if result > 0 {
106            Ok(())
107        } else {
108            Err("Failed to sign X509Req with Ed25519".to_string())
109        };
110    }
111
112    // SAFETY: same invariants as in `sign_certificate_digestless` — `ctx` is
113    // owned by `MdCtx` and freed on every path; `pkey_ptr`/`req_ptr` are borrows.
114    let ctx = MdCtx(unsafe { openssl_sys::EVP_MD_CTX_new() });
115    if ctx.0.is_null() {
116        return Err("EVP_MD_CTX_new returned NULL".to_string());
117    }
118    let init = unsafe {
119        openssl_sys::EVP_DigestSignInit(
120            ctx.0,
121            std::ptr::null_mut(),
122            std::ptr::null(),
123            std::ptr::null_mut(),
124            pkey_ptr,
125        )
126    };
127    if init <= 0 {
128        return Err("EVP_DigestSignInit failed for PQC key".to_string());
129    }
130    let result = unsafe { X509_REQ_sign_ctx(req_ptr, ctx.0) };
131
132    if result > 0 {
133        Ok(())
134    } else {
135        Err("X509_REQ_sign_ctx failed for PQC key".to_string())
136    }
137}
138
139/// FIPS 203 ML-KEM algorithm OIDs, arc `2.16.840.1.101.3.4.4.x`. These are the
140/// `id-alg-ml-kem-*` identifiers from draft-ietf-lamps-kyber-certificates that
141/// appear in an ML-KEM `SubjectPublicKeyInfo`. Detection in [`is_mlkem_pkey`] is
142/// by OpenSSL EVP algorithm name (provider-agnostic, like [`is_pqc_pkey`]); the
143/// OIDs are kept here for reference since `oid-registry` does not know them yet.
144#[cfg(feature = "pqc")]
145#[allow(dead_code)]
146const ML_KEM_OIDS: [&str; 3] = [
147    "2.16.840.1.101.3.4.4.1", // id-alg-ml-kem-512
148    "2.16.840.1.101.3.4.4.2", // id-alg-ml-kem-768
149    "2.16.840.1.101.3.4.4.3", // id-alg-ml-kem-1024
150];
151
152/// Sign a just-built `X509` in-place with a digest-less key (Ed25519 or PQC).
153///
154/// We avoid `X509_sign(x, pkey, NULL)` because OpenSSL 3.5+ infers a default
155/// digest for ML-DSA/SLH-DSA in that path, which their providers then reject.
156/// Instead we initialise an `EVP_MD_CTX` with an explicit NULL `mdname` and
157/// hand it to `X509_sign_ctx`.
158/// RAII guard that frees an `EVP_MD_CTX` on drop, including on early return and
159/// unwind. Keeps the digest-less signing paths leak-free without manual
160/// `EVP_MD_CTX_free` on every branch. Mirrors `pqc::PkeyCtx`.
161struct MdCtx(*mut openssl_sys::EVP_MD_CTX);
162
163impl Drop for MdCtx {
164    fn drop(&mut self) {
165        if !self.0.is_null() {
166            // SAFETY: freed exactly once (on every path including unwind);
167            // EVP_MD_CTX_free is a no-op on NULL.
168            unsafe { openssl_sys::EVP_MD_CTX_free(self.0) }
169        }
170    }
171}
172
173/// Defines what type of key that can be used with the certificate
174#[derive(Debug, Clone, PartialEq)]
175pub enum KeyType {
176    /// RSA key with a 2048-bit length.
177    RSA2048,
178    /// RSA key with a 4096-bit length.
179    RSA4096,
180    /// Elliptic Curve key using the NIST P-224 curve (secp224r1).
181    P224,
182    /// Elliptic Curve key using the NIST P-256 curve (secp256r1). Also known as prime256v1.
183    P256,
184    /// Elliptic Curve key using the NIST P-384 curve (secp384r1).
185    P384,
186    /// Elliptic Curve key using the NIST P-521 curve (secp521r1).
187    P521,
188    /// Edwards-curve Digital Signature Algorithm using Ed25519.
189    Ed25519,
190    /// ML-DSA-44 (FIPS 204, formerly Dilithium2). Post-quantum lattice signature.
191    #[cfg(feature = "pqc")]
192    MlDsa44,
193    /// ML-DSA-65 (FIPS 204, formerly Dilithium3). Post-quantum lattice signature.
194    #[cfg(feature = "pqc")]
195    MlDsa65,
196    /// ML-DSA-87 (FIPS 204, formerly Dilithium5). Post-quantum lattice signature.
197    #[cfg(feature = "pqc")]
198    MlDsa87,
199    /// SLH-DSA-SHA2-128s (FIPS 205, formerly SPHINCS+). Hash-based signature, small variant.
200    #[cfg(feature = "pqc")]
201    SlhDsaSha2_128s,
202    /// SLH-DSA-SHA2-192s (FIPS 205). Hash-based signature, medium variant.
203    #[cfg(feature = "pqc")]
204    SlhDsaSha2_192s,
205    /// SLH-DSA-SHA2-256s (FIPS 205). Hash-based signature, large variant.
206    #[cfg(feature = "pqc")]
207    SlhDsaSha2_256s,
208    /// ML-KEM-512 (FIPS 203, formerly Kyber). Post-quantum key-encapsulation
209    /// key. Encapsulation/encryption only — cannot sign. See [`KeyType`] notes
210    /// on ML-KEM: only `keyEncipherment` is a valid KeyUsage and certificates
211    /// must be issued by a separate signing CA, not self-signed.
212    #[cfg(feature = "pqc")]
213    MlKem512,
214    /// ML-KEM-768 (FIPS 203). Post-quantum key-encapsulation key. See
215    /// [`KeyType::MlKem512`].
216    #[cfg(feature = "pqc")]
217    MlKem768,
218    /// ML-KEM-1024 (FIPS 203). Post-quantum key-encapsulation key. See
219    /// [`KeyType::MlKem512`].
220    #[cfg(feature = "pqc")]
221    MlKem1024,
222}
223
224pub(crate) fn select_key(key_type: &Option<KeyType>) -> Result<PKey<Private>, ErrorStack> {
225    match key_type {
226        Some(KeyType::P224) => {
227            let group = EcGroup::from_curve_name(Nid::SECP224R1)?;
228            let ec_key = EcKey::generate(&group)?;
229            PKey::from_ec_key(ec_key)
230        }
231        Some(KeyType::P256) => {
232            let group = EcGroup::from_curve_name(Nid::X9_62_PRIME256V1)?;
233            let ec_key = EcKey::generate(&group)?;
234            PKey::from_ec_key(ec_key)
235        }
236        Some(KeyType::P384) => {
237            let group = EcGroup::from_curve_name(Nid::SECP384R1)?;
238            let ec_key = EcKey::generate(&group)?;
239            PKey::from_ec_key(ec_key)
240        }
241        Some(KeyType::P521) => {
242            let group = EcGroup::from_curve_name(Nid::SECP521R1)?;
243            let ec_key = EcKey::generate(&group)?;
244            PKey::from_ec_key(ec_key)
245        }
246        Some(KeyType::Ed25519) => PKey::generate_ed25519(),
247        #[cfg(feature = "pqc")]
248        Some(KeyType::MlDsa44) => generate_pqc_key("ML-DSA-44"),
249        #[cfg(feature = "pqc")]
250        Some(KeyType::MlDsa65) => generate_pqc_key("ML-DSA-65"),
251        #[cfg(feature = "pqc")]
252        Some(KeyType::MlDsa87) => generate_pqc_key("ML-DSA-87"),
253        #[cfg(feature = "pqc")]
254        Some(KeyType::SlhDsaSha2_128s) => generate_pqc_key("SLH-DSA-SHA2-128s"),
255        #[cfg(feature = "pqc")]
256        Some(KeyType::SlhDsaSha2_192s) => generate_pqc_key("SLH-DSA-SHA2-192s"),
257        #[cfg(feature = "pqc")]
258        Some(KeyType::SlhDsaSha2_256s) => generate_pqc_key("SLH-DSA-SHA2-256s"),
259        #[cfg(feature = "pqc")]
260        Some(KeyType::MlKem512) => generate_pqc_key("ML-KEM-512"),
261        #[cfg(feature = "pqc")]
262        Some(KeyType::MlKem768) => generate_pqc_key("ML-KEM-768"),
263        #[cfg(feature = "pqc")]
264        Some(KeyType::MlKem1024) => generate_pqc_key("ML-KEM-1024"),
265        Some(KeyType::RSA4096) => {
266            let rsa = Rsa::generate(4096)?;
267            PKey::from_rsa(rsa)
268        }
269        _ => {
270            let rsa = Rsa::generate(2048)?;
271            PKey::from_rsa(rsa)
272        }
273    }
274}
275
276#[cfg(feature = "pqc")]
277mod pqc {
278    use foreign_types::ForeignType;
279    use openssl::error::ErrorStack;
280    use openssl::pkey::{PKey, Private};
281    use std::ffi::CString;
282
283    unsafe extern "C" {
284        fn EVP_PKEY_CTX_new_from_name(
285            libctx: *mut std::ffi::c_void,
286            name: *const std::os::raw::c_char,
287            propquery: *const std::os::raw::c_char,
288        ) -> *mut openssl_sys::EVP_PKEY_CTX;
289        fn EVP_PKEY_keygen_init(ctx: *mut openssl_sys::EVP_PKEY_CTX) -> std::os::raw::c_int;
290        fn EVP_PKEY_generate(
291            ctx: *mut openssl_sys::EVP_PKEY_CTX,
292            ppkey: *mut *mut openssl_sys::EVP_PKEY,
293        ) -> std::os::raw::c_int;
294        fn EVP_PKEY_CTX_free(ctx: *mut openssl_sys::EVP_PKEY_CTX);
295        /// Returns 1 if `pkey` is of algorithm `name`, 0 otherwise.
296        /// Use this instead of `EVP_PKEY_id` for provider-only algorithms
297        /// (ML-DSA, SLH-DSA) whose legacy NID is -1.
298        pub fn EVP_PKEY_is_a(
299            pkey: *mut openssl_sys::EVP_PKEY,
300            name: *const std::os::raw::c_char,
301        ) -> std::os::raw::c_int;
302    }
303
304    /// RAII guard that frees an `EVP_PKEY_CTX` on drop, including unwinds.
305    struct PkeyCtx(*mut openssl_sys::EVP_PKEY_CTX);
306
307    impl Drop for PkeyCtx {
308        fn drop(&mut self) {
309            if !self.0.is_null() {
310                unsafe { EVP_PKEY_CTX_free(self.0) }
311            }
312        }
313    }
314
315    /// Generate a post-quantum signing key by OpenSSL EVP algorithm name.
316    ///
317    /// Accepts the FIPS 204 / FIPS 205 canonical names:
318    /// `"ML-DSA-44"`, `"ML-DSA-65"`, `"ML-DSA-87"`,
319    /// `"SLH-DSA-SHA2-128s"`, `"SLH-DSA-SHA2-192s"`, `"SLH-DSA-SHA2-256s"`.
320    ///
321    /// Returns `Err(ErrorStack)` if the algorithm is unknown to the linked
322    /// OpenSSL, keygen init fails, or key generation fails. Never panics,
323    /// never leaks the `EVP_PKEY_CTX`.
324    pub(crate) fn generate_pqc_key(alg_name: &str) -> Result<PKey<Private>, ErrorStack> {
325        let cname = CString::new(alg_name).expect("alg_name contains interior NUL");
326
327        // SAFETY: NULL libctx => default library context. NULL propquery matches
328        // every provider. The returned ctx is owned by PkeyCtx, freed on all paths.
329        let ctx_ptr = unsafe {
330            EVP_PKEY_CTX_new_from_name(std::ptr::null_mut(), cname.as_ptr(), std::ptr::null())
331        };
332        if ctx_ptr.is_null() {
333            return Err(ErrorStack::get());
334        }
335        let ctx = PkeyCtx(ctx_ptr);
336
337        if unsafe { EVP_PKEY_keygen_init(ctx.0) } <= 0 {
338            return Err(ErrorStack::get());
339        }
340
341        let mut pkey_ptr: *mut openssl_sys::EVP_PKEY = std::ptr::null_mut();
342        if unsafe { EVP_PKEY_generate(ctx.0, &mut pkey_ptr) } <= 0 {
343            return Err(ErrorStack::get());
344        }
345        if pkey_ptr.is_null() {
346            return Err(ErrorStack::get());
347        }
348
349        // SAFETY: EVP_PKEY_generate returned ownership of a freshly-allocated
350        // EVP_PKEY. PKey::from_ptr takes ownership and frees on drop.
351        Ok(unsafe { PKey::<Private>::from_ptr(pkey_ptr) })
352    }
353}
354#[cfg(feature = "pqc")]
355pub(crate) use pqc::generate_pqc_key;
356
357#[cfg(feature = "pqc")]
358pub(crate) fn is_pqc_pkey<T>(pkey: &PKey<T>) -> bool {
359    use std::ffi::CString;
360    use std::sync::OnceLock;
361
362    // Cache the CStrings so we don't rebuild them per call.
363    static NAMES: OnceLock<[CString; 6]> = OnceLock::new();
364    let names = NAMES.get_or_init(|| {
365        [
366            CString::new("ML-DSA-44").unwrap(),
367            CString::new("ML-DSA-65").unwrap(),
368            CString::new("ML-DSA-87").unwrap(),
369            CString::new("SLH-DSA-SHA2-128s").unwrap(),
370            CString::new("SLH-DSA-SHA2-192s").unwrap(),
371            CString::new("SLH-DSA-SHA2-256s").unwrap(),
372        ]
373    });
374    use foreign_types::ForeignType;
375    let ptr = pkey.as_ptr();
376    names
377        .iter()
378        // SAFETY: EVP_PKEY_is_a accepts any NUL-terminated C string and a
379        // valid EVP_PKEY*; returns 0 for mismatch, 1 for match — never UB.
380        .any(|n| unsafe { pqc::EVP_PKEY_is_a(ptr, n.as_ptr()) } == 1)
381}
382
383/// Returns true if `pkey` is an ML-KEM (FIPS 203) key-encapsulation key.
384///
385/// This is deliberately separate from [`is_pqc_pkey`]: ML-KEM keys are *not*
386/// signature keys. Per draft-ietf-lamps-kyber-certificates they may only assert
387/// the `keyEncipherment` KeyUsage bit, and they cannot produce signatures — so
388/// they can neither self-sign a certificate nor sign a CSR. Keeping them out of
389/// `is_pqc_pkey` also keeps them out of [`is_digestless_key`], which gates the
390/// signing path.
391#[cfg(feature = "pqc")]
392pub(crate) fn is_mlkem_pkey<T>(pkey: &PKey<T>) -> bool {
393    use std::ffi::CString;
394    use std::sync::OnceLock;
395
396    // Cache the CStrings so we don't rebuild them per call.
397    static NAMES: OnceLock<[CString; 3]> = OnceLock::new();
398    let names = NAMES.get_or_init(|| {
399        [
400            CString::new("ML-KEM-512").unwrap(),
401            CString::new("ML-KEM-768").unwrap(),
402            CString::new("ML-KEM-1024").unwrap(),
403        ]
404    });
405    use foreign_types::ForeignType;
406    let ptr = pkey.as_ptr();
407    names
408        .iter()
409        // SAFETY: EVP_PKEY_is_a accepts any NUL-terminated C string and a
410        // valid EVP_PKEY*; returns 0 for mismatch, 1 for match — never UB.
411        .any(|n| unsafe { pqc::EVP_PKEY_is_a(ptr, n.as_ptr()) } == 1)
412}
413
414/// Returns true for keys whose OpenSSL EVP signing path does not take an
415/// external digest. Today: Ed25519 and (when the `pqc` feature is enabled)
416/// the six FIPS 204 / 205 post-quantum variants.
417pub(crate) fn is_digestless_key(pkey: &PKey<Private>) -> bool {
418    if pkey.id() == Id::ED25519 {
419        return true;
420    }
421    #[cfg(feature = "pqc")]
422    {
423        return is_pqc_pkey(pkey);
424    }
425    #[allow(unreachable_code)]
426    false
427}
428
429/// Reject signing operations that an ML-KEM key cannot perform.
430///
431/// ML-KEM is a key-encapsulation mechanism and cannot produce signatures, so it
432/// can neither self-sign a certificate nor sign a CSR. `message` lets the caller
433/// supply the context-specific guidance. Returns `Ok(())` for any non-ML-KEM key.
434#[cfg(feature = "pqc")]
435pub(crate) fn reject_mlkem_signing(
436    pkey: &PKey<Private>,
437    message: &'static str,
438) -> Result<(), Box<dyn std::error::Error>> {
439    if is_mlkem_pkey(pkey) {
440        return Err(message.into());
441    }
442    Ok(())
443}