Skip to content

Commit c029523

Browse files
authored
Merge pull request #357 from credebl/feat/sd-jwt-revocation-flow
feat: sd-jwt revocation flow
2 parents 306c524 + c1ebf1a commit c029523

17 files changed

Lines changed: 647 additions & 60 deletions

.env.demo

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,14 @@ OID4VP_AUTH_REQUEST_PROOF_REQUEST_EXPIRY=3600
6868
APP_JSON_BODY_SIZE=5mb
6969
APP_URL_ENCODED_BODY_SIZE=5mb
7070

71+
72+
API_KEY=supersecret-that-too-16chars
73+
UPDATE_JWT_SECRET=false
74+
75+
76+
STATUS_LIST_SERVER_URL=https://dev-status-list.sovio.id/
77+
STATUS_LIST_API_KEY=test_key
78+
STATUS_LIST_DEFAULT_SIZE=131072
7179
BCOVRIN_TEST_GENESIS='{"reqSignature":{},"txn":{"data":{"data":{"alias":"Node1","blskey":"4N8aUNHSgjQVgkpm8nhNEfDf6txHznoYREg9kirmJrkivgL4oSEimFF6nsQ6M41QvhM2Z33nves5vfSn9n1UwNFJBYtWVnHYMATn76vLuL3zU88KyeAYcHfsih3He6UHcXDxcaecHVz6jhCYz1P2UZn2bDVruL5wXpehgBfBaLKm3Ba","blskey_pop":"RahHYiCvoNCtPTrVtP7nMC5eTYrsUA8WjXbdhNc8debh1agE9bGiJxWBXYNFbnJXoXhWFMvyqhqhRoq737YQemH5ik9oL7R4NTTCz2LEZhkgLJzB3QRQqJyBNyv7acbdHrAT8nQ9UkLbaVL9NBpnWXBTw4LEMePaSHEw66RzPNdAX1","client_ip":"138.197.138.255","client_port":9702,"node_ip":"138.197.138.255","node_port":9701,"services":["VALIDATOR"]},"dest":"Gw6pDLhcBcoQesN72qfotTgFa7cbuqZpkX3Xo6pLhPhv"},"metadata":{"from":"Th7MpTaRZVRYnPiabds81Y"},"type":"0"},"txnMetadata":{"seqNo":1,"txnId":"fea82e10e894419fe2bea7d96296a6d46f50f93f9eeda954ec461b2ed2950b62"},"ver":"1"}
7280
{"reqSignature":{},"txn":{"data":{"data":{"alias":"Node2","blskey":"37rAPpXVoxzKhz7d9gkUe52XuXryuLXoM6P6LbWDB7LSbG62Lsb33sfG7zqS8TK1MXwuCHj1FKNzVpsnafmqLG1vXN88rt38mNFs9TENzm4QHdBzsvCuoBnPH7rpYYDo9DZNJePaDvRvqJKByCabubJz3XXKbEeshzpz4Ma5QYpJqjk","blskey_pop":"Qr658mWZ2YC8JXGXwMDQTzuZCWF7NK9EwxphGmcBvCh6ybUuLxbG65nsX4JvD4SPNtkJ2w9ug1yLTj6fgmuDg41TgECXjLCij3RMsV8CwewBVgVN67wsA45DFWvqvLtu4rjNnE9JbdFTc1Z4WCPA3Xan44K1HoHAq9EVeaRYs8zoF5","client_ip":"138.197.138.255","client_port":9704,"node_ip":"138.197.138.255","node_port":9703,"services":["VALIDATOR"]},"dest":"8ECVSk179mjsjKRLWiQtssMLgp6EPhWXtaYyStWPSGAb"},"metadata":{"from":"EbP4aYNeTHL6q385GuVpRV"},"type":"0"},"txnMetadata":{"seqNo":2,"txnId":"1ac8aece2a18ced660fef8694b61aac3af08ba875ce3026a160acbc3a3af35fc"},"ver":"1"}
7381
{"reqSignature":{},"txn":{"data":{"data":{"alias":"Node3","blskey":"3WFpdbg7C5cnLYZwFZevJqhubkFALBfCBBok15GdrKMUhUjGsk3jV6QKj6MZgEubF7oqCafxNdkm7eswgA4sdKTRc82tLGzZBd6vNqU8dupzup6uYUf32KTHTPQbuUM8Yk4QFXjEf2Usu2TJcNkdgpyeUSX42u5LqdDDpNSWUK5deC5","blskey_pop":"QwDeb2CkNSx6r8QC8vGQK3GRv7Yndn84TGNijX8YXHPiagXajyfTjoR87rXUu4G4QLk2cF8NNyqWiYMus1623dELWwx57rLCFqGh7N4ZRbGDRP4fnVcaKg1BcUxQ866Ven4gw8y4N56S5HzxXNBZtLYmhGHvDtk6PFkFwCvxYrNYjh","client_ip":"138.197.138.255","client_port":9706,"node_ip":"138.197.138.255","node_port":9705,"services":["VALIDATOR"]},"dest":"DKVxG2fXXTU8yT5N7hGEbXB3dfdAnYv1JczDUHpmDxya"},"metadata":{"from":"4cU41vWW82ArfxJxHkzXPG"},"type":"0"},"txnMetadata":{"seqNo":3,"txnId":"7e9f355dffa78ed24668f0e0e369fd8c224076571c51e2ea8be5f26479edebe4"},"ver":"1"}

