ex_icao_vds
An Elixir library for issuing and verifying ICAO Visible Digital Seals (VDS) — the machine-readable, cryptographically-signed 2D barcodes that prove a document was issued by a legitimate authority and has not been tampered with.
The Problem
Travel and identity documents are routinely forged. Traditional security features (holograms, watermarks, security inks) are visible to the naked eye but require expert judgment to authenticate. There is no easy, instant way for a border officer, airline agent, or automated gate to confirm that a passport, visa, or health certificate is genuine.
ICAO Doc 9303 Part 13 addresses this by standardising a visible digital seal: a 2D barcode printed on or attached to the document, containing a cryptographically signed summary of the document's key data fields. Anyone with the issuing authority's public key can verify the seal in milliseconds, offline, with a standard barcode scanner.
ex_icao_vds is an Elixir implementation of this standard. It handles the entire lifecycle:
- Issuance — encode document fields, sign with ECDSA, render a Data Matrix / QR / Aztec / PDF417 barcode
- Verification — decode the barcode, verify the signature against a trusted key, extract the fields
- Encryption — optionally encrypt sensitive fields (HPKE/RFC 9180) so only authorised parties can read them, while the signature still covers the ciphertext
Example use cases
| Document type | Typical fields | Notes |
|---|---|---|
| Passport (MRTD) | Document number, name, nationality, DOB, sex, expiry |
Built-in MRTD.V1 profile |
| Visa / Entry sticker | Visa number, holder name, validity period, permitted entries, issuing post | Seal affixed to passport page or printed on sticker |
| Electronic Travel Authorisation (ETA/ESTA) | Application number, name, passport number, travel window, status |
Built-in ETA.V1 profile; status field encrypted for border use only |
| Vaccination / health certificate | Vaccine type, doses, dates, issuing clinic | Sensitive medical data encrypted with HPKE; inspector decrypts at border |
| Arrival / departure card | Flight number, origin, accommodation address, declared purpose | Seal scanned at e-gate; card cross-checked against seal |
| Residence permit | Permit number, holder name, nationality, permit class, validity | Seal on card back; renewal offices verify without network access |
| Seafarer's identity document | Seaman number, flag state, vessel, endorsements | IMO Circular FAL.6/Circ.15 use case |
| Event / access credential | Badge ID, access zones, valid dates, issuing organisation | Conference badges, restricted-area passes |
Features
| Capability | Detail |
|---|---|
| Wire format | ICAO VDS v4: C40 header, BER-TLV message zone, DER signature zone |
| Signing | ECDSA P-256 / P-384; local key, HashiCorp Vault Transit, PKCS#11 / HSM |
| Verification | Signature + trust chain; pluggable trust resolvers |
| Trust resolvers | In-memory map, PEM/key files, HTTP (JWKS/PEM), Ecto database |
| Carriers | Data Matrix (default), Aztec, PDF417 via Zint CLI; QR Code is pure Elixir (no system dep) |
| Field encryption | HPKE RFC 9180 (DHKEM-P256 + HKDF-SHA256 + AES-256-GCM); per-field, ciphertext is signed |
| Profile DSL |
Compile-time defprofile do macro with field validation and capacity warnings |
| Generic profile | Runtime-configured profile — no custom module needed |
| Capacity planning | Design-time estimates + runtime preflight checks before signing |
| Encoding | C40, UTF-8, binary date (BCD), boolean, integer, CBOR, encrypted CBOR |
Requirements
-
Elixir
~> 1.19/ OTP 27+ zinton$PATH— only needed for Data Matrix, Aztec, and PDF417 carriers. The QR Code carrier is pure Elixir with no system dependency.
# macOS
brew install zint
# Ubuntu / Debian
sudo apt-get install -y zint
# Fedora / RHEL
sudo dnf install zintInstallation
Add ex_icao_vds to your mix.exs:
def deps do
[
{:ex_icao_vds, "~> 0.2"}
]
end
The library bundles its required dependencies. If you use the PKCS#11 signer for HSM/smartcard signing, also add the optional p11ex library to your application:
{:p11ex, "~> 0.3"} # only needed for ExIcaoVds.Signers.PKCS11Quick Start
Data Matrix — no encryption
All fields are signed and readable by any verifier with the issuing authority's public key.
# Generate (or load) a signing key pair. In production, load from secure storage.
{pub_key, priv_key} = :crypto.generate_key(:ecdh, :secp256r1)
config = %{
profile: ExIcaoVds.Profiles.MRTD.V1,
issuing_country: "KEN", # 3-char ISO code — written into the VDS header
signer: %{
backend: ExIcaoVds.Signers.LocalKey,
private_key: priv_key,
signer_identifier: "KENSNG", # issuing authority code
key_reference: "key-2026-01"
},
carrier: %{backend: ExIcaoVds.Carriers.DataMatrix}
}
{:ok, seal} = ExIcaoVds.issue(
%{
document_number: "AB1234567",
issuing_state: "KEN", # profile field — encoded in the message zone
nationality: "KEN",
date_of_birth: ~D[1985-03-22],
sex: "M",
expiry_date: ~D[2030-09-30]
},
config
)
File.write!("seal.png", seal.carrier) # PNG of the Data Matrix barcodeVerify:
{:ok, result} = ExIcaoVds.verify(seal.raw_vds, %{
profile: ExIcaoVds.Profiles.MRTD.V1,
verifier: %{
trust_resolver: ExIcaoVds.TrustResolvers.StaticKeyStore,
keys: %{{"KENSNG", "key-2026-01"} => {pub_key, :secp256r1}}
}
})
result.status # :valid
result.features # [%Feature{name: :document_number, value: "AB1234567"}, ...]Data Matrix — with field encryption
Sensitive fields are encrypted at issuance. The signature still covers the ciphertext, so any tampering is detected. Only a party holding the recipient private key can read the encrypted field values.
This requires a profile that declares one or more fields with encoding: :encrypted_cbor. The example below uses a custom profile with a biometric_ref field:
defmodule MyApp.VDS.SecurePass do
use ExIcaoVds.Profile
defprofile do
profile_id :secure_pass_v1
document_type_category "A"
feature_definition_reference 1
version 1
field :document_number, :string, tag: 1, encoding: :c40, required: true, max_length: 9
field :expiry_date, :date, tag: 2, encoding: :date, required: true
field :nationality, :string, tag: 3, encoding: :c40, required: false, max_length: 3
# This field is encrypted — only the recipient can read it
field :biometric_ref, :string,
tag: 4,
encoding: :encrypted_cbor,
required: false,
max_length: 32
end
endIssue with a recipient public key:
# Signing key (issuing authority)
{signing_pub, signing_priv} = :crypto.generate_key(:ecdh, :secp256r1)
# Recipient key pair (e.g. border control system). Only the public key is needed at issuance.
{recipient_pub, recipient_priv} = :crypto.generate_key(:ecdh, :secp256r1)
{:ok, seal} = ExIcaoVds.issue(
%{
document_number: "AB1234567",
nationality: "KEN",
expiry_date: ~D[2030-09-30],
biometric_ref: "BIO-2026-XYZ-00142" # will be encrypted in the barcode
},
%{
profile: MyApp.VDS.SecurePass,
issuing_country: "KEN",
signer: %{
backend: ExIcaoVds.Signers.LocalKey,
private_key: signing_priv,
signer_identifier: "KENSNG",
key_reference: "key-2026-01"
},
encryption: %{
backend: ExIcaoVds.Encryptors.HPKE,
recipient_public_key: recipient_pub,
recipient_key_id: "border-key-2026-01"
},
carrier: %{backend: ExIcaoVds.Carriers.DataMatrix}
}
)
File.write!("seal_encrypted.png", seal.carrier)Verify and decrypt (border control, holds the recipient private key):
{:ok, result} = ExIcaoVds.verify(seal.raw_vds, %{
profile: MyApp.VDS.SecurePass,
verifier: %{
trust_resolver: ExIcaoVds.TrustResolvers.StaticKeyStore,
keys: %{{"KENSNG", "key-2026-01"} => {signing_pub, :secp256r1}}
},
decryption: %{
backend: ExIcaoVds.Encryptors.HPKE,
key_store: %{"border-key-2026-01" => {recipient_priv, recipient_pub}}
}
})
result.status # :valid
bio = Enum.find(result.features, &(&1.name == :biometric_ref))
bio.value # "BIO-2026-XYZ-00142"Verify without the decryption key (airline agent — can confirm the seal is genuine but cannot read sensitive fields):
{:ok, result} = ExIcaoVds.verify(seal.raw_vds, %{
profile: MyApp.VDS.SecurePass,
verifier: %{
trust_resolver: ExIcaoVds.TrustResolvers.StaticKeyStore,
keys: %{{"KENSNG", "key-2026-01"} => {signing_pub, :secp256r1}}
}
# no decryption config
})
result.status # :valid — signature still verified
bio = Enum.find(result.features, &(&1.name == :biometric_ref))
bio.value # nil — encrypted field is opaque without the keyDefining a Profile
A profile describes the document type: which fields it has, how each field is encoded, and which are required. There are two ways to define one.
Option A — Compile-time DSL (recommended)
defmodule MyApp.VDS.TravelPass do
use ExIcaoVds.Profile
defprofile do
profile_id :travel_pass_v1
document_type_category "A"
feature_definition_reference 1
version 1
field :document_number, :string,
tag: 1,
encoding: :c40,
required: true,
max_length: 20
field :given_names, :string,
tag: 2,
encoding: :utf8,
required: true,
max_length: 39
field :expiry_date, :date,
tag: 3,
encoding: :date,
required: true
field :issuing_country, :string,
tag: 4,
encoding: :c40,
required: true,
max_length: 3
# Optional field — will be omitted silently if absent
field :notes, :string,
tag: 5,
encoding: :utf8,
required: false,
max_length: 50
end
end
The macro injects issue/1,2, verify/1,2, decode/1,2, render/1,2, definition/0, estimate_capacity/0,1, and preflight/1,2 directly on the module:
{:ok, seal} = MyApp.VDS.TravelPass.issue(doc_data, signer_config)
{:ok, result} = MyApp.VDS.TravelPass.verify(seal.raw_vds, verifier_config)
estimate = MyApp.VDS.TravelPass.estimate_capacity()Compile-time checks — the macro raises CompileError on duplicate field tags or names, and emits compile warnings when:
-
A string/binary field is missing
max_length(capacity estimation will be inaccurate) - The estimated payload exceeds 80% of the default 800-byte carrier capacity
Option B — Runtime generic profile
No custom module needed. Pass field definitions directly in the config:
profile_config = %{
profile_id: :my_doc,
document_type_category: "A",
feature_definition_reference: 1,
fields: [
%{name: :doc_number, tag: 1, type: :string, encoding: :c40,
required?: true, max_length: 20},
%{name: :valid_until, tag: 2, type: :date, encoding: :date,
required?: true}
]
}
ExIcaoVds.issue(doc_data, %{
profile: ExIcaoVds.Profiles.Generic,
profile_config: profile_config,
signer: signer_config
})Bundled example profiles
| Module | Fields | Notes |
|---|---|---|
ExIcaoVds.Profiles.MRTD.V1 | 8 (document_number, issuing_state, nationality, date_of_birth, sex, expiry_date, optional_data, holder_name) | Machine Readable Travel Document example |
ExIcaoVds.Profiles.ETA.V1 | 10 (application_number, family_name, given_names, nationality, date_of_birth, sex, passport_number, passport_expiry, eta_expiry, status) | Electronic Travel Authorisation example |
Issuance Config Reference
All keys are optional unless marked required.
%{
# --- Profile ---
profile: MyApp.VDS.TravelPass, # profile module (default: Generic)
profile_config: %{...}, # field defs when using Generic
# --- Signer (required) ---
signer: %{
backend: ExIcaoVds.Signers.LocalKey, # see Signers section
signer_identifier: "UTOSNG",
key_reference: "key-2026-01",
# ... backend-specific keys
},
# --- Carrier ---
carrier: %{
backend: ExIcaoVds.Carriers.DataMatrix, # see Carriers section for per-backend opts
output: :png, # :png (default) or :svg
max_carrier_bytes: 800 # capacity ceiling (all backends)
},
# --- Encryption ---
encryption: %{
backend: ExIcaoVds.Encryptors.HPKE,
recipient_public_key: pub_key_bytes, # 65-byte uncompressed P-256 point
recipient_key_id: "verifier-key-2026-01"
},
# --- Capacity policy ---
capacity_policy: :fail, # :fail (default) | :omit_optional
# :fail — returns error if payload too large
# :omit_optional — drops non-required fields and retries
# --- Misc ---
issuing_country: "UTO", # ISO 3-char; also read from doc_data[:issuing_country]
clock: ExIcaoVds.Clocks.System,
clock_opts: [],
audit_logger: ExIcaoVds.AuditLoggers.Noop
}Issued seal result
ExIcaoVds.issue/2 returns {:ok, %ExIcaoVds.IssuedSeal{}}:
| Field | Description |
|---|---|
raw_vds | Binary VDS bytes — store this or pass to a carrier |
carrier |
Rendered barcode binary (PNG/SVG), or nil if no carrier configured |
carrier_type |
Carrier format atom (e.g. :data_matrix) |
header |
Decoded %Header{} struct |
message_zone | %MessageZone{} with encoded features |
signature_zone | %SignatureZone{} with signature bytes |
profile_id | Profile identifier atom |
signer_id | Signer identifier string |
key_reference | Key reference string |
issued_at | Date.t() — document issue date |
signed_at | Date.t() — signature creation date |
encryption | %EncryptionOutput{} or nil |
Verification Config Reference
%{
# --- Profile (recommended — used for field decoding) ---
profile: MyApp.VDS.TravelPass,
# --- Trust resolver (required) ---
verifier: %{
trust_resolver: ExIcaoVds.TrustResolvers.StaticKeyStore,
# ... resolver-specific keys (see Trust Resolvers section)
},
# --- Decryption (only needed if sealed with HPKE encryption) ---
decryption: %{
backend: ExIcaoVds.Encryptors.HPKE,
key_store: %{
"verifier-key-2026-01" => {priv_key_bytes, pub_key_bytes}
}
}
}Verification result
ExIcaoVds.verify/2 returns {:ok, %VerificationResult{}} when the signature is valid, or {:error, %VerificationResult{}} when it is not.
| Field | Description |
|---|---|
status | :valid or :invalid |
features | [%Feature{}] — decoded document fields |
header | Decoded header |
error | %Error{} with :code and :message when invalid |
Each %Feature{} contains:
| Field | Description |
|---|---|
name |
Field name atom (e.g. :document_number) |
value |
Decoded Elixir value (String.t(), Date.t(), etc.) |
tag | TLV tag integer |
encoding | Encoding atom |
required? | Whether required by the profile |
sensitive? | Whether marked sensitive |
Signers
ExIcaoVds.Signers.LocalKey — ECDSA with a local key
%{
backend: ExIcaoVds.Signers.LocalKey,
private_key: raw_ec_private_key_bytes, # 32 bytes for P-256
# OR:
private_key_pem: "-----BEGIN EC PRIVATE KEY-----\n...",
# OR:
private_key_path: "/etc/secrets/vds-signing-key.pem",
algorithm: :ecdsa_p256_sha256, # default; or :ecdsa_p384_sha384
curve: :secp256r1, # inferred from PEM; fallback default
signer_identifier: "UTOSNG",
key_reference: "key-2026-01"
}ExIcaoVds.Signers.Vault — HashiCorp Vault Transit
%{
backend: ExIcaoVds.Signers.Vault,
vault_addr: "https://vault.example.com",
token: {:system, "VAULT_TOKEN"}, # or a binary token string
key_name: "vds-signing-key",
mount_path: "transit", # default
signer_identifier: "UTOSNG",
key_reference: "key-2026-01",
receive_timeout: 5_000,
tls_verify: :verify_peer # or :verify_none
}
Vault signs pre-hashed payloads (prehashed: true). The key in Vault must be an ECDSA P-256 key.
ExIcaoVds.Signers.PKCS11 — HSM / Smartcard via p11ex
Add {:p11ex, "~> 0.3"} to your app's deps, then:
%{
backend: ExIcaoVds.Signers.PKCS11,
lib_path: "/usr/lib/softhsm/libsofthsm2.so",
slot: :first_with_token, # or an integer slot ID
pin: {:system, "HSM_PIN"}, # or a binary PIN
key_label: "vds-signing-key", # CKA_LABEL
# OR:
key_id: <<0x01, 0x02>>, # CKA_ID (if no label)
signer_identifier: "UTOSNG",
key_reference: "key-2026-01"
}SoftHSM2 quick start:
softhsm2-util --init-token --slot 0 --label "vds" --pin 1234 --so-pin 0000
pkcs11-tool --module /usr/lib/softhsm/libsofthsm2.so \
--login --pin 1234 --keypairgen --key-type EC:prime256v1 \
--label "vds-signing-key"Trust Resolvers
ExIcaoVds.TrustResolvers.StaticKeyStore — in-memory map
Best for tests and simple deployments.
verifier: %{
trust_resolver: ExIcaoVds.TrustResolvers.StaticKeyStore,
keys: %{
{"UTOSNG", "key-2026-01"} => {pub_key_bytes, :secp256r1}
}
}ExIcaoVds.TrustResolvers.FileKeyStore — PEM/key files on disk
verifier: %{
trust_resolver: ExIcaoVds.TrustResolvers.FileKeyStore,
key_dir: "/etc/vds/trusted-keys/", # files named <signer_id>_<key_ref>.pem
curve: :secp256r1
}ExIcaoVds.TrustResolvers.HttpStore — JWKS or PEM from HTTP endpoint
Fetches keys on every verification call. Wrap in a GenServer or :persistent_term cache for production.
verifier: %{
trust_resolver: ExIcaoVds.TrustResolvers.HttpStore,
url: "https://trust.authority.example/csca/keys.jwks",
format: :jwks, # :jwks (default) or :pem_list
headers: [{"Authorization", "Bearer #{token}"}],
receive_timeout: 5_000,
tls_verify: :verify_peer,
curve: :secp256r1
}
For :jwks, the JWKS kid field is matched against the VDS key_reference header field. If no kid matches, the first key in the set is used as a fallback.
ExIcaoVds.TrustResolvers.DatabaseStore — Ecto repo
verifier: %{
trust_resolver: ExIcaoVds.TrustResolvers.DatabaseStore,
repo: MyApp.Repo,
schema: MyApp.TrustedSigner,
signer_identifier_field: :signer_identifier, # default
key_reference_field: :key_reference, # default
public_key_field: :public_key, # default
curve_field: :curve, # default (can be nil)
default_curve: :secp256r1
}Example Ecto schema:
defmodule MyApp.TrustedSigner do
use Ecto.Schema
schema "trusted_signers" do
field :signer_identifier, :string
field :key_reference, :string
field :public_key, :binary
field :curve, :string, default: "secp256r1"
end
endCarriers
The carrier wraps the raw VDS binary into a printed symbol. The QR Code carrier is pure Elixir and has no system dependency. Data Matrix, Aztec, and PDF417 require zint on $PATH.
| Module | Format | Max capacity | Implementation |
|---|---|---|---|
ExIcaoVds.Carriers.DataMatrix | Data Matrix | ~800 bytes | Zint CLI (system dep) |
ExIcaoVds.Carriers.QR | QR Code | ~2953 bytes |
Pure Elixir (eqrcode) |
ExIcaoVds.Carriers.Aztec | Aztec Code | ~3832 bytes | Zint CLI (system dep) |
ExIcaoVds.Carriers.PDF417 | PDF417 | ~1100 bytes | Zint CLI (system dep) |
QR Code (pure Elixir — no system dependency):
carrier: %{
backend: ExIcaoVds.Carriers.QR,
output: :png, # :png (default) or :svg
width: 400, # image width in pixels (PNG only)
error_correction: :m # :l | :m | :q | :h (default :m)
}Data Matrix, Aztec, PDF417 (require zint on $PATH):
carrier: %{
backend: ExIcaoVds.Carriers.DataMatrix, # or .Aztec, .PDF417
output: :png, # :png (default) or :svg
module_size: 4, # pixel size per module (zint --scale)
quiet_zone: 2 # quiet zone width (zint --border)
}Render separately after issue:
{:ok, png_bytes} = ExIcaoVds.render_carrier(seal.raw_vds, %{
backend: ExIcaoVds.Carriers.DataMatrix
})
Carrier decode (reading a barcode image) is not implemented — use a dedicated barcode SDK for that step, then pass the raw VDS bytes to ExIcaoVds.verify/2.
Field-Level Encryption
Encryption is optional and field-level. Only fields declared with encoding: :encrypted_cbor in the profile are encrypted. The signature covers the ciphertext, so tampering with an encrypted field is still detected at verification.
Encryption algorithm: HPKE Base Mode — DHKEM(P-256, HKDF-SHA256) + HKDF-SHA256 + AES-256-GCM (RFC 9180).
Encrypted profile field
defprofile do
# ... other fields ...
field :biometric_reference, :string,
tag: 9,
encoding: :encrypted_cbor, # triggers HPKE encryption at issuance
required: false,
max_length: 32
endIssue with encryption
# The recipient generates a P-256 key pair (once, stored securely)
{recipient_pub, recipient_priv} = :crypto.generate_key(:ecdh, :secp256r1)
{:ok, seal} = ExIcaoVds.issue(doc_data, %{
profile: MyApp.VDS.TravelPass,
signer: signer_config,
encryption: %{
backend: ExIcaoVds.Encryptors.HPKE,
recipient_public_key: recipient_pub,
recipient_key_id: "verifier-key-2026-01"
}
})Verify and decrypt
{:ok, result} = ExIcaoVds.verify(seal.raw_vds, %{
profile: MyApp.VDS.TravelPass,
verifier: verifier_config,
decryption: %{
backend: ExIcaoVds.Encryptors.HPKE,
key_store: %{
"verifier-key-2026-01" => {recipient_priv, recipient_pub}
}
}
})
# Field is decrypted transparently:
biometric = Enum.find(result.features, &(&1.name == :biometric_reference))
biometric.value # => "REF-20260101-XYZ"Verify without decryption key
If the verifier has no decryption key, encrypted fields are returned with value: nil and encoding: :encrypted_cbor. The signature is still verified — the seal is :valid, the field just remains opaque.
Capacity Planning
VDS payloads must fit in the chosen carrier. Data Matrix has a practical safe ceiling of 800 bytes (the default). Use the capacity tools to detect problems before you deploy.
Design-time estimate (use in CI)
estimate = ExIcaoVds.estimate_capacity(MyApp.VDS.TravelPass.profile_config())
# Or via the profile module shortcut:
estimate = MyApp.VDS.TravelPass.estimate_capacity()
estimate.status # :ok | :warning | :too_large
estimate.usage_percent # 54.3
estimate.estimated_payload_bytes # 435
estimate.largest_fields # [{:holder_name, 39}, {:notes, 50}, ...]
estimate.recommendations # ["Fields without max_length bounds: ..."]Runtime preflight (use before committing to issuance)
preflight = ExIcaoVds.preflight(MyApp.VDS.TravelPass, doc_data)
preflight.status # :ok | :warning | :too_large
preflight.actual_payload_bytes # 312
preflight.remaining_bytes # 488
preflight.field_sizes # %{document_number: 12, holder_name: 25, ...}Capacity policies
Set :capacity_policy in the issuance config:
| Policy | Behaviour |
|---|---|
:fail (default) |
Returns {:error, %Error{code: :payload_too_large}} with full diagnostics |
:omit_optional | Silently drops non-required fields and retries; errors only if still too large |
# Automatically drop optional fields if the payload is too large
ExIcaoVds.issue(doc_data, Map.put(config, :capacity_policy, :omit_optional))Advanced: Implementing Custom Backends
All backends are plain modules implementing a behaviour. Here is what each looks like:
| Behaviour | Callbacks | Purpose |
|---|---|---|
ExIcaoVds.Profile | profile_id/0, fields/0, normalize/2, validate/2, encode_feature/3, decode_feature/3, post_decode_validate/2 | Custom document type |
ExIcaoVds.Signer | sign/3, algorithm/1, signer_identifier/1, key_reference/1, public_metadata/1 | Custom signing backend (KMS, etc.) |
ExIcaoVds.TrustResolver | resolve/2, trust_mode/1, trusted_material/1, revocation_material/1 | Custom key lookup |
ExIcaoVds.Carrier | encode/2, decode/2, format/0, max_bytes/0 | Custom barcode format |
ExIcaoVds.Encryptor | encrypt_field/5, decrypt_field/5, mode/1, algorithms/1 | Custom encryption scheme |
ExIcaoVds.Clock | utc_today/1 | Inject a fixed date in tests |
ExIcaoVds.AuditLogger | log/3 | Observability hook |
ExIcaoVds.Policy | apply/2 | Post-verification business rules |
Security Model
VDS provides authenticity and integrity. A verifier can confirm:
- The seal was issued by the claimed signer (signature over the header + message zone)
- The seal data has not been modified since issuance
VDS does not provide:
- Confidentiality — the encoded fields are readable by anyone who can decode the barcode, unless you use the
Encryptorbehaviour (HPKE) - Anti-cloning — anyone who obtains a valid seal can copy it onto a different document. Mitigate by comparing the VDS data against printed fields on the document itself, or by checking a revocation list / backend status
Key material
-
Never include private key bytes in application config files. Use
{:system, "ENV_VAR"}resolution for environment variables, or load from a secrets manager / HSM at startup. -
The
signer_identifierandkey_referencevalues are written into every seal header in plaintext. Use stable, non-guessable identifiers.
Inspecting a Seal Without Verifying
# Decode the structure without checking the signature — useful for debugging
{:ok, %{header: header, message_zone: mz}} = ExIcaoVds.inspect_seal(raw_vds_bytes)
header.issuing_country # "UTO"
header.signer_identifier # "UTOSNG"
mz.features # raw features, may be uninterpreted without a profileTesting
Inject a fixed clock and an in-memory key store so tests are deterministic:
# Generate a key pair once per test module
setup_all do
{pub, priv} = :crypto.generate_key(:ecdh, :secp256r1)
%{pub: pub, priv: priv}
end
def issue_config(priv) do
%{
profile: ExIcaoVds.Profiles.MRTD.V1,
signer: %{
backend: ExIcaoVds.Signers.LocalKey,
private_key: priv,
signer_identifier: "TSTSNG",
key_reference: "key-test"
},
clock: ExIcaoVds.Clocks.Fixed,
clock_opts: [date: ~D[2026-01-01]]
}
end
def verify_config(pub) do
%{
profile: ExIcaoVds.Profiles.MRTD.V1,
verifier: %{
trust_resolver: ExIcaoVds.TrustResolvers.StaticKeyStore,
keys: %{{"TSTSNG", "key-test"} => {pub, :secp256r1}}
}
}
end
test "issued seal verifies", %{pub: pub, priv: priv} do
{:ok, seal} = ExIcaoVds.issue(doc_data(), issue_config(priv))
{:ok, result} = ExIcaoVds.verify(seal.raw_vds, verify_config(pub))
assert result.status == :valid
endFlutter Verification
A Flutter app can scan and verify a VDS barcode without any Elixir dependency. The verification steps are:
- Scan the Data Matrix / QR / Aztec barcode → raw bytes
- Parse the VDS binary (header → message zone → signature zone)
- Verify the ECDSA P-256 / SHA-256 signature using the issuing authority's public key
- Display the result and the decoded public fields
Sensitive fields encrypted with HPKE cannot be decrypted on the device. If your profile
uses encoding: :encrypted_cbor, decrypt those fields server-side (send the raw VDS bytes
to a trusted backend that holds the recipient private key) and return the plaintext to the
app.
Packages
# pubspec.yaml
dependencies:
mobile_scanner: ^6.0.0 # Data Matrix / QR / Aztec scanning (ML Kit + iOS Vision)
pointycastle: ^3.9.0 # ECDSA P-256 signature verification — pure Dartmobile_scanner wraps Google ML Kit (Android) and Apple Vision (iOS), both of which support
Data Matrix ECC 200 natively. pointycastle is a pure-Dart port of Bouncy Castle — no
native code, works on all Flutter targets.
Core verification library
Create lib/vds/vds_verifier.dart in your Flutter project:
import 'dart:typed_data';
import 'package:pointycastle/export.dart';
// ── Data classes ────────────────────────────────────────────────────────────
class VdsHeader {
final String issuingCountry;
final String signerIdentifier;
final String keyReference;
final DateTime documentIssueDate;
final DateTime signatureCreationDate;
final int featureDefinitionReference;
final String documentTypeCategory;
const VdsHeader({
required this.issuingCountry,
required this.signerIdentifier,
required this.keyReference,
required this.documentIssueDate,
required this.signatureCreationDate,
required this.featureDefinitionReference,
required this.documentTypeCategory,
});
}
class VdsFeature {
final int tag;
final Uint8List encodedValue;
const VdsFeature({required this.tag, required this.encodedValue});
}
class VdsVerificationResult {
final bool signatureValid;
final VdsHeader? header;
final List<VdsFeature> features;
final String? error;
const VdsVerificationResult({
required this.signatureValid,
this.header,
this.features = const [],
this.error,
});
}
// ── C40 decoder ─────────────────────────────────────────────────────────────
// Set 0 only: space(3), 0–9(4–13), A–Z(14–39). Padding symbol (0) is skipped.
String _decodeC40(Uint8List bytes) {
const table = {
3: ' ', 4: '0', 5: '1', 6: '2', 7: '3', 8: '4', 9: '5', 10: '6',
11: '7', 12: '8', 13: '9', 14: 'A', 15: 'B', 16: 'C', 17: 'D',
18: 'E', 19: 'F', 20: 'G', 21: 'H', 22: 'I', 23: 'J', 24: 'K',
25: 'L', 26: 'M', 27: 'N', 28: 'O', 29: 'P', 30: 'Q', 31: 'R',
32: 'S', 33: 'T', 34: 'U', 35: 'V', 36: 'W', 37: 'X', 38: 'Y', 39: 'Z',
};
final buf = StringBuffer();
for (var i = 0; i < bytes.length - 1; i += 2) {
final word = bytes[i] * 256 + bytes[i + 1] - 1;
for (final v in [word ~/ 1600, (word % 1600) ~/ 40, word % 40]) {
final ch = table[v];
if (ch != null) buf.write(ch);
}
}
return buf.toString();
}
// ── BCD date decoder with pivot-year century inference ───────────────────────
// Wire format: DDMMYY (3 bytes BCD). Century is inferred: yy ≤ (currentYear+20)%100
// means 2000s, otherwise 1900s — matching the Elixir server behaviour.
DateTime _decodeBcdDate(int bcdDay, int bcdMonth, int bcdYear) {
int fromBcd(int b) => (b >> 4) * 10 + (b & 0xF);
final day = fromBcd(bcdDay);
final month = fromBcd(bcdMonth);
final yy = fromBcd(bcdYear);
final cutoff = (DateTime.now().year + 20) % 100;
final year = yy <= cutoff ? 2000 + yy : 1900 + yy;
return DateTime(year, month, day);
}
// ── BER-TLV length decoder ───────────────────────────────────────────────────
(int length, int bytesConsumed) _decodeBerLength(Uint8List bytes, int offset) {
final first = bytes[offset];
if (first <= 0x7F) return (first, 1);
if (first == 0x81) return (bytes[offset + 1], 2);
if (first == 0x82) return (bytes[offset + 1] * 256 + bytes[offset + 2], 3);
throw FormatException('Unsupported BER length prefix: 0x${first.toRadixString(16)}');
}
// ── VDS binary parser ────────────────────────────────────────────────────────
({
Uint8List headerBytes,
Uint8List messageZoneBytes,
Uint8List signatureBytes,
VdsHeader header,
List<VdsFeature> features,
}) _parseVds(Uint8List bytes) {
var offset = 0;
// Magic + version
if (bytes.length < 2 || bytes[0] != 0xDC || bytes[1] != 0x04) {
throw const FormatException('Not a VDS v4 binary (missing 0xDC 0x04)');
}
offset = 2;
// Issuing country — always 2 C40 bytes (3 chars)
final country = _decodeC40(bytes.sublist(offset, offset + 2)).substring(0, 3);
offset += 2;
// Signer identifier — length-prefixed C40
final signerLen = bytes[offset++];
final signerId = _decodeC40(bytes.sublist(offset, offset + signerLen));
offset += signerLen;
// Key / certificate reference — ref_type (1) + ref_len (1) + ref_bytes (n)
final refType = bytes[offset++]; // 0x01 = key_reference, 0x02 = cert_reference
final refLen = bytes[offset++];
final keyRef = String.fromCharCodes(bytes.sublist(offset, offset + refLen));
offset += refLen;
// Dates — each is 3 BCD bytes: day, month, last-2-digits-of-year
final issueDate = _decodeBcdDate(bytes[offset], bytes[offset + 1], bytes[offset + 2]);
offset += 3;
final sigDate = _decodeBcdDate(bytes[offset], bytes[offset + 1], bytes[offset + 2]);
offset += 3;
// Feature definition reference + document type category (ASCII)
final fdr = bytes[offset++];
final docType = String.fromCharCode(bytes[offset++]);
final headerBytes = bytes.sublist(0, offset);
final header = VdsHeader(
issuingCountry: country,
signerIdentifier: signerId,
keyReference: keyRef,
documentIssueDate: issueDate,
signatureCreationDate: sigDate,
featureDefinitionReference: fdr,
documentTypeCategory: docType,
);
// Message zone — BER-TLV entries until 0xFF signature marker
final mzStart = offset;
final features = <VdsFeature>[];
while (offset < bytes.length && bytes[offset] != 0xFF) {
final tag = bytes[offset++];
final (len, lenBytes) = _decodeBerLength(bytes, offset);
offset += lenBytes;
final value = Uint8List.fromList(bytes.sublist(offset, offset + len));
offset += len;
features.add(VdsFeature(tag: tag, encodedValue: value));
}
final messageZoneBytes = bytes.sublist(mzStart, offset);
// Signature zone — 0xFF + algo_byte + BER-length + DER-signature
if (offset >= bytes.length || bytes[offset] != 0xFF) {
throw const FormatException('Missing VDS signature marker 0xFF');
}
offset++; // skip 0xFF
// algo byte: 0x01 = ECDSA P-256/SHA-256, 0x02 = ECDSA P-384/SHA-384
offset++; // algo byte (validated by verifyEcdsaP256Sha256 below)
final (sigLen, sigLenBytes) = _decodeBerLength(bytes, offset);
offset += sigLenBytes;
final signatureBytes = Uint8List.fromList(bytes.sublist(offset, offset + sigLen));
return (
headerBytes: headerBytes,
messageZoneBytes: messageZoneBytes,
signatureBytes: signatureBytes,
header: header,
features: features,
);
}
// ── ECDSA P-256 / SHA-256 verification ──────────────────────────────────────
// publicKeyBytes: 65-byte uncompressed EC point (0x04 | x_32 | y_32)
// derSignature: DER-encoded ECDSA signature from the VDS signature zone
bool _verifyEcdsaP256(Uint8List payload, Uint8List publicKeyBytes, Uint8List derSignature) {
try {
final curve = ECCurve_secp256r1();
final point = curve.curve.decodePoint(publicKeyBytes);
if (point == null) return false;
final signer = ECDSASigner(SHA256Digest())
..init(false, PublicKeyParameter<ECPublicKey>(ECPublicKey(point, curve)));
return signer.verifySignature(payload, _parseDerSignature(derSignature));
} catch (_) {
return false;
}
}
ECSignature _parseDerSignature(Uint8List der) {
// SEQUENCE (0x30) { INTEGER r, INTEGER s }
var i = 2; // skip 0x30 + total-length byte
assert(der[i] == 0x02, 'Expected INTEGER tag for r');
final rLen = der[++i];
final r = _bigIntFromBytes(der, ++i, rLen);
i += rLen;
assert(der[i] == 0x02, 'Expected INTEGER tag for s');
final sLen = der[++i];
final s = _bigIntFromBytes(der, ++i, sLen);
return ECSignature(r, s);
}
BigInt _bigIntFromBytes(Uint8List bytes, int offset, int length) {
// DER may prefix a 0x00 byte when the high bit of a positive integer is set.
// Converting the full slice (including any 0x00 prefix) to BigInt is correct
// because 0x00 contributes nothing to the unsigned magnitude.
var result = BigInt.zero;
for (var j = 0; j < length; j++) {
result = (result << 8) | BigInt.from(bytes[offset + j]);
}
return result;
}
// ── Public API ───────────────────────────────────────────────────────────────
/// Verify a raw VDS binary against a key store.
///
/// [keyStore] maps `(signerId, keyRef)` pairs to 65-byte uncompressed
/// P-256 public key points — the same bytes returned by the Elixir
/// `:crypto.generate_key(:ecdh, :secp256r1)` call (pub_key component).
///
/// Returns a [VdsVerificationResult] with [signatureValid] set to `true`
/// when the seal is authentic. Features are always decoded regardless of
/// signature status so the caller can display them for comparison.
VdsVerificationResult verifyVds({
required Uint8List rawVds,
required Map<({String signerId, String keyRef}), Uint8List> keyStore,
}) {
late final ({
Uint8List headerBytes,
Uint8List messageZoneBytes,
Uint8List signatureBytes,
VdsHeader header,
List<VdsFeature> features,
}) parsed;
try {
parsed = _parseVds(rawVds);
} on FormatException catch (e) {
return VdsVerificationResult(signatureValid: false, error: e.message);
} catch (e) {
return VdsVerificationResult(signatureValid: false, error: 'Parse error: $e');
}
final pubKey = keyStore[(
signerId: parsed.header.signerIdentifier,
keyRef: parsed.header.keyReference,
)];
if (pubKey == null) {
return VdsVerificationResult(
signatureValid: false,
header: parsed.header,
features: parsed.features,
error: 'Unknown signer: ${parsed.header.signerIdentifier} / ${parsed.header.keyReference}',
);
}
final signedPayload = Uint8List.fromList([
...parsed.headerBytes,
...parsed.messageZoneBytes,
]);
final valid = _verifyEcdsaP256(signedPayload, pubKey, parsed.signatureBytes);
return VdsVerificationResult(
signatureValid: valid,
header: parsed.header,
features: parsed.features,
error: valid ? null : 'Signature verification failed — seal may be tampered',
);
}Scanner widget
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import 'vds_verifier.dart'; // the file above
/// Hardcode or fetch from your trust store API.
/// Keys are the raw 65-byte uncompressed P-256 points from the Elixir issuing server.
final Map<({String signerId, String keyRef}), Uint8List> _keyStore = {
(signerId: 'KENSNG', keyRef: 'key-2026-01'): _loadKey('assets/keys/KENSNG_key-2026-01.bin'),
};
Uint8List _loadKey(String assetPath) {
// In a real app: load from assets, fetch from a JWKS endpoint, or embed at build time.
throw UnimplementedError('Load your public key bytes here');
}
class VdsScannerPage extends StatefulWidget {
const VdsScannerPage({super.key});
@override
State<VdsScannerPage> createState() => _VdsScannerPageState();
}
class _VdsScannerPageState extends State<VdsScannerPage> {
VdsVerificationResult? _result;
bool _processing = false;
void _onDetect(BarcodeCapture capture) {
if (_processing) return;
final barcode = capture.barcodes.firstOrNull;
if (barcode == null) return;
// rawBytes contains the binary payload for Data Matrix / Aztec barcodes.
// rawValue is a decoded string — do NOT use it; binary VDS data is not valid UTF-8.
final raw = barcode.rawBytes;
if (raw == null || raw.isEmpty) return;
setState(() => _processing = true);
final result = verifyVds(
rawVds: Uint8List.fromList(raw),
keyStore: _keyStore,
);
setState(() {
_result = result;
_processing = false;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Scan VDS')),
body: Stack(
children: [
MobileScanner(
onDetect: _onDetect,
// Restrict to formats your documents use; omit to scan everything.
controller: MobileScannerController(
formats: const [BarcodeFormat.dataMatrix, BarcodeFormat.qrCode],
),
),
if (_result != null) _ResultBanner(result: _result!),
],
),
);
}
}
class _ResultBanner extends StatelessWidget {
const _ResultBanner({required this.result});
final VdsVerificationResult result;
@override
Widget build(BuildContext context) {
final color = result.signatureValid ? Colors.green.shade700 : Colors.red.shade700;
final label = result.signatureValid ? '✓ Seal valid' : '✗ Invalid seal';
return Positioned(
bottom: 0, left: 0, right: 0,
child: SafeArea(
child: Container(
color: color,
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(label,
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold)),
if (result.header != null) ...[
const SizedBox(height: 4),
Text('Issuer: ${result.header!.signerIdentifier}',
style: const TextStyle(color: Colors.white)),
Text('Country: ${result.header!.issuingCountry}',
style: const TextStyle(color: Colors.white)),
Text('Issued: ${result.header!.documentIssueDate.toLocal()}',
style: const TextStyle(color: Colors.white70)),
Text('Fields: ${result.features.length} encoded',
style: const TextStyle(color: Colors.white70)),
],
if (result.error != null)
Text(result.error!,
style: const TextStyle(color: Colors.white70, fontSize: 12)),
],
),
),
),
);
}
}Public key distribution
The issuing server's public key (65-byte uncompressed P-256 point) needs to reach the app. Common patterns:
| Approach | Trade-offs |
|---|---|
| Bundled in app assets | Simple; requires app update to rotate keys |
| Fetched from a JWKS endpoint on startup | Rotatable without redeploy; requires network |
| Pinned in code at build time | Simplest; hardest to rotate |
The Elixir server exposes the public key via the pub_key returned by :crypto.generate_key/2.
Convert it to Base64 for transport:
# Elixir — export the public key as Base64 for bundling in the Flutter app
Base.encode64(pub_key)// Dart — decode from Base64
import 'dart:convert';
final pubKeyBytes = Uint8List.fromList(base64.decode(base64PubKeyString));Encrypted fields
If the profile uses encoding: :encrypted_cbor (e.g. ETA.V1 for holder_name,
document_number, etc.), those features arrive as opaque ciphertext in
feature.encodedValue. The ECDSA signature still verifies — the ciphertext is authentic —
but the plaintext is unreadable without the HPKE recipient private key.
Do not put the recipient private key in the Flutter app. Instead, send the raw VDS bytes to a trusted backend (e.g., a border-control API) that holds the private key, and return the decrypted plaintext:
Future<Map<int, String>> decryptFields(Uint8List rawVds) async {
final response = await http.post(
Uri.parse('https://api.border.example/vds/decrypt'),
headers: {'Content-Type': 'application/octet-stream'},
body: rawVds,
);
if (response.statusCode != 200) throw Exception('Decryption failed');
// Returns { "5": "1992-04-22", "10": "MICHAEL NANA SIMMONS", ... }
return (jsonDecode(response.body) as Map<String, dynamic>)
.map((k, v) => MapEntry(int.parse(k), v as String));
}Contributing
Contributions should keep the public API, docs, and release metadata aligned.
Before opening a release PR or tagging a version:
mix precommitVersioning
This project follows Semantic Versioning. The version in mix.exs is the
source of truth, and git tags should always match it as v<version>.
| Bump | When |
|---|---|
patch | Backwards-compatible bug fixes, documentation-only fixes, internal changes with no public behaviour change |
minor | Backwards-compatible new features, new public APIs, additive functionality |
major | Breaking changes to the public API, behaviour, configuration, or supported upgrade path |
To bump the version in mix.exs:
mix ex_icao_vds.versionRunning the task without an argument prompts for the bump type interactively. You can also specify it directly:
mix ex_icao_vds.version patch
mix ex_icao_vds.version minor
mix ex_icao_vds.version majorReleasing
To create the matching annotated git tag after updating mix.exs:
mix ex_icao_vds.tag
The tag task reads @version from mix.exs, creates v<version>, and refuses
to run if the git worktree is dirty or the tag already exists.
Typical release flow:
mix precommit
mix docs
mix ex_icao_vds.version minor
git add mix.exs README.md
git commit -m "Prepare 0.2.0 release"
mix ex_icao_vds.tag
git push origin main
git push origin v0.2.0
mix hex.publishLicense
MIT