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.
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 |
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.
The manufacturer brings the build artifacts; LAAVAT returns them signed or encrypted, together with the material the device needs to verify them.
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.
printf '%s' "$TOKEN" | signing-tool -c -t @- -a https://app.laavat.io/<tenant>/api/v1 \
product add -T rpi4-gateway-product.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 for the wrapping scheme.
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.
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.
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 for the operation in
detail.
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.
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. 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.
# 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.
{
"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" }
]
}
# 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 -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.der
# the platform returns the ECDSA signature DER-encoded; JWS wants the raw 64-byte r‖s,
# so the pipeline converts it, 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¶
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¶
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.
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:
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 checked with the manifest public key, over the same signing input that was hashed for the request:
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 |
Building a device this way does not by itself make a product CRA compliant. The Annex I mapping sets out which requirements the platform supports and which remain yours.
Related¶
- Digest signing example flow — the
DigestSigningoperation behind the boot and manifest signatures - AES image encryption — the
EncryptImageWithAESmodes and the GCM packet layout - Clients — registering the factory station and the key-wrapping scheme
- Product management — templates, operations and approval rules
- CRA-ready i.MX8M gateways — the same goals with RAUC bundles and a certificate chain instead of a custom manifest
- Security and architecture — where the keys live and who can reach them
References¶
- Raspberry Pi:
secure boot
and
usbboot secure-boot-recovery
— EEPROM configuration,
rpi-eeprom-digest, OTP key programming - RFC 7515, JSON Web Signature and RFC 7518 (ES256)
- RFC 5116, authenticated encryption
- Regulation (EU) 2024/2847, Cyber Resilience Act, Annex I