Skip to main content

oracledb_protocol/tls/
wallet.rs

1//! Oracle wallet readers and wallet-location resolution.
2//!
3//! Three wallet shapes are supported:
4//!
5//! * **`ewallet.pem`** — a single PEM file holding the trust-anchor
6//!   certificate(s) and, for mTLS, the client certificate chain plus the
7//!   client private key (optionally encrypted with a wallet password). This is
8//!   the format python-oracledb thin loads
9//!   (`transport.pyx::create_ssl_context`: `load_verify_locations(ewallet.pem)`
10//!   then a best-effort `load_cert_chain(ewallet.pem, password=...)`).
11//!   Encrypted `ENCRYPTED PRIVATE KEY` (PKCS#8 PBES2) blocks are decrypted
12//!   when a wallet password is supplied.
13//!
14//! * **`ewallet.p12`** — the standard PKCS#12 wallet (the file `orapki wallet
15//!   create` produces and Autonomous Database wallet zips ship). Requires the
16//!   wallet password. Modern PBES2/PBKDF2/AES-CBC wallets are supported;
17//!   legacy 3DES/RC2 wallets return a typed error.
18//!
19//! * **`cwallet.sso`** — the SSO auto-login wallet (proprietary Oracle
20//!   container wrapping a PKCS#12); see [`super::sso`].
21//!
22//! All parsed certificates and keys are returned as DER bytes so the I/O crate
23//! can hand them to rustls without this (sans-I/O) crate depending on the async
24//! TLS stack.
25
26use std::io::BufRead;
27use std::path::{Path, PathBuf};
28
29/// File name of the PEM wallet (python-oracledb `PEM_WALLET_FILE_NAME`).
30pub const PEM_WALLET_FILE_NAME: &str = "ewallet.pem";
31/// File name of the standalone PKCS#12 wallet.
32pub const P12_WALLET_FILE_NAME: &str = "ewallet.p12";
33/// File name of the SSO auto-login wallet.
34pub const SSO_WALLET_FILE_NAME: &str = "cwallet.sso";
35
36/// Errors raised while resolving or reading a wallet.
37#[derive(thiserror::Error)]
38#[non_exhaustive]
39pub enum WalletError {
40    /// The wallet directory did not contain the expected file.
41    #[error("wallet file is missing")]
42    FileMissing(String),
43    /// An I/O error occurred reading the wallet.
44    #[error("failed to read wallet file: {source}")]
45    Io {
46        path: String,
47        #[source]
48        source: std::io::Error,
49    },
50    /// The PEM content could not be parsed.
51    #[error("failed to parse wallet PEM: {0}")]
52    Pem(String),
53    /// The wallet contained no usable trust-anchor certificates.
54    #[error("wallet contained no certificates")]
55    NoCertificates,
56    /// SSO (cwallet.sso) outer-container parsing failure.
57    #[error("cwallet.sso parse error: {0}")]
58    Sso(String),
59    /// Historical: SSO support compiled out. No longer returned as of 0.7.x
60    /// (the `cwallet.sso` reader is always available); the variant is kept so
61    /// existing `match` arms keep compiling.
62    #[error(
63        "cwallet.sso support is not enabled in this build; convert the wallet \
64         to ewallet.pem"
65    )]
66    SsoNotEnabled,
67    /// PKCS#12 (`ewallet.p12`, or the PKCS#12 embedded in `cwallet.sso`)
68    /// parsing or decryption failure. The message names OIDs/structures only —
69    /// never paths or passwords.
70    #[error("PKCS#12 wallet parse error: {0}")]
71    Pkcs12(String),
72    /// An encrypted private key could not be decrypted (wrong wallet password,
73    /// or an unsupported encryption scheme — only PKCS#8 PBES2 with
74    /// PBKDF2-HMAC-SHA1/SHA256 + AES-CBC is supported).
75    #[error("wallet private key decryption failed: {0}")]
76    KeyDecrypt(String),
77    /// The wallet (or its private key) is encrypted and requires a wallet
78    /// password, but none was supplied. Machine-classifiable remediation:
79    /// supply `wallet_password`, or use an auto-login `cwallet.sso` /
80    /// unencrypted `ewallet.pem` wallet.
81    #[error(
82        "wallet {format} is encrypted and requires a wallet password; supply \
83         wallet_password (or use an auto-login cwallet.sso or unencrypted \
84         ewallet.pem wallet)"
85    )]
86    PasswordRequired { format: &'static str },
87    /// A recognized wallet file is present but this thin build does not support
88    /// the format.
89    #[error("wallet format {format} is not supported by this thin build")]
90    UnsupportedFormat { format: &'static str },
91}
92
93impl std::fmt::Debug for WalletError {
94    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95        const REDACTED_PATH: &str = "***redacted***";
96        let redacted = |_: &String| REDACTED_PATH;
97        match self {
98            Self::FileMissing(path) => f.debug_tuple("FileMissing").field(&redacted(path)).finish(),
99            Self::Io { path, source } => f
100                .debug_struct("Io")
101                .field("path", &redacted(path))
102                .field("source", source)
103                .finish(),
104            Self::Pem(message) => f.debug_tuple("Pem").field(message).finish(),
105            Self::NoCertificates => f.write_str("NoCertificates"),
106            Self::Sso(message) => f.debug_tuple("Sso").field(message).finish(),
107            Self::SsoNotEnabled => f.write_str("SsoNotEnabled"),
108            Self::Pkcs12(message) => f.debug_tuple("Pkcs12").field(message).finish(),
109            Self::KeyDecrypt(message) => f.debug_tuple("KeyDecrypt").field(message).finish(),
110            Self::PasswordRequired { format } => f
111                .debug_struct("PasswordRequired")
112                .field("format", format)
113                .finish(),
114            Self::UnsupportedFormat { format } => f
115                .debug_struct("UnsupportedFormat")
116                .field("format", format)
117                .finish(),
118        }
119    }
120}
121
122/// Parsed contents of an Oracle wallet, as DER bytes ready for rustls.
123#[derive(Debug, Clone, Default)]
124pub struct WalletContents {
125    /// Trust-anchor / CA certificates used to verify the server (DER).
126    pub ca_certificates: Vec<Vec<u8>>,
127    /// Client certificate chain for mTLS, leaf first (DER). Empty if the
128    /// wallet is verify-only.
129    pub client_cert_chain: Vec<Vec<u8>>,
130    /// Client private key for mTLS (DER, PKCS#8 or PKCS#1/SEC1). `None` if the
131    /// wallet is verify-only.
132    pub client_private_key: Option<Vec<u8>>,
133}
134
135impl WalletContents {
136    /// Returns `true` if a client identity (cert chain + key) is present, i.e.
137    /// the wallet can be used for mutual TLS.
138    #[must_use]
139    pub fn has_client_identity(&self) -> bool {
140        !self.client_cert_chain.is_empty() && self.client_private_key.is_some()
141    }
142}
143
144/// Resolve the wallet directory the way python-oracledb does.
145///
146/// Precedence (first non-`None`/non-`SYSTEM` wins):
147/// 1. An explicit `wallet_location` (from the connect descriptor's
148///    `MY_WALLET_DIRECTORY`/`wallet_location` param). The special value
149///    `SYSTEM` (case-insensitive) is treated as "no wallet" — the system trust
150///    store is used (reference: 23ai `SYSTEM` keyword).
151/// 2. The `TNS_ADMIN` environment variable (python-oracledb `config_dir`).
152///
153/// Returns `None` when neither yields a directory (the caller should then fall
154/// back to system roots).
155#[must_use]
156pub fn resolve_wallet_dir(
157    wallet_location: Option<&str>,
158    tns_admin: Option<&str>,
159) -> Option<PathBuf> {
160    if let Some(loc) = wallet_location {
161        if !loc.is_empty() && !loc.eq_ignore_ascii_case("SYSTEM") {
162            return Some(PathBuf::from(loc));
163        }
164        // Explicit SYSTEM => no wallet directory.
165        if loc.eq_ignore_ascii_case("SYSTEM") {
166            return None;
167        }
168    }
169    tns_admin.filter(|s| !s.is_empty()).map(PathBuf::from)
170}
171
172/// Returns the path to `ewallet.pem` inside a wallet directory.
173#[must_use]
174pub fn pem_wallet_path(dir: &Path) -> PathBuf {
175    dir.join(PEM_WALLET_FILE_NAME)
176}
177
178/// Returns the path to `ewallet.p12` inside a wallet directory.
179#[must_use]
180pub fn p12_wallet_path(dir: &Path) -> PathBuf {
181    dir.join(P12_WALLET_FILE_NAME)
182}
183
184/// Returns the path to `cwallet.sso` inside a wallet directory.
185#[must_use]
186pub fn sso_wallet_path(dir: &Path) -> PathBuf {
187    dir.join(SSO_WALLET_FILE_NAME)
188}
189
190/// Parse an `ewallet.pem` byte buffer into [`WalletContents`].
191///
192/// Mirrors python-oracledb: every certificate block is loaded as a trust
193/// anchor (`load_verify_locations`), and additionally — if a private key and at
194/// least one certificate are present — they form the client identity for mTLS
195/// (`load_cert_chain`). A wallet without a private key is verify-only, which is
196/// the common server-verification case.
197///
198/// When the private key is an `ENCRYPTED PRIVATE KEY` (PKCS#8
199/// `EncryptedPrivateKeyInfo`) block — the shape Autonomous Database wallet
200/// downloads produce — it is decrypted with `wallet_password` (PBES2 /
201/// PBKDF2-HMAC-SHA1/SHA256 / AES-CBC, the scheme `openssl pkcs8 -topk8` and
202/// Oracle wallet exports emit). A missing password yields
203/// [`WalletError::PasswordRequired`]; a wrong password or unsupported scheme
204/// yields [`WalletError::KeyDecrypt`]. Legacy OpenSSL PEM-level encryption
205/// (`Proc-Type: 4,ENCRYPTED`) is rejected with a typed remediation.
206///
207/// # Errors
208/// Returns [`WalletError::Pem`] on malformed PEM,
209/// [`WalletError::NoCertificates`] if no certificate blocks are found, and the
210/// encrypted-key errors described above.
211pub fn parse_ewallet_pem(
212    pem: &[u8],
213    wallet_password: Option<&str>,
214) -> Result<WalletContents, WalletError> {
215    // Legacy OpenSSL PEM-level encryption scrambles the base64 payload of a
216    // PKCS#1 block; rustls-pemfile would surface it as a garbage key. Reject it
217    // up front with a typed remediation (fail closed).
218    if pem_contains_legacy_encryption(pem) {
219        return Err(WalletError::KeyDecrypt(
220            "legacy OpenSSL PEM encryption (Proc-Type: 4,ENCRYPTED) is not \
221             supported; re-export the key as PKCS#8 with \
222             `openssl pkcs8 -topk8` (optionally encrypted, then supply \
223             wallet_password)"
224                .to_string(),
225        ));
226    }
227
228    let mut reader = std::io::BufReader::new(pem);
229    let mut contents = WalletContents::default();
230    let mut all_certs: Vec<Vec<u8>> = Vec::new();
231    let mut keys: Vec<Vec<u8>> = Vec::new();
232
233    loop {
234        match rustls_pemfile::read_one(&mut reader) {
235            Ok(Some(item)) => match item {
236                rustls_pemfile::Item::X509Certificate(der) => {
237                    all_certs.push(der.as_ref().to_vec());
238                }
239                rustls_pemfile::Item::Pkcs8Key(der) => {
240                    keys.push(der.secret_pkcs8_der().to_vec());
241                }
242                rustls_pemfile::Item::Pkcs1Key(der) => {
243                    keys.push(der.secret_pkcs1_der().to_vec());
244                }
245                rustls_pemfile::Item::Sec1Key(der) => {
246                    keys.push(der.secret_sec1_der().to_vec());
247                }
248                // ENCRYPTED PRIVATE KEY blocks are not handled by
249                // rustls-pemfile; they are extracted and decrypted below.
250                _ => {}
251            },
252            Ok(None) => break,
253            Err(e) => return Err(WalletError::Pem(e.to_string())),
254        }
255    }
256
257    if all_certs.is_empty() {
258        return Err(WalletError::NoCertificates);
259    }
260
261    // Decrypt an ENCRYPTED PRIVATE KEY block when no plaintext key was found.
262    if keys.is_empty() {
263        let encrypted_blocks = extract_encrypted_key_pem_blocks(pem);
264        if !encrypted_blocks.is_empty() {
265            let Some(password) = wallet_password else {
266                return Err(WalletError::PasswordRequired {
267                    format: PEM_WALLET_FILE_NAME,
268                });
269            };
270            // Oracle wallets carry a single client key; decrypt the first block
271            // and surface its error directly (never silently degrade to a
272            // verify-only wallet).
273            let block = &encrypted_blocks[0];
274            keys.push(decrypt_encrypted_pem_key(block, password)?);
275        }
276    }
277
278    // Every certificate is a candidate trust anchor (python-oracledb loads the
279    // whole PEM via load_verify_locations).
280    contents.ca_certificates = all_certs.clone();
281
282    // If a private key is present, treat the certs as the client chain for
283    // mTLS as well (python-oracledb's best-effort load_cert_chain). The leaf is
284    // the first cert in the file by Oracle wallet convention.
285    if let Some(key) = keys.into_iter().next() {
286        contents.client_cert_chain = all_certs;
287        contents.client_private_key = Some(key);
288    }
289
290    Ok(contents)
291}
292
293/// Parse a standalone `ewallet.p12` (PKCS#12) wallet into [`WalletContents`].
294///
295/// This is the wallet file `orapki wallet create` produces and Autonomous
296/// Database wallet zips ship. Only the modern PBES2 / PBKDF2 / AES-CBC scheme
297/// is supported (orapki 19c+, `openssl pkcs12 -export` defaults); legacy
298/// 3DES/RC2 wallets return a typed [`WalletError::Pkcs12`] naming the
299/// unsupported OID.
300///
301/// # Errors
302/// Returns [`WalletError::PasswordRequired`] when `wallet_password` is `None`
303/// (Oracle PKCS#12 wallets are always password-protected), and
304/// [`WalletError::Pkcs12`] on parse/decrypt failure (including a wrong
305/// password).
306pub fn parse_ewallet_p12(
307    data: &[u8],
308    wallet_password: Option<&str>,
309) -> Result<WalletContents, WalletError> {
310    let Some(password) = wallet_password else {
311        return Err(WalletError::PasswordRequired {
312            format: P12_WALLET_FILE_NAME,
313        });
314    };
315    super::pfx::parse_pfx(data, password.as_bytes())
316}
317
318/// Extract the raw text of every `ENCRYPTED PRIVATE KEY` PEM block.
319fn extract_encrypted_key_pem_blocks(pem: &[u8]) -> Vec<String> {
320    const BEGIN: &str = "-----BEGIN ENCRYPTED PRIVATE KEY-----";
321    const END: &str = "-----END ENCRYPTED PRIVATE KEY-----";
322    let text = String::from_utf8_lossy(pem);
323    let mut blocks = Vec::new();
324    let mut rest: &str = &text;
325    while let Some(start) = rest.find(BEGIN) {
326        let Some(end_rel) = rest[start..].find(END) else {
327            break;
328        };
329        let stop = start + end_rel + END.len();
330        blocks.push(rest[start..stop].to_string());
331        rest = &rest[stop..];
332    }
333    blocks
334}
335
336/// Decode one `ENCRYPTED PRIVATE KEY` PEM block and decrypt it to plaintext
337/// PKCS#8 `PrivateKeyInfo` DER.
338fn decrypt_encrypted_pem_key(block: &str, password: &str) -> Result<Vec<u8>, WalletError> {
339    let (label, doc) = der::Document::from_pem(block)
340        .map_err(|e| WalletError::Pem(format!("ENCRYPTED PRIVATE KEY block: {e}")))?;
341    if label != "ENCRYPTED PRIVATE KEY" {
342        return Err(WalletError::Pem(format!(
343            "expected ENCRYPTED PRIVATE KEY PEM label, got {label}"
344        )));
345    }
346    super::pfx::decrypt_encrypted_private_key_info(doc.as_bytes(), password.as_bytes())
347}
348
349/// Heuristic: does this PEM buffer use legacy OpenSSL PEM-level encryption?
350fn pem_contains_legacy_encryption(pem: &[u8]) -> bool {
351    let mut reader = std::io::BufReader::new(pem);
352    let mut line = String::new();
353    while let Ok(n) = reader.read_line(&mut line) {
354        if n == 0 {
355            break;
356        }
357        if line.contains("Proc-Type: 4,ENCRYPTED") {
358            return true;
359        }
360        line.clear();
361    }
362    false
363}
364
365/// Parse all `CERTIFICATE` blocks from a PEM reader into DER byte vectors.
366///
367/// Exposed so the I/O crate can load OS root bundles (for the no-wallet TCPS
368/// path) without taking its own `rustls-pemfile` dependency.
369pub fn parse_pem_certificates(reader: &mut dyn BufRead) -> Vec<Vec<u8>> {
370    rustls_pemfile::certs(reader)
371        .filter_map(Result::ok)
372        .map(|der| der.as_ref().to_vec())
373        .collect()
374}
375
376/// Read and parse `ewallet.pem` from a wallet directory.
377///
378/// # Errors
379/// Returns [`WalletError::FileMissing`] if the file does not exist,
380/// [`WalletError::Io`] on a read error, and parse errors from
381/// [`parse_ewallet_pem`].
382pub fn read_ewallet_pem(
383    dir: &Path,
384    wallet_password: Option<&str>,
385) -> Result<WalletContents, WalletError> {
386    let path = pem_wallet_path(dir);
387    if !path.exists() {
388        return Err(WalletError::FileMissing(path.display().to_string()));
389    }
390    let bytes = std::fs::read(&path).map_err(|source| WalletError::Io {
391        path: path.display().to_string(),
392        source,
393    })?;
394    parse_ewallet_pem(&bytes, wallet_password)
395}
396
397/// Read and parse `ewallet.p12` from a wallet directory.
398///
399/// # Errors
400/// Returns [`WalletError::FileMissing`] if the file does not exist,
401/// [`WalletError::Io`] on a read error, and parse errors from
402/// [`parse_ewallet_p12`].
403pub fn read_ewallet_p12(
404    dir: &Path,
405    wallet_password: Option<&str>,
406) -> Result<WalletContents, WalletError> {
407    let path = p12_wallet_path(dir);
408    if !path.exists() {
409        return Err(WalletError::FileMissing(path.display().to_string()));
410    }
411    let bytes = std::fs::read(&path).map_err(|source| WalletError::Io {
412        path: path.display().to_string(),
413        source,
414    })?;
415    parse_ewallet_p12(&bytes, wallet_password)
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421
422    #[test]
423    fn resolve_prefers_explicit_location() {
424        let dir = resolve_wallet_dir(Some("/wallets/db1"), Some("/etc/tns"));
425        assert_eq!(dir, Some(PathBuf::from("/wallets/db1")));
426    }
427
428    #[test]
429    fn resolve_system_means_no_wallet() {
430        assert_eq!(resolve_wallet_dir(Some("SYSTEM"), Some("/etc/tns")), None);
431        assert_eq!(resolve_wallet_dir(Some("system"), None), None);
432    }
433
434    #[test]
435    fn resolve_falls_back_to_tns_admin() {
436        assert_eq!(
437            resolve_wallet_dir(None, Some("/etc/tns")),
438            Some(PathBuf::from("/etc/tns"))
439        );
440    }
441
442    #[test]
443    fn resolve_none_when_nothing_set() {
444        assert_eq!(resolve_wallet_dir(None, None), None);
445        assert_eq!(resolve_wallet_dir(Some(""), None), None);
446    }
447
448    #[test]
449    fn parse_rejects_empty_pem() {
450        let err = parse_ewallet_pem(b"", None).unwrap_err();
451        assert!(matches!(err, WalletError::NoCertificates));
452    }
453
454    #[test]
455    fn wallet_errors_redact_paths_in_display_and_debug() {
456        let sensitive_path = "/private/wallet/ewallet.pem";
457        let err = WalletError::FileMissing(sensitive_path.to_string());
458        assert!(!format!("{err}").contains(sensitive_path));
459        assert!(!format!("{err:?}").contains(sensitive_path));
460
461        let err = WalletError::Io {
462            path: sensitive_path.to_string(),
463            source: std::io::Error::new(std::io::ErrorKind::NotFound, "missing"),
464        };
465        assert!(!format!("{err}").contains(sensitive_path));
466        assert!(!format!("{err:?}").contains(sensitive_path));
467    }
468}