libeufin

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

commit 00666e2153d40fd5c55189c4f4941965e25d3a89
parent 723afba376dae972ae06dff747dbb80a04efd161
Author: Antoine A <>
Date:   Wed,  3 Dec 2025 17:02:06 +0100

ebisync: init with azure blob storage API

Diffstat:
M.gitignore | 5+++--
Mdocker-compose.yml | 3++-
Alibeufin-ebisync/build.gradle | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alibeufin-ebisync/src/main/kotlin/tech/libeufin/ebisync/Main.kt | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Alibeufin-ebisync/src/main/kotlin/tech/libeufin/ebisync/azure.kt | 209+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msettings.gradle | 1+
6 files changed, 345 insertions(+), 3 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -29,4 +29,5 @@ debian/libeufin-common debian/libeufin-nexus debian/files debian/*.substvars -debian/*debhelper* -\ No newline at end of file +debian/*debhelper* +azurite +\ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml @@ -1,11 +1,12 @@ services: azurite: image: mcr.microsoft.com/azure-storage/azurite + command: azurite --blobHost 0.0.0.0 --debug /data/debug.log container_name: azurite ports: - 10000:10000 volumes: - - azurite-data:/data + - ./azurite:/data restart: unless-stopped volumes: diff --git a/libeufin-ebisync/build.gradle b/libeufin-ebisync/build.gradle @@ -0,0 +1,76 @@ +plugins { + id("kotlin") + id("application") + id("com.gradleup.shadow") version "$shadow_version" + id("org.jetbrains.kotlin.plugin.serialization") version "$kotlin_version" +} + +version = rootProject.version + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +compileKotlin.kotlinOptions.jvmTarget = "17" +compileTestKotlin.kotlinOptions.jvmTarget = "17" + +sourceSets.main.java.srcDirs = ["src/main/kotlin"] + +dependencies { + // Core language libraries + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version") + + implementation(project(":libeufin-common")) + implementation(project(":libeufin-nexus")) + + // Metrics + implementation("io.prometheus:prometheus-metrics-core:$prometheus_version") + implementation("io.prometheus:prometheus-metrics-instrumentation-jvm:$prometheus_version") + implementation("io.prometheus:prometheus-metrics-exposition-formats:$prometheus_version") + + // Command line parsing + implementation("com.github.ajalt.clikt:clikt:$clikt_version") + implementation("org.postgresql:postgresql:$postgres_version") + // Ktor client library + implementation("io.ktor:ktor-server-core:$ktor_version") + implementation("io.ktor:ktor-client-cio:$ktor_version") + implementation("io.ktor:ktor-client-mock:$ktor_version") + implementation("io.ktor:ktor-client-websockets:$ktor_version") + + // PDF generation + implementation("com.itextpdf:itext-core:9.3.0") + + // UNIX domain sockets support (used to connect to PostgreSQL) + implementation("com.kohlschutter.junixsocket:junixsocket-core:$junixsocket_version") + + // Serialization + implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor_version") + + // Unit testing + testImplementation("org.jetbrains.kotlin:kotlin-test:$kotlin_version") + testImplementation("io.ktor:ktor-server-test-host:$ktor_version") + testImplementation("io.ktor:ktor-server-cio:$ktor_version") +} + +application { + mainClass = "tech.libeufin.ebisync.MainKt" +} + +shadowJar { + version = "" + minimize { + // Kotlin serialization + exclude(dependency("io.ktor:ktor-serialization-kotlinx-json:.*")) + // Postgres unix socket driver + exclude(dependency("com.kohlschutter.junixsocket:junixsocket-core:.*")) + // CIO engine + exclude(dependency("io.ktor:ktor-client-cio:.*")) + // Crypto + exclude(dependency("org.bouncycastle:.*")) + // CLI + exclude(dependency("com.github.ajalt.mordant:mordant:.*")) + // PDF + exclude(dependency("com.itextpdf:itext-core:.*")) + } +} +\ No newline at end of file diff --git a/libeufin-ebisync/src/main/kotlin/tech/libeufin/ebisync/Main.kt b/libeufin-ebisync/src/main/kotlin/tech/libeufin/ebisync/Main.kt @@ -0,0 +1,52 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2025 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/> + */ + +package tech.libeufin.ebisync + +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.request.* +import io.ktor.client.plugins.* +import io.ktor.client.plugins.api.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.util.* +import tech.libeufin.common.setupSecurityProperties +import tech.libeufin.nexus.* +import java.security.Key +import java.time.* +import java.time.format.DateTimeFormatter +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec +import kotlinx.coroutines.runBlocking +import java.util.Base64 + +val ACCOUNT_NAME = "devstoreaccount1" +val ACCOUNT_KEY = "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==" + + +fun main(args: Array<String>) { + setupSecurityProperties() + val client = AzureBlogStorage(ACCOUNT_NAME, ACCOUNT_KEY, "http://localhost:10000/${ACCOUNT_NAME}/", httpClient()) + runBlocking { + //client.createContainer("main") + //client.listBlobs("main1") + client.putBlob("main", "file2", "Hello World".toByteArray(Charsets.UTF_8), ContentType.Application.Docx) + } +} +\ No newline at end of file diff --git a/libeufin-ebisync/src/main/kotlin/tech/libeufin/ebisync/azure.kt b/libeufin-ebisync/src/main/kotlin/tech/libeufin/ebisync/azure.kt @@ -0,0 +1,208 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2025 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/> + */ + +package tech.libeufin.ebisync + +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.request.* +import io.ktor.client.plugins.* +import io.ktor.client.plugins.api.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.util.* +import tech.libeufin.common.setupSecurityProperties +import tech.libeufin.nexus.* +import java.security.Key +import java.time.* +import java.time.format.DateTimeFormatter +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec +import kotlinx.coroutines.runBlocking +import java.util.Base64 + +data class AzureStorageConfig( + var accountName: String = "ACCOUNT_NAME", + var accountKey: String = "ACCOUNT_KEY" +) + +const val API_VERSION = "2025-11-05" + +val LINEAR_WHITESPACE = Regex("\\s+") + +/** + * Ktor Client Plugin for Azure Storage Shared Key Authorization. + */ +val AzureSharedKeyAuth = createClientPlugin("AzureSharedKeyAuth", ::AzureStorageConfig) { + val config = pluginConfig + val keyBytes = Base64.getDecoder().decode(config.accountKey) + val signingKey: Key = SecretKeySpec(keyBytes, "HmacSHA256") + + // Intercepts the request before it is sent + onRequest { req, _ -> + // 1. Set required headers (x-ms-date and x-ms-version) + val dateHeaderValue = Instant.now().atZone(ZoneOffset.ofHours(0)).format(DateTimeFormatter.RFC_1123_DATE_TIME) + + req.headers.apply { + // Azure uses x-ms-date instead of the standard Date header for signing + set("x-ms-date", dateHeaderValue) + set("x-ms-version", API_VERSION) + } + + for (entry in req.headers.entries()) { + println("${entry.key} ${entry.value}") + } + + // 2. Build the StringToSign + val stringToSign = createStringToSign(req, config.accountName) + println(stringToSign.replace("\n", "\\n")) + + // 3. Calculate the HMAC-SHA256 signature + val signature = run { + val mac = Mac.getInstance("HmacSHA256") + mac.init(signingKey) + val hash = mac.doFinal(stringToSign.toByteArray(Charsets.UTF_8)) + Base64.getEncoder().encodeToString(hash) + } + + // 4. Add the Authorization header + val authHeader = "SharedKey ${config.accountName}:$signature" + req.headers.set(HttpHeaders.Authorization, authHeader) + } +} + +/** + * Constructs the StringToSign based on the Azure Storage Shared Key specification. + */ +private fun createStringToSign( + req: HttpRequestBuilder, + accountName: String +): String = buildString { + val h = req.headers; + + fun add(value: Any?) { + append(value ?: "") + append('\n') + } + + // 1. VERB + add(req.method.value) + // 2. Content-Encoding + add(h[HttpHeaders.ContentEncoding]) + // 3. Content-Language + add(h[HttpHeaders.ContentLanguage]) + // 4. Content-Length (empty string if zero for modern versions) + val length = req.contentLength() + add(if (length != null && length != 0L) "$length" else null) + // 5. Content-MD5 + add(h["Content-MD5"]) + // 6. Content-Type + add(h[HttpHeaders.ContentType]) + // 7. Date + add("") // Must be empty as x-ms-date is used) + // 8. If-Modified-Since + add(h[HttpHeaders.IfModifiedSince]) + // 9. If-Match + add(h[HttpHeaders.IfMatch]) + // 10. If-None-Match + add(h[HttpHeaders.IfNoneMatch]) + // 11. If-Unmodified-Since + add(h[HttpHeaders.IfUnmodifiedSince]) + // 12. Range + add(h[HttpHeaders.Range]) + // 13. CanonicalizedHeaders + // This includes all x-ms- headers, converted to lowercase, sorted, and concatenated. + for (entry in h.entries().sortedBy { it.key }) { + val k = entry.key + if (k.startsWith("x-ms-")) { + append(k) + append(':') + append(entry.value.joinToString(" ").replace(LINEAR_WHITESPACE, " ").trim()) + append('\n') + } + + } + // 14. CanonicalizedResource + // This includes the account name, the path, and canonicalized query parameters. + append('/') + append(accountName) + append(req.url.encodedPath.trimEnd('/')) + for (entry in req.url.encodedParameters.entries().sortedBy { it.key }) { + append('\n') + append(entry.key) + append(':') + append(entry.value.sorted().joinToString(",")) + } +} + +data class AzureError(val status: HttpStatusCode, val code: String?): Exception("${status.value} ${code}") + +class AzureBlogStorage( + name: String, + key: String, + url: String, + client: HttpClient +) { + private val client = client.config { + defaultRequest { + url(url) + } + install(AzureSharedKeyAuth) { + accountName = name + accountKey = key + } + + install(HttpCallValidator) { + validateResponse { res -> + if (res.status.value >= 300) { + throw AzureError(res.status, res.headers["x-ms-error-code"]) + } + } + } + } + + suspend fun createContainer(name: String) { + client.put("$name?restype=container") + } + + suspend fun listBlobs(container: String) { + val res = client.get("$container?restype=container&comp=list") + val body = res.bodyAsText() + println("$res $body") + } + + suspend fun putBlob(container: String, name: String, content: ByteArray, contentType: ContentType) { + client.put("$container/$name") { + setBody(content) + contentType(contentType) + headers.set(HttpHeaders.ContentLength, "${content.size}") + headers.set("x-ms-blob-type", "BlockBlob") + } + } +} + +fun main(args: Array<String>) { + setupSecurityProperties() + val client = AzureBlogStorage(ACCOUNT_NAME, ACCOUNT_KEY, "http://localhost:10000/${ACCOUNT_NAME}/", httpClient()) + runBlocking { + //client.createContainer("main") + //client.listBlobs("main1") + client.putBlob("main", "file2", "Hello World".toByteArray(Charsets.UTF_8), ContentType.Application.Docx) + } +} +\ No newline at end of file diff --git a/settings.gradle b/settings.gradle @@ -2,4 +2,5 @@ rootProject.name = 'libeufin' include("libeufin-bank") include("libeufin-nexus") include("libeufin-common") +include("libeufin-ebisync") include("testbench") \ No newline at end of file