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, Read};
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/// Largest wallet image accepted from disk or a caller-provided byte buffer.
37///
38/// Real PEM, PKCS#12, and SSO wallets are normally measured in KiB. Keeping
39/// this bound modest prevents a configured-but-hostile wallet file from making
40/// the driver allocate unbounded memory before its format parser can reject it.
41pub const MAX_WALLET_FILE_BYTES: usize = 16 * 1024 * 1024;
42
43/// Errors raised while resolving or reading a wallet.
44#[derive(thiserror::Error)]
45#[non_exhaustive]
46pub enum WalletError {
47    /// The wallet directory did not contain the expected file.
48    #[error("wallet file is missing")]
49    FileMissing(String),
50    /// An I/O error occurred reading the wallet.
51    #[error("failed to read wallet file: {source}")]
52    Io {
53        path: String,
54        #[source]
55        source: std::io::Error,
56    },
57    /// A wallet image exceeded the fail-closed resource limit before parsing.
58    #[error("wallet data exceeds maximum size of {maximum_bytes} bytes")]
59    TooLarge { maximum_bytes: usize },
60    /// The PEM content could not be parsed.
61    #[error("failed to parse wallet PEM: {0}")]
62    Pem(String),
63    /// The wallet contained no usable trust-anchor certificates.
64    #[error("wallet contained no certificates")]
65    NoCertificates,
66    /// SSO (cwallet.sso) outer-container parsing failure.
67    #[error("cwallet.sso parse error: {0}")]
68    Sso(String),
69    /// Historical: SSO support compiled out. No longer returned as of 0.7.x
70    /// (the `cwallet.sso` reader is always available); the variant is kept so
71    /// existing `match` arms keep compiling.
72    #[error(
73        "cwallet.sso support is not enabled in this build; convert the wallet \
74         to ewallet.pem"
75    )]
76    SsoNotEnabled,
77    /// PKCS#12 (`ewallet.p12`, or the PKCS#12 embedded in `cwallet.sso`)
78    /// parsing or decryption failure. The message names OIDs/structures only —
79    /// never paths or passwords.
80    #[error("PKCS#12 wallet parse error: {0}")]
81    Pkcs12(String),
82    /// An encrypted private key could not be decrypted (wrong wallet password,
83    /// or an unsupported encryption scheme — only PKCS#8 PBES2 with
84    /// PBKDF2-HMAC-SHA1/SHA256 + AES-CBC is supported).
85    #[error("wallet private key decryption failed: {0}")]
86    KeyDecrypt(String),
87    /// The wallet (or its private key) is encrypted and requires a wallet
88    /// password, but none was supplied. Machine-classifiable remediation:
89    /// supply `wallet_password`, or use an auto-login `cwallet.sso` /
90    /// unencrypted `ewallet.pem` wallet.
91    #[error(
92        "wallet {format} is encrypted and requires a wallet password; supply \
93         wallet_password (or use an auto-login cwallet.sso or unencrypted \
94         ewallet.pem wallet)"
95    )]
96    PasswordRequired { format: &'static str },
97    /// A recognized wallet file is present but this thin build does not support
98    /// the format.
99    #[error("wallet format {format} is not supported by this thin build")]
100    UnsupportedFormat { format: &'static str },
101}
102
103impl std::fmt::Debug for WalletError {
104    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105        const REDACTED_PATH: &str = "***redacted***";
106        let redacted = |_: &String| REDACTED_PATH;
107        match self {
108            Self::FileMissing(path) => f.debug_tuple("FileMissing").field(&redacted(path)).finish(),
109            Self::Io { path, source } => f
110                .debug_struct("Io")
111                .field("path", &redacted(path))
112                .field("source", source)
113                .finish(),
114            Self::TooLarge { maximum_bytes } => f
115                .debug_struct("TooLarge")
116                .field("maximum_bytes", maximum_bytes)
117                .finish(),
118            Self::Pem(message) => f.debug_tuple("Pem").field(message).finish(),
119            Self::NoCertificates => f.write_str("NoCertificates"),
120            Self::Sso(message) => f.debug_tuple("Sso").field(message).finish(),
121            Self::SsoNotEnabled => f.write_str("SsoNotEnabled"),
122            Self::Pkcs12(message) => f.debug_tuple("Pkcs12").field(message).finish(),
123            Self::KeyDecrypt(message) => f.debug_tuple("KeyDecrypt").field(message).finish(),
124            Self::PasswordRequired { format } => f
125                .debug_struct("PasswordRequired")
126                .field("format", format)
127                .finish(),
128            Self::UnsupportedFormat { format } => f
129                .debug_struct("UnsupportedFormat")
130                .field("format", format)
131                .finish(),
132        }
133    }
134}
135
136/// Parsed contents of an Oracle wallet, as DER bytes ready for rustls.
137#[derive(Debug, Clone, Default)]
138pub struct WalletContents {
139    /// Trust-anchor / CA certificates used to verify the server (DER).
140    pub ca_certificates: Vec<Vec<u8>>,
141    /// Client certificate chain for mTLS, leaf first (DER). Empty if the
142    /// wallet is verify-only.
143    pub client_cert_chain: Vec<Vec<u8>>,
144    /// Client private key for mTLS (DER, PKCS#8 or PKCS#1/SEC1). `None` if the
145    /// wallet is verify-only.
146    pub client_private_key: Option<Vec<u8>>,
147}
148
149impl WalletContents {
150    /// Returns `true` if a client identity (cert chain + key) is present, i.e.
151    /// the wallet can be used for mutual TLS.
152    #[must_use]
153    pub fn has_client_identity(&self) -> bool {
154        !self.client_cert_chain.is_empty() && self.client_private_key.is_some()
155    }
156
157    /// Parse the X.509 validity window ([`CertMetadata`]) of every certificate
158    /// this wallet holds — the trust anchors ([`Self::ca_certificates`]) first,
159    /// then the client identity chain ([`Self::client_cert_chain`]), in that
160    /// order.
161    ///
162    /// Purely offline: it inspects the DER bytes already parsed into this
163    /// struct, so no connection or network I/O is involved. A non-certificate
164    /// or otherwise unparseable DER entry is silently skipped (it never fails
165    /// the whole call), so one odd entry does not hide the metadata of the
166    /// rest. This lets a doctor warn on a near-expiry trust anchor or client
167    /// certificate.
168    #[must_use]
169    pub fn certificate_metadata(&self) -> Vec<CertMetadata> {
170        self.ca_certificates
171            .iter()
172            .chain(self.client_cert_chain.iter())
173            .filter_map(|der| CertMetadata::from_der(der))
174            .collect()
175    }
176}
177
178/// The X.509 validity window of a wallet certificate, as Unix-epoch seconds.
179///
180/// Both fields are seconds since the Unix epoch (1970-01-01T00:00:00Z, UTC) —
181/// the form the certificate's `notBefore` / `notAfter` decode to. Plain seconds
182/// (rather than a richer date type) keeps this dependency-free and trivially
183/// comparable: a doctor compares [`Self::not_after`] against the current time
184/// to warn on an expired or soon-to-expire certificate.
185#[derive(Clone, Copy, Debug, Eq, PartialEq)]
186pub struct CertMetadata {
187    /// `notBefore`: Unix-epoch seconds at/after which the certificate is valid.
188    pub not_before: i64,
189    /// `notAfter`: Unix-epoch seconds after which the certificate is expired.
190    pub not_after: i64,
191}
192
193impl CertMetadata {
194    /// Parse the validity window out of a single DER-encoded X.509
195    /// certificate, or `None` when `der` is not a certificate we can read
196    /// (wrong ASN.1 shape, truncated, an out-of-range time, etc.). The parse is
197    /// deliberately narrow — it walks the `Certificate` → `TBSCertificate`
198    /// SEQUENCE only far enough to reach the `validity` field — so it never
199    /// pulls in a full X.509 stack and never fails on an unrelated DER blob.
200    #[must_use]
201    pub fn from_der(der: &[u8]) -> Option<Self> {
202        use der::asn1::{GeneralizedTime, UtcTime};
203        use der::{Decode, Header, Reader, SliceReader, Tag};
204
205        /// Read the body slice of a SEQUENCE, advancing `reader` past it.
206        fn seq_body<'a>(reader: &mut SliceReader<'a>) -> Option<&'a [u8]> {
207            let header = Header::decode(reader).ok()?;
208            if header.tag != Tag::Sequence {
209                return None;
210            }
211            reader.read_slice(header.length).ok()
212        }
213
214        /// Consume (skip) one TLV element, whatever its tag.
215        fn skip_tlv(reader: &mut SliceReader<'_>) -> Option<()> {
216            let header = Header::decode(reader).ok()?;
217            reader.read_slice(header.length).ok()?;
218            Some(())
219        }
220
221        /// Decode a `Time` CHOICE (UTCTime or GeneralizedTime) to Unix seconds.
222        fn read_time(reader: &mut SliceReader<'_>) -> Option<i64> {
223            let unix = match reader.peek_tag().ok()? {
224                Tag::UtcTime => UtcTime::decode(reader).ok()?.to_unix_duration(),
225                Tag::GeneralizedTime => GeneralizedTime::decode(reader).ok()?.to_unix_duration(),
226                _ => return None,
227            };
228            i64::try_from(unix.as_secs()).ok()
229        }
230
231        // Certificate ::= SEQUENCE { tbsCertificate, signatureAlgorithm, sig }
232        let mut root = SliceReader::new(der).ok()?;
233        let cert_body = seq_body(&mut root)?;
234        let mut cert = SliceReader::new(cert_body).ok()?;
235
236        // TBSCertificate ::= SEQUENCE {
237        //   version [0] EXPLICIT DEFAULT v1, serialNumber, signature, issuer,
238        //   validity, subject, ... }
239        let tbs_body = seq_body(&mut cert)?;
240        let mut tbs = SliceReader::new(tbs_body).ok()?;
241
242        // The optional [0] EXPLICIT version tag is context-specific; when
243        // present, skip it. Then skip serialNumber, signature, and issuer to
244        // land on validity.
245        if tbs.peek_tag().ok()?.is_context_specific() {
246            skip_tlv(&mut tbs)?; // version [0]
247        }
248        skip_tlv(&mut tbs)?; // serialNumber INTEGER
249        skip_tlv(&mut tbs)?; // signature AlgorithmIdentifier SEQUENCE
250        skip_tlv(&mut tbs)?; // issuer Name SEQUENCE
251
252        // Validity ::= SEQUENCE { notBefore Time, notAfter Time }
253        let validity_body = seq_body(&mut tbs)?;
254        let mut validity = SliceReader::new(validity_body).ok()?;
255        let not_before = read_time(&mut validity)?;
256        let not_after = read_time(&mut validity)?;
257        Some(CertMetadata {
258            not_before,
259            not_after,
260        })
261    }
262}
263
264/// Resolve the wallet directory the way python-oracledb does.
265///
266/// Precedence (first non-`None`/non-`SYSTEM` wins):
267/// 1. An explicit `wallet_location` (from the connect descriptor's
268///    `MY_WALLET_DIRECTORY`/`wallet_location` param). The special value
269///    `SYSTEM` (case-insensitive) is treated as "no wallet" — the system trust
270///    store is used (reference: 23ai `SYSTEM` keyword).
271/// 2. The `TNS_ADMIN` environment variable (python-oracledb `config_dir`).
272///
273/// Returns `None` when neither yields a directory (the caller should then fall
274/// back to system roots).
275#[must_use]
276pub fn resolve_wallet_dir(
277    wallet_location: Option<&str>,
278    tns_admin: Option<&str>,
279) -> Option<PathBuf> {
280    if let Some(loc) = wallet_location {
281        if !loc.is_empty() && !loc.eq_ignore_ascii_case("SYSTEM") {
282            return Some(PathBuf::from(loc));
283        }
284        // Explicit SYSTEM => no wallet directory.
285        if loc.eq_ignore_ascii_case("SYSTEM") {
286            return None;
287        }
288    }
289    tns_admin.filter(|s| !s.is_empty()).map(PathBuf::from)
290}
291
292/// Returns the path to `ewallet.pem` inside a wallet directory.
293#[must_use]
294pub fn pem_wallet_path(dir: &Path) -> PathBuf {
295    dir.join(PEM_WALLET_FILE_NAME)
296}
297
298/// Returns the path to `ewallet.p12` inside a wallet directory.
299#[must_use]
300pub fn p12_wallet_path(dir: &Path) -> PathBuf {
301    dir.join(P12_WALLET_FILE_NAME)
302}
303
304/// Returns the path to `cwallet.sso` inside a wallet directory.
305#[must_use]
306pub fn sso_wallet_path(dir: &Path) -> PathBuf {
307    dir.join(SSO_WALLET_FILE_NAME)
308}
309
310/// Parse an `ewallet.pem` byte buffer into [`WalletContents`].
311///
312/// Mirrors python-oracledb: every certificate block is loaded as a trust
313/// anchor (`load_verify_locations`), and additionally — if a private key and at
314/// least one certificate are present — they form the client identity for mTLS
315/// (`load_cert_chain`). A wallet without a private key is verify-only, which is
316/// the common server-verification case.
317///
318/// When the private key is an `ENCRYPTED PRIVATE KEY` (PKCS#8
319/// `EncryptedPrivateKeyInfo`) block — the shape Autonomous Database wallet
320/// downloads produce — it is decrypted with `wallet_password` (PBES2 /
321/// PBKDF2-HMAC-SHA1/SHA256 / AES-CBC, the scheme `openssl pkcs8 -topk8` and
322/// Oracle wallet exports emit). A missing password yields
323/// [`WalletError::PasswordRequired`]; a wrong password or unsupported scheme
324/// yields [`WalletError::KeyDecrypt`]. Legacy OpenSSL PEM-level encryption
325/// (`Proc-Type: 4,ENCRYPTED`) is rejected with a typed remediation.
326///
327/// # Errors
328/// Returns [`WalletError::Pem`] on malformed PEM,
329/// [`WalletError::NoCertificates`] if no certificate blocks are found, and the
330/// encrypted-key errors described above.
331pub fn parse_ewallet_pem(
332    pem: &[u8],
333    wallet_password: Option<&str>,
334) -> Result<WalletContents, WalletError> {
335    ensure_wallet_size(pem.len())?;
336    // Legacy OpenSSL PEM-level encryption scrambles the base64 payload of a
337    // PKCS#1 block; rustls-pemfile would surface it as a garbage key. Reject it
338    // up front with a typed remediation (fail closed).
339    if pem_contains_legacy_encryption(pem) {
340        return Err(WalletError::KeyDecrypt(
341            "legacy OpenSSL PEM encryption (Proc-Type: 4,ENCRYPTED) is not \
342             supported; re-export the key as PKCS#8 with \
343             `openssl pkcs8 -topk8` (optionally encrypted, then supply \
344             wallet_password)"
345                .to_string(),
346        ));
347    }
348
349    let mut reader = std::io::BufReader::new(pem);
350    let mut contents = WalletContents::default();
351    let mut all_certs: Vec<Vec<u8>> = Vec::new();
352    let mut keys: Vec<Vec<u8>> = Vec::new();
353
354    loop {
355        match rustls_pemfile::read_one(&mut reader) {
356            Ok(Some(item)) => match item {
357                rustls_pemfile::Item::X509Certificate(der) => {
358                    all_certs.push(der.as_ref().to_vec());
359                }
360                rustls_pemfile::Item::Pkcs8Key(der) => {
361                    keys.push(der.secret_pkcs8_der().to_vec());
362                }
363                rustls_pemfile::Item::Pkcs1Key(der) => {
364                    keys.push(der.secret_pkcs1_der().to_vec());
365                }
366                rustls_pemfile::Item::Sec1Key(der) => {
367                    keys.push(der.secret_sec1_der().to_vec());
368                }
369                // ENCRYPTED PRIVATE KEY blocks are not handled by
370                // rustls-pemfile; they are extracted and decrypted below.
371                _ => {}
372            },
373            Ok(None) => break,
374            Err(e) => return Err(WalletError::Pem(e.to_string())),
375        }
376    }
377
378    if all_certs.is_empty() {
379        return Err(WalletError::NoCertificates);
380    }
381
382    // Decrypt an ENCRYPTED PRIVATE KEY block when no plaintext key was found.
383    if keys.is_empty() {
384        let encrypted_blocks = extract_encrypted_key_pem_blocks(pem);
385        if !encrypted_blocks.is_empty() {
386            let Some(password) = wallet_password else {
387                return Err(WalletError::PasswordRequired {
388                    format: PEM_WALLET_FILE_NAME,
389                });
390            };
391            // Oracle wallets carry a single client key; decrypt the first block
392            // and surface its error directly (never silently degrade to a
393            // verify-only wallet).
394            let block = &encrypted_blocks[0];
395            keys.push(decrypt_encrypted_pem_key(block, password)?);
396        }
397    }
398
399    // Every certificate is a candidate trust anchor (python-oracledb loads the
400    // whole PEM via load_verify_locations).
401    contents.ca_certificates = all_certs.clone();
402
403    // If a private key is present, treat the certs as the client chain for
404    // mTLS as well (python-oracledb's best-effort load_cert_chain). The leaf is
405    // the first cert in the file by Oracle wallet convention.
406    if let Some(key) = keys.into_iter().next() {
407        contents.client_cert_chain = all_certs;
408        contents.client_private_key = Some(key);
409    }
410
411    Ok(contents)
412}
413
414/// Parse a standalone `ewallet.p12` (PKCS#12) wallet into [`WalletContents`].
415///
416/// This is the wallet file `orapki wallet create` produces and Autonomous
417/// Database wallet zips ship. Only the modern PBES2 / PBKDF2 / AES-CBC scheme
418/// is supported (orapki 19c+, `openssl pkcs12 -export` defaults); legacy
419/// 3DES/RC2 wallets return a typed [`WalletError::Pkcs12`] naming the
420/// unsupported OID.
421///
422/// # Errors
423/// Returns [`WalletError::PasswordRequired`] when `wallet_password` is `None`
424/// (Oracle PKCS#12 wallets are always password-protected), and
425/// [`WalletError::Pkcs12`] on parse/decrypt failure (including a wrong
426/// password).
427pub fn parse_ewallet_p12(
428    data: &[u8],
429    wallet_password: Option<&str>,
430) -> Result<WalletContents, WalletError> {
431    ensure_wallet_size(data.len())?;
432    let Some(password) = wallet_password else {
433        return Err(WalletError::PasswordRequired {
434            format: P12_WALLET_FILE_NAME,
435        });
436    };
437    super::pfx::parse_pfx(data, password.as_bytes())
438}
439
440/// Extract the raw text of every `ENCRYPTED PRIVATE KEY` PEM block.
441fn extract_encrypted_key_pem_blocks(pem: &[u8]) -> Vec<String> {
442    const BEGIN: &str = "-----BEGIN ENCRYPTED PRIVATE KEY-----";
443    const END: &str = "-----END ENCRYPTED PRIVATE KEY-----";
444    let text = String::from_utf8_lossy(pem);
445    let mut blocks = Vec::new();
446    let mut rest: &str = &text;
447    while let Some(start) = rest.find(BEGIN) {
448        let Some(end_rel) = rest[start..].find(END) else {
449            break;
450        };
451        let stop = start + end_rel + END.len();
452        blocks.push(rest[start..stop].to_string());
453        rest = &rest[stop..];
454    }
455    blocks
456}
457
458/// Decode one `ENCRYPTED PRIVATE KEY` PEM block and decrypt it to plaintext
459/// PKCS#8 `PrivateKeyInfo` DER.
460fn decrypt_encrypted_pem_key(block: &str, password: &str) -> Result<Vec<u8>, WalletError> {
461    let (label, doc) = der::Document::from_pem(block)
462        .map_err(|e| WalletError::Pem(format!("ENCRYPTED PRIVATE KEY block: {e}")))?;
463    if label != "ENCRYPTED PRIVATE KEY" {
464        return Err(WalletError::Pem(format!(
465            "expected ENCRYPTED PRIVATE KEY PEM label, got {label}"
466        )));
467    }
468    super::pfx::decrypt_encrypted_private_key_info(doc.as_bytes(), password.as_bytes())
469}
470
471/// Heuristic: does this PEM buffer use legacy OpenSSL PEM-level encryption?
472fn pem_contains_legacy_encryption(pem: &[u8]) -> bool {
473    let mut reader = std::io::BufReader::new(pem);
474    let mut line = String::new();
475    while let Ok(n) = reader.read_line(&mut line) {
476        if n == 0 {
477            break;
478        }
479        if line.contains("Proc-Type: 4,ENCRYPTED") {
480            return true;
481        }
482        line.clear();
483    }
484    false
485}
486
487/// Parse all `CERTIFICATE` blocks from a PEM reader into DER byte vectors.
488///
489/// Exposed so the I/O crate can load OS root bundles (for the no-wallet TCPS
490/// path) without taking its own `rustls-pemfile` dependency.
491pub fn parse_pem_certificates(reader: &mut dyn BufRead) -> Vec<Vec<u8>> {
492    rustls_pemfile::certs(reader)
493        .filter_map(Result::ok)
494        .map(|der| der.as_ref().to_vec())
495        .collect()
496}
497
498/// Read and parse `ewallet.pem` from a wallet directory.
499///
500/// # Errors
501/// Returns [`WalletError::FileMissing`] if the file does not exist,
502/// [`WalletError::Io`] on a read error, and parse errors from
503/// [`parse_ewallet_pem`].
504pub fn read_ewallet_pem(
505    dir: &Path,
506    wallet_password: Option<&str>,
507) -> Result<WalletContents, WalletError> {
508    let path = pem_wallet_path(dir);
509    if !path.exists() {
510        return Err(WalletError::FileMissing(path.display().to_string()));
511    }
512    let bytes = read_wallet_file(&path)?;
513    parse_ewallet_pem(&bytes, wallet_password)
514}
515
516/// Read and parse `ewallet.p12` from a wallet directory.
517///
518/// # Errors
519/// Returns [`WalletError::FileMissing`] if the file does not exist,
520/// [`WalletError::Io`] on a read error, and parse errors from
521/// [`parse_ewallet_p12`].
522pub fn read_ewallet_p12(
523    dir: &Path,
524    wallet_password: Option<&str>,
525) -> Result<WalletContents, WalletError> {
526    let path = p12_wallet_path(dir);
527    if !path.exists() {
528        return Err(WalletError::FileMissing(path.display().to_string()));
529    }
530    let bytes = read_wallet_file(&path)?;
531    parse_ewallet_p12(&bytes, wallet_password)
532}
533
534/// Read one wallet file without allowing a configured path to allocate an
535/// unbounded buffer before parsing begins.
536///
537/// The stream is cut off at one byte above [`MAX_WALLET_FILE_BYTES`], so this
538/// remains bounded even if file metadata races, is unavailable, or lies.
539pub fn read_wallet_file(path: &Path) -> Result<Vec<u8>, WalletError> {
540    let file = std::fs::File::open(path).map_err(|source| WalletError::Io {
541        path: path.display().to_string(),
542        source,
543    })?;
544    match read_wallet_reader(file, MAX_WALLET_FILE_BYTES).map_err(|source| WalletError::Io {
545        path: path.display().to_string(),
546        source,
547    })? {
548        Some(bytes) => Ok(bytes),
549        None => Err(WalletError::TooLarge {
550            maximum_bytes: MAX_WALLET_FILE_BYTES,
551        }),
552    }
553}
554
555/// Read at most `maximum_bytes + 1` bytes, returning `None` when the source is
556/// oversized. Kept separate from filesystem I/O so the boundary is directly
557/// regression-tested without creating a temporary wallet file.
558fn read_wallet_reader<R: Read>(
559    reader: R,
560    maximum_bytes: usize,
561) -> std::io::Result<Option<Vec<u8>>> {
562    let limit = u64::try_from(maximum_bytes)
563        .unwrap_or(u64::MAX)
564        .saturating_add(1);
565    let mut reader = reader.take(limit);
566    let mut bytes = Vec::new();
567    reader.read_to_end(&mut bytes)?;
568    Ok((bytes.len() <= maximum_bytes).then_some(bytes))
569}
570
571/// Enforce the same limit for public in-memory parser entry points.
572pub(crate) fn ensure_wallet_size(size: usize) -> Result<(), WalletError> {
573    if size > MAX_WALLET_FILE_BYTES {
574        return Err(WalletError::TooLarge {
575            maximum_bytes: MAX_WALLET_FILE_BYTES,
576        });
577    }
578    Ok(())
579}
580
581#[cfg(test)]
582mod tests {
583    use super::*;
584
585    #[test]
586    fn resolve_prefers_explicit_location() {
587        let dir = resolve_wallet_dir(Some("/wallets/db1"), Some("/etc/tns"));
588        assert_eq!(dir, Some(PathBuf::from("/wallets/db1")));
589    }
590
591    #[test]
592    fn resolve_system_means_no_wallet() {
593        assert_eq!(resolve_wallet_dir(Some("SYSTEM"), Some("/etc/tns")), None);
594        assert_eq!(resolve_wallet_dir(Some("system"), None), None);
595    }
596
597    #[test]
598    fn resolve_falls_back_to_tns_admin() {
599        assert_eq!(
600            resolve_wallet_dir(None, Some("/etc/tns")),
601            Some(PathBuf::from("/etc/tns"))
602        );
603    }
604
605    #[test]
606    fn resolve_none_when_nothing_set() {
607        assert_eq!(resolve_wallet_dir(None, None), None);
608        assert_eq!(resolve_wallet_dir(Some(""), None), None);
609    }
610
611    #[test]
612    fn parse_rejects_empty_pem() {
613        let err = parse_ewallet_pem(b"", None).unwrap_err();
614        assert!(matches!(err, WalletError::NoCertificates));
615    }
616
617    #[test]
618    fn bounded_wallet_reader_rejects_oversized_input() {
619        let bytes = read_wallet_reader(std::io::Cursor::new([0u8; 17]), 16)
620            .expect("in-memory reader is infallible");
621        assert!(bytes.is_none(), "one byte over the cap must be rejected");
622    }
623
624    #[test]
625    fn wallet_size_guard_rejects_before_parser_allocations() {
626        let err = ensure_wallet_size(MAX_WALLET_FILE_BYTES + 1).unwrap_err();
627        assert!(matches!(err, WalletError::TooLarge { .. }));
628    }
629
630    #[test]
631    fn wallet_errors_redact_paths_in_display_and_debug() {
632        let sensitive_path = "/private/wallet/ewallet.pem";
633        let err = WalletError::FileMissing(sensitive_path.to_string());
634        assert!(!format!("{err}").contains(sensitive_path));
635        assert!(!format!("{err:?}").contains(sensitive_path));
636
637        let err = WalletError::Io {
638            path: sensitive_path.to_string(),
639            source: std::io::Error::new(std::io::ErrorKind::NotFound, "missing"),
640        };
641        assert!(!format!("{err}").contains(sensitive_path));
642        assert!(!format!("{err:?}").contains(sensitive_path));
643    }
644
645    /// A synthetic self-signed X.509 certificate (DER) minted only for this
646    /// test with a *fixed* validity window so the parsed epochs are exact and
647    /// deterministic:
648    ///   subject/issuer CN=oracle-test.invalid (fictional; never a real host)
649    ///   notBefore = 2020-01-02T03:04:05Z (Unix 1_577_934_245)
650    ///   notAfter  = 2030-01-02T03:04:05Z (Unix 1_893_553_445)
651    /// (`openssl req -x509 -not_before 20200102030405Z -not_after
652    /// 20300102030405Z`). Both dates fall in 1950..2050 so they encode as
653    /// ASN.1 UTCTime.
654    const SYNTHETIC_CERT_DER_HEX: &str = "308203773082025fa00302010202142a2157bcbcc8fd4e52f45c36edb8b5e8ba8309c7300d06092a864886f70d01010b0500304b311c301a06035504030c136f7261636c652d746573742e696e76616c6964311e301c060355040a0c154f7261636c652053796e7468657469632054657374310b3009060355040613025553301e170d3230303130323033303430355a170d3330303130323033303430355a304b311c301a06035504030c136f7261636c652d746573742e696e76616c6964311e301c060355040a0c154f7261636c652053796e7468657469632054657374310b300906035504061302555330820122300d06092a864886f70d01010105000382010f003082010a0282010100a76a70aa8dc41c8254dca98dd01d683b253cf5cc189b019fa26f56f35c5c1ab57f5823b669d5f67cf15195d1d98e1da710ee06bde99133095c6fed0936a69d07d9d79c88d9d2741a0f680708e5a857c3df8f007ae963e5354af008211dbf6e1240e7ebf48a83ba7ead7c708e5775ecf2904caeadfc4464fdfa32a2d5040f6f63126762034ff65e816f63d59cfb0cb6a8a10da6f7fd49780cd5066eda2abc356970cab783743a8a556cc7c780fff5c73cee534a2eeddcfb54527ff3db40ffa202c5ec2e85bc6b9d97c54ab87acb3cfa895bcbc76b3935b080d8e6f98603c4c446e5c56ab0f4b33577affd36e12919d8fe520e5900b7919477bf3e81f493f516b30203010001a3533051301d0603551d0e041604147bc6964ed97e4e23f79f5f58ccdca185fb223893301f0603551d230418301680147bc6964ed97e4e23f79f5f58ccdca185fb223893300f0603551d130101ff040530030101ff300d06092a864886f70d01010b05000382010100904c05f871771ba1e15d9b18e92b7ed40d872b5eb84a7f795c1a908436d9a9a22d3a65f54f75dc8619820fbdb19738b9052849ef0b21b0b5ee0c455bb5eb019495a8abc517bf180f09cc8a937c1d7109d42a73f2ad9d716693676fee0a3b1d50d8908cfea7c9bb1d94a12408d7e967b6fb99705edfeda6de9f73dec4047d913e4173a2bfb4a196f571584d9b9fd84af455eaf228dcbcb1d2cf1a3fa9928b61a19f66400024ea92f9b9f70a2af994f831c017fca3563698a228367712112673175d505725318017ed3e3e5736465b174bf5669d7a8bae6fd595c4a03edb44b30465b32d7fd2d0d91f13fa40fd5c6ee0a79aec57472beb7be93cf0de05d0f01ad1";
655
656    #[test]
657    fn cert_metadata_parses_known_validity_dates() {
658        let der = hex::decode(SYNTHETIC_CERT_DER_HEX).expect("decode synthetic cert hex");
659        let meta = CertMetadata::from_der(&der).expect("synthetic cert must parse");
660        assert_eq!(
661            meta.not_before, 1_577_934_245,
662            "notBefore 2020-01-02T03:04:05Z"
663        );
664        assert_eq!(
665            meta.not_after, 1_893_553_445,
666            "notAfter 2030-01-02T03:04:05Z"
667        );
668        assert!(meta.not_before < meta.not_after);
669    }
670
671    #[test]
672    fn cert_metadata_skips_non_certificate_der() {
673        // Random bytes and a bare (non-cert) SEQUENCE are not certificates: the
674        // parser returns None instead of erroring.
675        assert!(CertMetadata::from_der(b"").is_none());
676        assert!(CertMetadata::from_der(&[0xDE, 0xAD, 0xBE, 0xEF]).is_none());
677        // A well-formed but empty SEQUENCE (0x30 0x00) — no TBSCertificate.
678        assert!(CertMetadata::from_der(&[0x30, 0x00]).is_none());
679    }
680
681    #[test]
682    fn certificate_metadata_collects_and_skips_cleanly() {
683        let der = hex::decode(SYNTHETIC_CERT_DER_HEX).expect("decode synthetic cert hex");
684        // ca_certificates holds one real cert plus a junk entry; client chain
685        // holds the same real cert. The junk entry is skipped, the two real
686        // certs are reported in order (CA first, then client chain).
687        let wallet = WalletContents {
688            ca_certificates: vec![der.clone(), vec![0x01, 0x02, 0x03]],
689            client_cert_chain: vec![der.clone()],
690            client_private_key: None,
691        };
692        let all = wallet.certificate_metadata();
693        assert_eq!(
694            all.len(),
695            2,
696            "one junk CA entry is skipped, two certs remain"
697        );
698        for meta in &all {
699            assert_eq!(meta.not_before, 1_577_934_245);
700            assert_eq!(meta.not_after, 1_893_553_445);
701        }
702        // A wallet with no certificates yields an empty vec (never panics).
703        assert!(WalletContents::default().certificate_metadata().is_empty());
704    }
705}