commit ef53e579a2f6afa909038f0204d727785f1aeab3
parent 35772d0b6eb2e695b1552c03fa41608e1155e919
Author: Antoine A <>
Date: Fri, 24 Apr 2026 10:37:56 +0200
full EBICS setup
Diffstat:
| M | Cargo.lock | | | 250 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++- |
| M | Cargo.toml | | | 21 | +++++++++++++-------- |
| M | src/keys.rs | | | 121 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------- |
| M | src/main.rs | | | 561 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------- |
| M | src/xml.rs | | | 72 | +++++++++++++++++++++++++++++++++++++++++------------------------------- |
| M | src/xml_sign.rs | | | 4 | ++-- |
6 files changed, 851 insertions(+), 178 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -3,6 +3,12 @@
version = 4
[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
name = "aho-corasick"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -74,6 +80,45 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
+name = "asn1-rs"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "56624a96882bb8c26d61312ae18cb45868e5a9992ea73c58e45c3101e56a1e60"
+dependencies = [
+ "asn1-rs-derive",
+ "asn1-rs-impl",
+ "displaydoc",
+ "nom",
+ "num-traits",
+ "rusticata-macros",
+ "thiserror 2.0.18",
+ "time",
+]
+
+[[package]]
+name = "asn1-rs-derive"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "synstructure",
+]
+
+[[package]]
+name = "asn1-rs-impl"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
name = "atoi"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -482,6 +527,35 @@ dependencies = [
]
[[package]]
+name = "data-encoding"
+version = "2.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
+
+[[package]]
+name = "der-parser"
+version = "10.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6"
+dependencies = [
+ "asn1-rs",
+ "displaydoc",
+ "nom",
+ "num-bigint",
+ "num-traits",
+ "rusticata-macros",
+]
+
+[[package]]
+name = "deranged"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
+dependencies = [
+ "powerfmt",
+]
+
+[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -584,6 +658,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
+name = "flate2"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
+dependencies = [
+ "miniz_oxide",
+ "zlib-rs",
+]
+
+[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1207,10 +1291,12 @@ dependencies = [
"aws-lc-rs",
"base64",
"clap",
+ "flate2",
"getrandom 0.4.2",
"jiff",
"pem",
"rand 0.10.0",
+ "rcgen",
"reqwest",
"roxmltree",
"serde",
@@ -1225,6 +1311,7 @@ dependencies = [
"tokio",
"tracing",
"url",
+ "x509-parser",
"xml-canonicalization",
]
@@ -1313,6 +1400,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
+name = "minimal-lexical"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
+
+[[package]]
+name = "miniz_oxide"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
+[[package]]
name = "mio"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1324,6 +1427,16 @@ dependencies = [
]
[[package]]
+name = "nom"
+version = "7.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
+dependencies = [
+ "memchr",
+ "minimal-lexical",
+]
+
+[[package]]
name = "nu-ansi-term"
version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1333,6 +1446,31 @@ dependencies = [
]
[[package]]
+name = "num-bigint"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
+dependencies = [
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-conv"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
+
+[[package]]
+name = "num-integer"
+version = "0.1.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1342,6 +1480,15 @@ dependencies = [
]
[[package]]
+name = "oid-registry"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7"
+dependencies = [
+ "asn1-rs",
+]
+
+[[package]]
name = "once_cell"
version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1490,6 +1637,12 @@ dependencies = [
]
[[package]]
+name = "powerfmt"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+
+[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1680,6 +1833,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba"
[[package]]
+name = "rcgen"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10b99e0098aa4082912d4c649628623db6aba77335e4f4569ff5083a6448b32e"
+dependencies = [
+ "aws-lc-rs",
+ "pem",
+ "rustls-pki-types",
+ "time",
+ "x509-parser",
+ "yasna",
+]
+
+[[package]]
name = "redox_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1794,6 +1961,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[package]]
+name = "rusticata-macros"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
+dependencies = [
+ "nom",
+]
+
+[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2071,6 +2247,12 @@ dependencies = [
]
[[package]]
+name = "simd-adler32"
+version = "0.3.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
+
+[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2456,6 +2638,37 @@ dependencies = [
]
[[package]]
+name = "time"
+version = "0.3.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
+dependencies = [
+ "deranged",
+ "itoa",
+ "num-conv",
+ "powerfmt",
+ "serde_core",
+ "time-core",
+ "time-macros",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
+
+[[package]]
+name = "time-macros"
+version = "0.2.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
+dependencies = [
+ "num-conv",
+ "time-core",
+]
+
+[[package]]
name = "tinystr"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2735,9 +2948,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
-version = "1.21.0"
+version = "1.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb"
+checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -3403,6 +3616,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
[[package]]
+name = "x509-parser"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202"
+dependencies = [
+ "asn1-rs",
+ "aws-lc-rs",
+ "data-encoding",
+ "der-parser",
+ "lazy_static",
+ "nom",
+ "oid-registry",
+ "rusticata-macros",
+ "thiserror 2.0.18",
+ "time",
+]
+
+[[package]]
name = "xml-canonicalization"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3416,6 +3647,15 @@ dependencies = [
]
[[package]]
+name = "yasna"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd"
+dependencies = [
+ "time",
+]
+
+[[package]]
name = "yoke"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3519,6 +3759,12 @@ dependencies = [
]
[[package]]
+name = "zlib-rs"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513"
+
+[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/Cargo.toml b/Cargo.toml
@@ -5,9 +5,9 @@ edition = "2024"
[dependencies]
reqwest = "*"
-tokio = { version = "*", features = ["macros", "rt-multi-thread"]}
+tokio = { version = "*", features = ["macros", "rt-multi-thread"] }
tracing = "*"
-thiserror ="*"
+thiserror = "*"
roxmltree = "*"
xml-canonicalization = "*"
base64 = "*"
@@ -17,10 +17,16 @@ jiff = "*"
rand = "*"
getrandom = "*"
serde_json = "*"
+rcgen = { version = "*", features = [
+ "aws_lc_rs",
+ "pem",
+], default-features = false }
+x509-parser = { version = "*", features = ["verify-aws"] }
+flate2 = { version = "1.0", features = ["zlib-rs"], default-features = false }
taler-common = { path = "../taler-rust/common/taler-common" }
-taler-api = { path = "../taler-rust/common/taler-api" }
-taler-build = { path = "../taler-rust/common/taler-build" }
-taler-test-utils = { path = "../taler-rust/common/taler-test-utils" }
+taler-api = { path = "../taler-rust/common/taler-api" }
+taler-build = { path = "../taler-rust/common/taler-build" }
+taler-test-utils = { path = "../taler-rust/common/taler-test-utils" }
#taler-common = { git = "git://git.taler.net/taler-rust.git/" }
#taler-api = { git = "git://git.taler.net/taler-rust.git/" }
#taler-build = { git = "git://git.taler.net/taler-rust.git/" }
@@ -29,5 +35,5 @@ url = "*"
clap = { version = "4.5", features = ["derive"] }
strum = "0.28"
strum_macros = "0.28"
-aws-lc-rs = {version = "*"}
-serde = { version = "*", features = ["derive"] }
-\ No newline at end of file
+aws-lc-rs = { version = "*" }
+serde = { version = "*", features = ["derive"] }
diff --git a/src/keys.rs b/src/keys.rs
@@ -17,11 +17,12 @@
* <http://www.gnu.org/licenses/>
*/
-use std::borrow::Cow;
+use std::{borrow::Cow, io::ErrorKind, path::Path};
+use anyhow::bail;
use aws_lc_rs::{
- encoding::AsDer,
- rsa::{PrivateDecryptingKey, PublicEncryptingKey},
+ encoding::{AsDer, Pkcs8V1Der, PublicKeyX509Der},
+ rsa::{KeySize, PrivateDecryptingKey, PublicEncryptingKey},
signature::RsaKeyPair,
};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
@@ -32,31 +33,54 @@ use taler_common::{
use crate::config::EbicsKeysCfg;
-#[derive(Debug, serde::Deserialize)]
+#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct ClientPriKeysFile {
- #[serde(serialize_with = "ser_der", deserialize_with = "de_ras_sign_base32")]
+ #[serde(serialize_with = "ser_pkcs8", deserialize_with = "de_ras_sign_base32")]
pub signature_private_key: RsaKeyPair,
- #[serde(serialize_with = "ser_der", deserialize_with = "de_ras_priv_base32")]
+ #[serde(serialize_with = "ser_pkcs8", deserialize_with = "de_ras_priv_base32")]
pub encryption_private_key: PrivateDecryptingKey,
- #[serde(serialize_with = "ser_der", deserialize_with = "de_ras_sign_base32")]
+ #[serde(serialize_with = "ser_pkcs8", deserialize_with = "de_ras_sign_base32")]
pub authentication_private_key: RsaKeyPair,
pub submitted_ini: bool,
pub submitted_hia: bool,
}
-#[derive(Debug, serde::Deserialize)]
+impl ClientPriKeysFile {
+ pub fn generate() -> anyhow::Result<Self> {
+ Ok(Self {
+ signature_private_key: RsaKeyPair::generate(KeySize::Rsa2048)?,
+ encryption_private_key: PrivateDecryptingKey::generate(KeySize::Rsa2048)?,
+ authentication_private_key: RsaKeyPair::generate(KeySize::Rsa2048)?,
+ submitted_ini: false,
+ submitted_hia: false,
+ })
+ }
+}
+
+#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct BankPubKeysFile {
- #[serde(serialize_with = "ser_der", deserialize_with = "de_ras_pub_base32")]
+ #[serde(serialize_with = "ser_x509", deserialize_with = "de_ras_pub_base32")]
pub bank_encryption_public_key: PublicEncryptingKey,
- #[serde(serialize_with = "ser_der", deserialize_with = "de_ras_pub_base32")]
+ #[serde(serialize_with = "ser_x509", deserialize_with = "de_ras_pub_base32")]
pub bank_authentication_public_key: PublicEncryptingKey,
pub accepted: bool,
}
-pub fn ser_der<S, K, D>(key: &K, serializer: S) -> Result<S::Ok, S::Error>
+fn ser_pkcs8<S, K>(key: &K, serializer: S) -> Result<S::Ok, S::Error>
+where
+ K: AsDer<Pkcs8V1Der<'static>>,
+ S: Serializer,
+{
+ let der = key
+ .as_der()
+ .map_err(|e| serde::ser::Error::custom(e.to_string()))?;
+ let base32 = base32::encode(der.as_ref());
+ base32.serialize(serializer)
+}
+
+fn ser_x509<S, K>(key: &K, serializer: S) -> Result<S::Ok, S::Error>
where
- D: AsRef<[u8]>,
- K: AsDer<D>,
+ K: AsDer<PublicKeyX509Der<'static>>,
S: Serializer,
{
let der = key
@@ -66,7 +90,7 @@ where
base32.serialize(serializer)
}
-pub fn de_ras_priv_base32<'de, D>(deserializer: D) -> Result<PrivateDecryptingKey, D::Error>
+fn de_ras_priv_base32<'de, D>(deserializer: D) -> Result<PrivateDecryptingKey, D::Error>
where
D: Deserializer<'de>,
{
@@ -78,7 +102,7 @@ where
Ok(key)
}
-pub fn de_ras_pub_base32<'de, D>(deserializer: D) -> Result<PublicEncryptingKey, D::Error>
+fn de_ras_pub_base32<'de, D>(deserializer: D) -> Result<PublicEncryptingKey, D::Error>
where
D: Deserializer<'de>,
{
@@ -90,7 +114,7 @@ where
Ok(key)
}
-pub fn de_ras_sign_base32<'de, D>(deserializer: D) -> Result<RsaKeyPair, D::Error>
+fn de_ras_sign_base32<'de, D>(deserializer: D) -> Result<RsaKeyPair, D::Error>
where
D: Deserializer<'de>,
{
@@ -101,12 +125,69 @@ where
Ok(key)
}
+/// Persist the bank keys file to disk
+pub fn persist_bank_keys(keys: &BankPubKeysFile, location: &Path) -> anyhow::Result<()> {
+ json_file::persist(location, keys)?;
+ // TODO better error message "bank public keys"
+ Ok(())
+}
+
+pub fn persist_client_keys(keys: &ClientPriKeysFile, location: &Path) -> anyhow::Result<()> {
+ json_file::persist(location, keys)?;
+ // TODO better error message "client private keys"
+ Ok(())
+}
+
+/// Load the bank keys file from disk
+pub fn load_bank_keys(path: &Path) -> anyhow::Result<Option<BankPubKeysFile>> {
+ match json_file::load(path) {
+ Ok(existing) => Ok(Some(existing)),
+ Err(e) if e.kind() == ErrorKind::NotFound => Ok(None),
+ Err(e) => anyhow::bail!(
+ "Could not read bank public keys at '{}': {}",
+ path.to_string_lossy(),
+ e.kind()
+ ),
+ }
+}
+
+/// Load the client keys file from disk
+pub fn load_client_keys(path: &Path) -> anyhow::Result<Option<ClientPriKeysFile>> {
+ match json_file::load(path) {
+ Ok(existing) => Ok(Some(existing)),
+ Err(e) if e.kind() == ErrorKind::NotFound => Ok(None),
+ Err(e) => anyhow::bail!(
+ "Could not read client private keys at '{}': {}",
+ path.to_string_lossy(),
+ e.kind()
+ ),
+ }
+}
+
+/// Load client and bank keys from disk and checks that the keying process has been fully completed
pub fn expect_full_keys(
cfg: &EbicsKeysCfg,
) -> anyhow::Result<(ClientPriKeysFile, BankPubKeysFile)> {
- let client_keys: ClientPriKeysFile = json_file::load(&cfg.client_priv_keys_path)?;
- let bank_keys: BankPubKeysFile = json_file::load(&cfg.bank_pub_keys_path)?;
- // TODO improve error
- // TODO missing checks
+ let setup_cmd = "TODO";
+ let client_keys = load_client_keys(cfg.client_priv_keys_path.as_ref())?;
+ let Some(client_keys) = client_keys else {
+ bail!(
+ "Missing client private keys file at '{}', run '{setup_cmd}' first",
+ cfg.client_priv_keys_path
+ )
+ };
+ if !client_keys.submitted_ini || !client_keys.submitted_hia {
+ bail!("Unsubmitted client private keys, run '{setup_cmd}' first")
+ }
+ let bank_keys = load_bank_keys(cfg.bank_pub_keys_path.as_ref())?;
+ let Some(bank_keys) = bank_keys else {
+ bail!(
+ "Missing bank public keys file at '{}', run '{setup_cmd}' first",
+ cfg.bank_pub_keys_path
+ )
+ };
+ if !bank_keys.accepted {
+ bail!("Unaccepted bank public keys, run '{setup_cmd}' until accepting the bank keys")
+ }
Ok((client_keys, bank_keys))
}
diff --git a/src/main.rs b/src/main.rs
@@ -17,21 +17,36 @@
* <http://www.gnu.org/licenses/>
*/
-use std::fmt::Display;
+use std::{fmt::Display, io::Write as _, path::Path};
use anyhow::bail;
+use aws_lc_rs::{
+ cipher::{DecryptingKey, DecryptionContext, UnboundCipherKey},
+ encoding::{AsDer, Pkcs8V1Der},
+ iv::FixedLength,
+ rsa::{Pkcs1PrivateDecryptingKey, PrivateDecryptingKey, PublicEncryptingKey},
+};
+use base64::{Engine, prelude::BASE64_STANDARD};
use clap::Parser;
+use flate2::{Compression, write::ZlibDecoder, write::ZlibEncoder};
+use jiff::{Timestamp, Zoned, tz::TimeZone};
+use rcgen::{BasicConstraints, CertificateParams, DnType, IsCa, KeyUsagePurpose};
use reqwest::{
Client, StatusCode,
header::{CONTENT_TYPE, HeaderValue},
};
+use strum_macros::Display;
use taler_build::long_version;
use taler_common::{CommonArgs, config::parser::ConfigSource, taler_main};
use tracing::{debug, info};
+use x509_parser::prelude::{FromDer as _, X509Certificate};
use crate::{
- config::{EbicsHostCfg, NexusCfg},
- ebics_code::EbicsReturnCode, xml_sign::sign_ebics,
+ config::{EbicsHostCfg, EbicsKeysCfg, NexusCfg},
+ ebics_code::EbicsReturnCode,
+ keys::{BankPubKeysFile, load_bank_keys, load_client_keys, persist_client_keys},
+ xml::XmlWriter,
+ xml_sign::sign_ebics,
};
use crate::{keys::ClientPriKeysFile, xml::XmlReader};
@@ -54,12 +69,40 @@ fn main() {
let args = Args::parse();
taler_main(SOURCE, args.common, |cfg| async move {
let cfg = NexusCfg::parse(cfg)?;
- ebics_setup(&Client::new(), &cfg).await?;
+ ebics_setup(&Client::new(), &cfg, false).await?;
Ok(())
})
}
-pub async fn ebics_setup(http: &Client, cfg: &NexusCfg) -> anyhow::Result<()> {
+/** Load client private keys at or create new ones if missing */
+pub fn load_or_generate_client_keys(path: &Path) -> anyhow::Result<ClientPriKeysFile> {
+ // If exists load from disk
+ let current = load_client_keys(path)?;
+ if let Some(current) = current {
+ return Ok(current);
+ }
+ // Else create new keys
+ let new = ClientPriKeysFile::generate()?;
+ persist_client_keys(&new, path)?;
+ info!(
+ "New client private keys created at '{}'",
+ path.to_string_lossy()
+ );
+ Ok(new)
+}
+
+pub async fn ebics_setup(
+ http: &Client,
+ cfg: &NexusCfg,
+ force_keys_submissions: bool,
+) -> anyhow::Result<()> {
+ let logger = EbicsLogger {};
+ let keys_cfg = cfg.keys()?;
+ let host_cfg = cfg.host()?;
+
+ let mut client = load_or_generate_client_keys(keys_cfg.client_priv_keys_path.as_ref())?;
+ let bank = load_bank_keys(keys_cfg.bank_pub_keys_path.as_ref())?;
+
// Check EBICS 3 support
let versions = hev(http, cfg.host()?).await?;
debug!(target: "setup",
@@ -80,154 +123,442 @@ pub async fn ebics_setup(http: &Client, cfg: &NexusCfg) -> anyhow::Result<()> {
bail!("EBICS 3 is not supported by your bank");
}
- let (client, _bank) = keys::expect_full_keys(cfg.keys().unwrap()).unwrap();
- let res = hpb(http, cfg.host().unwrap(), &client).await?;
+ // Privs exist. Upload their pubs
+ let keys_not_sub = !client.submitted_ini;
+ if !client.submitted_ini || force_keys_submissions {
+ submit_client_keys(keys_cfg, host_cfg, &mut client, http, &logger, Order::INI).await?;
+ }
+ // Eject PDF if the keys were submitted for the first time, or the user asked.
+ // TODO if (keysNotSub || generateRegistrationPdf) makePdf(clientKeys, hostCfg)
+ if !client.submitted_hia || force_keys_submissions {
+ submit_client_keys(keys_cfg, host_cfg, &mut client, http, &logger, Order::HIA).await?;
+ }
+
+ let res = hpb(http, host_cfg, &logger, &client).await?;
dbg!(res);
// Fetch bank keys
Ok(())
}
-pub async fn hev(http: &Client, cfg: &EbicsHostCfg) -> anyhow::Result<Vec<VersionNumber>> {
- let phase = "HEV";
- info!(target: "ebics", "Doing administrative request {phase}");
- let msg = xml_build!(
- "ebicsHEVRequest" ("xmlns": "http://www.ebics.org/H000") {
- "HostID": &cfg.host_id
- }
- );
- let res = post_to_bank(cfg.base_url.as_str(), http, msg).await?;
- XmlReader::parse(&res, "ebicsHEVResponse", |root| {
- let technical_code = root.one("SystemReturnCode", |n| {
- n.one("ReturnCode", |n| n.text().parse().unwrap())
- });
- let versions: Vec<_> = root
- .map("VersionNumber", |n| VersionNumber {
- number: n.text().parse().unwrap(),
- schema: n.attr("ProtocolVersion").to_owned(),
- })
- .collect();
- EbicsResponse {
- technical_code,
- bank_code: EbicsReturnCode::EBICS_OK,
- content: versions,
+#[derive(Debug, Display, Clone, Copy, PartialEq, Eq)]
+#[allow(non_camel_case_types)]
+pub enum Order {
+ INI,
+ HIA,
+ HPB,
+}
+
+impl Order {
+ pub fn name(&self) -> &'static str {
+ match self {
+ Order::INI => "INI",
+ Order::HIA => "HIA",
+ Order::HPB => "HPB",
}
- })
- .ok_or_fail(phase)
+ }
}
-/*
-fn parse_signature(xml: Document) -> ((usize, usize), Vec<u8>, Vec<(String, Vec<u8>)>) {
- XmlDestructor::parse_doc(xml, "ebicsNoPubKeyDigestsRequest", |r| {
+pub struct EbicsLogger {}
- })
- let sig = doc.root().children()
- .find(|n| n.is_element() && n.tag_name().name() == "AuthSignature")
- .expect("ds:Signature not found");
-
- let si = sig
- .children()
- .find(|n| n.is_element() && n.tag_name().name() == "SignedInfo")
- .expect("ds:SignedInfo not found");
- let si_span = {
- let r = si.range();
- (r.start, r.end)
- };
+/** Perform an EBICS public key management [order] using [client] and update on disk state */
+async fn submit_client_keys(
+ keys_cfg: &EbicsKeysCfg,
+ host_cfg: &EbicsHostCfg,
+ client: &mut ClientPriKeysFile,
+ http: &Client,
+ ebics_logger: &EbicsLogger,
+ order: Order,
+) -> anyhow::Result<()> {
+ if order == Order::HPB {
+ bail!("Only INI & HIA are supported for client keys");
+ }
+ let res = key_management(host_cfg, client, http, ebics_logger, order).await?;
- let sv = sig
- .descendants()
- .find(|n| n.is_element() && n.tag_name().name() == "SignatureValue")
- .and_then(|n| n.text())
- .expect("ds:SignatureValue not found");
- let sig_bytes = BASE64_STANDARD.decode(sv).unwrap();
-
- let reference = si
- .descendants()
- .find(|n: &Node<'_, '_>| n.is_element() && n.tag_name().name() == "Reference")
- .expect("ds:Reference not found");
- let dv = reference
- .descendants()
- .find(|n| n.is_element() && n.tag_name().name() == "DigestValue")
- .and_then(|n| n.text())
- .expect("ds:DigestValue not found");
-
- .map(|r| {
- let id = r
- .attribute("URI")
- .expect("Reference missing URI")?
- .trim_start_matches('#')
- .to_owned();
- let dv = r
- .descendants()
- .find(|n| n.is_element() && n.tag_name().name() == "DigestValue")
- .and_then(|n| n.text())
- .ok_or_else(|| Error::Structure(format!("DigestValue missing for '{id}'")))?;
- Ok((id, decode_b64(dv, "DigestValue")?))
- })
- .collect::<Result<_>>()?;
-
- Ok((si_span, sig_bytes, refs))
-}*/
-
-/*pub fn verify_ebics(xml: &str) -> bool {
- let doc = Document::parse(xml).unwrap();
-}*/
+ if res.technical_code == EbicsReturnCode::EBICS_INVALID_USER_STATE
+ || res.technical_code == EbicsReturnCode::EBICS_INVALID_USER_OR_USER_STATE
+ {
+ bail!(
+ "{order} status code {}: either your IDs are incorrect, or you already have keys registered with this bank",
+ res.technical_code
+ )
+ }
+ res.ok_or_fail(order.name())?;
+ match order {
+ Order::INI => client.submitted_ini = true,
+ Order::HIA => client.submitted_hia = true,
+ Order::HPB => unreachable!("Only INI & HIA are supported for client keys"),
+ }
+ keys::persist_client_keys(client, keys_cfg.client_priv_keys_path.as_ref())?;
+ // TODO better error: Could not update the $order state on disk
+ Ok(())
+}
-pub async fn hpb(
- http: &Client,
+/// Generate a self-signed X.509 certificate from an RSA private key (PEM or DER)
+pub fn x509_certificate_from_rsa_private(
+ pem: &str,
+ name: &str,
+) -> Result<rcgen::Certificate, rcgen::Error> {
+ let keys = rcgen::KeyPair::from_pem(pem).unwrap();
+ let mut params = CertificateParams::new(vec![])?;
+
+ // Set subject/issuer CN
+ params.distinguished_name.push(DnType::CommonName, name);
+
+ let now = Zoned::new(Timestamp::now(), TimeZone::UTC).date();
+
+ // 1000-year validity
+ params.not_before = rcgen::date_time_ymd(now.year() as i32, now.month() as u8, now.day() as u8);
+ params.not_after =
+ rcgen::date_time_ymd(now.year() as i32 + 1000, now.month() as u8, now.day() as u8);
+
+ // CA: true (basicConstraints)
+ params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
+
+ // Key usage flags
+ params.key_usages = vec![
+ KeyUsagePurpose::DigitalSignature,
+ KeyUsagePurpose::ContentCommitment, // NonRepudiation
+ KeyUsagePurpose::KeyEncipherment,
+ KeyUsagePurpose::DataEncipherment,
+ KeyUsagePurpose::KeyAgreement,
+ KeyUsagePurpose::KeyCertSign,
+ KeyUsagePurpose::CrlSign,
+ KeyUsagePurpose::EncipherOnly,
+ KeyUsagePurpose::DecipherOnly,
+ ];
+
+ let cert = params.self_signed(&keys)?;
+ Ok(cert)
+}
+
+/// Extract an RSA public key from a X.509 certificate
+pub fn rsa_private_from_x509_certificate_from(der: &[u8]) -> PublicEncryptingKey {
+ let (_, cert) = X509Certificate::from_der(der).unwrap();
+ let issuer_public_key = cert.public_key();
+ cert.verify_signature(Some(issuer_public_key)).unwrap();
+ PublicEncryptingKey::from_der(issuer_public_key.raw).unwrap()
+}
+
+pub async fn key_management(
cfg: &EbicsHostCfg,
- keys: &ClientPriKeysFile,
-) -> anyhow::Result<String> {
- let phase = "HPB";
- let nonce: u128 = rand::random();
- info!(target: "ebics", "Doing administrative request {phase}");
+ client: &ClientPriKeysFile,
+ http: &Client,
+ ebics_logger: &EbicsLogger,
+ order: Order,
+) -> anyhow::Result<EbicsResponse<Option<String>>> {
+ info!("Doing key request {order}");
+ //val txLog = ebicsLogger.tx(order.name)
+ // TODO is this still necessary ?
+
+ let (name, security_medium) = match order {
+ Order::INI | Order::HIA => ("ebicsUnsecuredRequest", "0200"),
+ Order::HPB => ("ebicsNoPubKeyDigestsRequest", "0000"),
+ };
+
+ fn xml_order_data(
+ cfg: &EbicsHostCfg,
+ name: &str,
+ schema: &str,
+ build: impl FnOnce(&mut XmlWriter),
+ ) -> String {
+ let xml = xml_build!(name ("xmlns":schema) ("xmlns:ds":"http://www.w3.org/2000/09/xmldsig#") {
+ @ build,
+ "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();
+ BASE64_STANDARD.encode(&compressed)
+ }
+
+ fn rsa_key_xml<K>(w: &mut XmlWriter, key: &K)
+ where
+ K: AsDer<Pkcs8V1Der<'static>>,
+ {
+ let der = key.as_der().unwrap();
+ let b64 = BASE64_STANDARD.encode(der.as_ref());
+ let lines = b64
+ .as_bytes()
+ .chunks(64)
+ .map(|c| std::str::from_utf8(c).unwrap())
+ .collect::<Vec<_>>()
+ .join("\n");
+ let pem =
+ format!("-----BEGIN RSA PRIVATE KEY-----\n{lines}\n-----END RSA PRIVATE KEY-----\n");
+ let cert = x509_certificate_from_rsa_private(&pem, "LibEuFin EBICS").unwrap();
+ let der = cert.der();
+ let b64 = BASE64_STANDARD.encode(der.as_ref());
+
+ xml!(w, "ds:X509Data" {
+ "ds:X509Certificate": &b64
+ });
+ }
+ let data = match order {
+ Order::INI => Some(xml_order_data(
+ cfg,
+ "SignaturePubKeyOrderData",
+ "http://www.ebics.org/S002",
+ |w| {
+ xml!(w, "SignaturePubKeyInfo" {
+ @ |w| rsa_key_xml(w, &client.signature_private_key),
+ "SignatureVersion": "A006"
+ });
+ },
+ )),
+ Order::HIA => Some(xml_order_data(
+ cfg,
+ "HIARequestOrderData",
+ "urn:org:ebics:H005",
+ |w| {
+ xml!(w, "AuthenticationPubKeyInfo" {
+ @ |w| rsa_key_xml(w, &client.authentication_private_key),
+ "AuthenticationVersion": "X002"
+ },
+ "EncryptionPubKeyInfo" {
+ @ |w| rsa_key_xml(w, &client.encryption_private_key),
+ "EncryptionVersion": "E002"
+ });
+ },
+ )),
+ Order::HPB => None,
+ };
+ let sign = order == Order::HPB;
let msg = xml_build!(
- "ebicsNoPubKeyDigestsRequest"
+ name
("xmlns": "urn:org:ebics:H005")
("xmlns:ds": "http://www.w3.org/2000/09/xmldsig#")
- ("Revision": "1")
("Version": "H005")
+ ("Revision": "1")
{
"header" ("authenticate": "true") {
"static" {
"HostID": &cfg.host_id,
- "Nonce": &format_args!("{:032x}", nonce),
- "Timestamp": &jiff::Timestamp::now(),
+ @ |w: &mut XmlWriter| {
+ if order == Order::HPB {
+ let nonce: u128 = rand::random();
+ xml!(w,
+ "Nonce": &format_args!("{:032x}", nonce),
+ "Timestamp": &jiff::Timestamp::now()
+ );
+ }
+ },
"PartnerID": &cfg.partner_id,
"UserID": &cfg.user_id,
"OrderDetails" {
- "AdminOrderType": "HPB"
+ "AdminOrderType": order
},
- "SecurityMedium": "0000"
+ "SecurityMedium": security_medium
},
"mutable"
},
- "AuthSignature",
- "body"
+ @ |w: &mut XmlWriter| {
+ if sign {
+ xml!(w, "AuthSignature");
+ }
+ },
+ "body" {
+ @ |w: &mut XmlWriter| {
+ if let Some(data) = data {
+ xml!(w,"DataTransfer" {
+ "OrderData": data
+ });
+ }
+ }
+ }
}
+
);
- let signed = sign_ebics(msg, &keys.authentication_private_key);
+ let signed = if sign {
+ sign_ebics(msg, &client.authentication_private_key)
+ } else {
+ msg
+ };
let res = post_to_bank(cfg.base_url.as_str(), http, signed).await?;
- println!("{res}");
- XmlReader::parse(&res, "ebicsKeyManagementResponse", |root| {
- let technical_code = root.one("header", |n| {
- // Check signed
- n.one("mutable", |n| {
- n.one("ReturnCode", |n| n.text().parse().unwrap())
+ Ok(XmlReader::parse(
+ &res,
+ "ebicsKeyManagementResponse",
+ |root| {
+ let technical_code = root
+ .one_signed("header")
+ .one("mutable")
+ .one("ReturnCode")
+ .text()
+ .parse()
+ .unwrap();
+ let body = root.one("body");
+ let bank_code = body.one_signed("ReturnCode").text().parse().unwrap();
+ let content = if let Some(data) = body.opt("DataTransfer") {
+ let info = data.one_signed("DataEncryptionInfo");
+ let info = DataEncryptionInfo {
+ transaction_key: info.one("TransactionKey").b64(),
+ bank_pub_digest: info.one("EncryptionPubKeyDigest").b64(),
+ };
+ let chunk = data.one("OrderData").b64();
+ let decoded = decrypt_and_decompress_payload(
+ &client.encryption_private_key,
+ info,
+ vec![chunk],
+ );
+ Some(String::from_utf8(decoded).unwrap())
+ } else {
+ None
+ };
+ EbicsResponse {
+ technical_code,
+ bank_code,
+ content,
+ }
+ },
+ ))
+}
+
+struct DataEncryptionInfo {
+ transaction_key: Vec<u8>,
+ bank_pub_digest: Vec<u8>,
+}
+
+/** Decrypts and decompresses EBICS BTS payload */
+fn decrypt_and_decompress_payload(
+ client_encryption_key: &PrivateDecryptingKey,
+ encryption_info: DataEncryptionInfo,
+ segments: Vec<Vec<u8>>,
+) -> Vec<u8> {
+ // TODO check bank_pub_digest
+ let tx_key = decrypt_ebics_e002_key(
+ client_encryption_key.clone(),
+ &encryption_info.transaction_key,
+ );
+ let mut decoder = ZlibDecoder::new(Vec::new());
+ for segment in segments {
+ let decrypted = decrypt_ebics_e002(&tx_key, segment);
+ decoder.write_all(&decrypted).unwrap();
+ }
+ decoder.finish().unwrap()
+}
+
+pub fn decrypt_ebics_e002(transaction_key: &DecryptingKey, mut encrypted_data: Vec<u8>) -> Vec<u8> {
+ // AES-CBC with a zero IV, as in the Kotlin original.
+ let iv = [0u8; 16];
+
+ let plaintext = transaction_key
+ .decrypt(
+ &mut encrypted_data,
+ DecryptionContext::Iv128(FixedLength::from(iv)),
+ )
+ .unwrap();
+
+ // Strip X9.23 / ANSI X9.23 padding:
+ // The last byte holds the number of padding bytes to remove.
+ let pad_len = *plaintext.last().unwrap() as usize;
+ if pad_len == 0 || pad_len > 16 || pad_len > plaintext.len() {
+ panic!("WTF");
+ }
+ let decoded = plaintext.len() - pad_len;
+ encrypted_data.truncate(decoded);
+ encrypted_data
+}
+
+fn decrypt_ebics_e002_key(
+ private_key: PrivateDecryptingKey,
+ encrypted_transaction_key: &[u8],
+) -> DecryptingKey {
+ let private_key = Pkcs1PrivateDecryptingKey::new(private_key).unwrap();
+ let mut plaintext = vec![0u8; private_key.min_output_size()];
+ let cipher = private_key
+ .decrypt(&encrypted_transaction_key, &mut plaintext)
+ .unwrap();
+ let cipher_key = UnboundCipherKey::new(&aws_lc_rs::cipher::AES_128, &cipher).unwrap();
+ DecryptingKey::cbc(cipher_key).unwrap()
+}
+
+pub async fn hev(http: &Client, cfg: &EbicsHostCfg) -> anyhow::Result<Vec<VersionNumber>> {
+ let phase = "HEV";
+ info!(target: "ebics", "Doing administrative request {phase}");
+ let msg = xml_build!(
+ "ebicsHEVRequest" ("xmlns": "http://www.ebics.org/H000") {
+ "HostID": &cfg.host_id
+ }
+ );
+ let res = post_to_bank(cfg.base_url.as_str(), http, msg).await?;
+ XmlReader::parse(&res, "ebicsHEVResponse", |root| {
+ let technical_code = root
+ .one("SystemReturnCode")
+ .one("ReturnCode")
+ .text()
+ .parse()
+ .unwrap();
+ let versions: Vec<_> = root
+ .each("VersionNumber")
+ .map(|n| VersionNumber {
+ number: n.text().parse().unwrap(),
+ schema: n.attr("ProtocolVersion").to_owned(),
})
- });
- let bank_code = root.one("body", |n| {
- // Check signed
- n.one("ReturnCode", |n| n.text().parse().unwrap())
- });
+ .collect();
EbicsResponse {
technical_code,
- bank_code,
- content: String::new(),
+ bank_code: EbicsReturnCode::EBICS_OK,
+ content: versions,
}
})
.ok_or_fail(phase)
}
+pub async fn hpb(
+ http: &Client,
+ cfg: &EbicsHostCfg,
+ logger: &EbicsLogger,
+ client: &ClientPriKeysFile,
+) -> anyhow::Result<BankPubKeysFile> {
+ let order = Order::HPB;
+ let res = key_management(cfg, client, http, logger, order).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",
+ res.technical_code
+ )
+ }
+ let order_data = res
+ .ok_or_fail(order.name())?
+ .expect("{order}: missing order data");
+
+ fn rsa_pub_key(xml: XmlReader) -> PublicEncryptingKey {
+ let der = xml.one("X509Data").one("X509Certificate").b64();
+ rsa_private_from_x509_certificate_from(&der)
+ }
+
+ Ok(XmlReader::parse(
+ &order_data,
+ "HPBResponseOrderData",
+ |root| {
+ let auth_pub_info = root.one("AuthenticationPubKeyInfo");
+ let version = auth_pub_info.one("AuthenticationVersion");
+ let version = version.text();
+ assert_eq!(
+ version, "X002",
+ "Expected authentication version X002 got unsupported {version}"
+ );
+ let auth_pub = rsa_pub_key(auth_pub_info);
+
+ let enc_pub_info = root.one("EncryptionPubKeyInfo");
+ let version = enc_pub_info.one("EncryptionVersion");
+ let version = version.text();
+ assert_eq!(
+ version, "E002",
+ "Expected encryption version E002 got unsupported {version}"
+ );
+ let enc_pub = rsa_pub_key(enc_pub_info);
+
+ BankPubKeysFile {
+ bank_authentication_public_key: auth_pub,
+ bank_encryption_public_key: enc_pub,
+ accepted: false,
+ }
+ },
+ ))
+}
+
#[derive(Debug, thiserror::Error)]
pub enum EbicsError {
#[error(transparent)]
diff --git a/src/xml.rs b/src/xml.rs
@@ -19,35 +19,25 @@
use std::fmt::Display;
+use base64::{Engine, prelude::BASE64_STANDARD};
use roxmltree::Document;
#[macro_export]
macro_rules! xml {
// Text element
- ($w:ident, $name:literal $(($k:literal: $v:literal))* : $content:expr $(, $($rest:tt)*)?) => {
+ ($w:ident, $name:tt $(($k:tt: $v:tt))* : $content:expr $(, $($rest:tt)*)?) => {
$w.text($name, &[$(($k, $v)),*], $content);
$(xml!($w, $($rest)*);)*
};
- ($w:ident, ($name:expr) $(($k:literal: $v:literal))* : $content:expr $(, $($rest:tt)*)?) => {
- $w.text($name, &[$(($k, $v)),*], $content);
- $(xml!($w, $($rest)*);)*
- };
-
// Nested block
- ($w:ident, $name:literal $(($k:literal: $v:literal))* { $($body:tt)* }$(, $($rest:tt)*)?) => {
- $w.nest($name, &[$(($k, $v)),*], |$w| {
- xml!($w, $($body)*);
- });
- $(xml!($w, $($rest)*);)*
- };
- ($w:ident, ($name:expr) $(($k:literal: $v:literal))* { $($body:tt)* }$(, $($rest:tt)*)?) => {
+ ($w:ident, $name:tt $(($k:tt: $v:tt))* { $($body:tt)* }$(, $($rest:tt)*)?) => {
$w.nest($name, &[$(($k, $v)),*], |$w| {
xml!($w, $($body)*);
});
$(xml!($w, $($rest)*);)*
};
// Empty element
- ($w:ident, $name:literal $(($k:literal: $v:literal))* $(, $($rest:tt)*)?) => {
+ ($w:ident, $name:tt $(($k:tt: $v:tt))* $(, $($rest:tt)*)?) => {
$w.empty($name, &[$(($k, $v)),*]);
$(xml!($w, $($rest)*);)*
};
@@ -60,7 +50,7 @@ macro_rules! xml {
#[macro_export]
macro_rules! xml_build {
- ($name:literal $(($k:literal: $v:literal))* { $($body:tt)* }) => {
+ ($name:tt $(($k:tt: $v:tt))* { $($body:tt)* }) => {
$crate::xml::XmlWriter::build(|w| {
w.nest($name, &[$(($k, $v)),*], |w| {
xml!(w, $($body)*);
@@ -105,7 +95,7 @@ impl XmlWriter {
self.buff.push_str("/>");
}
- pub fn text<D: Display + ?Sized>(&mut self, name: &str, attrs: &[(&str, &str)], content: &D) {
+ pub fn text<D: Display>(&mut self, name: &str, attrs: &[(&str, &str)], content: D) {
self.nest(name, attrs, |w| w.write_escaped(content));
}
@@ -121,7 +111,7 @@ impl XmlWriter {
}
}
- fn write_escaped<D: Display + ?Sized>(&mut self, content: &D) {
+ fn write_escaped<D: Display>(&mut self, content: D) {
struct EscapingWriter<'a>(&'a mut String);
impl<'a> std::fmt::Write for EscapingWriter<'a> {
@@ -151,7 +141,7 @@ pub struct XmlReader<'node, 'input> {
node: roxmltree::Node<'node, 'input>,
}
-impl XmlReader<'_, '_> {
+impl<'node, 'input> XmlReader<'node, 'input> {
pub fn parse<F, R>(raw: &str, tag: &str, f: F) -> R
where
R: 'static,
@@ -182,11 +172,7 @@ impl XmlReader<'_, '_> {
self.node.attribute(name).unwrap()
}
- pub fn one<F, R>(&self, tag: &str, f: F) -> R
- where
- R: 'static,
- F: for<'local> FnOnce(XmlReader<'local, '_>) -> R,
- {
+ pub fn one(&self, tag: &str) -> XmlReader<'node, 'input> {
let mut iter = self
.node
.children()
@@ -204,23 +190,47 @@ impl XmlReader<'_, '_> {
self.node.tag_name().name()
);
}
- f(XmlReader { node })
+ XmlReader { node }
}
- pub fn map<'a, F, R>(&'a self, tag: &'a str, mut f: F) -> impl Iterator<Item = R> + 'a
- where
- R: 'static,
- F: for<'local> FnMut(XmlReader<'local, '_>) -> R + 'static,
- {
+ pub fn opt(&self, tag: &str) -> Option<XmlReader<'node, 'input>> {
+ let mut iter = self
+ .node
+ .children()
+ .filter(|children| children.has_tag_name(tag));
+ let Some(node) = iter.next() else {
+ return None;
+ };
+ if iter.next().is_some() {
+ let count = iter.count() + 2;
+ panic!(
+ "expected optional '{}.{tag}', got {count}",
+ self.node.tag_name().name()
+ );
+ }
+ Some(XmlReader { node })
+ }
+
+ pub fn one_signed(&self, tag: &str) -> XmlReader<'node, 'input> {
+ let one = self.one(tag);
+ assert_eq!(one.node.attribute("authenticate"), Some("true"));
+ one
+ }
+
+ pub fn each(&self, tag: &str) -> impl Iterator<Item = XmlReader<'node, 'input>> {
self.node
.children()
- .filter(move |children| children.has_tag_name(tag))
- .map(move |children| f(XmlReader { node: children }))
+ .filter_map(move |node| node.has_tag_name(tag).then_some(XmlReader { node }))
}
pub fn text(&self) -> &str {
self.node.text().unwrap_or_default()
}
+
+ pub fn b64(&self) -> Vec<u8> {
+ let encoded = self.text();
+ BASE64_STANDARD.decode(encoded).unwrap()
+ }
}
#[cfg(test)]
diff --git a/src/xml_sign.rs b/src/xml_sign.rs
@@ -127,9 +127,9 @@ fn c14n_inclusive<'a>(
.collect();
for (k, v) in attributes {
out.push(' ');
- out.push_str(&k);
+ out.push_str(k);
out.push_str("=\"");
- out.push_str(&escape_attr(&v));
+ out.push_str(&escape_attr(v));
out.push('"');
}