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.


VDS Wire Format

A VDS is a compact binary payload, rendered as a 2D barcode, with three zones:

┌──────────────────────────────────────────────────────────────────────────────┐
│ HEADER (~17–30 bytes)                                                        │
│  0xDC │ version │ issuing_country │ signer_identifier │ key_reference        │
│  document_issue_date │ signature_date │ FDR │ document_type_category         │
├──────────────────────────────────────────────────────────────────────────────┤
│ MESSAGE ZONE (variable)                                                      │
│  BER-TLV fields:  tag │ length │ value  (one per document data element)      │
│  Encodings: C40, UTF-8, BCD date, integer, CBOR, HPKE-encrypted CBOR        │
├──────────────────────────────────────────────────────────────────────────────┤
│ SIGNATURE ZONE (~70–72 bytes)                                                │
│  signer_identifier │ key_reference │ DER ECDSA signature                    │
│  Signature covers header + message zone bytes.                               │
└──────────────────────────────────────────────────────────────────────────────┘

Concepts

Term Meaning
issuing_country ISO 3166-1 alpha-3 code of the issuing authority (e.g. "KEN"). 3 uppercase letters. Written into the header.
signer_identifier Alphanumeric code identifying the authority that signed the document. The verifier uses {signer_identifier, key_reference} as the lookup key to find the correct public key. Convention: 3-char country + 3-char authority short name (e.g. "KENSNG", "UTOBKM"). Arbitrary — choose a value, register it with your verifiers.
key_reference Identifies which specific key pair was used. Enables key rotation: when a new key pair is generated, give it a new reference (e.g. "key-2026-01"). Old seals remain verifiable as long as verifiers retain the previous {signer_identifier, old_key_reference} → public_key entry. No format enforced; date-based labels are conventional.
Feature Definition Reference (FDR) 1-byte integer (0–255) written into the header that identifies the field schema for the message zone. Set by the profile via feature_definition_reference. The MRTD profile uses 1.
document_type_category Single ASCII character in the header indicating the broad document class ("A" for travel documents). Set by the profile.
BER-TLV Binary Tag-Length-Value encoding used for each field in the message zone.
C40 3-characters-per-2-bytes encoding for uppercase alphanumeric text. Used in the VDS header and for uppercase-only message zone fields.

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:

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

# macOS
brew install zint

# Ubuntu / Debian
sudo apt-get install -y zint

# Fedora / RHEL
sudo dnf install zint

Installation

Add ex_icao_vds to your mix.exs:

def deps do
  [
    {:ex_icao_vds, "~> 0.3"}
  ]
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.PKCS11

Quick 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_type:   "P",
    issuing_state:   "KEN",
    holder_name:     "KAMAU WANJIKU",
    document_number: "AB1234567",
    nationality:     "KEN",
    date_of_birth:   ~D[1985-03-22],
    sex:             "F",
    date_of_expiry:  ~D[2030-09-30]
  },
  config
)

File.write!("seal.png", seal.carrier)   # PNG of the Data Matrix barcode

Verify:

