Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/adapters.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ jobs:
strategy:
fail-fast: false
matrix:
module: [adapters/otel, adapters/sql]
module: [adapters/otel, adapters/sql, adapters/payshield]
defaults:
run:
working-directory: ${{ matrix.module }}
Expand Down
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,30 @@ the [versioning policy](https://teqpace-services.github.io/isopace/versioning/).

## [Unreleased]

### Added

- **`vault` capability interfaces** for HSM adapters. The `vault.Vault` façade is
now composed from `PINEncryptor`, `PINTranslator`, and `Macer`, so a hardware
adapter can implement exactly the operations its device supports — a
general-purpose PKCS#11 HSM provides `Macer` (and possibly `PINEncryptor`),
while a payment HSM additionally provides `PINTranslator`. `Vault` keeps the
same method set, so this is **source-compatible**. `PINTranslator` documents
the PCI PIN Security contract: a conforming hardware implementation must
re-encipher atomically inside the device so the clear PIN never leaves it, and
an adapter that cannot do so (e.g. stock PKCS#11) must not implement it.
- **`adapters/payshield` — Thales payShield payment-HSM adapter (scaffold).** A
reference adapter that exposes a payShield over its host-command protocol as
`vault.PINTranslator` (PCI-secure PIN translate — the operation a general-purpose
PKCS#11 HSM cannot do) and `vault.Macer` (ISO 9797-1 MAC). Keys are named by the
device's LMK key token; the clear key never leaves the device. Ships with an
in-repo `Simulator` — a protocol **test double** whose cryptography is the
Isopace software vault — so the command flow (framing, command building,
response/error-code parsing, capability surface) is exercised end to end in CI
without hardware. **Scaffold:** the on-wire field layout is a simplified stand-in
for payShield's positional fields and it has not been validated against real
hardware/LMK schemes — pending device validation and security review (B1).
Separate, stdlib-only module.

### Changed

- **Docs:** corrected the `CoralPay` / `Zone` profile descriptions to state their
Expand Down
99 changes: 99 additions & 0 deletions adapters/payshield/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# Isopace Thales payShield (payment-HSM) adapter

A reference adapter that exposes a **Thales payShield** payment HSM as the Isopace
vault capabilities a switch needs — `vault.PINTranslator` (PCI-secure PIN
translate) and `vault.Macer` (ISO 9797-1 message MAC) — over the payShield
**host-command protocol**. Separate module; this module is itself stdlib-only
(it depends only on the core for the `vault` interfaces).

> ## ⚠️ Scaffold — validated against a simulator, not hardware
>
> This adapter is a **scaffold**: it is exercised end to end against the in-repo
> [`Simulator`](#the-simulator-a-test-double) in CI, but it has **not** been run
> against a real payShield, real LMK-encrypted key tokens, or PCI-certified
> hardware. Two things are deliberately not yet real-device-faithful — see
> [What's real vs. what's a stand-in](#whats-real-vs-whats-a-stand-in). Treat it
> as the integration skeleton, pending validation against a genuine payShield (or
> Thales's own simulator) and independent security review.

## Why a payment HSM (the capability split)

A payment HSM performs **PIN translate atomically inside the device**, so the
clear PIN never reaches host memory (PCI PIN Security). That is exactly the
capability a general-purpose PKCS#11 HSM lacks — see [`adapters/pkcs11`](../pkcs11),
which implements only `vault.Macer` for that reason and deliberately omits
`vault.PINTranslator`. This adapter advertises **`vault.PINTranslator` and
`vault.Macer`**.

```go
v, err := payshield.Open(payshield.Config{Addr: "10.0.0.5:1500"})
if err != nil { /* ... */ }
defer v.Close()

// PCI-secure translate: the clear PIN never reaches the host.
newBlock, err := v.TranslatePIN("zpk-acquirer", "zpk-issuer", encBlock, pan,
vault.ISO0, vault.ISO0)

mac, err := v.GenerateMAC("zak-1", vault.MACAlg3, vault.Pad1, msg) // retail MAC
```

Keys are named by the device's own **key token** (a key encrypted under the HSM's
Local Master Key); the clear key never leaves the device.

## Protocol

The host-command protocol is a TCP message framed by a 2-byte big-endian length
prefix over `header‖command`. Defaults (all overridable via `Config` to match
your firmware's *Host Command Reference Manual*):

| Operation | Request code | Response code | Notes |
|---|---|---|---|
| `TranslatePIN` | `CC` | `CD` | source/destination ZPK token, PIN-block format codes, PAN, source PIN block |
| `GenerateMAC` | `M6` | `M7` | key token, ISO 9797-1 algorithm + padding, message |
| `VerifyMAC` | `M8` | `M9` | …plus the MAC to check; verify-fail is a distinct error code |

The response code is the request code with its last character incremented; a
two-character error code (`00` = success) follows. A non-success code surfaces as
a `*payshield.HostError`. ISO 9564 PIN-block formats map to payShield format
codes via `Config.FormatCodes` (defaults: ISO0 `01`, ISO1 `05`, ISO3 `47`).

## The simulator (a test double)

`payshield.Simulator` is a TCP server that speaks the adapter's framing and
command set, so the adapter runs end to end without hardware. **Its cryptography
is the Isopace software vault** (`vault.SoftVault`), so the ISO 9564 / ISO 9797-1
values it returns are real — but it is **not** a payShield and **not** Thales's
simulator: there is no LMK, no key-token security, no PCI boundary. Keys are
loaded in the clear with `ImportKey`.

```go
sim, _ := payshield.NewSimulator(payshield.Config{})
defer sim.Close()
sim.ImportKey("zak-1", macKeyBytes)
v, _ := payshield.Open(payshield.Config{Addr: sim.Addr()})
```

The test suite asserts the adapter's MAC equals the `vault` software reference,
that an ISO0→ISO3→ISO0 translate round-trips, and that unknown keys / unsupported
algorithms surface correctly — all in-process, no external dependency.

## What's real vs. what's a stand-in

**Real / faithful:** the capability surface (`PINTranslator` + `Macer`, no
`PINEncryptor`); the host-command framing and request/response-code convention;
key-token references; PIN-block-format and MAC-algorithm selection; error-code
handling.

**Stand-in (the remaining integration work):**

- The on-wire **field layout** is a simplified, self-consistent encoding
(length-prefixed fields) standing in for payShield's **positional** field
structure. Swap it for the exact layout in the Host Command Reference for your
firmware.
- The transport is a single connection serialised by a mutex; it does **not**
pool or auto-reconnect. Front it with the supervised-connection machinery
(`connector`) for production.
- No validation against real LMK schemes, key tokens, or certified hardware;
independent security review is required before production use.

This is tracked under **B1** in [`ROADMAP-to-v1.md`](../../ROADMAP-to-v1.md).
124 changes: 124 additions & 0 deletions adapters/payshield/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// Copyright (C) 2026 Teqpace Services Ltd.
//
// This file is part of Isopace, a financial transaction framework.
//
// Isopace is dual-licensed:
// - under the GNU Affero General Public License v3.0 or later (see LICENSE); or
// - under a commercial license from Teqpace Services Ltd. (see COMMERCIAL-LICENSE.md).
//
// Authorship is recorded in the AUTHORS file.

package payshield

import (
"bufio"
"encoding/binary"
"errors"
"fmt"
"io"
"net"
"sync"
"time"
)

// errClosed is returned when a command is issued on a closed client.
var errClosed = errors.New("payshield: client is closed")

// client is the host-command transport: a single TCP connection to the device,
// serialised by a mutex (one command in flight at a time). Each message is a
// 2-byte big-endian length prefix over a payload of header‖body. This scaffold
// does not pool connections or auto-reconnect — production deployments should
// front it with the supervised-connection machinery (see the package doc).
type client struct {
cfg Config
mu sync.Mutex
conn net.Conn
r *bufio.Reader
}

func dial(cfg Config) (*client, error) {
d := net.Dialer{Timeout: cfg.DialTimeout}
conn, err := d.Dial("tcp", cfg.Addr)
if err != nil {
return nil, fmt.Errorf("payshield: dial %s: %w", cfg.Addr, err)
}
return &client{cfg: cfg, conn: conn, r: bufio.NewReader(conn)}, nil
}

func (c *client) close() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.conn == nil {
return nil
}
err := c.conn.Close()
c.conn = nil
return err
}

// do sends one host command (header‖cmd‖data) and returns the response error
// code and the response data (after the echoed header, response code, and error
// code have been stripped).
func (c *client) do(cmd string, data []byte) (errCode string, respData []byte, err error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.conn == nil {
return "", nil, errClosed
}
if c.cfg.Timeout > 0 {
_ = c.conn.SetDeadline(time.Now().Add(c.cfg.Timeout))
}

payload := make([]byte, 0, len(c.cfg.Header)+len(cmd)+len(data))
payload = append(payload, c.cfg.Header...)
payload = append(payload, cmd...)
payload = append(payload, data...)
if len(payload) > 0xFFFF {
return "", nil, fmt.Errorf("payshield: command too large (%d bytes)", len(payload))
}
if err := writeFrame(c.conn, payload); err != nil {
return "", nil, fmt.Errorf("payshield: write %s: %w", cmd, err)
}

resp, err := readFrame(c.r)
if err != nil {
return "", nil, fmt.Errorf("payshield: read %s response: %w", cmd, err)
}
h := len(c.cfg.Header)
if len(resp) < h+4 {
return "", nil, fmt.Errorf("payshield: short response (%d bytes)", len(resp))
}
resp = resp[h:]
respCode := string(resp[:2])
if want := incrementCmd(cmd); respCode != want {
return "", nil, fmt.Errorf("payshield: response code %q, want %q", respCode, want)
}
return string(resp[2:4]), resp[4:], nil
}

// writeFrame writes a 2-byte big-endian length prefix followed by payload.
func writeFrame(w io.Writer, payload []byte) error {
var hdr [2]byte
binary.BigEndian.PutUint16(hdr[:], uint16(len(payload)))
if _, err := w.Write(hdr[:]); err != nil {
return err
}
_, err := w.Write(payload)
return err
}

// readFrame reads a 2-byte big-endian length prefix and that many bytes.
func readFrame(r io.Reader) ([]byte, error) {
var hdr [2]byte
if _, err := io.ReadFull(r, hdr[:]); err != nil {
return nil, err
}
n := binary.BigEndian.Uint16(hdr[:])
payload := make([]byte, n)
if _, err := io.ReadFull(r, payload); err != nil {
return nil, err
}
return payload, nil
}
10 changes: 10 additions & 0 deletions adapters/payshield/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// Isopace Thales payShield (payment-HSM) Vault adapter — a separate module so the
// stdlib-only core never gains an HSM transport dependency. This module is itself
// stdlib-only (it depends only on the core module for the vault interfaces).
module github.com/teqpace-services/isopace/adapters/payshield

go 1.26

require github.com/teqpace-services/isopace v0.3.0

replace github.com/teqpace-services/isopace => ../..
Loading
Loading