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