{: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
end

Issue 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 key

Defining 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

Profile directives

profile_id(atom)

A unique atom identifying this profile. Stored in the issued seal and used as a routing key. Choose a descriptive, versioned name (e.g. :travel_pass_v1). If omitted, the module name is used.


document_type_category(single ASCII character)

Written into the VDS header. Identifies the broad class of document. ICAO VDS-NC defines the following values:

Value Document class
"A" Official travel document (passport, ID card, visa, eTA) — most common
"V" Visa sticker
"H" Health / vaccination certificate
"I" Identity document (non-travel)

Use "A" for travel documents unless your document type has a dedicated category.


feature_definition_reference(integer, 0–255)

A 1-byte integer written into the VDS header. It tells the verifier which field schema to use when decoding the message zone — think of it as the schema ID shared between issuer and verifier.

Value Meaning
1 MRTD — the ICAO Doc 9303 standardised value
2–254 Available for custom / private profiles
0, 255 Reserved

If you define a private profile, choose any unused value from 2–254 and ensure your verifiers are configured with the same value.


version(positive integer)

Your profile's own schema version number. Not encoded in the VDS binary — it is informational, accessible via profile_config().version, useful for tracking schema evolution within your system.


carrier_capacity(bytes)

Optional. The maximum payload capacity of your target carrier in bytes (default: 800). The compile-time capacity check emits a warning if the estimated payload exceeds 80% of this value. Set it to match your carrier:

Carrier Capacity
Data Matrix ECC200 1558 bytes
Aztec (max) 1914 bytes
QR Code (version 40, binary mode) 2953 bytes

field(name, type, opts)

Declares a single data element. Fields are encoded in tag-order into the message zone.

field :document_number, :string,
  tag: 1,
  encoding: :c40,
  required: true,
  max_length: 9

Types:

Type Elixir value Notes
:stringString.t() Text; encoding determines wire format
:integerinteger() Non-negative unsigned integer
:dateDate.t() Also accepts ISO-8601 strings; normalised on input
:booleanboolean()
:enumatom() One of the atoms in values:; encoded as 0-based integer index
:binarybinary() Raw bytes

Encodings:

Encoding Wire format When to use
:c40 3 chars → 2 bytes Uppercase alphanumeric (A–Z, 0–9, space, and a small set of symbols). Most compact for codes, names, and MRZ-style data.
:utf8 Raw UTF-8 bytes Mixed-case or Unicode text
:date 3 bytes BCD DDMMYY Date fields; century inferred by pivot-year (current year + 20)
:integer Unsigned big-endian Non-negative integers
:boolean 1 byte (0x00 / 0x01) True/false flags
:cbor CBOR bytes Structured values
:encrypted_cbor HPKE ciphertext Sensitive fields — CBOR-encoded then HPKE-encrypted; signature covers the ciphertext
:raw Bytes as-is Pre-encoded or opaque binary data

Field options:

Option Required Description
tag: yes BER-TLV tag byte, integer 1–127. Must be unique within the profile.
encoding: yes Wire encoding (see table above).
required: Default false. If true, issuance fails when the field is absent.
max_length: Character limit for :c40 / :utf8 / :encrypted_cbor; byte limit for :raw / :binary. Omitting this on a string/binary field triggers a compile warning.
sensitive: Default false. Marks the field as PII; affects audit log output. Always set to true for :encrypted_cbor fields.
values: for :enum List of allowed atoms. Order determines wire integer index (0-based): e.g. [:active, :cancelled]active = 0, cancelled = 1.
default: Value used when the field is absent in the input document data.

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:

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 9 — document_type, issuing_state, holder_name, document_number, nationality, date_of_birth, sex, date_of_expiry, optional_data Passport / travel document (ICAO Doc 9303)
ExIcaoVds.Profiles.ETA.V1 10 — authorization_number, issuing_country, document_number*, nationality*, date_of_birth*, holder_name*, document_expiry_date, authorization_issue_date, authorization_valid_until, status Electronic Travel Authorisation; *PII fields HPKE-encrypted

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",   # (1)
    key_reference:      "key-2026-01",               # (2)
    # ... 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
}

(1) signer_identifier — An uppercase alphanumeric code naming the issuing authority. Verifiers use the pair {signer_identifier, key_reference} as the key-store lookup key, so both values must match exactly what you register with your verifiers. Convention: 3-char country + 3-char authority short name (e.g. "KENSNG", "UTOBKM"). No format is enforced; the string is C40-encoded into the header and signature zone.

(2) key_reference — Identifies which key pair was used. When you rotate to a new signing key, assign it a new reference (e.g. "key-2026-02"). Old seals remain verifiable as long as verifiers keep the old {signer_identifier, old_key_reference} entry in their trust store. No format is enforced; date-based labels or sequential counters both work.

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_atDate.t() — document issue date
signed_atDate.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
end

Carriers

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
end

Issue 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.Profileprofile_id/0, fields/0, normalize/2, validate/2, encode_feature/3, decode_feature/3, post_decode_validate/2 Custom document type
ExIcaoVds.Signersign/3, algorithm/1, signer_identifier/1, key_reference/1, public_metadata/1 Custom signing backend (KMS, etc.)
ExIcaoVds.TrustResolverresolve/2, trust_mode/1, trusted_material/1, revocation_material/1 Custom key lookup
ExIcaoVds.Carrierencode/2, decode/2, format/0, max_bytes/0 Custom barcode format
ExIcaoVds.Encryptorencrypt_field/5, decrypt_field/5, mode/1, algorithms/1 Custom encryption scheme
ExIcaoVds.Clockutc_today/1 Inject a fixed date in tests
ExIcaoVds.AuditLoggerlog/3 Observability hook
ExIcaoVds.Policyapply/2 Post-verification business rules

Security Model

