diff --git a/src/cert/mod.rs b/src/cert/mod.rs index 4b10456..902f4bd 100644 --- a/src/cert/mod.rs +++ b/src/cert/mod.rs @@ -3,6 +3,7 @@ pub mod parser; pub mod tls; use serde::Serialize; +use std::collections::HashMap; use std::fmt; use std::time::{SystemTime, UNIX_EPOCH}; @@ -46,6 +47,20 @@ impl CertInfo { } } +pub struct VerboseCert { + pub base_cert: CertInfo, + pub extensions: HashMap>, +} + +// This could be implemented using a trait, however this would require some refactoring +impl VerboseCert { + // Days remaining and key description removed to keep clippy clean + + pub fn is_expired(&self) -> bool { + self.base_cert.is_expired() + } +} + /// Key algorithm type #[derive(Debug, Clone, Serialize)] pub enum KeyType { diff --git a/src/cert/parser.rs b/src/cert/parser.rs index 4487149..103887f 100644 --- a/src/cert/parser.rs +++ b/src/cert/parser.rs @@ -1,8 +1,10 @@ use anyhow::{bail, Context, Result}; +use rustls::internal::msgs::codec::Codec; +use std::collections::HashMap; use x509_parser::prelude::*; use x509_parser::public_key::PublicKey; -use crate::cert::{CertInfo, CertTime, KeyType}; +use crate::cert::{CertInfo, CertTime, KeyType, VerboseCert}; /// Format bytes as colon-separated hex fn format_hex(bytes: &[u8]) -> String { @@ -13,6 +15,12 @@ fn format_hex(bytes: &[u8]) -> String { .join(":") } +fn format_hex_from_u32(bytes: u32) -> String { + let mut buf: Vec = Vec::new(); + bytes.encode(&mut buf); + format_hex(&buf) +} + pub fn parse_cert_file(path: &str) -> Result> { let data = std::fs::read(path).with_context(|| format!("can't read {}", path))?; parse_cert_data_with_source(&data, path) @@ -101,6 +109,488 @@ pub fn parse_der_cert(der_data: &[u8]) -> Result { }) } +// Verbose parsing +pub fn verbose_parse_cert_file(path: &str) -> Result> { + let data = std::fs::read(path).with_context(|| format!("can't read {}", path))?; + verbose_parse_cert_data_with_source(&data, path) +} + +/// Parse certificate(s) from raw bytes (PEM or DER). +/// Use this when data is already in memory (e.g. read from stdin). +pub fn verbose_parse_cert_data(data: &[u8]) -> Result> { + verbose_parse_cert_data_with_source(data, "stdin") +} + +fn verbose_parse_cert_data_with_source(data: &[u8], source: &str) -> Result> { + if is_pem(data) { + verbose_parse_pem_certs(data) + } else if is_der(data) { + verbose_parse_der_cert(data).map(|c| vec![c]) + } else { + bail!( + "Unrecognized certificate format in '{}'. Expected PEM or DER.\n\ + Hint: PEM files start with '-----BEGIN CERTIFICATE-----'\n\ + Hint: For PKCS12 (.p12/.pfx), use the convert or extract command", + source + ) + } +} + +pub fn verbose_parse_pem_certs(data: &[u8]) -> Result> { + let pem_blocks = parse_pem_blocks(data)?; + + if pem_blocks.is_empty() { + bail!("No certificates found in PEM data"); + } + + let mut certs = Vec::new(); + for (i, block) in pem_blocks.iter().enumerate() { + let cert = verbose_parse_der_cert(block) + .with_context(|| format!("certificate {} in bundle is invalid", i + 1))?; + certs.push(cert); + } + + Ok(certs) +} + +pub fn verbose_parse_der_cert(der_data: &[u8]) -> Result { + let (_, cert) = X509Certificate::from_der(der_data) + .map_err(|e| anyhow::anyhow!("bad certificate: {}", e))?; + + // Could go to a HashMap + let mut extensions: HashMap> = HashMap::new(); + + for e in cert.iter_extensions() { + if let Some(d) = parse_der_extension(e) { + extensions.insert(d.0, d.1); + } + } + + Ok(VerboseCert { + base_cert: parse_der_cert(der_data)?, + extensions, + }) +} + +fn parse_der_extension(e: &X509Extension) -> Option<(String, Vec<(String, String)>)> { + // List of extensions: https://docs.rs/x509-parser/0.18.1/x509_parser/extensions/enum.ParsedExtension.html + let interpret_reason_flags = |flags: &ReasonFlags| -> String { + format!( + "Key compromised: {}, CA compromised: {}, Affiliation changed: {}, Superseded: {}, Cessation of operation: {}, Certificate hold: {}, Privilege withdrawn: {}, AA compromised: {}", + flags.key_compromise(), + flags.ca_compromise(), + flags.affilation_changed(), + flags.superseded(), + flags.cessation_of_operation(), + flags.certificate_hold(), + flags.privelege_withdrawn(), + flags.aa_compromise() + ) + }; + + /* + Match each extension in the enum and then format it to be rendered: + Each extension is a tuple, which is then placed in the HashMap with key being its title + and the body being a vec of (String, String) tuples which represent fields and their values + within the extension + */ + match e.parsed_extension() { + ParsedExtension::AuthorityKeyIdentifier(authority_key_id) => { + Some(( + "Authority key identifier".to_string(), + [ + ( + "Key identifier".to_string(), + match &authority_key_id.key_identifier { + None => "None".to_string(), + Some(id) => format_hex(id.0), + }, + ), + ( + "Authority cert issuer".to_string(), + match &authority_key_id.authority_cert_issuer { + None => "None".to_string(), + Some(issuer) => { + String::from_iter(issuer.iter().map(|gn| gn.to_string())) + } + }, + ), + ( + "Authority cert serial".to_string(), + match authority_key_id.authority_cert_serial { + None => "None".to_string(), + Some(serial) => format_hex(serial), + }, + ), + ] + .to_vec(), + )) + } + ParsedExtension::SubjectKeyIdentifier(key_id) => Some(( + "Subject key identifier".to_string(), + [("Subject key id".to_string(), format_hex(key_id.0))].to_vec(), + )), + ParsedExtension::KeyUsage(key_usage) => Some(( + "Key usage".to_string(), + [ + ( + "Digital signature".to_string(), + key_usage.digital_signature().to_string(), + ), + ( + "Non repudiation".to_string(), + key_usage.non_repudiation().to_string(), + ), + ( + "Key encipherment".to_string(), + key_usage.key_encipherment().to_string(), + ), + ( + "Data encipherment".to_string(), + key_usage.data_encipherment().to_string(), + ), + ( + "Key agreement".to_string(), + key_usage.key_agreement().to_string(), + ), + ( + "Key cert sign".to_string(), + key_usage.key_cert_sign().to_string(), + ), + ( + "CRL sign".to_string(), + key_usage.key_encipherment().to_string(), + ), + ( + "Encipher only".to_string(), + key_usage.key_encipherment().to_string(), + ), + ( + "Decipher only".to_string(), + key_usage.key_encipherment().to_string(), + ), + ] + .to_vec(), + )), + ParsedExtension::CertificatePolicies(cert_policies) => Some(( + "Certificate policies".to_string(), + Vec::from_iter(cert_policies.iter().map(|policy_information| { + ( + format!("Policy {}", policy_information.policy_id.to_id_string()), + match &policy_information.policy_qualifiers { + Some(qualifiers) => qualifiers + .iter() + .map(|pqi| { + format!( + "{}: {}", + pqi.policy_qualifier_id.to_id_string(), + format_hex(pqi.qualifier) + ) + }) + .collect::>() + .join(", "), + None => "None".to_string(), + }, + ) + })), + )), + ParsedExtension::PolicyMappings(policy_mappings) => Some(( + "Policy mappings".to_string(), + policy_mappings + .mappings + .iter() + .map(|mapping| { + ( + mapping.issuer_domain_policy.to_id_string(), + mapping.subject_domain_policy.to_id_string(), + ) + }) + .collect::>(), + )), + ParsedExtension::SubjectAlternativeName(subject_alt_name) => Some(( + "Subject alternative name".to_string(), + [( + "Name(s)".to_string(), + format!("{:?}", subject_alt_name.general_names), + )] + .to_vec(), + )), + ParsedExtension::IssuerAlternativeName(issuer_alt_name) => Some(( + "Issuer alternative name".to_string(), + [( + "Name(s)".to_string(), + format!("{:?}", issuer_alt_name.general_names), + )] + .to_vec(), + )), + ParsedExtension::BasicConstraints(_) => { + // Handled in CertInfo + None + } + ParsedExtension::NameConstraints(constraints_name) => { + let format_subtrees = + |subtrees_options: &Option>| match subtrees_options { + Some(subtrees) => subtrees + .iter() + .map(|tree| tree.base.to_string()) + .collect::>() + .join(", "), + None => "None".to_string(), + }; + + Some(( + "Name constraints".to_string(), + [ + ( + "Permitted subtrees".to_string(), + format_subtrees(&constraints_name.permitted_subtrees), + ), + ( + "Permitted subtrees".to_string(), + format_subtrees(&constraints_name.excluded_subtrees), + ), + ] + .to_vec(), + )) + } + ParsedExtension::PolicyConstraints(constraints_policy) => { + let format_constraint = |constraint: &Option| match constraint { + Some(u) => format_hex_from_u32(*u), + None => "None".to_string(), + }; + + Some(( + "Policy constraints".to_string(), + [ + ( + "Require explicit policy".to_string(), + format_constraint(&constraints_policy.inhibit_policy_mapping), + ), + ( + "Inhibit policy mapping".to_string(), + format_constraint(&constraints_policy.require_explicit_policy), + ), + ] + .to_vec(), + )) + } + ParsedExtension::ExtendedKeyUsage(extended_key_usage) => Some(( + "Extended key usage".to_string(), + [ + ("Any".to_string(), extended_key_usage.any.to_string()), + ( + "Server authentication".to_string(), + extended_key_usage.server_auth.to_string(), + ), + ( + "Client authentication".to_string(), + extended_key_usage.client_auth.to_string(), + ), + ( + "Code signing".to_string(), + extended_key_usage.code_signing.to_string(), + ), + ( + "Email protection".to_string(), + extended_key_usage.email_protection.to_string(), + ), + ( + "Time stamping".to_string(), + extended_key_usage.time_stamping.to_string(), + ), + ( + "OCSP signing".to_string(), + extended_key_usage.ocsp_signing.to_string(), + ), + ( + "Other".to_string(), + extended_key_usage + .other + .iter() + .map(|oid| oid.to_id_string()) + .collect::>() + .join(", "), + ), + ] + .to_vec(), + )), + ParsedExtension::CRLDistributionPoints(crl_dist_points) => { + let concatenate_name = |name: &Vec| -> String { + name.iter() + .map(|n| n.to_string()) + .collect::>() + .join(" ") + }; + Some(( + "CRL distribution points".to_string(), + Vec::from_iter( + crl_dist_points + .points + .iter() + .map(|point| -> (String, String) { + ( + format!( + "{}, {}", + match &point.distribution_point { + Some(dpn) => match dpn { + DistributionPointName::FullName(name) => { + concatenate_name(name) + } + DistributionPointName::NameRelativeToCRLIssuer( + _name, + ) => { + "Relative distinguished name".to_string() + } + }, + None => "No name".to_string(), + }, + match &point.crl_issuer { + Some(name) => concatenate_name(name), + None => "No issuer".to_string(), + } + ), + match &point.reasons { + Some(flags) => interpret_reason_flags(flags), + None => "No reasons for revocation".to_string(), + }, + ) + }), + ), + )) + } + ParsedExtension::InhibitAnyPolicy(policy_inhibit_any) => Some(( + "Inhibit any policy".to_string(), + [( + "Skip certs".to_string(), + format_hex_from_u32(policy_inhibit_any.skip_certs), + )] + .to_vec(), + )), + ParsedExtension::AuthorityInfoAccess(authority_info_access) => Some(( + "Authority info access".to_string(), + Vec::from_iter(authority_info_access.accessdescs.iter().map( + |description| -> (String, String) { + ( + description.access_location.to_string(), + description.access_method.to_id_string(), + ) + }, + )), + )), + ParsedExtension::NSCertType(ns_type) => Some(( + "NS cert type".to_string(), + [ + ("SSL client".to_string(), ns_type.ssl_client().to_string()), + ("SSL server".to_string(), ns_type.ssl_server().to_string()), + ("Smine".to_string(), ns_type.smime().to_string()), + ( + "Object signing".to_string(), + ns_type.object_signing().to_string(), + ), + ("SSL CA".to_string(), ns_type.ssl_ca().to_string()), + ("Smine CA".to_string(), ns_type.smime_ca().to_string()), + ( + "Object signing CA".to_string(), + ns_type.object_signing_ca().to_string(), + ), + ] + .to_vec(), + )), + ParsedExtension::NsCertComment(ns_cert_comment) => Some(( + "NS cert comment".to_string(), + [("Comment".to_string(), ns_cert_comment.to_string())].to_vec(), + )), + ParsedExtension::IssuingDistributionPoint(issuing_dist_point) => { + let point_name: String = match &issuing_dist_point.distribution_point { + None => "None".to_string(), + Some(name) => match name { + DistributionPointName::FullName(general_names) => general_names + .iter() + .map(|gn| gn.to_string()) + .collect::>() + .join(" "), + DistributionPointName::NameRelativeToCRLIssuer(_relative_name) => { + "Relative distinguished name".to_string() + } + }, + }; + Some(( + "Issuing distribution point".to_string(), + [ + ("Point".to_string(), point_name), + ( + "Only contains user certs".to_string(), + issuing_dist_point.only_contains_user_certs.to_string(), + ), + ( + "Only contains CA certs".to_string(), + issuing_dist_point.only_contains_ca_certs.to_string(), + ), + ( + "Only some reasons".to_string(), + match &issuing_dist_point.only_some_reasons { + Some(flags) => interpret_reason_flags(flags), + None => "None".to_string(), + }, + ), + ( + "Indirect CRL".to_string(), + issuing_dist_point.only_contains_user_certs.to_string(), + ), + ( + "Only contains attribute certs".to_string(), + issuing_dist_point.only_contains_ca_certs.to_string(), + ), + ] + .to_vec(), + )) + } + ParsedExtension::CRLNumber(crl_num) => Some(( + "CRL number".to_string(), + [("Number".to_string(), crl_num.to_string())].to_vec(), + )), + ParsedExtension::ReasonCode(code) => Some(( + "Reason code".to_string(), + [("Code".to_string(), format_hex(&[code.0]))].to_vec(), + )), + ParsedExtension::InvalidityDate(invalidity_date) => Some(( + "Invalidity date".to_string(), + [("Date".to_string(), invalidity_date.to_string())].to_vec(), + )), + ParsedExtension::SCT(timestamps) => Some(( + "Signed cert timestamp(s)".to_string(), + Vec::from_iter(timestamps.iter().map( + |sct: &SignedCertificateTimestamp| -> (String, String) { + ( + format!( + "{}, {}", + if sct.version == CtVersion::V1 { + "v1(0)".to_string() + } else { + format_hex(&[sct.version.0]) + }, + format_hex(sct.id.key_id) + ), + format!( + "Timestamp: {}, Signature: {}", + ASN1Time::from_timestamp(sct.timestamp.cast_signed()).unwrap(), + format_hex(sct.signature.data) + ), + ) + }, + )), + )), + + ParsedExtension::UnsupportedExtension { oid } => Some(( + "Unsupported extension".to_string(), + [("Extension ID".to_string(), oid.to_id_string())].to_vec(), + )), + ParsedExtension::ParseError { error: _error } => None, + ParsedExtension::Unparsed => None, + _ => None, + } +} + fn extract_key_info(cert: &X509Certificate<'_>) -> (KeyType, u32) { let spki = cert.public_key(); let algo_oid = spki.algorithm.algorithm.to_string(); diff --git a/src/commands/inspect.rs b/src/commands/inspect.rs index 01f4bce..f25365b 100644 --- a/src/commands/inspect.rs +++ b/src/commands/inspect.rs @@ -1,21 +1,33 @@ -use anyhow::Result; -use std::io::Read; - -use crate::cert::parser::{parse_cert_data, parse_cert_file}; +use crate::cert::parser::{ + parse_cert_data, parse_cert_file, verbose_parse_cert_data, verbose_parse_cert_file, +}; use crate::output::colors; use crate::output::json::JsonCert; use crate::output::json::JsonCertOutput; use crate::output::terminal; +use anyhow::{bail, Result}; +use std::io::Read; -pub fn run(path: &str, json: bool, no_color: bool) -> Result { - let certs = if path == "-" { - let mut buf = Vec::new(); - std::io::stdin().read_to_end(&mut buf)?; - parse_cert_data(&buf)? +pub fn run(path: &str, json: bool, verbose: bool, no_color: bool) -> Result { + if !verbose { + let certs = if path == "-" { + let mut buf = Vec::new(); + std::io::stdin().read_to_end(&mut buf)?; + parse_cert_data(&buf)? + } else { + parse_cert_file(path)? + }; + run_certs(&certs, json, no_color) } else { - parse_cert_file(path)? - }; - run_certs(&certs, json, no_color) + let certs = if path == "-" { + let mut buf = Vec::new(); + std::io::stdin().read_to_end(&mut buf)?; + verbose_parse_cert_data(&buf)? + } else { + verbose_parse_cert_file(path)? + }; + verbose_run_certs(&certs, json, no_color) + } } /// Render already-parsed certs (used by `decode` to avoid re-reading the source). @@ -29,7 +41,6 @@ pub fn run_certs(certs: &[crate::cert::CertInfo], json: bool, no_color: bool) -> println!("{}", serde_json::to_string_pretty(&output)?); return Ok(exit_code_for_certs(certs)); } - let total = certs.len(); for (i, cert) in certs.iter().enumerate() { println!("{}", terminal::render_cert(cert, i, total, use_color)); @@ -48,3 +59,36 @@ fn exit_code_for_certs(certs: &[crate::cert::CertInfo]) -> i32 { 0 // valid } } + +pub fn verbose_run_certs( + certs: &[crate::cert::VerboseCert], + json: bool, + no_color: bool, +) -> Result { + let use_color = !no_color && !json && colors::should_color(); + + if json { + bail!("Verbose json output not yet supported") + } + + let total = certs.len(); + for (i, cert) in certs.iter().enumerate() { + println!( + "{}", + terminal::render_verbose_cert(cert, i, total, use_color) + ); + if i < total - 1 { + println!("{}", terminal::render_chain_arrow(use_color)); + } + } + + Ok(exit_code_for_verbose_certs(certs)) +} + +fn exit_code_for_verbose_certs(certs: &[crate::cert::VerboseCert]) -> i32 { + if certs.iter().any(|c| c.is_expired()) { + 1 // expired + } else { + 0 // valid + } +} diff --git a/src/main.rs b/src/main.rs index 6e2493d..b12720f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -27,6 +27,10 @@ struct Cli { #[arg(long, global = true)] json: bool, + /// Verbose output + #[arg(long, global = true)] + verbose: bool, + /// Disable colored output #[arg(long, global = true)] no_color: bool, @@ -235,7 +239,9 @@ fn main() { fn run(cli: Cli) -> anyhow::Result { match cli.command { - Commands::Inspect { file } => commands::inspect::run(&file, cli.json, cli.no_color), + Commands::Inspect { file } => { + commands::inspect::run(&file, cli.json, cli.verbose, cli.no_color) + } Commands::Connect { host, sni, diff --git a/src/output/terminal.rs b/src/output/terminal.rs index 04ec895..3e6786f 100644 --- a/src/output/terminal.rs +++ b/src/output/terminal.rs @@ -1,15 +1,68 @@ -use crate::cert::CertInfo; +use crate::cert::{CertInfo, VerboseCert}; use crate::output::{box_chars, colors, expiry_display}; +const WIDTH: usize = 72; + /// Render a certificate in a beautiful box pub fn render_cert(cert: &CertInfo, index: usize, total: usize, use_color: bool) -> String { let mut lines = Vec::new(); - let width: usize = 56; + render_cert_body(cert, &mut lines, index, total, use_color); + render_cert_bottom(&mut lines, use_color); + + lines.join("\n") +} + +/// Render a certificate verbosely with its extensions in a beautiful box +pub fn render_verbose_cert( + cert: &VerboseCert, + index: usize, + total: usize, + use_color: bool, +) -> String { + let mut lines = Vec::new(); + + render_cert_body(&cert.base_cert, &mut lines, index, total, use_color); + + for extension in &cert.extensions { + // Header + lines.push("".to_string()); + add_row( + &mut lines, + extension.0.as_str(), + "", + colors::CYAN, + use_color, + ); + + // Content + for content in extension.1 { + add_row( + &mut lines, + format!(" {}:", content.0).as_str(), + content.1.as_str(), + colors::DIM, + use_color, + ); + } + } + render_cert_bottom(&mut lines, use_color); + + lines.join("\n") +} + +// Render the header and main body of a certificate's box +fn render_cert_body( + cert: &CertInfo, + lines: &mut Vec, + index: usize, + total: usize, + use_color: bool, +) { // Header let ca_label = if cert.is_ca { " (CA)" } else { "" }; let header = format!(" Certificate {} of {}{} ", index + 1, total, ca_label); - let padding = width.saturating_sub(header.len() + 2); + let padding = WIDTH.saturating_sub(header.len() + 2); let top = format!( "{}{}{}{}{}", box_chars::TOP_LEFT, @@ -24,49 +77,21 @@ pub fn render_cert(cert: &CertInfo, index: usize, total: usize, use_color: bool) top }); - // Content rows - let add_row = |lines: &mut Vec, label: &str, value: &str, color: &str| { - let content = format!(" {:<10}{}", label, value); - let pad = width.saturating_sub(content.len()); - let row = format!( - "{}{}{} {}", - box_chars::VERTICAL, - content, - " ".repeat(pad), - box_chars::VERTICAL, - ); - if use_color && !color.is_empty() { - let padding = " ".repeat(pad); - lines.push(format!( - "{}{}{}{} {}{}{}{}", - colors::CYAN, - box_chars::VERTICAL, - color, - content, - colors::CYAN, - padding, - box_chars::VERTICAL, - colors::RESET, - )); - } else { - lines.push(row); - } - }; - - add_row(&mut lines, "Subject:", &cert.subject, ""); - add_row(&mut lines, "Issuer:", &cert.issuer, colors::DIM); + add_row(lines, "Subject:", &cert.subject, "", use_color); + add_row(lines, "Issuer:", &cert.issuer, colors::DIM, use_color); add_row( - &mut lines, + lines, "Serial:", &truncate_hex(&cert.serial_hex, 24), colors::DIM, + use_color, ); // Empty line let empty = format!( "{}{} {}", box_chars::VERTICAL, - " ".repeat(width), + " ".repeat(WIDTH), box_chars::VERTICAL, ); lines.push(if use_color { @@ -81,7 +106,7 @@ pub fn render_cert(cert: &CertInfo, index: usize, total: usize, use_color: bool) cert.not_before.format("%Y-%m-%d"), cert.not_after.format("%Y-%m-%d"), ); - add_row(&mut lines, "Valid:", &valid_range, ""); + add_row(lines, "Valid:", &valid_range, "", use_color); // Expiry bar let expiry = expiry_display(cert.days_remaining(), use_color); @@ -104,7 +129,7 @@ pub fn render_cert(cert: &CertInfo, index: usize, total: usize, use_color: bool) }); // Key info - add_row(&mut lines, "Key:", &cert.key_description(), ""); + add_row(lines, "Key:", &cert.key_description(), "", use_color); // SANs if !cert.sans.is_empty() { @@ -117,22 +142,26 @@ pub fn render_cert(cert: &CertInfo, index: usize, total: usize, use_color: bool) cert.sans.len() - 2 ) }; - add_row(&mut lines, "SANs:", &sans_display, ""); + add_row(lines, "SANs:", &sans_display, "", use_color); } // Fingerprint add_row( - &mut lines, + lines, "SHA-256:", &truncate_hex(&cert.sha256_fingerprint, 24), colors::DIM, + use_color, ); +} +// Render the bottom (footer) of a certificate's box +fn render_cert_bottom(lines: &mut Vec, use_color: bool) { // Bottom border let bottom = format!( "{}{}{}", box_chars::BOTTOM_LEFT, - box_chars::HORIZONTAL.repeat(width + 2), + box_chars::HORIZONTAL.repeat(WIDTH + 2), box_chars::BOTTOM_RIGHT, ); lines.push(if use_color { @@ -140,8 +169,37 @@ pub fn render_cert(cert: &CertInfo, index: usize, total: usize, use_color: bool) } else { bottom }); +} - lines.join("\n") +// Add a row to content +fn add_row(lines: &mut Vec, label: &str, value: &str, color: &str, use_color: bool) { + let content = format!(" {:<20} {}", label, value); + let pad = WIDTH.saturating_sub(content.len()); + + let row = format!( + "{}{}{} {}", + box_chars::VERTICAL, + content, + " ".repeat(pad), + box_chars::VERTICAL, + ); + + if use_color && !color.is_empty() { + let padding = " ".repeat(pad); + lines.push(format!( + "{}{}{}{} {}{}{}{}", + colors::CYAN, + box_chars::VERTICAL, + color, + content, + colors::CYAN, + padding, + box_chars::VERTICAL, + colors::RESET, + )); + } else { + lines.push(row); + } } /// Render the "signed by" arrow between certs