Skip to main content

oracledb_protocol/tls/
wallet.rs

1//! Oracle wallet readers and wallet-location resolution.
2//!
3//! Two 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=...)`). Fully
11//!   supported here.
12//!
13//! * **`cwallet.sso`** — the SSO auto-login wallet (proprietary Oracle
14//!   container wrapping a PKCS#12). Parsing is gated behind the `experimental`
15//!   feature; see [`super::sso`].
16//!
17//! All parsed certificates and keys are returned as DER bytes so the I/O crate
18//! can hand them to rustls without this (sans-I/O) crate depending on the async
19//! TLS stack.
20
21use std::io::BufRead;
22use std::path::{Path, PathBuf};
23
24/// File name of the PEM wallet (python-oracledb `PEM_WALLET_FILE_NAME`).
25pub const PEM_WALLET_FILE_NAME: &str = "ewallet.pem";
26/// File name of the SSO auto-login wallet.
27pub const SSO_WALLET_FILE_NAME: &str = "cwallet.sso";
28
29/// Errors raised while resolving or reading a wallet.
30#[derive(thiserror::Error)]
31#[non_exhaustive]
32pub enum WalletError {
33    /// The wallet directory did not contain the expected file.
34    #[error("wallet file is missing")]
35    FileMissing(String),
36    /// An I/O error occurred reading the wallet.
37    #[error("failed to read wallet file: {source}")]
38    Io {
39        path: String,
40        #[source]
41        source: std::io::Error,
42    },
43    /// The PEM content could not be parsed.
44    #[error("failed to parse wallet PEM: {0}")]
45    Pem(String),
46    /// The wallet contained no usable trust-anchor certificates.
47    #[error("wallet contained no certificates")]
48    NoCertificates,
49    /// SSO (cwallet.sso) parsing failure (experimental).
50    #[error("cwallet.sso parse error: {0}")]
51    Sso(String),
52    /// SSO support is not compiled in.
53    #[error(
54        "cwallet.sso support is experimental and not enabled; rebuild with \
55         --features experimental, or convert the wallet to ewallet.pem"
56    )]
57    SsoNotEnabled,
58    /// A recognized wallet file is present but this thin build does not support
59    /// the format.
60    #[error("wallet format {format} is not supported by this thin build")]
61    UnsupportedFormat { format: &'static str },
62}
63
64impl std::fmt::Debug for WalletError {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        const REDACTED_PATH: &str = "***redacted***";
67        let redacted = |_: &String| REDACTED_PATH;
68        match self {
69            Self::FileMissing(path) => f.debug_tuple("FileMissing").field(&redacted(path)).finish(),
70            Self::Io { path, source } => f
71                .debug_struct("Io")
72                .field("path", &redacted(path))
73                .field("source", source)
74                .finish(),
75            Self::Pem(message) => f.debug_tuple("Pem").field(message).finish(),
76            Self::NoCertificates => f.write_str("NoCertificates"),
77            Self::Sso(message) => f.debug_tuple("Sso").field(message).finish(),
78            Self::SsoNotEnabled => f.write_str("SsoNotEnabled"),
79            Self::UnsupportedFormat { format } => f
80                .debug_struct("UnsupportedFormat")
81                .field("format", format)
82                .finish(),
83        }
84    }
85}
86
87/// Parsed contents of an Oracle wallet, as DER bytes ready for rustls.
88#[derive(Debug, Clone, Default)]
89pub struct WalletContents {
90    /// Trust-anchor / CA certificates used to verify the server (DER).
91    pub ca_certificates: Vec<Vec<u8>>,
92    /// Client certificate chain for mTLS, leaf first (DER). Empty if the
93    /// wallet is verify-only.
94    pub client_cert_chain: Vec<Vec<u8>>,
95    /// Client private key for mTLS (DER, PKCS#8 or PKCS#1/SEC1). `None` if the
96    /// wallet is verify-only.
97    pub client_private_key: Option<Vec<u8>>,
98}
99
100impl WalletContents {
101    /// Returns `true` if a client identity (cert chain + key) is present, i.e.
102    /// the wallet can be used for mutual TLS.
103    #[must_use]
104    pub fn has_client_identity(&self) -> bool {
105        !self.client_cert_chain.is_empty() && self.client_private_key.is_some()
106    }
107}
108
109/// Resolve the wallet directory the way python-oracledb does.
110///
111/// Precedence (first non-`None`/non-`SYSTEM` wins):
112/// 1. An explicit `wallet_location` (from the connect descriptor's
113///    `MY_WALLET_DIRECTORY`/`wallet_location` param). The special value
114///    `SYSTEM` (case-insensitive) is treated as "no wallet" — the system trust
115///    store is used (reference: 23ai `SYSTEM` keyword).
116/// 2. The `TNS_ADMIN` environment variable (python-oracledb `config_dir`).
117///
118/// Returns `None` when neither yields a directory (the caller should then fall
119/// back to system roots).
120#[must_use]
121pub fn resolve_wallet_dir(
122    wallet_location: Option<&str>,
123    tns_admin: Option<&str>,
124) -> Option<PathBuf> {
125    if let Some(loc) = wallet_location {
126        if !loc.is_empty() && !loc.eq_ignore_ascii_case("SYSTEM") {
127            return Some(PathBuf::from(loc));
128        }
129        // Explicit SYSTEM => no wallet directory.
130        if loc.eq_ignore_ascii_case("SYSTEM") {
131            return None;
132        }
133    }
134    tns_admin.filter(|s| !s.is_empty()).map(PathBuf::from)
135}
136
137/// Returns the path to `ewallet.pem` inside a wallet directory.
138#[must_use]
139pub fn pem_wallet_path(dir: &Path) -> PathBuf {
140    dir.join(PEM_WALLET_FILE_NAME)
141}
142
143/// Returns the path to `cwallet.sso` inside a wallet directory.
144#[must_use]
145pub fn sso_wallet_path(dir: &Path) -> PathBuf {
146    dir.join(SSO_WALLET_FILE_NAME)
147}
148
149/// Parse an `ewallet.pem` byte buffer into [`WalletContents`].
150///
151/// Mirrors python-oracledb: every certificate block is loaded as a trust
152/// anchor (`load_verify_locations`), and additionally — if a private key and at
153/// least one certificate are present — they form the client identity for mTLS
154/// (`load_cert_chain`). A wallet without a private key is verify-only, which is
155/// the common server-verification case.
156///
157/// The `wallet_password` is accepted for API symmetry with python-oracledb but
158/// is only meaningful for encrypted private keys; rustls-pemfile handles
159/// unencrypted PKCS#8/PKCS#1/SEC1 keys. Encrypted keys are reported via
160/// [`WalletError::Pem`] so the caller can surface a clear message rather than
161/// silently producing a verify-only wallet.
162///
163/// # Errors
164/// Returns [`WalletError::Pem`] on malformed PEM and
165/// [`WalletError::NoCertificates`] if no certificate blocks are found.
166pub fn parse_ewallet_pem(
167    pem: &[u8],
168    _wallet_password: Option<&str>,
169) -> Result<WalletContents, WalletError> {
170    let mut reader = std::io::BufReader::new(pem);
171    let mut contents = WalletContents::default();
172    let mut all_certs: Vec<Vec<u8>> = Vec::new();
173    let mut keys: Vec<Vec<u8>> = Vec::new();
174    let mut saw_encrypted_key = false;
175
176    loop {
177        match rustls_pemfile::read_one(&mut reader) {
178            Ok(Some(item)) => match item {
179                rustls_pemfile::Item::X509Certificate(der) => {
180                    all_certs.push(der.as_ref().to_vec());
181                }
182                rustls_pemfile::Item::Pkcs8Key(der) => {
183                    keys.push(der.secret_pkcs8_der().to_vec());
184                }
185                rustls_pemfile::Item::Pkcs1Key(der) => {
186                    keys.push(der.secret_pkcs1_der().to_vec());
187                }
188                rustls_pemfile::Item::Sec1Key(der) => {
189                    keys.push(der.secret_sec1_der().to_vec());
190                }
191                // Encrypted private keys are not handled by rustls-pemfile;
192                // they appear as Crl/Csr-less "other" items the iterator skips.
193                // We detect the PEM marker separately below.
194                _ => {}
195            },
196            Ok(None) => break,
197            Err(e) => return Err(WalletError::Pem(e.to_string())),
198        }
199    }
200
201    // rustls-pemfile silently skips ENCRYPTED PRIVATE KEY blocks; detect them so
202    // we can tell the operator their key needs decrypting rather than pretend
203    // the wallet is verify-only.
204    if keys.is_empty() && pem_contains_encrypted_key(pem) {
205        saw_encrypted_key = true;
206    }
207
208    if all_certs.is_empty() {
209        return Err(WalletError::NoCertificates);
210    }
211
212    // Every certificate is a candidate trust anchor (python-oracledb loads the
213    // whole PEM via load_verify_locations).
214    contents.ca_certificates = all_certs.clone();
215
216    // If a private key is present, treat the certs as the client chain for
217    // mTLS as well (python-oracledb's best-effort load_cert_chain). The leaf is
218    // the first cert in the file by Oracle wallet convention.
219    if let Some(key) = keys.into_iter().next() {
220        contents.client_cert_chain = all_certs;
221        contents.client_private_key = Some(key);
222    } else if saw_encrypted_key {
223        return Err(WalletError::Pem(
224            "wallet private key is encrypted; supply a wallet with an \
225             unencrypted ewallet.pem (orapki ... -auto_login) or use cwallet.sso"
226                .to_string(),
227        ));
228    }
229
230    Ok(contents)
231}
232
233/// Parse all `CERTIFICATE` blocks from a PEM reader into DER byte vectors.
234///
235/// Exposed so the I/O crate can load OS root bundles (for the no-wallet TCPS
236/// path) without taking its own `rustls-pemfile` dependency.
237pub fn parse_pem_certificates(reader: &mut dyn BufRead) -> Vec<Vec<u8>> {
238    rustls_pemfile::certs(reader)
239        .filter_map(Result::ok)
240        .map(|der| der.as_ref().to_vec())
241        .collect()
242}
243
244/// Heuristic: does this PEM buffer contain an encrypted private-key block?
245fn pem_contains_encrypted_key(pem: &[u8]) -> bool {
246    let mut reader = std::io::BufReader::new(pem);
247    let mut line = String::new();
248    while let Ok(n) = reader.read_line(&mut line) {
249        if n == 0 {
250            break;
251        }
252        if line.contains("ENCRYPTED PRIVATE KEY") || line.contains("Proc-Type: 4,ENCRYPTED") {
253            return true;
254        }
255        line.clear();
256    }
257    false
258}
259
260/// Read and parse `ewallet.pem` from a wallet directory.
261///
262/// # Errors
263/// Returns [`WalletError::FileMissing`] if the file does not exist,
264/// [`WalletError::Io`] on a read error, and parse errors from
265/// [`parse_ewallet_pem`].
266pub fn read_ewallet_pem(
267    dir: &Path,
268    wallet_password: Option<&str>,
269) -> Result<WalletContents, WalletError> {
270    let path = pem_wallet_path(dir);
271    if !path.exists() {
272        return Err(WalletError::FileMissing(path.display().to_string()));
273    }
274    let bytes = std::fs::read(&path).map_err(|source| WalletError::Io {
275        path: path.display().to_string(),
276        source,
277    })?;
278    parse_ewallet_pem(&bytes, wallet_password)
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    #[test]
286    fn resolve_prefers_explicit_location() {
287        let dir = resolve_wallet_dir(Some("/wallets/db1"), Some("/etc/tns"));
288        assert_eq!(dir, Some(PathBuf::from("/wallets/db1")));
289    }
290
291    #[test]
292    fn resolve_system_means_no_wallet() {
293        assert_eq!(resolve_wallet_dir(Some("SYSTEM"), Some("/etc/tns")), None);
294        assert_eq!(resolve_wallet_dir(Some("system"), None), None);
295    }
296
297    #[test]
298    fn resolve_falls_back_to_tns_admin() {
299        assert_eq!(
300            resolve_wallet_dir(None, Some("/etc/tns")),
301            Some(PathBuf::from("/etc/tns"))
302        );
303    }
304
305    #[test]
306    fn resolve_none_when_nothing_set() {
307        assert_eq!(resolve_wallet_dir(None, None), None);
308        assert_eq!(resolve_wallet_dir(Some(""), None), None);
309    }
310
311    #[test]
312    fn parse_rejects_empty_pem() {
313        let err = parse_ewallet_pem(b"", None).unwrap_err();
314        assert!(matches!(err, WalletError::NoCertificates));
315    }
316
317    #[test]
318    fn wallet_errors_redact_paths_in_display_and_debug() {
319        let sensitive_path = "/private/wallet/ewallet.pem";
320        let err = WalletError::FileMissing(sensitive_path.to_string());
321        assert!(!format!("{err}").contains(sensitive_path));
322        assert!(!format!("{err:?}").contains(sensitive_path));
323
324        let err = WalletError::Io {
325            path: sensitive_path.to_string(),
326            source: std::io::Error::new(std::io::ErrorKind::NotFound, "missing"),
327        };
328        assert!(!format!("{err}").contains(sensitive_path));
329        assert!(!format!("{err:?}").contains(sensitive_path));
330    }
331}