VDS provides authenticity and integrity. A verifier can confirm:

  1. The seal was issued by the claimed signer (signature over the header + message zone)
  2. The seal data has not been modified since issuance

VDS does not provide:

Key material


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 profile

Testing

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
end

Flutter Verification

A Flutter app can scan and verify a VDS barcode without any Elixir dependency. The verification steps are:

  1. Scan the Data Matrix / QR / Aztec barcode → raw bytes
  2. Parse the VDS binary (header → message zone → signature zone)
  3. Verify the ECDSA P-256 / SHA-256 signature using the issuing authority's public key
  4. 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 Dart

mobile_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 &#39;dart:typed_data&#39;;
import &#39;package:pointycastle/export.dart&#39;;

// ── 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: &#39; &#39;, 4: &#39;0&#39;, 5: &#39;1&#39;, 6: &#39;2&#39;, 7: &#39;3&#39;, 8: &#39;4&#39;, 9: &#39;5&#39;, 10: &#39;6&#39;,
    11: &#39;7&#39;, 12: &#39;8&#39;, 13: &#39;9&#39;, 14: &#39;A&#39;, 15: &#39;B&#39;, 16: &#39;C&#39;, 17: &#39;D&#39;,
    18: &#39;E&#39;, 19: &#39;F&#39;, 20: &#39;G&#39;, 21: &#39;H&#39;, 22: &#39;I&#39;, 23: &#39;J&#39;, 24: &#39;K&#39;,
    25: &#39;L&#39;, 26: &#39;M&#39;, 27: &#39;N&#39;, 28: &#39;O&#39;, 29: &#39;P&#39;, 30: &#39;Q&#39;, 31: &#39;R&#39;,
    32: &#39;S&#39;, 33: &#39;T&#39;, 34: &#39;U&#39;, 35: &#39;V&#39;, 36: &#39;W&#39;, 37: &#39;X&#39;, 38: &#39;Y&#39;, 39: &#39;Z&#39;,
  };
  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(&#39;Unsupported BER length prefix: 0x${first.toRadixString(16)}&#39;);
}

// ── 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(&#39;Not a VDS v4 binary (missing 0xDC 0x04)&#39;);
  }
  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(&#39;Missing VDS signature marker 0xFF&#39;);
  }
  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, &#39;Expected INTEGER tag for r&#39;);
  final rLen = der[++i];
  final r = _bigIntFromBytes(der, ++i, rLen);
  i += rLen;
  assert(der[i] == 0x02, &#39;Expected INTEGER tag for s&#39;);
  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: &#39;Parse error: $e&#39;);
  }

  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: &#39;Unknown signer: ${parsed.header.signerIdentifier} / ${parsed.header.keyReference}&#39;,
    );
  }

  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 : &#39;Signature verification failed — seal may be tampered&#39;,
  );
}

Scanner widget

import &#39;dart:typed_data&#39;;
import &#39;package:flutter/material.dart&#39;;
import &#39;package:mobile_scanner/mobile_scanner.dart&#39;;
import &#39;vds_verifier.dart&#39;; // 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: &#39;KENSNG&#39;, keyRef: &#39;key-2026-01&#39;): _loadKey(&#39;assets/keys/KENSNG_key-2026-01.bin&#39;),
};

Uint8List _loadKey(String assetPath) {
  // In a real app: load from assets, fetch from a JWKS endpoint, or embed at build time.
  throw UnimplementedError(&#39;Load your public key bytes here&#39;);
}

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(&#39;Scan VDS&#39;)),
      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 ? &#39;Seal valid&#39; : &#39;Invalid seal&#39;;

    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(&#39;Issuer: ${result.header!.signerIdentifier}&#39;,
                    style: const TextStyle(color: Colors.white)),
                Text(&#39;Country: ${result.header!.issuingCountry}&#39;,
                    style: const TextStyle(color: Colors.white)),
                Text(&#39;Issued: ${result.header!.documentIssueDate.toLocal()}&#39;,
                    style: const TextStyle(color: Colors.white70)),
                Text(&#39;Fields: ${result.features.length} encoded&#39;,
                    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 &#39;dart:convert&#39;;
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(&#39;https://api.border.example/vds/decrypt&#39;),
    headers: {&#39;Content-Type&#39;: &#39;application/octet-stream&#39;},
    body: rawVds,
  );
  if (response.statusCode != 200) throw Exception(&#39;Decryption failed&#39;);
  // 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 precommit

Versioning

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.version

Running 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 major

Releasing

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.publish

License

MIT