Skip to main content

crypto/
pem_loader.rs

1// SPDX-License-Identifier: Apache-2.0
2//! PEM-header classification shared across signer backends.
3//!
4//! Every `Signer` impl in this crate had its own ad-hoc header sniff
5//! (Ed25519's tried 4 formats, RSA's tried 2, the top-level
6//! `load_signer` tried 3 — each independently parsing the same
7//! `-----BEGIN ...-----` lines). Centralizing the classification keeps
8//! the "which formats Heddle accepts" question answerable from a
9//! single source.
10//!
11//! The dispatch helper [`load_signer_from_pem`] mirrors what
12//! `lib.rs::load_signer` used to do inline, but now reads as a
13//! straight match instead of a chain of `contains()` predicates.
14
15use crate::{Ed25519Signer, P256Signer, RsaSigner, Signer, SignerError};
16
17/// The wire format inferred from a PEM blob's BEGIN line, or `Raw*`
18/// when the input is just hex/base64 seed bytes with no PEM wrapper.
19/// Each variant maps to exactly one `Signer` constructor.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum PemKind {
22    /// `-----BEGIN PRIVATE KEY-----` — RFC 5208 PKCS#8.
23    Pkcs8,
24    /// `-----BEGIN RSA PRIVATE KEY-----` — legacy PKCS#1.
25    Pkcs1Rsa,
26    /// `-----BEGIN EC PRIVATE KEY-----` — SEC1.
27    Sec1Ec,
28    /// `-----BEGIN OPENSSH PRIVATE KEY-----` — not yet supported.
29    OpenSsh,
30    /// Bare 32 hex bytes (Ed25519 seed).
31    Ed25519HexSeed,
32    /// Bare base64 — 32 bytes (seed) or 64 bytes (signing-key + public-key pair).
33    Ed25519Base64Seed,
34    Unknown,
35}
36
37/// Classify a PEM/raw-key blob by its header (or shape, for unwrapped
38/// seed material). Pure function — no I/O, no allocation beyond what
39/// the input trim implies.
40pub fn classify_pem(pem: &str) -> PemKind {
41    let trimmed = pem.trim();
42    if trimmed.contains("-----BEGIN PRIVATE KEY-----") {
43        return PemKind::Pkcs8;
44    }
45    if trimmed.contains("-----BEGIN RSA PRIVATE KEY-----") {
46        return PemKind::Pkcs1Rsa;
47    }
48    if trimmed.contains("-----BEGIN EC PRIVATE KEY-----") {
49        return PemKind::Sec1Ec;
50    }
51    if trimmed.contains("-----BEGIN OPENSSH PRIVATE KEY-----") {
52        return PemKind::OpenSsh;
53    }
54    if hex::decode(trimmed).is_ok_and(|b| b.len() == 32) {
55        return PemKind::Ed25519HexSeed;
56    }
57    use base64::Engine;
58    if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(trimmed)
59        && (bytes.len() == 32 || bytes.len() == 64)
60    {
61        return PemKind::Ed25519Base64Seed;
62    }
63    PemKind::Unknown
64}
65
66/// Dispatch a PEM blob to the right `Signer` backend.
67///
68/// Replaces the chain of `if pem_content.contains(...)` blocks that
69/// `load_signer` used to inline. PKCS#8 is ambiguous (the same BEGIN
70/// line wraps Ed25519, RSA, and EC keys), so the PKCS#8 case probes
71/// the backends in order and returns the first one that accepts the
72/// key.
73pub fn load_signer_from_pem(pem: &str) -> Result<Box<dyn Signer>, SignerError> {
74    match classify_pem(pem) {
75        PemKind::Pkcs8 => {
76            // PKCS#8 doesn't expose the algorithm in the BEGIN line, so
77            // try each backend. Ed25519 keys also encode the marker
78            // `MC4CAQ` near the start of the base64 body — checking
79            // that first avoids RSA's heavier parse on Ed25519 input.
80            if pem.contains("MC4CAQ")
81                && let Ok(s) = Ed25519Signer::from_pem(pem)
82            {
83                return Ok(Box::new(s) as Box<dyn Signer>);
84            }
85            if let Ok(s) = RsaSigner::from_pem(pem) {
86                return Ok(Box::new(s) as Box<dyn Signer>);
87            }
88            if let Ok(s) = P256Signer::from_pem(pem) {
89                return Ok(Box::new(s) as Box<dyn Signer>);
90            }
91            if let Ok(s) = Ed25519Signer::from_pem(pem) {
92                return Ok(Box::new(s) as Box<dyn Signer>);
93            }
94            Err(SignerError::UnknownKeyFormat)
95        }
96        PemKind::Pkcs1Rsa => RsaSigner::from_pem(pem).map(|s| Box::new(s) as Box<dyn Signer>),
97        PemKind::Sec1Ec => P256Signer::from_pem(pem).map(|s| Box::new(s) as Box<dyn Signer>),
98        PemKind::Ed25519HexSeed | PemKind::Ed25519Base64Seed => {
99            Ed25519Signer::from_pem(pem).map(|s| Box::new(s) as Box<dyn Signer>)
100        }
101        PemKind::OpenSsh => Err(SignerError::Pem(
102            "OpenSSH private keys are not yet supported".to_string(),
103        )),
104        PemKind::Unknown => Err(SignerError::UnknownKeyFormat),
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    #[test]
113    fn classifies_pkcs8_header() {
114        let pem = "-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBC...\n-----END PRIVATE KEY-----";
115        assert_eq!(classify_pem(pem), PemKind::Pkcs8);
116    }
117
118    #[test]
119    fn classifies_pkcs1_rsa_header() {
120        let pem = "-----BEGIN RSA PRIVATE KEY-----\nMIIBOg...\n-----END RSA PRIVATE KEY-----";
121        assert_eq!(classify_pem(pem), PemKind::Pkcs1Rsa);
122    }
123
124    #[test]
125    fn classifies_sec1_ec_header() {
126        let pem = "-----BEGIN EC PRIVATE KEY-----\nMHc...\n-----END EC PRIVATE KEY-----";
127        assert_eq!(classify_pem(pem), PemKind::Sec1Ec);
128    }
129
130    #[test]
131    fn classifies_openssh_header() {
132        let pem = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3Bl...\n-----END OPENSSH PRIVATE KEY-----";
133        assert_eq!(classify_pem(pem), PemKind::OpenSsh);
134    }
135
136    #[test]
137    fn classifies_hex_seed() {
138        let pem = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
139        assert_eq!(classify_pem(pem), PemKind::Ed25519HexSeed);
140    }
141
142    #[test]
143    fn unknown_input_classified_as_such() {
144        assert_eq!(classify_pem(""), PemKind::Unknown);
145        assert_eq!(classify_pem("not a key"), PemKind::Unknown);
146    }
147}