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