commit 00666e2153d40fd5c55189c4f4941965e25d3a89
parent 723afba376dae972ae06dff747dbb80a04efd161
Author: Antoine A <>
Date: Wed, 3 Dec 2025 17:02:06 +0100
ebisync: init with azure blob storage API
Diffstat:
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