use crate::builder::{Builder, ClientConfig};
use crate::core::SessionContext;
use crate::error::{Error, ErrorKind, Result, Stage};
use crate::keys::Certificate;
use crate::party::Partner;
use crate::profile::{
CONFIRM_SWITCH_ACTION, DEFAULT_ACTION, PATH_SWITCH_SERVICE, PartnerPolicy, PartyTypeRule,
Profile, REQUEST_SWITCH_ACTION, SendHeader, ServiceRule, TEST_ACTION, TEST_SERVICE,
};
use crate::verified::{Payload, Receipt, ReceiptKind, Verified};
use std::collections::HashMap;
use std::sync::Arc;
use std::time::SystemTime;
#[derive(Debug, Clone)]
pub struct As4(pub(crate) Arc<As4Inner>);
#[derive(Debug)]
pub(crate) struct As4Inner {
pub(crate) config: ClientConfig,
pub(crate) sessions: HashMap<(Option<String>, String), SessionContext>,
pub(crate) inbound_session: SessionContext,
}
impl As4 {
pub fn builder(profile: Profile) -> Builder<As4> {
Builder::new(profile)
}
pub(crate) fn from_config(config: ClientConfig) -> Result<As4> {
let mut sessions = HashMap::new();
for (key, policy) in &config.partners {
sessions.insert(key.clone(), session_for(&config, policy)?);
}
let inbound_session =
crate::builder::base_session(&config, "inbound", Vec::new(), None, &[])?;
Ok(As4(Arc::new(As4Inner {
config,
sessions,
inbound_session,
})))
}
pub async fn send(&self, message: Message) -> Result<Verified<Receipt>> {
crate::as4::send_client::send(self, message).await
}
pub fn inbound(
&self,
dedup: impl crate::DedupStore,
handler: impl crate::Handler<UserMessage>,
) -> crate::Inbound {
crate::as4::inbound::inbound(self.clone(), Arc::new(dedup), Arc::new(handler))
}
pub(crate) fn config(&self) -> &ClientConfig {
&self.0.config
}
}
pub(crate) fn session_for(config: &ClientConfig, policy: &PartnerPolicy) -> Result<SessionContext> {
let partner_id = policy
.partner
.as_ref()
.map(|p| p.id().to_string())
.unwrap_or_else(|| "unregistered".to_string());
let signing: &[Certificate] = policy
.partner
.as_ref()
.map(|p| p.signing_certs())
.unwrap_or_default();
crate::builder::base_session(
config,
&partner_id,
policy.pins.clone(),
policy.encryption_cert.as_ref(),
signing,
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum PathSwitch {
Request,
Confirm,
}
impl PathSwitch {
fn action(self) -> &'static str {
match self {
PathSwitch::Request => REQUEST_SWITCH_ACTION,
PathSwitch::Confirm => CONFIRM_SWITCH_ACTION,
}
}
}
pub(crate) fn classify_path_switch(service: &str, action: &str) -> Option<PathSwitch> {
if service != PATH_SWITCH_SERVICE {
return None;
}
match action {
REQUEST_SWITCH_ACTION => Some(PathSwitch::Request),
CONFIRM_SWITCH_ACTION => Some(PathSwitch::Confirm),
_ => None,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum MessageKind {
Business,
Ping,
PathSwitch(PathSwitch),
}
#[derive(Debug, Clone)]
#[must_use]
pub struct Message {
pub(crate) partner: Partner,
pub(crate) service: Option<String>,
pub(crate) action: Option<String>,
pub(crate) conversation_id: Option<String>,
pub(crate) message_id: Option<String>,
pub(crate) timestamp: Option<SystemTime>,
pub(crate) payloads: Vec<Payload>,
pub(crate) kind: MessageKind,
}
impl Message {
pub fn to(partner: &Partner) -> Message {
Message {
partner: partner.clone(),
service: None,
action: None,
conversation_id: None,
message_id: None,
timestamp: None,
payloads: Vec::new(),
kind: MessageKind::Business,
}
}
pub fn ping(partner: &Partner) -> Message {
Message {
kind: MessageKind::Ping,
..Message::to(partner)
}
}
pub fn path_switch(partner: &Partner, kind: PathSwitch) -> Message {
Message {
kind: MessageKind::PathSwitch(kind),
..Message::to(partner)
}
}
pub fn service(self, service: impl Into<String>) -> Message {
Message {
service: Some(service.into()),
..self
}
}
pub fn action(self, action: impl Into<String>) -> Message {
Message {
action: Some(action.into()),
..self
}
}
pub fn conversation_id(self, id: impl Into<String>) -> Message {
Message {
conversation_id: Some(id.into()),
..self
}
}
pub fn message_id(self, id: impl Into<String>) -> Message {
Message {
message_id: Some(id.into()),
..self
}
}
pub fn timestamp(self, at: SystemTime) -> Message {
Message {
timestamp: Some(at),
..self
}
}
pub fn payload(mut self, payload: Payload) -> Message {
self.payloads.push(payload);
self
}
}
#[derive(Debug)]
pub struct UserMessage {
pub(crate) message_id: String,
pub(crate) conversation_id: String,
pub(crate) from: String,
pub(crate) service: String,
pub(crate) action: String,
pub(crate) payloads: Vec<Payload>,
pub(crate) path_switch: Option<PathSwitch>,
}
impl UserMessage {
pub fn message_id(&self) -> &str {
&self.message_id
}
pub fn conversation_id(&self) -> &str {
&self.conversation_id
}
pub fn from(&self) -> &str {
&self.from
}
pub fn service(&self) -> &str {
&self.service
}
pub fn action(&self) -> &str {
&self.action
}
pub fn payloads(&self) -> &[Payload] {
&self.payloads
}
pub fn path_switch(&self) -> Option<PathSwitch> {
self.path_switch
}
}
fn refuse(profile: &Profile, field: &str, detail: impl std::fmt::Display, section: &str) -> Error {
if section.is_empty() {
Error::config(format!("Message::{field}: {detail}"))
} else {
Error::config(format!(
"Message::{field}: {detail} ({} {section})",
profile.citation()
))
}
}
pub(crate) fn valid_message_id(id: &str) -> bool {
let Some((left, right)) = id.split_once('@') else {
return false;
};
!left.is_empty()
&& !right.is_empty()
&& !right.contains('@')
&& id.len() <= 255
&& id
.bytes()
.all(|b| (0x21..0x7f).contains(&b) && !matches!(b, b'<' | b'>' | b'"' | b'\\'))
}
pub(crate) fn valid_file_name(name: &str) -> bool {
!name.is_empty()
&& name.len() <= 255
&& !name
.bytes()
.any(|b| matches!(b, b'\r' | b'\n' | b'"' | b'\\' | b'/') || !(0x20..0x7f).contains(&b))
}
pub(crate) fn fresh_message_id() -> Result<String> {
let mut bytes = [0u8; 16];
getrandom::fill(&mut bytes).map_err(|err| {
Error::new(
ErrorKind::Config,
format!("no randomness for a message id: {err}"),
)
})?;
let hex: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
Ok(format!("{hex}@asx"))
}
pub(crate) struct Checked {
pub(crate) header: SendHeader,
pub(crate) message_id: String,
pub(crate) timestamp: String,
}
pub(crate) fn check_message(
config: &ClientConfig,
policy: &PartnerPolicy,
message: &Message,
) -> Result<Checked> {
let profile = &config.profile;
if let MessageKind::PathSwitch(_) = message.kind {
check_path_switch(profile, message)?;
}
let (service, action) = if let MessageKind::PathSwitch(kind) = message.kind {
(PATH_SWITCH_SERVICE.to_string(), kind.action().to_string())
} else if message.kind == MessageKind::Ping {
(TEST_SERVICE.to_string(), TEST_ACTION.to_string())
} else {
let service = match (profile.service_rule(), message.service.as_deref()) {
(_, None) => {
return Err(refuse(profile, "service", "a service is required", ""));
}
(ServiceRule::OneOf(allowed), Some(s)) if !allowed.contains(&s) => {
return Err(refuse(
profile,
"service",
format!("{s:?} is not one of {allowed:?}"),
"ยง2.3.1.2.1",
));
}
(_, Some(s)) => s.to_string(),
};
let action = match (profile.fixed_action(), message.action.as_deref()) {
(Some(fixed), Some(a)) if a != fixed => {
return Err(refuse(
profile,
"action",
format!("the profile fixes the action to {fixed:?}"),
"ยง2.3.1.2.2",
));
}
(Some(fixed), _) => fixed.to_string(),
(None, Some(a)) => a.to_string(),
(None, None) => DEFAULT_ACTION.to_string(),
};
(service, action)
};
if message.conversation_id.is_some() && !profile.conversation_id_allowed() {
return Err(refuse(
profile,
"conversation_id",
"the profile requires an empty ConversationId",
"ยง2.3.1.2.4",
));
}
if message.payloads.is_empty() && !matches!(message.kind, MessageKind::PathSwitch(_)) {
return Err(refuse(
profile,
"payload",
"at least one payload is required",
"",
));
}
let mut total: u64 = 0;
for payload in &message.payloads {
if payload.name().is_some() && !profile.filename_allowed() {
return Err(refuse(
profile,
"payload",
"file names are not allowed",
"ยง4.10",
));
}
crate::as4::mime_packaging::validate_media_type(payload.mime_type(), "Payload::mime_type")
.map_err(|err| refuse(profile, "payload", err.message, ""))?;
if let Some(name) = payload.name()
&& !valid_file_name(name)
{
return Err(refuse(
profile,
"payload",
"the file name contains CR, LF, '\"', '\\', a path separator or is longer than 255 bytes",
"",
));
}
total = total.saturating_add(payload.bytes().len() as u64);
}
if total > profile.max_bytes() {
return Err(refuse(
profile,
"payload",
format!(
"{total} bytes exceed max_message_bytes {}",
profile.max_bytes()
),
"",
));
}
let message_id = match &message.message_id {
Some(id) if valid_message_id(id) => id.clone(),
Some(id) => {
return Err(refuse(
profile,
"message_id",
format!(
"{id:?} is not an RFC 2822 id-left@id-right of at most 255 bytes without angle brackets"
),
"",
));
}
None => fresh_message_id()?,
};
let timestamp = crate::time_utils::format_rfc3339_secs(
message.timestamp.unwrap_or_else(|| policy.clock.now()),
);
let (from_type, to_type) = match profile.party_types() {
PartyTypeRule::Fixed(fixed) => (Some(fixed.to_string()), Some(fixed.to_string())),
_ => (
config.local.party_type.clone(),
message.partner.type_ref().map(str::to_owned),
),
};
Ok(Checked {
header: SendHeader {
from_party: config.local.id.clone(),
from_type,
to_party: message.partner.id().to_string(),
to_type,
service,
service_type: String::new(),
action,
conversation_id: if profile.conversation_id_allowed() {
message.conversation_id.clone()
} else {
Some(String::new())
},
},
message_id,
timestamp,
})
}
fn check_path_switch(profile: &Profile, message: &Message) -> Result<()> {
if !profile.path_switch() {
return Err(Error::config(format!(
"Message::path_switch: the transmission-path-switch service is defined only by \
BDEW AS4-Profil 1.2 ยง2.2.8, ยง2.3.7; {} has none",
profile.citation()
)));
}
let set = [
(!message.payloads.is_empty(), "payload"),
(message.service.is_some(), "service"),
(message.action.is_some(), "action"),
(message.conversation_id.is_some(), "conversation_id"),
];
if let Some((_, field)) = set.iter().find(|(is_set, _)| *is_set) {
return Err(refuse(
profile,
field,
"a path-switch message carries no payload and a library-set Service, Action and \
empty ConversationId",
"ยง2.3.7",
));
}
Ok(())
}
pub(crate) fn check_validity_at_use(config: &ClientConfig, policy: &PartnerPolicy) -> Result<()> {
let now = policy.clock.now();
let signer = &config.local.signer_ref().signer;
if let Some(leaf) = signer.certificate_chain().first()
&& !leaf.is_valid_at(now)
{
return Err(Error::config(
"LocalParty::signer: the signing certificate is expired or not yet valid",
));
}
let expired = |c: &Certificate| !c.is_valid_at(now);
if policy.encryption_cert.as_ref().is_some_and(expired) {
return Err(Error::config(
"Partner::encryption_cert: the certificate is expired or not yet valid",
));
}
if let Some(partner) = &policy.partner
&& partner.signing_certs().iter().all(expired)
&& !partner.signing_certs().is_empty()
{
return Err(Error::config(
"Partner::signing_cert: every pinned certificate is expired or not yet valid",
));
}
Ok(())
}
pub(crate) fn as4_receipt(ref_to: &str) -> Receipt {
Receipt::new(ref_to, ReceiptKind::As4)
}
#[allow(dead_code)]
pub(crate) fn rejected(err: crate::core::AsxError) -> Error {
Error::from_internal(Stage::Inbound, err)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn message_id_rule() {
assert!(valid_message_id("abc@example.org"));
assert!(!valid_message_id("<abc@example.org>"));
assert!(!valid_message_id("abc"));
assert!(!valid_message_id("a b@c"));
assert!(!valid_message_id(&format!("{}@x", "a".repeat(260))));
assert!(valid_message_id(&fresh_message_id().unwrap()));
}
#[test]
fn path_switch_classification_table() {
const S: &str = PATH_SWITCH_SERVICE;
const MP: &str = "https://www.bdew.de/as4/communication/services/MP";
let variants = |uri: &str| -> Vec<String> {
vec![
format!("{uri} "),
format!(" {uri}"),
format!("{uri}\n"),
uri[..uri.len() - 1].to_string(),
format!("{}X", &uri[..uri.len() - 1]),
uri.to_ascii_uppercase(),
uri.replace("https:", "http:"),
format!("{uri}/"),
]
};
let mut rows: Vec<(String, String, Option<PathSwitch>)> = vec![
(
S.into(),
REQUEST_SWITCH_ACTION.into(),
Some(PathSwitch::Request),
),
(
S.into(),
CONFIRM_SWITCH_ACTION.into(),
Some(PathSwitch::Confirm),
),
(TEST_SERVICE.into(), TEST_ACTION.into(), None),
(MP.into(), DEFAULT_ACTION.into(), None),
(REQUEST_SWITCH_ACTION.into(), S.into(), None),
(TEST_SERVICE.into(), REQUEST_SWITCH_ACTION.into(), None),
(S.into(), TEST_ACTION.into(), None),
(MP.into(), CONFIRM_SWITCH_ACTION.into(), None),
(S.into(), DEFAULT_ACTION.into(), None),
(S.into(), String::new(), None),
(String::new(), REQUEST_SWITCH_ACTION.into(), None),
(format!("type={S}"), REQUEST_SWITCH_ACTION.into(), None),
];
for service in variants(S) {
rows.push((service.clone(), REQUEST_SWITCH_ACTION.into(), None));
rows.push((service, CONFIRM_SWITCH_ACTION.into(), None));
}
for action in variants(REQUEST_SWITCH_ACTION)
.into_iter()
.chain(variants(CONFIRM_SWITCH_ACTION))
{
rows.push((S.into(), action, None));
}
let mut classified = 0;
for (service, action, want) in &rows {
let got = classify_path_switch(service, action);
assert_eq!(got, *want, "{service:?} / {action:?}");
classified += usize::from(got.is_some());
}
assert_eq!(classified, 2, "exactly two rows are a path switch");
}
#[test]
fn path_switch_send_rows() {
let partner = Partner::new("9900000000002");
let request = || Message::path_switch(&partner, PathSwitch::Request);
assert!(check_path_switch(&Profile::bdew(), &request()).is_ok());
assert!(
check_path_switch(
&Profile::bdew(),
&Message::path_switch(&partner, PathSwitch::Confirm)
)
.is_ok()
);
for profile in [
Profile::peppol(),
Profile::edelivery_1_15(),
Profile::entsog(),
] {
let err = check_path_switch(&profile, &request()).unwrap_err();
assert_eq!(err.kind(), ErrorKind::Config);
assert!(err.to_string().contains("ยง2.3.7"), "{err}");
}
for (message, field) in [
(
request().payload(Payload::new(b"x".to_vec(), "application/octet-stream")),
"payload",
),
(request().service(PATH_SWITCH_SERVICE), "service"),
(request().action(REQUEST_SWITCH_ACTION), "action"),
(request().conversation_id(""), "conversation_id"),
] {
let err = check_path_switch(&Profile::bdew(), &message).unwrap_err();
assert_eq!(err.kind(), ErrorKind::Config);
assert!(
err.to_string().contains(&format!("Message::{field}")),
"{err}"
);
}
assert_eq!(request().kind, MessageKind::PathSwitch(PathSwitch::Request));
assert_eq!(Message::to(&partner).kind, MessageKind::Business);
assert_eq!(Message::ping(&partner).kind, MessageKind::Ping);
}
#[test]
fn file_name_rule() {
assert!(valid_file_name("UTILMD_9900000000001.txt"));
assert!(!valid_file_name("a/b"));
assert!(!valid_file_name("a\\b"));
assert!(!valid_file_name("a\"b"));
assert!(!valid_file_name("a\r\nb"));
assert!(!valid_file_name(&"a".repeat(256)));
}
}