Skip to content

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:

key_provider:
  type: "pkcs11"        # "pkcs11" | "software" | "kms"
  software:
    keystore_dir: "keystore"
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_path is the vendor's PKCS#11 shared object. Common paths:
  • YubiHSM: /usr/lib/pkcs11/yubihsm_pkcs11.so
  • SoftHSM (Debian/Ubuntu): /usr/lib/softhsm/libsofthsm2.so or /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= (the CKA_LABEL), id= (the CKA_ID, as percent-encoded bytes), and type= (private/public/cert/…);
  • token attributes — token=, serial=, model=, manufacturer=, and slot-id=;
  • library attributes — library-description=, library-manufacturer=, library-version=;
  • query attributes — module-path= / module-name=, and pin-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:

pkcs11:
  pin_file: "/etc/secsy/hsm.pin"

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

pin_source:
  type: "env"
  env:
    var: "SECSY_USER_PIN"    # any variable name; defaults to SECSY_USER_PIN

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:

✓ pass  pin.source   all 1 PIN source(s) reachable, PIN retrieved (pkcs11→file /etc/secsy/hsm.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

key_provider:
  type: "software"
  software:
    keystore_dir: "keystore"

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:

eval "$(./scripts/setup-softhsm.sh --export-env)"
cd server
go test -p 1 -tags sqlite ./...

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