From 11298cddd2d5da18d82c599dd6462f7966716e20 Mon Sep 17 00:00:00 2001 From: Canaan Etai Date: Wed, 3 Jun 2026 16:03:38 +0100 Subject: [PATCH 1/3] feat(adapters/pkcs11): HSM Vault adapter foundation (B1) Foundation for a PKCS#11/HSM-backed vault.Vault, in a separate cgo module so the stdlib-only core is untouched. NOT production-ready. Implemented and build-verified (cgo, miekg/pkcs11): - Config + Open/Close: module load, token selection, session, C_Login. - Key lookup by CKA_LABEL (keyRef -> object handle). - Compile-time vault.Vault conformance; no-HSM tests for stubs + config. Deliberately stubbed (return ErrNotImplemented), with documented intended PKCS#11 mappings: - GenerateMAC/VerifyMAC: ISO 9797-1 -> mechanism mapping is HSM-specific; must be verified against SoftHSM and security-reviewed. - EncryptPINBlock: EncodePINBlock + CKM_DES3_ECB (forms clear PIN block in host). - TranslatePIN: BLOCKED. A secure HSM translate is an atomic operation where the clear PIN never leaves the token; stock PKCS#11 has no such mechanism, and the decrypt-reencrypt emulation of the current vault.Vault contract would expose the clear PIN block in host memory (PCI PIN Security violation). This surfaces a v1 vault-API decision (see README + ROADMAP B1): the production HSM path likely needs HSM-oriented operations over encrypted PIN blocks, rather than the software-model clear-PIN interface. No working/secure crypto is shipped here on purpose. --- adapters/pkcs11/README.md | 74 +++++++++++ adapters/pkcs11/go.mod | 13 ++ adapters/pkcs11/go.sum | 2 + adapters/pkcs11/pkcs11.go | 217 +++++++++++++++++++++++++++++++++ adapters/pkcs11/pkcs11_test.go | 50 ++++++++ 5 files changed, 356 insertions(+) create mode 100644 adapters/pkcs11/README.md create mode 100644 adapters/pkcs11/go.mod create mode 100644 adapters/pkcs11/go.sum create mode 100644 adapters/pkcs11/pkcs11.go create mode 100644 adapters/pkcs11/pkcs11_test.go diff --git a/adapters/pkcs11/README.md b/adapters/pkcs11/README.md new file mode 100644 index 0000000..64feff6 --- /dev/null +++ b/adapters/pkcs11/README.md @@ -0,0 +1,74 @@ +# Isopace PKCS#11 (HSM) Vault adapter + +A `vault.Vault` implementation backed by a PKCS#11 cryptographic token (an 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. + +> ## ⚠️ Foundation only — not production-ready +> +> This module currently provides the **connection / session / key-lookup +> foundation** (build-verified). The four cryptographic `Vault` methods are +> **stubbed** (`ErrNotImplemented`) on purpose — see *Status* below. Nothing here +> is production-ready: a PKCS#11 Vault requires **independent security review and +> validation against a certified HSM** (PCI PIN Security / FIPS) before use. + +## What works today + +```go +v, err := pkcs11.Open(pkcs11.Config{ + ModulePath: "/usr/lib/softhsm/libsofthsm2.so", + TokenLabel: "isopace", + PIN: "1234", +}) +// v satisfies vault.Vault; key lookup by CKA_LABEL is wired. +defer v.Close() +``` + +- Module load, token selection, session open, `C_Login`. +- Key lookup by label (`keyRef` → object handle). +- Compile-time `vault.Vault` conformance. + +## Status of the cryptographic methods + +| Method | Status | Intended PKCS#11 mapping | +|---|---|---| +| `GenerateMAC` / `VerifyMAC` | **stub** | `C_Sign`/`C_Verify` under a mechanism chosen from `(alg, pad)` — e.g. `CKM_DES3_MAC` for ISO 9797-1, `CKM_AES_CMAC` for CMAC. The mapping is HSM-specific and must be verified against a real token and security-reviewed. | +| `EncryptPINBlock` | **stub** | `vault.EncodePINBlock` + `C_Encrypt` (`CKM_DES3_ECB`). See PIN-security note. | +| `TranslatePIN` | **stub (blocked)** | No secure stock-PKCS#11 implementation exists — see below. | + +## Why `TranslatePIN` is blocked (a `vault` API decision) + +`vault.Vault.TranslatePIN` is contractually *decrypt under src → re-encode → +re-encrypt under dst*. On software (`SoftVault`) that is fine. On an HSM, +emulating it with `C_Decrypt`/`C_Encrypt` would materialise the **clear PIN block +in host memory**, which violates **PCI PIN Security**. A secure translate is a +single **atomic HSM operation** in which the clear PIN never leaves the token — +and that is a **vendor-specific** command (Thales, Futurex, …), **not** part of +standard PKCS#11. + +Likewise, `EncryptPINBlock` takes a clear `pin string`, so the clear PIN is +already in host memory regardless of the HSM — appropriate for issuer/testing +flows, but not for an acquiring switch. + +**Decision needed:** for the production-HSM path, should the `vault.Vault` +interface gain HSM-oriented operations that work on *encrypted* PIN blocks with +an atomic translate (so the clear PIN never transits the host), or should HSM PIN +translation live behind a separate, vendor-specific interface? This is tracked as +part of **B1** in [`ROADMAP-to-v1.md`](../../ROADMAP-to-v1.md) and is relevant to +the v1 API freeze of the `vault` package. + +## Testing + +The no-HSM tests (interface conformance, stub behaviour, config validation) run +anywhere. A functional suite against **SoftHSM2** is added once the operations +are implemented: + +```sh +# CI (Ubuntu): +sudo apt-get install -y softhsm2 +softhsm2-util --init-token --slot 0 --label isopace --pin 1234 --so-pin 5678 +# import a 3DES test key, then: go test ./... +``` + +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/pkcs11.go b/adapters/pkcs11/pkcs11.go new file mode 100644 index 0000000..e4199ac --- /dev/null +++ b/adapters/pkcs11/pkcs11.go @@ -0,0 +1,217 @@ +// 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.Vault façade against a PKCS#11 +// cryptographic token (an HSM). Keys are referenced by their CKA_LABEL and the +// key material never leaves the token. +// +// # Status +// +// This module provides the connection/session/key-lookup FOUNDATION, which is +// build-verified. The four cryptographic Vault methods are deliberately STUBBED +// (they return ErrNotImplemented) — they are not yet implemented because: +// +// - GenerateMAC/VerifyMAC require an ISO 9797-1 → PKCS#11 mechanism mapping +// that is HSM-specific and must be verified against a real token (SoftHSM in +// CI) and security-reviewed before use; and +// - TranslatePIN cannot be implemented securely on stock PKCS#11. The +// vault.Vault contract (decrypt under src, re-encode, re-encrypt under dst) +// would expose the clear PIN block in host memory, which violates PCI PIN +// Security. A secure translate is a single atomic HSM operation, which is a +// vendor-specific mechanism, not part of standard PKCS#11. This needs a +// vault-API decision (see README and ROADMAP-to-v1.md, B1). +// +// Nothing here is production-ready: it requires independent security review and +// validation against a certified HSM (PCI PIN Security / FIPS) before use. +package pkcs11 + +import ( + "errors" + "fmt" + "strings" + "sync" + + p11 "github.com/miekg/pkcs11" + + "github.com/teqpace-services/isopace/vault" +) + +// Vault implements vault.Vault (the crypto methods are stubbed; see package doc). +var _ vault.Vault = (*Vault)(nil) + +// ErrNotImplemented marks a cryptographic operation whose secure PKCS#11 +// implementation is pending verification and security review. +var ErrNotImplemented = errors.New("pkcs11: cryptographic operation not yet implemented (foundation only)") + +// 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 +} + +// Vault is a vault.Vault 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 +} + +// EncryptPINBlock implements vault.Vault. +// +// Intended PKCS#11 mapping: encode the clear block with vault.EncodePINBlock, +// then C_Encrypt the 8-byte block under the CKM_DES3_ECB mechanism with the key +// handle resolved from keyRef. +// +// NOTE: this still forms the clear PIN block in host memory (the interface takes +// a clear `pin string`). See the package and README security notes. Stubbed +// pending verification against a real token and security review. +func (v *Vault) EncryptPINBlock(keyRef string, format vault.PINBlockFormat, pin, pan string) ([]byte, error) { + return nil, ErrNotImplemented +} + +// TranslatePIN implements vault.Vault. +// +// A secure HSM PIN-translate is a single atomic operation in which the clear PIN +// never leaves the token. Standard PKCS#11 has no such mechanism; emulating the +// vault.Vault contract via C_Decrypt → re-encode → C_Encrypt would expose the +// clear PIN block in host memory and violate PCI PIN Security. Left unimplemented +// pending a vault-API decision (see README and ROADMAP-to-v1.md, B1). +func (v *Vault) TranslatePIN(srcRef, dstRef string, encBlock []byte, pan string, srcFormat, dstFormat vault.PINBlockFormat) ([]byte, error) { + return nil, fmt.Errorf("%w: secure PIN translate needs an atomic HSM mechanism, not stock PKCS#11", ErrNotImplemented) +} + +// GenerateMAC implements vault.Vault. +// +// Intended PKCS#11 mapping: C_SignInit/C_Sign under a mechanism chosen from +// (alg, pad) — e.g. CKM_DES3_MAC / CKM_DES3_MAC_GENERAL for ISO 9797-1, or +// CKM_AES_CMAC for CMAC. The exact mapping is HSM-specific and must be verified +// against a real token (SoftHSM in CI) and security-reviewed. Stubbed for now. +func (v *Vault) GenerateMAC(keyRef string, alg vault.MACAlgorithm, pad vault.Padding, data []byte) ([]byte, error) { + return nil, ErrNotImplemented +} + +// VerifyMAC implements vault.Vault. +// +// Intended PKCS#11 mapping: C_VerifyInit/C_Verify under the same mechanism as +// GenerateMAC, or recompute and compare in constant time. Stubbed for now. +func (v *Vault) VerifyMAC(keyRef string, alg vault.MACAlgorithm, pad vault.Padding, data, mac []byte) (bool, error) { + return false, ErrNotImplemented +} diff --git a/adapters/pkcs11/pkcs11_test.go b/adapters/pkcs11/pkcs11_test.go new file mode 100644 index 0000000..9615a98 --- /dev/null +++ b/adapters/pkcs11/pkcs11_test.go @@ -0,0 +1,50 @@ +// 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 ( + "errors" + "testing" + + pkcs11 "github.com/teqpace-services/isopace/adapters/pkcs11" + "github.com/teqpace-services/isopace/vault" +) + +// The adapter must satisfy the core Vault interface at compile time. +var _ vault.Vault = (*pkcs11.Vault)(nil) + +// The cryptographic methods are stubs for now; they must report that clearly and +// must not panic (they do not touch the session). A functional suite against +// SoftHSM is added once the operations are implemented. +func TestCryptoMethodsStubbed(t *testing.T) { + var v pkcs11.Vault + + if _, err := v.EncryptPINBlock("k", vault.ISO0, "1234", "4111111111111111"); !errors.Is(err, pkcs11.ErrNotImplemented) { + t.Errorf("EncryptPINBlock err = %v, want ErrNotImplemented", err) + } + if _, err := v.TranslatePIN("s", "d", []byte{0}, "4111111111111111", vault.ISO0, vault.ISO0); !errors.Is(err, pkcs11.ErrNotImplemented) { + t.Errorf("TranslatePIN err = %v, want ErrNotImplemented", err) + } + if _, err := v.GenerateMAC("k", vault.MACAlg1, vault.Pad1, []byte("x")); !errors.Is(err, pkcs11.ErrNotImplemented) { + t.Errorf("GenerateMAC err = %v, want ErrNotImplemented", err) + } + if ok, err := v.VerifyMAC("k", vault.MACAlg1, vault.Pad1, []byte("x"), []byte("y")); ok || !errors.Is(err, pkcs11.ErrNotImplemented) { + t.Errorf("VerifyMAC = %v, %v, want false, ErrNotImplemented", ok, err) + } +} + +func TestOpenRequiresModulePath(t *testing.T) { + if _, err := pkcs11.Open(pkcs11.Config{}); err == nil { + t.Fatal("Open with no ModulePath should error") + } +} From 5c41010d450b47bb9a12466eb8709188cfee899d Mon Sep 17 00:00:00 2001 From: Canaan Etai Date: Thu, 4 Jun 2026 03:04:12 +0100 Subject: [PATCH 2/3] feat(vault): decompose Vault into HSM capability interfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the key-management façade HSM-adapter-ready by splitting it into small capability interfaces, so an adapter implements exactly what its device supports. - Add PINEncryptor, PINTranslator, and Macer. Vault is now their composition, so it keeps the same method set — source-compatible; SoftVault and SealedVault still satisfy Vault unchanged. - PINTranslator documents the PCI PIN Security contract: a conforming hardware implementation must re-encipher the PIN block atomically inside the device so the clear PIN never leaves it; an adapter that cannot (e.g. stock PKCS#11) must not implement the interface, since callers rely on its presence to mean the operation is PIN-secure. - A general-purpose PKCS#11 HSM provides Macer (and possibly PINEncryptor); a payment HSM (Thales payShield, Futurex) additionally provides PINTranslator. Callers type-assert for the narrowest capability they need. Adds capability-detection tests. gofmt/vet clean; ./... builds, vault/flow/gateway pass under -race. --- CHANGELOG.md | 12 ++++++++ vault/capabilities_test.go | 58 +++++++++++++++++++++++++++++++++++ vault/vault.go | 63 ++++++++++++++++++++++++++++++++------ 3 files changed, 124 insertions(+), 9 deletions(-) create mode 100644 vault/capabilities_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 66f01fc..c986bd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ 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. + ## [0.3.0] - 2026-06-02 The acquirer-profiles release: two more ISO 8583:1987 switch profiles — diff --git a/vault/capabilities_test.go b/vault/capabilities_test.go new file mode 100644 index 0000000..046bb00 --- /dev/null +++ b/vault/capabilities_test.go @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// Copyright (C) 2026 Teqpace Services Ltd. +// +// This file is part of Isopace, a financial transaction framework. +// +// Isopace is dual-licensed: +// - under the GNU Affero General Public License v3.0 or later (see LICENSE); or +// - under a commercial license from Teqpace Services Ltd. (see COMMERCIAL-LICENSE.md). +// +// Authorship is recorded in the AUTHORS file. + +package vault_test + +import ( + "testing" + + "github.com/teqpace-services/isopace/vault" +) + +// The software vault satisfies every capability interface and the full Vault, +// verified from an external package (as an adapter would see them). +var ( + _ vault.PINEncryptor = (*vault.SoftVault)(nil) + _ vault.PINTranslator = (*vault.SoftVault)(nil) + _ vault.Macer = (*vault.SoftVault)(nil) + _ vault.Vault = (*vault.SoftVault)(nil) +) + +// macOnly stands in for a general-purpose (e.g. PKCS#11) HSM adapter that can +// MAC but cannot translate PINs. +type macOnly struct{} + +func (macOnly) GenerateMAC(string, vault.MACAlgorithm, vault.Padding, []byte) ([]byte, error) { + return nil, nil +} + +func (macOnly) VerifyMAC(string, vault.MACAlgorithm, vault.Padding, []byte, []byte) (bool, error) { + return false, nil +} + +func TestCapabilityDetection(t *testing.T) { + // A full software vault advertises every capability. + var v vault.Vault = vault.NewSoftVault() + if _, ok := v.(vault.PINTranslator); !ok { + t.Error("SoftVault should satisfy PINTranslator") + } + if _, ok := v.(vault.Macer); !ok { + t.Error("SoftVault should satisfy Macer") + } + + // A MAC-only adapter must NOT be mistakable for a PIN translator — this is + // exactly the check a switch performs before trusting a vault with PINs. + var m vault.Macer = macOnly{} + if _, ok := m.(vault.PINTranslator); ok { + t.Error("a Macer-only vault must not satisfy PINTranslator") + } +} diff --git a/vault/vault.go b/vault/vault.go index ca0cf69..a10ff66 100644 --- a/vault/vault.go +++ b/vault/vault.go @@ -21,25 +21,70 @@ import ( // ErrUnknownKey is returned when an operation names a key the vault does not hold. var ErrUnknownKey = errors.New("vault: unknown key") -// Vault is the key-management façade: operations name keys by reference rather -// than passing key material, so the same calls work whether the keys live in -// software ([SoftVault]) or in an HSM behind an adapter. A real HSM (e.g. via -// PKCS#11) is a drop-in Vault implementation kept in a separate module so the -// core stays stdlib-only. -type Vault interface { +// The key-management façade is composed from small capability interfaces so a +// hardware adapter can implement exactly the operations its device supports. +// Operations name keys by reference rather than passing key material, so the +// same calls work whether the keys live in software ([SoftVault]) or in an HSM +// behind an adapter (kept in a separate module so the core stays stdlib-only). +// +// A general-purpose PKCS#11 HSM typically provides [Macer] (and possibly +// [PINEncryptor]); a payment HSM additionally provides [PINTranslator]. Callers +// should depend on the narrowest capability they need and type-assert for it: +// +// tr, ok := v.(vault.PINTranslator) +// if !ok { return errors.New("configured vault cannot translate PINs") } + +// PINEncryptor enciphers a CLEAR PIN into a PIN block under a device-resident +// key. Because it takes the clear PIN, it is an issuer-side / trusted-context +// operation (e.g. PIN issuance) — at an acquiring switch the clear PIN must +// never be present, so a switch uses [PINTranslator] instead. +type PINEncryptor interface { // EncryptPINBlock encodes pin for the format and encrypts the 8-byte block // under the named PIN key (3DES ECB). EncryptPINBlock(keyRef string, format PINBlockFormat, pin, pan string) ([]byte, error) - // TranslatePIN decrypts an encrypted PIN block under srcRef, re-encodes it in - // dstFormat, and re-encrypts under dstRef — the classic switch PIN-translate. +} + +// PINTranslator re-enciphers an ENCRYPTED PIN block from one key (and format) to +// another — the classic acquirer/switch PIN-translate. The clear PIN does not +// appear in this interface. +// +// Contract: a conforming hardware implementation MUST perform the translation +// atomically inside the device so the clear PIN never leaves it (PCI PIN +// Security). An adapter that cannot translate without exposing the clear PIN in +// host memory (for example one limited to stock PKCS#11, which has no atomic +// translate mechanism) MUST NOT implement this interface — callers rely on its +// presence to mean the operation is PIN-secure. +type PINTranslator interface { + // TranslatePIN re-enciphers encBlock from srcRef/srcFormat to dstRef/dstFormat. TranslatePIN(srcRef, dstRef string, encBlock []byte, pan string, srcFormat, dstFormat PINBlockFormat) ([]byte, error) +} + +// Macer generates and verifies message authentication codes under a +// device-resident key; the key material never leaves the device. +type Macer interface { // GenerateMAC computes a MAC over data under the named key. GenerateMAC(keyRef string, alg MACAlgorithm, pad Padding, data []byte) ([]byte, error) // VerifyMAC verifies a MAC over data under the named key. VerifyMAC(keyRef string, alg MACAlgorithm, pad Padding, data, mac []byte) (bool, error) } -var _ Vault = (*SoftVault)(nil) +// Vault is the full key-management façade: the composition of every capability, +// implemented by the software backend ([SoftVault], [SealedVault]) and by +// full-function payment HSM adapters. An adapter that supports only some +// capabilities should expose those interface types ([Macer], etc.) rather than +// the full Vault. +type Vault interface { + PINEncryptor + PINTranslator + Macer +} + +var ( + _ Vault = (*SoftVault)(nil) + _ PINEncryptor = (*SoftVault)(nil) + _ PINTranslator = (*SoftVault)(nil) + _ Macer = (*SoftVault)(nil) +) // SoftVault is the in-process Vault: keys are held in memory and operations run // with the Go standard library. It is for development, testing, and conformance From 51c3cf952ab11a7d80d1318caba9cb416ec92fd9 Mon Sep 17 00:00:00 2001 From: Canaan Etai Date: Thu, 4 Jun 2026 08:09:18 +0100 Subject: [PATCH 3/3] feat(adapters/pkcs11): implement vault.Macer (HSM MAC) against SoftHSM2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turn the PKCS#11 foundation into a working, capability-correct MAC adapter. What it does: - Implements vault.Macer (GenerateMAC/VerifyMAC) for ISO 9797-1 algorithm 1 (single-DES CBC-MAC) and algorithm 3 (ANSI X9.19 retail MAC). - Composes the MAC from the token's 3DES primitives (CKM_DES3_CBC/CKM_DES3_ECB), since modern tokens disable single-DES and expose no retail-MAC mechanism: a single-DES E_K is obtained by presenting K as a triple-length key K||K||K. The key never leaves the token; only the public padding/message are host-side. - Output is byte-for-byte identical to vault.GenerateMAC, cross-checked against a real token under SoftHSM2 (new CI job; verified locally on SoftHSM 2.7.0). Capability surface (the security property): - The type now advertises exactly vault.Macer. The PIN methods are REMOVED, not stubbed, so the type does not structurally satisfy vault.PINTranslator — callers rely on that interface's presence to mean the op is PIN-secure, and a stock PKCS#11 HSM has no PCI-compliant atomic translate. EncryptPINBlock is a clear-PIN issuer-context op and likewise omitted. A test asserts the type is not a PINTranslator/PINEncryptor/Vault. Tests: - No-HSM: capability surface, ISO 9797-1 padding vectors, config validation. - SoftHSM2 (env-gated, skips without a token): provisions its own keys and cross-checks adapter MAC == vault.GenerateMAC across both algorithms, both paddings, empty/partial/multi-block messages, plus verify match/truncate/tamper. CI: new adapters/pkcs11 job installs softhsm2, provisions a token, runs the race suite. README rewritten for the MAC-only capability and key provisioning. --- .github/workflows/adapters.yml | 42 ++++ CHANGELOG.md | 10 + adapters/pkcs11/README.md | 121 ++++++---- adapters/pkcs11/padding_test.go | 51 +++++ adapters/pkcs11/pkcs11.go | 260 +++++++++++++++++----- adapters/pkcs11/pkcs11_softhsm_test.go | 291 +++++++++++++++++++++++++ adapters/pkcs11/pkcs11_test.go | 39 ++-- 7 files changed, 690 insertions(+), 124 deletions(-) create mode 100644 adapters/pkcs11/padding_test.go create mode 100644 adapters/pkcs11/pkcs11_softhsm_test.go 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 f7808fa..d8ef7ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,16 @@ the [versioning policy](https://teqpace-services.github.io/isopace/versioning/). 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 diff --git a/adapters/pkcs11/README.md b/adapters/pkcs11/README.md index 64feff6..cb64a59 100644 --- a/adapters/pkcs11/README.md +++ b/adapters/pkcs11/README.md @@ -1,19 +1,21 @@ -# Isopace PKCS#11 (HSM) Vault adapter +# Isopace PKCS#11 (HSM) MAC adapter -A `vault.Vault` implementation backed by a PKCS#11 cryptographic token (an 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. +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. -> ## ⚠️ Foundation only — not production-ready +> ## ⚠️ Capability: MAC only — and review before production > -> This module currently provides the **connection / session / key-lookup -> foundation** (build-verified). The four cryptographic `Vault` methods are -> **stubbed** (`ErrNotImplemented`) on purpose — see *Status* below. Nothing here -> is production-ready: a PKCS#11 Vault requires **independent security review and -> validation against a certified HSM** (PCI PIN Security / FIPS) before use. +> 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 works today +## What it does ```go v, err := pkcs11.Open(pkcs11.Config{ @@ -21,54 +23,77 @@ v, err := pkcs11.Open(pkcs11.Config{ TokenLabel: "isopace", PIN: "1234", }) -// v satisfies vault.Vault; key lookup by CKA_LABEL is wired. +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 label (`keyRef` → object handle). -- Compile-time `vault.Vault` conformance. +- 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) -## Status of the cryptographic methods +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`). -| Method | Status | Intended PKCS#11 mapping | +| Algorithm | Computation | Key object(s) under `keyRef` | |---|---|---| -| `GenerateMAC` / `VerifyMAC` | **stub** | `C_Sign`/`C_Verify` under a mechanism chosen from `(alg, pad)` — e.g. `CKM_DES3_MAC` for ISO 9797-1, `CKM_AES_CMAC` for CMAC. The mapping is HSM-specific and must be verified against a real token and security-reviewed. | -| `EncryptPINBlock` | **stub** | `vault.EncodePINBlock` + `C_Encrypt` (`CKM_DES3_ECB`). See PIN-security note. | -| `TranslatePIN` | **stub (blocked)** | No secure stock-PKCS#11 implementation exists — see below. | - -## Why `TranslatePIN` is blocked (a `vault` API decision) - -`vault.Vault.TranslatePIN` is contractually *decrypt under src → re-encode → -re-encrypt under dst*. On software (`SoftVault`) that is fine. On an HSM, -emulating it with `C_Decrypt`/`C_Encrypt` would materialise the **clear PIN block -in host memory**, which violates **PCI PIN Security**. A secure translate is a -single **atomic HSM operation** in which the clear PIN never leaves the token — -and that is a **vendor-specific** command (Thales, Futurex, …), **not** part of -standard PKCS#11. - -Likewise, `EncryptPINBlock` takes a clear `pin string`, so the clear PIN is -already in host memory regardless of the HSM — appropriate for issuer/testing -flows, but not for an acquiring switch. - -**Decision needed:** for the production-HSM path, should the `vault.Vault` -interface gain HSM-oriented operations that work on *encrypted* PIN blocks with -an atomic translate (so the clear PIN never transits the host), or should HSM PIN -translation live behind a separate, vendor-specific interface? This is tracked as -part of **B1** in [`ROADMAP-to-v1.md`](../../ROADMAP-to-v1.md) and is relevant to -the v1 API freeze of the `vault` package. +| `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 (interface conformance, stub behaviour, config validation) run -anywhere. A functional suite against **SoftHSM2** is added once the operations -are implemented: +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 -# CI (Ubuntu): -sudo apt-get install -y softhsm2 +# 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 -# import a 3DES test key, then: go test ./... + +# 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/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 index e4199ac..4a09160 100644 --- a/adapters/pkcs11/pkcs11.go +++ b/adapters/pkcs11/pkcs11.go @@ -10,31 +10,69 @@ // // Authorship is recorded in the AUTHORS file. -// Package pkcs11 implements the Isopace vault.Vault façade against a PKCS#11 -// cryptographic token (an HSM). Keys are referenced by their CKA_LABEL and the -// key material never leaves the token. +// 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 // -// This module provides the connection/session/key-lookup FOUNDATION, which is -// build-verified. The four cryptographic Vault methods are deliberately STUBBED -// (they return ErrNotImplemented) — they are not yet implemented because: -// -// - GenerateMAC/VerifyMAC require an ISO 9797-1 → PKCS#11 mechanism mapping -// that is HSM-specific and must be verified against a real token (SoftHSM in -// CI) and security-reviewed before use; and -// - TranslatePIN cannot be implemented securely on stock PKCS#11. The -// vault.Vault contract (decrypt under src, re-encode, re-encrypt under dst) -// would expose the clear PIN block in host memory, which violates PCI PIN -// Security. A secure translate is a single atomic HSM operation, which is a -// vendor-specific mechanism, not part of standard PKCS#11. This needs a -// vault-API decision (see README and ROADMAP-to-v1.md, B1). -// -// Nothing here is production-ready: it requires independent security review and -// validation against a certified HSM (PCI PIN Security / FIPS) before use. +// 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" @@ -45,12 +83,15 @@ import ( "github.com/teqpace-services/isopace/vault" ) -// Vault implements vault.Vault (the crypto methods are stubbed; see package doc). -var _ vault.Vault = (*Vault)(nil) +// Vault implements vault.Macer. It deliberately does not implement +// vault.PINTranslator or vault.PINEncryptor (see the package doc). +var _ vault.Macer = (*Vault)(nil) -// ErrNotImplemented marks a cryptographic operation whose secure PKCS#11 -// implementation is pending verification and security review. -var ErrNotImplemented = errors.New("pkcs11: cryptographic operation not yet implemented (foundation only)") +// 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 { @@ -58,9 +99,14 @@ type Config struct { 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.Vault backed by a PKCS#11 token. It is safe for concurrent +// 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 { @@ -174,44 +220,142 @@ func (v *Vault) findKey(ref string) (p11.ObjectHandle, error) { return objs[0], nil } -// EncryptPINBlock implements vault.Vault. -// -// Intended PKCS#11 mapping: encode the clear block with vault.EncodePINBlock, -// then C_Encrypt the 8-byte block under the CKM_DES3_ECB mechanism with the key -// handle resolved from keyRef. -// -// NOTE: this still forms the clear PIN block in host memory (the interface takes -// a clear `pin string`). See the package and README security notes. Stubbed -// pending verification against a real token and security review. -func (v *Vault) EncryptPINBlock(keyRef string, format vault.PINBlockFormat, pin, pan string) ([]byte, error) { - return nil, ErrNotImplemented +// 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) + } } -// TranslatePIN implements vault.Vault. -// -// A secure HSM PIN-translate is a single atomic operation in which the clear PIN -// never leaves the token. Standard PKCS#11 has no such mechanism; emulating the -// vault.Vault contract via C_Decrypt → re-encode → C_Encrypt would expose the -// clear PIN block in host memory and violate PCI PIN Security. Left unimplemented -// pending a vault-API decision (see README and ROADMAP-to-v1.md, B1). -func (v *Vault) TranslatePIN(srcRef, dstRef string, encBlock []byte, pan string, srcFormat, dstFormat vault.PINBlockFormat) ([]byte, error) { - return nil, fmt.Errorf("%w: secure PIN translate needs an atomic HSM mechanism, not stock PKCS#11", ErrNotImplemented) +// 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 } -// GenerateMAC implements vault.Vault. -// -// Intended PKCS#11 mapping: C_SignInit/C_Sign under a mechanism chosen from -// (alg, pad) — e.g. CKM_DES3_MAC / CKM_DES3_MAC_GENERAL for ISO 9797-1, or -// CKM_AES_CMAC for CMAC. The exact mapping is HSM-specific and must be verified -// against a real token (SoftHSM in CI) and security-reviewed. Stubbed for now. -func (v *Vault) GenerateMAC(keyRef string, alg vault.MACAlgorithm, pad vault.Padding, data []byte) ([]byte, error) { - return nil, ErrNotImplemented +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 } -// VerifyMAC implements vault.Vault. +// 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. // -// Intended PKCS#11 mapping: C_VerifyInit/C_Verify under the same mechanism as -// GenerateMAC, or recompute and compare in constant time. Stubbed for now. -func (v *Vault) VerifyMAC(keyRef string, alg vault.MACAlgorithm, pad vault.Padding, data, mac []byte) (bool, error) { - return false, ErrNotImplemented +// - 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<