diff --git a/core/pom.xml b/core/pom.xml index f168f363..eb08f23c 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -36,10 +36,6 @@ jar - - org.jetbrains.kotlin - kotlin-stdlib - com.google.crypto.tink tink diff --git a/core/src/main/kotlin/org/nessus/didcomm/aries/AriesAgent.kt b/core/src/main/kotlin/org/nessus/didcomm/aries/AriesAgent.kt deleted file mode 100644 index 4701d16d..00000000 --- a/core/src/main/kotlin/org/nessus/didcomm/aries/AriesAgent.kt +++ /dev/null @@ -1,102 +0,0 @@ -package org.nessus.didcomm.aries - -import org.hyperledger.aries.AriesClient -import org.hyperledger.aries.api.multitenancy.WalletRecord -import java.net.MalformedURLException -import java.net.URL - -class AgentConfiguration(val adminUrl: String?, val userUrl: String?, val apiKey: String?) { - - companion object { - private val host = System.getenv("ACAPY_HOSTNAME") ?: "localhost" - private val adminPort = System.getenv("ACAPY_ADMIN_PORT") ?: "8031" - private val userPort = System.getenv("ACAPY_USER_PORT") ?: "8030" - private val apiKey = System.getenv("ACAPY_ADMIN_API_KEY") ?: "adminkey" - val defaultConfiguration= AgentConfigurationBuilder() - .adminUrl(String.format("http://%s:%s", host, adminPort)) - .userUrl(String.format("http://%s:%s", host, userPort)) - .apiKey(apiKey) - .build() - } - - val webSocketUrl: String - get() = try { - val url = URL(adminUrl) - String.format("ws://%s:%d/ws", url.host, url.port) - } catch (ex: MalformedURLException) { - throw IllegalArgumentException(ex) - } - - override fun toString(): String { - val reductedApiKey = if (apiKey != null) apiKey.substring(0, 4) + "..." else null - return "AgentConfiguration [agentAdminUrl=$adminUrl, agentUserUrl=$userUrl, agentApiKey=$reductedApiKey]" - } - - fun builder(): AgentConfigurationBuilder { - return AgentConfigurationBuilder() - } - - class AgentConfigurationBuilder { - private var adminUrl: String? = null - private var userUrl: String? = null - private var apiKey: String? = null - fun adminUrl(adminUrl: String?): AgentConfigurationBuilder { - this.adminUrl = adminUrl - return this - } - - fun userUrl(userUrl: String?): AgentConfigurationBuilder { - this.userUrl = userUrl - return this - } - - fun apiKey(apiKey: String?): AgentConfigurationBuilder { - this.apiKey = apiKey - return this - } - - fun build(): AgentConfiguration { - return AgentConfiguration(adminUrl, userUrl, apiKey) - } - } - - private fun getSystemEnv(key: String?, defaultValue: String?): String? { - var value = System.getenv(key) - if (value == null || value.isBlank() || value.isEmpty()) value = defaultValue - return value - } -} - -object AriesClientFactory { - /** - * Create a client for the admin wallet - */ - fun adminClient(): AriesClient { - return createClient(AgentConfiguration.defaultConfiguration, null) - } - - /** - * Create a client for the admin wallet - */ - fun adminClient(config: AgentConfiguration): AriesClient { - return createClient(config, null) - } - - /** - * Create a client for a multitenant wallet - */ - fun createClient(wallet: WalletRecord?): AriesClient { - return createClient(AgentConfiguration.defaultConfiguration, wallet) - } - - /** - * Create a client for a multitenant wallet - */ - fun createClient(config: AgentConfiguration, wallet: WalletRecord?): AriesClient { - return AriesClient.builder() - .url(config.adminUrl) - .apiKey(config.apiKey) - .bearerToken(wallet?.token) - .build() - } -} diff --git a/core/src/main/kotlin/org/nessus/didcomm/model/MessageParser.kt b/core/src/main/kotlin/org/nessus/didcomm/model/MessageParser.kt deleted file mode 100644 index 9549f3dd..00000000 --- a/core/src/main/kotlin/org/nessus/didcomm/model/MessageParser.kt +++ /dev/null @@ -1,30 +0,0 @@ -package org.nessus.didcomm.model - -import com.google.gson.Gson -import org.didcommx.didcomm.message.Message - -/** - * Parses a JSON string to a DIDComm Message - */ -class MessageParser { - - companion object { - private val gson = Gson() - - fun fromJson(json: String) : Message { - val jsonMap: MutableMap = mutableMapOf() - gson.fromJson(json, Map::class.java).forEach { en -> - val enval = en.value!! - when(val key: String = en.key.toString()) { - "created_time" -> jsonMap[key] = (enval as Double).toLong() - "expires_time" -> jsonMap[key] = (enval as Double).toLong() - "custom_headers" -> if ((enval as Map).isNotEmpty()) { - jsonMap[key] = enval - } - else -> jsonMap[key] = enval - } - } - return Message.parse(jsonMap) - } - } -} diff --git a/core/src/main/kotlin/org/nessus/didcomm/model/MessageReader.kt b/core/src/main/kotlin/org/nessus/didcomm/model/MessageReader.kt new file mode 100644 index 00000000..33ce9de7 --- /dev/null +++ b/core/src/main/kotlin/org/nessus/didcomm/model/MessageReader.kt @@ -0,0 +1,32 @@ +package org.nessus.didcomm.model + +import com.google.gson.Gson +import org.didcommx.didcomm.message.Message + +/** + * Parses a JSON string to a DIDComm Message + */ +object MessageReader { + + private val gson = Gson() + + fun fromJson(json: String) : Message { + val jsonMap: MutableMap = mutableMapOf() + gson.fromJson(json, Map::class.java).forEach { en -> + val enval = en.value!! + when(val key: String = en.key.toString()) { + "created_time" -> jsonMap[key] = (enval as Double).toLong() + "expires_time" -> jsonMap[key] = (enval as Double).toLong() + "custom_headers" -> if (enval is Map<*, *> && enval.isNotEmpty()) { + jsonMap[key] = enval + } + else -> jsonMap[key] = enval + } + } + return Message.parse(jsonMap) + } + + fun fromJson(json: String, type: Class) : T { + return gson.fromJson(json, type) + } +} diff --git a/core/src/main/kotlin/org/nessus/didcomm/model/MessageType.kt b/core/src/main/kotlin/org/nessus/didcomm/model/MessageType.kt new file mode 100644 index 00000000..d45c890a --- /dev/null +++ b/core/src/main/kotlin/org/nessus/didcomm/model/MessageType.kt @@ -0,0 +1,14 @@ +package org.nessus.didcomm.model + +abstract class MessageType ( + + /** + * The header conveying the DIDComm Message Type URI. + * REQUIRED + */ + val type: String +) { + companion object { + const val OUT_OF_BAND_INVITATION = "https://didcomm.org/out-of-band/2.0/invitation" + } +} diff --git a/core/src/main/kotlin/org/nessus/didcomm/model/MessageWriter.kt b/core/src/main/kotlin/org/nessus/didcomm/model/MessageWriter.kt index 5be3b366..51b1b258 100644 --- a/core/src/main/kotlin/org/nessus/didcomm/model/MessageWriter.kt +++ b/core/src/main/kotlin/org/nessus/didcomm/model/MessageWriter.kt @@ -3,29 +3,51 @@ package org.nessus.didcomm.model import com.google.gson.FieldNamingPolicy import com.google.gson.Gson import com.google.gson.GsonBuilder +import com.nimbusds.jose.util.Base64URL import org.didcommx.didcomm.message.Message /** * Serializes a DIDComm Message to JSON */ -class MessageWriter { - - companion object { - private val gson: Gson = GsonBuilder() - .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) - .create() - private val prettyGson: Gson = GsonBuilder() - .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) - .setPrettyPrinting() - .create() - - fun toJson(msg: Message, pretty: Boolean = false) : String { - return toJson(msg as Any, pretty) +object MessageWriter { + + private val gson: Gson = GsonBuilder() + .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) + .create() + private val prettyGson: Gson = GsonBuilder() + .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) + .setPrettyPrinting() + .create() + + fun toBase64URL(msg: Message): String { + return Base64URL.encode(toJson(msg)).toString() + } + + fun toJson(msg: Message, pretty: Boolean = false) : String { + val jsonObj = gson.toJsonTree(msg).asJsonObject + // Remove empty 'custom_headers' + // [TODO] we may have to remove emtpty content for other headers too + val customHeaders = jsonObj.getAsJsonObject("custom_headers") + if (customHeaders.entrySet().isEmpty()) { + jsonObj.remove("custom_headers") } + return auxGson(pretty).toJson(jsonObj) + } - fun toJson(obj: Any, pretty: Boolean = false) : String { - val gson = if (pretty) prettyGson else gson - return gson.toJson(obj) + fun toJson(obj: Any, pretty: Boolean = false) : String { + return auxGson(pretty).toJson(obj) + } + + fun toMutableMap(obj: Any) : MutableMap { + val result: MutableMap = mutableMapOf() + val input: String = if (obj is String) obj else gson.toJson(obj) + gson.fromJson(input, MutableMap::class.java).forEach { + en -> result[en.key as String] = en.value!! } + return result + } + + private fun auxGson(pretty: Boolean = false): Gson { + return if (pretty) prettyGson else gson } } diff --git a/core/src/main/kotlin/org/nessus/didcomm/model/OutOfBandInvitationV2.kt b/core/src/main/kotlin/org/nessus/didcomm/model/OutOfBandInvitation.kt similarity index 90% rename from core/src/main/kotlin/org/nessus/didcomm/model/OutOfBandInvitationV2.kt rename to core/src/main/kotlin/org/nessus/didcomm/model/OutOfBandInvitation.kt index c457553b..a162daaa 100644 --- a/core/src/main/kotlin/org/nessus/didcomm/model/OutOfBandInvitationV2.kt +++ b/core/src/main/kotlin/org/nessus/didcomm/model/OutOfBandInvitation.kt @@ -28,7 +28,7 @@ import com.google.gson.Gson * ] * } */ -data class OutOfBandInvitationV2( +data class OutOfBandInvitation( /** * Message ID. The id attribute value MUST be unique to the sender, across all messages they send. @@ -69,19 +69,12 @@ data class OutOfBandInvitationV2( * OPTIONAL */ val attachments: List?, -) { +) : MessageType(OUT_OF_BAND_INVITATION) { companion object { - - /** - * The header conveying the DIDComm MTURI. - * REQUIRED - */ - const val type: String = "https://didcomm.org/out-of-band/2.0/invitation" - - fun fromBody(body: Map): OutOfBandInvitationV2 { + fun fromBody(body: Map): OutOfBandInvitation { val gson = Gson() - return gson.fromJson(gson.toJson(body), OutOfBandInvitationV2::class.java) + return gson.fromJson(gson.toJson(body), OutOfBandInvitation::class.java) } } } diff --git a/core/src/main/kotlin/org/nessus/didcomm/service/AgentService.kt b/core/src/main/kotlin/org/nessus/didcomm/service/AgentService.kt new file mode 100644 index 00000000..afd6bac5 --- /dev/null +++ b/core/src/main/kotlin/org/nessus/didcomm/service/AgentService.kt @@ -0,0 +1,19 @@ +package org.nessus.didcomm.service + +import org.didcommx.didcomm.message.Message +import org.nessus.didcomm.wallet.NessusWallet + +/** + * An Agent can create, send, receive DIDComMessages + */ +interface AgentService : Service { + + companion object { + val type: Class = AgentService::class.java + } + + override val type: Class + get() = Companion.type + + fun createMessage(wallet: NessusWallet, type: String, body: Map = mapOf()) : Message +} diff --git a/core/src/main/kotlin/org/nessus/didcomm/service/ServiceRegistry.kt b/core/src/main/kotlin/org/nessus/didcomm/service/ServiceRegistry.kt new file mode 100644 index 00000000..6d485612 --- /dev/null +++ b/core/src/main/kotlin/org/nessus/didcomm/service/ServiceRegistry.kt @@ -0,0 +1,20 @@ +package org.nessus.didcomm.service + +// [TODO] document all services +interface Service { + val type: Class +} + +// [TODO] document ServiceRegistry +object ServiceRegistry { + + private val registry : MutableMap = mutableMapOf() + + fun getService(type : Class) : T { + return registry[type.name] as T + } + + fun addService(service: T) { + registry[service.type.name] = service + } +} diff --git a/core/src/main/kotlin/org/nessus/didcomm/service/WalletService.kt b/core/src/main/kotlin/org/nessus/didcomm/service/WalletService.kt new file mode 100644 index 00000000..7e3f184e --- /dev/null +++ b/core/src/main/kotlin/org/nessus/didcomm/service/WalletService.kt @@ -0,0 +1,34 @@ +package org.nessus.didcomm.service + +import org.nessus.didcomm.wallet.NessusWallet +import org.nessus.didcomm.wallet.WalletException +import org.nessus.didcomm.wallet.WalletRegistry + +interface WalletService : Service { + + companion object { + val type: Class = WalletService::class.java + val registry = WalletRegistry() + } + + override val type: Class + get() = Companion.type + + fun assertConfigValue(config: Map, key: String) : Any { + return config[key] ?: throw WalletException("No config value for: $key") + } + + fun getConfigValue(config: Map, key: String) : Any? { + return config[key] + } + + fun hasConfigValue(config: Map, key: String) : Boolean { + return config[key] != null + } + + fun createWallet(config: Map): NessusWallet + + fun publicDid(wallet: NessusWallet): String? + + fun closeAndRemove(wallet: NessusWallet?) +} diff --git a/core/src/main/kotlin/org/nessus/didcomm/wallet/Did.kt b/core/src/main/kotlin/org/nessus/didcomm/wallet/Did.kt index 600f39a9..d4f30f3e 100644 --- a/core/src/main/kotlin/org/nessus/didcomm/wallet/Did.kt +++ b/core/src/main/kotlin/org/nessus/didcomm/wallet/Did.kt @@ -4,7 +4,7 @@ import id.walt.crypto.KeyAlgorithm val DEFAULT_KEY_ALGORITHM = KeyAlgorithm.EdDSA_Ed25519 -enum class DidMethod(mname : String) { +enum class DidMethod(val mname : String) { KEY("key"), SOV("sov"); @@ -13,11 +13,11 @@ enum class DidMethod(mname : String) { } } -open class Did(method: DidMethod) { -} - -class DidKey() : Did(DidMethod.KEY) { -} - -class DidSov() : Did(DidMethod.SOV) { -} +//open class Did(method: DidMethod) { +//} +// +//class DidKey() : Did(DidMethod.KEY) { +//} +// +//class DidSov() : Did(DidMethod.SOV) { +//} diff --git a/core/src/main/kotlin/org/nessus/didcomm/wallet/LedgerRole.kt b/core/src/main/kotlin/org/nessus/didcomm/wallet/LedgerRole.kt new file mode 100644 index 00000000..92677feb --- /dev/null +++ b/core/src/main/kotlin/org/nessus/didcomm/wallet/LedgerRole.kt @@ -0,0 +1,7 @@ +package org.nessus.didcomm.wallet + +enum class LedgerRole { + STEWARD, + TRUSTEE, + ENDORSER +} diff --git a/core/src/main/kotlin/org/nessus/didcomm/wallet/NessusWallet.kt b/core/src/main/kotlin/org/nessus/didcomm/wallet/NessusWallet.kt new file mode 100644 index 00000000..1419a01c --- /dev/null +++ b/core/src/main/kotlin/org/nessus/didcomm/wallet/NessusWallet.kt @@ -0,0 +1,23 @@ + +package org.nessus.didcomm.wallet + +import org.nessus.didcomm.service.ServiceRegistry +import org.nessus.didcomm.service.WalletService + +class WalletException(msg: String) : Exception(msg) + +/** + * A NessusWallet gives acces to wallet information as known by the agent. + */ +data class NessusWallet( + val walletId: String, + val walletName: String, + val accessToken: String? = null, +) { + // [TODO] override toString with redacted values + + fun publicDid() { + val walletService = ServiceRegistry.getService(WalletService.type) + walletService.publicDid(this) + } +} diff --git a/core/src/main/kotlin/org/nessus/didcomm/wallet/NessusWalletBuilder.kt b/core/src/main/kotlin/org/nessus/didcomm/wallet/NessusWalletBuilder.kt new file mode 100644 index 00000000..07af73ca --- /dev/null +++ b/core/src/main/kotlin/org/nessus/didcomm/wallet/NessusWalletBuilder.kt @@ -0,0 +1,37 @@ +package org.nessus.didcomm.wallet + +import org.nessus.didcomm.service.ServiceRegistry +import org.nessus.didcomm.service.WalletService + +class NessusWalletBuilder(val name: String) { + + private var ledgerRole: LedgerRole? = null + private var selfRegister: Boolean = false + private var trusteeWallet: NessusWallet? = null + + fun ledgerRole(ledgerRole: LedgerRole): NessusWalletBuilder { + this.ledgerRole = ledgerRole + return this + } + + fun selfRegisterNym(): NessusWalletBuilder { + this.selfRegister = true + return this + } + + fun trusteeWallet(trusteeWallet: NessusWallet): NessusWalletBuilder { + this.trusteeWallet = trusteeWallet + return this + } + + fun build(): NessusWallet { + val walletService = ServiceRegistry.getService(WalletService.type) + val config: Map = mapOf( + "walletName" to name, + "ledgerRole" to ledgerRole, + "selfRegister" to selfRegister, + "trusteeWallet" to trusteeWallet, + ) + return walletService.createWallet(config) + } +} diff --git a/core/src/main/kotlin/org/nessus/didcomm/wallet/Wallet.kt b/core/src/main/kotlin/org/nessus/didcomm/wallet/OldNessusWallet.kt similarity index 90% rename from core/src/main/kotlin/org/nessus/didcomm/wallet/Wallet.kt rename to core/src/main/kotlin/org/nessus/didcomm/wallet/OldNessusWallet.kt index 2d0ad4b8..74cda326 100644 --- a/core/src/main/kotlin/org/nessus/didcomm/wallet/Wallet.kt +++ b/core/src/main/kotlin/org/nessus/didcomm/wallet/OldNessusWallet.kt @@ -11,13 +11,11 @@ import mu.KotlinLogging import java.security.SecureRandom -class WalletError(message: String) : Exception(message) - fun ByteArray.encodeBase58(): String = Base58.encode(this) fun String.decodeBase58(): ByteArray = Base58.decode(this) -class Wallet { +class OldNessusWallet { private val log = KotlinLogging.logger {} @@ -33,7 +31,7 @@ class Wallet { // validate key_type if (keyAlgorithm !in didMethod.supportedAlgorithms()) - throw WalletError("Invalid key type $keyType for method $method") + throw WalletException("Invalid key type $keyType for method $method") var secureRandom = SecureRandom.getInstance("SHA1PRNG") secureRandom.setSeed(seedBytes) @@ -69,7 +67,7 @@ class Wallet { SecureRandom().nextBytes(byteArray); } if (byteArray.size != 32) { - throw WalletError("Seed value must be 32 bytes in length") + throw WalletException("Seed value must be 32 bytes in length") } return byteArray } diff --git a/core/src/main/kotlin/org/nessus/didcomm/wallet/WalletRegistry.kt b/core/src/main/kotlin/org/nessus/didcomm/wallet/WalletRegistry.kt new file mode 100644 index 00000000..518537b0 --- /dev/null +++ b/core/src/main/kotlin/org/nessus/didcomm/wallet/WalletRegistry.kt @@ -0,0 +1,34 @@ +package org.nessus.didcomm.wallet + +class WalletRegistry { + + private val walletsCache: MutableMap = mutableMapOf() + + fun walletNames(): Set { + return walletsCache.keys + } + + fun putWallet(wallet: NessusWallet) { + walletsCache[wallet.walletId] = wallet + } + + fun removeWallet(walletId: String) { + walletsCache.remove(walletId) + } + + fun wallets(): Set { + return walletsCache.values.toSet() + } + + fun getWallet(walletId: String): NessusWallet? { + return walletsCache[walletId] + } + + fun getWalletName(walletId: String): String? { + return getWallet(walletId)?.walletName + } + + fun getWalletByName(walletName: String): NessusWallet? { + return walletsCache.values.firstOrNull { w -> w.walletName == walletName } + } +} diff --git a/core/src/test/kotlin/org/nessus/didcomm/test/did/DidKeyTest.kt b/core/src/test/kotlin/org/nessus/didcomm/test/did/DidKeyTest.kt index 71f895a7..91c11401 100644 --- a/core/src/test/kotlin/org/nessus/didcomm/test/did/DidKeyTest.kt +++ b/core/src/test/kotlin/org/nessus/didcomm/test/did/DidKeyTest.kt @@ -3,6 +3,7 @@ package org.nessus.didcomm.test.did import id.walt.crypto.KeyAlgorithm import id.walt.services.crypto.TinkCryptoService import org.junit.jupiter.api.Test +import kotlin.test.assertTrue class DidKeyTest { @@ -17,5 +18,6 @@ class DidKeyTest { val keyId = tinkCryptoService.generateKey(KeyAlgorithm.EdDSA_Ed25519) val sig = tinkCryptoService.sign(keyId, data) val res = tinkCryptoService.verify(keyId, sig, data) + assertTrue(res) } -} \ No newline at end of file +} diff --git a/core/src/test/kotlin/org/nessus/didcomm/test/model/Fixtures.kt b/core/src/test/kotlin/org/nessus/didcomm/test/model/Fixtures.kt index 75ea5cd6..96feda32 100644 --- a/core/src/test/kotlin/org/nessus/didcomm/test/model/Fixtures.kt +++ b/core/src/test/kotlin/org/nessus/didcomm/test/model/Fixtures.kt @@ -1,8 +1,6 @@ package org.nessus.didcomm.test.model -import org.didcommx.didcomm.message.Attachment -import org.didcommx.didcomm.message.Message -import org.nessus.didcomm.model.OutOfBandInvitationV2 +import org.nessus.didcomm.model.MessageReader class OutOfBand { @@ -11,20 +9,97 @@ class OutOfBand { const val ALICE_DID = "did:example:alice" const val FABER_DID = "did:example:faber" - private const val ID = "1234567890" - private const val TYPE = OutOfBandInvitationV2.type + val FABER_OUT_OF_BAND_INVITATION = MessageReader.fromJson(""" + { + "type": "https://didcomm.org/out-of-band/2.0/invitation", + "id": "1234567890", + "from": "did:example:faber", + "body": { + "goal_code": "issue-vc", + "goal": "To issue a Faber College Graduate credential", + "accept": [ + "didcomm/v2", + "didcomm/aip2;env=rfc587" + ] + }, + "attachments": [ + { + "id": "request-0", + "media_type": "application/json", + "data": { + "json": {"protocol message": "content"} + } + } + ] + } + """.trimIndent()) - val OUT_OF_BAND_INVITATION = Message.builder(ID, mapOf( - "goal_code" to "issue-vc", - "goal" to "To issue a Faber College Graduate credential", - "accept" to listOf("didcomm/v2", "didcomm/aip2;env=rfc587")), - TYPE) - .from(FABER_DID) - .createdTime(1516269022) - .expiresTime(1516385931) - .attachments(listOf( - Attachment.builder( - "request-0", Attachment.Data.parse(mapOf("base64" to "qwerty"))).build())) - .build() + val FABER_OUT_OF_BAND_INVITATION_WRAPPED = MessageReader.fromJson(""" + { + "type": "https://didcomm.org/out-of-band/2.0/invitation", + "id": "1234567890", + "from": "did:example:faber", + "body": { + "goal_code": "issue-vc", + "goal": "To issue a Faber College Graduate credential", + "accept": [ + "didcomm/v2", + "didcomm/aip2;env=rfc587" + ] + }, + "attachments": [ + { + "id": "0fa20500-2677-4e72-a8b3-dfff26ae2044", + "media_type": "application/json", + "data": { + "json": { + "invitation": { + "@type": "did:sov:BzCbsNYhMrjHiqZDTUASHg;spec/out-of-band/1.1/invitation", + "@id": "0fa20500-2677-4e72-a8b3-dfff26ae2044", + "label": "Aries Cloud Agent", + "services": [ + { + "id": "#inline", + "type": "did-communication", + "recipientKeys": [ + "did:key:z6MkqQUeLBtuYvef7BS1wvUjCJj1hskcs94tY38dKy1fzCsL" + ], + "serviceEndpoint": "http://localhost:8030" + } + ], + "handshake_protocols": [ + "did:sov:BzCbsNYhMrjHiqZDTUASHg;spec/didexchange/1.0" + ] + }, + "state": "initial", + "oob_id": "f2f24452-f43c-4a08-bf5c-5e6734d3f0db", + "invi_msg_id": "0fa20500-2677-4e72-a8b3-dfff26ae2044" + } + } + } + ] + } + """.trimIndent()) + + val ALICE_OUT_OF_BAND_INVITATION = MessageReader.fromJson(""" + { + "type": "https://didcomm.org/out-of-band/2.0/invitation", + "id": "69212a3a-d068-4f9d-a2dd-4741bca89af3", + "from": "did:example:alice", + "body": { + "goal_code": "", + "goal": "" + }, + "attachments": [ + { + "id": "request-0", + "media_type": "application/json", + "data": { + "base64": "qwerty" + } + } + ] + } + """.trimIndent()) } } diff --git a/core/src/test/kotlin/org/nessus/didcomm/test/model/OutOfBandInvitationTest.kt b/core/src/test/kotlin/org/nessus/didcomm/test/model/OutOfBandInvitationTest.kt index 366bf8e3..41db202b 100644 --- a/core/src/test/kotlin/org/nessus/didcomm/test/model/OutOfBandInvitationTest.kt +++ b/core/src/test/kotlin/org/nessus/didcomm/test/model/OutOfBandInvitationTest.kt @@ -1,15 +1,15 @@ package org.nessus.didcomm.test.model import mu.KotlinLogging +import org.didcommx.didcomm.message.Attachment import org.didcommx.didcomm.message.Message +import org.hyperledger.acy_py.generated.model.InvitationRecord import org.junit.jupiter.api.Test -import org.nessus.didcomm.model.MessageParser +import org.nessus.didcomm.model.MessageReader import org.nessus.didcomm.model.MessageWriter -import org.nessus.didcomm.model.OutOfBandInvitationV2 -import kotlin.test.Ignore +import org.nessus.didcomm.model.OutOfBandInvitation import kotlin.test.assertEquals -@Ignore class OutOfBandInvitationTest { private val log = KotlinLogging.logger {} @@ -17,18 +17,41 @@ class OutOfBandInvitationTest { @Test fun testOutOfBandInvitation() { - val exp: Message = OutOfBand.OUT_OF_BAND_INVITATION - val expBody = OutOfBandInvitationV2.fromBody(exp.body) + val exp: Message = OutOfBand.FABER_OUT_OF_BAND_INVITATION + val expBody = OutOfBandInvitation.fromBody(exp.body) val expJson: String = MessageWriter.toJson(exp) log.info("exp: {}", expJson) - val was = MessageParser.fromJson(expJson) + val was = MessageReader.fromJson(expJson) log.info("was: {}", MessageWriter.toJson(was)) assertEquals(exp, was) - val wasBody = OutOfBandInvitationV2.fromBody(was.body) + val wasBody = OutOfBandInvitation.fromBody(was.body) log.info("body: {}", MessageWriter.toJson(wasBody)) assertEquals(expBody, wasBody) } + @Test + fun testOutOfBandEncoding() { + val exp: Message = OutOfBand.ALICE_OUT_OF_BAND_INVITATION + val expJson: String = MessageWriter.toJson(exp) + log.info("exp: {}", expJson) + + val base64URLEncoded = MessageWriter.toBase64URL(exp) + log.info("enc: {}", base64URLEncoded) + assertEquals("eyJpZCI6IjY5MjEy", base64URLEncoded.substring(0, 16)) + } + + @Test + fun testWrappedOutOfBandInvitation() { + + val exp: Message = OutOfBand.FABER_OUT_OF_BAND_INVITATION_WRAPPED + val expJson: String = MessageWriter.toJson(exp) + log.info("exp: {}", expJson) + + val att0: Attachment = exp.attachments?.get(0)!! + val invJson = MessageWriter.toJson(att0.data.toJSONObject()["json"]!!) + val invRec: InvitationRecord = MessageReader.fromJson(invJson, InvitationRecord::class.java) + assertEquals(att0.id, invRec.inviMsgId) + } } diff --git a/core/src/test/kotlin/org/nessus/didcomm/test/wallet/InMemoryWalletTest.kt b/core/src/test/kotlin/org/nessus/didcomm/test/wallet/InMemoryWalletTest.kt index cbe79e43..119487b1 100644 --- a/core/src/test/kotlin/org/nessus/didcomm/test/wallet/InMemoryWalletTest.kt +++ b/core/src/test/kotlin/org/nessus/didcomm/test/wallet/InMemoryWalletTest.kt @@ -2,7 +2,6 @@ package org.nessus.didcomm.test.wallet import mu.KotlinLogging import org.junit.jupiter.api.Test -import org.nessus.didcomm.wallet.Wallet import kotlin.test.Ignore @Ignore @@ -14,6 +13,6 @@ class InMemoryWalletTest { fun testCreateLocalDID() { // Wallet().createLocalDID("sov") - Wallet().createLocalDID("sov", seed = "000000000000000000000000Trustee1") + // Wallet().createLocalDID("sov", seed = "000000000000000000000000Trustee1") } } diff --git a/core/src/test/resources/logback-test.xml b/core/src/test/resources/logback-test.xml index 913be84c..47c37fc2 100644 --- a/core/src/test/resources/logback-test.xml +++ b/core/src/test/resources/logback-test.xml @@ -5,6 +5,7 @@ %date %level [%thread] %logger{10} [%file:%line] -%kvp- %msg%n + FALSE @@ -12,7 +13,7 @@ %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} -%kvp- %msg%n - WARN + INFO diff --git a/demo/README.md b/demo/README.md new file mode 100644 index 00000000..359abf15 --- /dev/null +++ b/demo/README.md @@ -0,0 +1,51 @@ +## Nessus DIDComm - Proof of Concept + +This POC describes the initial scope of Nessus DIDComm - an SSI agent that uses [DIDComm-2.0 ](https://identity.foundation/didcomm-messaging/spec/v2.0/) +to communicate with other agents (e.g. [Aries Cloud Agent Python](https://github.com/hyperledger/aries-cloudagent-python)) + +### Meet Alice, Faber and Acme + +Alice, a citizen of British Columbia has graduated from Faber College some time ago. Faber College, well situated at the heart of emerging tech, +has since adopted a form of digital transcripts that it now offers to its former students. These transcripts are verifiable credentials, +which are a key feature of [Self Sovereign Identity](https://www.manning.com/books/self-sovereign-identity). Alice has since moved to Munich, which +provides access to [EBSI](https://ec.europa.eu/digital-building-blocks/wikis/display/EBSI/Home) services for its citizens. + +In SSI terms, Faber is an **Issuer** of verifiable credentials (VC) and Alice +is a **Holder**. Alice may later apply for a job with Acme Corp, which then +becomes a **Verifier** in our [Trust Triangle](https://academy.affinidi.com/what-is-the-trust-triangle-9a9caf36b321) + +### Agent Communication + +All three parties need to agree on reliable/secure communication, which [DIDComm Messaging](https://identity.foundation/didcomm-messaging/spec/v2.0) is +well suited for. Faber uses [AcyPy](https://github.com/hyperledger/aries-cloudagent-python) and registers the necessary cryptographic material on the +[VON Network](https://github.com/bcgov/von-network). Alice is not known to the VON Network, neither does she have access to a [Hyperledger Aries](https://aries-interop.info/) compliant agent. +All parties communicate via DIDComm alone and use common standards to exchange information. + +### POC Milestones + +1. [Out of Band Invitation](https://identity.foundation/didcomm-messaging/spec/#out-of-band-messages) from Faber to Alice and vice versa +2. [DID Exchange](https://github.com/hyperledger/aries-rfcs/tree/main/features/0023-did-exchange) between Faber & Alice +3. Alice uses [did:key](https://w3c-ccg.github.io/did-method-key/) instead of [did:sov](https://sovrin-foundation.github.io/sovrin/spec/did-method-spec-template.html) +4. [Plaintext Message](https://identity.foundation/didcomm-messaging/spec/#didcomm-plaintext-messages) exchange +5. [Signed Message](https://identity.foundation/didcomm-messaging/spec/#didcomm-signed-messages) exchange +6. [Encrypted Message](https://identity.foundation/didcomm-messaging/spec/#didcomm-encrypted-messages) exchange +7. Anything else? + +### Further Work + +* Support for [W3C Verifiable Credentials](https://www.w3.org/TR/vc-data-model-2.0/) +* Support for [Aries Verifiable Credentials](https://github.com/hyperledger/aries-rfcs/tree/main/features/0453-issue-credential-v2) (maybe) +* Closely work with Aries on [AIP3.0](https://hackmd.io/_Kkl9ClTRBu8W4UmZVGdUQ) +* Credential revocation +* Anything else? + +### Tech Stack + +* Nessus DIDComm is written in [Kotlin](https://kotlinlang.org/) +* For DIDComm Messages it uses [didcomm-jvm](https://github.com/sicpa-dlab/didcomm-jvm) from [SICPA](https://www.sicpa.com/) +* For integration with AcaPy it uses [acapy-java-client](https://github.com/hyperledger-labs/acapy-java-client) +* For integration with EBSI is uses [waltid-ssikit](https://github.com/walt-id/waltid-ssikit) +* Future integration with [Apache Camel](https://camel.apache.org/) to supplement/replace [nessus-aries](https://github.com/tdiesler/nessus-aries) + + + diff --git a/docker-compose-single.yml b/docker-compose-single.yml new file mode 100644 index 00000000..7f8bb97d --- /dev/null +++ b/docker-compose-single.yml @@ -0,0 +1,203 @@ +# --------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# --------------------------------------------------------------------------- + +version: '3' + +networks: + von-network: + +services: + + # + # Webserver + # + webserver: + image: nessusio/von-network:${VON_NETWORK_VERSION:-1.7.2} + container_name: indy-webserver + command: bash -c 'sleep 10 && ./scripts/start_webserver.sh' + environment: + - IP=${IP} + - IPS=${IPS} + - DOCKERHOST=${DOCKERHOST:-host.docker.internal} + - LOG_LEVEL=${LOG_LEVEL:-info} + - RUST_LOG=${RUST_LOG:-warn} + - GENESIS_URL=${GENESIS_URL} + - ANONYMOUS=${ANONYMOUS} + - LEDGER_SEED=${LEDGER_SEED} + - LEDGER_CACHE_PATH=${LEDGER_CACHE_PATH} + - MAX_FETCH=${MAX_FETCH:-50000} + - RESYNC_TIME=${RESYNC_TIME:-120} + - REGISTER_NEW_DIDS=${REGISTER_NEW_DIDS:-True} + - LEDGER_INSTANCE_NAME=${LEDGER_INSTANCE_NAME:-localhost} + - WEB_ANALYTICS_SCRIPT=${WEB_ANALYTICS_SCRIPT} + - INFO_SITE_TEXT=${INFO_SITE_TEXT} + - INFO_SITE_URL=${INFO_SITE_URL} + extra_hosts: + - host.docker.internal:host-gateway + networks: + - von-network + ports: + - ${WEB_SERVER_HOST_PORT:-9000}:8000 + volumes: + - webserver-cli:/home/indy/.indy-cli + - webserver-ledger:/home/indy/ledger + + # + # Nodes + # + node1: + image: nessusio/von-network:${VON_NETWORK_VERSION:-1.7.2} + container_name: indy-node1 + command: ./scripts/start_node.sh 1 + networks: + - von-network + ports: + - 9701:9701 + - 9702:9702 + environment: + - IP=${IP} + - IPS=${IPS} + - DOCKERHOST=${DOCKERHOST:-host.docker.internal} + - LOG_LEVEL=${LOG_LEVEL:-info} + - RUST_LOG=${RUST_LOG:-warn} + extra_hosts: + - host.docker.internal:host-gateway + volumes: + - node1-data:/home/indy/ledger + + node2: + image: nessusio/von-network:${VON_NETWORK_VERSION:-1.7.2} + container_name: indy-node2 + command: ./scripts/start_node.sh 2 + networks: + - von-network + ports: + - 9703:9703 + - 9704:9704 + environment: + - IP=${IP} + - IPS=${IPS} + - DOCKERHOST=${DOCKERHOST:-host.docker.internal} + - LOG_LEVEL=${LOG_LEVEL:-info} + - RUST_LOG=${RUST_LOG:-warn} + extra_hosts: + - host.docker.internal:host-gateway + volumes: + - node2-data:/home/indy/ledger + + node3: + image: nessusio/von-network:${VON_NETWORK_VERSION:-1.7.2} + container_name: indy-node3 + command: ./scripts/start_node.sh 3 + networks: + - von-network + ports: + - 9705:9705 + - 9706:9706 + environment: + - IP=${IP} + - IPS=${IPS} + - DOCKERHOST=${DOCKERHOST:-host.docker.internal} + - LOG_LEVEL=${LOG_LEVEL:-info} + - RUST_LOG=${RUST_LOG:-warn} + extra_hosts: + - host.docker.internal:host-gateway + volumes: + - node3-data:/home/indy/ledger + + node4: + image: nessusio/von-network:${VON_NETWORK_VERSION:-1.7.2} + container_name: indy-node4 + command: ./scripts/start_node.sh 4 + networks: + - von-network + ports: + - 9707:9707 + - 9708:9708 + environment: + - IP=${IP} + - IPS=${IPS} + - DOCKERHOST=${DOCKERHOST:-host.docker.internal} + - LOG_LEVEL=${LOG_LEVEL:-info} + - RUST_LOG=${RUST_LOG:-warn} + extra_hosts: + - host.docker.internal:host-gateway + volumes: + - node4-data:/home/indy/ledger + + tails-server: + image: nessusio/indy-tails-server:${TAILS_SERVER_VERSION:-1.0.0} + container_name: tails-server + ports: + - 6543:6543 + networks: + - von-network + extra_hosts: + - host.docker.internal:host-gateway + command: > + tails-server + --host 0.0.0.0 + --port 6543 + --storage-path ${STORAGE_PATH:-/tmp/tails-files} + --log-level ${LOG_LEVEL:-info} + + acapy: + image: nessusio/aries-cloudagent-python:${ACAPY_VERSION:-0.7.5} + container_name: acapy + ports: + - ${ACAPY_USER_PORT:-8030}:${ACAPY_USER_PORT:-8030} + - ${ACAPY_ADMIN_PORT:-8031}:${ACAPY_ADMIN_PORT:-8031} + networks: + - von-network + extra_hosts: + - host.docker.internal:host-gateway + command: > + start + --genesis-url http://${DOCKERHOST:-host.docker.internal}:9000/genesis + --endpoint http://${ACAPY_HOSTNAME:-localhost}:${ACAPY_USER_PORT:-8030} + --inbound-transport http 0.0.0.0 ${ACAPY_USER_PORT:-8030} + --outbound-transport http + --tails-server-base-url http://tails-server:6543 + --admin 0.0.0.0 ${ACAPY_ADMIN_PORT:-8031} + --admin-api-key ${ACAPY_ADMIN_API_KEY:-adminkey} + --seed 000000000000000000000000Trustee1 + --wallet-storage-type default + --wallet-key trusteewkey + --wallet-name trustee + --wallet-type indy + --storage-type indy + --recreate-wallet + --auto-provision + --auto-ping-connection + --auto-accept-requests + --log-level info + depends_on: + - node1 + - node2 + - node3 + - node4 + - webserver + - tails-server + +volumes: + webserver-cli: + webserver-ledger: + node1-data: + node2-data: + node3-data: + node4-data: + nodes-data: diff --git a/docker-compose.yml b/docker-compose.yml index 7f8bb97d..61ae3c47 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -174,6 +174,9 @@ services: --tails-server-base-url http://tails-server:6543 --admin 0.0.0.0 ${ACAPY_ADMIN_PORT:-8031} --admin-api-key ${ACAPY_ADMIN_API_KEY:-adminkey} + --jwt-secret ${ACAPY_MULTITENANT_JWT_SECRET:-jwtsecret} + --multitenant + --multitenant-admin --seed 000000000000000000000000Trustee1 --wallet-storage-type default --wallet-key trusteewkey diff --git a/itests/pom.xml b/itests/pom.xml index 2306ee78..f531d9fc 100644 --- a/itests/pom.xml +++ b/itests/pom.xml @@ -38,9 +38,13 @@ org.nessus.didcomm - nessus-didcomm-core + nessus-didcomm-agent ${project.version} + + network.idu.acapy + aries-client-python + @@ -58,6 +62,12 @@ kotlin-test-junit5 test + + org.nessus.didcomm + nessus-didcomm-agent + 1.0-SNAPSHOT + test + diff --git a/itests/src/test/kotlin/org/nessus/didcomm/itest/AbstractAriesTest.kt b/itests/src/test/kotlin/org/nessus/didcomm/itest/AbstractAriesTest.kt index 76a978fe..4b5d841a 100644 --- a/itests/src/test/kotlin/org/nessus/didcomm/itest/AbstractAriesTest.kt +++ b/itests/src/test/kotlin/org/nessus/didcomm/itest/AbstractAriesTest.kt @@ -1,56 +1,14 @@ package org.nessus.didcomm.itest -import com.google.gson.JsonSyntaxException import mu.KotlinLogging -import okhttp3.OkHttpClient -import okhttp3.logging.HttpLoggingInterceptor -import org.hyperledger.aries.AriesClient -import org.hyperledger.aries.config.GsonConfig -import org.nessus.didcomm.aries.AgentConfiguration -import java.util.concurrent.TimeUnit +import org.nessus.didcomm.service.ServiceRegistry +import org.nessus.didcomm.service.WalletService abstract class AbstractAriesTest { val log = KotlinLogging.logger {} - private val gson = GsonConfig.defaultConfig() - - private fun messageLoggingInterceptor(): HttpLoggingInterceptor { - val pretty = GsonConfig.prettyPrinter() - val logging = HttpLoggingInterceptor { msg: String -> - if (log.isDebugEnabled && msg.isNotEmpty()) { - if (msg.startsWith("{")) { - try { - val json: Any = gson.fromJson(msg, Any::class.java) - log.debug("\n{}", pretty.toJson(json)) - } catch (e: JsonSyntaxException) { - log.debug("{}", msg) - } - } else { - log.debug("{}", msg) - } - } - } - logging.level = HttpLoggingInterceptor.Level.BODY - logging.redactHeader("X-API-Key") - logging.redactHeader("Authorization") - return logging - } - - fun adminClient(loggingInterceptor: HttpLoggingInterceptor? = null): AriesClient { - val loggingInterceptor = loggingInterceptor ?: messageLoggingInterceptor() - val httpClient = OkHttpClient.Builder() - .writeTimeout(60, TimeUnit.SECONDS) - .readTimeout(60, TimeUnit.SECONDS) - .connectTimeout(60, TimeUnit.SECONDS) - .callTimeout(60, TimeUnit.SECONDS) - .addInterceptor(loggingInterceptor) - .build() - val config = AgentConfiguration.defaultConfiguration - return AriesClient.builder() - .url(config.adminUrl) - .apiKey(config.apiKey) - .client(httpClient) - .build() + fun walletService(): WalletService { + return ServiceRegistry.getService(WalletService.type) } } diff --git a/itests/src/test/kotlin/org/nessus/didcomm/itest/Scenario001Test.kt b/itests/src/test/kotlin/org/nessus/didcomm/itest/Scenario001Test.kt new file mode 100644 index 00000000..bfab127b --- /dev/null +++ b/itests/src/test/kotlin/org/nessus/didcomm/itest/Scenario001Test.kt @@ -0,0 +1,82 @@ +package org.nessus.didcomm.itest + +import mu.KotlinLogging +import org.didcommx.didcomm.message.Attachment +import org.didcommx.didcomm.message.Message +import org.hyperledger.acy_py.generated.model.InvitationRecord +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import org.nessus.didcomm.agent.aries.AriesAgentService +import org.nessus.didcomm.agent.aries.AriesWalletService +import org.nessus.didcomm.model.MessageReader +import org.nessus.didcomm.model.MessageType.Companion.OUT_OF_BAND_INVITATION +import org.nessus.didcomm.model.MessageWriter +import org.nessus.didcomm.service.AgentService +import org.nessus.didcomm.service.ServiceRegistry +import org.nessus.didcomm.service.WalletService +import org.nessus.didcomm.wallet.LedgerRole +import org.nessus.didcomm.wallet.NessusWallet +import org.nessus.didcomm.wallet.NessusWalletBuilder +import kotlin.test.assertEquals + +class Scenario001Test : AbstractAriesTest() { + + companion object { + private val log = KotlinLogging.logger {} + + var governmentWallet: NessusWallet? = null + var faberWallet: NessusWallet? = null + + @BeforeAll + @JvmStatic + internal fun beforeAll() { + ServiceRegistry.addService(AriesAgentService()) + ServiceRegistry.addService(AriesWalletService()) + + // Create initial TRUSTEE Wallet + governmentWallet = NessusWalletBuilder("Government") + .ledgerRole(LedgerRole.TRUSTEE) + .selfRegisterNym() + .build() + + // Onboard an ENDORSER wallet + faberWallet = NessusWalletBuilder("Faber") + .trusteeWallet(governmentWallet!!) + .ledgerRole(LedgerRole.ENDORSER) + .build() + + val did = faberWallet?.publicDid() + log.info("Faber: Public {}", did) + } + + @AfterAll + @JvmStatic + internal fun afterAll() { + val walletService = ServiceRegistry.getService(WalletService.type) + walletService.closeAndRemove(faberWallet) + walletService.closeAndRemove(governmentWallet) + } + } + + @Test + fun testFaberInvitesAlice() { + + // Create the OOB Invitation through the Agent + val body = MessageWriter.toMutableMap(""" + { + "goal_code": "did-exchange", + "goal": "Faber College invites you for a DID exchange", + "accept": [ "didcomm/v2" ] + } + """.trimIndent()) + val agent = ServiceRegistry.getService(AgentService.type) + val msg: Message = agent.createMessage(faberWallet!!, OUT_OF_BAND_INVITATION, body) + + // Verify the DCV2 message + val att0: Attachment = msg.attachments?.get(0)!! + val invJson = MessageWriter.toJson(att0.data.toJSONObject()["json"]!!) + val invRec: InvitationRecord = MessageReader.fromJson(invJson, InvitationRecord::class.java) + assertEquals(att0.id, invRec.inviMsgId) + } +} diff --git a/itests/src/test/kotlin/org/nessus/didcomm/itest/features/Scenario001Test.kt b/itests/src/test/kotlin/org/nessus/didcomm/itest/features/Scenario001Test.kt deleted file mode 100644 index c2f86528..00000000 --- a/itests/src/test/kotlin/org/nessus/didcomm/itest/features/Scenario001Test.kt +++ /dev/null @@ -1,18 +0,0 @@ -package org.nessus.didcomm.itest.features - -import org.hyperledger.aries.api.out_of_band.InvitationCreateRequest -import org.junit.jupiter.api.Test -import org.nessus.didcomm.itest.AbstractAriesTest - -class Scenario001Test : AbstractAriesTest() { - - @Test - fun testFaberInvitesAlice() { - val client = adminClient() - val invitationRequest = InvitationCreateRequest.builder() - .alias("Faber") - .build() - val invitationResponse = client.outOfBandCreateInvitation(invitationRequest, null).get() - log.info("{}", invitationResponse) - } -} diff --git a/itests/src/test/resources/logback-test.xml b/itests/src/test/resources/logback-test.xml index 913be84c..47c37fc2 100644 --- a/itests/src/test/resources/logback-test.xml +++ b/itests/src/test/resources/logback-test.xml @@ -5,6 +5,7 @@ %date %level [%thread] %logger{10} [%file:%line] -%kvp- %msg%n + FALSE @@ -12,7 +13,7 @@ %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} -%kvp- %msg%n - WARN + INFO diff --git a/pom.xml b/pom.xml index 3328e4b3..1a063d52 100644 --- a/pom.xml +++ b/pom.xml @@ -1,9 +1,9 @@ - + 4.0.0 + Nessus DIDComm + nessus-didcomm org.nessus.didcomm 1.0-SNAPSHOT @@ -112,6 +112,13 @@ + + + org.jetbrains.kotlin + kotlin-stdlib + + + src/main/kotlin src/test/kotlin diff --git a/services/agent/pom.xml b/services/agent/pom.xml index 1f462b44..2e62e6d5 100644 --- a/services/agent/pom.xml +++ b/services/agent/pom.xml @@ -36,6 +36,27 @@ jar + + org.nessus.didcomm + nessus-didcomm-core + ${project.version} + + + io.github.microutils + kotlin-logging-jvm + + + network.idu.acapy + aries-client-python + + + org.didcommx + didcomm + + + org.slf4j + slf4j-api + diff --git a/services/agent/src/main/kotlin/org/nessus/didcomm/agent/aries/AriesAgentService.kt b/services/agent/src/main/kotlin/org/nessus/didcomm/agent/aries/AriesAgentService.kt new file mode 100644 index 00000000..fe199d21 --- /dev/null +++ b/services/agent/src/main/kotlin/org/nessus/didcomm/agent/aries/AriesAgentService.kt @@ -0,0 +1,77 @@ +package org.nessus.didcomm.agent.aries + +import mu.KotlinLogging +import org.didcommx.didcomm.message.Attachment +import org.didcommx.didcomm.message.Message +import org.didcommx.didcomm.message.MessageBuilder +import org.didcommx.didcomm.utils.idGeneratorDefault +import org.hyperledger.acy_py.generated.model.InvitationRecord +import org.hyperledger.aries.AriesClient +import org.hyperledger.aries.api.connection.ConnectionRecord +import org.hyperledger.aries.api.out_of_band.CreateInvitationFilter +import org.hyperledger.aries.api.out_of_band.InvitationCreateRequest +import org.nessus.didcomm.model.MessageType.Companion.OUT_OF_BAND_INVITATION +import org.nessus.didcomm.model.MessageWriter +import org.nessus.didcomm.service.AgentService +import org.nessus.didcomm.wallet.NessusWallet +import org.slf4j.event.Level + +class UnsupportedMessageType(msg: String) : Exception(msg) + +class AriesAgentService : AgentService { + + private val log = KotlinLogging.logger {} + + companion object { + private val interceptorLogLevel = Level.DEBUG + fun adminClient(): AriesClient { + return AriesClientFactory.adminClient(level=interceptorLogLevel) + } + fun walletClient(wallet: NessusWallet): AriesClient { + return AriesClientFactory.walletClient(wallet=wallet, level=interceptorLogLevel) + } + } + + override fun createMessage(wallet: NessusWallet, type: String, body: Map) : Message { + val message = when(type) { + OUT_OF_BAND_INVITATION -> createOutOfBandInvitation(wallet, body) + else -> throw UnsupportedMessageType(type) + } + if (log.isDebugEnabled) { + log.debug("{}", MessageWriter.toJson(message, true)) + } + return message + } + +// fun sendMessage(msg: Message) { +// +// } + + private fun createId(): String { + return idGeneratorDefault() + } + + private fun createOutOfBandInvitation(wallet: NessusWallet, body: Map): Message { + + // Make the call to AcaPy + val reqObj = InvitationCreateRequest.builder() + .handshakeProtocols(listOf(ConnectionRecord.ConnectionProtocol.DID_EXCHANGE_V1.value)) + .build() + val filterObj = CreateInvitationFilter.builder().build() + val invRecord: InvitationRecord = walletClient(wallet).outOfBandCreateInvitation(reqObj, filterObj).get() + val invRecordMap = MessageWriter.toMutableMap(invRecord) + + // Get the msgId and remove the encoded URL that points to AcaPy + val msgId = invRecordMap["invi_msg_id"] as String + invRecordMap.remove("invitation_url") + + // Prepare the attachment + val datMap = mapOf("json" to invRecordMap) + val att0 = Attachment.builder(msgId, Attachment.Data.parse(datMap)).build() + + // Create the DIDcomm message + return MessageBuilder(createId(), body, OUT_OF_BAND_INVITATION) + .attachments(listOf(att0)).build() + } + +} diff --git a/services/agent/src/main/kotlin/org/nessus/didcomm/agent/aries/AriesClientFactory.kt b/services/agent/src/main/kotlin/org/nessus/didcomm/agent/aries/AriesClientFactory.kt new file mode 100644 index 00000000..d58f8f5e --- /dev/null +++ b/services/agent/src/main/kotlin/org/nessus/didcomm/agent/aries/AriesClientFactory.kt @@ -0,0 +1,141 @@ +package org.nessus.didcomm.agent.aries + +import com.google.gson.JsonSyntaxException +import mu.KotlinLogging +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import org.hyperledger.aries.AriesClient +import org.hyperledger.aries.config.GsonConfig +import org.nessus.didcomm.wallet.NessusWallet +import org.slf4j.event.Level +import java.util.concurrent.TimeUnit + +class AgentConfiguration private constructor( + val adminUrl: String?, + val userUrl: String?, + val apiKey: String? +) { + companion object { + private val host = System.getenv("ACAPY_HOSTNAME") ?: "localhost" + private val adminPort = System.getenv("ACAPY_ADMIN_PORT") ?: "8031" + private val userPort = System.getenv("ACAPY_USER_PORT") ?: "8030" + private val apiKey = System.getenv("ACAPY_ADMIN_API_KEY") ?: "adminkey" + val defaultConfiguration: AgentConfiguration = builder() + .adminUrl(String.format("http://%s:%s", host, adminPort)) + .userUrl(String.format("http://%s:%s", host, userPort)) + .apiKey(apiKey) + .build() + + fun builder(): AgentConfigurationBuilder { + return AgentConfigurationBuilder() + } + } + + override fun toString(): String { + val reductedApiKey = if (apiKey != null) apiKey.substring(0, 4) + "..." else null + return "AgentConfiguration [agentAdminUrl=$adminUrl, agentUserUrl=$userUrl, agentApiKey=$reductedApiKey]" + } + + class AgentConfigurationBuilder { + + private var adminUrl: String? = null + private var userUrl: String? = null + private var apiKey: String? = null + + fun adminUrl(adminUrl: String): AgentConfigurationBuilder { + this.adminUrl = adminUrl + return this + } + + fun userUrl(userUrl: String): AgentConfigurationBuilder { + this.userUrl = userUrl + return this + } + + fun apiKey(apiKey: String): AgentConfigurationBuilder { + this.apiKey = apiKey + return this + } + + fun build(): AgentConfiguration { + return AgentConfiguration(adminUrl, userUrl, apiKey) + } + } +} + +object AriesClientFactory { + + /** + * Create a client for the admin wallet + */ + fun adminClient(config: AgentConfiguration? = null, level: Level? = null): AriesClient { + val auxConfig = config ?: AgentConfiguration.defaultConfiguration + return walletClient(auxConfig, level=level) + } + + /** + * Create a client for a multitenant wallet + */ + fun walletClient(config: AgentConfiguration? = null, wallet: NessusWallet? = null, level: Level? = null): AriesClient { + val auxConfig = config ?: AgentConfiguration.defaultConfiguration + val loggingInterceptor = if (level != null) createHttpLoggingInterceptor(level) else null + return walletClient(auxConfig, wallet, null, loggingInterceptor) + } + + /** + * Create a client for a multitenant wallet + */ + fun walletClient( + config: AgentConfiguration, + wallet: NessusWallet? = null, + httpClient: OkHttpClient? = null, + loggingInterceptor: HttpLoggingInterceptor? = null + ): AriesClient { + val auxHttpClient = httpClient ?: OkHttpClient.Builder() + .writeTimeout(60, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .connectTimeout(60, TimeUnit.SECONDS) + .callTimeout(60, TimeUnit.SECONDS) + .addInterceptor(loggingInterceptor ?: createHttpLoggingInterceptor(Level.TRACE)) + .build() + return AriesClient.builder() + .url(config.adminUrl) + .apiKey(config.apiKey) + .bearerToken(wallet?.accessToken) + .client(auxHttpClient) + .build() + } + + private fun createHttpLoggingInterceptor(level: Level): HttpLoggingInterceptor { + val log = KotlinLogging.logger {} + val gson = GsonConfig.defaultConfig() + val pretty = GsonConfig.prettyPrinter() + fun log(spec: String, msg: String) { + when(level) { + Level.ERROR -> log.error(spec, msg) + Level.WARN -> log.warn(spec, msg) + Level.INFO -> log.info(spec, msg) + Level.DEBUG -> log.debug(spec, msg) + else -> log.trace(spec, msg) + } + } + val interceptor = HttpLoggingInterceptor { msg: String -> + if (log.isEnabledForLevel(level) && msg.isNotEmpty()) { + if (msg.startsWith("{")) { + try { + val json: Any = gson.fromJson(msg, Any::class.java) + log("\n{}", pretty.toJson(json)) + } catch (e: JsonSyntaxException) { + log("{}", msg) + } + } else { + log("{}", msg) + } + } + } + interceptor.level = HttpLoggingInterceptor.Level.BODY + interceptor.redactHeader("X-API-Key") + interceptor.redactHeader("Authorization") + return interceptor + } +} diff --git a/services/agent/src/main/kotlin/org/nessus/didcomm/agent/aries/AriesWalletService.kt b/services/agent/src/main/kotlin/org/nessus/didcomm/agent/aries/AriesWalletService.kt new file mode 100644 index 00000000..4c548793 --- /dev/null +++ b/services/agent/src/main/kotlin/org/nessus/didcomm/agent/aries/AriesWalletService.kt @@ -0,0 +1,110 @@ +package org.nessus.didcomm.agent.aries + +import mu.KotlinLogging +import org.hyperledger.acy_py.generated.model.DID +import org.hyperledger.aries.AriesClient +import org.hyperledger.aries.api.ledger.IndyLedgerRoles +import org.hyperledger.aries.api.ledger.RegisterNymFilter +import org.hyperledger.aries.api.multitenancy.CreateWalletRequest +import org.hyperledger.aries.api.multitenancy.RemoveWalletRequest +import org.hyperledger.aries.api.wallet.WalletDIDCreate +import org.nessus.didcomm.agent.aries.AriesAgentService.Companion.adminClient +import org.nessus.didcomm.service.WalletService +import org.nessus.didcomm.wallet.NessusWallet +import org.nessus.didcomm.wallet.WalletException + + +class AriesWalletService : WalletService { + + private val log = KotlinLogging.logger {} + + override fun createWallet(config: Map): NessusWallet { + + val walletName = assertConfigValue(config,"walletName") as String + val auxWalletKey = getConfigValue(config,"walletKey") + val walletKey = if (auxWalletKey != null) auxWalletKey as String else walletName + "Key" + val selfRegister = getConfigValue(config, "selfRegister") + val trusteeWallet = getConfigValue(config, "trusteeWallet") + val ledgerRole = getConfigValue(config, "ledgerRole") + val indyLedgerRole = if (ledgerRole != null) IndyLedgerRoles.valueOf(ledgerRole.toString()) else null + + val walletRequest = CreateWalletRequest.builder() + .walletName(walletName) + .walletKey(walletKey) + .build() + val walletRecord = adminClient().multitenancyWalletCreate(walletRequest).get() + val nessusWallet = NessusWallet(walletRecord.walletId, walletName, walletRecord.token) + WalletService.registry.putWallet(nessusWallet) + log.info("{}: [{}] {}", walletName, nessusWallet.walletId, nessusWallet) + + if (indyLedgerRole != null) { + if (selfRegister == false && trusteeWallet == null) + throw WalletException("LedgerRole $indyLedgerRole requires selfRegister or trusteeWallet") + + // Create a local DID for the wallet + val walletClient = AriesAgentService.walletClient(nessusWallet) + val did: DID = walletClient.walletDidCreate(WalletDIDCreate.builder().build()).get() + log.info("{}: {}", walletName, did) + if (trusteeWallet != null) { + val trustee: AriesClient = AriesAgentService.walletClient(trusteeWallet as NessusWallet) + val trusteeName: String = trusteeWallet.walletName + val nymResponse = trustee.ledgerRegisterNym( + RegisterNymFilter.builder() + .verkey(did.verkey) + .did(did.did) + .role(indyLedgerRole) + .build() + ).get() + log.info("{} for {}: {}", trusteeName, walletName, nymResponse) + } else if (selfRegister == true) { + // Register DID with the leder (out-of-band) + selfRegisterWithDid(walletName, did.did, did.verkey, indyLedgerRole) + } + + // Set the public DID for the wallet + walletClient.walletDidPublic(did.did) + val didEndpoint = walletClient.walletGetDidEndpoint(did.did).get() + log.info("{}: {}", walletName, didEndpoint) + } + + return nessusWallet + } + + override fun publicDid(wallet: NessusWallet): String? { + val walletClient = AriesAgentService.walletClient(wallet) + return walletClient.walletDidPublic().orElse(null)?.toString() + } + + override fun closeAndRemove(wallet: NessusWallet?) { + + if (wallet != null) { + + val walletId = wallet.walletId + val walletName = wallet.walletName + val accessToken = wallet.accessToken + log.info("Remove Wallet: {}", walletName) + + WalletService.registry.removeWallet(walletId) + + val adminClient: AriesClient = adminClient() + adminClient.multitenancyWalletRemove( + walletId, RemoveWalletRequest.builder() + .walletKey(accessToken) + .build() + ) + + // Wait for the wallet to get removed + Thread.sleep(500) + while (adminClient.multitenancyWallets(walletName).get().isNotEmpty()) { + Thread.sleep(500) + } + } + } + + private fun selfRegisterWithDid(alias: String, did: String, vkey: String, role: IndyLedgerRoles): Boolean { + val host: String = System.getenv("INDY_WEBSERVER_HOSTNAME") ?: "localhost" + val port: String = System.getenv("INDY_WEBSERVER_PORT") ?: "9000" + return SelfRegistrationHandler(String.format("http://%s:%s/register", host, port)) + .registerWithDID(alias, did, vkey, role) + } +} diff --git a/services/agent/src/main/kotlin/org/nessus/didcomm/agent/aries/HttpClientFactory.kt b/services/agent/src/main/kotlin/org/nessus/didcomm/agent/aries/HttpClientFactory.kt new file mode 100644 index 00000000..2e4a03a3 --- /dev/null +++ b/services/agent/src/main/kotlin/org/nessus/didcomm/agent/aries/HttpClientFactory.kt @@ -0,0 +1,42 @@ +package org.nessus.didcomm.agent.aries + +import mu.KotlinLogging +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import org.apache.commons.lang3.StringUtils +import org.hyperledger.aries.config.GsonConfig +import java.util.concurrent.TimeUnit + +object HttpClientFactory { + + private val log = KotlinLogging.logger {} + + fun createHttpClient(): OkHttpClient { + return OkHttpClient.Builder() + .writeTimeout(60, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .connectTimeout(60, TimeUnit.SECONDS) + .callTimeout(60, TimeUnit.SECONDS) + .addInterceptor(defaultLoggingInterceptor()) + .build() + } + + fun defaultLoggingInterceptor(): HttpLoggingInterceptor { + val gson = GsonConfig.defaultConfig() + val pretty = GsonConfig.prettyPrinter() + val interceptor = HttpLoggingInterceptor { msg: String -> + if (log.isTraceEnabled && StringUtils.isNotEmpty(msg)) { + if (msg.startsWith("{")) { + val json = gson.fromJson(msg, Any::class.java) + log.trace("\n{}", pretty.toJson(json)) + } else { + log.trace("{}", msg) + } + } + } + interceptor.level = HttpLoggingInterceptor.Level.BODY + interceptor.redactHeader("Authorization") + interceptor.redactHeader("X-API-Key") + return interceptor + } +} diff --git a/services/agent/src/main/kotlin/org/nessus/didcomm/agent/aries/SelfRegistrationHandler.kt b/services/agent/src/main/kotlin/org/nessus/didcomm/agent/aries/SelfRegistrationHandler.kt new file mode 100644 index 00000000..556837ff --- /dev/null +++ b/services/agent/src/main/kotlin/org/nessus/didcomm/agent/aries/SelfRegistrationHandler.kt @@ -0,0 +1,86 @@ +package org.nessus.didcomm.agent.aries + +import com.google.gson.JsonObject +import mu.KotlinLogging +import okhttp3.MediaType +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import org.apache.commons.lang3.StringUtils +import org.hyperledger.acy_py.generated.model.DID +import org.hyperledger.aries.api.exception.AriesException +import org.hyperledger.aries.api.ledger.IndyLedgerRoles +import org.hyperledger.aries.config.GsonConfig +import java.util.concurrent.TimeUnit + +class SelfRegistrationHandler(private val networkURL: String) { + + private val log = KotlinLogging.logger {} + private val gson = GsonConfig.defaultConfig() + + companion object { + private val httpClient = OkHttpClient.Builder() + .writeTimeout(60, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .connectTimeout(60, TimeUnit.SECONDS) + .callTimeout(60, TimeUnit.SECONDS) + .build() + } + + fun registerWithDID(alias: String?, did: String?, verkey: String?, role: IndyLedgerRoles?): Boolean { + var json = JsonObject() + json.addProperty("did", did) + json.addProperty("verkey", verkey) + if (alias != null) json.addProperty("alias", alias) + if (role != null) json.addProperty("role", role.toString()) + log.info("Self register: {}", json) + val res = call(buildPost(json)) + json = gson.fromJson(res, JsonObject::class.java) + log.info("Respose: {}", json) + return true + } + + fun registerWithSeed(alias: String?, seed: String?, role: IndyLedgerRoles?): DID { + val json = JsonObject() + json.addProperty("seed", seed) + if (alias != null) json.addProperty("alias", alias) + if (role != null) json.addProperty("role", role.toString()) + log.info("Self register: {}", json) + val res = call(buildPost(json)) + val did = gson.fromJson(res, DID::class.java) + log.info("Respose: {}", did) + return did + } + + private fun buildPost(body: Any): Request { + val jsonType: MediaType = "application/json; charset=utf-8".toMediaType() + val jsonBody: RequestBody = gson.toJson(body).toRequestBody(jsonType) + return Request.Builder().url(networkURL).post(jsonBody).build() + } + + private fun call(req: Request): String? { + var result: String? = null + httpClient.newCall(req).execute().use { resp -> + if (resp.isSuccessful && resp.body != null) { + result = resp.body!!.string() + } else if (!resp.isSuccessful) { + handleError(resp) + } + } + return result + } + + private fun handleError(resp: Response) { + val msg = if (StringUtils.isNotEmpty(resp.message)) resp.message else "" + val body = if (resp.body != null) resp.body!!.string() else "" + log.error("code={} message={}\nbody={}", resp.code, msg, body) + throw AriesException(resp.code, """ + $msg + $body + """.trimIndent() + ) + } +}