.env.sample

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,14 @@ OID4VP_AUTH_REQUEST_PROOF_REQUEST_EXPIRY=3600
6464
APP_JSON_BODY_SIZE=5mb
6565
APP_URL_ENCODED_BODY_SIZE=5mb
6666

67+
# Security
68+
API_KEY=supersecret-that-too-16chars
69+
UPDATE_JWT_SECRET=false
70+
71+
# Status List
72+
STATUS_LIST_SERVER_URL=https://dev-status-list.sovio.id/
73+
STATUS_LIST_API_KEY=test_key
74+
STATUS_LIST_DEFAULT_SIZE=131072
6775
# Specify Bcovrin test genesis
6876
BCOVRIN_TEST_GENESIS=`{"reqSignature":{},"txn":{"data":{"data":{"alias":"Node1","blskey":"4N8aUNHSgjQVgkpm8nhNEfDf6txHznoYREg9kirmJrkivgL4oSEimFF6nsQ6M41QvhM2Z33nves5vfSn9n1UwNFJBYtWVnHYMATn76vLuL3zU88KyeAYcHfsih3He6UHcXDxcaecHVz6jhCYz1P2UZn2bDVruL5wXpehgBfBaLKm3Ba","blskey_pop":"RahHYiCvoNCtPTrVtP7nMC5eTYrsUA8WjXbdhNc8debh1agE9bGiJxWBXYNFbnJXoXhWFMvyqhqhRoq737YQemH5ik9oL7R4NTTCz2LEZhkgLJzB3QRQqJyBNyv7acbdHrAT8nQ9UkLbaVL9NBpnWXBTw4LEMePaSHEw66RzPNdAX1","client_ip":"138.197.138.255","client_port":9702,"node_ip":"138.197.138.255","node_port":9701,"services":["VALIDATOR"]},"dest":"Gw6pDLhcBcoQesN72qfotTgFa7cbuqZpkX3Xo6pLhPhv"},"metadata":{"from":"Th7MpTaRZVRYnPiabds81Y"},"type":"0"},"txnMetadata":{"seqNo":1,"txnId":"fea82e10e894419fe2bea7d96296a6d46f50f93f9eeda954ec461b2ed2950b62"},"ver":"1"}
6977
{"reqSignature":{},"txn":{"data":{"data":{"alias":"Node2","blskey":"37rAPpXVoxzKhz7d9gkUe52XuXryuLXoM6P6LbWDB7LSbG62Lsb33sfG7zqS8TK1MXwuCHj1FKNzVpsnafmqLG1vXN88rt38mNFs9TENzm4QHdBzsvCuoBnPH7rpYYDo9DZNJePaDvRvqJKByCabubJz3XXKbEeshzpz4Ma5QYpJqjk","blskey_pop":"Qr658mWZ2YC8JXGXwMDQTzuZCWF7NK9EwxphGmcBvCh6ybUuLxbG65nsX4JvD4SPNtkJ2w9ug1yLTj6fgmuDg41TgECXjLCij3RMsV8CwewBVgVN67wsA45DFWvqvLtu4rjNnE9JbdFTc1Z4WCPA3Xan44K1HoHAq9EVeaRYs8zoF5","client_ip":"138.197.138.255","client_port":9704,"node_ip":"138.197.138.255","node_port":9703,"services":["VALIDATOR"]},"dest":"8ECVSk179mjsjKRLWiQtssMLgp6EPhWXtaYyStWPSGAb"},"metadata":{"from":"EbP4aYNeTHL6q385GuVpRV"},"type":"0"},"txnMetadata":{"seqNo":2,"txnId":"1ac8aece2a18ced660fef8694b61aac3af08ba875ce3026a160acbc3a3af35fc"},"ver":"1"}

samples/cliConfig.json

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"label": "AFJ Rest Agent 1",
3-
"walletId": "sample",
4-
"walletKey": "sample",
3+
"walletId": "sample10",
4+
"walletKey": "sample10",
55
"walletType": "postgres",
66
"walletUrl": "localhost:5432",
77
"walletAccount": "postgres",
@@ -23,7 +23,9 @@
2323
"indyNamespace": "bcovrin:testnet"
2424
}
2525
],
26-
"endpoint": ["http://localhost:4002"],
26+
"endpoint": [
27+
"http://localhost:4002"
28+
],
2729
"autoAcceptConnections": true,
2830
"autoAcceptCredentials": "always",
2931
"autoAcceptProofs": "contentApproved",
@@ -34,15 +36,15 @@
3436
"port": 4002
3537
}
3638
],
37-
"outboundTransport": ["http"],
39+
"outboundTransport": [
40+
"http"
41+
],
3842
"adminPort": 4001,
3943
"tenancy": true,
4044
"schemaFileServerURL": "https://schema.credebl.id/schemas/",
4145
"didRegistryContractAddress": "0xcB80F37eDD2bE3570c6C9D5B0888614E04E1e49E",
4246
"schemaManagerContractAddress": "0x4742d43C2dFCa5a1d4238240Afa8547Daf87Ee7a",
4347
"rpcUrl": "https://rpc-amoy.polygon.technology",
4448
"fileServerUrl": "https://schema.credebl.id",
45-
"fileServerToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJBeWFuV29ya3MiLCJpZCI6ImNhZDI3ZjhjLTMyNWYtNDRmZC04ZmZkLWExNGNhZTY3NTMyMSJ9.I3IR7abjWbfStnxzn1BhxhV0OEzt1x3mULjDdUcgWHk",
46-
"apiKey": "supersecret-that-too-16chars",
47-
"updateJwtSecret": false
48-
}
49+
"fileServerToken": ""
50+
}

src/cli.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ import type { AriesRestConfig } from './cliAgent.js'
22

33
import yargs from 'yargs'
44
import { hideBin } from 'yargs/helpers'
5+
import dotenv from 'dotenv'
6+
7+
dotenv.config()
58

69
import { runRestAgent } from './cliAgent.js'
710

@@ -151,7 +154,9 @@ async function parseArguments(): Promise<Parsed> {
151154
.option('wallet-idle-timeout', { number: true })
152155
.option('apiKey', {
153156
string: true,
154-
coerce: (input: string) => {
157+
default: process.env.API_KEY,
158+
coerce: (input: string | undefined) => {
159+
if (!input) return input
155160
input = input.trim()
156161
if (input && input.length < 16) {
157162
throw new Error('API key must be at least 16 characters long')
@@ -161,7 +166,7 @@ async function parseArguments(): Promise<Parsed> {
161166
})
162167
.option('updateJwtSecret', {
163168
boolean: true,
164-
default: false,
169+
default: process.env.UPDATE_JWT_SECRET === 'true',
165170
})
166171
.config()
167172
.env('AFJ_REST')

src/cliAgent.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
X509Module,
3232
JwkDidRegistrar,
3333
JwkDidResolver,
34+
SdJwtVcModule,
3435
PeerDidNumAlgo,
3536
} from '@credo-ts/core'
3637
import {
@@ -255,6 +256,7 @@ const getModules = (
255256
rpcUrl: rpcUrl ? rpcUrl : (process.env.RPC_URL as string),
256257
serverUrl: fileServerUrl ? fileServerUrl : (process.env.SERVER_URL as string),
257258
}),
259+
sdJwtVc: new SdJwtVcModule(),
258260
openid4vc: new OpenId4VcModule({
259261
app: expressApp,
260262
issuer: {

src/controllers/openid4vc/issuance-sessions/issuance-sessions.Controller.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,4 +104,19 @@ export class IssuanceSessionsController extends Controller {
104104
throw ErrorHandlingService.handle(error)
105105
}
106106
}
107+
108+
/**
109+
* Revoke credentials in an issuance session by session ID
110+
*/
111+
@Post('{issuanceSessionId}/revoke')
112+
public async revokeSessionById(
113+
@Request() request: Req,
114+
@Path('issuanceSessionId') issuanceSessionId: string,
115+
) {
116+
try {
117+
return await issuanceSessionService.revokeBySessionId(request, issuanceSessionId)
118+
} catch (error) {
119+
throw ErrorHandlingService.handle(error)
120+
}
121+
}
107122
}

src/controllers/openid4vc/issuance-sessions/issuance-sessions.service.ts

Lines changed: 140 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,12 @@ import type { Request as Req } from 'express'
44
import { type OpenId4VcIssuanceSessionState } from '@credo-ts/openid4vc'
55
import { OpenId4VcIssuanceSessionRepository } from '@credo-ts/openid4vc'
66

7-
import { SignerMethod } from '../../../enums/enum'
7+
import { CredentialFormat, SignerMethod } from '../../../enums/enum'
88
import { BadRequestError, NotFoundError } from '../../../errors/errors'
99

10+
import { checkAndCreateStatusList, getServerUrl, revokeCredentialInStatusList } from '../../../utils/statusListService'
11+
import { STATUS_LISTS_PATH } from '../../../utils/constant'
12+
1013
class IssuanceSessionsService {
1114
public async createCredentialOffer(options: OpenId4VcIssuanceSessionsCreateOffer, agentReq: Req) {
1215
const { credentials, publicIssuerId } = options
@@ -15,49 +18,36 @@ class IssuanceSessionsService {
1518
if (!issuer) {
1619
throw new NotFoundError(`Issuer with id ${publicIssuerId} not found`)
1720
}
18-
const mappedCredentials = credentials.map((cred) => {
19-
const supported = issuer?.credentialConfigurationsSupported[cred.credentialSupportedId]
20-
if (!supported) {
21-
throw new Error(`CredentialSupportedId '${cred.credentialSupportedId}' is not supported by issuer`)
22-
}
23-
if (supported.format !== cred.format) {
24-
throw new Error(
25-
`Format mismatch for '${cred.credentialSupportedId}': expected '${supported.format}', got '${cred.format}'`,
26-
)
27-
}
28-
29-
// must have signing options
30-
if (!cred.signerOptions?.method) {
31-
throw new BadRequestError(
32-
`signerOptions must be provided and allowed methods are ${Object.values(SignerMethod).join(', ')}`,
33-
)
34-
}
35-
36-
if (cred.signerOptions.method == SignerMethod.Did && !cred.signerOptions.did) {
37-
throw new BadRequestError(
38-
`For ${cred.credentialSupportedId} : did must be present inside signerOptions if SignerMethod is 'did' `,
39-
)
40-
}
41-
42-
if (cred.signerOptions.method === SignerMethod.X5c && !cred.signerOptions.x5c) {
43-
throw new BadRequestError(
44-
`For ${cred.credentialSupportedId} : x5c must be present inside signerOptions if SignerMethod is 'x5c' `,
45-
)
46-
}
47-
48-
const currentVct = cred.payload && 'vct' in cred.payload ? cred.payload.vct : undefined
49-
return {
50-
...cred,
51-
payload: {
52-
...cred.payload,
53-
vct: currentVct ?? (typeof supported.vct === 'string' ? supported.vct : undefined),
54-
},
55-
}
56-
})
5721

58-
options.issuanceMetadata ||= {}
22+
const offerStatusInfo: any[] = []
23+
24+
const mappedCredentials = await Promise.all(
25+
credentials.map(async (cred) => {
26+
const supported = issuer.credentialConfigurationsSupported[cred.credentialSupportedId]
27+
28+
this.validateCredentialConfig(cred, supported)
29+
30+
const statusBlock = await this.processStatusList(cred, options, agentReq, offerStatusInfo)
31+
32+
const currentVct = cred.payload && 'vct' in cred.payload ? cred.payload.vct : undefined
33+
return {
34+
...cred,
35+
payload: {
36+
...cred.payload,
37+
vct: currentVct ?? (typeof supported.vct === 'string' ? supported.vct : undefined),
38+
...(statusBlock ? { status: statusBlock } : {}),
39+
},
40+
}
41+
}),
42+
)
5943

44+
options.issuanceMetadata ||= {}
6045
options.issuanceMetadata.credentials = mappedCredentials
46+
options.issuanceMetadata.isRevocable = options.isRevocable
47+
48+
if (offerStatusInfo.length > 0) {
49+
options.issuanceMetadata.StatusListInfo = offerStatusInfo
50+
}
6151

6252
const issuerModule = agentReq.agent.modules.openid4vc.issuer
6353

@@ -75,6 +65,91 @@ class IssuanceSessionsService {
7565
return { credentialOffer, issuanceSession }
7666
}
7767

68+
private validateCredentialConfig(cred: any, supported: any) {
69+
if (!supported) {
70+
throw new Error(`CredentialSupportedId '${cred.credentialSupportedId}' is not supported by issuer`)
71+
}
72+
if (supported.format !== cred.format) {
73+
throw new Error(
74+
`Format mismatch for '${cred.credentialSupportedId}': expected '${supported.format}', got '${cred.format}'`,
75+
)
76+
}
77+
78+
if (!cred.signerOptions?.method) {
79+
throw new BadRequestError(
80+
`signerOptions must be provided and allowed methods are ${Object.values(SignerMethod).join(', ')}`,
81+
)
82+
}
83+
84+
if (cred.signerOptions.method === SignerMethod.Did && !cred.signerOptions.did) {
85+
throw new BadRequestError(
86+
`For ${cred.credentialSupportedId} : did must be present inside signerOptions if SignerMethod is 'did' `,
87+
)
88+
}
89+
90+
if (cred.signerOptions.method === SignerMethod.X5c && !cred.signerOptions.x5c) {
91+
throw new BadRequestError(
92+
`For ${cred.credentialSupportedId} : x5c must be present inside signerOptions if SignerMethod is 'x5c' `,
93+
)
94+
}
95+
}
96+
97+
private async processStatusList(
98+
cred: any,
99+
options: OpenId4VcIssuanceSessionsCreateOffer,
100+
agentReq: Req,
101+
offerStatusInfo: any[],
102+
) {
103+
if (!options.isRevocable) {
104+
return undefined
105+
}
106+
107+
const effectiveIssuerDid = cred.signerOptions?.method === SignerMethod.Did ? cred.signerOptions.did : undefined
108+
const effectiveStatusList = cred.statusListDetails || options.statusListDetails
109+
110+
if (![CredentialFormat.VcSdJwt, CredentialFormat.DcSdJwt].includes(cred.format as unknown as CredentialFormat)) {
111+
throw new BadRequestError(
112+
`Revocation is only supported for SD-JWT formats (vc+sd-jwt, dc+sd-jwt), got '${cred.format}'`,
113+
)
114+
}
115+
116+
if (!process.env.STATUS_LIST_SERVER_URL) {
117+
throw new BadRequestError('Cannot create revocable credentials: STATUS_LIST_SERVER_URL is not configured')
118+
}
119+
120+
if (cred.signerOptions.method !== SignerMethod.Did || !effectiveIssuerDid) {
121+
throw new BadRequestError(`Revocation is not supported without a DID signer (found ${cred.signerOptions.method})`)
122+
}
123+
124+
if (!effectiveStatusList) {
125+
throw new BadRequestError('Status list details must be provided for revocable credentials')
126+
}
127+
128+
await checkAndCreateStatusList(
129+
agentReq.agent as any,
130+
effectiveStatusList.listId,
131+
effectiveIssuerDid,
132+
effectiveStatusList.listSize,
133+
)
134+
135+
const listUri = `${getServerUrl()}/${STATUS_LISTS_PATH}/${effectiveStatusList.listId}`
136+
137+
offerStatusInfo.push({
138+
credentialSupportedId: cred.credentialSupportedId,
139+
listId: effectiveStatusList.listId,
140+
index: effectiveStatusList.index,
141+
issuerDid: effectiveIssuerDid,
142+
})
143+
144+
return {
145+
status_list: {
146+
uri: listUri,
147+
idx: effectiveStatusList.index,
148+
},
149+
}
150+
}
151+
152+
78153
public async getIssuanceSessionsById(agentReq: Req, sessionId: string) {
79154
const issuer = agentReq.agent.modules.openid4vc.issuer
80155
if (!issuer) {
@@ -144,6 +219,30 @@ class IssuanceSessionsService {
144219
const issuanceSessionRepository = agentReq.agent.dependencyManager.resolve(OpenId4VcIssuanceSessionRepository)
145220
await issuanceSessionRepository.deleteById(agentReq.agent.context, sessionId)
146221
}
222+
223+
public async revokeBySessionId(agentReq: Req, sessionId: string) {
224+
const issuanceSessionRepository = agentReq.agent.dependencyManager.resolve(OpenId4VcIssuanceSessionRepository)
225+
const record = await issuanceSessionRepository.findById(agentReq.agent.context, sessionId)
226+
227+
if (!record) {
228+
throw new NotFoundError(`Issuance session with id ${sessionId} not found`)
229+
}
230+
231+
const statusInfo = record.issuanceMetadata?.StatusListInfo as any[]
232+
if (!statusInfo || statusInfo.length === 0) {
233+
throw new Error(`No status list information found for session ${sessionId}`)
234+
}
235+
236+
if (!process.env.STATUS_LIST_SERVER_URL) {
237+
throw new BadRequestError('Cannot execute revocation: STATUS_LIST_SERVER_URL is not configured')
238+
}
239+
240+
for (const info of statusInfo) {
241+
await revokeCredentialInStatusList(agentReq.agent as any, info.listId, info.index, info.issuerDid)
242+
}
243+
244+
return { message: 'Credentials in session revoked successfully' }
245+
}
147246
}
148247

149248
export const issuanceSessionService = new IssuanceSessionsService()

src/controllers/openid4vc/types/issuer.types.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,7 @@ import type { OpenId4VciCredentialFormatProfile } from '@credo-ts/openid4vc'
44
import { Kms } from '@credo-ts/core'
55
import { OpenId4VciCreateCredentialOfferOptions, OpenId4VciSignCredentials } from '@credo-ts/openid4vc'
66

7-
export enum SignerMethod {
8-
Did = 'did',
9-
X5c = 'x5c',
10-
}
7+
import { SignerMethod } from '../../../enums/enum'
118

129
export interface OpenId4VciOfferCredentials {
1310
credentialSupportedId: string
@@ -18,6 +15,11 @@ export interface OpenId4VciOfferCredentials {
1815
x5c?: string[]
1916
keyId?: string
2017
}
18+
statusListDetails?: {
19+
listId: string
20+
index: number
21+
listSize?: number
22+
}
2123
}
2224

2325
export interface DisclosureFrameForOffer {
@@ -72,6 +74,12 @@ export interface OpenId4VcIssuanceSessionsCreateOffer {
7274
authorizationServerUrl: string
7375
}
7476
issuanceMetadata?: Record<string, unknown>
77+
statusListDetails?: {
78+
listId: string
79+
index: number
80+
listSize?: number
81+
}
82+
isRevocable?: boolean
7583
}
7684

7785
export interface X509GenericRecordContent {

0 commit comments

Comments
 (0)