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}