diff --git a/.github/workflows/adapters.yml b/.github/workflows/adapters.yml index 74c9f3d..8637af4 100644 --- a/.github/workflows/adapters.yml +++ b/.github/workflows/adapters.yml @@ -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 6441be9..d8ef7ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 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<