X.509 Registration Walkthrough

Overview

This page walks through a complete X.509 registration from a terminal, using nothing but openssl and curl. It is the quickest way to prove a Device Model is correctly configured — and to see exactly what your firmware has to do — before any firmware exists.

Everything below uses a throwaway Certificate Authority created on your own machine. Nothing here is specific to a real production PKI, but the steps are identical.


📘

Protocol reference

This page is a worked example. For the endpoint contract, the full field list and the error codes, see Registering with an X.509 certificate.

Before you start

X.509 is not self-serve. Both halves of the Model setup are done by Xyte on request — neither is available in the Partner Portal:

  • Switching a Device Model's Authentication Method to X.509 Certificate. The X.509 Certificate option is not selectable in the Partner Portal; ask Xyte to enable it for the Model.
  • Registering your Certificate Authority against that Model. Send Xyte the CA public certificate and we register it. There is no Partner Portal screen or API for registering or revoking a CA either.

Ask for both against a dedicated test or lab Device Model — one with no real Devices on it. Step 2 below creates a throwaway CA and registers that CA against the Model you use here, so it must be a Model you are happy to have trusting a disposable key. Never run this walkthrough against the Model your production CA is registered for.

From the Partner Portal you then need two values:

  • Your short codeSettings → Provision. It prefixes every Cloud ID you mint.
  • The test Model's Hardware Key — Partner Portal → Product → Models → select model → Hardware Keys.

Step 1 — Mint a Cloud ID

A Cloud ID is your short code followed by a unique 16–36 character string. See Cloud ID for the full rules.

export SHORTCODE='<your short code>'
export CLOUD_ID="${SHORTCODE}$(LC_ALL=C tr -dc 'A-Za-z0-9' </dev/urandom | head -c 21)"
echo "$CLOUD_ID"

Step 2 — Create a test CA and Device certificate

The Certificate Authority must carry CA:TRUE in its basic constraints — a leaf certificate is refused as a trust anchor.

openssl ecparam -name secp384r1 -genkey -noout -out ca.key
openssl req -x509 -new -key ca.key -sha384 -days 3650 \
  -subj "/CN=Example Manufacturing Root CA/O=Example" \
  -addext "basicConstraints=critical,CA:TRUE" \
  -addext "keyUsage=critical,keyCertSign,cRLSign" \
  -out ca.crt

The Device certificate's Subject Common Name must be the Cloud ID. This is the binding between the certificate and the Device identity, and it is checked on every registration.

openssl ecparam -name secp384r1 -genkey -noout -out device.key
openssl req -new -key device.key -sha384 -subj "/CN=${CLOUD_ID}/O=Example" -out device.csr
openssl x509 -req -in device.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
  -sha384 -days 3650 \
  -extfile <(printf 'basicConstraints=critical,CA:FALSE\nkeyUsage=critical,digitalSignature\n') \
  -out device.crt

Confirm the chain before going near the API:

openssl verify -CAfile ca.crt device.crt
openssl x509 -in device.crt -noout -subject

Send ca.crt to Xyte to register against your test Device Model. ca.key and device.key never leave your machine — Xyte stores only the CA public certificate and never holds private key material.


🚧

A registered CA stays trusted until Xyte revokes it

Registering a CA against a Model is not something you can undo yourself — the Model trusts that CA until you ask Xyte to revoke it, and there is no Partner Portal screen or API for either operation. The CA you just created has its private key sitting in a scratch directory on your machine, so anyone who gets hold of that key can mint certificates the Model will accept. Register it only against the dedicated test Model, never against a Model that has real Devices on it.

Step 3 — Set the request variables

export BASE_URL='https://entry.xyte.io'
export HARDWARE_KEY='<your test Model Hardware Key>'
export SN='SN-001'
export FW='1.0.1'

Step 4 — Request a challenge

Send a normal registration body with no certificate fields. The response is a 401 carrying a single-use nonce, valid for 120 seconds. No Device is created by this call.

NONCE=$(curl -s -X POST "$BASE_URL/v1/devices" \
  -H 'Content-Type: application/json' \
  -d '{
    "hardware_key": "'"$HARDWARE_KEY"'",
    "cloud_id":     "'"$CLOUD_ID"'",
    "sn":           "'"$SN"'",
    "firmware_version": "'"$FW"'"
  }' | jq -r '.nonce // empty')

export NONCE
[ -n "$NONCE" ] || echo 'No nonce in the response — re-run the curl on its own and read the whole body.'
echo "$NONCE"

Two small things in that snippet are load-bearing, because a silent failure here is expensive: you sign a meaningless value, get another 401 from Step 6, and go looking for the wrong problem.

  • .nonce // empty yields an empty string when the response carries no nonce — a 400 because the Hardware Key is wrong, for instance. A plain .nonce would put the literal string null into $NONCE, which looks like a value and is not one.
  • The assignment and the export are separate statements on purpose. In export NONCE=$(…) the exit status you see is export's own, always 0, so a failing command substitution is swallowed — which is why the emptiness check, rather than an exit status, is what catches it.

The nonce is bound to the hardware_key and cloud_id you just sent. Send the same two values in the next request or the challenge will not match. (Both are trimmed on the way in, so surrounding whitespace in a pasted value makes no difference.)

Step 5 — Sign the nonce

Sign the raw nonce — no prefix, no wrapping, no trailing newline — with SHA-384, and Base64 the result.

export SIGNATURE=$(printf '%s' "$NONCE" | openssl dgst -sha384 -sign device.key | openssl base64 -A)
export CRT=$(jq -Rs . < device.crt)

