Skip to content

Building CRA-ready nRF devices

Secure boot and signed firmware updates, end to end, on Nordic nRF. Nordic's b0/NSIB verifies MCUboot, MCUboot verifies the Zephyr application — and every signing key stays inside the HSM.

This is the design pattern behind the CRA's essential requirements on tamper resistance and update authenticity: a device that boots only firmware you authorised, and accepts only updates you signed.

The chain

Three stages, each verifying the next. Trust starts in ROM, which cannot be changed after manufacturing, and is handed forward one link at a time.

  • b0 / NSIB


    Stage 1 · ROM

    Nordic's immutable first-stage bootloader. Cannot be updated. Enforces the whole chain.

    Trusts — an ECDSA-P256 public key provisioned into a fixed flash slot at manufacturing

    Verifies — MCUboot, the next stage

  • MCUboot


    Stage 2 · flash

    Second-stage bootloader. Updatable, and signed so b0 will accept it.

    Trusts — an ECDSA-P256 public key embedded in the MCUboot binary at build time

    Verifies — the Zephyr app, the next stage

  • Zephyr app


    Stage 3 · app slot

    The application. Updatable, signed via imgtool so MCUboot will accept it.

    Provides — the signature MCUboot verifies at each boot

Core property — HSM-resident keys

The two ECDSA-P256 private keys that anchor this chain live in the HSM and never leave it. What the device holds is the matching pair of public keys — one written into b0's provisioning slot at manufacturing, one built into the MCUboot binary. At boot, verification uses only those public keys: LAAVAT is not in the loop, and a device in the field never contacts the platform to boot.

Both keys are ECDSA on prime256v1 (P-256, secp256r1). Nordic's b0 hardware-accelerates only P-256, and imgtool supports it natively. See key custody for how the private keys are held.

The LAAVAT product

One product exposes both signing operations. Each binds an HSM-held ECDSA-P256 key to a specific role in the chain, and both gate on the same approval rule.

  • nRF MCUboot signing


    operationType: DigestSigning

    The raw-digest primitive. You compute the 32-byte b0 pre-image hash locally and submit it; the HSM signs it with ECDSA-P256 and returns a 64-byte signature, which you assemble into b0's validation-info block.

    KeyECDSAP256, "Digest Signing key"

  • nRF Zephyr app signing


    operationType: SignMcuBoot

    The imgtool batch primitive. You submit a .tgz with a small request.json plus the unsigned .bin; LAAVAT runs imgtool sign internally with the HSM key and returns a signed MCUboot-format image.

    KeyECDSAP256, "MCUboot sign"

Operation type versus CLI argument

The operationType in the product definition is SignMcuBoot, but the imagesigning add command takes MCUBoot. Both are correct — they are different identifiers for the same operation.

Example product definition

Group UUIDs are per-tenant; substitute your own.

{
  "name": "nRF52840 NSIB",
  "description": "nRF52840 with signing of MCUboot for b0/NSIB and the Zephyr app for MCUboot",
  "productType": "Production",
  "enabled": true,
  "caInfo": [],
  "rndKeys": [],
  "productOperations": [
    {
      "name": "nRF Zephyr app signing",
      "description": "Sign nRF Zephyr app",
      "operationType": "SignMcuBoot",
      "token": {
        "name": "MCUboot sign",
        "description": "Signing key for nRF MCUboot and Zephyr app",
        "keyType": "ECDSAP256"
      },
      "approvalRule": {
        "name": "Test rule",
        "description": "Rule used for testing",
        "allowedGroups": ["<allowed-group-uuid>"],
        "approvalGroups": ["<approval-group-uuid>"],
        "blanketGroups": []
      }
    },
    {
      "name": "nRF MCUboot signing",
      "description": "Digest signing with ECC key",
      "operationType": "DigestSigning",
      "token": {
        "name": "Digest Signing key",
        "description": "Digest signing key",
        "keyType": "ECDSAP256"
      },
      "approvalRule": {
        "name": "Rule for access",
        "description": "Rule used for testing",
        "allowedGroups": ["<allowed-group-uuid>"],
        "approvalGroups": ["<approval-group-uuid>"],
        "blanketGroups": []
      }
    }
  ]
}

Token handling

The examples below pipe the token in on stdin with -t @-, which suits CI secret managers. A config file created with config-init works equally well — see secure token handling.

Hop A: signing MCUboot for b0

