# Terminal Management — Direct-Acquiring Extension

**Module:** New standalone app `tms_acquirer_core` (revised — see §0; the rest of this
document's module names (`TmsCore.KeyManagement`, `TmsCore.Certification`) describe the
original design intent and now live under `TmsAcquirerCore.*` instead).
**Part of:** Acquiring Platform Transformation program (Workstream C).
**Status:** see [`docs/ACQUIRING_PLATFORM_PROGRESS.md`](../ACQUIRING_PLATFORM_PROGRESS.md).
C1 and C2 built (including UI); C1 live-verified against Verisec's sandbox (found the client
cert expired — see §0). C3 built in enrichment scope — see §4a — per explicit user follow-up
request, superseding the "not built, deferred" status this document originally recorded.

---

## 0. As-built revision (2026-08-09)

Two things changed from this document's original design during implementation — kept here
rather than silently editing the sections below, which still describe the reasoning
accurately otherwise:

1. **New app, not additive `tms_core` modules.** User-suggested during planning, confirmed
   and extended to cover both C1 and C2: `tms_acquirer_core` (depends on `platform_core` +
   `scheme_core`; `tms_core` was intended to depend on it, see finding 2). Matches the
   `scheme_core`/`clearing_core` pattern used everywhere else in this program.
2. **`tms_core`'s `get_certification_status/1` stub is not what §3.2 assumed.** It's one
   helper inside `generate_compliance_report/2` — a separate, already-fake
   compliance-*framework*-scoring feature (hardcoded PCI-DSS/GDPR/SOX/ISO27001 percentages, a
   fake digital signature, a no-op "store report" call), not terminal-*model* certification.
   Wiring `TerminalCertification` into it would have been a wrong conceptual fit — that call
   site actually wants `scheme_core.ComplianceRecord` (Phase 8), if it's ever fixed at all.
   Per the user's decision, `TerminalCertification` shipped standalone instead
   (`/admin/acquirer/certifications`); `tms_core` does not currently depend on
   `tms_acquirer_core`.
3. **C1 was live-verified against Verisec's real sandbox**, not just built against fixtures
   (per explicit user instruction). Result: the Middle East endpoint (`:32600`) times out at
   the TCP level from the dev environment used (the EU endpoint, `:443`, connects fine —
   possibly network/IP-allowlisting specific to that environment). Against the EU endpoint,
   the full mTLS handshake completes — Verisec's server verifies, the client presents its
   certificate, `CERT verify`/`Finished` complete — and the server then returns
   `SERVER ALERT: Fatal - Certificate Unknown`. Reproduced identically via raw
   `curl --cert/--key` and via `TmsAcquirerCore.KeyManagement.Hsm.Verisec`'s own `Req`-based
   request. Root cause confirmed directly: the supplied `Mercury RKL_keystore.pfx`'s
   certificate **expired 2026-01-09** (`openssl x509 -noout -dates`). This is a real,
   concrete confirmation that the adapter's connection/credential-loading/request-building
   logic is correct — the sole blocker is the expired credential, an operational renewal
   task, not a code defect. (Aside: Erlang/OTP's `:public_key` has no PKCS12 support on the
   OTP release used here, so `.pfx` → PEM conversion was done once via `openssl pkcs12`,
   output kept alongside the original files, same already-`.gitignore`d directory.)

---

## 1. Purpose

Today's `tms_core` is fleet/parameter/OTA-config focused. Becoming a direct-connect acquirer
requires two capabilities it doesn't have:

1. **Real scheme-grade key management** — today's `KeysConfigService` only does terminal-level
   RKI symmetric key injection.
2. **Structured device/terminal certification tracking** — today's `get_certification_status/1`
   is a hardcoded stub returning `"valid"` unconditionally.

All new modules are **additive** (new files, new tables) so existing `terminal_management`
code is untouched.

> **Non-disruption**: `KeysConfigService` and `get_certification_status/1` keep running
> exactly as they do today for every terminal, on every acquiring path.
> `TmsCore.KeyManagement` and `TmsCore.Certification` are new, optional modules a terminal
> only interacts with once it's provisioned onto the direct-connect path. Terminals on the
> existing YSP-routed path never call into them and see no behavior change.

