Skip to content
tsengerPublic

About

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

Topics

Resources

Stars

13 stars

Watchers

4 watching

Forks

Repository files navigation

VdsTools - Kotlin multiplatform library to work with Visible Digital Seals

vdstools Logo

License Maven Central Version GitHub Actions Workflow Status

This is a Kotlin multiplatform (JVM and iOS) library to decode/verify and encode/sign Visible Digital Seals (VDS) and ICAO Datastructure for Barcode (IDB) seals.

Supported specifications:

Specification Scope Format
ICAO Doc 9303 Part 13 Visible Digital Seals for travel and non-electronic documents VDS
ICAO Datastructure for Barcode (draft) Successor barcode format to VDS IDB
BSI TR-03137 Part 1 German profiles for official documents (older ones as VDS, newer ones as IDB) VDS, IDB
BSI TR-03171 v0.8, v0.9 (German only) Digital seals for administrative documents (extended VDS header, UUID-based profiles) VDS-based

VDS and IDB 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 barcodes 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.SealParser
import de.tsenger.vdstools.generic.SealType
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"

// Create a parser that accepts all seal types (default)
val parser = SealParser()
val seal: Seal = parser.parse(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 MessageValue
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 expects the signer's public key as DER-encoded SubjectPublicKeyInfo
// and the ECDSA signature as plain r||s bytes (not ASN.1/DER-encoded).
// signatureInfo.plainSignatureBytes contains the signature in r||s format.
val publicKeyBytes: ByteArray = ... // DER-encoded SubjectPublicKeyInfo from your certificate store
val verifier = Verifier(
    seal.signedBytes,
    signatureInfo!!.plainSignatureBytes,
    publicKeyBytes,
    "brainpoolP224r1"
)
val result: Verifier.Result = verifier.verify()

Configuring accepted seal types

SealParser can be configured to only accept specific seal types — analogous to configuring a barcode scanner to recognise only certain symbologies. If an input matches a type that is not in allowedTypes, a SealParseException is thrown.

// Accept only VDS, reject IDB
val parser = SealParser(allowedTypes = setOf(SealType.VDS))
val seal = parser.parse(rawString)  // throws SealParseException for an IDB input

For VDS seals provided as raw bytes (e.g. when working with test fixtures or binary data):

val parser = SealParser()
val seal: Seal = parser.parse(rawBytes)  // ByteArray input, VDS only

Dispatching on seal type

Every Seal exposes a sealType: SealType property. Combined with Kotlin's exhaustive when, this gives compile-time safety when adding a new seal type:

when (seal.sealType) {
    SealType.VDS -> println("VDS seal, issuing country: ${seal.issuingCountry}")
    SealType.IDB -> println("IDB seal, document type: ${seal.documentType}")
    // compiler warns here if a new SealType value is added and not handled
}

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_V8 or ADMINISTRATIVE_DOCUMENTS_V9), 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 = SealParser().parse(rawString)

// Regular user-visible messages (metadata tags are excluded)
val messageList = seal.messageList

// Metadata messages (e.g. DOC_PROFILE_NUMBER, VALIDITY_DATES for v0.8, VALID_FROM/PROFILE_URI for v0.9)
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 (v0.8)
    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_V8") // → {0, 1, 2, 3}
