Skip to content

Raw PKCS#11 inspector

The token as Cryptoki describes it, rather than as this PKI summarises it.

Everywhere else, secsy-pki keeps the HSM behind an abstraction that answers the questions a CA asks: give me a signer for this label, is any key extractable, is the device reachable. That is the right shape for issuing certificates, and the wrong shape for the two situations an operator actually ends up in.

Something is on the token that this PKI did not put there. A key left behind by a vendor utility, a certificate object imported by a migration, an object whose CKA_LABEL is empty, or — the one that silently breaks signatures — two objects sharing a label, so that a label lookup can pair the public half of one key with the private half of another. An abstraction that enumerates private keys with labels cannot show you any of them.

The module and the token disagree with the documentation. A mechanism the firmware does not actually offer, a CKA_* the module answers differently from the standard, a token whose flags say it is write-protected or whose PIN is one attempt from locking, a second initialised token in a slot nobody expected.

The inspector answers both by reading out everything Cryptoki can be asked, and printing it in the standard's own vocabulary:

Surface
CLI secsy-ca pkcs11 module · secsy-ca pkcs11 mechanisms · secsy-ca pkcs11 objects
Console the PKCS#11 view
REST GET /api/pkcs11/module · GET /api/pkcs11/objects

It is read-only on every surface. There is no create, modify or destroy path, and — see below — it never asks the token for private key material.

What it reads

Cryptoki call Reported as
C_GetInfo the module: Cryptoki version, manufacturer, library description and version, flags
C_GetSlotList (all slots, not only those with a token) the slot table
C_GetSlotInfo per slot: description, manufacturer, hardware/firmware version, flags
C_GetTokenInfo per token: label, model, manufacturer, serial, flags, session counts, PIN length bounds, memory, clock
C_GetMechanismList + C_GetMechanismInfo per slot: every mechanism with its key-size range and operation flags
C_FindObjects with an empty template every object the session can see, of any class
C_GetAttributeValue per object: every attribute the standard defines for its class
C_GetObjectSize per object, where the module implements it (many decline)

Flags are decoded to their CKF_ names — and per namespace, because Cryptoki reuses the bit values: 0x2 is CKF_REMOVABLE_DEVICE on a slot, CKF_WRITE_PROTECTED on a token and CKF_DECRYPT on a mechanism. A bit the name tables do not know is kept as hex rather than dropped, since a vendor flag is exactly the thing worth noticing.

The constant → name tables are generated from the PKCS#11 binding this project links, by go run scripts/gen-pkcs11-names.go, which has the Go compiler evaluate each constant rather than transcribing it. That is what makes the aliases (CKA_ECDSA_PARAMS is CKA_EC_PARAMS) and composed values (CKA_WRAP_TEMPLATE is CKF_ARRAY_ATTRIBUTE | 0x211) impossible to get wrong.

Private objects need the login

C_FindObjects returns what the session can see, and a private object (CKA_PRIVATE=true — every CA private key) is invisible to an unauthenticated session. The object dump therefore runs on an authenticated session: the server's own already-logged-in session pool, or a short-lived login for the CLI.

The module view does not log in at all, deliberately. C_GetInfo, the slot list and C_GetTokenInfo need no session, and an operator reaching for a slot list is very often one whose login does not work — a PIN that no longer resolves, a token selector matching nothing, a device that moved slots. Requiring a login first would make the one view that can diagnose those the one view that fails with them.

Private key material is never requested

An attribute whose value is private key material is not read. Not read and redacted — never put in the template:

  • CKA_PRIVATE_EXPONENT, CKA_PRIME_1, CKA_PRIME_2, CKA_EXPONENT_1, CKA_EXPONENT_2, CKA_COEFFICIENT (the RSA private exponent and CRT factors), for every key class;
  • CKA_VALUE on a private, secret or OTP key — the DSA/EC private scalar, or the symmetric key bytes.

They appear in the output with status withheld and no bytes. CKA_VALUE on a certificate or data object is a different attribute of a different object and is shown: there it is the object itself, not a secret.

A correctly provisioned token refuses these anyway, with CKR_ATTRIBUTE_SENSITIVE — and that refusal is worth showing, which is why sensitive refusals are reported rather than hidden. But the inspector must not be the one path in this system that turns a mis-provisioned, extractable key into an HTTP response body. If you need to know whether a key could leave the device, the answer is the non-extractability verdict in secsy-ca inventory / GET /api/inventory/keys, and the device-signed proof is key attestation.

