Skip to content

Commit 5e01569

Browse files
authored
Implement DID Exchange Protocol (#28)
Signed-off-by: conanoc <conanoc@gmail.com>
1 parent 72c218c commit 5e01569

30 files changed

+852
-20
lines changed

.idea/.gitignore

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

DEVELOP.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,11 @@ Then, get the invitation urls from faber agent.
9595
Run `testDemoFaber()` with this url and operate the faber agent to issue a credential.
9696
Aliasing `10.0.2.2` as `lo0` is needed to allow the local mediator and the local faber can communicate with each other with the IP `10.0.2.2`.
9797

98+
You can see the debug messages of faber agent by adding the following option to the agent config in `BaseAgent.ts`:
99+
```javascript
100+
logger: new ConsoleLogger(LogLevel.debug),
101+
```
102+
98103
### Testing using the sample app
99104

100105
You can run the sample app in `/app` directory. This sample app uses [Indicio Public Mediator](https://indicio-tech.github.io/mediator/) and connects to other agents by receiving invitions by scanning QR codes or by entering invitation urls. You can use the sample app to test the credential exchange flow and the proof exchange flow.

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ Aries Framework Kotlin supports most of [AIP 1.0](https://github.com/hyperledger
1515
- Does not implement alternate begining (Prover begins with proposal)
1616
- ✅ HTTP & WebSocket Transport
1717
- ✅ ([RFC 0434](https://github.com/hyperledger/aries-rfcs/blob/main/features/0434-outofband/README.md)) Out of Band Protocol (AIP 2.0)
18+
- ✅ ([RFC 0023](https://github.com/hyperledger/aries-rfcs/tree/main/features/0023-did-exchange)) DID Exchange Protocol (AIP 2.0)
1819

1920
### Not supported yet
20-
- ❌ ([RFC 0023](https://github.com/hyperledger/aries-rfcs/tree/main/features/0023-did-exchange)) DID Exchange Protocol (AIP 2.0)
2121
- ❌ ([RFC 0035](https://github.com/hyperledger/aries-rfcs/blob/main/features/0035-report-problem/README.md)) Report Problem Protocol
2222
- ❌ ([RFC 0056](https://github.com/hyperledger/aries-rfcs/blob/main/features/0056-service-decorator/README.md)) Service Decorator
2323

@@ -44,6 +44,7 @@ allprojects {
4444
password = "your github token for read:packages"
4545
}
4646
}
47+
maven { url 'https://jitpack.io' }
4748
}
4849
}
4950
```

ariesframework/build.gradle

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,26 +46,27 @@ ktlint {
4646
}
4747

4848
dependencies {
49-
implementation("org.hyperledger:anoncreds_uniffi:0.2.0-wrapper.1")
50-
implementation("org.hyperledger:indy_vdr_uniffi:0.2.1-wrapper.2")
51-
implementation("org.hyperledger:askar_uniffi:0.2.0-wrapper.1")
49+
implementation 'org.hyperledger:anoncreds_uniffi:0.2.0-wrapper.1'
50+
implementation 'org.hyperledger:indy_vdr_uniffi:0.2.1-wrapper.2'
51+
implementation 'org.hyperledger:askar_uniffi:0.2.0-wrapper.1'
5252

53-
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.0"
53+
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.0'
5454
implementation 'org.slf4j:slf4j-api:1.7.32'
5555
implementation 'ch.qos.logback:logback-classic:1.2.6'
5656
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.0'
5757
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-jdk8:1.7.0'
5858
implementation 'org.jetbrains.kotlinx:kotlinx-datetime:0.4.0'
5959
implementation 'com.squareup.okhttp3:okhttp:4.10.0'
60+
implementation 'org.didcommx:peerdid:0.5.0'
6061

6162
implementation 'androidx.core:core-ktx:1.7.0'
6263
implementation 'androidx.appcompat:appcompat:1.6.1'
6364
implementation 'com.google.android.material:material:1.8.0'
6465
testImplementation 'junit:junit:4.13.2'
65-
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.0"
66+
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.0'
6667
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
6768
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
68-
androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.0"
69+
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.0'
6970
}
7071

7172
ext["githubUsername"] = null

ariesframework/src/androidTest/java/org/hyperledger/ariesframework/agent/AgentTest.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import org.hyperledger.ariesframework.connection.messages.ConnectionInvitationMe
1010
import org.hyperledger.ariesframework.connection.models.ConnectionState
1111
import org.hyperledger.ariesframework.connection.repository.ConnectionRecord
1212
import org.hyperledger.ariesframework.oob.messages.OutOfBandInvitation
13+
import org.hyperledger.ariesframework.oob.models.HandshakeProtocol
1314
import org.junit.After
1415
import org.junit.Assert.assertEquals
1516
import org.junit.Assert.assertFalse
@@ -152,12 +153,13 @@ class AgentTest {
152153
fun testDemoFaber() = runBlocking {
153154
val context = InstrumentationRegistry.getInstrumentation().targetContext
154155
var config = TestHelper.getBcorvinConfig()
156+
config.preferredHandshakeProtocol = HandshakeProtocol.DidExchange11
155157
config.mediatorConnectionsInvite = URL(mediatorInvitationUrl).readText()
156158

157159
agent = Agent(context, config)
158160
agent.initialize()
159161

160-
val faberInvitationUrl = "http://localhost:9001?oob=eyJAdHlwZSI6Imh0dHBzOi8vZGlkY29tbS5vcmcvb3V0LW9mLWJhbmQvMS4xL2ludml0YXRpb24iLCJAaWQiOiIyNDFjNjNkMC1mMjZkLTRlNDktYjkyYy00N2JhYTk1MzAwMzUiLCJsYWJlbCI6ImZhYmVyIiwiYWNjZXB0IjpbImRpZGNvbW0vYWlwMSIsImRpZGNvbW0vYWlwMjtlbnY9cmZjMTkiXSwiaGFuZHNoYWtlX3Byb3RvY29scyI6WyJodHRwczovL2RpZGNvbW0ub3JnL2RpZGV4Y2hhbmdlLzEuMSIsImh0dHBzOi8vZGlkY29tbS5vcmcvY29ubmVjdGlvbnMvMS4wIl0sInNlcnZpY2VzIjpbeyJpZCI6IiNpbmxpbmUtMCIsInNlcnZpY2VFbmRwb2ludCI6Imh0dHA6Ly8xMC4wLjIuMjo5MDAxIiwidHlwZSI6ImRpZC1jb21tdW5pY2F0aW9uIiwicmVjaXBpZW50S2V5cyI6WyJkaWQ6a2V5Ono2TWttcDZNNjhNeHFuazlGUzdFZU5lUHpETmNSWXhpR1lUcUJFVm4yRjhENk41YSJdLCJyb3V0aW5nS2V5cyI6W119XX0" // ktlint-disable max-line-length
162+
val faberInvitationUrl = "http://localhost:9001?oob=eyJAdHlwZSI6Imh0dHBzOi8vZGlkY29tbS5vcmcvb3V0LW9mLWJhbmQvMS4xL2ludml0YXRpb24iLCJAaWQiOiIzNDU5NDk5NS0xOTk3LTQ5ODItYTQ0MC0xMjE2OTk4YjllM2MiLCJsYWJlbCI6ImZhYmVyIiwiYWNjZXB0IjpbImRpZGNvbW0vYWlwMSIsImRpZGNvbW0vYWlwMjtlbnY9cmZjMTkiXSwiaGFuZHNoYWtlX3Byb3RvY29scyI6WyJodHRwczovL2RpZGNvbW0ub3JnL2RpZGV4Y2hhbmdlLzEuMSIsImh0dHBzOi8vZGlkY29tbS5vcmcvY29ubmVjdGlvbnMvMS4wIl0sInNlcnZpY2VzIjpbeyJpZCI6IiNpbmxpbmUtMCIsInNlcnZpY2VFbmRwb2ludCI6Imh0dHA6Ly8xMC4wLjIuMjo5MDAxIiwidHlwZSI6ImRpZC1jb21tdW5pY2F0aW9uIiwicmVjaXBpZW50S2V5cyI6WyJkaWQ6a2V5Ono2TWtrcnQ2NURBVG5zeUs2bTlwZFZIY01FWmNLTFJCOFl5VnhaYjU3dkFIN3JRNyJdLCJyb3V0aW5nS2V5cyI6W119XX0" // ktlint-disable max-line-length
161163
val invitation = OutOfBandInvitation.fromUrl(faberInvitationUrl)
162164
agent.oob.receiveInvitation(invitation)
163165

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package org.hyperledger.ariesframework.connection
2+
3+
import androidx.test.platform.app.InstrumentationRegistry
4+
import kotlinx.coroutines.runBlocking
5+
import kotlinx.coroutines.test.runTest
6+
import org.hyperledger.ariesframework.TestHelper
7+
import org.hyperledger.ariesframework.agent.Agent
8+
import org.hyperledger.ariesframework.agent.SubjectOutboundTransport
9+
import org.hyperledger.ariesframework.connection.models.ConnectionState
10+
import org.hyperledger.ariesframework.oob.models.CreateOutOfBandInvitationConfig
11+
import org.hyperledger.ariesframework.oob.models.HandshakeProtocol
12+
import org.junit.After
13+
import org.junit.Assert.assertEquals
14+
import org.junit.Before
15+
import org.junit.Test
16+
17+
class DidExchangeTest {
18+
lateinit var faberAgent: Agent
19+
lateinit var aliceAgent: Agent
20+
21+
@Before
22+
fun setUp() = runTest {
23+
val faberConfig = TestHelper.getBaseConfig("faber")
24+
val aliceConfig = TestHelper.getBaseConfig("alice")
25+
26+
val context = InstrumentationRegistry.getInstrumentation().targetContext
27+
faberAgent = Agent(context, faberConfig)
28+
aliceAgent = Agent(context, aliceConfig)
29+
30+
faberAgent.setOutboundTransport(SubjectOutboundTransport(aliceAgent))
31+
aliceAgent.setOutboundTransport(SubjectOutboundTransport(faberAgent))
32+
33+
faberAgent.initialize()
34+
aliceAgent.initialize()
35+
}
36+
37+
@After
38+
fun tearDown() = runTest {
39+
faberAgent.reset()
40+
aliceAgent.reset()
41+
}
42+
43+
@Test
44+
fun testOobConnection() = runBlocking {
45+
val outOfBandRecord = faberAgent.oob.createInvitation(CreateOutOfBandInvitationConfig())
46+
val invitation = outOfBandRecord.outOfBandInvitation
47+
48+
aliceAgent.agentConfig.preferredHandshakeProtocol = HandshakeProtocol.DidExchange11
49+
val (_, connection) = aliceAgent.oob.receiveInvitation(invitation)
50+
val aliceFaberConnection = connection
51+
?: throw Exception("Connection is nil after receiving oob invitation")
52+
assertEquals(aliceFaberConnection.state, ConnectionState.Complete)
53+
54+
val faberAliceConnection = faberAgent.connectionService.findByInvitationKey(invitation.invitationKey()!!)
55+
?: throw Exception("Cannot find connection by invitation key")
56+
assertEquals(faberAliceConnection.state, ConnectionState.Complete)
57+
58+
assertEquals(TestHelper.isConnectedWith(faberAliceConnection, aliceFaberConnection), true)
59+
assertEquals(TestHelper.isConnectedWith(aliceFaberConnection, faberAliceConnection), true)
60+
}
61+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package org.hyperledger.ariesframework.connection
2+
3+
import androidx.test.platform.app.InstrumentationRegistry
4+
import kotlinx.coroutines.test.runTest
5+
import kotlinx.serialization.json.Json
6+
import kotlinx.serialization.json.JsonObject
7+
import kotlinx.serialization.json.jsonPrimitive
8+
import org.hyperledger.ariesframework.TestHelper
9+
import org.hyperledger.ariesframework.agent.Agent
10+
import org.hyperledger.ariesframework.agent.decorators.JwsFlattenedFormat
11+
import org.hyperledger.ariesframework.decodeBase64url
12+
import org.junit.After
13+
import org.junit.Assert
14+
import org.junit.Before
15+
import org.junit.Test
16+
17+
class JwsServiceTest {
18+
lateinit var agent: Agent
19+
val seed = "00000000000000000000000000000My2"
20+
val verkey = "kqa2HyagzfMAq42H5f9u3UMwnSBPQx2QfrSyXbUPxMn"
21+
val payload = "hello".toByteArray()
22+
23+
@Before
24+
fun setUp() = runTest {
25+
val context = InstrumentationRegistry.getInstrumentation().targetContext
26+
val config = TestHelper.getBaseConfig()
27+
agent = Agent(context, config)
28+
agent.initialize()
29+
30+
val didInfo = agent.wallet.createDid(seed)
31+
Assert.assertEquals(didInfo.verkey, verkey)
32+
}
33+
34+
@After
35+
fun tearDown() = runTest {
36+
agent.reset()
37+
}
38+
39+
@Test
40+
fun testCreateAndVerify() = runTest {
41+
val jws = agent.jwsService.createJws(payload, verkey)
42+
Assert.assertEquals(
43+
"did:key:z6MkfD6ccYE22Y9pHKtixeczk92MmMi2oJCP6gmNooZVKB9A",
44+
jws.header?.get("kid"),
45+
)
46+
val protectedJson = jws.protected.decodeBase64url().decodeToString()
47+
val protected = Json.decodeFromString<JsonObject>(protectedJson)
48+
Assert.assertEquals("EdDSA", protected["alg"]?.jsonPrimitive?.content)
49+
Assert.assertNotNull(protected["jwk"])
50+
51+
val (valid, signer) = agent.jwsService.verifyJws(jws, payload)
52+
Assert.assertTrue(valid)
53+
Assert.assertEquals(signer, verkey)
54+
}
55+
56+
@Test
57+
fun testFlattenedJws() = runTest {
58+
val jws = agent.jwsService.createJws(payload, verkey)
59+
val list = JwsFlattenedFormat(arrayListOf(jws))
60+
61+
val (valid, signer) = agent.jwsService.verifyJws(list, payload)
62+
Assert.assertTrue(valid)
63+
Assert.assertEquals(signer, verkey)
64+
}
65+
66+
@Test
67+
fun testVerifyFail() = runTest {
68+
val wrongPayload = "world".toByteArray()
69+
val jws = agent.jwsService.createJws(payload, verkey)
70+
val (valid, signer) = agent.jwsService.verifyJws(jws, wrongPayload)
71+
Assert.assertFalse(valid)
72+
Assert.assertEquals(signer, verkey)
73+
}
74+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package org.hyperledger.ariesframework.connection
2+
3+
import androidx.test.platform.app.InstrumentationRegistry
4+
import kotlinx.coroutines.test.runTest
5+
import org.hyperledger.ariesframework.TestHelper
6+
import org.hyperledger.ariesframework.agent.Agent
7+
import org.hyperledger.ariesframework.connection.models.didauth.DidComm
8+
import org.junit.After
9+
import org.junit.Assert.assertEquals
10+
import org.junit.Assert.assertTrue
11+
import org.junit.Before
12+
import org.junit.Test
13+
14+
class PeerDIDServiceTest {
15+
lateinit var agent: Agent
16+
val verkey = "3uhKmLCRYfe5YWDsgBC4VNTKk3RbnFCzgjVH3zmSKHWa"
17+
18+
@Before
19+
fun setUp() = runTest {
20+
val context = InstrumentationRegistry.getInstrumentation().targetContext
21+
val config = TestHelper.getBaseConfig()
22+
agent = Agent(context, config)
23+
agent.initialize()
24+
}
25+
26+
@After
27+
fun tearDown() = runTest {
28+
agent.reset()
29+
}
30+
31+
@Test
32+
fun testPeerDIDwithLegacyService() = runTest {
33+
val peerDID = agent.peerDIDService.createPeerDID(verkey)
34+
parsePeerDID(peerDID)
35+
}
36+
37+
@Test
38+
fun testPeerDIDwithDidCommV2Service() = runTest {
39+
val peerDID = agent.peerDIDService.createPeerDID(verkey, useLegacyService = false)
40+
parsePeerDID(peerDID)
41+
}
42+
43+
suspend fun parsePeerDID(peerDID: String) {
44+
assertTrue(peerDID.startsWith("did:peer:2"))
45+
46+
val didDoc = agent.peerDIDService.parsePeerDID(peerDID)
47+
assertEquals(didDoc.id, peerDID)
48+
assertEquals(didDoc.publicKey.size, 1)
49+
assertEquals(didDoc.service.size, 1)
50+
assertEquals(didDoc.authentication.size, 1)
51+
assertEquals(didDoc.publicKey[0].value, verkey)
52+
53+
val service = didDoc.service.first()
54+
assertTrue(service is DidComm)
55+
val didCommService = service as DidComm
56+
assertEquals(didCommService.recipientKeys.size, 1)
57+
assertEquals(didCommService.recipientKeys[0], verkey)
58+
assertEquals(didCommService.routingKeys?.size, 0)
59+
assertEquals(didCommService.serviceEndpoint, agent.agentConfig.endpoints[0])
60+
}
61+
}

ariesframework/src/androidTest/java/org/hyperledger/ariesframework/connection/didauth/DidDocTest.kt

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,18 @@ class DidDocTest {
5454
"recipientKeys": ["DADEajsDSaksLng9h"],
5555
"routingKeys": ["DADEajsDSaksLng9h"],
5656
"priority": 10
57+
},
58+
{
59+
"id": "did:example:123456789abcdefghi#didcomm-1",
60+
"type": "DIDCommMessaging",
61+
"serviceEndpoint": {
62+
"uri": "https://example.com/path",
63+
"accept": [
64+
"didcomm/v2",
65+
"didcomm/aip2;env=rfc587"
66+
],
67+
"routingKeys": ["did:example:somemediator#somekey"]
68+
}
5769
}
5870
],
5971
"authentication": [
@@ -94,6 +106,7 @@ class DidDocTest {
94106
assert(didDoc.service[0] is DidDocumentService)
95107
assert(didDoc.service[1] is IndyAgentService)
96108
assert(didDoc.service[2] is DidCommService)
109+
assert(didDoc.service[3] is DidCommV2Service)
97110

98111
assert(didDoc.authentication[0] is ReferencedAuthentication)
99112
assert(didDoc.authentication[1] is EmbeddedAuthentication)
@@ -105,7 +118,7 @@ class DidDocTest {
105118
assertEquals("did:sov:LjgpST2rjsoxYegQDRm7EL", encodedJson["id"]!!.jsonPrimitive.content)
106119
assertEquals("https://w3id.org/did/v1", encodedJson["@context"]!!.jsonPrimitive.content)
107120
assertEquals(3, encodedJson["publicKey"]!!.jsonArray.size)
108-
assertEquals(3, encodedJson["service"]!!.jsonArray.size)
121+
assertEquals(4, encodedJson["service"]!!.jsonArray.size)
109122
assertEquals(2, encodedJson["authentication"]!!.jsonArray.size)
110123

111124
assertEquals("3", encodedJson["publicKey"]!!.jsonArray[0].jsonObject["id"]!!.jsonPrimitive.content)

ariesframework/src/main/java/org/hyperledger/ariesframework/agent/Agent.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ import org.hyperledger.ariesframework.anoncreds.storage.RevocationRegistryReposi
1010
import org.hyperledger.ariesframework.basicmessage.BasicMessageCommand
1111
import org.hyperledger.ariesframework.connection.ConnectionCommand
1212
import org.hyperledger.ariesframework.connection.ConnectionService
13+
import org.hyperledger.ariesframework.connection.DidExchangeService
14+
import org.hyperledger.ariesframework.connection.JwsService
15+
import org.hyperledger.ariesframework.connection.PeerDIDService
1316
import org.hyperledger.ariesframework.connection.repository.ConnectionRepository
1417
import org.hyperledger.ariesframework.credentials.CredentialService
1518
import org.hyperledger.ariesframework.credentials.CredentialsCommand
@@ -35,6 +38,9 @@ class Agent(val context: Context, val agentConfig: AgentConfig) {
3538
val messageSender = MessageSender(this)
3639
val connectionRepository = ConnectionRepository(this)
3740
val connectionService = ConnectionService(this)
41+
val didExchangeService = DidExchangeService(this)
42+
val peerDIDService = PeerDIDService(this)
43+
val jwsService = JwsService(this)
3844
val connections = ConnectionCommand(this, dispatcher)
3945
val mediationRecipient = MediationRecipient(this, dispatcher)
4046
val outOfBandRepository = OutOfBandRepository(this)

ariesframework/src/main/java/org/hyperledger/ariesframework/agent/AgentConfig.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package org.hyperledger.ariesframework.agent
33
import kotlinx.serialization.SerialName
44
import kotlinx.serialization.Serializable
55
import org.hyperledger.ariesframework.credentials.models.AutoAcceptCredential
6+
import org.hyperledger.ariesframework.oob.models.HandshakeProtocol
67
import org.hyperledger.ariesframework.proofs.models.AutoAcceptProof
78

89
@Serializable
@@ -35,6 +36,7 @@ enum class MediatorPickupStrategy {
3536
* @property publicDidSeed The seed to use for the public did. The public did is used to register items on the ledger. For testing.
3637
* @property agentEndpoints The agent endpoints to use for testing.
3738
* @property useReturnRoute Whether to use the transport-return-route. Default is true.
39+
* @property preferredHandshakeProtocol The preferred handshake protocol to use. Default is [HandshakeProtocol.Connections].
3840
* @property endpoints The endpoints of the agent. Read only.
3941
*/
4042
@Serializable
@@ -58,6 +60,7 @@ data class AgentConfig(
5860
var publicDidSeed: String? = null,
5961
var agentEndpoints: List<String>? = null,
6062
var useReturnRoute: Boolean = true,
63+
var preferredHandshakeProtocol: HandshakeProtocol = HandshakeProtocol.Connections,
6164
) {
6265
val endpoints: List<String>
6366
get() = agentEndpoints ?: listOf("didcomm:transport/queue")

ariesframework/src/main/java/org/hyperledger/ariesframework/agent/decorators/Attachment.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import kotlinx.serialization.json.Json
88
import kotlinx.serialization.json.JsonObject
99
import org.hyperledger.ariesframework.decodeBase64
1010
import org.hyperledger.ariesframework.encodeBase64
11+
import java.util.UUID
1112

1213
@Serializable
1314
class AttachmentData(
@@ -54,7 +55,7 @@ class Attachment(
5455
}
5556

5657
companion object {
57-
fun fromData(data: ByteArray, id: String): Attachment {
58+
fun fromData(data: ByteArray, id: String = UUID.randomUUID().toString()): Attachment {
5859
return Attachment(
5960
id = id,
6061
mimetype = "application/json",

0 commit comments

Comments
 (0)