Skip to main content
Documentation
Preview
You're viewing the Next docs — a rolling preview of in-development changes. The current release docs may differ.

Hardware-Bound Device Keys

Each device authenticates to Nexigon Hub with a certificate and its private key. By default, Nexigon Agent stores the private key in a file. Anyone who copies the device’s storage, for example by cloning its SD card, also copies the key.

This guide shows how to bind the key to the device’s hardware instead. A TPM, a PKCS#11 token, or device firmware then holds the key and signs with it on request, so the key is not stored in a file. A device booted from a copy of the storage then cannot authenticate as the original device. Software on the running device can still have the hardware sign, so an attacker who controls the device can still authenticate as it.

How It Works

Nexigon Agent uses a hardware-bound key through a signer script: an executable that you provide and that performs operations with the key. The agent runs the script to read the public key, to sign a new certificate when none exists at ssl-cert, and to sign each TLS handshake with Nexigon Hub. Unlike with a key file, the agent does not generate the key. You create it in the hardware once, as described below.

The script adapts the hardware’s tools to the protocol that the agent expects. This guide provides scripts for TPM 2.0, for PKCS#11 tokens, and for the Raspberry Pi firmware. For other hardware, write a script that follows the signer script protocol.

Set Up the Key

Install the signer script in your device image at /usr/libexec/nexigon/nexigon-signer and make it executable. Create the key once on each device, for example during manufacturing or in a first-boot service that runs before the agent.

TPM 2.0

A TPM 2.0 can create an ECDSA P-256 key that cannot be exported from it. A key that the TPM creates is transient at first: the TPM forgets it when it resets, for example on a reboot. To keep the key, you have the TPM store it in its non-volatile memory under a persistent handle, a 32-bit address that software then uses to refer to the key.

The TCG handle registry reserves parts of the handle space, for example for the TPM’s endorsement key. For keys like the one below, which you create in the owner hierarchy, it marks the range from 0x81008000 to 0x8100FFFF as available. The examples use 0x81008000. List the handles in use with tpm2_getcap handles-persistent and pick a free one in that range. If tpm2_readpublic -c 0x81008000 succeeds, the handle already holds a key, for example one created earlier.

The following commands use tpm2-tools to create the key and persist it:

tpm2_createprimary -C o -g sha256 -G ecc256 -c primary.ctx
tpm2_create -C primary.ctx -G ecc256:ecdsa-sha256 \
    -a "fixedtpm|fixedparent|sensitivedataorigin|userwithauth|sign" \
    -u key.pub -r key.priv
tpm2_load -C primary.ctx -u key.pub -r key.priv -c key.ctx
tpm2_evictcontrol -C o -c key.ctx 0x81008000
rm primary.ctx key.pub key.priv key.ctx

A TPM creates every key under a parent key. The first command creates a primary key as the parent in the owner hierarchy, the hierarchy that the device’s owner controls rather than the device’s firmware. The second command creates the signing key under it with these attributes:

  • fixedtpm and fixedparent prevent the key from ever being exported.
  • sensitivedataorigin makes the TPM generate the private key itself instead of accepting one from outside.
  • sign, without decrypt, makes the key a signing-only key.
  • userwithauth with the default empty password lets any process with access to the TPM use the key without a password.

The third and fourth commands load the signing key and persist it on its own. The last command deletes the files, which are not needed afterward, nor is the parent key. If your TPM has an owner password, pass it to tpm2_createprimary and tpm2_evictcontrol with -P.

The following signer script uses the key at 0x81008000:

/usr/libexec/nexigon/nexigon-signer
#!/bin/sh

set -eu

export TPM2TOOLS_TCTI="device:/dev/tpmrm0"
key=0x81008000

case "${1-}" in
    public-key) exec tpm2_readpublic -Q -c "$key" -f der -o /dev/stdout ;;
    sign) exec tpm2_sign -Q -c "$key" -g sha256 -f plain -o /dev/stdout /dev/stdin ;;
    *) echo "unsupported operation: ${1-}" >&2; exit 1 ;;
esac

TPM2TOOLS_TCTI makes the tools connect to the kernel’s TPM resource manager. If your system runs the tpm2-abrmd daemon instead, remove that line. The -f der and -f plain options make the tools write the DER-encoded public key and signature that the agent expects. -Q keeps tpm2_readpublic from printing a description of the key to stdout as well.

The script runs as the agent’s user. That user needs access to /dev/tpmrm0.

