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