Skip to main content
Electronic trust, from hash to archive

Signing keys, HSMs and PKCS#11: where a key lives

How a signing key is generated, stored and used inside its device: key attributes, the PKCS#11 model, local vs remote signing, and what an HSM can't do.

In this series
Part 3 of 9
Author
Lamine Diallo
Read time
20 min read
Published
On this page

Who this is for

Software engineers and technical decision-makers who have read Articles 1 and 2 and know what a key pair, a signature and a certificate are.

Read first

You will learn

  • Explain how a signing key should be generated, and why it must never be derived from a password
  • Describe the PKCS#11 model: slots, tokens, objects, the SO and user roles, sessions and login
  • Read the key attributes that keep a private key inside its device, and the ones that only look like they do
  • Tell local signing from remote signing, and say who controls the key in each
  • Generate a key in SoftHSM, sign with it through pkcs11-tool, verify with OpenSSL, and read the attributes that stop the key from being exported
  • Say why a key held in an HSM does not, on its own, show that a signature was authorized

The problem: the whole scheme rests on one secret

In Article 1, Mariama signed a loan contract with her private key and Ibrahima verified it with her public key. In Article 2, a certificate tied that public key to her name.

Both articles assumed that only Mariama can use the private key. If the key sits in a file on her laptop, anyone who copies the file can sign as her, the signatures will verify, and the certificate will vouch for them.

FIPS 186-5 states the requirement plainly: “A private key shall be protected from unauthorized access, disclosure, and modification”, and it “shall be kept secret” (FIPS 186-5, §5.2). NIST SP 800-57 adds that signature keys support non-repudiation only “when properly handled” (SP 800-57, §5.1.1). This article is about that handling, and about what a secure device cannot do for you.

Making a key: randomness, not a password

A signing key must be unpredictable. FIPS 186-5 has ECDSA key pairs generated from an approved random bit generator specified in SP 800-90A, at the security strength of the curve (§6.2.1, Appendix A.2), and requires the same for RSA (§5.4). In everyday terms: a cryptographically secure random number generator (CSPRNG), never rand() and never something a person chose.

ECDSA on P-256 gives short keys and 64-byte signatures in raw form; RSA needs a modulus of at least 2048 bits. An ECDSA key “shall only be used” for ECDSA signatures (FIPS 186-5, §6.2, §6.2.2).

Never derive a signing key from a password. NIST SP 800-132 says user-chosen passwords “have low entropy and weak randomness properties” and “shall not be used directly as cryptographic keys” (SP 800-132, §5). Its password-based derivation is scoped to the “protection of electronically-stored data” (§1), not signing (our reading). A password can unlock a key. It should never be the key.

Who generates the key

SP 800-57 is specific about signature keys: the owner of the key pair “should generate the keying material rather than any other entity”, because “this will facilitate support for non-repudiation”. The private key “shall not be distributed to other entities” (SP 800-57, §8.1.5.1).

This is where per-user keys come from. As Article 1 showed, one company-wide key shows only that the company’s key signed. A key generated for one person, usable only by that person, lets a signature point at an individual.

Where a key can live

  • A software keystore. A file on a disk, often password-encrypted. Easy to create, easy to copy.
  • A smart card or USB token that the signer carries.
  • A hardware security module (HSM), a device built to hold keys for many users or services.

