> Markdown version of https://docs.laavat.io/solutions/cra-ready-raspberry-pi/ from the LAAVAT PKI and Signing Platform documentation. All pages: https://docs.laavat.io/llms.txt

# Building CRA-ready Raspberry Pi gateways

A Raspberry Pi 4 gateway can be set up so that it boots only software its
manufacturer signed, and accepts only updates the manufacturer built, which
nobody else can read. The board itself covers just the first step: its
bootloader checks one RSA signature. Everything after that is up to the
manufacturer.

This guide is an example of such a setup, in production today. It shows how
three keys held on the LAAVAT platform cover it: one signs the boot image, one
protects the update images, one signs the update manifest. Take the parts that
match your own design.

> **Note: How to read this**
> This is an example configuration, not a design guide. It shows how three
> platform operations cover secure boot, encrypted update images and a
> signed manifest on a Raspberry Pi 4, so that a manufacturer with a design
> of its own can see which calls fulfil which part. Update delivery — the
> deployment server, download and rollout — and the updater itself stay
> with the manufacturer and are not covered.

## Secure boot, encrypted payloads and a signed manifest

**Secure boot** on a Raspberry Pi 4 is enforced by the bootloader in the
board's EEPROM. In signed-boot mode it loads the operating system only from
`boot.img`, which holds the kernel and an initramfs, and only if the
accompanying `boot.sig` carries a valid RSA-2048 signature over it. The
bootloader's own configuration is signed the same way, so the settings that
govern boot cannot be changed either.

What makes this secure is one value programmed into the SoC's one-time
programmable memory (OTP) at the factory: the hash of the manufacturer's public
key. From then on the board accepts only what the matching private key signed,
and signed boot cannot be switched off.

For the manufacturer this means one RSA-2048 key that must exist, unchanged and
never leaked, for as long as the product is supported, and two signatures for
every release: one over `boot.img` and one over the bootloader configuration.
That key is the first of the three this guide is about.

**Secure update** is not something the Pi provides. A gateway that receives new
root filesystems and applications in the field needs its own update format, and
a home-grown format has to answer the same questions RAUC or Mender answer for
their users: who produced this update, has it been altered, is it for this
hardware, is it newer than what is installed, and can anyone who intercepts it
read the payloads? The configuration here answers them with two more keys: an
AES-128 product key that lives in the device's ATECC608C secure element and
unlocks the per-image encryption keys, and an ECDSA P-256 key that signs a
manifest describing the update.

The three belong together. Secure boot guarantees that the updater running on
the device is the one the manufacturer shipped, with the manifest key pinned
inside it; the manifest guarantees which payloads make up a release and that
they are newer than what is installed; encryption keeps the payloads
confidential and, through the authenticated-encryption tag, adds a second
integrity check bound to the manifest entry.

This is where LAAVAT comes in. The manufacturer keeps its update format, its
build and its updater. LAAVAT holds the three keys in an HSM, signs and
encrypts on request, controls who may ask, and hands out the public keys and
the product key the factory needs. The images themselves never reach the
platform.

## LAAVAT's part in each link

Each artifact on the gateway is verified by the component that loads it,
against an anchor placed on the device once. Nothing on the device talks to
LAAVAT at boot or at update time.

| Artifact | Verified by | Trust anchor on the device | LAAVAT role |
| --- | --- | --- | --- |
| Bootloader configuration (`boot.conf`) | EEPROM bootloader | SHA-256 hash of the RSA-2048 public key in SoC OTP; the key itself in the signed EEPROM image | Signs the SHA-256 digest (`DigestSigning`, RSA-2048), provides the public key |
| Boot image (`boot.img`: kernel + initramfs) | EEPROM bootloader, via `boot.sig` | Same anchor | Signs the SHA-256 digest (`DigestSigning`, RSA-2048); `rpi-eeprom-digest` can call it through its `-H` hook |
| Update manifest (`update.jws`) | Updater in the initramfs | ECDSA P-256 public key pinned in `boot.img` | Signs the manifest digest (`DigestSigning`, ECDSA P-256), provides the public key |
| Update images (`*.enc`) | Updater, with the product key in the ATECC608C | AES-128 product key in the ATECC608C | Wraps each image's encryption key under the product key (`EncryptImageWithAES`); releases the product key to the factory client |