DataEncoder.vdsDocumentTypes.getMetadataTags("ADMINISTRATIVE_DOCUMENTS_V9") // → {0, 1, 2, 3, 4, 5, 6}
DataEncoder.vdsDocumentTypes.getMetadataTags("RESIDENCE_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

The library follows a symmetric design:

  • Parsing uses a single entry point — SealParser accepts any raw barcode string (or bytes) and returns a Seal, regardless of whether it is VDS or IDB. You never need to know the internal structure.
  • Building mirrors this with one builder per seal type — VdsSeal.Builder and IdbSeal.Builder. Call build(signer) at the end and get back the finished seal. All internal components (header, message group, signature, payload) are handled automatically.

Both builders follow the same Kotlin fluent style: chain configuration calls, then call build().

VDS

VDS seals are always signed. The only way to create one is via VdsSeal.Builder.build(signer):

import de.tsenger.vdstools.EcdsaSigner
import de.tsenger.vdstools.vds.VdsSeal
import kotlinx.datetime.LocalDate

// In this JVM example we use a BouncyCastle keystore to get the private key for signing
val ecKey: ECPrivateKey = keystore.getKey(keyAlias, keyStorePassword.toCharArray())

// Initialize the EcdsaSigner with the private key bytes and curve name
val signer = EcdsaSigner(ecKey.encoded, "brainpoolP224r1")

// Build and sign the VdsSeal in a single step
val vdsSeal = VdsSeal.Builder("ARRIVAL_ATTESTATION")
    .issuingCountry("D<<")
    .signerIdentifier("DETS")
    .certificateReference("32")
    .issuingDate(LocalDate.parse("2024-09-27"))
    .sigDate(LocalDate.parse("2024-09-27"))
    .addMessage("MRZ", "MED<<MANNSENS<<MANNY<<<<<<<<<<<<<<<<6525845096USA7008038M2201018<<<<<<06")
    .addMessage("AZR", "ABC123456DEF")
    .build(signer)

// The encoded bytes can now be used to build a DataMatrix (or other) code
val encodedSealBytes = vdsSeal.encoded

IDB

IDB seals can be signed or unsigned, as allowed by ICAO Doc 9303-13; the barcode flag says which (IdbSeal.isSigned). Use build() for unsigned and build(signer) for signed. The signature algorithm is derived automatically from the signer's key size.

An unsigned seal proves nothing about its origin or integrity, and anyone can turn a signed seal into an unsigned one with the same content. Applications must therefore not accept an unsigned seal where a signed one is expected. When parsing, a barcode flag that does not match the payload (signed flag without signature, or unsigned flag with signature) is rejected with an IllegalArgumentException.

import de.tsenger.vdstools.DataEncoder
import de.tsenger.vdstools.EcdsaSigner
import de.tsenger.vdstools.idb.IdbSeal

// In this JVM example we use a BouncyCastle keystore to get the certificate and private key
val cert: X509Certificate = keystore.getCertificate(keyAlias)
val ecKey: ECPrivateKey = keystore.getKey(keyAlias, keyStorePassword.toCharArray())

// Initialize the EcdsaSigner with the private key bytes and curve name
val signer = EcdsaSigner(ecKey.encoded, "brainpoolP256r1")

// The certificate reference is a hash derived from the signer certificate's DER bytes
val certRef: ByteArray = DataEncoder.buildCertificateReference(cert.encoded)

// Build and sign the IdbSeal in a single step
val idbSeal = IdbSeal.Builder()
    .countryIdentifier("D<<")
    .certificateReference(certRef)
    .signingDate("2025-02-11")
    .addMessage(0x80, faceImageBytes)   // FACE_IMAGE
    .addMessage(0x81, mrzTd2String)     // MRZ_TD2
    .addMessage(0x84, "2026-04-23")     // EXPIRY_DATE
    .addMessage(0x86, 0x01)             // NATIONAL_DOCUMENT_IDENTIFIER
    .build(signer)

// 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. Their UUID-based document profiles are not bundled and not defined in JSON; they are loaded at runtime from TR-03171 XML document profiles (v0.8 → document category 0xC8, v0.9 → 0xC9):

DataEncoder.loadVdsProfileDefinitionFromXml(xmlString)

Message names and the document type name are taken verbatim from the definition. The bundled JSON registries use UPPER_SNAKE_CASE (e.g. SURNAME), while TR-03171 XML profiles use whatever the profile publisher chose for <name> and <profileName> (e.g. familyName or Familienname, document type Meldebestätigung); a profile without <profileName> gets its UUID as document type name. getMessageByName() ignores case. Tools that map message names to other languages should key their mapping on profile UUID and tag rather than on the name.

A profile is identified by its UUID and its TR-03171 version: an issuer may keep the UUID (and the name) when moving a profile from v0.8 to v0.9, and both can be loaded side by side. Parsing picks the profile matching the seal's document category (0xC8 → v0.8, 0xC9 → v0.9) automatically. Building by profile name or UUID works as long as it is registered for only one version; otherwise pass the version:

// Unique name → same as for any other document type
val seal = VdsSeal.Builder("Meldebestätigung")
    .issuingCountry("D<<")
    .signerIdentifier("DEZV")
    .certificateReference("00112233445566778899AABBCCDDEEFF00112233")
    .addMessage("familyName", "Mustermann")
    .build(signer)

// UUID registered for v0.8 and v0.9 → select the version (throws IllegalArgumentException if ambiguous)
val sealV9 = VdsSeal.Builder.forProfileUuid("9a4223406d374ef99e2cf95e31a23846", TrVersion.V0_9)
    .issuingCountry("D<<")
    .signerIdentifier("DEZV")
    .certificateReference("00112233445566778899AABBCCDDEEFF00112233")
    .addMessage("PROFILE_URI", "example.com/profiles")      // mandatory for v0.9
    .addMessage("CERTIFICATE_URI", "example.com/certs")     // mandatory for v0.9
    .addMessage("familyName", "Mustermann")
    .build(signer)

Applications that manage profiles themselves (e.g. several sources publishing the same UUID) can replace the lookup used while parsing. The resolver receives the UUID (tag 0x00), the PROFILE_URI (tag 0x03, always null for v0.8) and the base document type from the header:

DataEncoder.vdsProfileResolver = VdsProfileResolver { uuid, profileUri, baseDocumentType ->
    myProfileStore.find(uuid, profileUri, baseDocumentType)  // or fall back to DataEncoder.defaultVdsProfileResolver
}

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)

// Revert all registries to embedded defaults:
DataEncoder.resetToDefaults()

Using Custom Document Types

Once loaded, custom document types work seamlessly with the existing API:

val vdsSeal = VdsSeal.Builder("MY_CUSTOM_DOCUMENT")
    .issuingCountry("D<<")
    .signerIdentifier("TEST")
    .certificateReference("32")
    .addMessage("OWNER_NAME", "MAX MUSTERMANN")
    .addMessage("ISSUE_DATE", "2024-06-15")
    .build(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 = SealParser().parse(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 decoded payload bytes returned by seal.encoded — the library concatenates header, message group, optional signer certificate, and signature for you.

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.21.1'
}

Maven

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

<dependency>
    <groupId>de.tsenger</groupId>
    <artifactId>vdstools</artifactId>
    <version>0.21.1</version>
</dependency>

About

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

Topics

Resources

Stars

13 stars

Watchers

4 watching

Forks

Releases

Packages

Used by

Contributors

Languages