Two details matter here:

  • Use printf, not echo. A trailing newline would become part of the signed data and the signature would not verify.
  • Use openssl base64 -A, not base64. GNU coreutils base64 wraps its output at 76 characters, while the BSD base64 on macOS does not — so the same code can work on a developer's Mac and fail everywhere else. A wrapped signature breaks the JSON body outright; escaped into valid JSON, it then fails the server's strict Base64 decode.

jq -Rs . turns the PEM file into a JSON string — it escapes the newlines and supplies its own surrounding quotes. That is why $CRT is the one value in the body below with no JSON quotes written around it: it is still shell-quoted, as '"$CRT"', but the quotes that make it valid JSON come from jq rather than from the request body.

Step 6 — Register

Resend the same identity fields, adding the challenge, the signature and the certificate.

curl -i -X POST "$BASE_URL/v1/devices" \
  -H 'Content-Type: application/json' \
  -d '{
    "hardware_key": "'"$HARDWARE_KEY"'",
    "cloud_id":     "'"$CLOUD_ID"'",
    "sn":           "'"$SN"'",
    "firmware_version": "'"$FW"'",
    "nonce":     "'"$NONCE"'",
    "signature": "'"$SIGNATURE"'",
    "x509_crt":  '"$CRT"',
    "x509_chain": []
  }'

A 201 returns the Device's id, access_key, hub_url, hub_url_static_cert and mqtt_hub_url.

export DEVICE_ID='<id>'
export ACCESS_KEY='<access_key>'
export HUB_URL='<hub_url>'

Step 7 — Send the first Telemetry

From here the Device is an ordinary access-key Device and the certificate is never presented again. Note the change of host: registration happens on the Provisioning Server, everything afterwards goes to the hub_url returned above.

curl -i -X POST "$HUB_URL/v1/devices/$DEVICE_ID/telemetry" \
  -H 'Content-Type: application/json' \
  -H "Authorization: $ACCESS_KEY" \
  -d '{"status": "online", "telemetries": {"temperature": 41.5}}'

Read the Device back to confirm it landed:

curl -s "$HUB_URL/v1/devices/$DEVICE_ID" -H "Authorization: $ACCESS_KEY" | jq .

Step 8 — Clean up, and what a second run needs

The 201 created a real Device. It is not a sandbox object: it belongs to your account and counts against your registration quota exactly like a Device built on a production line. Delete it from the Partner Portal once you are done with it.

A second run cannot reuse the material from the first:

  • Mint a new Cloud ID. Registering again with the same cloud_id returns a 422Device already registered. The Cloud ID alone identifies the Device here, so changing only the serial number does not help.
  • Issue a new Device certificate for that new Cloud ID. A fresh CLOUD_ID with the old device.crt still carrying the previous Cloud ID in its Subject CN fails with certificate_identity_mismatch.

So repeat Step 1, then the Device-certificate half of Step 2 (device.key, device.csr, device.crt). The test CA and its registration against the Model stay as they are.

Troubleshooting

Step 6 returns another challenge

A 401 at step 6 means the nonce you sent was not live. Every registration attempt that carries a nonce and reaches the certificate check consumes it, before the certificate or the signature is looked at — so a request that failed for an unrelated reason, such as an empty signature field, still spends it. (A request rejected earlier than that — a bad Hardware Key, or a missing sn or cloud_id — never touches the nonce.) Once a nonce is spent, every retry receives a fresh challenge no matter what else you correct.

Always sign the nonce from the most recent response, and check the body is complete before spending a challenge on it. Build it into a variable first:

export BODY='{
    "hardware_key": "'"$HARDWARE_KEY"'",
    "cloud_id":     "'"$CLOUD_ID"'",
    "sn":           "'"$SN"'",
    "firmware_version": "'"$FW"'",
    "nonce":     "'"$NONCE"'",
    "signature": "'"$SIGNATURE"'",
    "x509_crt":  '"$CRT"',
    "x509_chain": []
}'

printf '%s' "$BODY" | jq -e '
  (.nonce     | length > 0) and
  (.signature | length > 0) and
  (.x509_crt  | startswith("-----BEGIN CERTIFICATE-----"))
'

Then send it with curl -i -X POST "$BASE_URL/v1/devices" -H 'Content-Type: application/json' -d "$BODY".

If the body is sound and the challenge still repeats, the identity the challenge was issued for is not the identity you are registering. The two things that actually cause it:

  • CLOUD_ID was re-minted between the two calls. Re-running the Step 1 command overwrites the variable, and the nonce belongs to the previous value.
  • A different Hardware Key was used. The challenge is issued under the key that requested it.

Run step 4 and step 6 back to back in the same shell, without re-exporting either variable in between.

Step 6 returns a 403

A 403 does not automatically mean the certificate was rejected — read the error_code before assuming it. The full list is in Registering with an X.509 certificate; these are the ones you are likely to hit here:

  • signature_invalid — the certificate itself is fine: its chain, validity period and Subject CN all passed, and only the signature over the nonce failed to verify. The usual cause is echo instead of printf in Step 5, which signs a trailing newline along with the nonce. Redo Step 5 as written — and fetch a fresh nonce first, because the failed attempt spent the last one.
  • certificate_identity_mismatch — the certificate's Subject CN is not the cloud_id being registered.
  • certificate_untrusted — the CA registered for the Model is not the one that signed this certificate.

A 403 can also come back before the challenge is reached at all — a missing sn or cloud_id, or the request going to the wrong host. Those carry none of the certificate error codes above, so if the error_code is missing or unfamiliar, check the request line and the identity fields before looking at the certificate.