> **Abstract: Core property — HSM-resident keys**
> The boot key, the manifest key and the product key all live in the HSM.
> The two signing keys never leave it. The AES-128 product key has to reach
> the device's secure element, so it leaves exactly once per factory
> station, wrapped for a registered client and never in the clear — the
> same export path every encryption key has. See
> [key custody](https://docs.laavat.io/security/#key-custody-and-export).

The manufacturer brings the build artifacts; LAAVAT returns them signed or
encrypted, together with the material the device needs to verify them.

```mermaid
graph LR
    subgraph you["You bring"]
        B1["<b>Boot artifacts</b><br/>boot.img<br/>boot.conf"]
        B2["<b>Update inputs</b><br/>28-byte key blobs<br/>manifest digest"]
    end
    subgraph laavat["LAAVAT platform"]
        L1["<b>Boot signing</b><br/>DigestSigning<br/>RSA-2048"]
        L2["<b>Update protection</b><br/>EncryptImageWithAES<br/>+ DigestSigning P-256"]
    end
    subgraph out["Signed and verifiable"]
        O1["<b>Boot signatures</b><br/>boot.sig · conf.sig<br/>+ RSA public key"]
        O2["<b>Signed update</b><br/>wrapped keys · JWS<br/>+ ECDSA public key"]
    end
    B1 --> L1 --> O1
    B2 --> L2 --> O2
```

One product, three keys. The images are encrypted on the build host; only their
28-byte key blobs go to the platform. The AES-128 product key goes separately
to the factory station. Every operation runs with HSM-protected keys,
role-based access, an approval gate and a full audit trail.

## The LAAVAT product

The product is created from the template below with `signing-tool` and
approved.

```bash
printf '%s' "$TOKEN" | signing-tool -c -t @- -a https://app.laavat.io/<tenant>/api/v1 \
    product add -T rpi4-gateway-product.json
```

```json
{
    "name": "Raspberry Pi 4 gateway",
    "description": "Secure boot (RSA-2048 digest signing of boot.img for rpi-eeprom), AES-128 key wrapping for locally encrypted images, and a signed update manifest (ECDSA P-256)",
    "productType": "Production",
    "enabled": true,
    "caInfo": [],
    "rndKeys": [],
    "productOperations": [
        {
            "name": "RPi boot image signing",
            "description": "RSA-2048 PKCS#1 v1.5 / SHA-256 over the boot.img digest; the public key goes into the EEPROM config",
            "operationType": "DigestSigning",
            "token": { "name": "RPi boot signing key", "description": "RSA-2048", "keyType": "RSA2048" },
            "approvalRule": {
                "name": "Two-person", "description": "A member of the approval group releases every boot signing request",
                "allowedGroups":  ["<release-group-uuid>"],
                "approvalGroups": ["<approver-group-uuid>"],
                "blanketGroups":  []
            }
        },
        {
            "name": "Update key wrapping",
            "description": "Wraps the 28-byte data-key + nonce blob of a locally encrypted image under the product AES-128 key (AES-GCM-128); the wrapped packet travels in the manifest",
            "operationType": "EncryptImageWithAES",
            "token": { "name": "Product AES-128 key", "description": "AES-128, extractable: provisioned into the ATECC608C at manufacturing", "keyType": "AES128", "extractable": true },
            "approvalRule": {
                "name": "Blanket", "description": "Unattended wrapping from the build pipeline",
                "allowedGroups":  ["<release-group-uuid>"],
                "approvalGroups": ["<release-group-uuid>"],
                "blanketGroups":  ["<release-group-uuid>"]
            }
        },
        {
            "name": "Update manifest signing",
            "description": "ECDSA P-256 / SHA-256 detached signature over the update manifest (JWS ES256)",
            "operationType": "DigestSigning",
            "token": { "name": "Update manifest key", "description": "ECDSA P-256", "keyType": "ECDSAP256" },
            "approvalRule": {
                "name": "Blanket", "description": "Unattended signing from the build pipeline",
                "allowedGroups":  ["<release-group-uuid>"],
                "approvalGroups": ["<release-group-uuid>"],
                "blanketGroups":  ["<release-group-uuid>"]
            }
        }
    ]
}
```

The boot key is RSA-2048 because the EEPROM bootloader verifies nothing else.
The product key is AES-128 because the ATECC608C is, and it is marked
`extractable` so the factory can fetch it wrapped. The boot signing operation
requires a second person: its public key sits in the EEPROM of every device,
and it is used only when the boot image or configuration changes. The wrapping
and manifest operations are blanket-approved for the release group, which is
how every ordinary release runs unattended.

## The three keys and where their other halves go

Each key's public half, or the product key itself, is placed on the device
once: at manufacturing, or inside the signed boot image.

| Key (operation) | Used for | What the device holds | Placed by |
| --- | --- | --- | --- |
| RSA-2048 (`DigestSigning`) | `boot.sig`, `boot.conf.sig` | The public key inside the signed EEPROM image, and its SHA-256 hash in the SoC's OTP | Factory, once: `update-pieeprom.sh -k` builds the signed EEPROM image, then the usbboot recovery flow with `program_pubkey=1` programs the hash. Irreversible |
| ECDSA P-256 (`DigestSigning`) | `update.jws` | The public key, inside the initramfs of the signed `boot.img` | The build; it arrives with every signed boot image |
| AES-128 (`EncryptImageWithAES`) | Wraps the data key and nonce of every update image (the 56-byte packet in the manifest) | The key itself, in a slot of the ATECC608C; it never leaves the chip | Factory station: fetched from the platform wrapped for the station's registered client, written into the secure element |

The product key is one per product line: every device of the line shares it,
and a new line gets new keys. The images themselves are encrypted under keys
that exist for one release only.

The product key never leaves the HSM in the clear. It is released only wrapped
for a *client* registered on the product: an EC key pair whose private half the
factory station holds, bound to the identity that fetches the key. Registering
the station is a one-time step, approved like any other request — see
[Clients](https://docs.laavat.io/usage/clientguide/clients/#exporting-aes-keys-with-a-securityengineer-client)
for the wrapping scheme.

```bash
openssl ecparam -name prime256v1 -genkey -noout -out station.key
openssl ec -in station.key -pubout -out station.pub

printf '%s' "$TOKEN" | signing-tool -c -t @- -a https://app.laavat.io/<tenant>/api/v1 \
    client add -N "factory-station" -D "unwraps the product key for ATECC608C provisioning" -K station.pub \
    -U "appid:<station-identity-app-id>" -T SecurityEngineer -p <product-uuid>
Client Add request sent. Request ID: <request-uuid> state: ApprovalRequired

printf '%s' "$TOKEN" | signing-tool -c -t @- -a https://app.laavat.io/<tenant>/api/v1 \
    client approve -I <request-uuid>
Client approved
```

## Signing and wrapping the release artifacts

The commands below are what the pipeline runs for one release. Every request
follows the same pattern: submit, wait until the request is *Ready*, download
the result. Where an operation carries an approval rule, the request waits in
*ApprovalRequired* until an approver releases it.

> **Note: Token handling**
> Every command below reads the bearer token from `$TOKEN` on stdin with
> `-t @-` and targets the tenant's API with `-a`. A config file created with
> `config-init` works equally well — see
> [secure token handling](https://docs.laavat.io/democlient/usage/#providing-the-token).

```bash
export TOKEN=<bearer-token>

# imagesigning get --wait polls until the request is Ready (state 16), then downloads the result.
# It keeps polling through InProcess (8) and ApprovalRequired (2) for up to --wait-timeout seconds
# (default 1800) and stops on Rejected (32) or Failed (64). get prints the request record first;
# the listings below show only the download lines.
```

### Boot image and configuration — `DigestSigning`, RSA-2048

The boot image and the bootloader configuration are each signed by sending
their SHA-256 to the boot signing operation. The `.sig` file the bootloader
expects holds that hash, a timestamp and the signature in hex. See
[digest signing](https://docs.laavat.io/exampleflows/detachedsigning/) for the operation in
detail.

```bash
DIGEST=$(sha256sum boot.img | cut -d' ' -f1 | xxd -r -p | base64 -w0)

printf '%s' "$TOKEN" | signing-tool -c -t @- -a https://app.laavat.io/<tenant>/api/v1 \
    imagesigning add DigestSigning -P <product-uuid> --operid <boot-signing-operation-uuid> \
    -p "$DIGEST" -H SHA256 -N "rpi-boot.img" -D "boot image, release 1.4.0"
Image signing request sent. Request ID: <request-uuid> state: Created

# the boot signing operation carries an approval rule, so the request moves to ApprovalRequired;
# the pipeline waits here until a member of the approval group releases it
printf '%s' "$TOKEN" | signing-tool -c -t @- -a https://app.laavat.io/<tenant>/api/v1 \
    imagesigning get -I <request-uuid> --wait -O boot.sig.bin

# the approver, with their own token, from their own session (or from the approvals view in the UI)
printf '%s' "$APPROVER_TOKEN" | signing-tool -c -t @- -a https://app.laavat.io/<tenant>/api/v1 \
    imagesigning approve -I <request-uuid>
Image signing request <request-uuid> approved

# ... and the pipeline's wait returns
Downloading signed binary to: boot.sig.bin
File Downloaded                       # 256 raw bytes
```

Optionally, `rpi-eeprom-digest`, Raspberry Pi's own tool, can write the `.sig`
file for you. Its `-H` option names a script that it calls as
`script -a rsa2048-sha256 <file>` and that must print the RSA signature over
the file's SHA-256 as hex. A script that makes the request above and prints the
downloaded signature with `xxd -p` is all it takes.

```bash
rpi-eeprom-digest -H sign-with-laavat.sh -i boot.img  -o boot.sig
rpi-eeprom-digest -H sign-with-laavat.sh -i boot.conf -o boot.conf.sig
```

### Update images: encrypt locally, wrap the key — `EncryptImageWithAES`, AES-GCM-128

The image is encrypted on the build host, not on the platform. The build
generates a fresh AES-128 data key (16 bytes) and a 12-byte nonce, encrypts the
image with them, and packs key ‖ nonce into 28 bytes. That blob is the only
thing sent to LAAVAT: the wrapping operation encrypts it under the product key
with AES-GCM-128 and returns 56 bytes — a nonce of its own (12) ‖ the wrapped
blob (28) ‖ the tag (16), the layout described in
[AES image encryption](https://docs.laavat.io/usage/signing-encryption/aes-encryption/#gcm-modes-aes-gcm-aes-gcm-pskek).
Encrypting locally is also what keeps an image of any size clear of the
single-shot limit on GCM sealed inside the HSM.

The packet travels in the manifest; on the device the ATECC608C, which holds
the product key, opens it and hands the data key and nonce to the updater. The
additional authenticated data (AAD) on the image is
`<product>/<version>/<image name>`: not secret, but bound into the tag, so a
ciphertext cannot be moved to another manifest entry or another version. The
wrapping request itself carries no AAD.

```bash
# on the build host: per-image key and nonce, image encrypted locally (any size), 28-byte blob
python3 - firmware.img firmware.img.enc firmware.blob "rpi4-gateway/1.4.0/firmware.img" <<'PY'
import os, sys, pathlib
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
src, dst, blob, aad = sys.argv[1:]; dk, nonce = os.urandom(16), os.urandom(12)
pathlib.Path(dst).write_bytes(nonce + AESGCM(dk).encrypt(nonce, pathlib.Path(src).read_bytes(), aad.encode()))
pathlib.Path(blob).write_bytes(dk + nonce)                       # 28 bytes: data key 16 ‖ nonce 12
PY

# the platform wraps the blob under the product AES-128 key
printf '%s' "$TOKEN" | signing-tool -c -t @- -a https://app.laavat.io/<tenant>/api/v1 \
    imagesigning add EncryptImageWithAES -P <product-uuid> --operid <key-wrapping-operation-uuid> \
    -F firmware.blob -M AES-GCM-128 -N "rpi-firmware-1.4.0-key" -D "wrapped data key, firmware 1.4.0"
Image signing request sent. Request ID: <request-uuid> state: Created

printf '%s' "$TOKEN" | signing-tool -c -t @- -a https://app.laavat.io/<tenant>/api/v1 \
    imagesigning get -I <request-uuid> --wait -O firmware.wrapped
Downloading signed binary to: firmware.wrapped
File Downloaded                       # 56 bytes = nonce 12 ‖ wrapped blob 28 ‖ tag 16; base64 into the manifest as wrapped_key
```

### Update manifest — `DigestSigning`, ECDSA P-256

The manifest is the manufacturer's own format; the platform signs its digest.
The example below is a compact JWS signed with ES256, written after the
payloads are encrypted so that it can list their hashes. Version and
compatibility give anti-rollback and hardware binding; expiry limits how long a
captured manifest stays usable.

```json
{
  "product": "rpi4-gateway",
  "compatible": "rpi4-gateway-v1",
  "version": "1.4.0",
  "issued":  <unix time>,
  "expires": <unix time, e.g. issued + 30 days>,
  "payloads": [
    { "name": "firmware.img", "file": "firmware.img.enc", "size": <bytes>, "sha256": "<hex of the ciphertext>",
      "encryption": "AES-128-GCM-WRAPPED", "wrapped_key": "<base64 of the 56-byte packet>",
      "aad": "rpi4-gateway/1.4.0/firmware.img" }
  ]
}
```

```bash
# signing input = base64url(header) "." base64url(manifest); header = {"alg":"ES256","typ":"JOSE","kid":"<manifest-signing-operation-uuid>"}
DIGEST=$(sha256sum update.input | cut -d' ' -f1 | xxd -r -p | base64 -w0)

printf '%s' "$TOKEN" | signing-tool -c -t @- -a https://app.laavat.io/<tenant>/api/v1 \
    imagesigning add DigestSigning -P <product-uuid> --operid <manifest-signing-operation-uuid> \
    -p "$DIGEST" -H SHA256 --encoding RAW -N "rpi-manifest-1.4.0" -D "update manifest, release 1.4.0"
Image signing request sent. Request ID: <request-uuid> state: Created

printf '%s' "$TOKEN" | signing-tool -c -t @- -a https://app.laavat.io/<tenant>/api/v1 \
    imagesigning get -I <request-uuid> --wait -O manifest.sig
# --encoding RAW returns the 64-byte r‖s JWS expects; the pipeline appends base64url(r‖s)
# to the signing input and writes update.jws
```

The release then consists of `boot.img` + `boot.sig` (and the signed
configuration) for the factory or an EEPROM update, and the encrypted payloads
together with `update.jws` for the field.

## What goes on the device

| Trust anchor | Source on the platform | Where it goes |
| --- | --- | --- |
| Boot public key | `product getpubkey` for the boot signing operation | Embedded in the signed EEPROM image; its hash programmed into OTP by the usbboot secure-boot-recovery flow (`program_pubkey=1`). Irreversible |
| Manifest public key | `product getpubkey` for the manifest signing operation | Initramfs of `boot.img`, read by the updater; protected by the boot signature |
| Product key (AES-128) | `secrets getkeys` with the station's registered client key | A slot of the ATECC608C, written once by the factory station; the chip never reveals it again |

### Public keys

```bash
printf '%s' "$TOKEN" | signing-tool -c -t @- -a https://app.laavat.io/<tenant>/api/v1 \
    product getpubkey -P <product-uuid> --operid <boot-signing-operation-uuid> -O boot-pub.pem
public key written to: boot-pub.pem

printf '%s' "$TOKEN" | signing-tool -c -t @- -a https://app.laavat.io/<tenant>/api/v1 \
    product getpubkey -P <product-uuid> --operid <manifest-signing-operation-uuid> -O manifest-pub.pem
public key written to: manifest-pub.pem
```

### Product key, at the factory station

```bash
printf '%s' "$TOKEN" | signing-tool -c -t @- -a https://app.laavat.io/<tenant>/api/v1 \
    secrets getkeys -P <product-uuid> -C station.key --cloudhsm -O product.keys
product.keys0.bin (name='Update key wrapping' type=AES128 ...): symmetric key (16 bytes)
# only a registered client can do this; the key is written into the ATECC608C
```

`--cloudhsm` selects the key wrap used by a CloudHSM-backed production tenant;
a tenant on a software HSM omits it. See
[Clients](https://docs.laavat.io/usage/clientguide/clients/#exporting-aes-keys-with-a-securityengineer-client).

## Verifying before shipping

The boot signatures can be checked on the build host with the vendor tool and
the public key fetched from the platform:

```text
rpi-eeprom-digest -k boot-pub.pem -i boot.img  -v boot.sig
Verified OK
rpi-eeprom-digest -k boot-pub.pem -i boot.conf -v boot.conf.sig
Verified OK
```

The manifest signature is raw `r‖s`; convert it to DER, then check it with the
manifest public key over the same signing input that was hashed for the request:

```bash
python3 -c 'import sys; from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature as e; s=open("manifest.sig","rb").read(); h=len(s)//2; sys.stdout.buffer.write(e(int.from_bytes(s[:h],"big"), int.from_bytes(s[h:],"big")))' > manifest.sig.der
```

```text
openssl dgst -sha256 -verify manifest-pub.pem -signature manifest.sig.der update.input
Verified OK
```

Each image is checked the way the device does it: open its packet with the
product key, then decrypt the image with the data key under the manifest's AAD.
On the build host the product key comes from the same `secrets getkeys` export
the factory uses.

## Mapping to the CRA

| Annex I requirement | Covered by |
| --- | --- |
| Integrity of the software and its configuration is protected; unauthorised modification is detected | Signed-boot mode of the EEPROM bootloader; signed manifest with payload hashes; AES-GCM tags |
| Security updates are provided and can be installed securely | The signed manifest with compatibility, anti-rollback and expiry; payloads verified before installation |
| Confidentiality of data, including data at rest | Update images encrypted under per-image keys whose wrapped packets open only inside a secure element holding the product key |
| Keys are protected; access is controlled and logged | All three keys in the HSM; the product key leaves it only wrapped for a registered factory client; per-operation roles, approval gates and audit trail on the platform |
| Products are identifiable and can be onboarded securely | IDevID and LDevID issuance — see [Trusted device identities](https://docs.laavat.io/solutions/trusted-device-identities/) |

Building a device this way does not by itself make a product CRA compliant.
The [Annex I mapping](https://docs.laavat.io/cra-compliance/annex-i-mapping/) sets out which
requirements the platform supports and which remain yours.

## Related

- [Digest signing example flow](https://docs.laavat.io/exampleflows/detachedsigning/) — the
  `DigestSigning` operation behind the boot and manifest signatures
- [AES image encryption](https://docs.laavat.io/usage/signing-encryption/aes-encryption/) — the
  `EncryptImageWithAES` modes and the GCM packet layout
- [Clients](https://docs.laavat.io/usage/clientguide/clients/) — registering the factory station
  and the key-wrapping scheme
- [Product management](https://docs.laavat.io/usage/productguide/product/) — templates,
  operations and approval rules
- [CRA-ready i.MX8M gateways](https://docs.laavat.io/solutions/cra-ready-imx8m/) — the same goals with RAUC
  bundles and a certificate chain instead of a custom manifest
- [Encrypted RAUC updates](https://docs.laavat.io/solutions/encrypted-rauc-per-device-keys/) — encrypted
  updates with a key per device instead of one product key
- [Security and architecture](https://docs.laavat.io/security/) — where the keys live and
  who can reach them

## References

- Raspberry Pi:
  [secure boot](https://www.raspberrypi.com/documentation/computers/raspberry-pi.html#secure-boot)
  and
  [usbboot secure-boot-recovery](https://github.com/raspberrypi/usbboot/blob/master/secure-boot-recovery/README.md)
  — EEPROM configuration, `rpi-eeprom-digest`, OTP key programming
- [RFC 7515, JSON Web Signature](https://www.rfc-editor.org/rfc/rfc7515) and
  [RFC 7518](https://www.rfc-editor.org/rfc/rfc7518) (ES256)
- [RFC 5116, authenticated encryption](https://www.rfc-editor.org/rfc/rfc5116)
- [Regulation (EU) 2024/2847, Cyber Resilience Act](https://eur-lex.europa.eu/eli/reg/2024/2847/oj),
  Annex I