PKCS#11 Tokens

PKCS#11 is a standard interface to hardware that holds keys, such as smart cards, USB security keys, and hardware security modules. PKCS#11 calls such hardware a token. The token’s vendor provides a PKCS#11 module, a shared library through which software uses the token. The pkcs11-tool command from OpenSC works with any such module. Add OpenSC and your token’s module to your device image.

A removable token, such as a USB security key, binds the key to the token rather than to the device. Anyone who has the token and a copy of the device’s storage can authenticate as the device.

The examples use the module /path/to/vendor-pkcs11.so, a token with the label My Token, and a key with the ID 01. Replace them with your module, the label that pkcs11-tool --module /path/to/vendor-pkcs11.so --list-slots shows for your token, and an unused ID. An ID is a hex-encoded byte string, so 01 and 1 are different IDs. If the following command succeeds, the token already holds a key with that ID:

pkcs11-tool --module /path/to/vendor-pkcs11.so --token-label "My Token" \
    --read-object --type pubkey --id 01 --output-file /dev/null

Otherwise, create the key. The token must already have a user PIN, set by its vendor or with pkcs11-tool --init-token and --init-pin. The following command asks for the user PIN. In a manufacturing or first-boot script, pass the PIN with --pin env:PKCS11_PIN instead, as the signer script below does:

pkcs11-tool --module /path/to/vendor-pkcs11.so --token-label "My Token" \
    --login --keypairgen --key-type EC:prime256v1 --usage-sign \
    --id 01 --label nexigon-device-key

--key-type EC:prime256v1 selects an ECDSA key on the curve P-256, which OpenSSL calls prime256v1. --usage-sign allows the key to sign. The token stores the private key and the public key as separate objects with the same ID, through which the signer script finds them. The label is a name for people. Check that the command’s output lists the private key with Access: sensitive, always sensitive, never extractable, local, which shows that the token generated the key and does not let it out.

Some tokens create keys only with their vendor’s tools. With those, create an ECDSA P-256 key as the vendor describes and use its ID in the signer script below.

Signing requires the user PIN. The following signer script reads it from /etc/nexigon/agent/pkcs11-pin. Create that file with the PIN as its only content and make it readable only by the users that run the signer script, typically only the agent’s user. The script passes the PIN to pkcs11-tool through an environment variable, because other processes can read command-line arguments. The PIN file is on the device’s storage, so the PIN prevents use of the token on its own but not together with a copy of the storage.

With a wrong PIN, every signing attempt fails. The agent retries failed TLS handshakes, and a service manager typically restarts an agent that failed to start, so the wrong PIN reaches the token repeatedly. Many tokens lock the user PIN after a few wrong attempts. Check the script before you deploy it:

/usr/libexec/nexigon/nexigon-signer
#!/bin/sh

set -eu

module=/path/to/vendor-pkcs11.so
token="My Token"
id=01

case "${1-}" in
    public-key)
        exec pkcs11-tool --module "$module" --token-label "$token" \
            --read-object --type pubkey --id "$id" --output-file /dev/stdout
        ;;
    sign)
        PKCS11_PIN="$(cat /etc/nexigon/agent/pkcs11-pin)"
        export PKCS11_PIN
        exec pkcs11-tool --module "$module" --token-label "$token" \
            --login --pin env:PKCS11_PIN --id "$id" \
            --sign --mechanism ECDSA-SHA256 --signature-format openssl \
            --input-file /dev/stdin --output-file /dev/stdout
        ;;
    *) echo "unsupported operation: ${1-}" >&2; exit 1 ;;
esac

--read-object --type pubkey writes the DER-encoded public key that the agent expects. With --mechanism ECDSA-SHA256, the token hashes the message with SHA-256 and signs the hash, and --signature-format openssl makes pkcs11-tool write the signature as DER instead of concatenated r and s values.

Some tokens support only the ECDSA mechanism, which signs a hash that the caller computes. The following command lists the mechanisms of your token:

pkcs11-tool --module /path/to/vendor-pkcs11.so --token-label "My Token" \
    --list-mechanisms

For a token without ECDSA-SHA256, replace the exec pkcs11-tool command of the sign operation with a pipeline that hashes the message with OpenSSL first. This variant also needs the openssl command in your device image:

        openssl dgst -sha256 -binary | pkcs11-tool --module "$module" \
            --token-label "$token" --login --pin env:PKCS11_PIN --id "$id" \
            --sign --mechanism ECDSA --signature-format openssl \
            --input-file /dev/stdin --output-file /dev/stdout

