ExIcaoVds.Profile behaviour (ex_icao_vds v0.3.2)

Copy Markdown

Behaviour for VDS document profiles, plus the defprofile do DSL for defining profiles with use ExIcaoVds.Profile.

Quick example

defmodule MyApp.VDS.TravelPass do
  use ExIcaoVds.Profile

  defprofile do
    profile_id :travel_pass_v1
    document_type_category "A"
    feature_definition_reference 2
    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 :holder_name, :string,
      tag: 3,
      encoding: :utf8,
      required: true,
      max_length: 39

    field :notes, :string,
      tag: 4,
      encoding: :utf8,
      required: false,
      max_length: 50,
      sensitive: true
  end
end

The macro injects issue/2, verify/2, decode/2, definition/0, profile_config/0, estimate_capacity/1, and preflight/2 on the module.

Profile directives

profile_id

A unique atom that identifies this profile. It is stored in the issued seal and returned in verification results, so you can tell which profile produced a given barcode.

profile_id :travel_pass_v1

If omitted, the module name is used as the identifier.

document_type_category

A single ASCII character written into the VDS binary header. It broadly classifies the document type. ICAO Doc 9303 Part 13 defines the following standard values:

ValueDocument class
"A"Official travel document — passport, ID card, visa, eTA. Use this for the vast majority of travel-related documents.
"V"Visa sticker or label
"H"Health or vaccination certificate
"I"Identity document not used for international travel

When in doubt, use "A". This directive defaults to "A" if omitted.

feature_definition_reference

An integer from 0 to 255, written as a single byte in the VDS header. It acts as the schema identifier for the message zone: the verifier reads this byte to know which set of field tags to expect and how to decode them.

Think of it as a profile registry number. Issuer and verifier must agree on the same value — if they differ, the verifier will not know how to interpret the fields.

Standard values defined by ICAO Doc 9303:

ValueAssigned meaning
1MRTD — Machine Readable Travel Document (passport/ID card fields as per ICAO Doc 9303 Parts 4–6)
2–254Available for custom / private profiles
0, 255Reserved

If you are defining a private profile, choose any value from 2 to 254 that does not conflict with other profiles you issue, and register it with the parties who will verify your seals. Defaults to 1 if omitted.

version

Your profile schema's internal version number (positive integer). This is stored in the profile config and accessible via profile_config().version, but is not written into the VDS binary. Use it to track schema evolution within your own system. Defaults to 1.

carrier_capacity

The maximum payload size in bytes of the physical carrier (barcode) you intend to use. The compile-time capacity estimator emits a warning when the estimated payload exceeds 80% of this value, helping you catch over-sized payloads before deployment.

carrier_capacity 1558   # Data Matrix ECC200

Typical values:

CarrierCapacity
Data Matrix ECC2001558 bytes
Aztec Code (max)1914 bytes
QR Code version 40, binary mode2953 bytes

Defaults to 1558 bytes (Data Matrix ECC200) if omitted — the same carrier used by default when no carrier backend is specified at issuance.

Defining fields

Each field declaration adds one data element to the profile. Fields are encoded in tag order into the message zone of the VDS binary.

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

tag

An integer from 1 to 127 that uniquely identifies this field within the profile. Think of it as a column number: the VDS binary stores each field as a [tag][length][value] triplet, and the verifier reads the tag to know which field it has found.

Rules:

  • Must be unique within a profile. Duplicate tags cause a CompileError.
  • Use values 1–127. Values above 127 require multi-byte tag encoding (not supported).
  • Once a profile is deployed and seals are in circulation, never change a tag. A seal issued with tag 3 meaning :nationality will always have tag 3 meaning :nationality — if you later reassign tag 3 to a different field, existing seals become unreadable.
  • Gaps are fine: tags 1, 3, 7 work as well as 1, 2, 3.
  • Convention: assign sequentially starting at 1, in the order fields appear in the document.

The underlying encoding is a simplified form of BER-TLV (Basic Encoding Rules — Tag, Length, Value), the same binary framing used in smart card protocols and ASN.1. You do not need to know BER-TLV to use this library; the tag is simply a unique field number.

type

The Elixir type of the field value. Controls normalisation and validation before encoding.

TypeAccepted Elixir valuesNotes
:stringString.t()Any text. Encoding determines the wire format.
:integernon-negative integer()Unsigned whole number.
:date%Date{} or ISO-8601 string ("2026-05-04")Normalised to %Date{} before encoding.
:booleanboolean()true or false.
:enumatom()One of the atoms listed in values:.
:binarybinary()Raw bytes.

encoding

Controls how the field value is serialised into bytes on the wire.

:c40

Encodes three characters into two bytes using the C40 scheme defined in ICAO Doc 9303. It is the most compact option for uppercase alphanumeric text: a 9-character document number fits in 6 bytes.

Use when: the field contains only uppercase letters (A–Z), digits (0–9), spaces, and a small set of symbols. Document numbers, country codes, MRZ names, and ISO codes are all good candidates.

Do not use when: the value might contain lowercase letters, diacritics, or non-ASCII characters — use :utf8 instead.

:utf8

Stores the string as raw UTF-8 bytes. ASCII characters take 1 byte each; non-ASCII characters take 2–4 bytes.

Use when: the field may contain mixed-case text, accented characters, or any Unicode content (full holder names, free-text remarks, addresses).

:date

Encodes a date as 3 bytes using BCD (Binary Coded Decimal): one byte each for day, month, and year (two-digit). For example, 22 April 1992 is stored as 0x22 0x04 0x92.

The year is stored as two digits (00–99) and the century is inferred when decoding using a pivot-year heuristic: if YY ≤ (current_year + 20) mod 100, the century is 2000; otherwise it is 1900. This reliably round-trips dates from roughly 1925 through 2045 given today's year.

Use for: all date fields (date of birth, expiry date, issue date, etc.). Pair with type :date.

:integer

Encodes a non-negative integer as unsigned big-endian bytes. The byte width grows with the magnitude: 0–255 uses 1 byte, 256–65535 uses 2 bytes, and so on.

Use for: counters, status codes encoded as numbers, or fields where you need the raw numeric value on the wire. For a small fixed set of values, :enum is usually cleaner.

:boolean

Encodes true as 0x01 and false as 0x00, always 1 byte. Pair with type :boolean.

:cbor

Serialises the value using CBOR (Concise Binary Object Representation, RFC 7049). Any CBOR-serialisable Elixir value is accepted.

Use for: structured or heterogeneous values that do not fit the simpler encodings.

:encrypted_cbor

The value is first CBOR-serialised, then encrypted using HPKE (Hybrid Public Key Encryption, RFC 9180) with the recipient's public key. The ciphertext (not the plaintext) is what gets signed by ECDSA, so the signature still authenticates the encrypted bytes. Only a party holding the recipient private key can decrypt and read the field value.

Use for: PII and sensitive fields that must be unreadable to third-party scanners — date of birth, passport number, biometric references. Always pair with sensitive: true.

Note: max_length for an :encrypted_cbor field is the plaintext character limit, not the ciphertext byte length. The HPKE overhead is ~111 bytes per field and is accounted for separately in capacity estimation.

:raw

Passes bytes through unchanged. The field value must already be a binary.

Use for: pre-encoded values or fields managed outside the profile encoding pipeline.

Field options

required:

Default: false.

When true, issuance fails with :missing_required_field if the field is absent from the document data. Set this for fields that must always be present for the seal to be meaningful (document number, expiry date, etc.). Optional fields are silently omitted from the binary if absent.

max_length:

The maximum allowed length, checked before encoding:

  • For :c40, :utf8, and :encrypted_cbor: a character count of the plaintext value.
  • For :raw and :binary: a byte count of the binary.

Encoding fails with :payload_too_large if the value exceeds this limit.

Omitting max_length on a :string or :binary field triggers a compile-time warning because capacity estimation becomes inaccurate.

sensitive:

Default: false.

Marks a field as containing sensitive or personally-identifying information. This flag affects two things:

  1. Audit logging. The AuditLogger callbacks receive field data on every sign and verify operation. A custom audit logger (writing to a database, SIEM, or external service) should use sensitive? to decide whether to record the field value. The built-in ExIcaoVds.AuditLoggers.Noop drops all events, but if you implement a real one, sensitive: true is the signal to redact or omit the value from the log entry.

  2. Tooling and display. Tools that inspect or display decoded seals can use this flag to hide sensitive values from operators who do not need them.

