1use std::io::{BufRead, Read};
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
36pub const MAX_WALLET_FILE_BYTES: usize = 16 * 1024 * 1024;
42
43#[derive(thiserror::Error)]
45#[non_exhaustive]
46pub enum WalletError {
47 #[error("wallet file is missing")]
49 FileMissing(String),
50 #[error("failed to read wallet file: {source}")]
52 Io {
53 path: String,
54 #[source]
55 source: std::io::Error,
56 },
57 #[error("wallet data exceeds maximum size of {maximum_bytes} bytes")]
59 TooLarge { maximum_bytes: usize },
60 #[error("failed to parse wallet PEM: {0}")]
62 Pem(String),
63 #[error("wallet contained no certificates")]
65 NoCertificates,
66 #[error("cwallet.sso parse error: {0}")]
68 Sso(String),
69 #[error(
73 "cwallet.sso support is not enabled in this build; convert the wallet \
74 to ewallet.pem"
75 )]
76 SsoNotEnabled,
77 #[error("PKCS#12 wallet parse error: {0}")]
81 Pkcs12(String),
82 #[error("wallet private key decryption failed: {0}")]
86 KeyDecrypt(String),
87 #[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 #[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#[derive(Debug, Clone, Default)]
138pub struct WalletContents {
139 pub ca_certificates: Vec<Vec<u8>>,
141 pub client_cert_chain: Vec<Vec<u8>>,
144 pub client_private_key: Option<Vec<u8>>,
147}
148
149impl WalletContents {
150 #[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 #[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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
186pub struct CertMetadata {
187 pub not_before: i64,
189 pub not_after: i64,
191}
192
193impl CertMetadata {
194 #[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 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 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 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 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 let tbs_body = seq_body(&mut cert)?;
240 let mut tbs = SliceReader::new(tbs_body).ok()?;
241
242 if tbs.peek_tag().ok()?.is_context_specific() {
246 skip_tlv(&mut tbs)?; }
248 skip_tlv(&mut tbs)?; skip_tlv(&mut tbs)?; skip_tlv(&mut tbs)?; 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#[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 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#[must_use]
294pub fn pem_wallet_path(dir: &Path) -> PathBuf {
295 dir.join(PEM_WALLET_FILE_NAME)
296}
297
298#[must_use]
300pub fn p12_wallet_path(dir: &Path) -> PathBuf {
301 dir.join(P12_WALLET_FILE_NAME)
302}
303
304#[must_use]
306pub fn sso_wallet_path(dir: &Path) -> PathBuf {
307 dir.join(SSO_WALLET_FILE_NAME)
308}
309
310pub fn parse_ewallet_pem(
332 pem: &[u8],
333 wallet_password: Option<&str>,
334) -> Result<WalletContents, WalletError> {
335 ensure_wallet_size(pem.len())?;
336 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 _ => {}
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 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 let block = &encrypted_blocks[0];
395 keys.push(decrypt_encrypted_pem_key(block, password)?);
396 }
397 }
398
399 contents.ca_certificates = all_certs.clone();
402
403 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
414pub 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
440fn 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
458fn 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
471fn 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
487pub 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
498pub 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
516pub 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
534pub 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
555fn 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
571pub(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 parse_rejects_malformed_pem_body() {
619 let bad = concat!(
628 "-----BEGIN CERT",
629 "IFICATE-----\n***not valid base64***\n-----END CERT",
630 "IFICATE-----\n"
631 )
632 .as_bytes();
633 let err = parse_ewallet_pem(bad, None).unwrap_err();
634 assert!(matches!(err, WalletError::Pem(_)), "got {err:?}");
635 }
636
637 #[test]
638 fn bounded_wallet_reader_rejects_oversized_input() {
639 let bytes = read_wallet_reader(std::io::Cursor::new([0u8; 17]), 16)
640 .expect("in-memory reader is infallible");
641 assert!(bytes.is_none(), "one byte over the cap must be rejected");
642 }
643
644 #[test]
645 fn wallet_size_guard_rejects_before_parser_allocations() {
646 let err = ensure_wallet_size(MAX_WALLET_FILE_BYTES + 1).unwrap_err();
647 assert!(matches!(err, WalletError::TooLarge { .. }));
648 }
649
650 #[test]
651 fn wallet_errors_redact_paths_in_display_and_debug() {
652 let sensitive_path = "/private/wallet/ewallet.pem";
653 let err = WalletError::FileMissing(sensitive_path.to_string());
654 assert!(!format!("{err}").contains(sensitive_path));
655 assert!(!format!("{err:?}").contains(sensitive_path));
656
657 let err = WalletError::Io {
658 path: sensitive_path.to_string(),
659 source: std::io::Error::new(std::io::ErrorKind::NotFound, "missing"),
660 };
661 assert!(!format!("{err}").contains(sensitive_path));
662 assert!(!format!("{err:?}").contains(sensitive_path));
663 }
664
665 const SYNTHETIC_CERT_DER_HEX: &str = "308203773082025fa00302010202142a2157bcbcc8fd4e52f45c36edb8b5e8ba8309c7300d06092a864886f70d01010b0500304b311c301a06035504030c136f7261636c652d746573742e696e76616c6964311e301c060355040a0c154f7261636c652053796e7468657469632054657374310b3009060355040613025553301e170d3230303130323033303430355a170d3330303130323033303430355a304b311c301a06035504030c136f7261636c652d746573742e696e76616c6964311e301c060355040a0c154f7261636c652053796e7468657469632054657374310b300906035504061302555330820122300d06092a864886f70d01010105000382010f003082010a0282010100a76a70aa8dc41c8254dca98dd01d683b253cf5cc189b019fa26f56f35c5c1ab57f5823b669d5f67cf15195d1d98e1da710ee06bde99133095c6fed0936a69d07d9d79c88d9d2741a0f680708e5a857c3df8f007ae963e5354af008211dbf6e1240e7ebf48a83ba7ead7c708e5775ecf2904caeadfc4464fdfa32a2d5040f6f63126762034ff65e816f63d59cfb0cb6a8a10da6f7fd49780cd5066eda2abc356970cab783743a8a556cc7c780fff5c73cee534a2eeddcfb54527ff3db40ffa202c5ec2e85bc6b9d97c54ab87acb3cfa895bcbc76b3935b080d8e6f98603c4c446e5c56ab0f4b33577affd36e12919d8fe520e5900b7919477bf3e81f493f516b30203010001a3533051301d0603551d0e041604147bc6964ed97e4e23f79f5f58ccdca185fb223893301f0603551d230418301680147bc6964ed97e4e23f79f5f58ccdca185fb223893300f0603551d130101ff040530030101ff300d06092a864886f70d01010b05000382010100904c05f871771ba1e15d9b18e92b7ed40d872b5eb84a7f795c1a908436d9a9a22d3a65f54f75dc8619820fbdb19738b9052849ef0b21b0b5ee0c455bb5eb019495a8abc517bf180f09cc8a937c1d7109d42a73f2ad9d716693676fee0a3b1d50d8908cfea7c9bb1d94a12408d7e967b6fb99705edfeda6de9f73dec4047d913e4173a2bfb4a196f571584d9b9fd84af455eaf228dcbcb1d2cf1a3fa9928b61a19f66400024ea92f9b9f70a2af994f831c017fca3563698a228367712112673175d505725318017ed3e3e5736465b174bf5669d7a8bae6fd595c4a03edb44b30465b32d7fd2d0d91f13fa40fd5c6ee0a79aec57472beb7be93cf0de05d0f01ad1";
675
676 #[test]
677 fn cert_metadata_parses_known_validity_dates() {
678 let der = hex::decode(SYNTHETIC_CERT_DER_HEX).expect("decode synthetic cert hex");
679 let meta = CertMetadata::from_der(&der).expect("synthetic cert must parse");
680 assert_eq!(
681 meta.not_before, 1_577_934_245,
682 "notBefore 2020-01-02T03:04:05Z"
683 );
684 assert_eq!(
685 meta.not_after, 1_893_553_445,
686 "notAfter 2030-01-02T03:04:05Z"
687 );
688 assert!(meta.not_before < meta.not_after);
689 }
690
691 #[test]
692 fn cert_metadata_skips_non_certificate_der() {
693 assert!(CertMetadata::from_der(b"").is_none());
696 assert!(CertMetadata::from_der(&[0xDE, 0xAD, 0xBE, 0xEF]).is_none());
697 assert!(CertMetadata::from_der(&[0x30, 0x00]).is_none());
699 }
700
701 #[test]
702 fn certificate_metadata_collects_and_skips_cleanly() {
703 let der = hex::decode(SYNTHETIC_CERT_DER_HEX).expect("decode synthetic cert hex");
704 let wallet = WalletContents {
708 ca_certificates: vec![der.clone(), vec![0x01, 0x02, 0x03]],
709 client_cert_chain: vec![der.clone()],
710 client_private_key: None,
711 };
712 let all = wallet.certificate_metadata();
713 assert_eq!(
714 all.len(),
715 2,
716 "one junk CA entry is skipped, two certs remain"
717 );
718 for meta in &all {
719 assert_eq!(meta.not_before, 1_577_934_245);
720 assert_eq!(meta.not_after, 1_893_553_445);
721 }
722 assert!(WalletContents::default().certificate_metadata().is_empty());
724 }
725}