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
endThe 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_v1If 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:
| Value | Document 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:
| Value | Assigned meaning |
|---|---|
1 | MRTD — Machine Readable Travel Document (passport/ID card fields as per ICAO Doc 9303 Parts 4–6) |
2–254 | Available for custom / private profiles |
0, 255 | Reserved |
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 ECC200Typical values:
| Carrier | Capacity |
|---|---|
| Data Matrix ECC200 | 1558 bytes |
| Aztec Code (max) | 1914 bytes |
| QR Code version 40, binary mode | 2953 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: 9tag
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
3meaning:nationalitywill always have tag3meaning:nationality— if you later reassign tag3to a different field, existing seals become unreadable. - Gaps are fine: tags
1, 3, 7work as well as1, 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.
| Type | Accepted Elixir values | Notes |
|---|---|---|
:string | String.t() | Any text. Encoding determines the wire format. |
:integer | non-negative integer() | Unsigned whole number. |
:date | %Date{} or ISO-8601 string ("2026-05-04") | Normalised to %Date{} before encoding. |
:boolean | boolean() | true or false. |
:enum | atom() | One of the atoms listed in values:. |
:binary | binary() | 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
:rawand: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:
Audit logging. The
AuditLoggercallbacks receive field data on every sign and verify operation. A custom audit logger (writing to a database, SIEM, or external service) should usesensitive?to decide whether to record the field value. The built-inExIcaoVds.AuditLoggers.Noopdrops all events, but if you implement a real one,sensitive: trueis the signal to redact or omit the value from the log entry.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
tagvalues within the profile - Duplicate field
namevalues within the profile
It emits compile-time warnings when:
- A
:stringor:binaryfield has nomax_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
@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.
@callback document_type_category() :: String.t()
Single-character document type category string (e.g. "A").
@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.
@callback feature_definition_reference() :: non_neg_integer()
Feature definition reference byte value.
@callback fields() :: [map()]
List of field definition maps supported by this profile.
@callback normalize(document_data :: map(), opts :: keyword()) :: {:ok, map()} | {:error, ExIcaoVds.Error.t()}
Normalise raw document data into canonicalised form.
@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.
@callback profile_id() :: atom()
Unique profile identifier atom.
@callback validate(document_data :: map(), opts :: keyword()) :: {:ok, map()} | {:error, ExIcaoVds.Error.t()}
Validate normalised document data. Returns the data or an error.
@callback version() :: pos_integer()
Profile version integer.