Skip to content

tsenger/vdstools

Repository files navigation

VdsTools - Kotlin multiplatform library to work with Visible Digital Seals

License Maven Central Version GitHub Actions Workflow Status

This a Kotlin multiplatform (JVM and iOS) library to decode/verify and encode/sign Visible Digital Seals (VDS) as specified in

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.

Get it on Google Play

Download on the App Store

Parse and verify a VDS / IDB

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()

Metadata messages (administrative documents)

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.

Build a barcode

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.

Custom seal codings

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.

Loading Custom Codings

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 only

The 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()

Using Custom Document Types

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)

Available Coding Types

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.

Byte-level structure inspection with the dissect package

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())

How to include

The vdstools library is available on the Maven Central Repository to be easy to integrate in your projects.

Gradle

To include this library to your Gradle build add this dependency:

dependencies {
    implementation 'de.tsenger:vdstools:0.14.0'
}

Maven

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>

About

A library to parse and generate Visible Digital Seals (VDS)

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages