From 2cd837fef9dc1516814307f49e1bee5a0a3bcec3 Mon Sep 17 00:00:00 2001 From: Antoine A <> Date: Thu, 7 Mar 2024 00:49:21 +0100 Subject: Use XML builder for Ebics3 download initialization --- ebics/src/main/kotlin/XMLUtil.kt | 56 ++-------- ebics/src/main/kotlin/XmlCombinators.kt | 98 +++++++++++++---- ebics/src/test/kotlin/XmlCombinatorsTest.kt | 37 ++++--- .../main/kotlin/tech/libeufin/nexus/Iso20022.kt | 2 +- .../kotlin/tech/libeufin/nexus/ebics/Ebics3.kt | 119 +++++++++++++-------- 5 files changed, 182 insertions(+), 130 deletions(-) diff --git a/ebics/src/main/kotlin/XMLUtil.kt b/ebics/src/main/kotlin/XMLUtil.kt index fbebc124..3af1cd8c 100644 --- a/ebics/src/main/kotlin/XMLUtil.kt +++ b/ebics/src/main/kotlin/XMLUtil.kt @@ -158,10 +158,9 @@ class XMLUtil private constructor() { */ private class EbicsSigUriDereferencer : URIDereferencer { override fun dereference(myRef: URIReference?, myCtx: XMLCryptoContext?): Data { - val ebicsXpathExpr = "//*[@authenticate='true']" if (myRef !is DOMURIReference) throw Exception("invalid type") - if (myRef.uri != "#xpointer($ebicsXpathExpr)") + if (myRef.uri != "#xpointer(//*[@authenticate='true'])") throw Exception("invalid EBICS XML signature URI: '${myRef.uri}'") val xp: XPath = XPathFactory.newInstance().newXPath() val nodeSet = xp.compile("//*[@authenticate='true']/descendant-or-self::node()").evaluate( @@ -341,15 +340,10 @@ class XMLUtil private constructor() { } fun convertDomToBytes(document: Document): ByteArray { - /* Make Transformer. */ - val tf = TransformerFactory.newInstance() - val t = tf.newTransformer() - - /* Make bytes writer. */ val w = ByteArrayOutputStream() - - /* Extract string. */ - t.transform(DOMSource(document), StreamResult(w)) + val transformer = TransformerFactory.newInstance().newTransformer() + transformer.setOutputProperty(OutputKeys.STANDALONE, "yes") + transformer.transform(DOMSource(document), StreamResult(w)) return w.toByteArray() } @@ -409,24 +403,9 @@ class XMLUtil private constructor() { signingPriv: PrivateKey, withEbics3: Boolean = false ) { - val xpath = XPathFactory.newInstance().newXPath() - xpath.namespaceContext = object : NamespaceContext { - override fun getNamespaceURI(p0: String?): String { - return when (p0) { - "ebics" -> if (withEbics3) "urn:org:ebics:H005" else "urn:org:ebics:H004" - else -> throw IllegalArgumentException() - } - } - - override fun getPrefix(p0: String?): String { - throw UnsupportedOperationException() - } - - override fun getPrefixes(p0: String?): MutableIterator { - throw UnsupportedOperationException() - } - } - val authSigNode = xpath.compile("/*[1]/ebics:AuthSignature").evaluate(doc, XPathConstants.NODE) + val ns = if (withEbics3) "urn:org:ebics:H005" else "urn:org:ebics:H004" + val authSigNode = XPathFactory.newInstance().newXPath() + .evaluate("/*[1]/$ns:AuthSignature", doc, XPathConstants.NODE) if (authSigNode !is Node) throw java.lang.Exception("no AuthSignature") val fac = XMLSignatureFactory.getInstance("DOM") @@ -461,25 +440,10 @@ class XMLUtil private constructor() { signingPub: PublicKey, withEbics3: Boolean = false ): Boolean { - val xpath = XPathFactory.newInstance().newXPath() - xpath.namespaceContext = object : NamespaceContext { - override fun getNamespaceURI(p0: String?): String { - return when (p0) { - "ebics" -> if (withEbics3) "urn:org:ebics:H005" else "urn:org:ebics:H004" - else -> throw IllegalArgumentException() - } - } - - override fun getPrefix(p0: String?): String { - throw UnsupportedOperationException() - } - - override fun getPrefixes(p0: String?): MutableIterator { - throw UnsupportedOperationException() - } - } val doc2: Document = doc.cloneNode(true) as Document - val authSigNode = xpath.compile("/*[1]/ebics:AuthSignature").evaluate(doc2, XPathConstants.NODE) + val ns = if (withEbics3) "urn:org:ebics:H005" else "urn:org:ebics:H004" + val authSigNode = XPathFactory.newInstance().newXPath() + .evaluate("/*[1]/$ns:AuthSignature", doc2, XPathConstants.NODE) if (authSigNode !is Node) throw java.lang.Exception("no AuthSignature") val sigEl = doc2.createElementNS("http://www.w3.org/2000/09/xmldsig#", "ds:Signature") diff --git a/ebics/src/main/kotlin/XmlCombinators.kt b/ebics/src/main/kotlin/XmlCombinators.kt index ce1c9f37..9cb0b327 100644 --- a/ebics/src/main/kotlin/XmlCombinators.kt +++ b/ebics/src/main/kotlin/XmlCombinators.kt @@ -19,17 +19,62 @@ package tech.libeufin.ebics -import org.w3c.dom.Element +import org.w3c.dom.* import java.io.InputStream import java.io.StringWriter import java.time.LocalDate import java.time.LocalDateTime import java.time.format.DateTimeFormatter +import javax.xml.parsers.* import javax.xml.stream.XMLOutputFactory import javax.xml.stream.XMLStreamWriter -class XmlBuilder(private val w: XMLStreamWriter) { - fun el(path: String, lambda: XmlBuilder.() -> Unit = {}) { +interface XmlBuilder { + fun el(path: String, lambda: XmlBuilder.() -> Unit = {}) + fun el(path: String, content: String) { + el(path) { + text(content) + } + } + fun attr(namespace: String, name: String, value: String) + fun attr(name: String, value: String) + fun text(content: String) + + companion object { + fun toString(root: String, f: XmlBuilder.() -> Unit): String { + val factory = XMLOutputFactory.newFactory() + val stream = StringWriter() + var writer = factory.createXMLStreamWriter(stream) + /** + * NOTE: commenting out because it wasn't obvious how to output the + * "standalone = 'yes' directive". Manual forge was therefore preferred. + */ + stream.write("") + XmlStreamBuilder(writer).el(root) { + this.f() + } + writer.writeEndDocument() + return stream.buffer.toString() + } + + fun toDom(root: String, schema: String?, f: XmlBuilder.() -> Unit): Document { + val factory = DocumentBuilderFactory.newInstance(); + factory.isNamespaceAware = true + val builder = factory.newDocumentBuilder(); + val doc = builder.newDocument(); + doc.setXmlVersion("1.0") + doc.setXmlStandalone(true) + val root = doc.createElementNS(schema, root) + doc.appendChild(root); + XmlDOMBuilder(doc, schema, root).f() + doc.normalize() + return doc + } + } +} + +private class XmlStreamBuilder(private val w: XMLStreamWriter): XmlBuilder { + override fun el(path: String, lambda: XmlBuilder.() -> Unit) { path.splitToSequence('/').forEach { w.writeStartElement(it) } @@ -39,35 +84,42 @@ class XmlBuilder(private val w: XMLStreamWriter) { } } - fun el(path: String, content: String) { - el(path) { - text(content) - } + override fun attr(namespace: String, name: String, value: String) { + w.writeAttribute(namespace, name, value) } - fun attr(name: String, value: String) { + override fun attr(name: String, value: String) { w.writeAttribute(name, value) } - fun text(content: String) { + override fun text(content: String) { w.writeCharacters(content) } } -fun constructXml(root: String, f: XmlBuilder.() -> Unit): String { - val factory = XMLOutputFactory.newFactory() - val stream = StringWriter() - var writer = factory.createXMLStreamWriter(stream) - /** - * NOTE: commenting out because it wasn't obvious how to output the - * "standalone = 'yes' directive". Manual forge was therefore preferred. - */ - stream.write("") - XmlBuilder(writer).el(root) { - this.f() - } - writer.writeEndDocument() - return stream.buffer.toString() +private class XmlDOMBuilder(private val doc: Document, private val schema: String?, private var node: Element): XmlBuilder { + override fun el(path: String, lambda: XmlBuilder.() -> Unit) { + val current = node + path.splitToSequence('/').forEach { + val new = doc.createElementNS(schema, it) + node.appendChild(new) + node = new + } + lambda() + node = current + } + + override fun attr(namespace: String, name: String, value: String) { + node.setAttributeNS(namespace, name, value) + } + + override fun attr(name: String, value: String) { + node.setAttribute(name, value) + } + + override fun text(content: String) { + node.appendChild(doc.createTextNode(content)); + } } class DestructionError(m: String) : Exception(m) diff --git a/ebics/src/test/kotlin/XmlCombinatorsTest.kt b/ebics/src/test/kotlin/XmlCombinatorsTest.kt index 8d722414..396de451 100644 --- a/ebics/src/test/kotlin/XmlCombinatorsTest.kt +++ b/ebics/src/test/kotlin/XmlCombinatorsTest.kt @@ -19,49 +19,58 @@ import org.junit.Test import tech.libeufin.ebics.XmlBuilder -import tech.libeufin.ebics.constructXml +import tech.libeufin.ebics.XMLUtil import kotlin.test.assertEquals class XmlCombinatorsTest { + fun testBuilder(expected: String, root: String, builder: XmlBuilder.() -> Unit) { + val toString = XmlBuilder.toString(root, builder) + val toDom = XmlBuilder.toDom(root, null, builder) + assertEquals(expected, toString) + assertEquals(expected, XMLUtil.convertDomToBytes(toDom).toString(Charsets.UTF_8)) + } @Test fun testWithModularity() { fun module(base: XmlBuilder) { base.el("module") } - val s = constructXml("root") { + testBuilder( + "", + "root" + ) { module(this) } - println(s) - assertEquals("", s) } @Test fun testWithIterable() { - val s = constructXml("iterable") { + testBuilder( + "111222333444555666777888999101010", + "iterable" + ) { el("endOfDocument") { for (i in 1..10) - el("$i/$i$i", "$i$i$i") + el("e$i/e$i$i", "$i$i$i") } } - println(s) - assertEquals("<1><11>111<2><22>222<3><33>333<4><44>444<5><55>555<6><66>666<7><77>777<8><88>888<9><99>999<10><1010>101010", s) } @Test fun testBasicXmlBuilding() { - val s = constructXml("ebics:ebicsRequest") { + testBuilder( + "", + "ebicsRequest" + ) { attr("version", "H004") el("a/b/c") { attr("attribute-of", "c") - el("//d/e/f//") { + el("d/e/f") { attr("nested", "true") - el("g/h/") + el("g/h") } } - el("one more") + el("one_more") } - println(s) - assertEquals("<><><>< nested=\"true\">", s) } } diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt index fb9c03ce..946a1808 100644 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt +++ b/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt @@ -83,7 +83,7 @@ fun createPain001( ) val zonedTimestamp = ZonedDateTime.ofInstant(initiationTimestamp, ZoneId.of("UTC")) val amountWithoutCurrency: String = getAmountNoCurrency(amount) - return constructXml("Document") { + return XmlBuilder.toString("Document") { attr("xmlns", namespace.fullNamespace) attr("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance") attr("xsi:schemaLocation", "${namespace.fullNamespace} ${namespace.xsdFilename}") diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/Ebics3.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/Ebics3.kt index 29a5e5e7..099669c1 100644 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/Ebics3.kt +++ b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/Ebics3.kt @@ -19,18 +19,21 @@ package tech.libeufin.nexus.ebics import io.ktor.client.* -import tech.libeufin.ebics.PreparedUploadData -import tech.libeufin.ebics.XMLUtil +import tech.libeufin.ebics.* +import tech.libeufin.common.* +import tech.libeufin.common.crypto.* import tech.libeufin.ebics.ebics_h005.Ebics3Request -import tech.libeufin.ebics.getNonce -import tech.libeufin.ebics.getXmlDate import tech.libeufin.nexus.BankPublicKeysFile import tech.libeufin.nexus.ClientPrivateKeysFile import tech.libeufin.nexus.EbicsSetupConfig import tech.libeufin.nexus.logger import java.math.BigInteger -import java.time.Instant +import java.time.* +import java.time.format.* import java.util.* +import java.io.File +import org.w3c.dom.* +import javax.xml.datatype.XMLGregorianCalendar import javax.xml.datatype.DatatypeFactory /** @@ -97,7 +100,6 @@ fun createEbics3DownloadTransferPhase( return XMLUtil.convertDomToBytes(doc) } - /** * Creates the EBICS 3 document for the init phase of a download * transaction. @@ -109,58 +111,83 @@ fun createEbics3DownloadTransferPhase( */ fun createEbics3DownloadInitialization( cfg: EbicsSetupConfig, - bankkeys: BankPublicKeysFile, + bankKeys: BankPublicKeysFile, clientKeys: ClientPrivateKeysFile, whichDoc: SupportedDocument, startDate: Instant? = null, endDate: Instant? = null ): ByteArray { val nonce = getNonce(128) - val req = Ebics3Request.createForDownloadInitializationPhase( - cfg.ebicsUserId, - cfg.ebicsPartnerId, - cfg.ebicsHostId, - nonce, - DatatypeFactory.newInstance().newXMLGregorianCalendar(GregorianCalendar()), - bankAuthPub = bankkeys.bank_authentication_public_key, - bankEncPub = bankkeys.bank_encryption_public_key, - myOrderParams = if (whichDoc == SupportedDocument.PAIN_002_LOGS) null else Ebics3Request.OrderDetails.BTDOrderParams().apply { - service = Ebics3Request.OrderDetails.Service().apply { - serviceName = when(whichDoc) { - SupportedDocument.PAIN_002 -> "PSR" - SupportedDocument.CAMT_052 -> "STM" - SupportedDocument.CAMT_053 -> "EOP" - SupportedDocument.CAMT_054 -> "REP" - SupportedDocument.PAIN_002_LOGS -> "HAC" - } - scope = "CH" - container = Ebics3Request.OrderDetails.Service.Container().apply { - containerType = "ZIP" + val timestamp = DatatypeFactory.newInstance().newXMLGregorianCalendar(GregorianCalendar()) + val doc = XmlBuilder.toDom("ebicsRequest", "urn:org:ebics:H005") { + attr("http://www.w3.org/2000/xmlns/", "xmlns", "urn:org:ebics:H005") + attr("http://www.w3.org/2000/xmlns/", "xmlns:ds", "http://www.w3.org/2000/09/xmldsig#") + attr("http://www.w3.org/2000/xmlns/", "xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance") + attr("http://www.w3.org/2001/XMLSchema-instance", "xsi:schemaLocation", "urn:org:ebics:H005 ebics_request_H005.xsd") + attr("Version", "H005") + attr("Revision", "1") + el("header") { + attr("authenticate", "true") + el("static") { + el("HostID", cfg.ebicsHostId) + el("Nonce", nonce.toHexString()) + el("Timestamp", timestamp.toXMLFormat() ) + el("PartnerID", cfg.ebicsPartnerId) + el("UserID", cfg.ebicsUserId) + el("OrderDetails") { + if (whichDoc == SupportedDocument.PAIN_002_LOGS) { + el("AdminOrderType", "HAC") + } else { + el("AdminOrderType", "BTD") + el("BTDOrderParams") { + el("Service") { + el("ServiceName", when(whichDoc) { + SupportedDocument.PAIN_002 -> "PSR" + SupportedDocument.CAMT_052 -> "STM" + SupportedDocument.CAMT_053 -> "EOP" + SupportedDocument.CAMT_054 -> "REP" + SupportedDocument.PAIN_002_LOGS -> "HAC" + }) + val (msg_value, msg_version) = when(whichDoc) { + SupportedDocument.PAIN_002 -> Pair("pain.002", "10") + SupportedDocument.CAMT_052 -> Pair("pain.052", "08") + SupportedDocument.CAMT_053 -> Pair("pain.053", "08") + SupportedDocument.CAMT_054 -> Pair("camt.054", "08") + SupportedDocument.PAIN_002_LOGS -> throw Exception("HAC (--only-logs) not available in EBICS 3") + } + el("Scope", "CH") + el("Container") { + attr("containerType", "ZIP") + } + el("MsgName") { + attr("version", msg_version) + text(msg_value) + } + } + } + } } - messageName = Ebics3Request.OrderDetails.Service.MessageName().apply { - val (msg_value, msg_version) = when(whichDoc) { - SupportedDocument.PAIN_002 -> Pair("pain.002", "10") - SupportedDocument.CAMT_052 -> Pair("pain.052", "08") - SupportedDocument.CAMT_053 -> Pair("pain.053", "08") - SupportedDocument.CAMT_054 -> Pair("camt.054", "08") - SupportedDocument.PAIN_002_LOGS -> throw Exception("HAC (--only-logs) not available in EBICS 3") + el("BankPubKeyDigests") { + el("Authentication") { + attr("Version", "X002") + attr("Algorithm", "http://www.w3.org/2001/04/xmlenc#sha256") + text(CryptoUtil.getEbicsPublicKeyHash(bankKeys.bank_authentication_public_key).encodeBase64()) + } + el("Encryption") { + attr("Version", "E002") + attr("Algorithm", "http://www.w3.org/2001/04/xmlenc#sha256") + text(CryptoUtil.getEbicsPublicKeyHash(bankKeys.bank_encryption_public_key).encodeBase64()) } - value = msg_value - version = msg_version } + el("SecurityMedium", "0000") } - if (startDate != null) { - Ebics3Request.DateRange().apply { - start = getXmlDate(startDate) - end = getXmlDate(endDate ?: Instant.now()) - } + el("mutable") { + el("TransactionPhase", "Initialisation") } } - ) - val doc = XMLUtil.convertJaxbToDocument( - req, - withSchemaLocation = "urn:org:ebics:H005 ebics_request_H005.xsd" - ) + el("AuthSignature") + el("body") + } XMLUtil.signEbicsDocument( doc, clientKeys.authentication_private_key, -- cgit v1.2.3