libeufin

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

commit ef53e579a2f6afa909038f0204d727785f1aeab3
parent 35772d0b6eb2e695b1552c03fa41608e1155e919
Author: Antoine A <>
Date:   Fri, 24 Apr 2026 10:37:56 +0200

full EBICS setup

Diffstat:
MCargo.lock | 250++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
MCargo.toml | 21+++++++++++++--------
Msrc/keys.rs | 121++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Msrc/main.rs | 561+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Msrc/xml.rs | 72+++++++++++++++++++++++++++++++++++++++++-------------------------------
Msrc/xml_sign.rs | 4++--
6 files changed, 851 insertions(+), 178 deletions(-)

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