Recursive AI

Sign with an HSM (PKCS#11) in C#

All onecrypto recipes

An HSM, smart card or USB token keeps its private keys inside: you send it data and get a signature back. PKCS #11 is the standard interface every vendor ships as a native library (a “module”). RecursiveAI.OneCrypto.Keys loads that module, logs in and turns a key on the token into an ordinary OneCrypto signing key, so CMS, X.509, OpenPGP and PDF signing can use it like a key in memory.

Terminal
dotnet new console -o HsmSign && cd HsmSign
dotnet add package RecursiveAI.OneCrypto.Keys --version 0.4.0-preview.1

OneCrypto is a preview, so dotnet add package needs --version or --prerelease. This page was run on SoftHSM2 2.6.1, a software token that keeps its keys in files, so it can be tried without hardware (sudo apt install softhsm2 opensc on Debian and Ubuntu, brew install softhsm opensc on macOS). The commands below make a throwaway token in a temporary directory, with an ECDSA P-256 key generated inside it by OpenSC’s pkcs11-tool. With a real HSM, skip this step: its vendor’s tools create the key, and PKCS11_MODULE is the vendor’s library.

Terminal
export SOFTHSM2_CONF="$(mktemp -d)/softhsm2.conf"
mkdir "${SOFTHSM2_CONF%/*}/tokens"
echo "directories.tokendir = ${SOFTHSM2_CONF%/*}/tokens" > "$SOFTHSM2_CONF"
softhsm2-util --init-token --free --label "demo token" \
  --so-pin 12345678 --pin 1234 > /dev/null
 
export PKCS11_MODULE=/usr/lib/softhsm/libsofthsm2.so
pkcs11-tool --module "$PKCS11_MODULE" --token-label "demo token" --login --pin 1234 \
  --keypairgen --key-type EC:prime256v1 --label "signing key" --id 01
Text
Key pair generated:
Private Key Object; EC
  label:      signing key
  ID:         01
  Usage:      decrypt, sign, signRecover, unwrap, derive
  Access:     sensitive, always sensitive, never extractable, local
  uri:        pkcs11:model=SoftHSM%20v2;manufacturer=SoftHSM%20project;serial=8bbc117ebbb4411e;token=demo%20token;id=%01;object=signing%20key;type=private
Public Key Object; EC  EC_POINT 256 bits
  EC_POINT:   044104498a181ccae5a22ec16fa1f5a1eb5187442b7a1e4ec3475067f06a3f69c7849167635b3674d637996e19740c23f932e2953252268aa87aba378bc9e9c47425d3
  EC_PARAMS:  06082a8648ce3d030107 ("prime256v1" OID:"1.2.840.10045.3.1.7")
  label:      signing key
  ID:         01
  Usage:      encrypt, verify, verifyRecover, wrap, derive
  Access:     local
  uri:        pkcs11:model=SoftHSM%20v2;manufacturer=SoftHSM%20project;serial=8bbc117ebbb4411e;token=demo%20token;id=%01;object=signing%20key;type=public

Put this in Program.cs:

C#
using System.Text;
using RecursiveAI.OneCrypto.Keys;
using RecursiveAI.OneCrypto.Keys.Pkcs11;
 
// The vendor's PKCS #11 library; SoftHSM2's on Debian and Ubuntu by default
string modulePath = Environment.GetEnvironmentVariable("PKCS11_MODULE")
    ?? "/usr/lib/softhsm/libsofthsm2.so";
string pin = "1234"; // read it from your secret store, or ask the user
 
using Pkcs11Module module = Pkcs11Module.Load(modulePath);
 
// Find the token by its label, not by slot number: slot numbers can change
Pkcs11Slot slot = module.GetSlots().First(s => s.GetTokenInfo().Label == "demo token");
Console.WriteLine($"Token: {slot.GetTokenInfo().Label} ({slot.GetTokenInfo().Model})");
using Pkcs11Session session = slot.OpenSession();
session.Login(pin);
 
// A handle to the key; the private key stays in the token
using Pkcs11Key key = session.OpenKey(label: "signing key");
var signer = (ISigningKey)key;
Console.WriteLine($"Key: {key.Label}, {key.GetType().Name}");
 
byte[] document = Encoding.UTF8.GetBytes("Invoice 2026-118: 4,200.00 EUR\n");
File.WriteAllBytes("invoice.txt", document);
 
// The token signs. OneCrypto hashes the data first and checks the token's
// signature against the public key before it returns it.
SignatureSpec spec = signer.DefaultSpec;
byte[] signature = signer.Sign(document, spec);
File.WriteAllBytes("invoice.sig", signature);
Console.WriteLine($"Signature: {signature.Length} bytes, {spec}");
 
// Anyone with the public key can check it
Console.WriteLine($"Check: {spec.Verify(key.PublicKey, document, signature).Status}");
byte[] changed = Encoding.UTF8.GetBytes("Invoice 2026-118: 9,200.00 EUR\n");
Console.WriteLine($"Changed: {spec.Verify(key.PublicKey, changed, signature).Status}");
Text
Token: demo token (SoftHSM v2)
Key: signing key, Pkcs11EcKey
Signature: 71 bytes, ECDSA SHA256 (Der)
Check: Valid
Changed: Invalid

DefaultSpec for a P-256 key is ECDSA with SHA-256, DER-encoded as OpenSSL expects. For an RSA key, OpenKey returns an IRsaKey, and SignatureSpec.RsaPss(HashAlgorithmName.SHA256) or SignatureSpec.RsaPkcs1(HashAlgorithmName.SHA256) picks the padding. OpenKey also takes the key’s ID (id:), and requires exactly one private key to match.

Check it with OpenSSL

Read the public key out of the token with pkcs11-tool, not from the program, and let OpenSSL verify the signature with it:

Terminal
pkcs11-tool --module "$PKCS11_MODULE" --token-label "demo token" \
  --read-object --type pubkey --label "signing key" -o public.der
openssl dgst -sha256 -verify public.der -keyform DER -signature invoice.sig invoice.txt
Text
Verified OK

When you’re done, delete the token: rm -r "${SOFTHSM2_CONF%/*}".

Signing for CMS, PDF and OpenPGP

The token key is an ISigningKey, so it goes wherever OneCrypto takes one: CmsSignerSpec.Key for a CMS signature (Sign a file with CMS), X509Signer.FromKey to issue certificates, the PDF signer (Sign a PDF with PAdES and long-term validation), and PgpSecretKey.FromExternalKey(publicKey, key) for OpenPGP. If the token is on another machine than the document, sign in two steps: prepare the signature in one process (PreparedSignedData, PgpPreparedSignature), sign its bytes where the token is, and complete it in a third; see Keys and two-phase signing.

Limits

  • Not audited. OneCrypto (cryptography, not cryptocurrency) has not been independently audited. It is a preview: 0.4.0-preview.1, and the API can change.
  • The key-backend glue (PKCS #11, gpg-agent and ssh-agent) “had an internal review … it has not been independently audited” (Key-backend review).
  • No hardware token tested. The project tests against SoftHSM2 2.6.1 and 2.7.0; “there is no hardware token in the test set”. This page ran on SoftHSM2 2.6.1, which keeps keys in files and is no substitute for an HSM.
  • Linux and macOS, 64-bit. PKCS #11 on Windows is refused with a Pkcs11ModuleException.
  • A key marked CKA_ALWAYS_AUTHENTICATE (it wants the PIN again before every signature, as some smart cards do) is refused. A wrong PIN is a Pkcs11PinException, and the library never retries it.

Mechanisms, PIN handling, errors and what was tested: Keys, two-phase signing and key backends.