Skip to content
Merged
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
44 changes: 43 additions & 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 All @@ -54,3 +54,45 @@ jobs:
run: go vet ./...
- name: Test (race)
run: go test -race ./...

pkcs11:
name: adapters/pkcs11 (SoftHSM2)
runs-on: ubuntu-latest
defaults:
run:
working-directory: adapters/pkcs11
env:
CGO_ENABLED: '1'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.26'
check-latest: true
- name: Install SoftHSM2 + OpenSC
run: sudo apt-get update && sudo apt-get install -y softhsm2 opensc
- name: Provision a SoftHSM2 token
run: |
export SOFTHSM2_CONF="$RUNNER_TEMP/softhsm2.conf"
mkdir -p "$RUNNER_TEMP/tokens"
printf 'directories.tokendir = %s/tokens\nobjectstore.backend = file\nlog.level = ERROR\n' "$RUNNER_TEMP" > "$SOFTHSM2_CONF"
softhsm2-util --init-token --slot 0 --label isopace --pin 1234 --so-pin 5678
# Resolve the module path and export both for the test step.
module="$(dpkg -L libsofthsm2 2>/dev/null | grep -m1 'libsofthsm2.so' || echo /usr/lib/softhsm/libsofthsm2.so)"
echo "SOFTHSM2_CONF=$SOFTHSM2_CONF" >> "$GITHUB_ENV"
echo "ISOPACE_SOFTHSM_MODULE=$module" >> "$GITHUB_ENV"
- name: Verify formatting
run: |
fmt_out="$(gofmt -l .)"
if [ -n "$fmt_out" ]; then
echo "Not gofmt-clean:"; echo "$fmt_out"; exit 1
fi
- name: Build
run: go build ./...
- name: Vet
run: go vet ./...
- name: Test (race, against SoftHSM2)
env:
ISOPACE_SOFTHSM_TOKEN: isopace
ISOPACE_SOFTHSM_PIN: '1234'
run: go test -race -v ./...
39 changes: 38 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,48 @@ 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/pkcs11` — HSM-backed MAC via a PKCS#11 token.** The adapter now
implements `vault.Macer` (`GenerateMAC` / `VerifyMAC`) for ISO 9797-1
algorithm 1 (single-DES CBC-MAC) and algorithm 3 (ANSI X9.19 retail MAC),
composed from the token's 3DES primitives so the key never leaves the device.
The output is byte-for-byte identical to `vault.GenerateMAC`, **cross-checked
against a real token under SoftHSM2 in CI**. Per the `PINTranslator` contract
the type deliberately advertises **only** `Macer` — it does not implement PIN
translate (no PCI-secure stock-PKCS#11 mechanism exists) or PIN-block encrypt
(a clear-PIN, issuer-context operation). Separate cgo module; the core stays
stdlib-only.
- **`adapters/payshield` — Thales payShield payment-HSM adapter (scaffold).** A
reference adapter that exposes a payShield over its host-command protocol as the
**full `vault.Vault`**: `vault.PINEncryptor` (issuer-context clear-PIN encrypt),
`vault.PINTranslator` (PCI-secure PIN translate — the operation a general-purpose
PKCS#11 HSM cannot do), and `vault.Macer` (ISO 9797-1 MAC). A payment HSM
performs every one of these inside the device, so the adapter satisfies the whole
façade. 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.

## [1.0.0-rc.1] - 2026-06-09

First release candidate for `v1.0.0`. This is an **interim, feedback-seeking
tag under a "may still change" banner** — not the v1.0.0 contract itself. Per
the [roadmap to v1](ROADMAP-to-v1.md), it deliberately **narrows** what v1 will
the [roadmap to v1](https://github.com/teqpace-services/isopace/blob/main/ROADMAP-to-v1.md),
it deliberately **narrows** what v1 will
freeze and is published to gather integration feedback before the API freeze:

- **Stable candidate — the core.** `iso8583`, `packager`, `fieldcodec`,
Expand Down
106 changes: 106 additions & 0 deletions adapters/payshield/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# Isopace Thales payShield (payment-HSM) adapter

A reference adapter that exposes a **Thales payShield** payment HSM as the **full
`vault.Vault`** — `vault.PINEncryptor` (issuer-context clear-PIN encrypt),
`vault.PINTranslator` (PCI-secure PIN translate), and `vault.Macer` (ISO 9797-1
message MAC) — over the payShield **host-command protocol**. A payment HSM performs
every one of these inside the device, so the adapter satisfies the whole façade.
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` and `vault.PINEncryptor`. This adapter, backed by a payment
HSM, advertises the **full `vault.Vault`**.

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

// Issuer context (clear PIN present): encrypt a PIN block under a PIN key.
block, err := v.EncryptPINBlock("zpk-issuer", vault.ISO0, "1234", pan)
```

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 |
|---|---|---|---|
| `EncryptPINBlock` | `BA` | `BB` | PIN-key token, PIN-block format code, clear PIN, PAN → encrypted PIN block |
| `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 and ISO0 PIN-block encrypt equal 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 (the full `vault.Vault` —
`PINEncryptor` + `PINTranslator` + `Macer`); 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