The script runs as the agent’s user. That user needs access to the token. What that requires depends on the module, for example access to the USB device of a security key.

Raspberry Pi Firmware

The firmware of Raspberry Pi models with a device-unique private key in one-time programmable (OTP) memory, such as the Raspberry Pi 4 and 5, provides a cryptography service for that ECDSA P-256 key. The rpi-fw-crypto tool from Raspberry Pi uses this service. Raspberry Pi OS ships the tool.

Key 1 is the device-unique OTP key. If rpi-fw-crypto pubkey --key-id 1 succeeds, the device already has the key. Otherwise, generate it. Generation writes OTP memory and cannot be undone:

rpi-fw-crypto genkey --key-id 1 --alg ec

The following signer script uses key 1. The pubkey command writes the DER-encoded public key, and the sign command hashes the message with SHA-256 and writes a DER-encoded signature, as the agent expects:

/usr/libexec/nexigon/nexigon-signer
#!/bin/sh

set -eu

case "${1-}" in
    public-key) exec rpi-fw-crypto pubkey --key-id 1 ;;
    sign) exec rpi-fw-crypto sign --in /dev/stdin --key-id 1 --alg ec ;;
    *) echo "unsupported operation: ${1-}" >&2; exit 1 ;;
esac

The script runs as the agent’s user. That user needs access to the firmware through /dev/vcio_crypto or /dev/vcio.

Until the key is locked against reading, software on the device can read it with rpi-fw-crypto privkey. Set lock_device_private_key=1 in config.txt, so that the firmware locks the key at every boot while keeping signing available. Without secure boot, anyone who can modify the boot partition can remove this setting, because the firmware authenticates config.txt only with secure boot enabled. Even with the lock, software with root privileges can read the OTP memory directly. On a Raspberry Pi, binding the key to the firmware therefore protects against copies of the storage but not against root access to the running device.

Check the Signer Script

A broken signer script keeps the agent from starting. Before you deploy a script, sign a test message on the device and verify the signature with OpenSSL. Run the commands as the user that runs Nexigon Agent, so that missing access to the hardware shows up as well:

signer=/usr/libexec/nexigon/nexigon-signer
echo test > /tmp/message
"$signer" public-key > /tmp/public-key.der
"$signer" sign < /tmp/message > /tmp/signature.der
openssl dgst -sha256 -verify /tmp/public-key.der -keyform DER \
    -signature /tmp/signature.der /tmp/message

OpenSSL prints Verified OK for a working script. If your own script uses a P-384 key, use -sha384 instead of -sha256.

Configure Nexigon Agent

Add the [signer] section to the agent’s configuration in place of ssl-key. All other settings stay the same. For example, for a device that obtains its credentials through provisioning:

/etc/nexigon/agent.toml
fingerprint-script = "/usr/libexec/nexigon/nexigon-device-fingerprint"

[signer]
script = "/usr/libexec/nexigon/nexigon-signer"

When the agent starts without a certificate at ssl-cert, it has the signer sign a new one. Nexigon Hub treats the certificate like any other new certificate, so it is accepted automatically or pending depending on the deployment token. See Signer Configuration for all options and for how the agent handles signer failures.

Switch an Enrolled Device

You can switch a device that already authenticates with a key file to a hardware-bound key. The device fingerprint does not depend on the key, so the device keeps its device ID and history in Nexigon Hub.

To switch:

  1. Set up the key and the signer script, and check the script.
  2. In the configuration, add [signer], remove ssl-key if it is set, and set ssl-cert to a new path, for example /etc/nexigon/agent/ssl/signer-cert.pem. The existing certificate contains the old public key, so the agent would fail to start with it. With the old files left in place, the agent can connect with the old configuration again, for example after a rollback.
  3. Restart the agent, which creates the new certificate.
  4. Accept the new certificate in Nexigon Hub. Then revoke the old certificate and delete the old certificate and key file, by default /etc/nexigon/agent/ssl/cert.pem and /etc/nexigon/agent/ssl/key.pem. Until you revoke the old certificate, anyone with a copy of the old key file can authenticate as the device.

Nexigon Hub does not accept the device’s new certificate automatically, even with an Auto Accept deployment token, because the device already has a certificate. The device cannot connect until you accept it.