The CK_ATTRIBUTE-array attributes (CKA_WRAP_TEMPLATE, CKA_UNWRAP_TEMPLATE, CKA_DERIVE_TEMPLATE) are not read either, with status not-read: their value is an array of CK_ATTRIBUTE structs whose pValue members are pointers the caller is supposed to have set up, and a module handed a zeroed buffer may write through them. There is no portable way to read one blind.

Attribute status vocabulary

PKCS#11 has no "list this object's attributes" call. C_GetAttributeValue only answers questions: you name a CK_ATTRIBUTE_TYPE and the token either returns its value or refuses. So "all attributes" means "every attribute the standard defines, asked of every object", and each row carries what came back:

Status Meaning
present the token returned a value. value is the decoding, raw the hex
unavailable the token refused. reason is the CKR_ name — CKR_ATTRIBUTE_SENSITIVE (it exists and may not be read: on a CA key, the token asserting the private value cannot leave) or CKR_ATTRIBUTE_TYPE_INVALID (this object has no such attribute)
withheld the inspector did not ask: the value would be private key material
not-read the inspector cannot ask portably (a CK_ATTRIBUTE array)

By default an object is asked only for the attributes the standard defines for its CKA_CLASS — a certificate is not interrogated about CKA_PIXEL_X — and CKR_ATTRIBUTE_TYPE_INVALID rows are dropped as noise. -all / ?all=1 / the console's whole catalogue checkbox asks every object for every catalogued attribute and keeps those rows, which is the honest way to discover what a particular module answers outside the standard. The withholding policy above is not relaxed by it.

Values are decoded by their Cryptoki type, not guessed from the bytes — the same four bytes are a CK_ULONG, a CK_MECHANISM_TYPE or the start of a modulus depending only on which attribute they came from. A CKA_EC_PARAMS becomes a curve name, a CKA_SUBJECT a distinguished name, a certificate's CKA_VALUE a subject/issuer/serial/expiry summary, a modulus its bit length. The hex is always there too, capped (default 2 KiB) with a SHA-256 over the whole value, so a truncated modulus can still be compared between two tokens.

The CLI

$ secsy-ca pkcs11 module
module path       /usr/lib/x86_64-linux-gnu/pkcs11/yubihsm_pkcs11.so
cryptoki version  2.40
manufacturer      Yubico (www.yubico.com)
library           YubiHSM PKCS#11 Library 2.80
flags             0x00000000

slot 0  <- this deployment's token
  description         YubiHSM Connector 127.0.0.1
  manufacturer        Yubico
  hardware/firmware   0.0 / 0.0
  flags               0x00000007 CKF_TOKEN_PRESENT CKF_REMOVABLE_DEVICE CKF_HW_SLOT
  token present       true
  token label         YubiHSM
  token model         YubiHSM
  token serial        31650425
  token flags         0x0000040D CKF_RNG CKF_LOGIN_REQUIRED CKF_USER_PIN_INITIALIZED CKF_TOKEN_INITIALIZED
  pin length          8..64
  token hw/fw         2.40 / 2.40

-mechanisms adds each slot's mechanism list; pkcs11 mechanisms prints it as a table with the key-size range and flags, and -filter narrows it:

$ secsy-ca pkcs11 mechanisms -filter ecdsa
slot 1473751053 (SoftHSM slot ID 0x57d7a80d)
MECHANISM  TYPE        MIN KEY  MAX KEY  FLAGS
CKM_ECDSA  0x00001041  112      521      CKF_SIGN CKF_VERIFY CKF_EC_F_P CKF_EC_NAMEDCURVE CKF_EC_UNCOMPRESS
1 of 70 mechanism(s).

A mechanism absent here cannot be used however the configuration is written, which is the quickest way to tell a firmware limitation from a misconfiguration.

pkcs11 objects prints one attribute table per object:

$ secsy-ca pkcs11 objects
token "YubiHSM" (serial 31650425), slot 0 — 2 object(s)
by class: CKO_PRIVATE_KEY=1 CKO_PUBLIC_KEY=1

