Skip to main content

fiscal_crypto/certificate/
pfx.rs

1//! PFX/PKCS#12 certificate loading, parsing, and info extraction.
2
3use std::sync::Once;
4
5use openssl::pkcs12::Pkcs12;
6
7use fiscal_core::FiscalError;
8use fiscal_core::types::{CertificateData, CertificateInfo};
9
10/// Hash algorithm used for XML-DSig digest and RSA signature.
11///
12/// Brazilian ICP-Brasil v5 certificates require SHA-256, and some SEFAZs
13/// already reject SHA-1 (rejeição 297). Use [`SignatureAlgorithm::Sha256`]
14/// for new certificates; [`SignatureAlgorithm::Sha1`] is kept for
15/// backwards compatibility.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
17pub enum SignatureAlgorithm {
18    /// RSA-SHA1 — legacy, kept as default for backwards compatibility.
19    #[default]
20    Sha1,
21    /// RSA-SHA256 — required by ICP-Brasil v5 certificates.
22    Sha256,
23}
24
25/// Load OpenSSL legacy provider (needed for RC2-40-CBC in old PFX files on OpenSSL 3.x).
26///
27/// The provider must stay loaded for the entire process lifetime. We use
28/// `std::mem::forget` to prevent `Drop` from calling `OSSL_PROVIDER_unload`.
29/// `try_load(None, "legacy", true)` keeps the default provider as fallback.
30fn ensure_legacy_provider() {
31    static INIT: Once = Once::new();
32    INIT.call_once(|| {
33        if let Ok(provider) = openssl::provider::Provider::try_load(None, "legacy", true) {
34            std::mem::forget(provider);
35        }
36    });
37}
38
39/// Ensure a PFX buffer can be used with modern TLS stacks.
40///
41/// Brazilian A1 certificates are commonly issued with legacy encryption
42/// (RC2-40-CBC) which OpenSSL 3.x rejects by default. This function loads
43/// the OpenSSL legacy provider (process-wide) so the PFX can be parsed.
44///
45/// If the PFX uses legacy encryption and the legacy provider loaded
46/// successfully, the PFX is re-exported with modern algorithms (AES-256-CBC)
47/// via the OpenSSL API — no external CLI dependency.
48///
49/// If the PFX is already modern, the original bytes are returned as-is.
50///
51/// # Errors
52///
53/// Returns [`FiscalError::Certificate`] if the PFX is invalid, the
54/// passphrase is wrong, or the legacy provider cannot be loaded.
55pub fn ensure_modern_pfx(pfx_buffer: &[u8], passphrase: &str) -> Result<Vec<u8>, FiscalError> {
56    ensure_legacy_provider();
57
58    let pkcs12 = Pkcs12::from_der(pfx_buffer)
59        .map_err(|e| FiscalError::Certificate(format!("Invalid PFX data: {e}")))?;
60
61    match pkcs12.parse2(passphrase) {
62        Ok(parsed) => {
63            // PFX parsed OK. Re-export with modern encryption to guarantee
64            // compatibility with native-tls / Identity::from_pkcs12_der,
65            // which may not load the legacy provider independently.
66            re_export_pfx(&parsed, passphrase)
67        }
68        Err(e) => {
69            let msg = e.to_string();
70            if msg.contains("unsupported") || msg.contains("RC2") || msg.contains("mac") {
71                Err(FiscalError::Certificate(format!(
72                    "Legacy PFX (RC2-40-CBC) detected but OpenSSL legacy provider \
73                     could not handle it. Ensure OpenSSL 3.x with legacy provider \
74                     support is available. Error: {e}"
75                )))
76            } else {
77                Err(FiscalError::Certificate(format!(
78                    "Failed to parse PFX (wrong password?): {e}"
79                )))
80            }
81        }
82    }
83}
84
85/// Re-export a parsed PKCS12 with modern encryption algorithms.
86///
87/// This converts legacy-encrypted PFX files to use AES-256-CBC (the OpenSSL
88/// default for new PKCS12), ensuring compatibility across TLS stacks.
89fn re_export_pfx(
90    parsed: &openssl::pkcs12::ParsedPkcs12_2,
91    passphrase: &str,
92) -> Result<Vec<u8>, FiscalError> {
93    let pkey = parsed
94        .pkey
95        .as_ref()
96        .ok_or_else(|| FiscalError::Certificate("PFX does not contain a private key".into()))?;
97    let cert = parsed
98        .cert
99        .as_ref()
100        .ok_or_else(|| FiscalError::Certificate("PFX does not contain a certificate".into()))?;
101
102    let mut builder = Pkcs12::builder();
103    if let Some(chain) = &parsed.ca {
104        let mut stack = openssl::stack::Stack::new()
105            .map_err(|e| FiscalError::Certificate(format!("Failed to create CA stack: {e}")))?;
106        for ca in chain {
107            stack
108                .push(ca.to_owned())
109                .map_err(|e| FiscalError::Certificate(format!("Failed to add CA to stack: {e}")))?;
110        }
111        builder.ca(stack);
112    }
113
114    let new_pfx = builder
115        .name("")
116        .pkey(pkey)
117        .cert(cert)
118        .build2(passphrase)
119        .map_err(|e| FiscalError::Certificate(format!("Failed to re-export PFX: {e}")))?;
120
121    new_pfx
122        .to_der()
123        .map_err(|e| FiscalError::Certificate(format!("Failed to serialize PFX: {e}")))
124}
125
126fn parse_pfx(
127    pfx_buffer: &[u8],
128    passphrase: &str,
129) -> Result<openssl::pkcs12::ParsedPkcs12_2, FiscalError> {
130    let modern = ensure_modern_pfx(pfx_buffer, passphrase)?;
131    let pkcs12 = Pkcs12::from_der(&modern)
132        .map_err(|e| FiscalError::Certificate(format!("Invalid PFX data: {e}")))?;
133    pkcs12
134        .parse2(passphrase)
135        .map_err(|e| FiscalError::Certificate(format!("Failed to parse PFX: {e}")))
136}
137
138/// Extract private key and certificate PEM strings from a PKCS#12/PFX buffer.
139///
140/// Parses the PFX using the provided passphrase and returns a [`CertificateData`]
141/// containing both PEM-encoded private key and certificate, along with the
142/// original PFX buffer and passphrase for later reuse.
143///
144/// # Errors
145///
146/// Returns [`FiscalError::Certificate`] if:
147/// - The buffer is not a valid PKCS#12 file
148/// - The passphrase is incorrect
149/// - The PFX does not contain a private key or certificate
150pub fn load_certificate(
151    pfx_buffer: &[u8],
152    passphrase: &str,
153) -> Result<CertificateData, FiscalError> {
154    ensure_legacy_provider();
155    let parsed = parse_pfx(pfx_buffer, passphrase)?;
156
157    let pkey = parsed
158        .pkey
159        .ok_or_else(|| FiscalError::Certificate("PFX does not contain a private key".into()))?;
160
161    let cert = parsed
162        .cert
163        .ok_or_else(|| FiscalError::Certificate("PFX does not contain a certificate".into()))?;
164
165    let private_key_pem = String::from_utf8(
166        pkey.private_key_to_pem_pkcs8()
167            .map_err(|e| FiscalError::Certificate(format!("Failed to export private key: {e}")))?,
168    )
169    .map_err(|e| FiscalError::Certificate(format!("Private key PEM is not valid UTF-8: {e}")))?;
170
171    let certificate_pem = String::from_utf8(
172        cert.to_pem()
173            .map_err(|e| FiscalError::Certificate(format!("Failed to export certificate: {e}")))?,
174    )
175    .map_err(|e| FiscalError::Certificate(format!("Certificate PEM is not valid UTF-8: {e}")))?;
176
177    Ok(CertificateData::new(
178        private_key_pem,
179        certificate_pem,
180        pfx_buffer.to_vec(),
181        passphrase,
182    ))
183}
184
185/// Extract display metadata from a PKCS#12/PFX certificate.
186///
187/// Parses the PFX and reads the X.509 subject, issuer, validity dates,
188/// and serial number without exposing the private key.
189///
190/// # Errors
191///
192/// Returns [`FiscalError::Certificate`] if:
193/// - The buffer is not a valid PKCS#12 file
194/// - The passphrase is incorrect
195/// - The certificate fields cannot be parsed
196pub fn get_certificate_info(
197    pfx_buffer: &[u8],
198    passphrase: &str,
199) -> Result<CertificateInfo, FiscalError> {
200    ensure_legacy_provider();
201    let parsed = parse_pfx(pfx_buffer, passphrase)?;
202
203    let cert = parsed
204        .cert
205        .ok_or_else(|| FiscalError::Certificate("PFX does not contain a certificate".into()))?;
206
207    let common_name = extract_cn_from_x509_name(cert.subject_name());
208    let issuer = extract_cn_from_x509_name(cert.issuer_name());
209
210    let valid_from = asn1_time_to_naive_date(cert.not_before())?;
211    let valid_until = asn1_time_to_naive_date(cert.not_after())?;
212
213    let serial_number = cert
214        .serial_number()
215        .to_bn()
216        .map_err(|e| FiscalError::Certificate(format!("Failed to read serial number: {e}")))?
217        .to_hex_str()
218        .map_err(|e| FiscalError::Certificate(format!("Failed to format serial number: {e}")))?
219        .to_string();
220
221    Ok(CertificateInfo::new(
222        common_name,
223        valid_from,
224        valid_until,
225        serial_number,
226        issuer,
227    ))
228}
229
230/// Extract the Common Name (CN) from an X509Name.
231fn extract_cn_from_x509_name(name: &openssl::x509::X509NameRef) -> String {
232    for entry in name.entries_by_nid(openssl::nid::Nid::COMMONNAME) {
233        if let Ok(s) = entry.data().as_utf8() {
234            return s.to_string();
235        }
236    }
237    // Fallback: return the full subject string
238    format!("{:?}", name)
239}
240
241/// Convert an OpenSSL ASN1Time to a chrono NaiveDate.
242fn asn1_time_to_naive_date(
243    time: &openssl::asn1::Asn1TimeRef,
244) -> Result<chrono::NaiveDate, FiscalError> {
245    let epoch = openssl::asn1::Asn1Time::from_unix(0)
246        .map_err(|e| FiscalError::Certificate(format!("ASN1 epoch creation failed: {e}")))?;
247    let diff = epoch
248        .diff(time)
249        .map_err(|e| FiscalError::Certificate(format!("ASN1 time diff failed: {e}")))?;
250
251    let days = diff.days as i64;
252    let secs = diff.secs as i64;
253    let total_secs = days * 86400 + secs;
254
255    let dt = chrono::DateTime::from_timestamp(total_secs, 0)
256        .ok_or_else(|| FiscalError::Certificate("Invalid timestamp from ASN1 time".into()))?;
257
258    Ok(dt.date_naive())
259}