diff --git a/.github/workflows/adapters.yml b/.github/workflows/adapters.yml index 74c9f3d..c1dc175 100644 --- a/.github/workflows/adapters.yml +++ b/.github/workflows/adapters.yml @@ -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 }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 6441be9..d5b8aee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/adapters/payshield/README.md b/adapters/payshield/README.md new file mode 100644 index 0000000..420da08 --- /dev/null +++ b/adapters/payshield/README.md @@ -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). diff --git a/adapters/payshield/client.go b/adapters/payshield/client.go new file mode 100644 index 0000000..9d4081f --- /dev/null +++ b/adapters/payshield/client.go @@ -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 +} diff --git a/adapters/payshield/go.mod b/adapters/payshield/go.mod new file mode 100644 index 0000000..6ffb909 --- /dev/null +++ b/adapters/payshield/go.mod @@ -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 => ../.. diff --git a/adapters/payshield/payshield.go b/adapters/payshield/payshield.go new file mode 100644 index 0000000..62b1fd6 --- /dev/null +++ b/adapters/payshield/payshield.go @@ -0,0 +1,239 @@ +// 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 is a reference adapter that exposes a Thales payShield +// payment HSM as the Isopace vault capabilities a switch needs: [vault.PINTranslator] +// (PCI-secure PIN translate, the operation a stock PKCS#11 HSM cannot do) and +// [vault.Macer] (ISO 9797-1 message MAC). It lives in a separate module so the +// stdlib-only core gains no HSM-transport dependency. +// +// # Why payShield (and 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, which +// implements only [vault.Macer] for that reason. This adapter therefore advertises +// [vault.PINTranslator] in addition to [vault.Macer]. Keys are referenced 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 adapter speaks the payShield host-command protocol: a TCP message framed +// by a 2-byte big-endian length prefix over header‖command. The request command +// code (e.g. "CC" translate, "M6"/"M8" MAC generate/verify) and the response +// code (the request code with its last character incremented) and a two-digit +// error code follow the payShield convention. The command codes and PIN-block +// format codes are configurable to match a specific firmware's Host Command +// Reference Manual. +// +// # Scaffold status (read before production) +// +// This is a SCAFFOLD validated against the in-repo [Simulator] (a protocol test +// double whose cryptography is the Isopace software vault). It exercises the +// command flow — framing, command building, response and error-code parsing, the +// capability surface — end to end in CI without hardware. Two things are NOT yet +// real-device-faithful and are the remaining integration work against a genuine +// payShield (or Thales's own simulator): +// +// - The on-wire FIELD layout is a simplified, self-consistent stand-in +// (length-prefixed fields) for payShield's positional field structure. The +// command set, key references, format codes, and error handling are modelled +// on payShield; the byte-level field packing must be swapped for the exact +// layout from the Host Command Reference for the targeted firmware. +// - It has not been validated against real payShield key tokens, LMK schemes, +// or PCI-certified hardware. Independent security review is required. +package payshield + +import ( + "time" + + "github.com/teqpace-services/isopace/vault" +) + +// Config configures a payShield-backed Vault. +type Config struct { + Addr string // host:port of the payShield (or a Simulator) + Header string // message header the device echoes; default "" (none) + DialTimeout time.Duration // connect timeout; default 5s + Timeout time.Duration // per-command deadline; default 5s + + // Host command codes. Override to match the targeted firmware's Host Command + // Reference Manual. Defaults: TranslateCmd "CC", GenerateMACCmd "M6", + // VerifyMACCmd "M8". + TranslateCmd string + GenerateMACCmd string + VerifyMACCmd string + + // FormatCodes overrides the ISO 9564 → payShield PIN-block-format code map + // (see defaultFormatCodes). Entries override defaults; unset formats fall back. + FormatCodes map[vault.PINBlockFormat]string +} + +func (c Config) withDefaults() Config { + if c.DialTimeout == 0 { + c.DialTimeout = 5 * time.Second + } + if c.Timeout == 0 { + c.Timeout = 5 * time.Second + } + if c.TranslateCmd == "" { + c.TranslateCmd = "CC" + } + if c.GenerateMACCmd == "" { + c.GenerateMACCmd = "M6" + } + if c.VerifyMACCmd == "" { + c.VerifyMACCmd = "M8" + } + return c +} + +func (c Config) formatCode(f vault.PINBlockFormat) (string, bool) { + if c.FormatCodes != nil { + if s, ok := c.FormatCodes[f]; ok { + return s, true + } + } + s, ok := defaultFormatCodes[f] + return s, ok +} + +func (c Config) formatFromCode(code string) (vault.PINBlockFormat, bool) { + for _, f := range []vault.PINBlockFormat{vault.ISO0, vault.ISO1, vault.ISO3} { + if s, ok := c.formatCode(f); ok && s == code { + return f, true + } + } + return 0, false +} + +// Vault is a payShield-backed vault that satisfies PINTranslator and Macer. +type Vault struct { + cfg Config + cl *client +} + +var ( + _ vault.PINTranslator = (*Vault)(nil) + _ vault.Macer = (*Vault)(nil) +) + +// Open dials the payShield (or Simulator) at cfg.Addr and returns a Vault. Call +// Close to release the connection. +func Open(cfg Config) (*Vault, error) { + cfg = cfg.withDefaults() + cl, err := dial(cfg) + if err != nil { + return nil, err + } + return &Vault{cfg: cfg, cl: cl}, nil +} + +// Close releases the underlying connection. +func (v *Vault) Close() error { return v.cl.close() } + +// TranslatePIN implements vault.PINTranslator via the device's PIN-translate +// command: the device re-enciphers the encrypted PIN block from srcRef/srcFormat +// to dstRef/dstFormat internally, so the clear PIN never reaches the host. +func (v *Vault) TranslatePIN(srcRef, dstRef string, encBlock []byte, pan string, srcFormat, dstFormat vault.PINBlockFormat) ([]byte, error) { + sf, ok := v.cfg.formatCode(srcFormat) + if !ok { + return nil, &HostError{Op: "TranslatePIN", Code: codeFormatError} + } + df, ok := v.cfg.formatCode(dstFormat) + if !ok { + return nil, &HostError{Op: "TranslatePIN", Code: codeFormatError} + } + var f fieldWriter + f.str(srcRef) + f.str(dstRef) + f.str(sf) + f.str(df) + f.str(pan) + f.hexBytes(encBlock) + + ec, data, err := v.cl.do(v.cfg.TranslateCmd, f.b) + if err != nil { + return nil, err + } + if ec != codeOK { + return nil, &HostError{Op: "TranslatePIN", Code: ec} + } + rd := newFieldReader(data) + out := rd.hexBytes() + if rd.err != nil { + return nil, rd.err + } + return out, nil +} + +// GenerateMAC implements vault.Macer via the device's MAC-generate command. +func (v *Vault) GenerateMAC(keyRef string, alg vault.MACAlgorithm, pad vault.Padding, data []byte) ([]byte, error) { + body, err := macBody(keyRef, alg, pad, data) + if err != nil { + return nil, err + } + ec, resp, err := v.cl.do(v.cfg.GenerateMACCmd, body) + if err != nil { + return nil, err + } + if ec != codeOK { + return nil, &HostError{Op: "GenerateMAC", Code: ec} + } + rd := newFieldReader(resp) + mac := rd.hexBytes() + if rd.err != nil { + return nil, rd.err + } + return mac, nil +} + +// VerifyMAC implements vault.Macer via the device's MAC-verify command: a +// success code means the MAC matched; the verify-fail code means it did not. +func (v *Vault) VerifyMAC(keyRef string, alg vault.MACAlgorithm, pad vault.Padding, data, mac []byte) (bool, error) { + body, err := macBody(keyRef, alg, pad, data) + if err != nil { + return false, err + } + var f fieldWriter + f.b = body + f.hexBytes(mac) + + ec, _, err := v.cl.do(v.cfg.VerifyMACCmd, f.b) + if err != nil { + return false, err + } + switch ec { + case codeOK: + return true, nil + case codeVerifyFail: + return false, nil + default: + return false, &HostError{Op: "VerifyMAC", Code: ec} + } +} + +// macBody builds the common MAC command fields: keyRef, algorithm, padding, and +// the hex-encoded message. +func macBody(keyRef string, alg vault.MACAlgorithm, pad vault.Padding, data []byte) ([]byte, error) { + ac, err := algCode(alg) + if err != nil { + return nil, err + } + var f fieldWriter + f.str(keyRef) + f.str(ac) + f.str(padCode(pad)) + f.hexBytes(data) + return f.b, nil +} diff --git a/adapters/payshield/payshield_test.go b/adapters/payshield/payshield_test.go new file mode 100644 index 0000000..60ff948 --- /dev/null +++ b/adapters/payshield/payshield_test.go @@ -0,0 +1,192 @@ +// 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_test + +import ( + "bytes" + "errors" + "testing" + + payshield "github.com/teqpace-services/isopace/adapters/payshield" + "github.com/teqpace-services/isopace/vault" +) + +// The adapter advertises the payment-HSM capabilities a switch needs. +var ( + _ vault.PINTranslator = (*payshield.Vault)(nil) + _ vault.Macer = (*payshield.Vault)(nil) +) + +const pan = "4111111111111111" + +// test keys (mirrored into the simulator and a local SoftVault for ground truth) +var ( + zpkSrc = []byte{0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02} + zpkDst = []byte{0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04} + zak1 = []byte{0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17} + zak3 = []byte{0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x2B, 0x2C, 0x2D, 0x2E, 0x2F} +) + +// harness starts a simulator with the test keys and returns a connected adapter +// plus a local SoftVault holding the same keys (for computing expected values). +func harness(t *testing.T) (*payshield.Vault, *vault.SoftVault) { + t.Helper() + sim, err := payshield.NewSimulator(payshield.Config{}) + if err != nil { + t.Fatalf("NewSimulator: %v", err) + } + t.Cleanup(func() { sim.Close() }) + + local := vault.NewSoftVault() + for ref, key := range map[string][]byte{ + "zpk-src": zpkSrc, "zpk-dst": zpkDst, "zak1": zak1, "zak3": zak3, + } { + sim.ImportKey(ref, key) + local.SetKey(ref, key) + } + + v, err := payshield.Open(payshield.Config{Addr: sim.Addr()}) + if err != nil { + t.Fatalf("Open: %v", err) + } + t.Cleanup(func() { v.Close() }) + return v, local +} + +func TestCapabilitySurface(t *testing.T) { + var pt vault.PINTranslator = (*payshield.Vault)(nil) + if _, ok := pt.(vault.Macer); !ok { + t.Error("payShield Vault should also satisfy vault.Macer") + } + if _, ok := pt.(vault.PINEncryptor); ok { + t.Error("this scaffold does not implement vault.PINEncryptor") + } +} + +// TranslatePIN with no reformat (ISO0→ISO0) is deterministic, so it must equal an +// independent software translate byte-for-byte. +func TestTranslatePIN_Deterministic(t *testing.T) { + v, local := harness(t) + + block0, err := local.EncryptPINBlock("zpk-src", vault.ISO0, "1234", pan) + if err != nil { + t.Fatalf("EncryptPINBlock: %v", err) + } + got, err := v.TranslatePIN("zpk-src", "zpk-dst", block0, pan, vault.ISO0, vault.ISO0) + if err != nil { + t.Fatalf("TranslatePIN: %v", err) + } + want, err := local.TranslatePIN("zpk-src", "zpk-dst", block0, pan, vault.ISO0, vault.ISO0) + if err != nil { + t.Fatalf("local TranslatePIN: %v", err) + } + if !bytes.Equal(got, want) { + t.Fatalf("translated block\n got = % x\n want = % x", got, want) + } +} + +// Reformat ISO0→ISO3 then back ISO3→ISO0 must reproduce the original (deterministic) +// ISO0 block — proving the PIN and PAN survive the reformat through the device. +func TestTranslatePIN_ReformatRoundTrip(t *testing.T) { + v, local := harness(t) + + block0, err := local.EncryptPINBlock("zpk-src", vault.ISO0, "987654", pan) + if err != nil { + t.Fatalf("EncryptPINBlock: %v", err) + } + block3, err := v.TranslatePIN("zpk-src", "zpk-dst", block0, pan, vault.ISO0, vault.ISO3) + if err != nil { + t.Fatalf("TranslatePIN ISO0->ISO3: %v", err) + } + back0, err := v.TranslatePIN("zpk-dst", "zpk-src", block3, pan, vault.ISO3, vault.ISO0) + if err != nil { + t.Fatalf("TranslatePIN ISO3->ISO0: %v", err) + } + if !bytes.Equal(back0, block0) { + t.Fatalf("round-trip block mismatch\n back = % x\n orig = % x", back0, block0) + } +} + +func TestMAC(t *testing.T) { + v, local := harness(t) + + cases := []struct { + name string + keyRef string + alg vault.MACAlgorithm + }{ + {"alg1", "zak1", vault.MACAlg1}, + {"alg3-retail", "zak3", vault.MACAlg3}, + } + pads := []struct { + name string + pad vault.Padding + }{{"pad1", vault.Pad1}, {"pad2", vault.Pad2}} + msgs := [][]byte{nil, []byte("8"), []byte("hello world"), []byte("0123456789ABCDEF0123")} + + for _, c := range cases { + for _, p := range pads { + for _, msg := range msgs { + t.Run(c.name+"/"+p.name, func(t *testing.T) { + want, err := local.GenerateMAC(c.keyRef, c.alg, p.pad, msg) + if err != nil { + t.Fatalf("local GenerateMAC: %v", err) + } + got, err := v.GenerateMAC(c.keyRef, c.alg, p.pad, msg) + if err != nil { + t.Fatalf("GenerateMAC: %v", err) + } + if !bytes.Equal(got, want) { + t.Fatalf("MAC\n got = % x\n want = % x", got, want) + } + if ok, err := v.VerifyMAC(c.keyRef, c.alg, p.pad, msg, want); err != nil || !ok { + t.Fatalf("VerifyMAC(full) = %v, %v; want true, nil", ok, err) + } + if ok, err := v.VerifyMAC(c.keyRef, c.alg, p.pad, msg, want[:4]); err != nil || !ok { + t.Fatalf("VerifyMAC(4-byte) = %v, %v; want true, nil", ok, err) + } + bad := append([]byte(nil), want...) + bad[0] ^= 0xFF + if ok, err := v.VerifyMAC(c.keyRef, c.alg, p.pad, msg, bad); err != nil || ok { + t.Fatalf("VerifyMAC(tampered) = %v, %v; want false, nil", ok, err) + } + }) + } + } + } +} + +func TestUnknownKeyIsHostError(t *testing.T) { + v, _ := harness(t) + _, err := v.GenerateMAC("no-such-key", vault.MACAlg1, vault.Pad1, []byte("x")) + var he *payshield.HostError + if !errors.As(err, &he) { + t.Fatalf("err = %v, want *payshield.HostError", err) + } + if he.Code != "10" { + t.Errorf("host error code = %q, want %q (key not found)", he.Code, "10") + } +} + +func TestUnsupportedAlgorithm(t *testing.T) { + v, _ := harness(t) + if _, err := v.GenerateMAC("zak1", vault.MACAlgorithm(99), vault.Pad1, []byte("x")); err == nil { + t.Fatal("GenerateMAC with an unsupported algorithm should error") + } +} + +func TestOpenUnreachable(t *testing.T) { + if _, err := payshield.Open(payshield.Config{Addr: "127.0.0.1:1"}); err == nil { + t.Fatal("Open against an unreachable address should error") + } +} diff --git a/adapters/payshield/proto.go b/adapters/payshield/proto.go new file mode 100644 index 0000000..755ce2c --- /dev/null +++ b/adapters/payshield/proto.go @@ -0,0 +1,160 @@ +// 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 ( + "encoding/hex" + "fmt" + "strconv" + + "github.com/teqpace-services/isopace/vault" +) + +// Host-command response error codes. "00" is success; the others are this +// scaffold's stand-ins for the device's two-digit error codes (the real codes +// are firmware-specific — see the Host Command Reference Manual). +const ( + codeOK = "00" + codeVerifyFail = "01" // MAC verify: computed MAC did not match + codeKeyNotFound = "10" + codeFormatError = "20" + codeBadCommand = "30" +) + +// HostError is returned when the device replies with a non-success error code. +type HostError struct { + Op string // the adapter operation (e.g. "TranslatePIN") + Code string // the two-character host error code +} + +func (e *HostError) Error() string { + return fmt.Sprintf("payshield: %s: host error code %q", e.Op, e.Code) +} + +// incrementCmd returns the response command code for a request code — the +// payShield convention is the request code with its last character incremented +// (e.g. "CC" → "CD", "M6" → "M7"). +func incrementCmd(cmd string) string { + if cmd == "" { + return "" + } + b := []byte(cmd) + b[len(b)-1]++ + return string(b) +} + +// algCode / padCode map the vault MAC parameters to the one-character selectors +// carried in the MAC commands (ISO 9797-1 algorithm number; padding method). +func algCode(alg vault.MACAlgorithm) (string, error) { + switch alg { + case vault.MACAlg1: + return "1", nil + case vault.MACAlg3: + return "3", nil + default: + return "", fmt.Errorf("payshield: unsupported MAC algorithm %d", alg) + } +} + +func algFromCode(s string) (vault.MACAlgorithm, bool) { + switch s { + case "1": + return vault.MACAlg1, true + case "3": + return vault.MACAlg3, true + default: + return 0, false + } +} + +func padCode(pad vault.Padding) string { + if pad == vault.Pad2 { + return "2" + } + return "1" +} + +func padFromCode(s string) vault.Padding { + if s == "2" { + return vault.Pad2 + } + return vault.Pad1 +} + +// defaultFormatCodes maps each ISO 9564 PIN block format to a payShield +// PIN-block-format code. These are firmware-typical defaults; override them via +// Config.FormatCodes to match your device's Host Command Reference Manual. +var defaultFormatCodes = map[vault.PINBlockFormat]string{ + vault.ISO0: "01", + vault.ISO1: "05", + vault.ISO3: "47", +} + +// fieldWriter builds a command/response body as a sequence of length-prefixed +// fields: each field is a 4-decimal-digit byte length followed by that many +// bytes. (This is a simplified, self-consistent stand-in for payShield's +// positional field layout — see the package doc.) +type fieldWriter struct{ b []byte } + +func (w *fieldWriter) str(s string) { + w.b = append(w.b, fmt.Sprintf("%04d", len(s))...) + w.b = append(w.b, s...) +} + +func (w *fieldWriter) hexBytes(p []byte) { w.str(hex.EncodeToString(p)) } + +// fieldReader parses the length-prefixed fields written by fieldWriter. The +// first error is sticky; callers check err once after reading all fields. +type fieldReader struct { + b []byte + i int + err error +} + +func newFieldReader(b []byte) *fieldReader { return &fieldReader{b: b} } + +func (r *fieldReader) str() string { + if r.err != nil { + return "" + } + if r.i+4 > len(r.b) { + r.err = fmt.Errorf("payshield: truncated field length at offset %d", r.i) + return "" + } + n, err := strconv.Atoi(string(r.b[r.i : r.i+4])) + if err != nil || n < 0 { + r.err = fmt.Errorf("payshield: bad field length %q", r.b[r.i:r.i+4]) + return "" + } + r.i += 4 + if r.i+n > len(r.b) { + r.err = fmt.Errorf("payshield: truncated field value (want %d bytes at offset %d)", n, r.i) + return "" + } + s := string(r.b[r.i : r.i+n]) + r.i += n + return s +} + +func (r *fieldReader) hexBytes() []byte { + s := r.str() + if r.err != nil { + return nil + } + p, err := hex.DecodeString(s) + if err != nil { + r.err = fmt.Errorf("payshield: bad hex field: %w", err) + return nil + } + return p +} diff --git a/adapters/payshield/simulator.go b/adapters/payshield/simulator.go new file mode 100644 index 0000000..320f38c --- /dev/null +++ b/adapters/payshield/simulator.go @@ -0,0 +1,205 @@ +// 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" + "errors" + "net" + "sync" + + "github.com/teqpace-services/isopace/vault" +) + +// Simulator is a payShield host-command TEST DOUBLE: a TCP server that speaks the +// adapter's framing and command set so the adapter can be exercised end to end +// without hardware. Its cryptography is the Isopace software vault +// ([vault.SoftVault]), so the values it returns are real ISO 9564 / ISO 9797-1 +// results — but it is NOT a Thales payShield and NOT Thales's simulator: there is +// no LMK, no key-token security, and no PCI boundary. Use it for protocol and +// integration testing only. Keys are loaded in the clear via ImportKey, and the +// adapter's keyRef is the label under which a key was imported. +type Simulator struct { + cfg Config + ln net.Listener + v *vault.SoftVault + wg sync.WaitGroup +} + +// NewSimulator starts a simulator listening on cfg.Addr (default "127.0.0.1:0", +// an OS-chosen port — read it back with Addr). Call Close to stop it. +func NewSimulator(cfg Config) (*Simulator, error) { + cfg = cfg.withDefaults() + addr := cfg.Addr + if addr == "" { + addr = "127.0.0.1:0" + } + ln, err := net.Listen("tcp", addr) + if err != nil { + return nil, err + } + s := &Simulator{cfg: cfg, ln: ln, v: vault.NewSoftVault()} + s.wg.Go(s.serve) + return s, nil +} + +// Addr returns the simulator's listen address (host:port). +func (s *Simulator) Addr() string { return s.ln.Addr().String() } + +// ImportKey loads a clear key under ref. The adapter then names it as a keyRef. +func (s *Simulator) ImportKey(ref string, key []byte) { s.v.SetKey(ref, key) } + +// Close stops the listener and waits for in-flight connections to drain. +func (s *Simulator) Close() error { + err := s.ln.Close() + s.wg.Wait() + return err +} + +func (s *Simulator) serve() { + for { + conn, err := s.ln.Accept() + if err != nil { + return // listener closed + } + s.wg.Go(func() { s.handle(conn) }) + } +} + +func (s *Simulator) handle(conn net.Conn) { + defer conn.Close() + r := bufio.NewReader(conn) + for { + payload, err := readFrame(r) + if err != nil { + return // connection closed or malformed + } + h := len(s.cfg.Header) + if len(payload) < h+2 { + return + } + body := payload[h:] + cmd := string(body[:2]) + respCode, errCode, respData := s.dispatch(cmd, body[2:]) + + out := make([]byte, 0, h+4+len(respData)) + out = append(out, s.cfg.Header...) + out = append(out, respCode...) + out = append(out, errCode...) + out = append(out, respData...) + if err := writeFrame(conn, out); err != nil { + return + } + } +} + +func (s *Simulator) dispatch(cmd string, data []byte) (respCode, errCode string, respData []byte) { + respCode = incrementCmd(cmd) + switch cmd { + case s.cfg.TranslateCmd: + errCode, respData = s.translate(data) + case s.cfg.GenerateMACCmd: + errCode, respData = s.genMAC(data) + case s.cfg.VerifyMACCmd: + errCode, respData = s.verifyMAC(data) + default: + errCode = codeBadCommand + } + return respCode, errCode, respData +} + +func (s *Simulator) translate(data []byte) (string, []byte) { + rd := newFieldReader(data) + srcRef := rd.str() + dstRef := rd.str() + srcCode := rd.str() + dstCode := rd.str() + pan := rd.str() + block := rd.hexBytes() + if rd.err != nil { + return codeFormatError, nil + } + srcFmt, ok1 := s.cfg.formatFromCode(srcCode) + dstFmt, ok2 := s.cfg.formatFromCode(dstCode) + if !ok1 || !ok2 { + return codeFormatError, nil + } + out, err := s.v.TranslatePIN(srcRef, dstRef, block, pan, srcFmt, dstFmt) + if err != nil { + return hostCodeFor(err), nil + } + var f fieldWriter + f.hexBytes(out) + return codeOK, f.b +} + +func (s *Simulator) genMAC(data []byte) (string, []byte) { + keyRef, alg, pad, msg, ok := readMACBody(data) + if !ok { + return codeFormatError, nil + } + mac, err := s.v.GenerateMAC(keyRef, alg, pad, msg) + if err != nil { + return hostCodeFor(err), nil + } + var f fieldWriter + f.hexBytes(mac) + return codeOK, f.b +} + +func (s *Simulator) verifyMAC(data []byte) (string, []byte) { + rd := newFieldReader(data) + keyRef := rd.str() + ac := rd.str() + pc := rd.str() + msg := rd.hexBytes() + mac := rd.hexBytes() + if rd.err != nil { + return codeFormatError, nil + } + alg, ok := algFromCode(ac) + if !ok { + return codeFormatError, nil + } + ok, err := s.v.VerifyMAC(keyRef, alg, padFromCode(pc), msg, mac) + if err != nil { + return hostCodeFor(err), nil + } + if ok { + return codeOK, nil + } + return codeVerifyFail, nil +} + +func readMACBody(data []byte) (keyRef string, alg vault.MACAlgorithm, pad vault.Padding, msg []byte, ok bool) { + rd := newFieldReader(data) + keyRef = rd.str() + ac := rd.str() + pc := rd.str() + msg = rd.hexBytes() + if rd.err != nil { + return "", 0, 0, nil, false + } + alg, ok = algFromCode(ac) + if !ok { + return "", 0, 0, nil, false + } + return keyRef, alg, padFromCode(pc), msg, true +} + +func hostCodeFor(err error) string { + if errors.Is(err, vault.ErrUnknownKey) { + return codeKeyNotFound + } + return codeFormatError +} diff --git a/vault/capabilities_test.go b/vault/capabilities_test.go new file mode 100644 index 0000000..046bb00 --- /dev/null +++ b/vault/capabilities_test.go @@ -0,0 +1,58 @@ +// 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 vault_test + +import ( + "testing" + + "github.com/teqpace-services/isopace/vault" +) + +// The software vault satisfies every capability interface and the full Vault, +// verified from an external package (as an adapter would see them). +var ( + _ vault.PINEncryptor = (*vault.SoftVault)(nil) + _ vault.PINTranslator = (*vault.SoftVault)(nil) + _ vault.Macer = (*vault.SoftVault)(nil) + _ vault.Vault = (*vault.SoftVault)(nil) +) + +// macOnly stands in for a general-purpose (e.g. PKCS#11) HSM adapter that can +// MAC but cannot translate PINs. +type macOnly struct{} + +func (macOnly) GenerateMAC(string, vault.MACAlgorithm, vault.Padding, []byte) ([]byte, error) { + return nil, nil +} + +func (macOnly) VerifyMAC(string, vault.MACAlgorithm, vault.Padding, []byte, []byte) (bool, error) { + return false, nil +} + +func TestCapabilityDetection(t *testing.T) { + // A full software vault advertises every capability. + var v vault.Vault = vault.NewSoftVault() + if _, ok := v.(vault.PINTranslator); !ok { + t.Error("SoftVault should satisfy PINTranslator") + } + if _, ok := v.(vault.Macer); !ok { + t.Error("SoftVault should satisfy Macer") + } + + // A MAC-only adapter must NOT be mistakable for a PIN translator — this is + // exactly the check a switch performs before trusting a vault with PINs. + var m vault.Macer = macOnly{} + if _, ok := m.(vault.PINTranslator); ok { + t.Error("a Macer-only vault must not satisfy PINTranslator") + } +} diff --git a/vault/vault.go b/vault/vault.go index ca0cf69..a10ff66 100644 --- a/vault/vault.go +++ b/vault/vault.go @@ -21,25 +21,70 @@ import ( // ErrUnknownKey is returned when an operation names a key the vault does not hold. var ErrUnknownKey = errors.New("vault: unknown key") -// Vault is the key-management façade: operations name keys by reference rather -// than passing key material, so the same calls work whether the keys live in -// software ([SoftVault]) or in an HSM behind an adapter. A real HSM (e.g. via -// PKCS#11) is a drop-in Vault implementation kept in a separate module so the -// core stays stdlib-only. -type Vault interface { +// The key-management façade is composed from small capability interfaces so a +// hardware adapter can implement exactly the operations its device supports. +// Operations name keys by reference rather than passing key material, so the +// same calls work whether the keys live in software ([SoftVault]) or in an HSM +// behind an adapter (kept in a separate module so the core stays stdlib-only). +// +// A general-purpose PKCS#11 HSM typically provides [Macer] (and possibly +// [PINEncryptor]); a payment HSM additionally provides [PINTranslator]. Callers +// should depend on the narrowest capability they need and type-assert for it: +// +// tr, ok := v.(vault.PINTranslator) +// if !ok { return errors.New("configured vault cannot translate PINs") } + +// PINEncryptor enciphers a CLEAR PIN into a PIN block under a device-resident +// key. Because it takes the clear PIN, it is an issuer-side / trusted-context +// operation (e.g. PIN issuance) — at an acquiring switch the clear PIN must +// never be present, so a switch uses [PINTranslator] instead. +type PINEncryptor interface { // EncryptPINBlock encodes pin for the format and encrypts the 8-byte block // under the named PIN key (3DES ECB). EncryptPINBlock(keyRef string, format PINBlockFormat, pin, pan string) ([]byte, error) - // TranslatePIN decrypts an encrypted PIN block under srcRef, re-encodes it in - // dstFormat, and re-encrypts under dstRef — the classic switch PIN-translate. +} + +// PINTranslator re-enciphers an ENCRYPTED PIN block from one key (and format) to +// another — the classic acquirer/switch PIN-translate. The clear PIN does not +// appear in this interface. +// +// Contract: a conforming hardware implementation MUST perform the translation +// atomically inside the device so the clear PIN never leaves it (PCI PIN +// Security). An adapter that cannot translate without exposing the clear PIN in +// host memory (for example one limited to stock PKCS#11, which has no atomic +// translate mechanism) MUST NOT implement this interface — callers rely on its +// presence to mean the operation is PIN-secure. +type PINTranslator interface { + // TranslatePIN re-enciphers encBlock from srcRef/srcFormat to dstRef/dstFormat. TranslatePIN(srcRef, dstRef string, encBlock []byte, pan string, srcFormat, dstFormat PINBlockFormat) ([]byte, error) +} + +// Macer generates and verifies message authentication codes under a +// device-resident key; the key material never leaves the device. +type Macer interface { // GenerateMAC computes a MAC over data under the named key. GenerateMAC(keyRef string, alg MACAlgorithm, pad Padding, data []byte) ([]byte, error) // VerifyMAC verifies a MAC over data under the named key. VerifyMAC(keyRef string, alg MACAlgorithm, pad Padding, data, mac []byte) (bool, error) } -var _ Vault = (*SoftVault)(nil) +// Vault is the full key-management façade: the composition of every capability, +// implemented by the software backend ([SoftVault], [SealedVault]) and by +// full-function payment HSM adapters. An adapter that supports only some +// capabilities should expose those interface types ([Macer], etc.) rather than +// the full Vault. +type Vault interface { + PINEncryptor + PINTranslator + Macer +} + +var ( + _ Vault = (*SoftVault)(nil) + _ PINEncryptor = (*SoftVault)(nil) + _ PINTranslator = (*SoftVault)(nil) + _ Macer = (*SoftVault)(nil) +) // SoftVault is the in-process Vault: keys are held in memory and operations run // with the Go standard library. It is for development, testing, and conformance