HSM / PKCS#11 configuration¶
Every private key in secsy-pki — CA signing keys and the secret-encryption KEK — is created and used through a pluggable key-provider. In production that provider is a hardware security module (HSM) reached over PKCS#11; for development and CI it is SoftHSM or an on-disk software keystore. This guide covers how to configure each backend and how the abstraction fits together.
Migrating from SoftHSM to a real HSM? See the dedicated production HSM migration guide.
The key-provider abstraction¶
The server never touches raw key files or the PKCS#11 API directly. All key
operations flow through the key_provider configured in config.yaml:
type |
Where keys live | Use for |
|---|---|---|
pkcs11 |
An HSM / PKCS#11 token (YubiHSM, SoftHSM, network HSM) | Production, staging, HSM tests |
software |
On-disk PKCS#8 keystore under keystore_dir |
Local development without any HSM |
kms |
AWS KMS, Azure Key Vault, Google Cloud KMS, or HashiCorp Vault Transit | Cloud/enterprise deployments without a dedicated HSM — see Cloud KMS backend and Vault Transit backend |
If type is omitted it defaults to pkcs11 when pkcs11.module_path is set,
to kms when key_provider.kms.backend is set, and to software otherwise. The
kms backend and per-role backend selection are documented in
Cloud KMS backend (AWS KMS / Azure Key Vault); the HashiCorp
Vault Transit variant (kms.backend: vault, with wrap/unwrap KEK support) is in
Vault Transit backend.
In both backends, private keys are never exported. The software provider
generates keys on disk and signs with them in-process; the PKCS#11 provider
generates keys on the device and every sign / unwrap runs on the device via
C_Sign / C_Decrypt.
Supported key types (canonical names, aliases are normalized): ed25519,
ecdsa-p256, ecdsa-p384, ecdsa-p521, rsa-2048, rsa-4096.
Configuring a PKCS#11 backend¶
pkcs11:
module_path: "/usr/lib/pkcs11/yubihsm_pkcs11.so" # the .so provided by your HSM vendor
pin: "0001password" # PKCS#11 user PIN
token_label: "YubiHSM" # select the token by CKA_LABEL
token_serial: "" # optional: match by serial
token_manufacturer: "" # optional: match by manufacturer
token_model: "" # optional: match CK_TOKEN_INFO.model
slot_id: # optional: pin the CK_SLOT_ID
slot_description: "" # optional: match CK_SLOT_INFO.slotDescription
slot_manufacturer: "" # optional: match CK_SLOT_INFO.manufacturerID
module_pathis the vendor's PKCS#11 shared object. Common paths:- YubiHSM:
/usr/lib/pkcs11/yubihsm_pkcs11.so - SoftHSM (Debian/Ubuntu):
/usr/lib/softhsm/libsofthsm2.soor/usr/lib/x86_64-linux-gnu/softhsm/libsofthsm2.so
Debian installs modules under a multiarch directory
(/usr/lib/x86_64-linux-gnu/…, /usr/lib/aarch64-linux-gnu/…) and this
setting cannot say "whichever". The published
-yubihsm container image
builds Yubico's module itself and symlinks it to the
architecture-independent path above, so the same config file is correct on
amd64 and arm64.
- pin is the user PIN used to log into the token for signing. Storing it
here (or in SECSY_USER_PIN) leaves an HSM credential in plaintext at rest.
Write pin_file: /run/secrets/hsm-pin instead — the file's contents are the
PIN — or, for a credential store rather than a file, the full
pin_source block
it is shorthand for. Either way the PIN is fetched lazily at login and never
enters a dumped config. Every other password in the configuration takes the
same _file treatment: see
credentials from files.
- token_label / token_serial / token_manufacturer / token_model and
slot_id / slot_description / slot_manufacturer select which token to
use when more than one is present. The first slot matching every set
selector wins; unset selectors do not constrain the search. Label alone is
enough for SoftHSM. These are exactly the RFC 7512 token and slot attributes,
so pkcs11.uri can supply any of them instead — see
PKCS#11 URIs in the configuration.
slot_id is the one that separates two tokens nothing else can: two SoftHSM
tokens initialised with the same label, or two identical HSMs in one host.
Key labels must be unique¶
secsy-pki addresses keys on a token by their CKA_LABEL. Two objects sharing a
label can resolve their public and private halves to different key pairs,
producing signatures that fail verification. The provider therefore rejects
generating a second key with an existing label. Choose one label per CA / KEK
and keep it stable — it is also how the CA is referenced everywhere else in the
system.
Addressing keys with RFC 7512 pkcs11: URIs¶
Every CA stores an RFC 7512 pkcs11:
URI describing its signing key, and both the config pkcs11.uri field and each
CA's stored URI are parsed with a complete RFC 7512 parser. A key can therefore
be addressed by any combination of:
- object attributes —
object=(theCKA_LABEL),id=(theCKA_ID, as percent-encoded bytes), andtype=(private/public/cert/…); - token attributes —
token=,serial=,model=,manufacturer=, andslot-id=; - library attributes —
library-description=,library-manufacturer=,library-version=; - query attributes —
module-path=/module-name=, andpin-value=/pin-source=.
Addressing by id= (CKA_ID) or by serial= / slot-id= matters in a
high-availability set: replicas
deliberately share one CKA_LABEL, so the CKA_ID or the token serial is the
only unambiguous way to pin an operation to a specific replica. When a stored CA
URI names a token by serial/slot-id, issuance is routed only to the matching
token; a URI that matches no token in the set fails closed rather than signing on
the wrong replica.
The optional pkcs11.uri field lets you point at an HSM with a single
self-describing string instead of a block of fields. It backfills only the
fields left unset — explicit module_path / token_* / slot_* / pin
always win — and its pin-value / pin-source feed the same
PIN sourcing as the
structured pin_source block (pin-source=file:/path becomes a file source).
Any embedded pin-value is redacted from a dumped or logged config.
pkcs11:
# A single self-describing URI. Equivalent to setting module_path + token_label
# + a file pin_source below.
uri: "pkcs11:token=secsy-pki-root?module-path=/usr/lib/softhsm/libsofthsm2.so&pin-source=file:/etc/secsy/hsm.pin"
module-name= is accepted in place of module-path= and is resolved the way
p11-kit resolves it, which is how one config file stays correct across
distributions whose module directories differ.
Keys are named the same way. Every setting that names a key —
tsa.key_label, signing.signers[].key_label, brski.registrar_key_label,
brski.masa.key_label, secret.escrow.agents[].key_label,
server.tls.self_issue.key_label — accepts a bare label, software:<label>, or
a full pkcs11: URI, and so do the commands that provision those keys
(secsy-ca tsa-key -label …, secsy-ca signing-key -label …). A key URI must
name an object and must not carry the module or a PIN (both are
deployment-wide, not per-key); a token URI must not name an object. Either
mistake is rejected when the config loads, naming the field.
The full reference is PKCS#11 URIs in the configuration.
secsy-ca doctor includes a pkcs11.uris check that parses every configured
pkcs11: URI (the config uri fields, every configured key reference written
as a URI, and each CA's stored key URI), warns on an embedded plaintext
pin-value or an unrecognized attribute, and — against a live token — confirms
each key actually resolves under its full object/id/token addressing.
Sourcing the user PIN from a credential store (pin_source)¶
Storing the PKCS#11 user PIN as plaintext — in pkcs11.pin or the
SECSY_USER_PIN environment variable — leaves an HSM credential at rest in the
config file or the process environment. The pkcs11.pin_source block eliminates
that: the PIN is fetched lazily, at HSM login time, from a pluggable
credential source, is kept out of any logged or dumped config representation
(secret fields are redacted), and — if the source is unreachable — the login
fails closed with an actionable error rather than starting without a working
credential.
pkcs11:
module_path: "/usr/lib/softhsm/libsofthsm2.so"
token_label: "secsy-pki-root"
# pin: "…" # ← omit; the PIN is sourced below instead
pin_source:
type: "file" # inline | env | file | vault | aws | azure | gcp
file:
path: "/etc/secsy/hsm.pin"
Select the source with type; only the matching sub-block is read:
type |
Where the PIN comes from | Key fields |
|---|---|---|
inline |
pkcs11.pin (default; plaintext at rest, emits a deprecation warning) |
— |
env |
An environment variable | env.var (default SECSY_USER_PIN) |
file |
A file (enforced 0600) |
file.path, file.allow_insecure_perms |
vault |
A HashiCorp Vault KV secret | vault.address + auth, vault.mount, vault.path, vault.field |
aws |
AWS Secrets Manager | aws.region, aws.secret_id, aws.field |
azure |
Azure Key Vault secret | azure.vault_url, azure.name, azure.field |
gcp (alias gcpsm) |
Google Cloud Secret Manager | gcp.project, gcp.secret, gcp.version, gcp.field |
file¶
pin_source:
type: "file"
file:
path: "/etc/secsy/hsm.pin" # contents are the PIN; a trailing newline is trimmed
# allow_insecure_perms: false # by default the file must be 0600
The file must not be readable by group or other; a looser mode fails closed with
a chmod 600 hint. Mount it from a tmpfs, a systemd credential
(LoadCredential= lands 0400 owned by the unit's user), or a Kubernetes
Secret — the last needing allow_insecure_perms: true, because a projected
Secret is a root-owned per-pod tmpfs a non-root container can only read through
the group bits. See
credentials from files.
Because a file is the common case, it has a one-line spelling. These are the same configuration:
pin_file expands to exactly the file source above, so everything in this
section — the lazy read, the permission invariant, the startup check, the
doctor probe, and the rule that an explicitly configured source wins over
SECSY_USER_PIN — applies unchanged. Setting both is an error, as is combining
pin_file with an inline pin. Each member of a
multi-token HA set takes its own
tokens[].pin_file. See
credentials from files for the same
convention applied to every other password in the configuration.
This one is checked at startup, unlike the sources that resolve over a
network. A file's permissions will be exactly the same at the first signature as
they are at boot, so there is nothing to gain by waiting — and a great deal to
lose, because the server used to start, bind its listener and answer /healthz
with 200 while every signature failed with HTTP 500:
signing oracle responded with HTTP 500: {"error":"signing: resolving signing key
"sim-test-key": keyprovider: resolving HSM PIN from file /run/secrets/hsm.pin:
keyprovider: pin_source file /run/secrets/hsm.pin has insecure permissions 0644…"}
Automation that verifies a rollout by polling /healthz saw a healthy CA. Now it
is a boot failure naming the path:
FATAL: the PKCS#11 user PIN cannot be read as configured, so every signature
would fail while this server looked healthy: keyprovider: pin_source file
/run/secrets/hsm.pin has insecure permissions 0640: it must not be readable by
group or other (chmod 600 /run/secrets/hsm.pin), or set
pin_source.file.allow_insecure_perms to override
0640 (root:<group> plus a default umask) is the common form of the mistake, not
0644, and it is the one that looks deliberate. The check applies only where a
PKCS#11 backend actually signs, so a software-keystore or cloud-KMS deployment
carrying an unused pkcs11 block is unaffected. The network-backed sources are
left to secsy-ca doctor, whose pin.source check resolves each one on demand
— a server that refused to boot because a secrets service was briefly slow would
be a worse failure than the one being prevented.
env¶
Unlike a bare SECSY_USER_PIN override (which is treated as an inline PIN and
warns), an explicit env source is the sanctioned way to inject the PIN from the
environment (e.g. a Kubernetes secretKeyRef).
vault (HashiCorp Vault KV)¶
Reuses the same connection parameters as the Vault Transit backend — address, token / AppRole auth, namespace, and TLS:
pin_source:
type: "vault"
vault:
address: "https://vault.example:8200" # or the VAULT_ADDR env var
auth_method: "approle" # token | approle
role_id: "…"
secret_id: "…"
mount: "secret" # KV mount (default "secret")
path: "hsm/prod" # secret path within the mount
field: "pin" # key within the secret (default "pin")
kv_version: 2 # 1 or 2 (default 2)
aws (AWS Secrets Manager)¶
Uses the standard AWS credential chain (environment, shared config, IRSA / instance role):
pin_source:
type: "aws"
aws:
region: "eu-central-1" # optional; SDK default resolution otherwise
secret_id: "secsy/hsm-pin" # secret name or ARN
# field: "pin" # optional: pick a key from a JSON secret value
azure (Azure Key Vault)¶
Uses DefaultAzureCredential (environment, workload identity, or managed
identity):
pin_source:
type: "azure"
azure:
vault_url: "https://my-vault.vault.azure.net/"
name: "hsm-pin"
# version: "…" # optional; latest otherwise
# field: "pin" # optional: pick a key from a JSON secret value
gcp (Google Cloud Secret Manager)¶
Uses Application Default Credentials (the metadata server, Workload Identity
Federation, GOOGLE_APPLICATION_CREDENTIALS, or gcloud auth application-default
login), sharing the Cloud KMS backend credential
wiring. The alias gcpsm selects the same source.
pin_source:
type: "gcp"
gcp:
project: "my-project" # or give secret as a full projects/…/secrets/… name
secret: "hsm-pin" # secret id (or a full resource name)
# version: "latest" # optional; "latest" otherwise
# field: "pin" # optional: pick a key from a JSON secret value
# credentials_file: "…" # optional service-account JSON path (else ADC)
The secret must grant the runtime identity roles/secretmanager.secretAccessor.
The PIN is resolved lazily at HSM login, never at process start, and is never
logged.
Per-token override (HA)¶
Each token in a high-availability set
may carry its own pin_source; when omitted it inherits the set-level
pkcs11.pin_source (then the inline pin), exactly like the per-token pin
fallback:
pkcs11:
pin_source: { type: "vault", vault: { address: "…", path: "hsm/shared" } }
tokens:
- { name: "primary", token_label: "hsm-a" } # inherits the vault source
- { name: "backup", token_label: "hsm-b",
pin_source: { type: "file", file: { path: "/etc/secsy/hsm-b.pin" } } }
Verifying the source¶
secsy-ca doctor runs a pin.source check that resolves the configured
source(s) and confirms a PIN comes back — without ever printing it — so a
plaintext-free configuration is verified before the HSM actually needs the PIN:
An unreachable source, a missing secret, or an insecure file mode fails the check (and the HSM login) closed.
High availability across multiple tokens¶
To make HSM access resilient, the PKCS#11 backend can span several tokens/slots behind health-tracked failover: signing is routed to a healthy token and fails over on error, with per-token health and failover metrics. Replicas of a key are placed on each token under the same label (that is how failover finds the same key), while the within-token uniqueness rule above still holds. See HSM high availability (multi-token failover).
YubiHSM specifics¶
When the YubiHSM PKCS#11 module is used, the server also reads a yubihsm:
block and auto-generates yubihsm_pkcs11.conf from it:
yubihsm:
# Direct USB is exclusive to one claim. A deployment that collects the device
# audit log, or attests keys/devices, while signing through the PKCS#11 module
# needs a connector in between — see
# docs/hsm/audit-log.md#the-transport-must-be-shareable.
connector_url: "yhusb://" # direct USB; or an http:// connector URL
auth_key_id: 1
password: "password"
suppress_audit_warning: false
# The connector this process runs itself, which is what lets one container
# both sign and collect: it takes the USB claim and serves Yubico's connector
# protocol on loopback for the module, while the native driver uses the same
# claim directly.
embedded_connector:
# Unset decides: on for direct USB with a YubiHSM PKCS#11 signing path —
# the configuration that otherwise cannot drain its device log at all — and
# off when nothing competes for the device. Set it to true to serve a
# connector_url this config already names; false keeps Yubico's daemon.
enabled: true
# The address served. Empty takes the host:port of a local connector_url,
# else 127.0.0.1:12345. Keep it on loopback: the relay carries an SCP03
# channel it cannot read, but it has no authentication of its own.
listen: "127.0.0.1:12345"
# Which attached device to hold, when several are. Empty refuses to guess.
device: "yhusb://serial=0031650425"
Set YUBIHSM_PKCS11_CONF yourself if you need to manage that file manually. The
YubiHSM path also unlocks the hardware audit-log verification described in the
README.
Six of these settings also come from the environment, because they are the ones a deployment — a container especially — cannot put in a file it baked earlier. A non-empty variable wins over the file; an unset one changes nothing:
| Variable | Sets |
|---|---|
SECSY_YUBIHSM_PASSWORD |
yubihsm.password — a credential, so it belongs in the deployment's secret store |
SECSY_YUBIHSM_AUTH_KEY_ID |
yubihsm.auth_key_id |
SECSY_YUBIHSM_CONNECTOR_URL |
yubihsm.connector_url, and therefore the generated yubihsm_pkcs11.conf too |
SECSY_YUBIHSM_AUDIT_LOG_FILE |
yubihsm.audit_log_file — where the device audit log is permanently written (audit log) |
SECSY_YUBIHSM_EMBEDDED_CONNECTOR |
yubihsm.embedded_connector.enabled — whether one process multiplexes the device, which is a property of how the deployment is packaged rather than of the PKI |
SECSY_YUBIHSM_EMBEDDED_CONNECTOR_LISTEN |
yubihsm.embedded_connector.listen |
The config file does no ${VAR} expansion of its own: a ${FOO} written in it
is that literal string. Use the overrides.
Configuring the software backend¶
Keys are stored as PKCS#8 files under keystore_dir (created on first use).
This backend needs no HSM and is convenient for unit tests and local demos, but
offers no hardware protection — the private keys are ordinary files. Do not
use it for anything you care about protecting.
Secrets and environment¶
The SECSY_* environment variables override the file-based configuration. They
match what scripts/setup-softhsm.sh --export-env emits, so a single eval
wires the CLIs and the test suite to a token. Note that SECSY_USER_PIN sets the
inline PIN (and so emits the plaintext deprecation warning); to source the
PIN from a credential store use
pkcs11.pin_source
instead (its env type reads any variable without the warning):
| Variable | Overrides |
|---|---|
SECSY_KEY_PROVIDER |
key_provider.type |
SECSY_PKCS11_MODULE |
pkcs11.module_path |
SECSY_PKCS11_URI |
pkcs11.uri — the whole token in one variable (module, device, PIN source), which is how a container is pointed at an HSM whose address the image could not know |
SECSY_TOKEN_LABEL |
pkcs11.token_label |
SECSY_TOKEN_SERIAL |
pkcs11.token_serial |
SECSY_TOKEN_MODEL |
pkcs11.token_model |
SECSY_PKCS11_SLOT_ID |
pkcs11.slot_id |
SECSY_USER_PIN |
pkcs11.pin |
SECSY_USER_PIN_FILE |
pkcs11.pin_file — the PIN read from the named file, with no plaintext anywhere. Unlike SECSY_USER_PIN it names a source, so it replaces a configured pin_file/pin_source; SECSY_USER_PIN does not, because an explicit source has always outranked it |
SECSY_PKCS11_SESSION_POOL_SIZE |
pkcs11.session_pool_size |
SECSY_PKCS11_SELECTION_POLICY |
pkcs11.selection_policy |
SECSY_PKCS11_FAILURE_THRESHOLD |
pkcs11.failure_threshold |
SECSY_PKCS11_MODULE_DIRS |
the directories a module-name= is resolved against (colon-separated), replacing the built-in list |
SECSY_SOFTWARE_KEYSTORE_DIR |
software.keystore_dir |
Explicit config fields win over pkcs11.uri, and these variables win over the
file — so SECSY_TOKEN_LABEL still overrides a token= carried by
SECSY_PKCS11_URI.
Every credential variable has a _FILE twin (SECSY_USER_PIN_FILE,
SECSY_ROOT_PASSWORD_FILE, SECSY_YUBIHSM_PASSWORD_FILE,
SECSY_DATABASE_DSN_FILE, VAULT_TOKEN_FILE, SECSY_VAULT_SECRET_ID_FILE),
because an environment variable is not a good place for a secret either — it is
inherited by every child process and printed by docker inspect. Setting either
form clears the other, so the credential has exactly one origin. See
credentials from files.
SoftHSM for development and CI¶
scripts/setup-softhsm.sh initializes a SoftHSM2 token idempotently and prints
the module path plus the environment variables above.
# One-time: create/reuse a token labelled "secsy-pki-root"
./scripts/setup-softhsm.sh
# Wire the current shell to it (module path, token label, PINs)
eval "$(./scripts/setup-softhsm.sh --export-env)"
# Recreate the token from scratch
SOFTHSM_REINIT=1 ./scripts/setup-softhsm.sh
Defaults: token label secsy-pki-root, user PIN 1234, SO PIN 5678, token
dir /tmp/softhsm/tokens, config /tmp/softhsm2.conf. Override any of them via
SOFTHSM_TOKEN_LABEL, SOFTHSM_USER_PIN, SOFTHSM_SO_PIN, SOFTHSM_TOKEN_DIR,
SOFTHSM2_CONF.
Point config.yaml at the exported values, e.g.:
key_provider:
type: "pkcs11"
pkcs11:
module_path: "/usr/lib/softhsm/libsofthsm2.so"
pin: "1234"
token_label: "secsy-pki-root"
SoftHSM caveat: RSA-OAEP is SHA-1 only¶
SoftHSM 2.6.x can only perform RSA-OAEP unwrap with SHA-1, while YubiHSM and the software backend support SHA-256. The secret-encryption feature negotiates the strongest OAEP hash the KEK can actually use and records the choice in each envelope, so ciphertext stays portable — but prefer a SHA-256-capable HSM in production. See password encryption.
Run HSM-gated Go tests serially, because a SoftHSM token is a single shared resource:
Verifying the token works¶
# List objects on the token
pkcs11-tool --module "$SECSY_PKCS11_MODULE" --login --pin "$SECSY_USER_PIN" \
--list-objects --token-label "$SECSY_TOKEN_LABEL"
# Create a CA key end-to-end through secsy-ca (see the CA guide)
secsy-ca -config config.yaml init-root -label "Root CA" -cn "Example Root CA"
secsy-ca -config config.yaml list
See also¶
- Certificate authority operations — create CAs and issue against this provider.
- Password / secret encryption — the HSM-backed KEK.
- Production HSM migration — SoftHSM → real HSM.