README.md

# 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+
- [`zint`](https://www.zint.org.uk) on `$PATH` — **only needed for Data Matrix, Aztec, and PDF417 carriers**. The QR Code carrier is pure Elixir with no system dependency.

```bash
# 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`:

```elixir
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:

```elixir
{: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.

```elixir
# 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:

```elixir
{: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:

```elixir
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:

```elixir
# 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):

```elixir
{: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):

```elixir
{: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)

```elixir
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:

```elixir
{: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:

```elixir
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**.

```elixir
%{
  # --- 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

```elixir
%{
  # --- 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

```elixir
%{
  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

```elixir
%{
  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:

```elixir
%{
  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:**

```bash
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.

```elixir
verifier: %{
  trust_resolver: ExIcaoVds.TrustResolvers.StaticKeyStore,
  keys: %{
    {"UTOSNG", "key-2026-01"} => {pub_key_bytes, :secp256r1}
  }
}
```

### `ExIcaoVds.TrustResolvers.FileKeyStore` — PEM/key files on disk

```elixir
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.

```elixir
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

```elixir
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:

```elixir
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):

```elixir
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`):

```elixir
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:**

```elixir
{: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

```elixir
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

```elixir
# 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

```elixir
{: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)

```elixir
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)

```elixir
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 |

```elixir
# 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:

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:

- **Confidentiality** — the encoded fields are readable by anyone who can decode the barcode, unless you use the `Encryptor` behaviour (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_identifier` and `key_reference` values are written into every seal header in plaintext. Use stable, non-guessable identifiers.

---

## Inspecting a Seal Without Verifying

```elixir
# 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:

```elixir
# 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

```yaml
# 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:

```dart
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

```dart
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
# Elixir — export the public key as Base64 for bundling in the Flutter app
Base.encode64(pub_key)
```

```dart
// 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:

```dart
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:

```bash
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`:

```bash
mix ex_icao_vds.version
```

Running the task without an argument prompts for the bump type interactively.
You can also specify it directly:

```bash
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`:

```bash
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:

```bash
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