b0 has no "sign this binary" tool. It has a fixed on-flash structure — the validation-info block — that it parses at boot to decide whether stage 2 is authentic. Producing that block with an HSM-held key splits into two parts: assemble everything locally except the signature, then have the HSM sign the one hash that ties it together.

What b0 reads at boot

A 176-byte block appended to the MCUboot image, in five fields:

u8[12]  magic       // NCS-defined constant, identifies a b0 info block
u32     fw_address  // where MCUboot lives in flash (boot slot start)
u8[32]  fw_hash     // SHA-256 over the MCUboot bytes b0 will boot
u8[64]  pubkey      // P-256 uncompressed X‖Y
u8[64]  signature   // ECDSA-P256 over SHA-256(magic ‖ addr ‖ fw_hash ‖ pubkey)

Everything except the signature can be computed on the build host. The signature is the only part that needs the private key.

1. Fetch the public key and assemble the pre-image

Fetch the public key once, then pack magic ‖ fw_address_LE ‖ SHA-256(mcuboot.bin) ‖ pubkey into 112 bytes and hash it to a 32-byte digest.

printf '%s' "$TOKEN" | signing-tool -c -t @- \
    -a https://app.laavat.io/<CustomerName>/api/v1 \
    product getpubkey \
    -P <product-id> \
    --operid <digest-oper-id> \
    -O /tmp/laavat_b0_pub.pem

2. Send only the digest

Submit the 32 bytes, base64-encoded, to the DigestSigning operation. The image itself never leaves your build environment — only its hash goes over the wire.

REQ_ID=$(printf '%s' "$TOKEN" | signing-tool --json -c -t @- \
    -a https://app.laavat.io/<CustomerName>/api/v1 \
    imagesigning add DigestSigning \
    -P <product-id> \
    --operid <digest-oper-id> \
    -p <base64-digest> \
    -H SHA256 \
    -N nrf-b0-mcuboot \
    -D "b0 signing" | jq -r '.id')

The request is asynchronous: it returns an ID and moves from Created to Ready. Capture the ID with --json, then download the 64-byte signature — --wait blocks until the request is ready rather than polling by hand.

printf '%s' "$TOKEN" | signing-tool -c -t @- \
    -a https://app.laavat.io/<CustomerName>/api/v1 \
    imagesigning get -I "$REQ_ID" --wait \
    -O /tmp/b0_sig.bin

The signature may come back as raw r‖s or DER-encoded — detect which from the first byte and unwrap if needed.

3. Assemble the block

Concatenate the 64-byte signature onto the 112-byte pre-image to form the 176-byte validation-info block, and append it to mcuboot.bin. That is the artifact b0 verifies at boot.

The image now boots on any device provisioned with the matching public key, with no further contact with LAAVAT.

Hop B: signing the application for MCUboot

MCUboot has its own signing tool, imgtool, producing a well-known format: a fixed header, the payload, and a trailing TLV block carrying the signature and metadata. This hop delegates the whole imgtool invocation to LAAVAT, so the private key never appears on the build host.

1. Package what imgtool needs

Build a .tgz containing the unsigned zephyr.bin and a request.json carrying the usual imgtool parameters:

{
  "images": [{
    "filename": "zephyr.bin",
    "output": "signed-zephyr.bin",
    "align": "8",
    "version": "1.6.44+1628771740",
    "header_size": "0x200",
    "slot_size": "0xea000",
    "pad_header": true,
    "pad": true,
    "confirm": true,
    "security_counter": "auto"
  }]
}

2. Submit to the MCUboot operation

LAAVAT unpacks the archive, runs imgtool sign inside the HSM boundary with the key bound to this operation, and returns a .tgz containing the signed image.

printf '%s' "$TOKEN" | signing-tool -c -t @- \
    -a https://app.laavat.io/<CustomerName>/api/v1 \
    imagesigning add MCUBoot \
    -P <product-id> \
    --operid <app-oper-id> \
    -F app-submit.tgz \
    -N nrf-zephyr-app \
    -D "Zephyr app signing"

3. Extract the signed image

Untar the response; the signed binary is at signed/signed-zephyr.bin. It is a standard MCUboot-format image — the on-device bootloader, imgtool verify and mcumgr all treat it identically to a locally signed one.

For the request and response formats in detail, see MCUboot signing.

Variant: with TF-M in the chain

Some builds split the application region into a Secure Processing Environment (TF-M, Trusted Firmware-M) and a Non-Secure Processing Environment (the Zephyr app). MCUboot then verifies two images at boot instead of one, and both need signing.

