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.

---

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

- **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.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:

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

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

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

**Types:**

| Type | Elixir value | Notes |
|------|-------------|-------|
| `:string` | `String.t()` | Text; encoding determines wire format |
| `:integer` | `integer()` | Non-negative unsigned integer |
| `:date` | `Date.t()` | Also accepts ISO-8601 strings; normalised on input |
| `:boolean` | `boolean()` | |
| `:enum` | `atom()` | One of the atoms in `values:`; encoded as 0-based integer index |
| `:binary` | `binary()` | 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:

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

```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",   # (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_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