use std::collections::BTreeMap;
use std::sync::LazyLock;
use crate::constants::DEFAULT_TIMEOUT_SOAP_SECS;
use crate::error::RevenantError;
use crate::net::TlsMode;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CertFieldSource {
#[default]
Name,
Dn,
Organization,
Email,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CertField {
pub id: String,
pub label: String,
pub source: CertFieldSource,
pub regex: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SigAuto {
Date,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SigFieldValue {
Cert(String),
Auto(SigAuto),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SigField {
pub value: SigFieldValue,
pub label: Option<String>,
}
impl SigField {
fn from_cert(id: &str) -> Self {
SigField {
value: SigFieldValue::Cert(id.to_owned()),
label: None,
}
}
fn from_cert_labeled(id: &str, label: &str) -> Self {
SigField {
value: SigFieldValue::Cert(id.to_owned()),
label: Some(label.to_owned()),
}
}
fn auto(kind: SigAuto) -> Self {
SigField {
value: SigFieldValue::Auto(kind),
label: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IdentityMethod {
Server,
Manual,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TrustAnchors {
None,
Pinned(Vec<Vec<u8>>),
Tsl(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ServerProfile {
pub name: String,
pub display_name: String,
pub url: String,
pub timeout: u32,
pub identity_methods: Vec<IdentityMethod>,
pub tls_mode: TlsMode,
pub ca_cert_markers: Vec<String>,
pub max_auth_attempts: u32,
pub cert_fields: Vec<CertField>,
pub sig_fields: Vec<SigField>,
pub font: String,
pub cli_description: String,
pub trust: TrustAnchors,
}
impl ServerProfile {
#[must_use]
pub fn has_identity_method(&self, method: IdentityMethod) -> bool {
self.identity_methods.contains(&method)
}
}
fn default_identity_methods() -> Vec<IdentityMethod> {
vec![IdentityMethod::Server, IdentityMethod::Manual]
}
pub const EKENG: &str = "ekeng";
pub static BUILTIN_PROFILES: LazyLock<BTreeMap<&'static str, ServerProfile>> =
LazyLock::new(|| {
let mut profiles = BTreeMap::new();
profiles.insert(EKENG, ekeng_profile());
profiles
});
fn ekeng_profile() -> ServerProfile {
ServerProfile {
name: EKENG.to_owned(),
display_name: "EKENG (Armenian Government)".to_owned(),
url: "https://ca.gov.am:8080/SAPIWS/DSS.asmx".to_owned(),
timeout: 120,
identity_methods: default_identity_methods(),
tls_mode: TlsMode::Legacy {
pins: vec![
"0c00d213f7945bfec24402f8b76ff25f23bc613e58e38aef34e40adcbf9ea6e4".to_owned(),
],
},
ca_cert_markers: vec![
"ekeng".to_owned(),
"\u{0567}\u{056f}\u{0565}\u{0576}\u{0563}".to_owned(),
],
max_auth_attempts: 5,
cert_fields: vec![
CertField {
id: "name".to_owned(),
label: "Name".to_owned(),
source: CertFieldSource::Name,
regex: Some(r"^(.+?)\s+\d{5,}$".to_owned()),
},
CertField {
id: "gov_id".to_owned(),
label: "SSN".to_owned(),
source: CertFieldSource::Name,
regex: Some(r"(\d{5,})$".to_owned()),
},
CertField {
id: "email".to_owned(),
label: "Email".to_owned(),
source: CertFieldSource::Email,
regex: None,
},
],
sig_fields: vec![
SigField::from_cert("name"),
SigField::from_cert_labeled("gov_id", "SSN"),
SigField::auto(SigAuto::Date),
],
font: "ghea-grapalat".to_owned(),
cli_description: "Cross-platform CLI for ARX CoSign electronic signatures (EKENG profile)."
.to_owned(),
trust: TrustAnchors::Pinned(ekeng_trust_anchors()),
}
}
fn ekeng_trust_anchors() -> Vec<Vec<u8>> {
const STAFF_GOV_RA_ROOT_CA: &[u8] =
include_bytes!("anchors/staff_of_government_of_ra_root_ca.der");
vec![STAFF_GOV_RA_ROOT_CA.to_vec()]
}
impl ServerProfile {
pub fn builtin(name: &str) -> Result<Self, RevenantError> {
let key = name.trim().to_lowercase();
BUILTIN_PROFILES.get(key.as_str()).cloned().ok_or_else(|| {
let available = BUILTIN_PROFILES
.keys()
.copied()
.collect::<Vec<_>>()
.join(", ");
RevenantError::Config(format!("Unknown profile {name:?}. Available: {available}"))
})
}
pub fn custom(url: &str, timeout: u32) -> Result<Self, RevenantError> {
let parsed = url::Url::parse(url)
.map_err(|e| RevenantError::Config(format!("Invalid URL {url:?}: {e}")))?;
match parsed.scheme() {
"https" => {}
"http" => {
return Err(RevenantError::Config(
"HTTP URLs are not supported. Use https:// to protect credentials in transit."
.to_owned(),
));
}
other => {
return Err(RevenantError::Config(format!(
"Invalid URL scheme {other:?}. Use https://."
)));
}
}
Ok(ServerProfile {
name: "custom".to_owned(),
display_name: format!("Custom ({url})"),
url: url.to_owned(),
timeout,
identity_methods: default_identity_methods(),
tls_mode: TlsMode::Standard,
ca_cert_markers: Vec::new(),
max_auth_attempts: 0,
cert_fields: Vec::new(),
sig_fields: Vec::new(),
font: "noto-sans".to_owned(),
cli_description: String::new(),
trust: TrustAnchors::None,
})
}
pub fn custom_default(url: &str) -> Result<Self, RevenantError> {
Self::custom(url, DEFAULT_TIMEOUT_SOAP_SECS)
}
pub fn custom_with_tls(
url: &str,
timeout: u32,
tls_mode: TlsMode,
) -> Result<Self, RevenantError> {
let mut profile = Self::custom(url, timeout)?;
profile.tls_mode = tls_mode;
Ok(profile)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ekeng_profile_matches_spec() {
let ekeng = ServerProfile::builtin("ekeng").expect("ekeng is built in");
assert_eq!(ekeng.name, "ekeng");
assert_eq!(ekeng.url, "https://ca.gov.am:8080/SAPIWS/DSS.asmx");
assert_eq!(ekeng.timeout, 120);
let TlsMode::Legacy { pins } = &ekeng.tls_mode else {
panic!("ekeng must declare legacy TLS");
};
assert_eq!(pins.len(), 1);
assert_eq!(pins[0].len(), 64);
assert_eq!(ekeng.max_auth_attempts, 5);
assert_eq!(ekeng.font, "ghea-grapalat");
assert!(matches!(ekeng.trust, TrustAnchors::Pinned(_)));
assert!(ekeng.has_identity_method(IdentityMethod::Server));
assert!(ekeng.has_identity_method(IdentityMethod::Manual));
assert!(ekeng
.ca_cert_markers
.iter()
.any(|m| m == "\u{0567}\u{056f}\u{0565}\u{0576}\u{0563}"));
assert_eq!(ekeng.cert_fields.len(), 3);
assert_eq!(ekeng.sig_fields.len(), 3);
}
#[test]
fn ekeng_pins_the_verified_government_root() {
use sha2::Digest as _;
let anchors = ekeng_trust_anchors();
assert_eq!(anchors.len(), 1, "EKENG should pin exactly one root");
let fingerprint = hex::encode(sha2::Sha256::digest(&anchors[0]));
assert_eq!(
fingerprint,
"671c272eaf581886e549fbd2d2879188b3aee1c6188a33a65ef8ccfa457ee2bc"
);
}
#[test]
fn get_profile_is_case_insensitive_and_trims() {
assert!(ServerProfile::builtin(" EKENG ").is_ok());
assert!(ServerProfile::builtin("Ekeng").is_ok());
}
#[test]
fn get_profile_unknown_lists_available() {
let err = ServerProfile::builtin("nope").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("Unknown profile"));
assert!(msg.contains("ekeng"));
}
#[test]
fn custom_profile_requires_https() {
let https = ServerProfile::custom_default("https://example.com/DSS.asmx")
.expect("https is accepted");
assert_eq!(https.name, "custom");
assert_eq!(https.tls_mode, TlsMode::Standard);
assert_eq!(https.timeout, 120);
assert_eq!(https.display_name, "Custom (https://example.com/DSS.asmx)");
let http_err = ServerProfile::custom_default("http://example.com/DSS.asmx").unwrap_err();
assert!(http_err.to_string().contains("HTTP URLs are not supported"));
let ftp_err = ServerProfile::custom_default("ftp://example.com/x").unwrap_err();
assert!(ftp_err.to_string().contains("Invalid URL scheme"));
}
#[test]
fn custom_profile_rejects_hostless_url() {
assert!(ServerProfile::custom_default("https://").is_err());
}
}