---

## 2. C1 — Real HSM / Scheme Key Management

### 2.1 Current state

`KeysConfigService` exists in both `apps/tms_core/lib/tms_core/terminal_management/` and
`apps/da_product_app/lib/da_product_app/terminal_management/` (near-duplicates). It is **not**
a real HSM integration: it POSTs to an external "getRki" endpoint (`@rki_endpoint`, default
`http://localhost:8300/api/v1/getRki`), builds a `keys.json` (rki_key, rki_kcv, kek_kcv,
slot_number, serial_number, model, 1-year expiry), writes it to `priv/ota/{serial}/keys.json`,
and returns a download URL for an MQTT OTA command. Scope is strictly terminal-level symmetric
key injection — no certs/PKI, no generic key-management abstraction.

### 2.2 The HSM is selected: Verisec 10xPay Cloud HSM (REST)

Supplied at `Veriscent-HSM-cloud/` in the repo root:

- **`VERISEC_10XPAY_REST_API.postman_collection_v1.0.10/…json`** — the live API contract.
- **`slot_1/slot_1/`** — `Mercury RKL_keystore.pfx`, `internal_ca_chain.crt`, `password.txt`:
  a client keystore + CA chain, i.e. authentication is **mutual TLS**. No bearer token or API
  key appears anywhere in the collection.
- **payShield 10K Host Programmers / Core Host Commands PDFs** (V1.9b, V2.2b) — the Thales
  command reference.

**The single most important design fact:** every 10xPay REST call is
`POST https://{10XPAY_service_URL}/api/v1/rest/` with a JSON body that is just an envelope
around a **standard Thales payShield host command** — `messageHeader`, `commandCode`, then
command-specific fields:

```json
{ "messageHeader": "1234", "commandCode": "NO", "modeFlag": "00" }
{ "messageHeader": "1234", "commandCode": "B2", "data": "3132333435363738" }
```

There is also a plain `POST /api/v1/status` health endpoint.

Because the *command semantics are identical* to an on-prem Thales payShield reached over TCP,
cloud-REST and on-prem-TCP differ **only in transport and encoding** — JSON-over-HTTPS versus
a length-prefixed binary command block over a socket. That is what makes the two-adapter
design below clean rather than speculative.

**Commands relevant to this program:**

| Need | Commands |
|---|---|
| Key generation / import / export | `A0` (generate/derive key, incl. TDES ZPK, IKEY from BDK), `A6` (import ZPK under ZMK), `A8` (export key under ZMK), `BY` (translate ZMK to LMK), `FC` (translate TMK) |
| Key check values | `BU` |
| PIN translation (switch hot path) | `CA` (TPK→ZPK/BDK), `CC` (ZPK→ZPK), `G0`/`GU` (DUKPT) |
| EMV auth (direct-connect) | `KQ`, `KW` (ARQC verify / ARPC generate), `KS`, `KU` |
| Card security codes | `CW`, `CY`, `RY`, `QY`, `PM` |
| Data / MAC | `M0`, `M2`, `M6`, `M8`, `MY` |
| Remote key loading / PKI | `B8` (TR-34 export), `EI`, `EO`, `EQ`, `L6`, `L8`, `QE` |
| Health / diagnostics | `NO`, `B2`, `JK`, `NC`, `/api/v1/status` |

### 2.3 What direct-connect acquiring needs

- **ZMK/ZPK (Zone Master Key / Zone PIN Key) exchange and rotation** with each scheme's
  network, for PIN block translation between terminal and scheme network
  (`A0`/`A6`/`A8`/`BY`).
- **Dual-control key ceremony tracking** — PCI PIN compliance requires recorded
  custodian/component-holder procedures for key generation and loading, not just the
  resulting key material.
- **Rotation scheduling with expiry alerts**, feeding Scheme Management Phase 8's
  `ComplianceRecord.compliance_type = :key_rotation`. Key *material* never leaves the HSM;
  Scheme Management stores only compliance *status*. This boundary is preserved exactly.

### 2.4 Proposed module: `TmsCore.KeyManagement`

