diff --git a/.github/workflows/adapters.yml b/.github/workflows/adapters.yml index 74c9f3d..f5cce7b 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 }} @@ -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 ./... diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ac80b5..319c6dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`, diff --git a/adapters/payshield/README.md b/adapters/payshield/README.md new file mode 100644 index 0000000..487013d --- /dev/null +++ b/adapters/payshield/README.md @@ -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). 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..e724cdc --- /dev/null +++ b/adapters/payshield/payshield.go @@ -0,0 +1,281 @@ +// 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 full Isopace [vault.Vault]: [vault.PINEncryptor] +// (issuer-context clear-PIN encrypt), [vault.PINTranslator] (PCI-secure PIN +// translate, the operation a stock PKCS#11 HSM cannot do), and [vault.Macer] +// (ISO 9797-1 message MAC). A payment HSM performs every one of these inside the +// device, so the adapter satisfies the whole façade. 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: EncryptPINCmd "BA", TranslateCmd "CC", + // GenerateMACCmd "M6", VerifyMACCmd "M8". + EncryptPINCmd string + 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.EncryptPINCmd == "" { + c.EncryptPINCmd = "BA" + } + 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. A payment HSM performs every key-management +// operation inside the device, so this adapter implements the full [vault.Vault]: +// [vault.PINEncryptor] (issuer-context clear-PIN encrypt), [vault.PINTranslator] +// (PCI-secure PIN translate), and [vault.Macer] (ISO 9797-1 MAC). +type Vault struct { + cfg Config + cl *client +} + +var ( + _ vault.PINEncryptor = (*Vault)(nil) + _ vault.PINTranslator = (*Vault)(nil) + _ vault.Macer = (*Vault)(nil) + _ vault.Vault = (*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() } + +// EncryptPINBlock implements vault.PINEncryptor via the device's clear-PIN +// encrypt command: the device encodes pin for format and enciphers the resulting +// block under the named PIN key, returning the encrypted block. Because it takes +// the clear PIN it is an issuer / trusted-context operation (see +// vault.PINEncryptor) — a switch never holds the clear PIN and uses TranslatePIN. +func (v *Vault) EncryptPINBlock(keyRef string, format vault.PINBlockFormat, pin, pan string) ([]byte, error) { + fc, ok := v.cfg.formatCode(format) + if !ok { + return nil, &HostError{Op: "EncryptPINBlock", Code: codeFormatError} + } + var f fieldWriter + f.str(keyRef) + f.str(fc) + f.str(pin) + f.str(pan) + + ec, data, err := v.cl.do(v.cfg.EncryptPINCmd, f.b) + if err != nil { + return nil, err + } + if ec != codeOK { + return nil, &HostError{Op: "EncryptPINBlock", Code: ec} + } + rd := newFieldReader(data) + out := rd.hexBytes() + if rd.err != nil { + return nil, rd.err + } + return out, nil +} + +// 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..ea961e1 --- /dev/null +++ b/adapters/payshield/payshield_test.go @@ -0,0 +1,230 @@ +// 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" +) + +// A payment HSM performs every key operation in the device, so the adapter is a +// full vault.Vault. +var ( + _ vault.PINEncryptor = (*payshield.Vault)(nil) + _ vault.PINTranslator = (*payshield.Vault)(nil) + _ vault.Macer = (*payshield.Vault)(nil) + _ vault.Vault = (*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("payShield Vault should satisfy vault.PINEncryptor") + } + if _, ok := pt.(vault.Vault); !ok { + t.Error("payShield Vault should satisfy the full vault.Vault") + } +} + +// EncryptPINBlock with ISO0 is deterministic (no random padding), so the device +// result must equal an independent software encrypt byte-for-byte. Encrypting +// then translating ISO0→ISO0 under another key must also match a local translate, +// proving the encrypted block the adapter produces is well-formed. +func TestEncryptPINBlock(t *testing.T) { + v, local := harness(t) + + got, err := v.EncryptPINBlock("zpk-src", vault.ISO0, "1234", pan) + if err != nil { + t.Fatalf("EncryptPINBlock: %v", err) + } + want, err := local.EncryptPINBlock("zpk-src", vault.ISO0, "1234", pan) + if err != nil { + t.Fatalf("local EncryptPINBlock: %v", err) + } + if !bytes.Equal(got, want) { + t.Fatalf("encrypted PIN block\n got = % x\n want = % x", got, want) + } + + translated, err := v.TranslatePIN("zpk-src", "zpk-dst", got, pan, vault.ISO0, vault.ISO0) + if err != nil { + t.Fatalf("TranslatePIN: %v", err) + } + wantT, err := local.TranslatePIN("zpk-src", "zpk-dst", want, pan, vault.ISO0, vault.ISO0) + if err != nil { + t.Fatalf("local TranslatePIN: %v", err) + } + if !bytes.Equal(translated, wantT) { + t.Fatalf("translate of adapter-encrypted block\n got = % x\n want = % x", translated, wantT) + } +} + +// 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..959f849 --- /dev/null +++ b/adapters/payshield/simulator.go @@ -0,0 +1,229 @@ +// 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.EncryptPINCmd: + errCode, respData = s.encryptPIN(data) + 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) encryptPIN(data []byte) (string, []byte) { + rd := newFieldReader(data) + keyRef := rd.str() + code := rd.str() + pin := rd.str() + pan := rd.str() + if rd.err != nil { + return codeFormatError, nil + } + format, ok := s.cfg.formatFromCode(code) + if !ok { + return codeFormatError, nil + } + out, err := s.v.EncryptPINBlock(keyRef, format, pin, pan) + if err != nil { + return hostCodeFor(err), nil + } + var f fieldWriter + f.hexBytes(out) + return codeOK, f.b +} + +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/adapters/pkcs11/README.md b/adapters/pkcs11/README.md new file mode 100644 index 0000000..cb64a59 --- /dev/null +++ b/adapters/pkcs11/README.md @@ -0,0 +1,99 @@ +# Isopace PKCS#11 (HSM) MAC adapter + +A `vault.Macer` implementation backed by a PKCS#11 cryptographic token (a +general-purpose HSM), in a **separate module** so the stdlib-only core never +gains a cgo / PKCS#11 dependency. Keys are referenced by `CKA_LABEL`; key +material never leaves the token. + +> ## ⚠️ Capability: MAC only — and review before production +> +> A general-purpose PKCS#11 HSM can compute MACs but **cannot** perform a +> PCI-compliant PIN translate, so this adapter implements **`vault.Macer` and +> nothing else**. It deliberately does **not** implement `vault.PINTranslator` +> or `vault.PINEncryptor` (see *Capability surface* below). The MAC path is +> functional and cross-checked against the `vault` software reference under +> SoftHSM2 in CI, but it still needs **independent security review and validation +> against your specific certified HSM** (PCI PIN Security / FIPS) before use. + +## What it does + +```go +v, err := pkcs11.Open(pkcs11.Config{ + ModulePath: "/usr/lib/softhsm/libsofthsm2.so", + TokenLabel: "isopace", + PIN: "1234", +}) +if err != nil { /* ... */ } +defer v.Close() + +// v is a vault.Macer. Key material never leaves the token. +mac, err := v.GenerateMAC("zak-1", vault.MACAlg3, vault.Pad1, msg) // retail MAC +ok, err := v.VerifyMAC("zak-1", vault.MACAlg3, vault.Pad1, msg, mac[:4]) +``` + +- Module load, token selection, session open, `C_Login`; key lookup by + `CKA_LABEL`. +- `GenerateMAC` / `VerifyMAC` for ISO 9797-1 **algorithm 1** (single-DES + CBC-MAC) and **algorithm 3** (ANSI X9.19 retail MAC). +- Output is the full 8-byte MAC, **byte-for-byte identical to + `vault.GenerateMAC`** (the CI suite asserts this against a real token), so a + software peer and an HSM peer interoperate. Callers truncate as agreed. + +## How the MAC is computed (and why) + +Modern tokens disable single-DES and expose **no retail-MAC mechanism** +(`CKM_DES3_MAC` is a *full-3DES* CBC-MAC — a different algorithm). So the MAC is +composed from the 3DES primitives the token does provide (`CKM_DES3_CBC`, +`CKM_DES3_ECB`). The ISO 9797-1 padding and the message are not secret, so the +padding is applied in the host; every keyed block operation runs on the token +and the key never leaves it. A single-DES operation `E_K` is obtained by +presenting `K` as a triple-length key `K‖K‖K` (3DES-EDE then collapses to `E_K`). + +| Algorithm | Computation | Key object(s) under `keyRef` | +|---|---|---| +| `MACAlg1` | final block of `CKM_DES3_CBC` | a `CKK_DES3` key `K‖K‖K` | +| `MACAlg3` | single-DES CBC-MAC under K1 over all but the last block, then a 3DES-EDE of `(lastBlock ⊕ prefixMAC)` | the natural retail key `K1‖K2‖K1` at `keyRef`, **plus** a `K1‖K1‖K1` helper at `keyRef`+`RetailCBCLabelSuffix` (default `-cbc`) for the CBC stage | + +This needs the MAC key usable for 3DES **encrypt**. A hardened device that +restricts MAC keys to `CKA_SIGN` with a native MAC/CMAC mechanism should use that +mechanism instead (wire it in per device) — or use a payment-HSM adapter. + +## Capability surface (the security property) + +The adapter type satisfies `vault.Macer` and **must not** satisfy +`vault.PINTranslator` or `vault.PINEncryptor`. A switch checks for +`vault.PINTranslator` before trusting a vault with PINs; this adapter omits the +method entirely so that check correctly fails. Why no PIN ops here: + +- **`TranslatePIN`** must be a single atomic operation inside the device so the + clear PIN never reaches host memory. Standard PKCS#11 has no such mechanism; + emulating it with `C_Decrypt` → re-encode → `C_Encrypt` would materialise the + clear PIN block in the host and violate **PCI PIN Security**. Per the + `vault.PINTranslator` contract, an adapter that cannot translate PIN-securely + **must not advertise the interface**. Use a payment-HSM adapter (e.g. payShield) + for PIN translation. +- **`EncryptPINBlock`** takes a *clear* PIN, so it belongs to an issuer / + trusted-context adapter, not this stock-PKCS#11 one. + +This is tracked under **B1** in [`ROADMAP-to-v1.md`](../../ROADMAP-to-v1.md). + +## Testing + +The no-HSM tests (capability surface, padding vectors, config validation) run +anywhere. The functional suite runs only when a token is configured via the +environment, and **cross-checks the adapter's MAC against the `vault` software +reference** for both algorithms, both paddings, and a range of message lengths, +plus verify/tamper: + +```sh +# Provision a SoftHSM2 token (CI does this on Ubuntu via `apt-get install softhsm2`): +export SOFTHSM2_CONF=/path/to/softhsm2.conf # tokendir must be writable +softhsm2-util --init-token --slot 0 --label isopace --pin 1234 --so-pin 5678 + +# Point the suite at the module; the test provisions/destroys its own keys: +ISOPACE_SOFTHSM_MODULE=/usr/lib/softhsm/libsofthsm2.so \ +ISOPACE_SOFTHSM_TOKEN=isopace ISOPACE_SOFTHSM_PIN=1234 \ + go test -race ./... +``` + +Building this module requires **cgo** (a C compiler) because PKCS#11 is a C ABI. diff --git a/adapters/pkcs11/go.mod b/adapters/pkcs11/go.mod new file mode 100644 index 0000000..d379058 --- /dev/null +++ b/adapters/pkcs11/go.mod @@ -0,0 +1,13 @@ +// Isopace PKCS#11 (HSM) Vault adapter — a separate module so the stdlib-only +// core never gains a cgo / PKCS#11 dependency. The miekg/pkcs11 requirement is +// filled in by `go mod tidy`. +module github.com/teqpace-services/isopace/adapters/pkcs11 + +go 1.26 + +require ( + github.com/miekg/pkcs11 v1.1.2 + github.com/teqpace-services/isopace v0.3.0 +) + +replace github.com/teqpace-services/isopace => ../.. diff --git a/adapters/pkcs11/go.sum b/adapters/pkcs11/go.sum new file mode 100644 index 0000000..40cfc51 --- /dev/null +++ b/adapters/pkcs11/go.sum @@ -0,0 +1,2 @@ +github.com/miekg/pkcs11 v1.1.2 h1:/VxmeAX5qU6Q3EwafypogwWbYryHFmF2RpkJmw3m4MQ= +github.com/miekg/pkcs11 v1.1.2/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs= diff --git a/adapters/pkcs11/padding_test.go b/adapters/pkcs11/padding_test.go new file mode 100644 index 0000000..15170be --- /dev/null +++ b/adapters/pkcs11/padding_test.go @@ -0,0 +1,51 @@ +// 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 pkcs11 + +import ( + "bytes" + "testing" + + "github.com/teqpace-services/isopace/vault" +) + +// iso9797Pad must mirror vault's ISO 9797-1 padding exactly — the SoftHSM +// cross-check relies on the HSM seeing the same padded bytes the software +// reference MACs. These vectors lock the behaviour even without a token. +func TestISO9797Pad(t *testing.T) { + cases := []struct { + name string + pad vault.Padding + in []byte + want []byte + }{ + {"pad1 empty", vault.Pad1, nil, []byte{0, 0, 0, 0, 0, 0, 0, 0}}, + {"pad1 aligned", vault.Pad1, []byte{1, 2, 3, 4, 5, 6, 7, 8}, []byte{1, 2, 3, 4, 5, 6, 7, 8}}, + {"pad1 partial", vault.Pad1, []byte{1, 2, 3}, []byte{1, 2, 3, 0, 0, 0, 0, 0}}, + {"pad2 empty", vault.Pad2, nil, []byte{0x80, 0, 0, 0, 0, 0, 0, 0}}, + {"pad2 aligned", vault.Pad2, []byte{1, 2, 3, 4, 5, 6, 7, 8}, + []byte{1, 2, 3, 4, 5, 6, 7, 8, 0x80, 0, 0, 0, 0, 0, 0, 0}}, + {"pad2 partial", vault.Pad2, []byte{1, 2, 3}, []byte{1, 2, 3, 0x80, 0, 0, 0, 0}}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := iso9797Pad(c.pad, c.in) + if len(got)%desBlockSize != 0 { + t.Fatalf("not block-aligned: %d bytes", len(got)) + } + if !bytes.Equal(got, c.want) { + t.Fatalf("iso9797Pad = % x, want % x", got, c.want) + } + }) + } +} diff --git a/adapters/pkcs11/pkcs11.go b/adapters/pkcs11/pkcs11.go new file mode 100644 index 0000000..4a09160 --- /dev/null +++ b/adapters/pkcs11/pkcs11.go @@ -0,0 +1,361 @@ +// 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 pkcs11 implements the Isopace [vault.Macer] capability against a +// PKCS#11 cryptographic token (a general-purpose HSM). Keys are referenced by +// their CKA_LABEL and the key material never leaves the token. +// +// # Capability: MAC only +// +// A general-purpose PKCS#11 HSM can compute message authentication codes but +// CANNOT perform a PCI-compliant PIN translate, so this adapter implements +// [vault.Macer] and nothing more. It deliberately does NOT implement +// [vault.PINTranslator] or [vault.PINEncryptor]: +// +// - PIN translate must be a single atomic operation inside the device so the +// clear PIN never reaches host memory. Standard PKCS#11 has no such +// mechanism; emulating it with C_Decrypt → re-encode → C_Encrypt would +// materialise the clear PIN block in the host and violate PCI PIN Security. +// Per the [vault.PINTranslator] contract an adapter that cannot translate +// PIN-securely MUST NOT advertise the interface — so this type omits the +// method entirely. Use the payment-HSM adapter (e.g. payShield) for PIN +// translation. +// - PIN-block encryption takes a clear PIN, so it belongs to an issuer / +// trusted-context adapter, not this stock-PKCS#11 one. +// +// # How the MAC is computed +// +// ISO 9797-1 padding and the message are not secret, so the padding is applied +// in the host and the keyed block operations run on the token; the key never +// leaves it. Modern tokens disable single-DES and expose no retail-MAC +// mechanism (CKM_DES3_MAC is a FULL-3DES CBC-MAC, a different algorithm), so the +// MAC is composed from the 3DES primitives the token does provide (CKM_DES3_CBC, +// CKM_DES3_ECB). A single-DES operation E_K is obtained by presenting K as a +// triple-length key K‖K‖K, since 3DES-EDE then collapses to E_K. +// +// - [vault.MACAlg1] (single-DES CBC-MAC): the MAC is the final block of a +// CKM_DES3_CBC pass under a K‖K‖K key. +// - [vault.MACAlg3] (ANSI X9.19 retail MAC, double-length key K1‖K2): the +// single-DES CBC-MAC under K1 over all but the last block (CKM_DES3_CBC under +// a K1‖K1‖K1 key), then a 3DES-EDE of (lastBlock XOR prefixMAC) under the +// natural retail key K1‖K2‖K1 (CKM_DES3_ECB). That EDE is E_K1(D_K2(E_K1(·))), +// which folds in the retail MAC's final transform E_K1(D_K2(·)). +// +// Key provisioning (the key never leaves the token): +// +// - MACAlg1: keyRef is a CKK_DES3 key with value K‖K‖K. +// - MACAlg3: keyRef is the natural retail key as a CKK_DES3 value K1‖K2‖K1; and +// keyRef+Config.RetailCBCLabelSuffix (default "-cbc") is a CKK_DES3 key with +// value K1‖K1‖K1, used for the single-DES CBC stage. +// +// The output is the full 8-byte MAC, byte-for-byte identical to +// [vault.GenerateMAC]; callers truncate to the agreed length themselves. +// +// # Status +// +// The MAC path is functional and cross-checked against the [vault] software +// reference under SoftHSM2 in CI (see pkcs11_softhsm_test.go). It still requires +// independent security review and validation against the specific certified HSM +// (PCI PIN Security / FIPS) before production use. The approach needs the MAC key +// usable for 3DES encrypt; a hardened device that restricts MAC keys to CKA_SIGN +// with a native MAC/CMAC mechanism should use that mechanism instead (wire it in +// per device, or use the payment-HSM adapter). +package pkcs11 + +import ( + "crypto/subtle" + "errors" + "fmt" + "strings" + "sync" + + p11 "github.com/miekg/pkcs11" + + "github.com/teqpace-services/isopace/vault" +) + +// Vault implements vault.Macer. It deliberately does not implement +// vault.PINTranslator or vault.PINEncryptor (see the package doc). +var _ vault.Macer = (*Vault)(nil) + +// ErrUnsupportedAlgorithm is returned for a MAC algorithm this adapter does not +// implement on stock PKCS#11. +var ErrUnsupportedAlgorithm = errors.New("pkcs11: unsupported MAC algorithm") + +const desBlockSize = 8 + +// Config configures a PKCS#11-backed Vault. +type Config struct { + ModulePath string // path to the PKCS#11 module, e.g. /usr/lib/softhsm/libsofthsm2.so + TokenLabel string // label of the token to use; empty selects the first token + PIN string // user PIN for C_Login; empty skips login (public session) + KeyClass uint // CKA_CLASS for key lookup; 0 defaults to CKO_SECRET_KEY + + // RetailCBCLabelSuffix is appended to keyRef to find the K1‖K1‖K1 helper key + // used for the single-DES CBC stage of a retail MAC (MACAlg3); empty defaults + // to "-cbc". keyRef itself is the natural retail key (K1‖K2‖K1). + RetailCBCLabelSuffix string +} + +// Vault is a vault.Macer backed by a PKCS#11 token. It is safe for concurrent +// use: a single session is serialised by a mutex (a session pool is a future +// optimisation). +type Vault struct { + cfg Config + ctx *p11.Ctx + mu sync.Mutex + session p11.SessionHandle + loggedIn bool +} + +// Open loads the PKCS#11 module, selects the configured token, opens a session, +// and logs in (when a PIN is provided). Call Close to release resources. +func Open(cfg Config) (*Vault, error) { + if cfg.ModulePath == "" { + return nil, errors.New("pkcs11: Config.ModulePath is required") + } + ctx := p11.New(cfg.ModulePath) + if ctx == nil { + return nil, fmt.Errorf("pkcs11: could not load module %q", cfg.ModulePath) + } + if err := ctx.Initialize(); err != nil { + ctx.Destroy() + return nil, fmt.Errorf("pkcs11: C_Initialize: %w", err) + } + v := &Vault{cfg: cfg, ctx: ctx} + + slot, err := v.findSlot() + if err != nil { + v.teardown() + return nil, err + } + sess, err := ctx.OpenSession(slot, p11.CKF_SERIAL_SESSION) + if err != nil { + v.teardown() + return nil, fmt.Errorf("pkcs11: C_OpenSession: %w", err) + } + v.session = sess + if cfg.PIN != "" { + if err := ctx.Login(sess, p11.CKU_USER, cfg.PIN); err != nil { + _ = ctx.CloseSession(sess) + v.teardown() + return nil, fmt.Errorf("pkcs11: C_Login: %w", err) + } + v.loggedIn = true + } + return v, nil +} + +// findSlot returns the slot whose token label matches Config.TokenLabel (or the +// first token-present slot when the label is empty). +func (v *Vault) findSlot() (uint, error) { + slots, err := v.ctx.GetSlotList(true) + if err != nil { + return 0, fmt.Errorf("pkcs11: C_GetSlotList: %w", err) + } + for _, s := range slots { + ti, err := v.ctx.GetTokenInfo(s) + if err != nil { + continue + } + if v.cfg.TokenLabel == "" || strings.TrimSpace(ti.Label) == v.cfg.TokenLabel { + return s, nil + } + } + return 0, fmt.Errorf("pkcs11: no token found (label %q)", v.cfg.TokenLabel) +} + +func (v *Vault) teardown() { + _ = v.ctx.Finalize() + v.ctx.Destroy() +} + +// Close logs out (if logged in), closes the session, and unloads the module. +func (v *Vault) Close() error { + v.mu.Lock() + defer v.mu.Unlock() + if v.loggedIn { + _ = v.ctx.Logout(v.session) + v.loggedIn = false + } + _ = v.ctx.CloseSession(v.session) + v.teardown() + return nil +} + +// findKey resolves a key reference (matched against CKA_LABEL) to an object +// handle within the active session. The caller must hold v.mu. +func (v *Vault) findKey(ref string) (p11.ObjectHandle, error) { + class := v.cfg.KeyClass + if class == 0 { + class = p11.CKO_SECRET_KEY + } + template := []*p11.Attribute{ + p11.NewAttribute(p11.CKA_CLASS, class), + p11.NewAttribute(p11.CKA_LABEL, ref), + } + if err := v.ctx.FindObjectsInit(v.session, template); err != nil { + return 0, fmt.Errorf("pkcs11: C_FindObjectsInit: %w", err) + } + objs, _, err := v.ctx.FindObjects(v.session, 1) + finalErr := v.ctx.FindObjectsFinal(v.session) + if err != nil { + return 0, fmt.Errorf("pkcs11: C_FindObjects: %w", err) + } + if finalErr != nil { + return 0, fmt.Errorf("pkcs11: C_FindObjectsFinal: %w", finalErr) + } + if len(objs) == 0 { + return 0, fmt.Errorf("%w: %q", vault.ErrUnknownKey, ref) + } + return objs[0], nil +} + +// GenerateMAC implements vault.Macer. It returns the full 8-byte ISO 9797-1 MAC, +// computed on the token; the key never leaves the device. See the package doc +// for the algorithm → mechanism mapping and the retail-key labelling. +func (v *Vault) GenerateMAC(keyRef string, alg vault.MACAlgorithm, pad vault.Padding, data []byte) ([]byte, error) { + padded := iso9797Pad(pad, data) + + v.mu.Lock() + defer v.mu.Unlock() + + switch alg { + case vault.MACAlg1: + k, err := v.findKey(keyRef) + if err != nil { + return nil, err + } + return v.cbcMACLastBlock(k, padded) + + case vault.MACAlg3: + // Retail MAC = E_K1(D_K2(H_n)) where H_n is the single-DES CBC-MAC under + // K1. Using H_n = E_K1(lastBlock XOR H_{n-1}), this equals a 3DES-EDE + // E_K1(D_K2(E_K1(·))) of (lastBlock XOR H_{n-1}) under the natural retail + // key K1‖K2‖K1, where H_{n-1} is the CBC-MAC of all but the last block. + final, err := v.findKey(keyRef) + if err != nil { + return nil, err + } + prefixKey, err := v.findKey(keyRef + v.retailCBCSuffix()) + if err != nil { + return nil, fmt.Errorf("pkcs11: retail-MAC CBC key (single-DES under K1): %w", err) + } + n := len(padded) / desBlockSize + prefixMAC := make([]byte, desBlockSize) // zero when only one block + if n > 1 { + prefixMAC, err = v.cbcMACLastBlock(prefixKey, padded[:desBlockSize*(n-1)]) + if err != nil { + return nil, err + } + } + x := xorBlock(padded[desBlockSize*(n-1):], prefixMAC) + return v.ecbEncrypt(final, x) + + default: + return nil, fmt.Errorf("%w: %d", ErrUnsupportedAlgorithm, alg) + } +} + +// VerifyMAC implements vault.Macer. It recomputes the MAC and constant-time +// compares its leftmost len(mac) bytes to mac (matching vault.VerifyMAC). +func (v *Vault) VerifyMAC(keyRef string, alg vault.MACAlgorithm, pad vault.Padding, data, mac []byte) (bool, error) { + full, err := v.GenerateMAC(keyRef, alg, pad, data) + if err != nil { + return false, err + } + if len(mac) == 0 || len(mac) > len(full) { + return false, fmt.Errorf("pkcs11: MAC length %d out of range", len(mac)) + } + return subtle.ConstantTimeCompare(full[:len(mac)], mac) == 1, nil +} + +func (v *Vault) retailCBCSuffix() string { + if v.cfg.RetailCBCLabelSuffix == "" { + return "-cbc" + } + return v.cfg.RetailCBCLabelSuffix +} + +// cbcMACLastBlock returns the final cipher block of a zero-IV CKM_DES3_CBC +// encryption of padded (a whole number of 8-byte blocks) under the key handle. +// With a K‖K‖K key this is the single-DES CBC-MAC under K. The caller must hold +// v.mu. +func (v *Vault) cbcMACLastBlock(key p11.ObjectHandle, padded []byte) ([]byte, error) { + iv := make([]byte, desBlockSize) + m := []*p11.Mechanism{p11.NewMechanism(p11.CKM_DES3_CBC, iv)} + if err := v.ctx.EncryptInit(v.session, m, key); err != nil { + return nil, fmt.Errorf("pkcs11: C_EncryptInit(DES3-CBC): %w", err) + } + ct, err := v.ctx.Encrypt(v.session, padded) + if err != nil { + return nil, fmt.Errorf("pkcs11: C_Encrypt(DES3-CBC): %w", err) + } + if len(ct) < desBlockSize { + return nil, fmt.Errorf("pkcs11: short DES3-CBC output (%d bytes)", len(ct)) + } + return ct[len(ct)-desBlockSize:], nil +} + +// ecbEncrypt runs one CKM_DES3_ECB block encryption under the key handle. With a +// K1‖K2‖K1 key this is the 3DES-EDE E_K1(D_K2(E_K1(·))). The caller must hold +// v.mu. +func (v *Vault) ecbEncrypt(key p11.ObjectHandle, block []byte) ([]byte, error) { + m := []*p11.Mechanism{p11.NewMechanism(p11.CKM_DES3_ECB, nil)} + if err := v.ctx.EncryptInit(v.session, m, key); err != nil { + return nil, fmt.Errorf("pkcs11: C_EncryptInit(DES3-ECB): %w", err) + } + out, err := v.ctx.Encrypt(v.session, block) + if err != nil { + return nil, fmt.Errorf("pkcs11: C_Encrypt(DES3-ECB): %w", err) + } + return out, nil +} + +// xorBlock returns a XOR b over the leftmost 8 bytes (the DES block size). +func xorBlock(a, b []byte) []byte { + out := make([]byte, desBlockSize) + for i := range desBlockSize { + out[i] = a[i] ^ b[i] + } + return out +} + +// iso9797Pad applies ISO 9797-1 padding to a multiple of the 8-byte DES block. +// It mirrors the (unexported) padding in vault.GenerateMAC; the SoftHSM +// functional test cross-checks the resulting MAC against vault.GenerateMAC, so +// any divergence here would fail CI. +// +// - Pad1 (method 1): minimum zero bytes to fill the last block (one zero block +// for empty input). +// - Pad2 (method 2): a 0x80 byte then zero bytes to fill the last block. +func iso9797Pad(pad vault.Padding, data []byte) []byte { + switch pad { + case vault.Pad2: + out := make([]byte, 0, len(data)+desBlockSize) + out = append(out, data...) + out = append(out, 0x80) + for len(out)%desBlockSize != 0 { + out = append(out, 0x00) + } + return out + default: // Pad1 + if len(data) == 0 { + return make([]byte, desBlockSize) + } + out := append([]byte(nil), data...) + for len(out)%desBlockSize != 0 { + out = append(out, 0x00) + } + return out + } +} diff --git a/adapters/pkcs11/pkcs11_softhsm_test.go b/adapters/pkcs11/pkcs11_softhsm_test.go new file mode 100644 index 0000000..9f8f0e8 --- /dev/null +++ b/adapters/pkcs11/pkcs11_softhsm_test.go @@ -0,0 +1,291 @@ +// 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 pkcs11_test + +import ( + "bytes" + "os" + "testing" + + p11 "github.com/miekg/pkcs11" + + pkcs11 "github.com/teqpace-services/isopace/adapters/pkcs11" + "github.com/teqpace-services/isopace/vault" +) + +// This functional suite runs only when a PKCS#11 token is configured via the +// environment (CI provisions SoftHSM2). It proves the adapter drives a real +// token to produce a MAC that is byte-for-byte identical to the vault software +// reference — so the HSM path is interoperable, not merely self-consistent. +// +// ISOPACE_SOFTHSM_MODULE path to the PKCS#11 module (required to run) +// ISOPACE_SOFTHSM_TOKEN token label (default "isopace") +// ISOPACE_SOFTHSM_PIN user PIN (default "1234") + +const ( + macKeyLabel = "isopace-mac1" // CKK_DES3 K‖K‖K (MACAlg1) + retailKeyLabel = "isopace-macr" // CKK_DES3 K1‖K2‖K1 (MACAlg3 final EDE) + retailCBCLabel = "isopace-macr-cbc" // CKK_DES3 K1‖K1‖K1 (MACAlg3 CBC stage) +) + +// Known test key halves (odd parity so a parity-checking token accepts them; DES +// ignores the parity bit, so vault and the token compute the same cipher). vault +// MACs with the natural 8-/16-byte keys; the token holds the triple-length +// expansions (single-DES E_K presented as 3DES K‖K‖K) — and the MACs must match. +var ( + desKey = oddParity([]byte{0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF}) + retailL = oddParity([]byte{0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88}) + retailR = oddParity([]byte{0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x00}) +) + +// cat concatenates 8-byte halves into a triple-length (24-byte) DES3 key value. +func cat(parts ...[]byte) []byte { + var out []byte + for _, p := range parts { + out = append(out, p...) + } + return out +} + +func softhsmConfig(t *testing.T) pkcs11.Config { + t.Helper() + mod := os.Getenv("ISOPACE_SOFTHSM_MODULE") + if mod == "" { + t.Skip("ISOPACE_SOFTHSM_MODULE not set; skipping SoftHSM functional suite") + } + token := os.Getenv("ISOPACE_SOFTHSM_TOKEN") + if token == "" { + token = "isopace" + } + pin := os.Getenv("ISOPACE_SOFTHSM_PIN") + if pin == "" { + pin = "1234" + } + return pkcs11.Config{ModulePath: mod, TokenLabel: token, PIN: pin} +} + +func TestSoftHSM_MAC(t *testing.T) { + cfg := softhsmConfig(t) + + // Provision the test keys with known values via an independent session, then + // exercise them through the adapter. Cleanup destroys them afterwards. + cleanup := provisionKeys(t, cfg) + defer cleanup() + + v, err := pkcs11.Open(cfg) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer v.Close() + + retailKey := append(append([]byte(nil), retailL...), retailR...) + + cases := []struct { + name string + keyRef string + alg vault.MACAlgorithm + key []byte + }{ + {"alg1 single-DES", macKeyLabel, vault.MACAlg1, desKey}, + {"alg3 retail", retailKeyLabel, vault.MACAlg3, retailKey}, + } + pads := []struct { + name string + pad vault.Padding + }{{"pad1", vault.Pad1}, {"pad2", vault.Pad2}} + msgs := [][]byte{ + nil, + []byte("8"), + []byte("hello"), + []byte("0123456789ABCDEF"), // two whole blocks + []byte("an ISO 8583 0200 request payload of some length"), + } + + for _, c := range cases { + for _, p := range pads { + for i, msg := range msgs { + t.Run(c.name+"/"+p.name+"/"+msgName(i), func(t *testing.T) { + want, err := vault.GenerateMAC(c.alg, p.pad, c.key, msg) + if err != nil { + t.Fatalf("vault.GenerateMAC: %v", err) + } + got, err := v.GenerateMAC(c.keyRef, c.alg, p.pad, msg) + if err != nil { + t.Fatalf("adapter.GenerateMAC: %v", err) + } + if !bytes.Equal(got, want) { + t.Fatalf("MAC mismatch\n adapter = % x\n vault = % x", got, want) + } + + // Verify: full match, truncated (4-byte) match, and tamper. + 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, _ := v.VerifyMAC(c.keyRef, c.alg, p.pad, msg, bad); ok { + t.Fatal("VerifyMAC accepted a tampered MAC") + } + }) + } + } + } +} + +func TestSoftHSM_UnknownKey(t *testing.T) { + cfg := softhsmConfig(t) + v, err := pkcs11.Open(cfg) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer v.Close() + if _, err := v.GenerateMAC("isopace-no-such-key", vault.MACAlg1, vault.Pad1, []byte("x")); err == nil { + t.Fatal("GenerateMAC with an unknown key should error") + } +} + +func msgName(i int) string { return []string{"empty", "1byte", "5byte", "2block", "long"}[i] } + +// provisionKeys creates the test DES keys as persistent token objects, then +// returns a cleanup that destroys them. Each phase opens and FULLY finalizes its +// own PKCS#11 context: C_Initialize is process-global, so the provisioning +// context must not overlap the adapter's. The keys survive between phases +// because they are token (persistent) objects. +func provisionKeys(t *testing.T, cfg pkcs11.Config) func() { + t.Helper() + withProvisionSession(t, cfg, func(ctx *p11.Ctx, sess p11.SessionHandle) { + // Remove any leftovers from a previous interrupted run, then (re)create + // the triple-length expansions the adapter expects on the token. + destroyByLabel(ctx, sess, macKeyLabel) + destroyByLabel(ctx, sess, retailKeyLabel) + destroyByLabel(ctx, sess, retailCBCLabel) + createDES3Key(t, ctx, sess, macKeyLabel, cat(desKey, desKey, desKey)) + createDES3Key(t, ctx, sess, retailKeyLabel, cat(retailL, retailR, retailL)) + createDES3Key(t, ctx, sess, retailCBCLabel, cat(retailL, retailL, retailL)) + }) + return func() { + withProvisionSession(t, cfg, func(ctx *p11.Ctx, sess p11.SessionHandle) { + destroyByLabel(ctx, sess, macKeyLabel) + destroyByLabel(ctx, sess, retailKeyLabel) + destroyByLabel(ctx, sess, retailCBCLabel) + }) + } +} + +// withProvisionSession opens a logged-in R/W session on the configured token, +// runs fn, and fully tears the context down (logout, close, finalize, destroy) +// before returning — so no PKCS#11 context outlives this call. +func withProvisionSession(t *testing.T, cfg pkcs11.Config, fn func(*p11.Ctx, p11.SessionHandle)) { + t.Helper() + ctx := p11.New(cfg.ModulePath) + if ctx == nil { + t.Fatalf("could not load module %q", cfg.ModulePath) + } + if err := ctx.Initialize(); err != nil { + t.Fatalf("C_Initialize: %v", err) + } + defer ctx.Destroy() + defer ctx.Finalize() + + slots, err := ctx.GetSlotList(true) + if err != nil || len(slots) == 0 { + t.Fatalf("GetSlotList: %v (slots=%d)", err, len(slots)) + } + var slot uint + found := false + for _, s := range slots { + ti, err := ctx.GetTokenInfo(s) + if err == nil && (cfg.TokenLabel == "" || trim(ti.Label) == cfg.TokenLabel) { + slot, found = s, true + break + } + } + if !found { + t.Fatalf("token %q not found", cfg.TokenLabel) + } + sess, err := ctx.OpenSession(slot, p11.CKF_SERIAL_SESSION|p11.CKF_RW_SESSION) + if err != nil { + t.Fatalf("OpenSession(RW): %v", err) + } + defer ctx.CloseSession(sess) + if err := ctx.Login(sess, p11.CKU_USER, cfg.PIN); err != nil { + t.Fatalf("C_Login: %v", err) + } + defer ctx.Logout(sess) + + fn(ctx, sess) +} + +func createDES3Key(t *testing.T, ctx *p11.Ctx, sess p11.SessionHandle, label string, value []byte) p11.ObjectHandle { + t.Helper() + tmpl := []*p11.Attribute{ + p11.NewAttribute(p11.CKA_CLASS, p11.CKO_SECRET_KEY), + p11.NewAttribute(p11.CKA_KEY_TYPE, p11.CKK_DES3), + p11.NewAttribute(p11.CKA_TOKEN, true), + p11.NewAttribute(p11.CKA_PRIVATE, true), + p11.NewAttribute(p11.CKA_LABEL, label), + p11.NewAttribute(p11.CKA_ENCRYPT, true), + p11.NewAttribute(p11.CKA_DECRYPT, true), + p11.NewAttribute(p11.CKA_VALUE, value), + } + h, err := ctx.CreateObject(sess, tmpl) + if err != nil { + t.Fatalf("C_CreateObject(%s): %v", label, err) + } + return h +} + +func destroyByLabel(ctx *p11.Ctx, sess p11.SessionHandle, label string) { + tmpl := []*p11.Attribute{ + p11.NewAttribute(p11.CKA_CLASS, p11.CKO_SECRET_KEY), + p11.NewAttribute(p11.CKA_LABEL, label), + } + if ctx.FindObjectsInit(sess, tmpl) != nil { + return + } + objs, _, _ := ctx.FindObjects(sess, 16) + _ = ctx.FindObjectsFinal(sess) + for _, o := range objs { + _ = ctx.DestroyObject(sess, o) + } +} + +func trim(s string) string { + for len(s) > 0 && (s[len(s)-1] == ' ' || s[len(s)-1] == 0) { + s = s[:len(s)-1] + } + return s +} + +// oddParity sets the low bit of each byte to make the byte's popcount odd, the +// DES key-parity convention some tokens enforce on import. +func oddParity(k []byte) []byte { + out := append([]byte(nil), k...) + for i, b := range out { + ones := 0 + for j := 1; j < 8; j++ { + if b&(1<