--- object 0x31234: CKO_PRIVATE_KEY ca-root
    C_GetObjectSize: 96 bytes
  ATTRIBUTE                TYPE        KIND        LEN  VALUE
  CKA_CLASS                0x00000000  class       8    CKO_PRIVATE_KEY (0x3)
  CKA_TOKEN                0x00000001  bool        1    true
  CKA_PRIVATE              0x00000002  bool        1    true
  CKA_LABEL                0x00000003  string      7    ca-root
  CKA_KEY_TYPE             0x00000100  key-type    8    CKK_EC (0x3)
  CKA_ID                   0x00000102  bytes       2    0x1234
  CKA_ALLOWED_MECHANISMS   0x40000600  mechanisms  48   CKM_ECDSA, CKM_ECDSA_SHA1, CKM_ECDSA_SHA256, …
  CKA_SENSITIVE            0x00000103  bool        1    true
  CKA_SIGN                 0x00000108  bool        1    true
  CKA_EXTRACTABLE          0x00000162  bool        1    false
  CKA_NEVER_EXTRACTABLE    0x00000164  bool        1    true
  CKA_PRIVATE_EXPONENT     0x00000123  biginteger  0    (withheld: not requested: the value is private key material)
  CKA_EC_PARAMS            0x00000180  der         10   P-256 (prime256v1 / secp256r1) [1.2.840.10045.3.1.7]
  CKA_VALUE                0x00000011  bytes       0    (withheld: not requested: the value is private key material)
Flag
-brief one row per object instead of the attribute tables
-all the whole attribute catalogue, including unsupported rows
-label / -class filter (-class private-key, or CKO_PRIVATE_KEY)
-max-objects raise or lower the enumeration cap (default 500)
-max-value-bytes raise or lower the per-value hex cap (default 2048)
-raw print the hex of every value alongside its decoding
-json the machine-readable form, exactly what the REST endpoints return

The REST endpoints

Both are gated on the platform-wide hsm:manage capability — the same capability that administers the device elsewhere (key import, backup, audit provisioning). The object dump names every object on the token, including ones no CA references; the module view reports the token's serial and PIN-attempt state. Neither is operator-general information.

GET /api/pkcs11/module module/slot/token/mechanism view. ?mechanisms=0 drops the mechanism lists (the bulk of the payload)
GET /api/pkcs11/objects object dump. ?all=1, ?max_objects=N, ?max_value_bytes=N

tokens in the response is a list: a high-availability set is several devices behind one provider, and the point of the view is to tell them apart — replicas deliberately share key labels, so only the member name and the token serial say which device an object is on. Inspection is the one operation that never fails over: a replica that cannot be reached contributes a view carrying its error, so a degraded set still shows the others and which one is down.

A backend with no PKCS#11 view — software, cloud KMS, Vault Transit — answers 501 naming itself, rather than a 200 carrying an empty view, which would read as the much more alarming "the token holds nothing".

Each object dump records one hsm.inspect audit event whose detail is the per-token object count, so an access review sees who enumerated the device and what was on it at the time — and a later reader can tell whether it gained objects between two inspections. A refused request is recorded too.

The console view

PKCS#11 in the console nav carries the same thing in five panels: Module (C_GetInfo), Token (the resolved CK_TOKEN_INFO, or a warning when no slot matches the configured selectors), Slots, Mechanisms (filterable, per slot) and Objects. The first four load with the page; objects load on a click, because they cost device round trips. Each object row expands to its attribute table, and both halves download as JSON.

In an HA set a token picker appears, naming each member and whether the failover layer currently has it in rotation.

Costs and caveats

On a force-audited YubiHSM, inspecting consumes device audit-log entries. C_FindObjects is the device's own ListObjects command and every attribute read is a GetObjectInfo; on a device provisioned for forced audit logging those fill its 62-entry ring, and a full ring is a device that refuses to sign. So an object dump prompts the same device-log drain a signature does, automatically. It is still worth knowing that an inspection is not free there.

Enumeration is capped (500 objects by default). A truncated result says so on every surface — an object missing from a truncated list is not evidence that it is absent from the token.

Object handles are session-scoped. CK_OBJECT_HANDLE values are valid only within the session that produced them, so the handle column changes between inspections. It is useful for correlating rows within one dump (and, on a YubiHSM, it encodes the on-device object ID), not as an identifier to store. CKA_ID and CKA_LABEL are the identifiers; see PKCS#11 URIs for addressing a key by them.

This is the token's own account of itself, not proof. CKA_EXTRACTABLE=false read over a session is the module's answer; a compromised module or connector could say anything. The provable version is key attestation, where the device signs a statement about the key with a Yubico-rooted key, and device attestation for the hardware itself. The inspector is for seeing what is there; attestation is for proving what it is.


↩ Back to HSM & key management · documentation map