**`HsmClient` behaviour** — the transport-agnostic contract, expressed in Thales command
terms since both adapters speak them:

```elixir
@callback hsm_status() :: {:ok, map()} | {:error, term()}
@callback echo(binary()) :: {:ok, binary()} | {:error, term()}
@callback generate_key(opts :: map()) :: {:ok, map()} | {:error, term()}        # A0
@callback import_key(opts :: map()) :: {:ok, map()} | {:error, term()}          # A6
@callback export_key(opts :: map()) :: {:ok, map()} | {:error, term()}          # A8
@callback generate_kcv(key :: map()) :: {:ok, binary()} | {:error, term()}      # BU
@callback translate_pin_block(opts :: map()) :: {:ok, map()} | {:error, term()} # CA/CC/G0
```

**Adapters, selected by config**
(`config :tms_core, :hsm_client, TmsCore.KeyManagement.Hsm.Verisec`):

1. **`Hsm.Verisec` — the real one.** Single `POST /api/v1/rest/` with the JSON envelope;
   mTLS via the supplied `.pfx`/CA chain, configured through `:ssl` options on the HTTP
   client (`Req`, already a dependency across this umbrella). Config keys: `:base_url`,
   `:keystore_path`, `:keystore_password`, `:cacert_path`, `:message_header`,
   `:lmk_identifier`.

   > **Credentials come from config/env only.** `Veriscent-HSM-cloud/` and `*.pfx` are
   > gitignored (added alongside the existing `mastercom-keys/` rule) — real HSM credential
   > material must never enter git history, sandbox or not.

2. **`Hsm.ThalesTcp` — stub only, deliberately.** Same behaviour, TCP transport to an on-prem
   payShield: length-prefixed binary command blocks per the Host Programmers manual. Every
   callback returns `{:error, :not_implemented}`, with a moduledoc pointing at the two PDFs
   in `Veriscent-HSM-cloud/` and noting that the **command codes and field semantics are
   already correct in `Hsm.Verisec`** — only framing and encoding differ, so filling this in
   later is a transport exercise, not a redesign. It exists now so on-prem Thales customers
   are a config change rather than a refactor, consistent with this program's dual-mode
   principle.

3. **`Hsm.Stub`** — canned success values, so tests and dev environments never need real HSM
   connectivity.

**Schemas** (neither stores key material — only KCVs, identifiers, and lifecycle metadata):

- `KeyRotationSchedule` — `key_type` (`zmk/zpk/tmk/bdk/terminal_rki`), `scope`
  (`scheme_id`/global), `last_rotated_at`, `next_rotation_due`, `status`.
- `KeyCeremonyLog` — `ceremony_type`, `custodians` (list — **no key material, ever**),
  `witnessed_by`, `performed_at`, `notes`.

### 2.5 Scope boundaries and remaining risk

- **The switch keeps its own HSM path.** `mercury_device_middlelayer` already has a `crypto/`
  namespace doing PIN translation on its hot transaction path. That is Workstream D territory
  and is **not** touched or rerouted through `TmsCore.KeyManagement`. This module covers
  `tms_core`'s key *management/lifecycle* responsibilities (generation, rotation, ceremony
  records, terminal key injection), not per-transaction PIN translation. The
  `translate_pin_block` callback is in the behaviour because the contract is naturally
  complete, not because the switch will call it.
- **`KeysConfigService` is untouched.** The existing RKI flow keeps serving every terminal
  exactly as today.
- **Security-led review before go-live.** Now much narrower than originally scoped, since the
  vendor, transport, auth mechanism, and command set are all known. The open items are
  operational, not architectural: LMK identifier/slot conventions, key ceremony procedure
  sign-off, and PCI PIN audit evidence. **Implementation of `Hsm.Verisec` against the sandbox
  can proceed; production key ceremonies cannot, until that review happens.**

---

## 3. C2 — Device/Terminal Certification Tracking

New module `TmsCore.Certification` — replaces the hardcoded stub in `terminal_management.ex`
(`defp get_certification_status(_framework), do: "valid"`) with real tracked data.