graph LR
    A["<b>b0 / NSIB</b><br/>stage 1 · ROM"]
    B["<b>MCUboot</b><br/>stage 2 · flash"]
    C["<b>TF-M</b><br/>stage 3a · secure"]
    D["<b>Zephyr app</b><br/>stage 3b · non-secure"]
    A -->|verifies| B
    B -->|verifies| C
    B -->|verifies| D

b0's role is unchanged — it still verifies only MCUboot. The extension sits entirely inside MCUboot's verify domain.

Append one more entry to productOperations, the same shape as the Zephyr app operation with a different name and key:

{
  "name": "nRF TF-M signing",
  "description": "Sign nRF TF-M secure image",
  "operationType": "SignMcuBoot",
  "token": {
    "name": "TF-M sign",
    "description": "Signing key for nRF TF-M",
    "keyType": "ECDSAP256"
  },
  "approvalRule": {
    "name": "Test rule",
    "description": "Rule used for testing",
    "allowedGroups": ["<allowed-group-uuid>"],
    "approvalGroups": ["<approval-group-uuid>"],
    "blanketGroups": []
  }
}

The signing flow gains one more submit-poll-download cycle, identical in shape to the application submission but pointed at the TF-M operation with the TF-M binary as input.

Verifying without hardware

Both hops can be checked in a build environment, before any silicon exists. The two verifiers have different natures, so they are checked differently.

Hop A: b0 verification is simulated

b0 lives in ROM, so without silicon it cannot actually run. But everything b0 does before jumping to stage 2 is public in the NCS source and can be replayed in ordinary Python. Read the 176-byte block back off the signed image and run the same five checks:

  1. Parse the block. Split the last 176 bytes into magic (12), fw_address (4, little-endian), fw_hash (32), pubkey (64, X‖Y) and signature (64, r‖s).
  2. Magic constant matches. The 12 bytes must equal CONFIG_SB_VALIDATION_INFO_MAGIC from NCS. On mismatch b0 treats the region as not a valid info block and refuses to boot.
  3. Embedded pubkey equals the provisioned pubkey. b0 has a P-256 public key in a fixed flash slot from manufacturing; the one in the block must match it byte for byte. Use the key from product getpubkey as the reference — that is exactly what a manufacturer programs into the device.
  4. Firmware hash matches. Hash the MCUboot bytes b0 would boot (everything before the appended block) and compare with fw_hash. A single changed byte fails here.
  5. Signature verifies. Reassemble magic ‖ fw_address_LE ‖ fw_hash ‖ pubkey from the parsed block, take its SHA-256, and verify the 64-byte signature against it with the embedded pubkey. This is the same ECDSA-P256 verification the hardware accelerator performs.

If all five pass, the image would boot on a real device.

Hop B: MCUboot verification is real

MCUboot ships its verifier as a Python CLI, running the same maths the on-device bootloader executes at boot. No simulation is needed, because the tool is the reference implementation.

printf '%s' "$TOKEN" | signing-tool -c -t @- \
    -a https://app.laavat.io/<CustomerName>/api/v1 \
    product getpubkey \
    -P <product-id> \
    --operid <app-oper-id> \
    -O /tmp/laavat_app_pub.pem

imgtool verify --key /tmp/laavat_app_pub.pem signed-app.bin
imgtool dumpinfo signed-app.bin

If imgtool verify exits zero, MCUboot on a real device would accept the image. dumpinfo parses the header and TLV block, so you can confirm the version, signature algorithm, key hash and TLV list are what you asked for.

Where off-hardware verification stops

Worth stating plainly, because a passing test suite is easy to over-read:

Claim Off-hardware What would close the gap
MCUboot signature is cryptographically correct for b0 Fully proven
App signature is cryptographically correct for MCUboot Fully proven
b0 parses the block at its expected flash offset Partial Boot on an nRF52840-DK with the pubkey provisioned
Struct magic, field layout and endianness match your NCS release Guarded by defaults, not pinned Cross-reference subsys/bootloader/bl_validation/bl_validation.c in your sdk-nrf checkout
The provisioning flow puts the pubkey in b0's slot correctly Not tested Manufacturing tooling on real silicon
Both signings survive concurrent submissions Not stressed A parallel-submission test, if throughput matters

References

  • bl_validation.c in sdk-nrfstruct fw_validation_info, the authoritative definition of the validation-info block and its field offsets
  • MCUboot design — the image format and what the bootloader verifies at boot
  • MCUboot imgtool — the signing and verification tool used in Hop B