The last two work the same way for an application: it asks the device to perform the operation, “with sensitive information such as private keys never being revealed” (PKCS #11 Usage Guide, §2.1).

Local signing and remote signing

Where the device sits decides who controls it. This framing is ours, not a term the standards define.

In local signing, the key is on a device the signer holds, “under the control of a single user” (§2.1).

In remote signing, the key is in a service’s HSM. The Cloud Signature Consortium’s CSC API, a common remote-signing specification, describes a provider “managing a set of credentials on behalf of multiple users”, which “typically operates an HSM (or functionally equivalent multi-user secure device) and an authentication service” (CSC API v2.0, §4.1). The signer proves who she is to the service, and the service asks the HSM to sign. Remote signing calls the property of a key that only its signer can use sole control.

Local signing keeps the device with the signer. Remote signing keeps it with a service, and control has to be proven through authorization: signature activation data (SAD), explained below.

The trade-off is control against convenience. A local device stays with the signer but can be lost and needs a reader. A remote HSM works from any phone, but the signer depends on the service to use her key only when she asks.

For comparison: how eIDAS names these devices

eIDAS defines an “electronic signature creation device” as “configured software or hardware used to create an electronic signature” (art. 3(22)), and a remote qualified one as “managed by a qualified trust service provider … on behalf of a signatory” (art. 3(23a)). These EU definitions describe neither Guinean law nor any particular provider. Source: eIDAS, consolidated 2024.

The PKCS#11 model

PKCS#11, an OASIS standard also called Cryptoki, gives every HSM, card and token a common interface. Version 3.2 was published on 3 June 2026 (PKCS #11 v3.2). It “specifies only the interface to the library, not its features”: a device need not support every mechanism (Usage Guide, §2.2). The pieces, from the outside in:

  • Library. The application loads a vendor’s .so or .dll and calls functions such as C_Login and C_Sign (§2.5).
  • Slot and token. The library exposes “slots”; a slot may contain a “token”, the device itself, which “may be implemented entirely in software … no special hardware is necessary” (§2.2).
  • Objects. Data objects, certificates and keys. Token objects persist; session objects disappear with the session that made them (§2.3).
  • Users. The Security Officer (SO) initialises the token and sets the user PIN. Only the normal user can reach private objects (§2.4).
  • Sessions. A read-only or read/write connection between an application and a token (§2.6.1).

One rule surprises most developers: login is per application, not per session. “When an application’s session logs into a token, all that application’s sessions with that token become logged in”, including sessions opened later (§2.6.5).

The application holds references to per-user keys, never the keys themselves. It asks the token to sign and gets a signature back.

The application finds a key by its label or CKA_ID. A key pair and its certificate “should” share a CKA_ID, but “Cryptoki does not enforce these associations” (PKCS #11 v3.2, §4.8).

The attributes that keep a key inside

Every PKCS#11 object carries attributes. Five tell you where a private key lives and whether it can leave. Only CKA_SENSITIVE and CKA_EXTRACTABLE actually keep it in (PKCS #11 v3.2, §4.4, §4.8, §4.10).

Attribute Meaning Can it change?
CKA_TOKEN TRUE for a persistent token object. Default FALSE. —
CKA_PRIVATE TRUE means a user “may not access the object until the user has been authenticated to the token”. —
CKA_SENSITIVE TRUE means the key’s secret value cannot be revealed in plaintext outside the token. Once TRUE, never back to FALSE
CKA_EXTRACTABLE TRUE means the key “is extractable and can be wrapped” (exported, encrypted under another key). Once FALSE, never back to TRUE
CKA_LOCAL TRUE only if the key was generated on the token, or copied from such a key. Set by the token

Those changes are one-way, so protection cannot be turned off later: decide at generation time. CKA_ALWAYS_SENSITIVE and CKA_NEVER_EXTRACTABLE record whether the key was ever exposed, and only CKA_LOCAL tells you it was born on the token.

If CKA_SENSITIVE is TRUE or CKA_EXTRACTABLE is FALSE, the secret value (for an EC key, CKA_VALUE, the private number d) “cannot be revealed in plaintext outside the token” (§4.10). C_GetAttributeValue returns CK_UNAVAILABLE_INFORMATION for it and “should return” CKR_ATTRIBUTE_SENSITIVE (§5.7.5). Wrapping a non-extractable key fails with CKR_KEY_UNEXTRACTABLE (§5.18.3). But unextractable keys “can still be used as keys” (Usage Guide, §3.1): non-extractable stops copying, not signing.

CKA_ALWAYS_AUTHENTICATE goes further: the user must give the PIN again for every signature, and it is only allowed when CKA_PRIVATE is TRUE (§4.10).

Signing through PKCS#11

To sign, the application picks a mechanism. Two matter for ECDSA (PKCS #11 v3.2, §6.3.12, §6.3.13):

  • CKM_ECDSA_SHA256 “computes the entire ECDSA specification, including the hashing”: you pass the document.
  • CKM_ECDSA takes a digest you have already computed.

The output format catches people out. A PKCS#11 ECDSA signature is the two integers r and s concatenated, each padded to the same length: always 64 bytes for P-256 (§6.3.1). OpenSSL, and most other tools, expect the DER structure SEQUENCE { r, s } instead. Hand OpenSSL the raw bytes and verification fails for a format reason, not a cryptographic one, as the hands-on shows.

HSM ≠ authorization

An HSM protects the key’s secrecy. It does not decide whether a signature should happen. The PKCS#11 usage guide is explicit: “Once a normal user has been authenticated to the token, Cryptoki does not restrict which cryptographic operations the user may perform; the user may perform any operation supported by the token” (Usage Guide, §3.1).

Combine that with per-application login (our synthesis): after one C_Login, every session of that application can use every private key the user can reach. The token sees a key reference and some bytes, not the document, person or transaction. The guide also warns that “rogue applications and devices may also change the commands sent to the cryptographic device” (§3.1). A compromised signing server with a logged-in session can sign anything, with keys that never leave the HSM.

So the binding between this person, this document and this signature has to be made outside the HSM. In the CSC API, “accessing a credential for remote signing requires an authorization from the user who owns the signing key”. It produces Signature Activation Data (SAD), “used to control a given signature operation”. A signature activation module uses the SAD “in order that the signing keys are used under sole control of the signer” (CSC API v2.0, §4.1, §8.2). At the level the CSC calls SCAL2, defined in CEN EN 419 241-1, the SAD “is linked to the document or the documents to be signed”, and “a two-factor authorization is needed” (§8.2).

“The key is in an HSM, so every signature is authorized.”

An HSM keeps the key from being copied. Once the user has logged in, PKCS#11 “does not restrict which cryptographic operations the user may perform” (Usage Guide, §3.1), and the token cannot tell which document or signer a C_Sign call is for. Authorization is a separate control, bound to the transaction outside the HSM: SAD in the CSC model, or the transaction-bound authorization built in Article 5.

Lifecycle: expiry, compromise and recovery

As Article 1 noted, SP 800-57 suggests a cryptoperiod of about one to three years for a private signature key, after which it “shall be destroyed” (SP 800-57, §5.3.6); the public key keeps verifying old signatures. If the private key is disclosed, “the integrity and non-repudiation qualities of all data signed by that key are suspect”, though timestamps can preserve signatures made before the compromise (§5.5). Article 4 covers revocation, Article 7 timestamps.

Backup? For a private signature key, SP 800-57 says: “No (in general); support for non-repudiation would be in question” (§8.2.2.1, Table 7), with exceptions such as a CA’s own key. A signer who loses her token gets a new key pair and certificate, not a restored copy.

A word on FIPS 140-3

HSMs are often sold as “FIPS 140-3 validated”: the module meets one of “four increasing, qualitative levels of security” (FIPS 140-3, item 3), as validated by the Cryptographic Module Validation Program. After 21 September 2026, the CMVP moves modules validated only under FIPS 140-2 to its Historical list, where federal agencies may use them for “existing systems only” (CMVP). A validation covers a module, not who authorized a signature or its legal effect.

Mariama’s key, Ibrahima’s check

Back to the loan. Mariama’s token has a user PIN only she knows, and a P-256 key pair is generated inside it, non-extractable from the start. Her application logs in and asks the token to sign the contract, then reads out the public key for Ibrahima (in real use, inside her certificate). Ibrahima verifies: “Verified OK”, and a tampered copy fails. Someone with access to her computer tries to export the private key. The token marks it never-extractable, so nothing comes out.

That last step is what changes compared with Article 1. The signing step is the warning: anyone with her PIN and her token can do exactly what she did.

Try it: a key that won’t leave its token

This hands-on uses SoftHSM, “a software implementation of a generic cryptographic device with a PKCS#11 interface” (SoftHSM README). It speaks real PKCS#11, so the same commands work against a physical HSM, given its library path and the mechanisms it supports. It is not a security boundary: its tokens are ordinary files, and “Backup can thus be done as a regular file copy”. Use it to learn the API, never to protect a real key.

The key and the PIN 1234 are throwaway. Never use these commands with a real key or PIN.

Executable educational example · Docker · alpine:edge · SoftHSM 2.7.0-r0 · OpenSC 0.27.1-r0 (pkcs11-tool) · OpenSSL 3.5.8 25 Aug 2026 · Python 3.14.7-r0

Save this script, the exact one that produced the output below, as run.sh.

#!/bin/sh
set -e

echo "### apk add --no-cache softhsm=2.7.0-r0 opensc=0.27.1-r0 openssl python3 ###"
apk add --no-cache softhsm=2.7.0-r0 opensc=0.27.1-r0 openssl python3

echo
echo "### softhsm2-util --version ###"
softhsm2-util --version

echo
echo "### pkcs11-tool -I --module /usr/lib/softhsm/libsofthsm2.so ###"
pkcs11-tool -I --module /usr/lib/softhsm/libsofthsm2.so

echo
echo "### softhsm2-util --init-token --slot 0 --label mariama-token --pin 1234 --so-pin 5678 ###"
softhsm2-util --init-token --slot 0 --label mariama-token --pin 1234 --so-pin 5678

echo
echo "### pkcs11-tool --module /usr/lib/softhsm/libsofthsm2.so --keypairgen --key-type EC:prime256v1 --id 01 --label mariama-sign --usage-sign --login --pin 1234 ###"
pkcs11-tool --module /usr/lib/softhsm/libsofthsm2.so \
  --keypairgen --key-type EC:prime256v1 --id 01 --label mariama-sign \
  --usage-sign --login --pin 1234

echo
echo "### printf 'Contrat de pret 2026-001 : montant 5000000 GNF' > contrat.txt ###"
printf 'Contrat de pret 2026-001 : montant 5000000 GNF\n' > contrat.txt
cat contrat.txt
openssl dgst -sha256 contrat.txt

echo
echo "### pkcs11-tool --sign -m ECDSA-SHA256 --id 01 --login --pin 1234 -i contrat.txt -o contrat.sig.raw (default 'rs' format = raw r||s) ###"
pkcs11-tool --module /usr/lib/softhsm/libsofthsm2.so \
  --sign -m ECDSA-SHA256 --id 01 --login --pin 1234 \
  -i contrat.txt -o contrat.sig.raw
echo "raw signature: $(wc -c < contrat.sig.raw) bytes"
od -An -tx1 contrat.sig.raw

echo
echo "### export the public key (needed to verify) and try openssl verify against the RAW signature ###"
pkcs11-tool --module /usr/lib/softhsm/libsofthsm2.so \
  --read-object --id 01 --type pubkey --public-key-info -o mariama_pub.der
openssl pkey -pubin -inform DER -in mariama_pub.der -pubout -out mariama_pub.pem
cat mariama_pub.pem

set +e
openssl dgst -sha256 -verify mariama_pub.pem -signature contrat.sig.raw contrat.txt
echo "exit=$?  (expected: openssl cannot parse a raw r||s blob as an ASN.1 ECDSA-Sig-Value)"
set -e

echo
echo "### convert raw r||s (32+32 bytes for P-256) to a DER ECDSA-Sig-Value ###"
cat > raw_to_der.py <<'PYEOF'
with open("contrat.sig.raw", "rb") as f:
    raw = f.read()

assert len(raw) == 64, f"expected 64 raw bytes (r||s for P-256), got {len(raw)}"
r, s = raw[:32], raw[32:]

def encode_int(b):
    b = b.lstrip(b"\x00") or b"\x00"
    if b[0] & 0x80:      # DER INTEGER: prepend 0x00 if the top bit would look negative
        b = b"\x00" + b
    return b"\x02" + bytes([len(b)]) + b

body = encode_int(r) + encode_int(s)
der = b"\x30" + bytes([len(body)]) + body

with open("contrat.sig.der", "wb") as f:
    f.write(der)

print("r   =", r.hex())
print("s   =", s.hex())
print("DER =", der.hex())
PYEOF
python3 raw_to_der.py

echo
echo "### openssl dgst -sha256 -verify against the DER-converted signature ###"
openssl dgst -sha256 -verify mariama_pub.pem -signature contrat.sig.der contrat.txt

echo
echo "### cross-check: pkcs11-tool can emit DER directly with --signature-format sequence ###"
pkcs11-tool --module /usr/lib/softhsm/libsofthsm2.so \
  --sign -m ECDSA-SHA256 --id 01 --login --pin 1234 \
  --signature-format sequence \
  -i contrat.txt -o contrat.sig.sequence.der
openssl dgst -sha256 -verify mariama_pub.pem -signature contrat.sig.sequence.der contrat.txt
echo "note: contrat.sig.der and contrat.sig.sequence.der differ byte-for-byte (fresh ECDSA nonce each signing call); both verify OK"

echo
echo "### tamper check: verify the DER signature against a modified contract (expected: fails) ###"
sed 's/5000000/6000000/' contrat.txt > contrat_tampered.txt
diff contrat.txt contrat_tampered.txt || true
set +e
openssl dgst -sha256 -verify mariama_pub.pem -signature contrat.sig.der contrat_tampered.txt
echo "exit=$?"
set -e

echo
echo "### attempt to export the private key ###"
echo "### pkcs11-tool --read-object --id 01 --type privkey --login --pin 1234 -o priv.der ###"
set +e
pkcs11-tool --module /usr/lib/softhsm/libsofthsm2.so \
  --read-object --id 01 --type privkey --login --pin 1234 -o priv.der
echo "exit=$?"
set -e
if [ -f priv.der ]; then
  echo "priv.der exists, size: $(wc -c < priv.der) bytes"
else
  echo "priv.der was NOT created -- no private key material left the token"
fi

echo
echo "### pkcs11-tool --list-objects --login --pin 1234 (attributes explaining the refusal) ###"
pkcs11-tool --module /usr/lib/softhsm/libsofthsm2.so --list-objects --login --pin 1234

echo
echo "### done ###"

Then run it in a throwaway container. The script is mounted read-only, and every file it writes disappears with the container:

docker run --rm --mount type=bind,src="$PWD/run.sh",dst=/w/run.sh,readonly -w /w alpine:edge sh /w/run.sh

Each ### line repeats the step that follows it. Docker’s image-download lines, if any, come first.

Install and inspect the token

Expected output

### apk add --no-cache softhsm=2.7.0-r0 opensc=0.27.1-r0 openssl python3 ###
( 1/24) Upgrading libcrypto3 (3.5.7-r0 -> 3.5.8-r0)
( 2/24) Upgrading libssl3 (3.5.7-r0 -> 3.5.8-r0)
( 3/24) Installing eudev-libs (3.2.14-r8)
( 4/24) Installing pcsc-lite (2.5.2-r0)
  Executing pcsc-lite-2.5.2-r0.pre-install
( 5/24) Installing ncurses-terminfo-base (6.6_p20260822-r0)
( 6/24) Installing libncursesw (6.6_p20260822-r0)
( 7/24) Installing readline (8.3.3-r1)
( 8/24) Installing opensc (0.27.1-r0)
( 9/24) Installing openssl (3.5.8-r0)
(10/24) Installing libexpat (2.8.4-r0)
(11/24) Installing libbz2 (1.0.8-r6)
(12/24) Installing libffi (3.8.0-r0)
(13/24) Installing xz-libs (5.8.4-r0)
(14/24) Installing libgcc (15.2.0-r9)
(15/24) Installing libstdc++ (15.2.0-r9)
(16/24) Installing mpdecimal (4.0.1-r0)
(17/24) Installing libpanelw (6.6_p20260822-r0)
(18/24) Installing sqlite-libs (3.53.4-r0)
(19/24) Installing python3 (3.14.7-r0)
(20/24) Installing python3-pycache-pyc0 (3.14.7-r0)
(21/24) Installing pyc (3.14.7-r0)
(22/24) Installing python3-pyc (3.14.7-r0)
(23/24) Installing sqlite (3.53.4-r0)
(24/24) Installing softhsm (2.7.0-r0)
Executing busybox-1.38.0-r4.trigger
OK: 58.9 MiB in 38 packages

### softhsm2-util --version ###
2.7.0

### pkcs11-tool -I --module /usr/lib/softhsm/libsofthsm2.so ###
Using slot 0 with a present token (0x0)
Cryptoki version 3.2
Manufacturer     SoftHSM
Library          Implementation of PKCS11 (ver 2.7)

alpine:edge was, at the time of writing, the only Alpine branch shipping both SoftHSM 2.7.0 and OpenSC 0.27.1. Edge moves: the install list will change, and if edge drops these exact versions, the pinned apk add will fail. The library reports Cryptoki version 3.2.

Create the token and generate the key inside it

Expected output

### softhsm2-util --init-token --slot 0 --label mariama-token --pin 1234 --so-pin 5678 ###
The token has been initialized and is reassigned to slot 1758845579

### pkcs11-tool --module /usr/lib/softhsm/libsofthsm2.so --keypairgen --key-type EC:prime256v1 --id 01 --label mariama-sign --usage-sign --login --pin 1234 ###
Using slot 0 with a present token (0x68d5da8b)
Key pair generated:
Private Key Object; EC
  label:      mariama-sign
  ID:         1 (0x01)
  Usage:      decrypt, sign, signRecover, unwrap
  Access:     sensitive, always sensitive, never extractable, local
  uri:        pkcs11:model=SoftHSM%20v2;manufacturer=SoftHSM%20project;serial=fde4458568d5da8b;token=mariama-token;id=%01;object=mariama-sign;type=private
Public Key Object; EC  EC_POINT 256 bits
  EC Point:   0441044d8e5e8c3cc030f0f888f728b873d5cea99dcaba7f947c1e1f2ef64b0a
              c1f1c5c87a10543c15eca778b200af3e31c7016146439030a821b4dae7416d18
              30f695
  EC Params:  06:08:2a:86:48:ce:3d:03:01:07 ("prime256v1" OID:"1.2.840.10045.3.1.7")
  label:      mariama-sign
  ID:         1 (0x01)
  Usage:      encrypt, verify, verifyRecover, wrap
  Access:     local
  uri:        pkcs11:model=SoftHSM%20v2;manufacturer=SoftHSM%20project;serial=fde4458568d5da8b;token=mariama-token;id=%01;object=mariama-sign;type=public

--init-token plays the SO’s role, setting both PINs. prime256v1 is P-256.

Read the private key’s Access: line: sensitive, always sensitive, never extractable, local. pkcs11-tool --keypairgen always asks for a sensitive, private key, and asks for an extractable one only if you pass --extractable; without it, SoftHSM’s own default, non-extractable, applies (pkcs11-tool source; SoftHSM source). On another token, check the Access: line rather than assuming.

The slot number, the 0x68d5da8b token handle, the serial and the EC point differ on every run, since each run creates a fresh token and key.

Sign on the token

Expected output

### printf 'Contrat de pret 2026-001 : montant 5000000 GNF' > contrat.txt ###
Contrat de pret 2026-001 : montant 5000000 GNF
SHA2-256(contrat.txt)= 0c53f84a6928871a965328cc983d0a20d07f16dd69a3b273847aa2a9f54cff4c

### pkcs11-tool --sign -m ECDSA-SHA256 --id 01 --login --pin 1234 -i contrat.txt -o contrat.sig.raw (default 'rs' format = raw r||s) ###
Using slot 0 with a present token (0x68d5da8b)
Using signature algorithm ECDSA-SHA256
raw signature: 64 bytes
 1b 26 96 ec 43 e4 42 4c 97 f8 5e ad b5 87 1d be
 de aa 1e 42 d1 ce 80 ee db 8a e9 cd 70 8e 72 8d
 1e 51 b4 e2 de 2e 7b 1d e8 69 cb e0 4d f8 80 63
 1f 00 ea 55 4f b4 34 bb 3d 21 c2 55 40 24 55 6f

The contract and its digest match Article 1 byte for byte. -m ECDSA-SHA256 selects CKM_ECDSA_SHA256, so the token hashes; SoftHSM added it in 2.7.0 (SoftHSM NEWS). The signature is 64 bytes, r then s, and changes every run because ECDSA uses a fresh random number each time.

Verify: first the wrong format, then the right one

Expected output

### export the public key (needed to verify) and try openssl verify against the RAW signature ###
Using slot 0 with a present token (0x68d5da8b)
warning: PKCS11 function getPUBLIC_KEY_INFO returned a value of length 0 failed: rv = CKR_OK (0x0)

-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAETY5ejDzAMPD4iPcouHPVzqmdyrp/
lHweHy72SwrB8cXIehBUPBXsp3iyAK8+MccBYUZDkDCoIbTa50FtGDD2lQ==
-----END PUBLIC KEY-----
Error verifying data
exit=1  (expected: openssl cannot parse a raw r||s blob as an ASN.1 ECDSA-Sig-Value)

### convert raw r||s (32+32 bytes for P-256) to a DER ECDSA-Sig-Value ###
r   = 1b2696ec43e4424c97f85eadb5871dbedeaa1e42d1ce80eedb8ae9cd708e728d
s   = 1e51b4e2de2e7b1de869cbe04df880631f00ea554fb434bb3d21c2554024556f
DER = 304402201b2696ec43e4424c97f85eadb5871dbedeaa1e42d1ce80eedb8ae9cd708e728d02201e51b4e2de2e7b1de869cbe04df880631f00ea554fb434bb3d21c2554024556f

### openssl dgst -sha256 -verify against the DER-converted signature ###
Verified OK

### cross-check: pkcs11-tool can emit DER directly with --signature-format sequence ###
Using slot 0 with a present token (0x68d5da8b)
Using signature algorithm ECDSA-SHA256
Verified OK
note: contrat.sig.der and contrat.sig.sequence.der differ byte-for-byte (fresh ECDSA nonce each signing call); both verify OK

The warning: line is a quirk of this OpenSC and SoftHSM pairing: the public key written is correct.

The first verification fails with “Error verifying data” because OpenSSL cannot parse raw r‖s. The Python script wraps the same two integers in DER, SEQUENCE { INTEGER r, INTEGER s }, adding a leading zero byte when an integer’s top bit is set so it is not read as negative, and the same bytes give “Verified OK”. In practice, ask pkcs11-tool for DER with --signature-format sequence (or openssl), as the cross-check does. The key, r, s and DER change every run.

Tamper with the contract

Expected output

### tamper check: verify the DER signature against a modified contract (expected: fails) ###
--- contrat.txt
+++ contrat_tampered.txt
@@ -1 +1 @@
-Contrat de pret 2026-001 : montant 5000000 GNF
+Contrat de pret 2026-001 : montant 6000000 GNF
Verification failure
287DF581FFFF0000:error:030000EA:digital envelope routines:EVP_DigestVerifyFinal:provider signature failure:crypto/evp/m_sigver.c:703:ECDSA digest_verify_final:
exit=1

Once out of the token, the signature is ordinary ECDSA: change one digit and it fails, as in Article 1. The error-line prefix (287DF581FFFF0000) changes every run, and so does where the error: line falls among the surrounding lines.

Try to export the private key

Expected output

### attempt to export the private key ###
### pkcs11-tool --read-object --id 01 --type privkey --login --pin 1234 -o priv.der ###
Using slot 0 with a present token (0x68d5da8b)
sorry, reading private keys not (yet) supported
exit=0
priv.der was NOT created -- no private key material left the token

### pkcs11-tool --list-objects --login --pin 1234 (attributes explaining the refusal) ###
Using slot 0 with a present token (0x68d5da8b)
Public Key Object; EC  EC_POINT 256 bits
  EC Point:   0441044d8e5e8c3cc030f0f888f728b873d5cea99dcaba7f947c1e1f2ef64b0a
              c1f1c5c87a10543c15eca778b200af3e31c7016146439030a821b4dae7416d18
              30f695
  EC Params:  06:08:2a:86:48:ce:3d:03:01:07 ("prime256v1" OID:"1.2.840.10045.3.1.7")
  label:      mariama-sign
  ID:         1 (0x01)
  Usage:      encrypt, verify, verifyRecover, wrap
  Access:     local
  uri:        pkcs11:model=SoftHSM%20v2;manufacturer=SoftHSM%20project;serial=fde4458568d5da8b;token=mariama-token;id=%01;object=mariama-sign;type=public
Private Key Object; EC
  label:      mariama-sign
  ID:         1 (0x01)
  Usage:      decrypt, sign, signRecover, unwrap
  Access:     sensitive, always sensitive, never extractable, local
  uri:        pkcs11:model=SoftHSM%20v2;manufacturer=SoftHSM%20project;serial=fde4458568d5da8b;token=mariama-token;id=%01;object=mariama-sign;type=private

### done ###

The message “sorry, reading private keys not (yet) supported” is produced client-side by pkcs11-tool: the tool refuses to read any private key, whatever its attributes, and never asks the token. It would print the same line for an extractable key. It is not a live CKR_ATTRIBUTE_SENSITIVE error returned by the token. The tool even exits 0, so a script checking only the exit code would miss the refusal; the check that matters is that no priv.der was written.

The evidence that the token protects the key is the second command: SoftHSM reports sensitive, always sensitive, never extractable, local. Under PKCS#11, a token must not reveal such a key in plaintext, and must refuse to wrap it with CKR_KEY_UNEXTRACTABLE (PKCS #11 v3.2, §4.10, §5.18.3). This run did not try a wrap. And the Usage: line still lists sign: non-extractable stops copying, not use. The order of the two objects in --list-objects can also differ between runs.

Where SEDEYA fits

SEDEYA’s signing keys are held in a hardware security module, accessed through PKCS#11, or in a key management service (KMS). The resulting signatures are PAdES, up to the B-LTA level, and anyone can check a signed PDF on SEDEYA’s public verification page, by upload or by scanning its QR code. Where a key lives answers one question; how each signing call is authorised is a separate one, covered in Article 5. Later articles in this series cover building the PKI behind those keys and binding each signature to one transaction.

Risks and misconceptions

“SoftHSM is an HSM.”

SoftHSM implements the PKCS#11 interface in software, and its tokens can be backed up “as a regular file copy” (SoftHSM README). The API is real; the boundary is not.

“CKA_PRIVATE means the key is secret.”

CKA_PRIVATE controls visibility before login (PKCS #11 v3.2, §4.4). Secrecy comes from CKA_SENSITIVE and CKA_EXTRACTABLE; a private object can still be extractable.

“The SO and the user are always different people.”

They “may be the same person or may be different” (Usage Guide, §2.4). Separation of duties is a policy you design.

Summary

  • A signing key must come from an approved random generator. A password can unlock a key but should never be one.
  • The owner should generate her own key. Per-user keys let a signature point at one person.
  • Cards, tokens and HSMs sign on request without handing the key out, behind PKCS#11.
  • CKA_SENSITIVE and CKA_EXTRACTABLE keep the key value inside the token; CKA_PRIVATE only hides the object before login.
  • PKCS#11 ECDSA signatures are raw r‖s. Convert to DER, or ask for it, before verifying with OpenSSL.
  • The hands-on export refusal came from pkcs11-tool itself; the token’s never extractable attribute is the real protection.
  • An HSM protects the key’s secrecy, not the decision to sign. Authorization has to be bound to each transaction outside it.

Check your understanding

Check your understanding

Your team stores a signing key in an HSM with CKA_EXTRACTABLE set to FALSE. An attacker gets shell access to the signing server while the application is logged in. What can the attacker do?

Show the answer

Sign anything, with any key the logged-in user can reach, by driving the logged-in application or by reusing the PIN it stores. The attacker cannot copy the key out, but PKCS#11 does not restrict which operations a logged-in application performs, and that login covers all of its sessions. That is why authorization has to be bound to each document outside the HSM.

Check your understanding

pkcs11-tool signs a file on a token, and openssl dgst -verify prints “Error verifying data” with the correct public key. What is the most likely cause?

Show the answer

A format mismatch, not a bad signature. PKCS#11 returns ECDSA signatures as raw r‖s, while OpenSSL expects a DER SEQUENCE. Sign with --signature-format sequence or openssl, or convert the raw bytes to DER.

Check your understanding

A signer loses her smart card. Should the provider restore her signing key from a backup?

Show the answer

In general, no. SP 800-57 says private signature keys are not backed up, because non-repudiation would be in question. She gets a new key pair and certificate; her old signatures still verify with the old public key.

Next step

Article 4 covers building the PKI behind a certificate: root and issuing CAs, CRLs and OCSP. Meanwhile, want to see how a signed PDF can be checked by anyone, without special tools? Talk to us and we will walk you through a SEDEYA signature from signing to public verification.

References

  1. PKCS #11 Specification Version 3.2, OASIS, OASIS Standard, 3 June 2026 (§3.3, §4.2, §4.4, §4.8, §4.10, §5.7.5, §5.18.3, §6.3.1, §6.3.12, §6.3.13)
  2. PKCS #11 Cryptographic Token Interface Usage Guide Version 3.2, OASIS, Committee Note 01, 15 April 2025 (non-normative) (§2.1–2.6, §3.1)
  3. SoftHSMv2 2.7.0: README and NEWS, OpenDNSSEC / SoftHSM project, 2.7.0, 20 January 2026 (Introduction, Backup; NEWS 2.7.0)
  4. pkcs11-tool(1), OpenSC 0.27.1, OpenSC project, 0.27.1, 31 March 2026 (--keypairgen, --sign, --signature-format, --read-object, --list-objects)
  5. Digital Signature Standard (DSS), FIPS 186-5, NIST, 3 February 2023 (§5.2, §5.4, §6.2, §6.2.1, §6.2.2; Appendix A.2)
  6. Recommendation for Key Management: Part 1 – General, SP 800-57 Part 1 Rev. 5, NIST, May 2020 (§5.1.1, §5.3.6, §5.5, §8.1.5.1, §8.2.2.1 (Table 7))
  7. Recommendation for Password-Based Key Derivation, Part 1: Storage Applications, SP 800-132, NIST, December 2010 (§1, §5)
  8. Security Requirements for Cryptographic Modules, FIPS 140-3, NIST, 22 March 2019 (Announcement items 3 and 7; §3.3)
  9. Cryptographic Module Validation Program, NIST CSRC, Read 23 September 2026 (Applicability of Validated Modules)
  10. Architectures and protocols for remote signature applications (CSC API) v2.0.0.2, Cloud Signature Consortium, v2.0.0.2 (v2.2 is current; this article quotes v2.0.0.2) (§4.1, §8.2)
  11. Regulation (EU) No 910/2014 (eIDAS), consolidated text, Publications Office of the European Union, Consolidated 18 October 2024 (Art. 3(22), art. 3(23a))