This a Kotlin multiplatform (JVM and iOS) library to decode/verify and encode/sign Visible Digital Seals (VDS) as specified in
- BSI TR-03137 Part 1
- ICAO Doc 9303 Part 13: Visible Digital Seals
- ICAO TR "VDS for Non-Electronic Documents"
It also fully supports encoding and decoding Seals defined in the new draft of ICAO Datastructure for Barcode. VDS and ICD barcodes can be parsed by a generic interface. An example is given in the following chapter
VDS can be created with the help of this library or, if you want to try it out quickly, via the web Sealgen tool. There is also the Sealva mobile app which scans, verifies and displays all VDS profiles defined in the above specifications.
Here is a quick overview how to use the generic parser and verifier. The generic interface handles VDS and IDB barcode via common function calls. When you received the raw string from your favorite datamatrix decoder use VdsTools like this:
import de.tsenger.vdstools.Verifier
import de.tsenger.vdstools.generic.Seal
import de.tsenger.vdstools.generic.Message
import de.tsenger.vdstools.generic.SignatureInfo
import de.tsenger.vdstools.generic.MessageValue
// example raw string coming as result from your used barcode scanner library
val rawString =
"RDB1BNK6ADJL2PECXOABAHIMWDAQEE6ATAXHCCNJVXVGK5TEHZJGMV22BGPATHQJTYEZ4CM6D73Z2FE4O4Q7RLE6RVZJNXMTHKH7GJN6BGPATNOAIEA7EAAAAADDKKAQCADIKQ4FAAAAACRTHI6LQNJYDEIAAAAAAA2TQGIQAAAAAI5VHAMTIAAAAAFTJNBSHEAAAACAQAAAAMQAACBYHAAAAAAAAB5RW63DSAEAAAAAAAAIQAAAADJZGK4ZAAAAAAETSMVZWGAJMAD7ACLAA7YCAIAAAAAAGU4BSMP7U772RAAUQAAAAAAAGIAAAACAQAAAAAAAAAAAAAAAAAZAAAAAICAAAAAAAAAAAAAAACBYBAH7VYAAXIJTTKZ2MM5GGOZCGGZDDMRVMHYED4CB5UP7VEAAMAAAAAAIAAMCAIAAA75SAADQAAFGFIX2KKAZF6MRSG37ZAAAKAAAAAAADB4AAD74TY7FX6HL6CBIU4OROHXUXWYFSZTVXFI47EE2NADZRJWKVIGHF7BZDEJUHKDBB2LVVJBUG6MCWD66UJDQPTNHAIKTKEB4THMTRBKM6ORXIEW5WWVQDEYMMIFHA43M5OEHWK62OQHQQKTBLBNONJTM3INJTFMRPXM6NUTBYIQWXPHK6EMENBL25ZRIW5FXG2PZO3CLJC6WCXCLFGNZKYPSKOQ7EULA7BVUAKBQ44Q6HCLT5RDUZM4D3TT55GA7H57NQ7G7LXSG4W4NNAT344KM5LE7EMSDFOE5OFQDYF6PQYZRXR3RQSBCDGV34YNJG3VUWGUJ3DL7TJAYWW7YVI5GVGPX4IKM25DFVEAGB6OM2VFHQAGMFNJFT56I7V5XIRMFOIFJDG2SRS5GFCKY6UUYUVPBL3TG2ULE6ULYNIICKTLUJK6ALUA2SNNU7TSPBXVQVRPEJ7R7UHJWBWI6XGNKWRRBXEFB27VLV3OEVKMVQBCLRFUWXFYXFVOWMF7I763YAUQXDNTP7E42DG26XKBB4HYG4UPR3NQONHCMQKS5FOZOP6JDW75G5EPKRLSGBURBIMMLAW72X4TTXLFH5ATDGB4VCA6US4G57CFEPCE6SB6JUZJGDLWTD2L7YLDXCTYPXZYSMPITJV5ABIDRACHAYDBJZXQXWIPCWWX6TVPXTIA6MQQJNAVSETK7IYNK6NXA66V2A6UELOCCMQDDEKQYNOCLDZ2NGWSIRQFODRERJXLTKFZ2PIUHF34VJDGYKAAFN67I2WUVWD2UY3FOBWKVU5YXMYV5FRRN6DWNJWA76JYR2ONQ72CZHBHYBTN7XNRGVNVRMMT7DZLUXZPOA2HA46H6ADOTMVJTNWAG6SENPRKH4SJQZWGSFXXFGP4P6Q3J5NSRFZZBLFGHVFGLQIYB5VGSHBBE2YEXIPMP4XGND45NCNSKOJIN6LIT4ZQO2QXRWLOX6BY7QNMEHHI4A5VUX54LSUSASKQRNZUREAU2IATUK5OYDB3XGQTZBHZC3SHGSQJPCRSBJVFG72WXX6RN2R34JFPYXVPUKMEM55ZTGXLR76AJR2TPT7HKGO6RTEIDE6RBFNRKJRR4NPILHJ5XLUEMZKB4A65NSF6T2YN3Y3T4EXDIQCQ3XQ2N7ERQQKZYWTTVPVCNFAJMMNE4JXNFCY4FRH27KA5P3LWZGCF4TIPXSWR2QZESM4AOSH74XRORBXWWCTS3VO4CW6Q773GBQQWPJEA4DG43NESDACDL7IABCBTZ3ZUTZWNPRAW35KVFV3NSTBLXPY7FHG3HEUY3XDT6KR37OK3J3LEJSGIENHILIYX5D2OZKBGMU7FCU7ZIXAPJORJA7MBTGAQEN"
val seal: Seal = Seal.fromString(rawString)
//Get list with all messages in seal
val messageList = seal.messageList
for (message in messageList) {
println("${message.name} -> ${message.value}")
}
// Access message data by name - value.toString() returns the decoded value
val mrz: String? = seal.getMessageByName("MRZ_TD2")?.toString()
// Or use type-safe access via sealed class
val messageValue = seal.getMessageByName("MRZ_TD2")?.value
if (messageValue is MessageValue.MrzValue) {
println("MRZ: ${messageValue.mrz}")
} else {
// rawBytes is always an option
println(messageValue?.rawBytes?.toHexString())
}
// SignatureInfo contains all signature relevant data
val signatureInfo: SignatureInfo? = seal.signatureInfo
println("Signing date: ${signatureInfo?.signingDate}")
// Get the signer certificate reference
val signerCertRef: String? = signatureInfo?.signerCertificateReference
println("Signer certificate reference: $signerCertRef")
// Since X509 certificate handling is strongly platform-dependent,
// the Verifier is given the plain publicKey (r|s) and the curve name.
val publicKeyBytes: ByteArray = ... // load from your certificate store
val verifier = Verifier(
seal.signedBytes,
signatureInfo!!.plainSignatureBytes,
publicKeyBytes,
"brainpoolP224r1"
)
val result: Verifier.Result = verifier.verify()Some seal types use a two-stage lookup: the header's documentRef points to a base type (e.g.
ADMINISTRATIVE_DOCUMENTS), and the first tags of the message zone carry administrative metadata
(e.g. the document profile UUID at tag 0, validity dates at tag 1) rather than user-visible content.
These tags are declared as metadataTagList in VdsDocumentTypes.json and are automatically separated
from the regular messageList during parsing. They are only accessible via metadataMessageList.
val seal: Seal = Seal.fromString(rawString)
// Regular user-visible messages (metadata tags are excluded)
val messageList = seal.messageList
// Metadata messages (e.g. DOC_PROFILE_NUMBER, VALIDITY_DATES)
val metadataList = seal.metadataMessageList
for (message in metadataList) {
println("${message.name} (tag ${message.tag}) -> ${message.value}")
}
// Check if the seal has any metadata at all
if (seal.metadataMessageList.isNotEmpty()) {
// access a specific metadata message by name
val validity = seal.metadataMessageList.firstOrNull { it.name == "VALIDITY_DATES" }
val v = validity?.value as? MessageValue.ValidityDatesValue
println("valid from ${v?.validFrom} to ${v?.validTo}")
}To check at the type level which tags are configured as metadata (without a concrete seal instance):
DataEncoder.vdsDocumentTypes.getMetadataTags("ADMINISTRATIVE_DOCUMENTS") // → {0, 1, 2, 3}
DataEncoder.vdsDocumentTypes.getMetadataTags("RESIDENT_PERMIT") // → {} (no metadata tags)Regular document types have no metadataTagList configured, so their metadataMessageList is
always empty and all tags appear in messageList as usual.
Here is an example on how to use the DataEncoder and Signer classes to build a VDS barcode:
import de.tsenger.vdstools.Signer
import de.tsenger.vdstools.vds.VdsHeader
import de.tsenger.vdstools.vds.VdsMessageGroup
import de.tsenger.vdstools.vds.VdsSeal
import kotlinx.datetime.LocalDate
val keystore: KeyStore = ...
// In this JVM example we use a BouncyCastle keystore to get the certificate (for the header information)
// and the private key for signing the seal's data
val cert: X509Certificate = keystore.getCertificate(keyAlias)
val ecKey: ECPrivateKey = keystore.getKey(certAlias, keyStorePassword.toCharArray())
// Initialize the Signer with the private key bytes and curve name
val signer = Signer(ecKey.encoded, curveName)
// 1. Build a VdsHeader
val header = VdsHeader.Builder("ARRIVAL_ATTESTATION")
.setIssuingCountry("D<<")
.setSignerIdentifier("DETS")
.setCertificateReference("32")
.setIssuingDate(LocalDate.parse("2024-09-27"))
.setSigDate(LocalDate.parse("2024-09-27"))
.build()
// 2. Build a VdsMessageGroup with messages
val mrz = "MED<<MANNSENS<<MANNY<<<<<<<<<<<<<<<<6525845096USA7008038M2201018<<<<<<06"
val azr = "ABC123456DEF"
val messageGroup = VdsMessageGroup.Builder(header.vdsType)
.addMessage("MRZ", mrz)
.addMessage("AZR", azr)
.build()
// 3. Build a signed VdsSeal
val vdsSeal = VdsSeal(header, messageGroup, signer)
// The encoded bytes can now be used to build a DataMatrix (or other) code
val encodedSealBytes = vdsSeal.encoded
Here is an example on how to use the DataEncoder and Signer classes to build an IDB barcode:
import de.tsenger.vdstools.DataEncoder
import de.tsenger.vdstools.Signer
import de.tsenger.vdstools.idb.*
val keystore: KeyStore = ...
// In this JVM example we use a BouncyCastle keystore to get the certificate (for the header information)
// and the private key for signing the seal's data
val cert: X509Certificate = keystore.getCertificate(keyAlias)
val ecKey: ECPrivateKey = keystore.getKey(certAlias, keyStorePassword.toCharArray())
// Initialize the Signer with the private key bytes and curve name
val signer: Signer = Signer(ecKey.encoded, curveName)
// 1. Build an IdbHeader
val header = IdbHeader(
countryIdentifier = "D<<",
signatureAlgorithm = IdbSignatureAlgorithm.SHA256_WITH_ECDSA,
certificateReference = DataEncoder.buildCertificateReference(cert.encoded),
signatureCreationDate = "2025-02-11"
)
// 2. Build an IdbMessageGroup with messages
val messageGroup = IdbMessageGroup.Builder()
.addMessage(0x80, faceImageBytes) // FACE_IMAGE
.addMessage(0x81, mrzTd2String) // MRZ_TD2
.addMessage(0x84, "2026-04-23") // EXPIRY_DATE
.addMessage(0x86, 0x01) // NATIONAL_DOCUMENT_IDENTIFIER
.build()
// 3. Sign the header + messageGroup
val dataToSign = header.encoded + messageGroup.encoded
val signatureBytes = signer.sign(dataToSign)
val signature = IdbSignature(signatureBytes)
// 4. Build the IdbSeal
val payload = IdbPayload(header, messageGroup, null, signature)
val idbSeal = IdbSeal(isSigned = true, isZipped = false, barcodePayload = payload)
// The raw string can now be used to build a DataMatrix (or other) code
val encodedRawString = idbSeal.rawString
Also have a look at the testcases for more usage inspiration. You will also find an example on how to generate a datamatrix image with the Zxing library in the jvmTests.
VdsTools uses a JSON-based configuration to define seal document types and their message encodings which follows the encoding based on BSI TR-03137. VDS data according to BSI TR-03171 are also supported and can be parsed from document profiles. However, the official profile definitions and certificates are not yet publicly available. You can load your own custom codings at runtime to support additional document types beyond the standard ones.
There are two strategies for providing custom definitions:
replaceCustom*– replaces the entire registry with your JSON. The built-in defaults are no longer available.addCustom*– merges your entries into the existing registry. Built-in defaults are preserved; entries with the same key (e.g.documentRef) are overwritten by your version.
val customJson = """[
{
"documentType": "MY_CUSTOM_DOCUMENT",
"documentRef": "ab01",
"version": 1,
"messages": [
{
"name": "OWNER_NAME",
"tag": 1,
"coding": "C40",
"required": true,
"minLength": 1,
"maxLength": 20
},
{
"name": "ISSUE_DATE",
"tag": 2,
"coding": "DATE",
"required": true,
"minLength": 3,
"maxLength": 3
}
]
}
]"""
// Add to defaults – built-in types remain available:
DataEncoder.addCustomVdsDocumentTypes(customJson)
DataEncoder.addCustomVdsDocumentTypesFromFile("path/to/MyDocumentTypes.json") // JVM only
// Replace defaults entirely – only your types are available:
DataEncoder.replaceCustomVdsDocumentTypes(customJson)
DataEncoder.replaceCustomVdsDocumentTypesFromFile("path/to/MyDocumentTypes.json") // JVM onlyThe same pattern applies to the other registries:
// IDB message types
DataEncoder.addCustomIdbMessageTypes(jsonString)
DataEncoder.replaceCustomIdbMessageTypes(jsonString)
// IDB document types
DataEncoder.addCustomIdbDocumentTypes(jsonString)
DataEncoder.replaceCustomIdbDocumentTypes(jsonString)
// VDS profile definitions (VDS administrative documents)
DataEncoder.addCustomVdsProfileDefinitions(jsonString)
DataEncoder.replaceCustomVdsProfileDefinitions(jsonString)
// Revert all registries to embedded defaults:
DataEncoder.resetToDefaults()Once loaded, custom document types work seamlessly with the existing API:
val messageGroup = VdsMessageGroup.Builder("MY_CUSTOM_DOCUMENT")
.addMessage("OWNER_NAME", "MAX MUSTERMANN")
.addMessage("ISSUE_DATE", "2024-06-15")
.build()
val header = VdsHeader.Builder("MY_CUSTOM_DOCUMENT")
.setIssuingCountry("D<<")
.setSignerIdentifier("TEST")
.setCertificateReference("32")
.setIssuingDate(LocalDate.now())
.setSigDate(LocalDate.now())
.build()
val vdsSeal = VdsSeal(header, messageGroup, signer)| Coding | Description |
|---|---|
C40 |
Compressed text encoding (uppercase, digits, special chars) |
MRZ |
Machine Readable Zone (special C40 variant) |
UTF8_STRING |
Direct UTF-8 text |
BYTE |
Single byte value |
BYTES |
Variable-length byte array (images, binary data) |
DATE |
3-byte date (ICAO format, e.g. "2024-06-15") |
MASKED_DATE |
4-byte date with uncertainty masks |
See src/commonMain/resources/VdsDocumentTypes.json for the complete structure of the default document types.
The dissect package is not part of the core VDS/IDB processing workflow. It is a utility for developers
who need to inspect the raw byte layout of a parsed seal — for example to build a hex viewer, a debugging
tool, or an educational visualisation of the barcode content.
The entry point is an extension function on Seal:
import de.tsenger.vdstools.dissect.dissect
val seal: Seal = Seal.fromString(rawString)
val dissection: SealDissection = seal.dissect()The result is a SealDissection with four fields:
| Field | Description |
|---|---|
header |
Byte range of the header, including all sub-fields |
messageGroup |
Byte range of the message group, including individual TLV fields |
signerCertificate |
Byte range of the signer certificate (IDB only, otherwise null) |
signature |
Byte range of the signature (null for unsigned seals) |
Each FieldDissection contains a ByteRange(offset, length) relative to the decoded payload bytes, and
optionally a list of children for nested sub-fields.
Example: highlighting fields in a hex viewer
val raw: ByteArray = seal.encoded // VDS: complete encoded bytes; IDB: decoded payload bytes
val d = seal.dissect()
// Print header with sub-fields
println("Header [${d.header.range.offset}..${d.header.range.offset + d.header.range.length - 1}]")
for (child in d.header.children) {
val r = child.range
println(" ${child.label}: bytes[${r.offset}..${r.offset + r.length - 1}]")
}
// Print individual TLV fields of the message group
for (field in d.messageGroup.children) {
val r = field.range
val tagRange = field.children.getOrNull(0)?.range
val valueRange = field.children.getOrNull(2)?.range
println(" ${field.label}: offset=${r.offset}, length=${r.length}")
println(" Tag: bytes[${tagRange?.offset}..${tagRange?.let { it.offset + it.length - 1 }}]")
println(" Value: bytes[${valueRange?.offset}..${valueRange?.let { it.offset + it.length - 1 }}]")
}Note for IDB seals: all offsets are relative to the concatenated decoded payload bytes in this order:
val payloadBytes =
seal.payLoad.idbHeader.encoded +
seal.payLoad.idbMessageGroup.encoded +
(seal.payLoad.idbSignerCertificate?.encoded ?: byteArrayOf()) +
(seal.payLoad.idbSignature?.encoded ?: byteArrayOf())The vdstools library is available on the Maven Central Repository to be easy to integrate in your projects.
To include this library to your Gradle build add this dependency:
dependencies {
implementation 'de.tsenger:vdstools:0.14.0'
}To include this library to your Maven build add this dependency:
<dependency>
<groupId>de.tsenger</groupId>
<artifactId>vdstools</artifactId>
<version>0.14.0</version>
</dependency>