**Schema `TmsCore.Certification.TerminalCertification`**: `terminal_model`, `vendor`,
`scheme_id` (cross-references Scheme Management's `scheme_masters` once that exists),
`certification_type` (`L2/L3/PCI_PTS/contactless/softpos`), `cert_id`, `issued_date`,
`expiry_date`, `renewal_status` (`current/expiring_soon/expired/renewal_in_progress`).

**Boundary, matching the Scheme Management requirement doc's own scope line:** this is
terminal-**instance/model**-level tracking (which physical terminal models and kernels are
certified — terminal inventory data), distinct from Scheme Management Phase 8's
scheme-**program**-level certification (whether the *organization* is L2-certified with a
given scheme). `tms_core` stays the source of truth for the former, `scheme_core` for the
latter. `TerminalCertification` rows reference `scheme_core`'s program-level record once
Scheme Management Phase 8 exists, but the tables stay in their respective apps.

---

## 4. C3 — Integration Points (scope further when picked up)

**Grounded against the real code this session** (2026-08-09), per the user's requirement:
generate the EMV config file per device model, using the *existing* `ConfigFileVersion`
registry and push pipeline completely unchanged — confirmed that pipeline already supports
exactly this shape, described below with the real modules/fields involved.

### 4a. EMV config generation from structured `scheme_core` data

**Built** (2026-08-09, per explicit user follow-up request) as
`TmsAcquirerCore.EmvConfig.Generator` — `apps/tms_acquirer_core/lib/tms_acquirer_core/emv_config/generator.ex`.
Lives in `tms_acquirer_core`, not `tms_core` as this section originally assumed: `scheme_core`
already depends on `tms_core` (Phase 9's `AlertsCore` notifications), so a direct
`tms_core → scheme_core` dependency is a cycle — Mix itself refused to sort it
(`Could not sort dependencies... form a cycle`) the first time this was attempted.
`tms_acquirer_core` already depends on `scheme_core` and safely takes on a `tms_core`
dependency too (neither depends back), so the generator reads `scheme_core` and writes
through `TmsCore.TerminalManagement.ConfigFileVersion` from there instead. Everything below
describes the design as built, not just planned.

**Scope actually implemented — narrower than "generate the whole file", by design**: this is
a surgical *enrichment* of the current active file, not a from-scratch regenerate. Only two
field groups are touched, both with unambiguous `scheme_core` provenance:
- **CAPK entries** (`modulus`/`exponent`/`checksum`, matched by `(rid, capk_index)`) — both
  MF919 XML and MyPinPad JSON.
- **MF919 XML AID entries only** — the 4 standard EMVCo Book 3 tags `9F1B`/`DF15`/`DF16`/`DF17`
  (floor limit, threshold value, target/max-target percentage), using the standard BCD-numeric
  encoding (a decimal digit string, zero-padded — decimal digits are valid BCD nibbles). This
  is deliberately **not** the same category of tag as the Mastercard Kernel 2
  `DF8123`-`DF8126` contactless tags that needed a real BCD-vs-hex fix in Phase 3a; those
  ambiguous, vendor-specific tags are not touched by this generator.

**Everything else — TAC tags, MyPinPad's per-transaction-type reader limits, brand-new
AID/CAPK entries not already in the file — is deliberately left alone**, per the reasoning
already captured below (§ "Trigger" and the TAC decision). Validated against real data: the
real MF919 file + the real seeded `visa` scheme (Phase 3a) round-trips CAPK data with **zero
changes** (proves fidelity — nothing silently corrupted) and confirms `floor_limit` etc. are
`nil` for real AIDs today (Phase 3a's import never populated them), so the generator correctly
skips those fields rather than blanking them.

**Safety**: `generate_and_register/1` always creates the new `ConfigFileVersion` with
`is_active: false` — `activate!/1` is a separate, human-triggered step via the existing
`ConfigFileVersion` UI/flow, never called automatically. Confirmed by a test that the
originally-active version stays active after a generation run.

**The existing versioning/push mechanism already supports this — it needs no changes.**
`TmsCore.TerminalManagement.ConfigFileVersion` (`apps/tms_core/lib/tms_core/terminal_management/config_file_version.ex`)
is a per-`(config_type, vendor, model)` **immutable versioned artifact registry**:
`find_active/3` returns the currently-active file for a given vendor+model,
`activate!/1` flips the active flag (deactivating siblings), `list_for/3` gives full
version history. `AutoPushService.push_mf919_emv_config/4`
(`apps/tms_core/lib/tms_core/terminal_management/auto_push_service.ex:273-317`) already
calls `ConfigFileVersion.find_active("emv_config", vendor, model)` to resolve
`source_path`, then hands it to the zip/push step — it has no idea whether that file was
hand-uploaded or generated, and doesn't need to.

**So the new work is entirely upstream of the push path**: a generator that

1. Reads `scheme_core`'s structured EMV data (AIDs, CAPKs, TACs — Workstream A Phase 3,
   already built and seeded with real data) for a given scheme/kernel.
2. **Renders it per device model**, not once generically — confirmed by the user: Morefun
   MF919, MyPinPad, and SR600 each need their **own file format/structure** aligned to that
   device (MF919 today is XML — `priv/static/mf919/1.0.1/YSDK_L3_configuration.xml`;
   MyPinPad today is JSON — `priv/static/Mypinpad/1.0.0/YSDK_L3_configuration.json`, both
   real files already parsed once for Workstream A Phase 3a's EMV import). Each model's
   file is a device-specific **subset/projection** of the same underlying acquirer-level
   EMV config, not an independently-sourced file.
3. Writes the rendered file to a **new versioned path** following the existing convention
   (`priv/static/{vendor_or_model}/{new_version}/...`) — a sibling of the current file, not
   an overwrite, consistent with `ConfigFileVersion`'s own "immutable versioned artifact"
   design.
4. Inserts a new `ConfigFileVersion` row (`config_type: "emv_config"`, real `vendor`/`model`,
   new `version`, the new `file_path`, a computed `checksum`) and calls `activate!/1` on it.

From that point on, `AutoPushService`/the MQTT push flow behave exactly as they do today —
`find_active/3` simply returns the newly-generated version instead of a hand-uploaded one.
**Nothing in the push path itself is touched**, per explicit instruction.

**Trigger**: the user's framing — "generated during/after certification of device" — ties
this to C2's `TerminalCertification` (`tms_acquirer_core`, built this session): once a
terminal model's certification is `active`/`current`, generation for that model becomes
eligible. Not wired automatically in this pass — C3 remains unbuilt, this section only
records the grounded design for when it's picked up.

**Branch note, not a real bug**: `AutoPushService.push_mf919_emv_config/4` aliases and calls
`TmsCore.TerminalManagement.L3ConfigZipBuilder.build_and_store/3` — that module isn't defined
on this branch (`feature/dispute-processing-system`). User confirmed L3 config push works
fine on their current working branch; `L3ConfigZipBuilder` lives there and will arrive on
merge. No action needed — tracked as bug #14 in `docs/ACQUIRING_PLATFORM_PROGRESS.md` purely
as a note in case this branch's push path gets exercised before that merge.

### 4b. Terminal risk parameters sourced from scheme masters

Floor limits, offline limits, and CVM rules (currently manually configured per MF919
parameter template) could pull defaults from Scheme Management Phase 3's
Contactless/EMV parameter masters instead of manual entry. Lower priority than §4a, not
scoped further yet.

---

## 5. Sequencing & Verification

**Recommended order: C2 → C1 → C3.** C2 is lower risk with no external vendor dependency; C1
can start against the sandbox in parallel but cannot reach production without the §2.5 review.

- **C2**: confirm `get_certification_status/1`'s stub is replaced and returns real data from
  `TerminalCertification` records; confirm existing terminal management flows are unaffected
  (additive-only check).
- **C1**: against the 10xPay sandbox, verify `NO` (HSM status) and `B2` (echo) round-trip over
  mTLS before anything else — those two commands validate transport, auth, and envelope
  handling with no key material involved. Then `BU` (KCV) and `A0` (generate). Confirm
  `Hsm.ThalesTcp` returns `{:error, :not_implemented}` cleanly and that swapping adapters is
  purely a config change. Production key ceremonies: blocked on §2.5.