Set sensitive: true on any field that contains PII (name, date of birth, document number, biometric reference) regardless of whether it is encrypted. Encryption controls who can read the value; sensitive controls whether it is logged or displayed. A field can be unencrypted but still logged incorrectly if sensitive is not set, and a field can be encrypted but still have its ciphertext unnecessarily logged.

Always set sensitive: true for :encrypted_cbor fields.

values:

Required when type: :enum. A list of atoms representing the allowed values.

field :status, :enum,
  tag: 5,
  encoding: :integer,
  values: [:active, :suspended, :expired]

Order matters: the wire integer index is 0-based positional — :active → 0, :suspended → 1, :expired → 2. Never reorder or remove values from a deployed profile — doing so shifts the indices of all later values and breaks existing seals.

default:

A value to use when the field is absent from the input document data. Applied during normalisation, before validation. If nil (the default) and the field is absent and not required, it is omitted from the binary.

Compile-time checks

The defprofile macro raises CompileError on:

  • Duplicate field tag values within the profile
  • Duplicate field name values within the profile

It emits compile-time warnings when:

  • A :string or :binary field has no max_length (capacity estimates will be inaccurate)
  • The estimated payload exceeds 80% of carrier_capacity

Behaviour callbacks

Implement these directly when you need behaviour the DSL cannot express — custom validation logic, non-standard encodings, or dynamic field sets. When using defprofile do, all callbacks are generated automatically and delegate to ExIcaoVds.Profiles.Generic. Override selectively with defoverridable.

Summary

Callbacks

Decode a raw TLV value for the given tag into a Feature struct.

Single-character document type category string (e.g. "A").

Encode a single field value into a Feature struct with encoded_value set.

Feature definition reference byte value.

List of field definition maps supported by this profile.

Normalise raw document data into canonicalised form.

Run post-decode validation on the full decoded feature list.

Unique profile identifier atom.

Validate normalised document data. Returns the data or an error.

Profile version integer.

Callbacks

decode_feature(tag, encoded_value, opts)

@callback decode_feature(
  tag :: non_neg_integer(),
  encoded_value :: binary(),
  opts :: keyword()
) :: {:ok, ExIcaoVds.Feature.t()} | {:error, ExIcaoVds.Error.t()}

Decode a raw TLV value for the given tag into a Feature struct.

document_type_category()

@callback document_type_category() :: String.t()

Single-character document type category string (e.g. "A").

encode_feature(field, value, opts)

@callback encode_feature(field :: map(), value :: term(), opts :: keyword()) ::
  {:ok, ExIcaoVds.Feature.t()} | {:error, ExIcaoVds.Error.t()}

Encode a single field value into a Feature struct with encoded_value set.

feature_definition_reference()

@callback feature_definition_reference() :: non_neg_integer()

Feature definition reference byte value.

fields()

@callback fields() :: [map()]

List of field definition maps supported by this profile.

normalize(document_data, opts)

@callback normalize(document_data :: map(), opts :: keyword()) ::
  {:ok, map()} | {:error, ExIcaoVds.Error.t()}

Normalise raw document data into canonicalised form.

post_decode_validate(features, opts)

@callback post_decode_validate(features :: [ExIcaoVds.Feature.t()], opts :: keyword()) ::
  {:ok, [ExIcaoVds.Feature.t()]} | {:error, ExIcaoVds.Error.t()}

Run post-decode validation on the full decoded feature list.

profile_id()

@callback profile_id() :: atom()

Unique profile identifier atom.

validate(document_data, opts)

@callback validate(document_data :: map(), opts :: keyword()) ::
  {:ok, map()} | {:error, ExIcaoVds.Error.t()}

Validate normalised document data. Returns the data or an error.

version()

@callback version() :: pos_integer()

Profile version integer.

Functions

carrier_capacity(bytes)

(macro)

defprofile(list)

(macro)

document_type_category(cat)

(macro)

encryption(_)

(macro)

feature_definition_reference(fdr)

(macro)

field(name, type, opts)

(macro)

profile_id(id)

(macro)

version(v)

(macro)