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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .github/workflows/adapters.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 ./...
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,28 @@ 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.

### Changed

- **Docs:** corrected the `CoralPay` / `Zone` profile descriptions to state their
Expand Down
99 changes: 99 additions & 0 deletions adapters/pkcs11/README.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 13 additions & 0 deletions adapters/pkcs11/go.mod
Original file line number Diff line number Diff line change
@@ -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 => ../..
2 changes: 2 additions & 0 deletions adapters/pkcs11/go.sum
Original file line number Diff line number Diff line change
@@ -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=
51 changes: 51 additions & 0 deletions adapters/pkcs11/padding_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
Loading
Loading