1use std::io::BufRead;
27use std::path::{Path, PathBuf};
28
29pub const PEM_WALLET_FILE_NAME: &str = "ewallet.pem";
31pub const P12_WALLET_FILE_NAME: &str = "ewallet.p12";
33pub const SSO_WALLET_FILE_NAME: &str = "cwallet.sso";
35
36#[derive(thiserror::Error)]
38#[non_exhaustive]
39pub enum WalletError {
40 #[error("wallet file is missing")]
42 FileMissing(String),
43 #[error("failed to read wallet file: {source}")]
45 Io {
46 path: String,
47 #[source]
48 source: std::io::Error,
49 },
50 #[error("failed to parse wallet PEM: {0}")]
52 Pem(String),
53 #[error("wallet contained no certificates")]
55 NoCertificates,
56 #[error("cwallet.sso parse error: {0}")]
58 Sso(String),
59 #[error(
63 "cwallet.sso support is not enabled in this build; convert the wallet \
64 to ewallet.pem"
65 )]
66 SsoNotEnabled,
67 #[error("PKCS#12 wallet parse error: {0}")]
71 Pkcs12(String),
72 #[error("wallet private key decryption failed: {0}")]
76 KeyDecrypt(String),
77 #[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 #[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#[derive(Debug, Clone, Default)]
124pub struct WalletContents {
125 pub ca_certificates: Vec<Vec<u8>>,
127 pub client_cert_chain: Vec<Vec<u8>>,
130 pub client_private_key: Option<Vec<u8>>,
133}
134
135impl WalletContents {
136 #[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 #[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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
172pub struct CertMetadata {
173 pub not_before: i64,
175 pub not_after: i64,
177}
178
179impl CertMetadata {
180 #[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 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 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 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 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 let tbs_body = seq_body(&mut cert)?;
226 let mut tbs = SliceReader::new(tbs_body).ok()?;
227
228 if tbs.peek_tag().ok()?.is_context_specific() {
232 skip_tlv(&mut tbs)?; }
234 skip_tlv(&mut tbs)?; skip_tlv(&mut tbs)?; skip_tlv(&mut tbs)?; 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#[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 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#[must_use]
280pub fn pem_wallet_path(dir: &Path) -> PathBuf {
281 dir.join(PEM_WALLET_FILE_NAME)
282}
283
284#[must_use]
286pub fn p12_wallet_path(dir: &Path) -> PathBuf {
287 dir.join(P12_WALLET_FILE_NAME)
288}
289
290#[must_use]
292pub fn sso_wallet_path(dir: &Path) -> PathBuf {
293 dir.join(SSO_WALLET_FILE_NAME)
294}
295
296pub fn parse_ewallet_pem(
318 pem: &[u8],
319 wallet_password: Option<&str>,
320) -> Result<WalletContents, WalletError> {
321 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 _ => {}
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 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 let block = &encrypted_blocks[0];
380 keys.push(decrypt_encrypted_pem_key(block, password)?);
381 }
382 }
383
384 contents.ca_certificates = all_certs.clone();
387
388 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
399pub 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
424fn 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
442fn 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
455fn 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
471pub 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
482pub 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
503pub 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 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 assert!(CertMetadata::from_der(b"").is_none());
606 assert!(CertMetadata::from_der(&[0xDE, 0xAD, 0xBE, 0xEF]).is_none());
607 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 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 assert!(WalletContents::default().certificate_metadata().is_empty());
634 }
635}