commit a960ec7c8e4119db5e9f45c7be97dd36d4b9181b
parent fa03b8e97ebe07dd5dd3fc6dfbeefd3952c6a297
Author: Antoine A <>
Date: Wed, 24 Jun 2026 12:20:24 +0200
ebics:
- verify bank signature
- reuse allocation in C14N normalization
- reduce xml signature allocation
Diffstat:
7 files changed, 329 insertions(+), 146 deletions(-)
diff --git a/libeufin-bank/src/api.rs b/libeufin-bank/src/api.rs
@@ -199,7 +199,7 @@ async fn config(State(state): State<Arc<BankState>>) -> Response {
}
pub fn bank_api(state: Arc<BankState>) -> Router {
- let router = Router::new().route("/config", get(config)); // TODO monitor
+ let router = Router::new().route("/config", get(config));
if let Some(path) = &state.cfg.spa_path {
router
.nest_service("/webui/", ServeDir::new(path))
diff --git a/libeufin-ebics/src/ebics.rs b/libeufin-ebics/src/ebics.rs
@@ -22,7 +22,7 @@ use std::{borrow::Cow, io::Write as _};
use aws_lc_rs::{
digest::Digest,
encoding::AsDer,
- rsa::{self, PrivateDecryptingKey},
+ rsa::{self, PrivateDecryptingKey, PublicKey},
};
use compact_str::CompactString;
use flate2::write::ZlibDecoder;
@@ -32,6 +32,7 @@ use reqwest::{
Client, ClientBuilder, StatusCode,
header::{CONTENT_TYPE, HeaderValue},
};
+use roxmltree::Document;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use sqlx::PgPool;
use taler_common::encoding::base64;
@@ -59,6 +60,7 @@ use crate::{
keys::{BankKeys, ClientKeys},
utils::deflate,
xml,
+ xml_sign::{VerifyError, verify_ebics},
};
pub mod administrative;
@@ -253,6 +255,9 @@ pub enum EbicsErrKind {
#[error(transparent)]
Zip(#[from] zip::result::ZipError),
+ #[error(transparent)]
+ Signature(#[from] VerifyError),
+
#[error("{0}")]
Custom(Cow<'static, str>),
}
@@ -337,11 +342,12 @@ impl<'a> EbicsClient<'a> {
&self,
xml: String,
ctx: &EbicsCtx<'_>,
- parse: impl FnOnce(&[u8]) -> xml::Result<EbicsResponse<T>>,
+ key: &PublicKey,
+ parse: impl FnOnce(Document) -> xml::Result<EbicsResponse<T>>,
) -> Result<T, EbicsError> {
let xml = self.post_to_bank(xml, ctx).await?;
- // TODO verify ebics signature
- let res = parse(&xml).ctx(ctx)?;
+ let doc = verify_ebics(&xml, key).ctx(ctx)?;
+ let res = parse(doc).ctx(ctx)?;
trace!(target: "ebics",
"{ctx}: {} {} - {}",
res.technical_code,
@@ -428,7 +434,7 @@ impl<'a> EbicsClient<'a> {
while let Some(tx_id) = ebics_first(db).await.ctx(&ctx)? {
let ctx = EbicsCtx::new(order).interrupt(&tx_id);
let xml = receipt(&self.cfg, client, order, &tx_id, false);
- if let Err(e) = self.post_bts(xml, &ctx, parse_d_init).await {
+ if let Err(e) = self.post_bts(xml, &ctx, &bank.auth, parse_d_init).await {
if !matches!(
e.kind,
// Transaction already closed or expired - EBICS protocol error
@@ -455,7 +461,7 @@ impl<'a> EbicsClient<'a> {
nb_segments,
segment,
data_encryption_info,
- } = self.post_bts(xml, &ctx, parse_d_init).await?;
+ } = self.post_bts(xml, &ctx, &bank.auth, parse_d_init).await?;
ebics_register(db, &tx_id).await.ctx(&ctx)?;
// Transfer phase
@@ -463,7 +469,9 @@ impl<'a> EbicsClient<'a> {
for segment_nb in 2..=nb_segments {
ctx = ctx.transfer(&tx_id, segment_nb);
let xml = d_transfer(&self.cfg, client, order, nb_segments, segment_nb, &tx_id);
- let DTransfer { segment, .. } = self.post_bts(xml, &ctx, parse_d_transfer).await?;
+ let DTransfer { segment, .. } = self
+ .post_bts(xml, &ctx, &bank.auth, parse_d_transfer)
+ .await?;
segments.push(segment);
}
@@ -477,7 +485,7 @@ impl<'a> EbicsClient<'a> {
ctx = ctx.receipt(&tx_id);
let xml = receipt(&self.cfg, client, order, &tx_id, res.is_ok() && !peek);
if let Err(e) = async {
- self.post_bts(xml, &ctx, parse_receipt).await?;
+ self.post_bts(xml, &ctx, &bank.auth, parse_receipt).await?;
ebics_remove(db, &tx_id).await.ctx(&ctx)
}
.await
@@ -511,13 +519,14 @@ impl<'a> EbicsClient<'a> {
// Init phase
ctx = ctx.init();
let xml = u_init(&self.cfg, bank, client, order, &payload);
- let U { tx_id, order_id } = self.post_bts(xml, &ctx, parse_u_init).await?;
+ let U { tx_id, order_id } = self.post_bts(xml, &ctx, &bank.auth, parse_u_init).await?;
// Transfer phase
for segment_nb in 1..=payload.nb_segments() {
ctx = ctx.transfer(&tx_id, segment_nb);
let xml = u_transfer(&self.cfg, client, order, &tx_id, &payload, segment_nb);
- self.post_bts(xml, &ctx, parse_u_transfer).await?;
+ self.post_bts(xml, &ctx, &bank.auth, parse_u_transfer)
+ .await?;
}
Ok(order_id)
@@ -642,6 +651,7 @@ pub async fn tx_check(
.post_bts(
d_init(&ebics.cfg, bank, client, fetch, &None),
&ctx,
+ &bank.auth,
parse_d_init,
)
.await?;
@@ -652,6 +662,7 @@ pub async fn tx_check(
.post_bts(
d_init(&ebics.cfg, bank, client, fetch, &None),
&ctx,
+ &bank.auth,
parse_d_init,
)
.await
@@ -664,6 +675,7 @@ pub async fn tx_check(
.post_bts(
receipt(&ebics.cfg, client, fetch, &tx_id, false),
&ctx,
+ &bank.auth,
parse_receipt,
)
.await?;
@@ -691,6 +703,7 @@ pub async fn tx_check(
.post_bts(
u_init(&ebics.cfg, bank, client, submit, &payload),
&ctx,
+ &bank.auth,
parse_u_init,
)
.await
@@ -702,6 +715,7 @@ pub async fn tx_check(
.post_bts(
u_transfer(&ebics.cfg, client, fetch, &tx_id, &payload, 1),
&ctx,
+ &bank.auth,
parse_u_transfer,
)
.await?;
@@ -710,6 +724,7 @@ pub async fn tx_check(
.post_bts(
u_init(&ebics.cfg, bank, client, submit, &payload),
&ctx,
+ &bank.auth,
parse_u_init,
)
.await
@@ -739,6 +754,7 @@ pub async fn tx_check(
.post_bts(
receipt(&ebics.cfg, client, fetch, &tx_id, false),
&ctx,
+ &bank.auth,
parse_receipt,
)
.await?;
@@ -751,6 +767,7 @@ pub async fn tx_check(
.post_bts(
receipt(&ebics.cfg, client, fetch, &tx_id, false),
&ctx,
+ &bank.auth,
parse_receipt,
)
.await
diff --git a/libeufin-ebics/src/ebics/bts.rs b/libeufin-ebics/src/ebics/bts.rs
@@ -21,6 +21,7 @@
use compact_str::CompactString;
use jiff::{Timestamp, Zoned, tz::TimeZone};
+use roxmltree::Document;
use taler_common::encoding::base64;
use crate::{
@@ -53,7 +54,7 @@ fn signed_request(
@ lambda
}
);
- sign_ebics(doc, &client.auth)
+ sign_ebics(doc, &client.auth).expect("EBICS signature never fails")
}
fn bank_digest(w: &mut XmlWriter, bank: &BankKeys) {
@@ -303,8 +304,8 @@ pub struct DInit {
pub nb_segments: usize,
}
-pub fn parse_d_init(xml: &[u8]) -> xml::Result<EbicsResponse<DInit>> {
- Xml::parse(xml, "ebicsResponse", |root| {
+pub fn parse_d_init(xml: Document) -> xml::Result<EbicsResponse<DInit>> {
+ Xml::doc(xml, "ebicsResponse", |root| {
let header = root.one_signed("header")?;
let st = header.one("static")?;
let mutable = header.one("mutable")?;
@@ -350,8 +351,8 @@ pub struct DTransfer {
pub nb_segments: usize,
}
-pub fn parse_d_transfer(xml: &[u8]) -> xml::Result<EbicsResponse<DTransfer>> {
- Xml::parse(xml, "ebicsResponse", |root| {
+pub fn parse_d_transfer(xml: Document) -> xml::Result<EbicsResponse<DTransfer>> {
+ Xml::doc(xml, "ebicsResponse", |root| {
let header = root.one_signed("header")?;
let st = header.one("static")?;
let mutable = header.one("mutable")?;
@@ -389,8 +390,8 @@ pub struct Receipt {
pub tx_id: CompactString,
}
-pub fn parse_receipt(xml: &[u8]) -> xml::Result<EbicsResponse<Receipt>> {
- Xml::parse(xml, "ebicsResponse", |root| {
+pub fn parse_receipt(xml: Document) -> xml::Result<EbicsResponse<Receipt>> {
+ Xml::doc(xml, "ebicsResponse", |root| {
let header = root.one_signed("header")?;
let st = header.one("static")?;
let mutable = header.one("mutable")?;
@@ -427,8 +428,8 @@ pub struct U {
pub order_id: CompactString,
}
-pub fn parse_u_init(xml: &[u8]) -> xml::Result<EbicsResponse<U>> {
- Xml::parse(xml, "ebicsResponse", |root| {
+pub fn parse_u_init(xml: Document) -> xml::Result<EbicsResponse<U>> {
+ Xml::doc(xml, "ebicsResponse", |root| {
let header = root.one_signed("header")?;
let st = header.one("static")?;
let mutable = header.one("mutable")?;
@@ -461,8 +462,8 @@ pub fn parse_u_init(xml: &[u8]) -> xml::Result<EbicsResponse<U>> {
})
}
-pub fn parse_u_transfer(xml: &[u8]) -> xml::Result<EbicsResponse<U>> {
- Xml::parse(xml, "ebicsResponse", |root| {
+pub fn parse_u_transfer(xml: Document) -> xml::Result<EbicsResponse<U>> {
+ Xml::doc(xml, "ebicsResponse", |root| {
let header = root.one_signed("header")?;
let st = header.one("static")?;
let mutable = header.one("mutable")?;
diff --git a/libeufin-ebics/src/ebics/key_management.rs b/libeufin-ebics/src/ebics/key_management.rs
@@ -55,7 +55,7 @@ impl EbicsClient<'_> {
if !matches!(order, Order::INI | Order::HIA) {
unreachable!("Only INI & HIA are supported for client keys");
}
- let res = self.key_management(client, &order).await?;
+ let res = self.key_management(client, &order, &ctx).await?;
if res.technical_code == EbicsReturnCode::EBICS_INVALID_USER_STATE
|| res.technical_code == EbicsReturnCode::EBICS_INVALID_USER_OR_USER_STATE
@@ -80,7 +80,8 @@ impl EbicsClient<'_> {
/** Perform an EBICS private key management HPB using [client] */
pub async fn hpb(&self, client: &ClientKeys) -> anyhow::Result<BankKeys> {
let order = Order::HPB;
- let res = self.key_management(client, &order).await?;
+ let ctx = EbicsCtx::new(&order);
+ let res = self.key_management(client, &order, &ctx).await?;
if res.technical_code == EbicsReturnCode::EBICS_AUTHENTICATION_FAILED {
bail!(
"{order} status code {}: could not download bank keys, send client keys (and/or related PDF document with --generate-registration-pdf) to the bank",
@@ -88,7 +89,7 @@ impl EbicsClient<'_> {
)
}
let order_data = res.ok_or_fail()?.expect("{order}: missing order data");
-
+ self.logger.log_payload(&ctx, &order_data, "xml")?;
Ok(Xml::parse(&order_data, "HPBResponseOrderData", |root| {
let auth_pub = root.one("AuthenticationPubKeyInfo")?;
let version = auth_pub.one("AuthenticationVersion")?.text();
@@ -118,6 +119,7 @@ impl EbicsClient<'_> {
&self,
client: &ClientKeys,
order: &Order,
+ ctx: &EbicsCtx<'_>,
) -> Result<EbicsResponse<Option<Vec<u8>>>, EbicsError> {
let EbicsHostCfg {
host_id,
@@ -125,7 +127,6 @@ impl EbicsClient<'_> {
partner_id,
..
} = &self.cfg;
- let ctx = EbicsCtx::new(order);
info!(target: "ebics", "Doing key request {order}");
let (name, security_medium) = match order {
@@ -145,7 +146,6 @@ impl EbicsClient<'_> {
"PartnerID": &cfg.partner_id,
"UserID": &cfg.user_id
});
- // Deflate TODO write inside the compressor directly
let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
encoder.write_all(xml.as_bytes()).unwrap();
let compressed = encoder.finish().unwrap();
@@ -224,11 +224,11 @@ impl EbicsClient<'_> {
}
);
let signed = if sign {
- sign_ebics(msg, &client.auth)
+ sign_ebics(msg, &client.auth).expect("EBICS signature never fails")
} else {
msg
};
- let res = self.post_to_bank(signed, &ctx).await?;
+ let res = self.post_to_bank(signed, ctx).await?;
Xml::parse(&res, "ebicsKeyManagementResponse", |root| {
let body = root.one("body")?;
let mutable = root.one_signed("header").one("mutable")?;
@@ -250,7 +250,7 @@ impl EbicsClient<'_> {
}),
})
})
- .ctx(&ctx)
+ .ctx(ctx)
}
}
diff --git a/libeufin-ebics/src/test.rs b/libeufin-ebics/src/test.rs
@@ -68,9 +68,7 @@ pub enum EbicsRes {
Failure,
}
pub struct EbicsState {
- bank_sign: KeyPair,
- bank_enc: KeyPair,
- bank_auth: KeyPair,
+ bank: KeyPair,
client_sign: Option<PublicKey>,
client_enc: Option<PublicKey>,
@@ -83,9 +81,7 @@ pub struct EbicsState {
impl EbicsState {
pub fn new() -> Self {
Self {
- bank_sign: KeyPair::generate(KeySize::Rsa2048).unwrap(),
- bank_enc: KeyPair::generate(KeySize::Rsa2048).unwrap(),
- bank_auth: KeyPair::generate(KeySize::Rsa2048).unwrap(),
+ bank: KeyPair::generate(KeySize::Rsa2048).unwrap(),
client_sign: None,
client_enc: None,
client_auth: None,
@@ -132,7 +128,7 @@ impl EbicsState {
}
fn signed_response(&self, xml: String) -> EbicsRes {
- EbicsRes::Ok(sign_ebics(xml, &self.bank_auth))
+ EbicsRes::Ok(sign_ebics(xml, &self.bank).expect("EBICS signature never fails"))
}
fn ebics_response_payload(&mut self, payload: &str, last: bool) -> EbicsRes {
@@ -267,11 +263,11 @@ impl EbicsState {
let payload = xml!("HPBResponseOrderData" "xmlns:ds"="http://www.w3.org/2000/09/xmldsig#" {
"AuthenticationPubKeyInfo" {
- @ |w| rsa_key_xml(w, &self.bank_auth),
+ @ |w| rsa_key_xml(w, &self.bank),
"AuthenticationVersion": "X002"
},
"EncryptionPubKeyInfo" {
- @ |w| rsa_key_xml(w, &self.bank_enc),
+ @ |w| rsa_key_xml(w, &self.bank),
"EncryptionVersion": "E002"
}
});
diff --git a/libeufin-ebics/src/xml_sign.rs b/libeufin-ebics/src/xml_sign.rs
@@ -17,41 +17,45 @@
* <http://www.gnu.org/licenses/>
*/
-use std::{
- borrow::Cow,
- collections::{BTreeMap, HashSet},
-};
+use std::{collections::HashSet, io::Write, rc::Rc};
use aws_lc_rs::{
- digest::Digest,
+ digest::{Context, Digest},
+ error::Unspecified,
rand::SystemRandom,
+ rsa::PublicKey,
signature::{RSA_PKCS1_SHA256, RsaKeyPair},
};
use roxmltree::{Document, Node};
use taler_common::encoding::base64;
+use thiserror::Error;
+
+use crate::xml::{self, Xml, XmlAccess as _};
-fn escape<'a>(text: &'a str, replacements: &[(char, &str)]) -> Cow<'a, str> {
+fn escape<'a>(
+ text: &'a str,
+ replacements: &[(char, &str)],
+ out: &mut impl Write,
+) -> std::io::Result<()> {
// Find the first character that needs escaping
let Some(first_pos) = text.find(|c| replacements.iter().any(|(r, _)| *r == c)) else {
- return Cow::Borrowed(text); // No escaping needed — zero allocations
+ return write!(out, "{text}"); // No escaping needed
};
- // Pre-allocate with a reasonable estimate
- let mut output = String::with_capacity(text.len() + 16);
- output.push_str(&text[..first_pos]);
+ write!(out, "{}", &text[..first_pos])?;
for ch in text[first_pos..].chars() {
match replacements.iter().find(|(r, _)| *r == ch) {
- Some((_, escaped)) => output.push_str(escaped),
- None => output.push(ch),
+ Some((_, escaped)) => write!(out, "{escaped}")?,
+ None => write!(out, "{ch}")?,
}
}
- Cow::Owned(output)
+ Ok(())
}
// C14N requires specific escaping for Text nodes
-fn escape_text(text: &str) -> Cow<'_, str> {
+fn escape_text(text: &str, out: &mut impl Write) -> std::io::Result<()> {
escape(
text,
&[
@@ -60,11 +64,12 @@ fn escape_text(text: &str) -> Cow<'_, str> {
('>', ">"),
('\r', "
"),
],
+ out,
)
}
// C14N requires specific escaping for Attributes
-fn escape_attr(text: &str) -> Cow<'_, str> {
+fn escape_attr(text: &str, out: &mut impl Write) -> std::io::Result<()> {
escape(
text,
&[
@@ -75,99 +80,135 @@ fn escape_attr(text: &str) -> Cow<'_, str> {
('\n', "
"),
('\r', "
"),
],
+ out,
)
}
-/// Updated C14N logic to prevent redundant namespace declarations
-fn c14n_inclusive<'a>(
- node: Node<'a, 'a>,
- mut active_namespaces: HashSet<(&'a str, &'a str)>,
- out: &mut String,
-) {
- if node.is_text() {
- out.push_str(&escape_text(node.text().unwrap_or("")));
- } else if node.is_element() {
- let prefix = node
- .tag_name()
- .namespace()
- .and_then(|uri| node.lookup_prefix(uri));
- let push_tag_name = |out: &mut String| {
- if let Some(ns) = prefix {
- out.push_str(ns);
- out.push(':');
- };
- out.push_str(node.tag_name().name());
- };
-
- // Open element
- out.push('<');
- push_tag_name(out);
-
- // Write sorted missing namespaces
- let missing: BTreeMap<&str, &str> = node
- .namespaces()
- .filter_map(|ns| {
- let value = (ns.name().unwrap_or_default(), ns.uri());
- active_namespaces.insert(value).then_some(value)
- })
- .collect();
- for (prefix, uri) in missing {
- out.push(' ');
- out.push_str("xmlns");
- if !prefix.is_empty() {
- out.push(':');
- out.push_str(prefix);
- }
- out.push_str("=\"");
- out.push_str(uri);
- out.push('"');
+/// C14N normalization context
+struct C14N<'a> {
+ active_namespaces: Rc<HashSet<(&'a str, &'a str)>>,
+ sorted: Vec<(&'a str, &'a str)>,
+}
+
+impl<'a> C14N<'a> {
+ pub fn new() -> Self {
+ Self {
+ active_namespaces: Rc::new(HashSet::new()),
+ sorted: Vec::new(),
}
+ }
- // Write sorted attributes
- let attributes: BTreeMap<&str, &str> = node
- .attributes()
- .map(|it| (it.name(), it.value()))
- .collect();
- for (k, v) in attributes {
- out.push(' ');
- out.push_str(k);
- out.push_str("=\"");
- out.push_str(&escape_attr(v));
- out.push('"');
+ /// C14N inclusive normalization
+ pub fn c14n_inclusive(
+ &mut self,
+ node: Node<'a, 'a>,
+ out: &mut impl Write,
+ ) -> std::io::Result<()> {
+ let Self {
+ active_namespaces,
+ sorted,
+ } = self;
+ Rc::make_mut(active_namespaces).clear();
+ fn inner<'a, W: Write>(
+ node: Node<'a, 'a>,
+ mut active_namespaces: Rc<HashSet<(&'a str, &'a str)>>,
+ sorted: &mut Vec<(&'a str, &'a str)>,
+ out: &mut W,
+ ) -> std::io::Result<()> {
+ if node.is_text() {
+ if let Some(text) = node.text() {
+ escape_text(text, out)?;
+ }
+ } else if node.is_element() {
+ let prefix = node
+ .tag_name()
+ .namespace()
+ .and_then(|uri| node.lookup_prefix(uri));
+ let tag_name = std::fmt::from_fn(|f| {
+ if let Some(ns) = prefix {
+ write!(f, "{ns}:")?;
+ };
+ write!(f, "{}", node.tag_name().name())
+ });
+
+ // Open element
+ write!(out, "<{tag_name}")?;
+
+ // Write sorted missing namespaces
+ sorted.clear();
+ sorted.extend(node.namespaces().filter_map(|ns| {
+ let value = (ns.name().unwrap_or_default(), ns.uri());
+ if active_namespaces.contains(&value) {
+ None
+ } else {
+ Rc::make_mut(&mut active_namespaces).insert(value);
+ Some(value)
+ }
+ }));
+ sorted.sort_unstable();
+ for (prefix, uri) in &*sorted {
+ write!(out, " xmlns")?;
+ if !prefix.is_empty() {
+ write!(out, ":{prefix}")?;
+ }
+ write!(out, "=\"{uri}\"")?;
+ }
+
+ // Write sorted attributes
+ sorted.clear();
+ sorted.extend(node.attributes().map(|it| (it.name(), it.value())));
+ sorted.sort_unstable();
+ for (k, v) in &*sorted {
+ write!(out, " {k}=\"")?;
+ escape_attr(v, out)?;
+ write!(out, "\"")?;
+ }
+
+ write!(out, ">")?;
+
+ for child in node.children() {
+ // Pass the cloned active_namespaces down to children
+ inner(child, active_namespaces.clone(), sorted, out)?;
+ }
+
+ write!(out, "</{tag_name}>")?;
+ }
+ Ok(())
}
+ inner(node, self.active_namespaces.clone(), sorted, out)
+ }
+}
- out.push('>');
+/// Digest wrapper to implement std::io::Write
+struct DigestWriter<'a>(&'a mut Context);
- for child in node.children() {
- // Pass the cloned active_namespaces down to children
- c14n_inclusive(child, active_namespaces.clone(), out);
- }
+impl<'a> Write for DigestWriter<'a> {
+ fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
+ self.0.update(buf);
+ Ok(buf.len())
+ }
- out.push_str("</");
- push_tag_name(out);
- out.push('>');
+ fn flush(&mut self) -> std::io::Result<()> {
+ Ok(())
}
}
-fn digest_authenticated(doc: &Document) -> Digest {
- fn find_top_level_authenticators<'a>(node: Node<'a, 'a>, results: &mut Vec<Node<'a, 'a>>) {
+/// Digest of all authenticated node
+fn digest_authenticated<'a>(doc: &'a Document, c14n: &mut C14N<'a>) -> Digest {
+ fn inner<'a>(node: Node<'a, 'a>, c14n: &mut C14N<'a>, out: &mut Context) {
if node.attribute("authenticate") == Some("true") {
- results.push(node);
+ c14n.c14n_inclusive(node, &mut DigestWriter(out))
+ .expect("digest never fails");
} else {
for child in node.children().filter(|n| n.is_element()) {
- find_top_level_authenticators(child, results);
+ inner(child, c14n, out);
}
}
}
- let mut nodes = Vec::new();
-
- find_top_level_authenticators(doc.root(), &mut nodes);
- let mut out = String::new();
- for node in nodes {
- c14n_inclusive(node, HashSet::new(), &mut out);
- }
- aws_lc_rs::digest::digest(&aws_lc_rs::digest::SHA256, out.as_bytes())
+ let mut out = aws_lc_rs::digest::Context::new(&aws_lc_rs::digest::SHA256);
+ inner(doc.root(), c14n, &mut out);
+ out.finish()
}
const C14N_ALG: &str = "http://www.w3.org/TR/2001/REC-xml-c14n-20010315";
@@ -175,17 +216,26 @@ const SIG_ALG: &str = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256";
const DIGEST_ALG: &str = "http://www.w3.org/2001/04/xmlenc#sha256";
const DSIG_NS: &str = "http://www.w3.org/2000/09/xmldsig#";
-pub fn sign_ebics(mut xml: String, key: &RsaKeyPair) -> String {
- let doc = Document::parse(&xml).unwrap();
+#[derive(Debug, Error)]
+pub enum SignError {
+ #[error(transparent)]
+ Xml(#[from] roxmltree::Error),
+ #[error("Must contain an empty AuthSignature element")]
+ NoAuthSignature,
+ #[error("bad signature")]
+ Signature(#[from] Unspecified),
+}
+
+/** Sign an EBICS document */
+pub fn sign_ebics(mut xml: String, key: &RsaKeyPair) -> Result<String, SignError> {
+ let doc = Document::parse(&xml)?;
+ let mut c14n = C14N::new();
- let digest = digest_authenticated(&doc);
+ let digest = digest_authenticated(&doc, &mut c14n);
let digest = base64::encode(digest.as_ref());
// Wrap signed info for signature in a canonical form
- let default_namespace = doc
- .root_element()
- .default_namespace()
- .expect("must be a root EBICS schema namespace");
+ let default_namespace = doc.root_element().default_namespace().unwrap_or_default();
let signed_info = format!(
r##"<ds:SignedInfo xmlns="{default_namespace}" xmlns:ds="{DSIG_NS}"><ds:CanonicalizationMethod Algorithm="{C14N_ALG}"></ds:CanonicalizationMethod><ds:SignatureMethod Algorithm="{SIG_ALG}"></ds:SignatureMethod><ds:Reference URI="#xpointer(//*[@authenticate='true'])"><ds:Transforms><ds:Transform Algorithm="{C14N_ALG}"></ds:Transform></ds:Transforms><ds:DigestMethod Algorithm="{DIGEST_ALG}"></ds:DigestMethod><ds:DigestValue>{digest}</ds:DigestValue></ds:Reference></ds:SignedInfo>"##
);
@@ -195,25 +245,74 @@ pub fn sign_ebics(mut xml: String, key: &RsaKeyPair) -> String {
&SystemRandom::new(),
signed_info.as_bytes(),
&mut sig,
- )
- .unwrap();
+ )?;
let sig = base64::encode(sig);
let signature = format!(
r##"<AuthSignature><ds:SignedInfo><ds:CanonicalizationMethod Algorithm="{C14N_ALG}"/><ds:SignatureMethod Algorithm="{SIG_ALG}"/><ds:Reference URI="#xpointer(//*[@authenticate='true'])"><ds:Transforms><ds:Transform Algorithm="{C14N_ALG}"/></ds:Transforms><ds:DigestMethod Algorithm="{DIGEST_ALG}"/><ds:DigestValue>{digest}</ds:DigestValue></ds:Reference></ds:SignedInfo><ds:SignatureValue>{sig}</ds:SignatureValue></AuthSignature>"##
);
let pattern = "<AuthSignature/>";
- let start = xml.find(pattern).unwrap();
+ let start = xml.find(pattern).ok_or(SignError::NoAuthSignature)?;
xml.replace_range(start..start + pattern.len(), &signature);
- xml
+ Ok(xml)
+}
+
+#[derive(Debug, Error)]
+pub enum VerifyError {
+ #[error(transparent)]
+ Utf8(#[from] std::str::Utf8Error),
+ #[error(transparent)]
+ Xml(#[from] roxmltree::Error),
+ #[error(transparent)]
+ Schema(#[from] xml::Error),
+ #[error("bad digest")]
+ Digest,
+ #[error("bad signature")]
+ Signature(#[from] Unspecified),
+}
+
+/** Verify an EBICS document */
+pub fn verify_ebics<'a>(xml: &'a [u8], key: &PublicKey) -> Result<Document<'a>, VerifyError> {
+ let str = std::str::from_utf8(xml)?;
+ let doc = Document::parse(str)?;
+ let mut c14n = C14N::new();
+
+ let auth_signature = Xml {
+ node: doc.root_element(),
+ }
+ .one("AuthSignature")?;
+ let signed_info = auth_signature.one("SignedInfo")?;
+ let signature = auth_signature.one("SignatureValue").b64()?;
+
+ // Check digest
+ let digest = signed_info.one("Reference").one("DigestValue").b64()?;
+ if digest_authenticated(&doc, &mut c14n).as_ref() != digest {
+ return Err(VerifyError::Digest);
+ }
+
+ // Check signature
+ let mut out = aws_lc_rs::digest::Context::new(&aws_lc_rs::digest::SHA256);
+ c14n.c14n_inclusive(signed_info.node, &mut DigestWriter(&mut out))
+ .expect("digest never fails");
+ let digest = out.finish();
+ aws_lc_rs::signature::UnparsedPublicKey::new(
+ &aws_lc_rs::signature::RSA_PKCS1_2048_8192_SHA256,
+ key,
+ )
+ .verify_digest(&digest, &signature)?;
+
+ Ok(doc)
}
#[cfg(test)]
mod test {
- use aws_lc_rs::signature::RsaKeyPair;
+ use aws_lc_rs::{
+ rsa::PublicKey,
+ signature::{KeyPair, RsaKeyPair},
+ };
use roxmltree::Document;
use taler_common::encoding::{base32, base64};
- use crate::xml_sign::{digest_authenticated, sign_ebics};
+ use crate::xml_sign::{C14N, digest_authenticated, sign_ebics, verify_ebics};
#[test]
fn canonicalize() {
@@ -225,7 +324,8 @@ kJMyqsK/0dKW+4n3JgWuVGK8YdoUuvmYooqgFw==</ds:SignatureValue></AuthSignature><bod
"##;
let doc = Document::parse(xml).unwrap();
- let res = digest_authenticated(&doc);
+ let mut c14n = C14N::new();
+ let res = digest_authenticated(&doc, &mut c14n);
let hex = base64::encode(res);
assert_eq!(hex, "ws6QyiLpZVu+CbpqlhQ11PGwCdHSgmtmL7FvwrqZqmU=");
@@ -263,7 +363,7 @@ kJMyqsK/0dKW+4n3JgWuVGK8YdoUuvmYooqgFw==</ds:SignatureValue></AuthSignature><bod
</ebicsResponse>
"##;
let doc = Document::parse(xml).unwrap();
- let res = digest_authenticated(&doc);
+ let res = digest_authenticated(&doc, &mut c14n);
assert_eq!(
base64::encode(res),
"WJz3HUYjV3HMK0Cy+69XCnAcmiD21mJ5BRiQPwsi1VI="
@@ -278,8 +378,8 @@ kJMyqsK/0dKW+4n3JgWuVGK8YdoUuvmYooqgFw==</ds:SignatureValue></AuthSignature><bod
RsaKeyPair::from_pkcs8(&base32::decode(key.as_bytes()).unwrap()).unwrap();
let tmp = r##"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><ebicsNoPubKeyDigestsRequest xmlns="urn:org:ebics:H005" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" Revision="1" Version="H005"><header authenticate="true"><static><HostID>PFEBICS</HostID><Nonce>6BC48C9C2576ABD00295788E56DFCD0A</Nonce><Timestamp>2026-02-21T17:01:53.186561035Z</Timestamp><PartnerID>PFC00563</PartnerID><UserID>PFC00563</UserID><OrderDetails><AdminOrderType>HPB</AdminOrderType></OrderDetails><SecurityMedium>0000</SecurityMedium></static><mutable/></header><AuthSignature/><body/></ebicsNoPubKeyDigestsRequest>"##;
let xml = tmp.to_owned();
- let signed = sign_ebics(xml, &key);
- let doc = Document::parse(&signed).unwrap();
+ let signed = sign_ebics(xml, &key).unwrap();
+ let doc = verify_ebics(signed.as_bytes(), key.public_key()).unwrap();
let signature = doc
.descendants()
.find(|it| it.has_tag_name("SignatureValue"))
@@ -291,4 +391,59 @@ kJMyqsK/0dKW+4n3JgWuVGK8YdoUuvmYooqgFw==</ds:SignatureValue></AuthSignature><bod
"eYyb1v/dGVOPndpMhXZlVQM2q9H9BJP77nYOWaa7jjoeLef7/8HjKIv8oq6Kaf6Z9mAfh/Pcip3a75gkdKpz7ocl1YdsaD+CcQkO1J/n4NwY821ccSh0Ahm2PBE168hyEMzPJrDeDtJrYqs+J/+nC8ek0hbo4/WPsH4UoxVu+ANsHR+BnQFQW3k9BFv+XKZbrBltIY62SN73tYwU8QzRtINJLzjhNB3T6S101n4CYwycXpL5b/oXXOUxxfDnn9EmIFt4DIgjxxqDYdQEBytULLORdkIdf563aw2wDaN12OQV2TB9gAs4Uu203FkUbmIagarMhbKKlqa1NkOteZ13Xw=="
);
}
+
+ #[test]
+ fn verify() {
+ let xml = r##"<?xml version="1.0" encoding="UTF-8"?>
+<ebicsResponse xmlns="urn:org:ebics:H005" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" Version="H005" Revision="1" xsi:schemaLocation="urn:org:ebics:H005 ebics_response_H005.xsd">
+ <header authenticate="true">
+ <static>
+ <TransactionID>846BEEFDCBB4293BA764FAC72CD99B4E</TransactionID>
+ <NumSegments>1</NumSegments>
+ </static>
+ <mutable>
+ <TransactionPhase>Initialisation</TransactionPhase>
+ <SegmentNumber lastSegment="true">1</SegmentNumber>
+ <ReturnCode>000000</ReturnCode>
+ <ReportText>[EBICS_OK] OK</ReportText>
+ </mutable>
+ </header>
+ <AuthSignature>
+ <ds:SignedInfo>
+ <ds:CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/>
+ <ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/>
+ <ds:Reference URI="#xpointer(//*[@authenticate='true'])">
+ <ds:Transforms>
+ <ds:Transform Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/>
+ </ds:Transforms>
+ <ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
+ <ds:DigestValue>9ifuDmTeB7+ZR0qAksWBqcuS2SgO+cIZrMCSrwmGfVw=</ds:DigestValue>
+ </ds:Reference>
+ </ds:SignedInfo>
+ <ds:SignatureValue>eeMBzXQTshVSBH5ryz2ducFgTegLyZhkZhGom5o6Se2lujaD3ixVVxnNlzbOVvWL8yvv2zfki877qTe5b+u4ejYfrR1rc6l1jB/lS9pqT9wyB0pD52LXNfrxhAkOsljv8V858Wjj7KSuUT0+7v8q6BRRg55BrIIJBWqPwWKY2a7AmFWTkYck/fLxawtQvrBOTxBMufQ9d060Ad8Ndewjzjt2lLSCH7K3mM2+E2AMmpwgXnYrlDr5GU5/Qt2KBG8XYJqe354+MKfp0FLqRq9tXnucjFOaRqQVzMNfFBkfFpvwNnoAC1FAgOQeXPyr4jOW6qlUBuBqM/jTCOyJe5RMvA==</ds:SignatureValue>
+ </AuthSignature>
+ <body>
+ <DataTransfer>
+ <DataEncryptionInfo authenticate="true">
+ <EncryptionPubKeyDigest Version="E002" Algorithm="http://www.w3.org/2001/04/xmlenc#sha256">ujHlW/uumNiBAls2bUCmMFdYUOoxe8QMjNCPKlLF5ow=</EncryptionPubKeyDigest>
+ <TransactionKey>PpGWjNbu4bx1TjV/5OgEOAQF92NNQ5LaW61lKGI6/2/ycYmI1usfNC4x1bq/dlusCw6E/fOnHzDFyaD1C/ef0fZO2Ir++6s/ZK0e87+KBU7bhkL4ftoWulkRpdZlI5GgNlDxP5II4ZZ3KhlLqgv34bhJuL4Thi3mZz0ajR21PP4cV+NeWVpCsDyPQlRRwBy8dNAY+QrZxXvOmu3V9UZyRhRZLjStEeOFBwsEyGwmd6LIE/jJjpFY4PaxXL/TtjvE3qPS2SKQ0W1uVwWvMLMuNYgHHt02bUS5LZt4tJdHmNkMqQZbv262a9xRPxwVa7QhY+5GIMpSY4ZMoFPRlTHaqw==</TransactionKey>
+ </DataEncryptionInfo>
+ <OrderData>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</OrderData>
+ </DataTransfer>
+ <ReturnCode authenticate="true">000000</ReturnCode>
+ <TimestampBankParameter authenticate="true">2020-11-25T19:03:45.693Z</TimestampBankParameter>
+ </body>
+</ebicsResponse>
+"##;
+ let doc = Document::parse(xml).unwrap();
+ let mut c14n = C14N::new();
+ let res = digest_authenticated(&doc, &mut c14n);
+ assert_eq!(
+ base64::encode(res),
+ "9ifuDmTeB7+ZR0qAksWBqcuS2SgO+cIZrMCSrwmGfVw="
+ );
+ let key = "621028HG1M30JAM6923FE381040GA003G80GY01GG80GM0M2040G1EACATA11EF5SVKNBNBYF1S3WSKQ2A2R9VZ7RW2HRX00293JPZ7VQ780RFRVYTQKKDDNJAQGBH4659GT9QYBMJCG1RKZEH1WDJ0GAAY7B7NBMW6FWXCKFYRMZQME0WBGZ1AAMY2VBQ5XAFV8216EFNF2EPG6M5ZGHG9RG6EGED56TK9JESQ02Q7AAVBRAAARVBN9NHCN64KQ3SRRHYXB8RWRK4TSSC93XG8RWMQH4ZDJSBYDCEXFY6G3AWTZ0EZNCJJAYB98T4GNFWZMN81AVYCQHXT1APX81AXCAYNK7J9XETF5CN1J1WV0BVA2BYG4VAMAW123REPN67JF1TNWPTADBMHS17N2V1GFYT8JRWX4TGM2996NXTEPMA8C2CDDE0CRY2A6HT8C5H2D6C62YGRSCF820C0G008";
+ let key = PublicKey::from_der(&base32::decode(key).unwrap()).unwrap();
+ verify_ebics(xml.as_bytes(), &key).unwrap();
+ }
}
diff --git a/libeufin-ebisync/src/lib.rs b/libeufin-ebisync/src/lib.rs
@@ -40,6 +40,7 @@ use libeufin_ebics::{
ebics_code::EbicsReturnCode,
order::{Order, OrderDoc},
},
+ iso20022::{HacAction, hac::parse_hac},
keys::{BankKeys, ClientKeys},
ws::listen_for_notification,
};
@@ -279,7 +280,20 @@ pub async fn ebics_fetch(
transient && peek,
async |content| {
if order.doc() == Some(OrderDoc::acknowledgement) {
- // TODO HAC
+ for ack in parse_hac(&content)? {
+ debug!(target: "fetch", "{ack}");
+ if let Some(order_id) = &ack.order_id {
+ match ack.action {
+ HacAction::ORDER_HAC_FINAL_POS => {
+ info!(target: "fetch", "Order {order_id} accepted at {}", ack.timestamp);
+ }
+ HacAction::ORDER_HAC_FINAL_NEG => {
+ info!(target: "fetch", "Order {order_id} refused at {}", ack.timestamp);
+ }
+ _ => {}
+ }
+ }
+ }
} else {
let mut z = zip::ZipArchive::new(Cursor::new(content))?;
for i in 0..z.len() {