commit a537dd277ff1b961d16d2711d7e9a728ab5cd4c9
parent 557d9f3a51eedbf97b0731a13248b1f7532f8217
Author: Antoine A <>
Date: Fri, 24 Apr 2026 10:37:56 +0200
ergonomic XML macro
Diffstat:
| M | Cargo.lock | | | 214 | +++++++++++++++++++++++++++++++++++++++++++++---------------------------------- |
| M | Cargo.toml | | | 6 | ++---- |
| A | src/config.rs | | | 92 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | src/ebics_code.rs | | | 210 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | src/keys.rs | | | 112 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| M | src/main.rs | | | 684 | ++++++++++++------------------------------------------------------------------- |
| A | src/xml.rs | | | 285 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
7 files changed, 923 insertions(+), 680 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -96,9 +96,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "aws-lc-rs"
-version = "1.16.0"
+version = "1.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d9a7b350e3bb1767102698302bc37256cbd48422809984b98d292c40e2579aa9"
+checksum = "94bffc006df10ac2a68c83692d734a465f8ee6c5b384d8545a636f81d858f4bf"
dependencies = [
"aws-lc-sys",
"untrusted 0.7.1",
@@ -107,9 +107,9 @@ dependencies = [
[[package]]
name = "aws-lc-sys"
-version = "0.37.1"
+version = "0.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b092fe214090261288111db7a2b2c2118e5a7f30dc2569f1732c4069a6840549"
+checksum = "4321e568ed89bb5a7d291a7f37997c2c0df89809d7b6d12062c81ddb54aa782e"
dependencies = [
"cc",
"cmake",
@@ -248,6 +248,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
+name = "chacha20"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.3.0",
+ "rand_core 0.10.0",
+]
+
+[[package]]
name = "clap"
version = "4.5.60"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -373,6 +384,15 @@ dependencies = [
]
[[package]]
+name = "cpufeatures"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
+dependencies = [
+ "libc",
+]
+
+[[package]]
name = "crc"
version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -682,20 +702,21 @@ dependencies = [
"cfg-if",
"js-sys",
"libc",
- "r-efi",
+ "r-efi 5.3.0",
"wasip2",
"wasm-bindgen",
]
[[package]]
name = "getrandom"
-version = "0.4.1"
+version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec"
+checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [
"cfg-if",
"libc",
- "r-efi",
+ "r-efi 6.0.0",
+ "rand_core 0.10.0",
"wasip2",
"wasip3",
]
@@ -1033,9 +1054,9 @@ dependencies = [
[[package]]
name = "ipnet"
-version = "2.11.0"
+version = "2.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
+checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
[[package]]
name = "iri-string"
@@ -1061,9 +1082,9 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
[[package]]
name = "jiff"
-version = "0.2.20"
+version = "0.2.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c867c356cc096b33f4981825ab281ecba3db0acefe60329f044c1789d94c6543"
+checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359"
dependencies = [
"jiff-static",
"jiff-tzdb-platform",
@@ -1076,9 +1097,9 @@ dependencies = [
[[package]]
name = "jiff-static"
-version = "0.2.20"
+version = "0.2.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f7946b4325269738f270bb55b3c19ab5c5040525f83fd625259422a9d25d9be5"
+checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4"
dependencies = [
"proc-macro2",
"quote",
@@ -1087,9 +1108,9 @@ dependencies = [
[[package]]
name = "jiff-tzdb"
-version = "0.1.5"
+version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "68971ebff725b9e2ca27a601c5eb38a4c5d64422c4cbab0c535f248087eda5c2"
+checksum = "c900ef84826f1338a557697dc8fc601df9ca9af4ac137c7fb61d4c6f2dfd3076"
[[package]]
name = "jiff-tzdb-platform"
@@ -1134,9 +1155,9 @@ dependencies = [
[[package]]
name = "js-sys"
-version = "0.3.87"
+version = "0.3.91"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "93f0862381daaec758576dcc22eb7bbf4d7efd67328553f3b45a412a51a3fb21"
+checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c"
dependencies = [
"once_cell",
"wasm-bindgen",
@@ -1186,11 +1207,10 @@ dependencies = [
"aws-lc-rs",
"base64",
"clap",
- "getrandom 0.2.17",
+ "getrandom 0.4.2",
"jiff",
"pem",
- "quick-xml 0.39.2",
- "rand 0.8.5",
+ "rand 0.10.0",
"reqwest",
"roxmltree",
"serde",
@@ -1206,25 +1226,25 @@ dependencies = [
"tracing",
"url",
"xml-canonicalization",
- "xml-macro",
]
[[package]]
name = "libredox"
-version = "0.1.12"
+version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616"
+checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a"
dependencies = [
"bitflags",
"libc",
- "redox_syscall 0.7.1",
+ "plain",
+ "redox_syscall 0.7.3",
]
[[package]]
name = "linux-raw-sys"
-version = "0.11.0"
+version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "listenfd"
@@ -1429,9 +1449,9 @@ dependencies = [
[[package]]
name = "pin-project-lite"
-version = "0.2.16"
+version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pin-utils"
@@ -1440,6 +1460,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
+name = "plain"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
+
+[[package]]
name = "portable-atomic"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1501,15 +1527,6 @@ dependencies = [
]
[[package]]
-name = "quick-xml"
-version = "0.39.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d"
-dependencies = [
- "memchr",
-]
-
-[[package]]
name = "quinn"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1567,9 +1584,9 @@ dependencies = [
[[package]]
name = "quote"
-version = "1.0.44"
+version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
@@ -1581,6 +1598,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
name = "rand"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1602,6 +1625,17 @@ dependencies = [
]
[[package]]
+name = "rand"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8"
+dependencies = [
+ "chacha20",
+ "getrandom 0.4.2",
+ "rand_core 0.10.0",
+]
+
+[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1640,6 +1674,12 @@ dependencies = [
]
[[package]]
+name = "rand_core"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba"
+
+[[package]]
name = "redox_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1650,9 +1690,9 @@ dependencies = [
[[package]]
name = "redox_syscall"
-version = "0.7.1"
+version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "35985aa610addc02e24fc232012c86fd11f14111180f902b67e2d5331f8ebf2b"
+checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16"
dependencies = [
"bitflags",
]
@@ -1682,9 +1722,9 @@ dependencies = [
[[package]]
name = "regex-syntax"
-version = "0.8.9"
+version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c"
+checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "reqwest"
@@ -1755,9 +1795,9 @@ checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[package]]
name = "rustix"
-version = "1.1.3"
+version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"errno",
@@ -1768,9 +1808,9 @@ dependencies = [
[[package]]
name = "rustls"
-version = "0.23.36"
+version = "0.23.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b"
+checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
dependencies = [
"aws-lc-rs",
"once_cell",
@@ -1974,9 +2014,9 @@ dependencies = [
[[package]]
name = "serde_with"
-version = "3.16.1"
+version = "3.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7"
+checksum = "381b283ce7bc6b476d903296fb59d0d36633652b633b27f64db4fb46dcbfc3b9"
dependencies = [
"serde_core",
"serde_with_macros",
@@ -1984,9 +2024,9 @@ dependencies = [
[[package]]
name = "serde_with_macros"
-version = "3.16.1"
+version = "3.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c"
+checksum = "a6d4e30573c8cb306ed6ab1dca8423eec9a463ea0e155f45399455e0368b27e0"
dependencies = [
"darling",
"proc-macro2",
@@ -2001,7 +2041,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
- "cpufeatures",
+ "cpufeatures 0.2.17",
"digest",
]
@@ -2206,20 +2246,19 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
-version = "0.26.3"
+version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
+checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
[[package]]
name = "strum_macros"
-version = "0.26.4"
+version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
+checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664"
dependencies = [
"heck",
"proc-macro2",
"quote",
- "rustversion",
"syn",
]
@@ -2356,12 +2395,12 @@ dependencies = [
[[package]]
name = "tempfile"
-version = "3.25.0"
+version = "3.26.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1"
+checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0"
dependencies = [
"fastrand",
- "getrandom 0.4.1",
+ "getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys 0.61.2",
@@ -2443,9 +2482,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
-version = "1.49.0"
+version = "1.50.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86"
+checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d"
dependencies = [
"bytes",
"libc",
@@ -2459,9 +2498,9 @@ dependencies = [
[[package]]
name = "tokio-macros"
-version = "2.6.0"
+version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
+checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c"
dependencies = [
"proc-macro2",
"quote",
@@ -2767,9 +2806,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
[[package]]
name = "wasm-bindgen"
-version = "0.2.110"
+version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1de241cdc66a9d91bd84f097039eb140cdc6eec47e0cdbaf9d932a1dd6c35866"
+checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e"
dependencies = [
"cfg-if",
"once_cell",
@@ -2780,9 +2819,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-futures"
-version = "0.4.60"
+version = "0.4.64"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a42e96ea38f49b191e08a1bab66c7ffdba24b06f9995b39a9dd60222e5b6f1da"
+checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8"
dependencies = [
"cfg-if",
"futures-util",
@@ -2794,9 +2833,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro"
-version = "0.2.110"
+version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e12fdf6649048f2e3de6d7d5ff3ced779cdedee0e0baffd7dff5cdfa3abc8a52"
+checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -2804,9 +2843,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
-version = "0.2.110"
+version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0e63d1795c565ac3462334c1e396fd46dbf481c40f51f5072c310717bc4fb309"
+checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3"
dependencies = [
"bumpalo",
"proc-macro2",
@@ -2817,9 +2856,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-shared"
-version = "0.2.110"
+version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e9f9cdac23a5ce71f6bf9f8824898a501e511892791ea2a0c6b8568c68b9cb53"
+checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16"
dependencies = [
"unicode-ident",
]
@@ -2860,9 +2899,9 @@ dependencies = [
[[package]]
name = "web-sys"
-version = "0.3.87"
+version = "0.3.91"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f2c7c5718134e770ee62af3b6b4a84518ec10101aad610c024b64d6ff29bb1ff"
+checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -3371,23 +3410,12 @@ checksum = "d9a3101284404fdfe80cc80be42e4c1b5642ac76aae9ef10dae2eaf7884f66e1"
dependencies = [
"pest",
"pest_derive",
- "quick-xml 0.37.5",
+ "quick-xml",
"regex",
"tracing",
]
[[package]]
-name = "xml-macro"
-version = "0.3.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0fee261e75e080d7986deeaa29bbbfd60a2d83dd673e282bd1ed487a708d95fb"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn",
-]
-
-[[package]]
name = "yoke"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3412,18 +3440,18 @@ dependencies = [
[[package]]
name = "zerocopy"
-version = "0.8.39"
+version = "0.8.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a"
+checksum = "a789c6e490b576db9f7e6b6d661bcc9799f7c0ac8352f56ea20193b2681532e5"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
-version = "0.8.39"
+version = "0.8.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517"
+checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953"
dependencies = [
"proc-macro2",
"quote",
diff --git a/Cargo.toml b/Cargo.toml
@@ -6,8 +6,6 @@ edition = "2024"
[dependencies]
reqwest = "*"
tokio = { version = "*", features = ["macros", "rt-multi-thread"]}
-quick-xml = "*"
-xml-macro = "*"
tracing = "*"
thiserror ="*"
roxmltree = "*"
@@ -29,7 +27,7 @@ taler-test-utils = { path = "../taler-rust/common/taler-test-utils" }
#taler-test-utils = { git = "git://git.taler.net/taler-rust.git/" }
url = "*"
clap = { version = "4.5", features = ["derive"] }
-strum = "0.26"
-strum_macros = "0.26"
+strum = "0.28"
+strum_macros = "0.28"
aws-lc-rs = {version = "*"}
serde = { version = "*", features = ["derive"] }
\ No newline at end of file
diff --git a/src/config.rs b/src/config.rs
@@ -0,0 +1,92 @@
+/*
+* This file is part of LibEuFin.
+* Copyright (C) 2026 Taler Systems S.A.
+
+* LibEuFin is free software; you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation; either version 3, or
+* (at your option) any later version.
+
+* LibEuFin is distributed in the hope that it will be useful, but
+* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
+* Public License for more details.
+
+* You should have received a copy of the GNU Affero General Public
+* License along with LibEuFin; see the file COPYING. If not, see
+* <http://www.gnu.org/licenses/>
+*/
+
+use std::cell::OnceCell;
+
+use taler_common::config::{Config, ValueErr};
+
+pub struct EbicsKeysCfg {
+ pub bank_pub_keys_path: String,
+ pub client_priv_keys_path: String,
+}
+
+impl EbicsKeysCfg {
+ pub fn parse(cfg: &Config) -> Result<Self, ValueErr> {
+ let sect = cfg.section("nexus-ebics");
+ Ok(Self {
+ bank_pub_keys_path: sect.path("bank_public_keys_file").require()?,
+ client_priv_keys_path: sect.path("client_private_keys_file").require()?,
+ })
+ }
+}
+
+pub struct EbicsHostCfg {
+ pub base_url: url::Url,
+ pub host_id: String,
+ pub user_id: String,
+ pub partner_id: String,
+}
+
+impl EbicsHostCfg {
+ pub fn parse(cfg: &Config) -> Result<Self, ValueErr> {
+ let sect = cfg.section("nexus-ebics");
+ Ok(Self {
+ base_url: sect.url("host_base_url").require()?,
+ host_id: sect.str("host_id").require()?,
+ user_id: sect.str("user_id").require()?,
+ partner_id: sect.str("partner_id").require()?,
+ })
+ }
+}
+
+pub struct NexusCfg {
+ pub cfg: Config,
+ pub keys: OnceCell<EbicsKeysCfg>,
+ pub host: OnceCell<EbicsHostCfg>,
+}
+
+impl NexusCfg {
+ pub fn parse(cfg: Config) -> Result<Self, ValueErr> {
+ Ok(Self {
+ cfg,
+ keys: OnceCell::new(),
+ host: OnceCell::new(),
+ })
+ }
+
+ pub fn keys(&self) -> Result<&EbicsKeysCfg, ValueErr> {
+ // TODO use get_or_try_init when stable
+ if let Some(keys) = self.keys.get() {
+ return Ok(keys);
+ }
+ let keys = EbicsKeysCfg::parse(&self.cfg)?;
+ self.keys.set(keys).ok();
+ Ok(self.keys.get().unwrap())
+ }
+
+ pub fn host(&self) -> Result<&EbicsHostCfg, ValueErr> {
+ // TODO use get_or_try_init when stable
+ if let Some(host) = self.host.get() {
+ return Ok(host);
+ }
+ let host = EbicsHostCfg::parse(&self.cfg)?;
+ self.host.set(host).ok();
+ Ok(self.host.get().unwrap())
+ }
+}
diff --git a/src/ebics_code.rs b/src/ebics_code.rs
@@ -0,0 +1,210 @@
+/*
+* This file is part of LibEuFin.
+* Copyright (C) 2026 Taler Systems S.A.
+
+* LibEuFin is free software; you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation; either version 3, or
+* (at your option) any later version.
+
+* LibEuFin is distributed in the hope that it will be useful, but
+* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
+* Public License for more details.
+
+* You should have received a copy of the GNU Affero General Public
+* License along with LibEuFin; see the file COPYING. If not, see
+* <http://www.gnu.org/licenses/>
+*/
+
+/// EBICS Error Class (First two digits of the return code)
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum EbicsKind {
+ /// 00 - Success / General Information
+ Information,
+ /// 01 - Positive notification, but action might be required
+ Note,
+ /// 03 - Warning
+ Warning,
+ /// 06 - Recoverable Error
+ RecoverableError,
+ /// 09 - Non-recoverable Error
+ NonRecoverableError,
+}
+
+use strum_macros::{AsRefStr, Display, EnumString};
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumString, AsRefStr, Display)]
+#[allow(non_camel_case_types)]
+pub enum EbicsReturnCode {
+ // --- 00: Information ---
+ #[strum(serialize = "000000")]
+ EBICS_OK,
+
+ // --- 01: Notes ---
+ #[strum(serialize = "011000")]
+ EBICS_DOWNLOAD_POSTPROCESS_DONE,
+ #[strum(serialize = "011001")]
+ EBICS_DOWNLOAD_POSTPROCESS_SKIPPED,
+ #[strum(serialize = "011101")]
+ EBICS_TX_SEGMENT_NUMBER_UNDERRUN,
+ #[strum(serialize = "011301")]
+ EBICS_NO_ONLINE_CHECKS,
+
+ // --- 03: Warnings ---
+ #[strum(serialize = "031001")]
+ EBICS_ORDER_PARAMS_IGNORED,
+
+ // --- 06: Technical Errors (Recoverable) ---
+ #[strum(serialize = "061001")]
+ EBICS_AUTHENTICATION_FAILED,
+ #[strum(serialize = "061002")]
+ EBICS_INVALID_REQUEST,
+ #[strum(serialize = "061099")]
+ EBICS_INTERNAL_ERROR,
+ #[strum(serialize = "061101")]
+ EBICS_TX_RECOVERY_SYNC,
+
+ // --- 09: Business Errors (Non-Recoverable) ---
+ #[strum(serialize = "090003")]
+ EBICS_AUTHORISATION_ORDER_IDENTIFIER_FAILED,
+ #[strum(serialize = "090004")]
+ EBICS_INVALID_ORDER_DATA_FORMAT,
+ #[strum(serialize = "090005")]
+ EBICS_NO_DOWNLOAD_DATA_AVAILABLE,
+ #[strum(serialize = "090006")]
+ EBICS_UNSUPPORTED_REQUEST_FOR_ORDER_INSTANCE,
+
+ // --- 09: Transaction Administration ---
+ #[strum(serialize = "091002")]
+ EBICS_INVALID_USER_OR_USER_STATE,
+ #[strum(serialize = "091003")]
+ EBICS_USER_UNKNOWN,
+ #[strum(serialize = "091004")]
+ EBICS_INVALID_USER_STATE,
+ #[strum(serialize = "091005")]
+ EBICS_INVALID_ORDER_TYPE,
+ #[strum(serialize = "091006")]
+ EBICS_UNSUPPORTED_ORDER_TYPE,
+ #[strum(serialize = "091007")]
+ EBICS_DISTRIBUTED_SIGNATURE_AUTHORISATION_FAILED,
+ #[strum(serialize = "091008")]
+ EBICS_BANK_PUBKEY_UPDATE_REQUIRED,
+ #[strum(serialize = "091009")]
+ EBICS_SEGMENT_SIZE_EXCEEDED,
+ #[strum(serialize = "091010")]
+ EBICS_INVALID_XML,
+ #[strum(serialize = "091011")]
+ EBICS_INVALID_HOST_ID,
+
+ // --- 09: Transaction Processing ---
+ #[strum(serialize = "091101")]
+ EBICS_TX_UNKNOWN_TXID,
+ #[strum(serialize = "091102")]
+ EBICS_TX_ABORT,
+ #[strum(serialize = "091103")]
+ EBICS_TX_MESSAGE_REPLAY,
+ #[strum(serialize = "091104")]
+ EBICS_TX_SEGMENT_NUMBER_EXCEEDED,
+ #[strum(serialize = "091105")]
+ EBICS_RECOVERY_NOT_SUPPORTED,
+ #[strum(serialize = "091111")]
+ EBICS_INVALID_SIGNATURE_FILE_FORMAT,
+ #[strum(serialize = "091112")]
+ EBICS_INVALID_ORDER_PARAMS,
+ #[strum(serialize = "091113")]
+ EBICS_INVALID_REQUEST_CONTENT,
+ #[strum(serialize = "091114")]
+ EBICS_ORDERID_UNKNOWN,
+ #[strum(serialize = "091115")]
+ EBICS_ORDERID_ALREADY_FINAL,
+ #[strum(serialize = "091116")]
+ EBICS_PROCESSING_ERROR,
+ #[strum(serialize = "091117")]
+ EBICS_MAX_ORDER_DATA_SIZE_EXCEEDED,
+ #[strum(serialize = "091118")]
+ EBICS_MAX_SEGMENTS_EXCEEDED,
+ #[strum(serialize = "091119")]
+ EBICS_MAX_TRANSACTIONS_EXCEEDED,
+ #[strum(serialize = "091120")]
+ EBICS_PARTNER_ID_MISMATCH,
+ #[strum(serialize = "091121")]
+ EBICS_INCOMPATIBLE_ORDER_ATTRIBUTE,
+ #[strum(serialize = "091122")]
+ EBICS_ORDER_ALREADY_EXISTS,
+
+ // --- 09: Key Management (X.509 & Keys) ---
+ #[strum(serialize = "091201")]
+ EBICS_KEYMGMT_UNSUPPORTED_VERSION_SIGNATURE,
+ #[strum(serialize = "091202")]
+ EBICS_KEYMGMT_UNSUPPORTED_VERSION_AUTHENTICATION,
+ #[strum(serialize = "091203")]
+ EBICS_KEYMGMT_UNSUPPORTED_VERSION_ENCRYPTION,
+ #[strum(serialize = "091204")]
+ EBICS_KEYMGMT_KEYLENGTH_ERROR_SIGNATURE,
+ #[strum(serialize = "091205")]
+ EBICS_KEYMGMT_KEYLENGTH_ERROR_AUTHENTICATION,
+ #[strum(serialize = "091206")]
+ EBICS_KEYMGMT_KEYLENGTH_ERROR_ENCRYPTION,
+ #[strum(serialize = "091207")]
+ EBICS_KEYMGMT_NO_X509_SUPPORT,
+ #[strum(serialize = "091208")]
+ EBICS_X509_CERTIFICATE_EXPIRED,
+ #[strum(serialize = "091209")]
+ EBICS_X509_CERTIFICATE_NOT_VALID_YET,
+ #[strum(serialize = "091210")]
+ EBICS_X509_WRONG_KEY_USAGE,
+ #[strum(serialize = "091211")]
+ EBICS_X509_WRONG_ALGORITHM,
+ #[strum(serialize = "091212")]
+ EBICS_X509_INVALID_THUMBPRINT,
+ #[strum(serialize = "091213")]
+ EBICS_X509_CTL_INVALID,
+ #[strum(serialize = "091214")]
+ EBICS_X509_UNKNOWN_CERTIFICATE_AUTHORITY,
+ #[strum(serialize = "091215")]
+ EBICS_X509_INVALID_POLICY,
+ #[strum(serialize = "091216")]
+ EBICS_X509_INVALID_BASIC_CONSTRAINTS,
+ #[strum(serialize = "091217")]
+ EBICS_ONLY_X509_SUPPORT,
+ #[strum(serialize = "091218")]
+ EBICS_KEYMGMT_DUPLICATE_KEY,
+ #[strum(serialize = "091219")]
+ EBICS_CERTIFICATES_VALIDATION_ERROR,
+
+ // --- 09: Pre-verification / Signature Logic ---
+ #[strum(serialize = "091301")]
+ EBICS_SIGNATURE_VERIFICATION_FAILED,
+ #[strum(serialize = "091302")]
+ EBICS_ACCOUNT_AUTHORISATION_FAILED,
+ #[strum(serialize = "091303")]
+ EBICS_AMOUNT_CHECK_FAILED,
+ #[strum(serialize = "091304")]
+ EBICS_SIGNER_UNKNOWN,
+ #[strum(serialize = "091305")]
+ EBICS_INVALID_SIGNER_STATE,
+ #[strum(serialize = "091306")]
+ EBICS_DUPLICATE_SIGNATURE,
+}
+
+impl EbicsReturnCode {
+ /// Automatically classifies the severity/kind based on standard EBICS prefixes.
+ pub fn kind(&self) -> EbicsKind {
+ match &self.as_ref()[0..2] {
+ "00" => EbicsKind::Information,
+ "01" => EbicsKind::Note,
+ "03" => EbicsKind::Warning,
+ "06" => EbicsKind::RecoverableError,
+ "09" => EbicsKind::NonRecoverableError,
+ _ => unreachable!("Internal parser mapping error"),
+ }
+ }
+
+ pub fn is_error(&self) -> bool {
+ matches!(
+ self.kind(),
+ EbicsKind::RecoverableError | EbicsKind::NonRecoverableError
+ )
+ }
+}
diff --git a/src/keys.rs b/src/keys.rs
@@ -0,0 +1,112 @@
+/*
+* This file is part of LibEuFin.
+* Copyright (C) 2026 Taler Systems S.A.
+
+* LibEuFin is free software; you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation; either version 3, or
+* (at your option) any later version.
+
+* LibEuFin is distributed in the hope that it will be useful, but
+* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
+* Public License for more details.
+
+* You should have received a copy of the GNU Affero General Public
+* License along with LibEuFin; see the file COPYING. If not, see
+* <http://www.gnu.org/licenses/>
+*/
+
+use std::borrow::Cow;
+
+use aws_lc_rs::{
+ encoding::AsDer,
+ rsa::{PrivateDecryptingKey, PublicEncryptingKey},
+ signature::RsaKeyPair,
+};
+use serde::{Deserialize, Deserializer, Serialize, Serializer};
+use taler_common::{
+ json_file,
+ types::base32::{self},
+};
+
+use crate::config::EbicsKeysCfg;
+
+#[derive(Debug, serde::Deserialize)]
+pub struct ClientPriKeysFile {
+ #[serde(serialize_with = "ser_der", deserialize_with = "de_ras_sign_base32")]
+ pub signature_private_key: RsaKeyPair,
+ #[serde(serialize_with = "ser_der", deserialize_with = "de_ras_priv_base32")]
+ pub encryption_private_key: PrivateDecryptingKey,
+ #[serde(serialize_with = "ser_der", deserialize_with = "de_ras_sign_base32")]
+ pub authentication_private_key: RsaKeyPair,
+ pub submitted_ini: bool,
+ pub submitted_hia: bool,
+}
+
+#[derive(Debug, serde::Deserialize)]
+pub struct BankPubKeysFile {
+ #[serde(serialize_with = "ser_der", deserialize_with = "de_ras_pub_base32")]
+ pub bank_encryption_public_key: PublicEncryptingKey,
+ #[serde(serialize_with = "ser_der", 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>
+where
+ D: AsRef<[u8]>,
+ K: AsDer<D>,
+ 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)
+}
+
+pub fn de_ras_priv_base32<'de, D>(deserializer: D) -> Result<PrivateDecryptingKey, D::Error>
+where
+ D: Deserializer<'de>,
+{
+ let base32 = Cow::<str>::deserialize(deserializer)?;
+ let der =
+ base32::decode(base32.as_bytes()).map_err(|e| serde::de::Error::custom(e.to_string()))?;
+ let key = PrivateDecryptingKey::from_pkcs8(&der)
+ .map_err(|e| serde::de::Error::custom(e.to_string()))?;
+ Ok(key)
+}
+
+pub fn de_ras_pub_base32<'de, D>(deserializer: D) -> Result<PublicEncryptingKey, D::Error>
+where
+ D: Deserializer<'de>,
+{
+ let base32 = Cow::<str>::deserialize(deserializer)?;
+ let der =
+ base32::decode(base32.as_bytes()).map_err(|e| serde::de::Error::custom(e.to_string()))?;
+ let key =
+ PublicEncryptingKey::from_der(&der).map_err(|e| serde::de::Error::custom(e.to_string()))?;
+ Ok(key)
+}
+
+pub fn de_ras_sign_base32<'de, D>(deserializer: D) -> Result<RsaKeyPair, D::Error>
+where
+ D: Deserializer<'de>,
+{
+ let base32 = Cow::<str>::deserialize(deserializer)?;
+ let der =
+ base32::decode(base32.as_bytes()).map_err(|e| serde::de::Error::custom(e.to_string()))?;
+ let key = RsaKeyPair::from_pkcs8(&der).map_err(|e| serde::de::Error::custom(e.to_string()))?;
+ Ok(key)
+}
+
+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
+ Ok((client_keys, bank_keys))
+}
diff --git a/src/main.rs b/src/main.rs
@@ -1,22 +1,21 @@
/*
- * This file is part of LibEuFin.
- * Copyright (C) 2026 Taler Systems S.A.
+* This file is part of LibEuFin.
+* Copyright (C) 2026 Taler Systems S.A.
- * LibEuFin is free software; you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation; either version 3, or
- * (at your option) any later version.
+* LibEuFin is free software; you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation; either version 3, or
+* (at your option) any later version.
- * LibEuFin is distributed in the hope that it will be useful, but
- * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
- * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
- * Public License for more details.
-
- * You should have received a copy of the GNU Affero General Public
- * License along with LibEuFin; see the file COPYING. If not, see
- * <http://www.gnu.org/licenses/>
- */
+* LibEuFin is distributed in the hope that it will be useful, but
+* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
+* Public License for more details.
+* You should have received a copy of the GNU Affero General Public
+* License along with LibEuFin; see the file COPYING. If not, see
+* <http://www.gnu.org/licenses/>
+*/
use std::{
collections::{BTreeMap, HashSet},
@@ -29,11 +28,6 @@ use aws_lc_rs::{
};
use base64::{Engine, prelude::BASE64_STANDARD};
use clap::Parser;
-use quick_xml::{
- Writer,
- events::{BytesDecl, BytesEnd, BytesStart, BytesText, Event},
-};
-use rand::Rng;
use reqwest::{
Client, StatusCode,
header::{CONTENT_TYPE, HeaderValue},
@@ -43,11 +37,16 @@ use taler_build::long_version;
use taler_common::{CommonArgs, config::parser::ConfigSource, taler_main};
use tracing::{debug, info};
-use crate::keys::ClientPriKeysFile;
use crate::{
config::{EbicsHostCfg, NexusCfg},
ebics_code::EbicsReturnCode,
};
+use crate::{keys::ClientPriKeysFile, xml::XmlReader};
+
+pub mod config;
+pub mod ebics_code;
+pub mod keys;
+pub mod xml;
const SOURCE: ConfigSource = ConfigSource::new("libeufin", "libeufin-nexus", "libeufin-nexus");
@@ -98,13 +97,13 @@ pub async fn ebics_setup(http: &Client, cfg: &NexusCfg) -> anyhow::Result<()> {
pub async fn hev(http: &Client, cfg: &EbicsHostCfg) -> anyhow::Result<Vec<VersionNumber>> {
let phase = "HEV";
info!(target: "ebics", "Doing administrative request {phase}");
- let msg = XmlBuilder::to_bytes(
- "ebicsHEVRequest",
- &[("xmlns", "http://www.ebics.org/H000")],
- |w| w.el_txt("HostID", &cfg.host_id),
+ 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?;
- XmlDestructor::parse(&res, "ebicsHEVResponse", |root| {
+ XmlReader::parse(&res, "ebicsHEVResponse", |root| {
let technical_code = root.one("SystemReturnCode", |n| {
n.one("ReturnCode", |n| n.text().parse().unwrap())
});
@@ -128,8 +127,7 @@ 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(xml: Vec<u8>, key: &RsaKeyPair) -> String {
- let mut xml = std::string::String::from_utf8(xml).unwrap();
+pub fn sign_ebics(mut xml: String, key: &RsaKeyPair) -> String {
let doc = Document::parse(&xml).unwrap();
let digest = digest_authenticated(&doc);
@@ -157,7 +155,7 @@ pub fn sign_ebics(xml: Vec<u8>, key: &RsaKeyPair) -> String {
let sig_block = 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></AuthSignature>";
+ let pattern = "<AuthSignature/>";
let start = xml.find(pattern).unwrap();
xml.replace_range(start..start + pattern.len(), &sig_block);
xml
@@ -225,37 +223,37 @@ pub async fn hpb(
keys: &ClientPriKeysFile,
) -> anyhow::Result<String> {
let phase = "HPB";
- let nonce: u128 = rand::thread_rng().r#gen();
+ let nonce: u128 = rand::random();
info!(target: "ebics", "Doing administrative request {phase}");
- let msg = XmlBuilder::to_bytes(
- "ebicsNoPubKeyDigestsRequest",
- &[
- ("xmlns", "urn:org:ebics:H005"),
- ("xmlns:ds", "http://www.w3.org/2000/09/xmldsig#"),
- ("Revision", "1"),
- ("Version", "H005"),
- ],
- |w| {
- w.el("header", &[("authenticate", "true")], |w| {
- w.el("static", &[], |w| {
- w.el_txt("HostID", &cfg.host_id);
- w.el_txt("Nonce", &format!("{:032x}", nonce));
- w.el_txt("Timestamp", &jiff::Timestamp::now().to_string());
- w.el_txt("PartnerID", &cfg.partner_id);
- w.el_txt("UserID", &cfg.user_id);
- w.el("OrderDetails", &[], |w| w.el_txt("AdminOrderType", "HPB"));
- w.el_txt("SecurityMedium", "0000");
- });
- w.el("mutable", &[], |_| {});
- });
- w.el("AuthSignature", &[], |_| {});
- w.el("body", &[], |_| {});
- },
+ let msg = xml_build!(
+ "ebicsNoPubKeyDigestsRequest"
+ ("xmlns": "urn:org:ebics:H005")
+ ("xmlns:ds": "http://www.w3.org/2000/09/xmldsig#")
+ ("Revision": "1")
+ ("Version": "H005")
+ {
+ "header" ("authenticate": "true") {
+ "static" {
+ "HostID": &cfg.host_id,
+ "Nonce": &format_args!("{:032x}", nonce),
+ "Timestamp": &jiff::Timestamp::now(),
+ "PartnerID": &cfg.partner_id,
+ "UserID": &cfg.user_id,
+ "OrderDetails" {
+ "AdminOrderType": "HPB"
+ },
+ "SecurityMedium": "0000"
+ },
+ "mutable"
+ },
+ "AuthSignature",
+ "body"
+ }
);
let signed = sign_ebics(msg, &keys.authentication_private_key);
- let res = post_to_bank(cfg.base_url.as_str(), http, signed.into_bytes()).await?;
+ let res = post_to_bank(cfg.base_url.as_str(), http, signed).await?;
println!("{res}");
- XmlDestructor::parse(&res, "ebicsKeyManagementResponse", |root| {
+ XmlReader::parse(&res, "ebicsKeyManagementResponse", |root| {
let technical_code = root.one("header", |n| {
// Check signed
n.one("mutable", |n| {
@@ -281,7 +279,7 @@ pub enum EbicsError {
Network(#[from] reqwest::Error),
}
-async fn post_to_bank(url: &str, client: &Client, msg: Vec<u8>) -> anyhow::Result<String> {
+async fn post_to_bank(url: &str, client: &Client, msg: String) -> anyhow::Result<String> {
let res = client
.post(url)
.header(CONTENT_TYPE, HeaderValue::from_static("application/xml"))
@@ -315,129 +313,6 @@ impl<T> EbicsResponse<T> {
}
}
-struct XmlBuilder {
- writer: Writer<Vec<u8>>,
-}
-impl XmlBuilder {
- fn to_bytes<F>(root: &str, attrs: &[(&str, &str)], f: F) -> Vec<u8>
- where
- F: FnOnce(&mut Self),
- {
- let mut writer = Writer::new(Vec::new());
- writer
- .write_event(Event::Decl(BytesDecl::new(
- "1.0",
- Some("UTF-8"),
- Some("yes"),
- )))
- .unwrap();
- let mut tmp = Self { writer };
- tmp.el(root, attrs, f);
- tmp.writer.into_inner()
- }
-
- fn el<F>(&mut self, name: &str, attrs: &[(&str, &str)], f: F)
- where
- F: FnOnce(&mut Self),
- {
- let mut elem = BytesStart::new(name);
- for (key, value) in attrs {
- elem.push_attribute((*key, *value));
- }
- self.writer.write_event(Event::Start(elem)).unwrap();
- f(self);
- self.writer
- .write_event(Event::End(BytesEnd::new(name)))
- .unwrap();
- }
-
- fn el_txt(&mut self, name: &str, content: &str) {
- self.el(name, &[], |w| w.text(content))
- }
-
- fn text(&mut self, content: &str) {
- self.writer
- .write_event(Event::Text(BytesText::new(content)))
- .unwrap();
- }
-}
-
-struct XmlDestructor<'node, 'input> {
- node: roxmltree::Node<'node, 'input>,
-}
-
-impl XmlDestructor<'_, '_> {
- pub fn parse<F, R>(raw: &str, tag: &str, f: F) -> R
- where
- R: 'static,
- F: for<'local> FnOnce(XmlDestructor<'local, '_>) -> R,
- {
- let xml = Document::parse(raw).unwrap();
- Self::parse_doc(xml, tag, f)
- }
-
- pub fn parse_doc<F, R>(xml: Document, tag: &str, f: F) -> R
- where
- R: 'static,
- F: for<'local> FnOnce(XmlDestructor<'local, '_>) -> R,
- {
- let root = xml.root_element();
- assert!(
- root.has_tag_name(tag),
- "{} != {tag}",
- root.tag_name().name()
- );
- let node = XmlDestructor { node: root };
- let res = f(node);
- drop(xml);
- res
- }
-
- pub fn attr(&self, name: &str) -> &str {
- self.node.attribute(name).unwrap()
- }
-
- pub fn one<F, R>(&self, tag: &str, f: F) -> R
- where
- R: 'static,
- F: for<'local> FnOnce(XmlDestructor<'local, '_>) -> R,
- {
- let mut iter = self
- .node
- .children()
- .filter(|children| children.has_tag_name(tag));
- let Some(node) = iter.next() else {
- panic!(
- "expected unique '{}.{tag}', got none",
- self.node.tag_name().name()
- );
- };
- if iter.next().is_some() {
- let count = iter.count() + 2;
- panic!(
- "expected unique '{}.{tag}', got {count}",
- self.node.tag_name().name()
- );
- }
- f(XmlDestructor { 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(XmlDestructor<'local, '_>) -> R + 'static,
- {
- self.node
- .children()
- .filter(move |children| children.has_tag_name(tag))
- .map(move |children| f(XmlDestructor { node: children }))
- }
-
- pub fn text(&self) -> &str {
- self.node.text().unwrap_or_default()
- }
-}
-
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VersionNumber {
pub number: String,
@@ -451,374 +326,6 @@ impl Display for VersionNumber {
}
}
-pub mod keys {
- use std::borrow::Cow;
-
- use aws_lc_rs::{
- encoding::AsDer,
- rsa::{PrivateDecryptingKey, PublicEncryptingKey},
- signature::RsaKeyPair,
- };
- use serde::{Deserialize, Deserializer, Serialize, Serializer};
- use taler_common::{
- json_file,
- types::base32::{self},
- };
-
- use crate::config::EbicsKeysCfg;
-
- #[derive(Debug, serde::Deserialize)]
- pub struct ClientPriKeysFile {
- #[serde(serialize_with = "ser_der", deserialize_with = "de_ras_sign_base32")]
- pub signature_private_key: RsaKeyPair,
- #[serde(serialize_with = "ser_der", deserialize_with = "de_ras_priv_base32")]
- pub encryption_private_key: PrivateDecryptingKey,
- #[serde(serialize_with = "ser_der", deserialize_with = "de_ras_sign_base32")]
- pub authentication_private_key: RsaKeyPair,
- pub submitted_ini: bool,
- pub submitted_hia: bool,
- }
-
- #[derive(Debug, serde::Deserialize)]
- pub struct BankPubKeysFile {
- #[serde(serialize_with = "ser_der", deserialize_with = "de_ras_pub_base32")]
- pub bank_encryption_public_key: PublicEncryptingKey,
- #[serde(serialize_with = "ser_der", 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>
- where
- D: AsRef<[u8]>,
- K: AsDer<D>,
- 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)
- }
-
- pub fn de_ras_priv_base32<'de, D>(deserializer: D) -> Result<PrivateDecryptingKey, D::Error>
- where
- D: Deserializer<'de>,
- {
- let base32 = Cow::<str>::deserialize(deserializer)?;
- let der = base32::decode(base32.as_bytes())
- .map_err(|e| serde::de::Error::custom(e.to_string()))?;
- let key = PrivateDecryptingKey::from_pkcs8(&der)
- .map_err(|e| serde::de::Error::custom(e.to_string()))?;
- Ok(key)
- }
-
- pub fn de_ras_pub_base32<'de, D>(deserializer: D) -> Result<PublicEncryptingKey, D::Error>
- where
- D: Deserializer<'de>,
- {
- let base32 = Cow::<str>::deserialize(deserializer)?;
- let der = base32::decode(base32.as_bytes())
- .map_err(|e| serde::de::Error::custom(e.to_string()))?;
- let key = PublicEncryptingKey::from_der(&der)
- .map_err(|e| serde::de::Error::custom(e.to_string()))?;
- Ok(key)
- }
-
- pub fn de_ras_sign_base32<'de, D>(deserializer: D) -> Result<RsaKeyPair, D::Error>
- where
- D: Deserializer<'de>,
- {
- let base32 = Cow::<str>::deserialize(deserializer)?;
- let der = base32::decode(base32.as_bytes())
- .map_err(|e| serde::de::Error::custom(e.to_string()))?;
- let key =
- RsaKeyPair::from_pkcs8(&der).map_err(|e| serde::de::Error::custom(e.to_string()))?;
- Ok(key)
- }
-
- 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
- Ok((client_keys, bank_keys))
- }
-}
-
-pub mod config {
-
- use std::cell::OnceCell;
-
- use taler_common::config::{Config, ValueErr};
-
- pub struct EbicsKeysCfg {
- pub bank_pub_keys_path: String,
- pub client_priv_keys_path: String,
- }
-
- impl EbicsKeysCfg {
- pub fn parse(cfg: &Config) -> Result<Self, ValueErr> {
- let sect = cfg.section("nexus-ebics");
- Ok(Self {
- bank_pub_keys_path: sect.path("bank_public_keys_file").require()?,
- client_priv_keys_path: sect.path("client_private_keys_file").require()?,
- })
- }
- }
-
- pub struct EbicsHostCfg {
- pub base_url: url::Url,
- pub host_id: String,
- pub user_id: String,
- pub partner_id: String,
- }
-
- impl EbicsHostCfg {
- pub fn parse(cfg: &Config) -> Result<Self, ValueErr> {
- let sect = cfg.section("nexus-ebics");
- Ok(Self {
- base_url: sect.url("host_base_url").require()?,
- host_id: sect.str("host_id").require()?,
- user_id: sect.str("user_id").require()?,
- partner_id: sect.str("partner_id").require()?,
- })
- }
- }
-
- pub struct NexusCfg {
- pub cfg: Config,
- pub keys: OnceCell<EbicsKeysCfg>,
- pub host: OnceCell<EbicsHostCfg>,
- }
-
- impl NexusCfg {
- pub fn parse(cfg: Config) -> Result<Self, ValueErr> {
- Ok(Self {
- cfg,
- keys: OnceCell::new(),
- host: OnceCell::new(),
- })
- }
-
- pub fn keys(&self) -> Result<&EbicsKeysCfg, ValueErr> {
- // TODO use get_or_try_init when stable
- if let Some(keys) = self.keys.get() {
- return Ok(keys);
- }
- let keys = EbicsKeysCfg::parse(&self.cfg)?;
- self.keys.set(keys).ok();
- Ok(self.keys.get().unwrap())
- }
-
- pub fn host(&self) -> Result<&EbicsHostCfg, ValueErr> {
- // TODO use get_or_try_init when stable
- if let Some(host) = self.host.get() {
- return Ok(host);
- }
- let host = EbicsHostCfg::parse(&self.cfg)?;
- self.host.set(host).ok();
- Ok(self.host.get().unwrap())
- }
- }
-}
-
-pub mod ebics_code {
- /// EBICS Error Class (First two digits of the return code)
- #[derive(Debug, Clone, Copy, PartialEq, Eq)]
- pub enum EbicsKind {
- /// 00 - Success / General Information
- Information,
- /// 01 - Positive notification, but action might be required
- Note,
- /// 03 - Warning
- Warning,
- /// 06 - Recoverable Error
- RecoverableError,
- /// 09 - Non-recoverable Error
- NonRecoverableError,
- }
-
- use strum_macros::{AsRefStr, Display, EnumString};
-
- #[derive(Debug, Clone, Copy, PartialEq, Eq, EnumString, AsRefStr, Display)]
- #[allow(non_camel_case_types)]
- pub enum EbicsReturnCode {
- // --- 00: Information ---
- #[strum(serialize = "000000")]
- EBICS_OK,
-
- // --- 01: Notes ---
- #[strum(serialize = "011000")]
- EBICS_DOWNLOAD_POSTPROCESS_DONE,
- #[strum(serialize = "011001")]
- EBICS_DOWNLOAD_POSTPROCESS_SKIPPED,
- #[strum(serialize = "011101")]
- EBICS_TX_SEGMENT_NUMBER_UNDERRUN,
- #[strum(serialize = "011301")]
- EBICS_NO_ONLINE_CHECKS,
-
- // --- 03: Warnings ---
- #[strum(serialize = "031001")]
- EBICS_ORDER_PARAMS_IGNORED,
-
- // --- 06: Technical Errors (Recoverable) ---
- #[strum(serialize = "061001")]
- EBICS_AUTHENTICATION_FAILED,
- #[strum(serialize = "061002")]
- EBICS_INVALID_REQUEST,
- #[strum(serialize = "061099")]
- EBICS_INTERNAL_ERROR,
- #[strum(serialize = "061101")]
- EBICS_TX_RECOVERY_SYNC,
-
- // --- 09: Business Errors (Non-Recoverable) ---
- #[strum(serialize = "090003")]
- EBICS_AUTHORISATION_ORDER_IDENTIFIER_FAILED,
- #[strum(serialize = "090004")]
- EBICS_INVALID_ORDER_DATA_FORMAT,
- #[strum(serialize = "090005")]
- EBICS_NO_DOWNLOAD_DATA_AVAILABLE,
- #[strum(serialize = "090006")]
- EBICS_UNSUPPORTED_REQUEST_FOR_ORDER_INSTANCE,
-
- // --- 09: Transaction Administration ---
- #[strum(serialize = "091002")]
- EBICS_INVALID_USER_OR_USER_STATE,
- #[strum(serialize = "091003")]
- EBICS_USER_UNKNOWN,
- #[strum(serialize = "091004")]
- EBICS_INVALID_USER_STATE,
- #[strum(serialize = "091005")]
- EBICS_INVALID_ORDER_TYPE,
- #[strum(serialize = "091006")]
- EBICS_UNSUPPORTED_ORDER_TYPE,
- #[strum(serialize = "091007")]
- EBICS_DISTRIBUTED_SIGNATURE_AUTHORISATION_FAILED,
- #[strum(serialize = "091008")]
- EBICS_BANK_PUBKEY_UPDATE_REQUIRED,
- #[strum(serialize = "091009")]
- EBICS_SEGMENT_SIZE_EXCEEDED,
- #[strum(serialize = "091010")]
- EBICS_INVALID_XML,
- #[strum(serialize = "091011")]
- EBICS_INVALID_HOST_ID,
-
- // --- 09: Transaction Processing ---
- #[strum(serialize = "091101")]
- EBICS_TX_UNKNOWN_TXID,
- #[strum(serialize = "091102")]
- EBICS_TX_ABORT,
- #[strum(serialize = "091103")]
- EBICS_TX_MESSAGE_REPLAY,
- #[strum(serialize = "091104")]
- EBICS_TX_SEGMENT_NUMBER_EXCEEDED,
- #[strum(serialize = "091105")]
- EBICS_RECOVERY_NOT_SUPPORTED,
- #[strum(serialize = "091111")]
- EBICS_INVALID_SIGNATURE_FILE_FORMAT,
- #[strum(serialize = "091112")]
- EBICS_INVALID_ORDER_PARAMS,
- #[strum(serialize = "091113")]
- EBICS_INVALID_REQUEST_CONTENT,
- #[strum(serialize = "091114")]
- EBICS_ORDERID_UNKNOWN,
- #[strum(serialize = "091115")]
- EBICS_ORDERID_ALREADY_FINAL,
- #[strum(serialize = "091116")]
- EBICS_PROCESSING_ERROR,
- #[strum(serialize = "091117")]
- EBICS_MAX_ORDER_DATA_SIZE_EXCEEDED,
- #[strum(serialize = "091118")]
- EBICS_MAX_SEGMENTS_EXCEEDED,
- #[strum(serialize = "091119")]
- EBICS_MAX_TRANSACTIONS_EXCEEDED,
- #[strum(serialize = "091120")]
- EBICS_PARTNER_ID_MISMATCH,
- #[strum(serialize = "091121")]
- EBICS_INCOMPATIBLE_ORDER_ATTRIBUTE,
- #[strum(serialize = "091122")]
- EBICS_ORDER_ALREADY_EXISTS,
-
- // --- 09: Key Management (X.509 & Keys) ---
- #[strum(serialize = "091201")]
- EBICS_KEYMGMT_UNSUPPORTED_VERSION_SIGNATURE,
- #[strum(serialize = "091202")]
- EBICS_KEYMGMT_UNSUPPORTED_VERSION_AUTHENTICATION,
- #[strum(serialize = "091203")]
- EBICS_KEYMGMT_UNSUPPORTED_VERSION_ENCRYPTION,
- #[strum(serialize = "091204")]
- EBICS_KEYMGMT_KEYLENGTH_ERROR_SIGNATURE,
- #[strum(serialize = "091205")]
- EBICS_KEYMGMT_KEYLENGTH_ERROR_AUTHENTICATION,
- #[strum(serialize = "091206")]
- EBICS_KEYMGMT_KEYLENGTH_ERROR_ENCRYPTION,
- #[strum(serialize = "091207")]
- EBICS_KEYMGMT_NO_X509_SUPPORT,
- #[strum(serialize = "091208")]
- EBICS_X509_CERTIFICATE_EXPIRED,
- #[strum(serialize = "091209")]
- EBICS_X509_CERTIFICATE_NOT_VALID_YET,
- #[strum(serialize = "091210")]
- EBICS_X509_WRONG_KEY_USAGE,
- #[strum(serialize = "091211")]
- EBICS_X509_WRONG_ALGORITHM,
- #[strum(serialize = "091212")]
- EBICS_X509_INVALID_THUMBPRINT,
- #[strum(serialize = "091213")]
- EBICS_X509_CTL_INVALID,
- #[strum(serialize = "091214")]
- EBICS_X509_UNKNOWN_CERTIFICATE_AUTHORITY,
- #[strum(serialize = "091215")]
- EBICS_X509_INVALID_POLICY,
- #[strum(serialize = "091216")]
- EBICS_X509_INVALID_BASIC_CONSTRAINTS,
- #[strum(serialize = "091217")]
- EBICS_ONLY_X509_SUPPORT,
- #[strum(serialize = "091218")]
- EBICS_KEYMGMT_DUPLICATE_KEY,
- #[strum(serialize = "091219")]
- EBICS_CERTIFICATES_VALIDATION_ERROR,
-
- // --- 09: Pre-verification / Signature Logic ---
- #[strum(serialize = "091301")]
- EBICS_SIGNATURE_VERIFICATION_FAILED,
- #[strum(serialize = "091302")]
- EBICS_ACCOUNT_AUTHORISATION_FAILED,
- #[strum(serialize = "091303")]
- EBICS_AMOUNT_CHECK_FAILED,
- #[strum(serialize = "091304")]
- EBICS_SIGNER_UNKNOWN,
- #[strum(serialize = "091305")]
- EBICS_INVALID_SIGNER_STATE,
- #[strum(serialize = "091306")]
- EBICS_DUPLICATE_SIGNATURE,
- }
-
- impl EbicsReturnCode {
- /// Automatically classifies the severity/kind based on standard EBICS prefixes.
- pub fn kind(&self) -> EbicsKind {
- match &self.as_ref()[0..2] {
- "00" => EbicsKind::Information,
- "01" => EbicsKind::Note,
- "03" => EbicsKind::Warning,
- "06" => EbicsKind::RecoverableError,
- "09" => EbicsKind::NonRecoverableError,
- _ => unreachable!("Internal parser mapping error"),
- }
- }
-
- pub fn is_error(&self) -> bool {
- matches!(
- self.kind(),
- EbicsKind::RecoverableError | EbicsKind::NonRecoverableError
- )
- }
- }
-}
-
// C14N requires specific escaping for Text nodes
fn escape_text(text: &str) -> String {
text.replace('&', "&")
@@ -926,21 +433,30 @@ pub fn digest_authenticated(doc: &Document) -> Digest {
aws_lc_rs::digest::digest(&aws_lc_rs::digest::SHA256, out.as_bytes())
}
-#[test]
-fn canonicalize() {
- let xml = 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>BC750C641453F93EBF236A9B25F6B70A</Nonce><Timestamp>2026-02-14T18:10:31.125926573Z</Timestamp><PartnerID>PFC00563</PartnerID><UserID>PFC00563</UserID><OrderDetails><AdminOrderType>HPB</AdminOrderType></OrderDetails><SecurityMedium>0000</SecurityMedium></static><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>ws6QyiLpZVu+CbpqlhQ11PGwCdHSgmtmL7FvwrqZqmU=</ds:DigestValue></ds:Reference></ds:SignedInfo><ds:SignatureValue>RvVxaDRsgtyZITf3C/UfmWGLERFRWZFxbwb5yhoJBOu5f6KsythhNvF28gznE1VN7E+5oP+nRkba
-hUBX3Y+0PahH+XeOnPGuUYdiOy0/FydtG2E1oQELNRojWhxxJMKPpN6jO9Y3j8QmS31oAWUiLjgA
+#[cfg(test)]
+mod test {
+ use aws_lc_rs::signature::RsaKeyPair;
+ use base64::{Engine as _, prelude::BASE64_STANDARD};
+ use roxmltree::Document;
+ use taler_common::types::base32;
+
+ use crate::{digest_authenticated, sign_ebics};
+
+ #[test]
+ fn canonicalize() {
+ let xml = 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>BC750C641453F93EBF236A9B25F6B70A</Nonce><Timestamp>2026-02-14T18:10:31.125926573Z</Timestamp><PartnerID>PFC00563</PartnerID><UserID>PFC00563</UserID><OrderDetails><AdminOrderType>HPB</AdminOrderType></OrderDetails><SecurityMedium>0000</SecurityMedium></static><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>ws6QyiLpZVu+CbpqlhQ11PGwCdHSgmtmL7FvwrqZqmU=</ds:DigestValue></ds:Reference></ds:SignedInfo><ds:SignatureValue>RvVxaDRsgtyZITf3C/UfmWGLERFRWZFxbwb5yhoJBOu5f6KsythhNvF28gznE1VN7E+5oP+nRkba
+hUBX3Y+0PahH+XeOnPGuUYdiOy0/FydtG2E1oQELNRojWhxxJMKPpN6jO9Y3j8QmS31oAWUiLjgA
S//AU924Wh0rIwA8L3riSzGZDAgf6c0Wg+loPk581AD9QtzMiDi6onLVQvlKYtlVJNheTIreG54i
a6vPTIqlMWB5iA5ZqoE6zO+VWr4sxTPswlHD29dDar7B4YJ1vYLLTzFHc0yJaDjWaURQNr0mDqUC
kJMyqsK/0dKW+4n3JgWuVGK8YdoUuvmYooqgFw==</ds:SignatureValue></AuthSignature><body/></ebicsNoPubKeyDigestsRequest>
"##;
- let doc = Document::parse(xml).unwrap();
- let res = digest_authenticated(&doc);
- let hex = BASE64_STANDARD.encode(res);
- assert_eq!(hex, "ws6QyiLpZVu+CbpqlhQ11PGwCdHSgmtmL7FvwrqZqmU=");
+ let doc = Document::parse(xml).unwrap();
+ let res = digest_authenticated(&doc);
+ let hex = BASE64_STANDARD.encode(res);
+ assert_eq!(hex, "ws6QyiLpZVu+CbpqlhQ11PGwCdHSgmtmL7FvwrqZqmU=");
- let xml = r##"<?xml version="1.0" encoding="UTF-8"?>
+ 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>
@@ -973,31 +489,33 @@ kJMyqsK/0dKW+4n3JgWuVGK8YdoUuvmYooqgFw==</ds:SignatureValue></AuthSignature><bod
</body>
</ebicsResponse>
"##;
- let doc = Document::parse(xml).unwrap();
- let res = digest_authenticated(&doc);
- assert_eq!(
- BASE64_STANDARD.encode(res),
- "WJz3HUYjV3HMK0Cy+69XCnAcmiD21mJ5BRiQPwsi1VI="
- );
-}
-
-#[test]
-fn sign() {
- let key = "62109F820403038614N8CJ46YW6G20810M0090G4MWR84153080G00M2040G18GPPFA8VSJD6G0ENC3SH2ET37BXQ1RTRAX162GWVTW33BX14FBAC0N7DFJBKKNS0EJ4DY07TABNKDHGX0EX7XWV47P456NXX8DP33MVZ010X2F248GDXQK3WWYZKAYMA61KYNTJ4QD1BAZWES5GBA8F9WD9EM9WN9ZYQHFGNWVDQ2BEE54CQAGF82AFR0NJEJWFT4QKNVR8QB79VESG623W5NZPFQM1ZSJ20ZDYCJC7KJ1Q23TDJA0C1SY3KWRM97R60BZXKQ9Q9FM1AFQ8CAYDG0FJSQQM0D5W8QQENK79W8VZ0605A1FKYZYBFQX34FBFJKTCSDEZWSYDQM4HD9JF8F86HXW3F2GE9A7H7JHZG7441MJ91H24ND5M8YK4VXYAB7RX8JAXSS8K33BQXF5QBRR20C0G0082G80G0079GETRHX75MR929BDEYWFKTXE82F0QV71AHY3MKKQXNK545W4XSGHGZARZTH5TGABDTW2SJHKXQ787X2CQFY8ZDDHN5WEMXT5VMBZK5B3TJW5XM98GRREWWPA8AJPA8QZ30Q9RYX49228NHSAM8F2DEH40KAXMFT8HB1PDVCVQRQV5HKYBD1Z6Z1ZZZXXJ114X0E4X6R3FMVWQGANAKYRT2S75FKCG00C96EBM1B8RBZPBQZ7FVA9ZB8F64PYG4KRFHT4CYQZ5D6HP1K42PKYB7TA45SZKRDXE3PYTZE6XASJSP9H1EH1JM0ZPMC1Y2FBWPQ8SR2233J55HX6PXWSJ2ZJYTC8V9FM9F442SQM5EGNYK59RCSEGWMZ0WSF98WX4QVDX4CVN3E1GQPNH9K8ERG82G60G1M763Z4MZSJG1MJZQV32HYNMBEV26J8JKC6E53GWKY101RCB1JXN08H8P64P8QTDV9WRS3EQV30557E7QJAYG1K5A4D76DYTNE0GBC7KE5FD6S8ADSJV9XWVVQHVV1BRQVZ4ZWWSK5M9VEVZNRXXBJVZPKR26XEBT6ANVEBTSQ538K1BZQGBQTKWVMDFMG91A4ATXQM2B5J1MC183080RTHA7FVF7SN90B4XQ87GST3Z50H6T6CRQGR0PDDV51HGH1JE76AAWP1VCEWGASWZ2C9KVB8Z5GH71SCW0MF89A02NFNGVQ9D3QV3PMZQSGM6RC35DDBD33J8N5G2V2SN5MCNB3RA7SMJVVG5DG7QHCJ83QX11G5P8KHQ8MFEV69HGXSS7DTHBV71EWP78PPEMSXP7C7ASYZC20M1G02CCQEFM8PYF0M5DPZBEYG56RRD8JYK9PDADYXM7P1SGSZT2J07705TZNKF0Y34W9T28FZ340PRAA5HSDX8SP3EFSFMNV8RC109HKRW0ZP4WRE1E8QJVGS19CZVPAKMRQA17VF9H4HK3FVXYCA2B3FAZTMRVABA9D03P01BYNERVDEJMQ2173562N24FEBYB18EYF94WD3GVX82G60G15KVSJV527Q6723V93NANH5CYPN6H8JE8CTXXA030S1EEBEDT4KYEDZE1BVJ2A42GPCS60BA448VKT83A793QEW5A7EDKFCKWFQYPSZTSCBWRS4ZQTYG00Z5T2G4JMY6PWPS92D6YNJCYK0EGNNFF7QGDXXYWN33MM0296SQ1MK0R0F45EXAQT5S2Q39SXAA8KHR32A8BC0HG418300KMYBR5ZKNTX7SANSJB5SSYGP9RZVHN23RC1J29QRH5XFSMABMDA582GJH5JAE0D31AH5PTAHP04Y61KNCSKNC748N1PRN503VZY7EA1C4G75C0F13K04TE8RVP63K0TQ693E2XB23WSHYERKSZ6AKCTR3E15N1AF70HEKK13E4QCCY2JN896YEWWT9B8CVW50D8C87S75EK3G";
+ let doc = Document::parse(xml).unwrap();
+ let res = digest_authenticated(&doc);
+ assert_eq!(
+ BASE64_STANDARD.encode(res),
+ "WJz3HUYjV3HMK0Cy+69XCnAcmiD21mJ5BRiQPwsi1VI="
+ );
+ }
- let key: RsaKeyPair = 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></AuthSignature><body/></ebicsNoPubKeyDigestsRequest>"##;
- let xml = tmp.to_owned().into_bytes();
- let signed = sign_ebics(xml, &key);
- let doc = Document::parse(&signed).unwrap();
- let signature = doc
- .descendants()
- .find(|it| it.has_tag_name("SignatureValue"))
- .unwrap()
- .text()
- .unwrap();
- assert_eq!(
- signature,
- "eYyb1v/dGVOPndpMhXZlVQM2q9H9BJP77nYOWaa7jjoeLef7/8HjKIv8oq6Kaf6Z9mAfh/Pcip3a75gkdKpz7ocl1YdsaD+CcQkO1J/n4NwY821ccSh0Ahm2PBE168hyEMzPJrDeDtJrYqs+J/+nC8ek0hbo4/WPsH4UoxVu+ANsHR+BnQFQW3k9BFv+XKZbrBltIY62SN73tYwU8QzRtINJLzjhNB3T6S101n4CYwycXpL5b/oXXOUxxfDnn9EmIFt4DIgjxxqDYdQEBytULLORdkIdf563aw2wDaN12OQV2TB9gAs4Uu203FkUbmIagarMhbKKlqa1NkOteZ13Xw=="
- );
+ #[test]
+ fn sign() {
+ let key = "62109F820403038614N8CJ46YW6G20810M0090G4MWR84153080G00M2040G18GPPFA8VSJD6G0ENC3SH2ET37BXQ1RTRAX162GWVTW33BX14FBAC0N7DFJBKKNS0EJ4DY07TABNKDHGX0EX7XWV47P456NXX8DP33MVZ010X2F248GDXQK3WWYZKAYMA61KYNTJ4QD1BAZWES5GBA8F9WD9EM9WN9ZYQHFGNWVDQ2BEE54CQAGF82AFR0NJEJWFT4QKNVR8QB79VESG623W5NZPFQM1ZSJ20ZDYCJC7KJ1Q23TDJA0C1SY3KWRM97R60BZXKQ9Q9FM1AFQ8CAYDG0FJSQQM0D5W8QQENK79W8VZ0605A1FKYZYBFQX34FBFJKTCSDEZWSYDQM4HD9JF8F86HXW3F2GE9A7H7JHZG7441MJ91H24ND5M8YK4VXYAB7RX8JAXSS8K33BQXF5QBRR20C0G0082G80G0079GETRHX75MR929BDEYWFKTXE82F0QV71AHY3MKKQXNK545W4XSGHGZARZTH5TGABDTW2SJHKXQ787X2CQFY8ZDDHN5WEMXT5VMBZK5B3TJW5XM98GRREWWPA8AJPA8QZ30Q9RYX49228NHSAM8F2DEH40KAXMFT8HB1PDVCVQRQV5HKYBD1Z6Z1ZZZXXJ114X0E4X6R3FMVWQGANAKYRT2S75FKCG00C96EBM1B8RBZPBQZ7FVA9ZB8F64PYG4KRFHT4CYQZ5D6HP1K42PKYB7TA45SZKRDXE3PYTZE6XASJSP9H1EH1JM0ZPMC1Y2FBWPQ8SR2233J55HX6PXWSJ2ZJYTC8V9FM9F442SQM5EGNYK59RCSEGWMZ0WSF98WX4QVDX4CVN3E1GQPNH9K8ERG82G60G1M763Z4MZSJG1MJZQV32HYNMBEV26J8JKC6E53GWKY101RCB1JXN08H8P64P8QTDV9WRS3EQV30557E7QJAYG1K5A4D76DYTNE0GBC7KE5FD6S8ADSJV9XWVVQHVV1BRQVZ4ZWWSK5M9VEVZNRXXBJVZPKR26XEBT6ANVEBTSQ538K1BZQGBQTKWVMDFMG91A4ATXQM2B5J1MC183080RTHA7FVF7SN90B4XQ87GST3Z50H6T6CRQGR0PDDV51HGH1JE76AAWP1VCEWGASWZ2C9KVB8Z5GH71SCW0MF89A02NFNGVQ9D3QV3PMZQSGM6RC35DDBD33J8N5G2V2SN5MCNB3RA7SMJVVG5DG7QHCJ83QX11G5P8KHQ8MFEV69HGXSS7DTHBV71EWP78PPEMSXP7C7ASYZC20M1G02CCQEFM8PYF0M5DPZBEYG56RRD8JYK9PDADYXM7P1SGSZT2J07705TZNKF0Y34W9T28FZ340PRAA5HSDX8SP3EFSFMNV8RC109HKRW0ZP4WRE1E8QJVGS19CZVPAKMRQA17VF9H4HK3FVXYCA2B3FAZTMRVABA9D03P01BYNERVDEJMQ2173562N24FEBYB18EYF94WD3GVX82G60G15KVSJV527Q6723V93NANH5CYPN6H8JE8CTXXA030S1EEBEDT4KYEDZE1BVJ2A42GPCS60BA448VKT83A793QEW5A7EDKFCKWFQYPSZTSCBWRS4ZQTYG00Z5T2G4JMY6PWPS92D6YNJCYK0EGNNFF7QGDXXYWN33MM0296SQ1MK0R0F45EXAQT5S2Q39SXAA8KHR32A8BC0HG418300KMYBR5ZKNTX7SANSJB5SSYGP9RZVHN23RC1J29QRH5XFSMABMDA582GJH5JAE0D31AH5PTAHP04Y61KNCSKNC748N1PRN503VZY7EA1C4G75C0F13K04TE8RVP63K0TQ693E2XB23WSHYERKSZ6AKCTR3E15N1AF70HEKK13E4QCCY2JN896YEWWT9B8CVW50D8C87S75EK3G";
+
+ let key: RsaKeyPair =
+ 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 signature = doc
+ .descendants()
+ .find(|it| it.has_tag_name("SignatureValue"))
+ .unwrap()
+ .text()
+ .unwrap();
+ assert_eq!(
+ signature,
+ "eYyb1v/dGVOPndpMhXZlVQM2q9H9BJP77nYOWaa7jjoeLef7/8HjKIv8oq6Kaf6Z9mAfh/Pcip3a75gkdKpz7ocl1YdsaD+CcQkO1J/n4NwY821ccSh0Ahm2PBE168hyEMzPJrDeDtJrYqs+J/+nC8ek0hbo4/WPsH4UoxVu+ANsHR+BnQFQW3k9BFv+XKZbrBltIY62SN73tYwU8QzRtINJLzjhNB3T6S101n4CYwycXpL5b/oXXOUxxfDnn9EmIFt4DIgjxxqDYdQEBytULLORdkIdf563aw2wDaN12OQV2TB9gAs4Uu203FkUbmIagarMhbKKlqa1NkOteZ13Xw=="
+ );
+ }
}
diff --git a/src/xml.rs b/src/xml.rs
@@ -0,0 +1,285 @@
+/*
+* This file is part of LibEuFin.
+* Copyright (C) 2026 Taler Systems S.A.
+
+* LibEuFin is free software; you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation; either version 3, or
+* (at your option) any later version.
+
+* LibEuFin is distributed in the hope that it will be useful, but
+* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
+* Public License for more details.
+
+* You should have received a copy of the GNU Affero General Public
+* License along with LibEuFin; see the file COPYING. If not, see
+* <http://www.gnu.org/licenses/>
+*/
+
+use std::fmt::Display;
+
+use roxmltree::Document;
+
+#[macro_export]
+macro_rules! xml {
+ // Text element
+ ($w:ident, $name:literal $(($k:literal: $v:literal))* : $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.nest($name, &[$(($k, $v)),*], |$w| {
+ xml!($w, $($body)*);
+ });
+ $(xml!($w, $($rest)*);)*
+ };
+ // Empty element
+ ($w:ident, $name:literal $(($k:literal: $v:literal))* $(, $($rest:tt)*)?) => {
+ $w.empty($name, &[$(($k, $v)),*]);
+ $(xml!($w, $($rest)*);)*
+ };
+ // Logic escape
+ ($w:ident, @ $logic:expr$(, $($rest:tt)*)?) => {
+ ($logic)($w);
+ $(xml!($w, $($rest)*);)*
+ };
+}
+
+#[macro_export]
+macro_rules! xml_build {
+ ($name:literal $(($k:literal: $v:literal))* { $($body:tt)* }) => {
+ $crate::xml::XmlWriter::build(|w| {
+ w.nest($name, &[$(($k, $v)),*], |w| {
+ xml!(w, $($body)*);
+ });
+ })
+ };
+}
+
+pub struct XmlWriter {
+ buff: String,
+}
+impl XmlWriter {
+ pub fn build<F>(f: F) -> String
+ where
+ F: FnOnce(&mut Self),
+ {
+ let buff = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>"#.to_owned();
+
+ let mut tmp = Self { buff };
+ f(&mut tmp);
+ tmp.buff
+ }
+
+ pub fn nest<F>(&mut self, name: &str, attrs: &[(&str, &str)], f: F)
+ where
+ F: FnOnce(&mut Self),
+ {
+ self.buff.push('<');
+ self.write_tag_attrs(name, attrs);
+ self.buff.push('>');
+
+ f(self);
+
+ self.buff.push_str("</");
+ self.buff.push_str(name);
+ self.buff.push('>');
+ }
+
+ pub fn empty(&mut self, name: &str, attrs: &[(&str, &str)]) {
+ self.buff.push('<');
+ self.write_tag_attrs(name, attrs);
+ self.buff.push_str("/>");
+ }
+
+ pub fn text<D: Display + ?Sized>(&mut self, name: &str, attrs: &[(&str, &str)], content: &D) {
+ self.nest(name, attrs, |w| w.write_escaped(content));
+ }
+
+ fn write_tag_attrs(&mut self, name: &str, attrs: &[(&str, &str)]) {
+ self.buff.push_str(name);
+
+ for (key, value) in attrs {
+ self.buff.push(' ');
+ self.buff.push_str(key);
+ self.buff.push_str("=\"");
+ self.write_escaped(value);
+ self.buff.push('"');
+ }
+ }
+
+ fn write_escaped<D: Display + ?Sized>(&mut self, content: &D) {
+ struct EscapingWriter<'a>(&'a mut String);
+
+ impl<'a> std::fmt::Write for EscapingWriter<'a> {
+ fn write_str(&mut self, s: &str) -> std::fmt::Result {
+ for c in s.chars() {
+ match c {
+ '<' => self.0.push_str("<"),
+ '>' => self.0.push_str(">"),
+ '&' => self.0.push_str("&"),
+ '\'' => self.0.push_str("'"),
+ '"' => self.0.push_str("""),
+ _ => self.0.push(c),
+ }
+ }
+ Ok(())
+ }
+ }
+ std::fmt::write(
+ &mut EscapingWriter(&mut self.buff),
+ format_args!("{content}"),
+ )
+ .unwrap();
+ }
+}
+
+pub struct XmlReader<'node, 'input> {
+ node: roxmltree::Node<'node, 'input>,
+}
+
+impl XmlReader<'_, '_> {
+ pub fn parse<F, R>(raw: &str, tag: &str, f: F) -> R
+ where
+ R: 'static,
+ F: for<'local> FnOnce(XmlReader<'local, '_>) -> R,
+ {
+ let xml = Document::parse(raw).unwrap();
+ Self::parse_doc(xml, tag, f)
+ }
+
+ pub fn parse_doc<F, R>(xml: Document, tag: &str, f: F) -> R
+ where
+ R: 'static,
+ F: for<'local> FnOnce(XmlReader<'local, '_>) -> R,
+ {
+ let root = xml.root_element();
+ assert!(
+ root.has_tag_name(tag),
+ "{} != {tag}",
+ root.tag_name().name()
+ );
+ let node = XmlReader { node: root };
+ let res = f(node);
+ drop(xml);
+ res
+ }
+
+ pub fn attr(&self, name: &str) -> &str {
+ 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,
+ {
+ let mut iter = self
+ .node
+ .children()
+ .filter(|children| children.has_tag_name(tag));
+ let Some(node) = iter.next() else {
+ panic!(
+ "expected unique '{}.{tag}', got none",
+ self.node.tag_name().name()
+ );
+ };
+ if iter.next().is_some() {
+ let count = iter.count() + 2;
+ panic!(
+ "expected unique '{}.{tag}', got {count}",
+ self.node.tag_name().name()
+ );
+ }
+ f(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,
+ {
+ self.node
+ .children()
+ .filter(move |children| children.has_tag_name(tag))
+ .map(move |children| f(XmlReader { node: children }))
+ }
+
+ pub fn text(&self) -> &str {
+ self.node.text().unwrap_or_default()
+ }
+}
+
+#[cfg(test)]
+
+mod test {
+ use crate::xml::XmlWriter;
+
+ #[test]
+ pub fn basic() {
+ assert_eq!(
+ xml_build!("ebicsRequest" ("version": "H004") {
+ "a" {
+ "b" {
+ "c" ("attribute-of": "c") {
+ "d" {
+ "e" {
+ "f" ("nested": "true") {
+ "g" {
+ "h"
+ }
+ }
+ }
+ }
+
+ }
+ }
+ },
+ "one_more"
+ }),
+ r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><ebicsRequest version="H004"><a><b><c attribute-of="c"><d><e><f nested="true"><g><h/></g></f></e></d></c></b></a><one_more/></ebicsRequest>"#
+ )
+ }
+
+ #[test]
+ pub fn modularity() {
+ fn module(w: &mut XmlWriter) {
+ xml!(w, "module");
+ }
+ assert_eq!(
+ xml_build!("root" { @ |w| module(w) }),
+ r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><root><module/></root>"#
+ )
+ }
+
+ #[test]
+ pub fn iterable() {
+ assert_eq!(
+ xml_build!("iterable" {
+ "endOfDocument" {
+ @ |w: &mut XmlWriter| {
+ for i in 1..=10 {
+ xml!(w, (&format!("e{i}")) {
+ (&format!("e{i}{i}")): &format_args!("{i}{i}{i}")
+ });
+ }
+ }
+ }
+ }),
+ r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><iterable><endOfDocument><e1><e11>111</e11></e1><e2><e22>222</e22></e2><e3><e33>333</e33></e3><e4><e44>444</e44></e4><e5><e55>555</e55></e5><e6><e66>666</e66></e6><e7><e77>777</e77></e7><e8><e88>888</e88></e8><e9><e99>999</e99></e9><e10><e1010>101010</e1010></e10></endOfDocument></iterable>"#
+ )
+ }
+}