libeufin

Integration and sandbox testing for FinTech APIs and data formats
Log | Files | Refs | Submodules | README | LICENSE

commit a537dd277ff1b961d16d2711d7e9a728ab5cd4c9
parent 557d9f3a51eedbf97b0731a13248b1f7532f8217
Author: Antoine A <>
Date:   Fri, 24 Apr 2026 10:37:56 +0200

ergonomic XML macro

Diffstat:
MCargo.lock | 214+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
MCargo.toml | 6++----
Asrc/config.rs | 92+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/ebics_code.rs | 210+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/keys.rs | 112+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/main.rs | 684++++++++++++-------------------------------------------------------------------
Asrc/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('&', "&amp;") @@ -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&#13; -hUBX3Y+0PahH+XeOnPGuUYdiOy0/FydtG2E1oQELNRojWhxxJMKPpN6jO9Y3j8QmS31oAWUiLjgA&#13; +#[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&#13; +hUBX3Y+0PahH+XeOnPGuUYdiOy0/FydtG2E1oQELNRojWhxxJMKPpN6jO9Y3j8QmS31oAWUiLjgA&#13; S//AU924Wh0rIwA8L3riSzGZDAgf6c0Wg+loPk581AD9QtzMiDi6onLVQvlKYtlVJNheTIreG54i&#13; a6vPTIqlMWB5iA5ZqoE6zO+VWr4sxTPswlHD29dDar7B4YJ1vYLLTzFHc0yJaDjWaURQNr0mDqUC&#13; 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("&lt;"), + '>' => self.0.push_str("&gt;"), + '&' => self.0.push_str("&amp;"), + '\'' => self.0.push_str("&apos;"), + '"' => self.0.push_str("&quot;"), + _ => 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>"# + ) + } +}