> Markdown version of https://docs.laavat.io/usage/signing-encryption/aes-encryption/ from the LAAVAT PKI and Signing Platform documentation. All pages: https://docs.laavat.io/llms.txt

# AES Image Encryption

> **Tip: Encrypting images larger than the HSM limit?**
> One pattern is to encrypt each image locally under a fresh data key and
> send only the 28-byte key and nonce to `EncryptImageWithAES` for wrapping
> under the product key. [Building CRA-ready Raspberry Pi gateways](https://docs.laavat.io/solutions/cra-ready-raspberry-pi/)
> shows it end to end, with the product key held in a secure element.

The `EncryptImageWithAES` operation encrypts an arbitrary image payload with one of eight supported AES modes. It covers the CBC variants and AES-GCM
(authenticated encryption) plus optional **Additional Authenticated Data (AAD)** binding.

The operation is exposed through the standard `imagesigning` flow:

1. The reference client uploads the plaintext payload to the platform's S3 staging bucket via a presigned URL.
2. It then issues an `imagesigning add EncryptImageWithAES` request that references the upload, the target product
   operation, the chosen `aesMode`, and (optionally) `aad`.
3. After approval, the platform encrypts the payload inside the HSM-backed crypto service. The IV used by CBC modes is
   the product's KEK-IV — a 16-byte value generated by the platform at product creation time and stored as a product
   config item. Callers do not supply it.
4. The client polls and downloads the resulting ciphertext, plus any unwrapping material the chosen mode produces in
   `service_provided_parameters` (sealed key, sealed-key IV, IV, AAD).

## Prerequisites

- A product that has been created with an operation of type `EncryptImageWithAES`. The operation is keyed by an AES
  symmetric key generated and held inside the HSM at product creation. See the
  [Product Configuration Guide](https://docs.laavat.io/usage/productguide/product/) for how to add the operation to a product.
- The product / operation IDs.
- A user token and, when the operation is configured to require approval, an approver token.
  See the [Authentication chapter](https://docs.laavat.io/api/authentication/).
- For CBC modes, the device or downstream pipeline that decrypts the result must know the product's KEK-IV. It is
  retrieved with a registered **ProductionPC** client through the product secrets endpoint (`signing-tool secrets get`)
  — the value is returned alongside `SRKHash` / `SRKTable` as `KEKIV` (also surfaced in the product config items as
  `KEK-IVCustomEncryption`). See [Clients](https://docs.laavat.io/usage/clientguide/clients/) for the registration and retrieval flow. The
  platform reuses the same KEK-IV across every `EncryptImageWithAES` request for the product, so this fetch only needs
  to happen once per provisioned device class.

## Supported AES modes

The `aesMode` field in the request determines which algorithm the platform uses, the key length, and whether the data
key is wrapped (PSKEK) or used directly. If the field is omitted, the platform falls back to **`AES-CBC-256-PSKEK`**.

| `aesMode`              | Algorithm           | Key length | Authenticated | Per-Session KEK? | When to use                                                                 |
|------------------------|---------------------|------------|---------------|------------------|-----------------------------------------------------------------------------|
| `AES-CBC-128`          | AES-128-CBC         | 128 bit    | No            | No               | Compatibility with legacy bootloaders that decrypt directly with the product key. |
| `AES-CBC-256`          | AES-256-CBC         | 256 bit    | No            | No               | Same as above with a 256-bit key.                                           |
| `AES-CBC-128-PSKEK`    | AES-128-CBC + KW    | 128 bit    | No            | Yes              | Each request uses a fresh data key; the data key is wrapped with the product KEK and returned as `sealed_key`. |
| `AES-CBC-256-PSKEK`    | AES-256-CBC + KW    | 256 bit    | No            | Yes              | Default. Unique-per-payload key reduces blast radius of a leaked ciphertext. |
| `AES-GCM-128`          | AES-128-GCM         | 128 bit    | Yes           | No               | Authenticated encryption with a long-lived product key (e.g. firmware sealed to a single device class). |
| `AES-GCM-256`          | AES-256-GCM         | 256 bit    | Yes           | No               | Same as above with a 256-bit key.                                           |
| `AES-GCM-128-PSKEK`    | AES-128-GCM + KW    | 128 bit    | Yes           | Yes              | Authenticated encryption with a unique-per-request data key. AAD covers the outer payload only — see [AAD and PSKEK](https://docs.laavat.io/usage/signing-encryption/aes-encryption/#aad-and-pskek) below. |
| `AES-GCM-256-PSKEK`    | AES-256-GCM + KW    | 256 bit    | Yes           | Yes              | Recommended default for new firmware encryption flows.                      |

> **Note: PSKEK explained**
> A `*-PSKEK` mode generates a fresh AES data key per request, encrypts the payload with that data key, then wraps the
> data key with the product's KEK (the long-lived key in the HSM). For CBC-PSKEK the wrap is AES-CBC under the
> product's KEK-IV; for GCM-PSKEK the wrap is AES-GCM with **no AAD** on the inner wrap (caller AAD only binds the
> outer payload — see [AAD and PSKEK](https://docs.laavat.io/usage/signing-encryption/aes-encryption/#aad-and-pskek)). The platform returns the wrapped key in `sealed_key` so the
> device can recover the data key with its KEK and then decrypt the payload.
> Non-PSKEK modes encrypt the payload directly with the product key, so no `sealed_key` is returned and the device
> must already share that product key.

## IV and nonce handling

Callers do not supply IVs or nonces to `EncryptImageWithAES`. The CBC and GCM modes get them from different sources,
and the response fields that surface them mean different things in each case — read on before wiring up a decrypter.

### CBC modes (`AES-CBC-*`, `AES-CBC-*-PSKEK`)

- The IV is **16 bytes** (the AES block size) and comes from the product's **KEK-IV**, generated by the platform at
  product creation and persisted as the `KEK-IVCustomEncryption` product config item. It is the same value for every
  `EncryptImageWithAES` request issued against this product.
- The KEK-IV is fetched out of the platform with a `ProductionPC`-typed client via `signing-tool secrets get`
  (returned as the `KEKIV` field in the decrypted product secrets). Devices need it to decrypt the result.
- For `AES-CBC-*-PSKEK` the platform additionally generates a **random per-request 16-byte payload IV**. The KEK-IV is
  used only to wrap the ephemeral data key under the product KEK; the payload itself is encrypted with the ephemeral
  data key under the random per-request IV.
    - The top-level `IV` in `service_provided_parameters` is the product **KEK-IV** (used to wrap the data key).
    - The `sealed_key_iv` field is the **per-request payload IV** (used to encrypt the payload with the ephemeral data
      key).
    - Devices need both: `IV` to unwrap `sealed_key` against the product KEK, then `sealed_key_iv` to decrypt the
      payload with the unwrapped data key.

> **Warning: Same KEK-IV across all CBC encryptions**
> For the non-PSKEK CBC modes (`AES-CBC-128`, `AES-CBC-256`) the payload is encrypted directly with the product key
> under the product KEK-IV. Because both are constant per product, encrypting the same plaintext twice produces the
> same ciphertext (and identical-prefix plaintexts produce identical-prefix ciphertexts). This is acceptable for
> one-shot firmware images, but if your threat model needs unique-per-payload ciphertexts use a `*-PSKEK` mode (which
> rotates the data key and the payload IV per request) or a GCM mode.

### GCM modes (`AES-GCM-*`, `AES-GCM-*-PSKEK`)

- GCM uses a **12-byte (96-bit) nonce**. This is the only nonce length AWS CloudHSM supports.
- The platform always generates the nonce. The product KEK-IV is **not** used for GCM modes. When the product key is
  HSM-backed the nonce is generated inside the HSM (CloudHSM FIPS requirement); when the key is software-protected the
  nonce is drawn from `crypto/rand`.
- The encrypted payload file is laid out as `nonce ‖ ciphertext ‖ tag` (12 + N + 16 bytes). The nonce is prepended
  for self-contained recovery; readers must slice it off before passing the rest to their GCM implementation.
- The top-level `IV` in `service_provided_parameters` is the same 12-byte payload nonce, surfaced for visibility.
- For `AES-GCM-*-PSKEK` the `sealed_key` blob is itself a self-contained `nonce ‖ ciphertext ‖ tag` of the wrapped data
  key (under the product KEK). `sealed_key_iv` is therefore **empty** for GCM-PSKEK — the wrap nonce lives inside
  `sealed_key`.

> **Warning: Single-shot GCM size limits**
> AES-GCM cannot be safely streamed through a block-cipher pipeline the way CBC can, so the platform encrypts the
> full payload in a single `Seal` call, and the limit depends on where that call runs. **Sealed in software**, for
> the `AES-GCM-*-PSKEK` modes with any product key and for `AES-GCM-*` with a software-protected product key: the
> limit is **4 GiB**. **Sealed inside the HSM**, for `AES-GCM-*` with an HSM-backed product key: AWS CloudHSM
> seals at most **16 248 bytes** in one `CKM_AES_GCM` operation, and the AAD counts against those bytes, rounded
> up to a multiple of 16. Without AAD the payload can be 16 248 bytes; with an AAD of 1 to 16 bytes it can be
> 16 232 bytes, with 17 to 32 bytes 16 216 bytes, and so on.
> A request whose plaintext exceeds its limit fails and reports the error status `aesGcmPayloadTooLarge`. To
> encrypt a large image under an HSM-backed key use an `AES-GCM-*-PSKEK` mode or a CBC mode, or split the payload
> into chunks that the firmware reassembles after decryption. The CBC modes stream the payload and have no
> platform-side size limit. In practice the upload sets the ceiling before the 4 GiB limit does: the payload
> reaches S3 as Base64 text through a single presigned URL, and S3 accepts at most 5 GiB per upload, which is
> about 3.75 GiB of raw data.

## Additional Authenticated Data (AAD)

AAD lets you bind the ciphertext to a piece of context (a device serial number, a firmware version, an image header)
without encrypting it. On decryption the device must supply the exact same AAD or the GCM tag verification fails.

- `--aad <base64>` is only honoured when `aesMode` is one of the `AES-GCM-*` variants. For any CBC mode the field is
  ignored.
- The value must be Base64-encoded raw bytes. Keep AAD small enough that the device firmware can reproduce it
  byte-for-byte at decryption time. With a plain `AES-GCM-*` mode and an HSM-backed product key the AAD also takes
  room from the payload — see [the size limits](https://docs.laavat.io/usage/signing-encryption/aes-encryption/#gcm-modes-aes-gcm-aes-gcm-pskek).
- If `--aad` is omitted in a GCM mode, the platform encrypts with empty AAD (zero bytes). Decryption then also requires
  empty AAD.

### AAD and PSKEK

For the `AES-GCM-*-PSKEK` modes the AAD is bound **only to the outer payload**. The PSKEK-wrapped inner data key is
not covered by the AAD — the data key wrap is a separate step performed by the HSM. In practice this means:

- An attacker who can substitute a different `sealed_key` cannot do so silently: decrypting the payload with the wrong
  data key will still cause GCM tag verification to fail.
- AAD is **not** a substitute for transport integrity over the `sealed_key` field — keep delivering both in the same
  signed manifest the device validates.

## Reference client usage

The reference client subcommand is `imagesigning add EncryptImageWithAES`:

```bash
(venv) $ signing-tool imagesigning add EncryptImageWithAES -h
usage: signing-tool imagesigning add EncryptImageWithAES [-h] -P P --operid OPERID -N N -D D -F F -M M [--aad AAD]

options:
  -h, --help     show this help message and exit
  -P P           Product ID
  --operid OPERID  Product operation ID
  -N N           Request name
  -D D           Request description
  -F F           File to encrypt
  -M M           Encryption mode, (AES-CBC-128, AES-CBC-256, AES-CBC-128-PSKEK, AES-CBC-256-PSKEK,
                 AES-GCM-128, AES-GCM-256, AES-GCM-128-PSKEK, AES-GCM-256-PSKEK)
  --aad AAD      Additional Authenticated Data, base64 encoded (GCM modes only)
```

The reference client does not expose an `--iv` flag for `EncryptImageWithAES`. The CBC IV is the product's KEK-IV
managed by the platform (see [IV and nonce handling](https://docs.laavat.io/usage/signing-encryption/aes-encryption/#iv-and-nonce-handling)); the GCM nonce is generated by the platform on
every request. The older `EncryptImageWithAESEscrowCustom` operation still has `--iv`, but it should not be used for
new flows.

### CBC with PSKEK (default)

```bash
(venv) $ signing-tool -n test.ini \
    imagesigning add EncryptImageWithAES \
    -P b508d600-977a-46f4-9070-5cf56646bae1 \
    --operid 8736e553-d8d3-442a-a56b-4d243cf22585 \
    -N firmware-v1.6.44 -D "encrypt firmware build 1.6.44" \
    -F firmware.bin -M AES-CBC-256-PSKEK

# Approve if the operation requires it
(venv) $ signing-tool -n approver.ini \
    imagesigning approve -I <request-id>

# Download the ciphertext + sealed key material
(venv) $ signing-tool -n test.ini \
    imagesigning get -I <request-id> -O firmware.bin.enc
```

### GCM without PSKEK, with AAD

In this example the firmware version string (`v1.6.44`) is bound to the ciphertext as AAD. The device must supply the
exact same bytes when it decrypts, otherwise the GCM tag check fails.

```bash
(venv) $ AAD_B64=$(printf 'v1.6.44' | base64)

(venv) $ signing-tool -n test.ini \
    imagesigning add EncryptImageWithAES \
    -P b508d600-977a-46f4-9070-5cf56646bae1 \
    --operid 8736e553-d8d3-442a-a56b-4d243cf22585 \
    -N firmware-v1.6.44-gcm -D "GCM-256, AAD=version" \
    -F firmware.bin -M AES-GCM-256 \
    --aad "$AAD_B64"
```

### GCM with PSKEK and AAD

```bash
(venv) $ signing-tool -n test.ini \
    imagesigning add EncryptImageWithAES \
    -P b508d600-977a-46f4-9070-5cf56646bae1 \
    --operid 8736e553-d8d3-442a-a56b-4d243cf22585 \
    -N firmware-v1.6.44-gcm-pskek -D "GCM-256-PSKEK with header AAD" \
    -F firmware.bin -M AES-GCM-256-PSKEK \
    --aad "$AAD_B64"
```

### Fetching the product KEK-IV (for CBC decryption)

For any of the CBC modes, the device or downstream pipeline that decrypts the result needs the product's KEK-IV. Fetch
it once with a registered `ProductionPC` client:

```bash
(venv) $ signing-tool -n test.ini secrets get \
    -P b508d600-977a-46f4-9070-5cf56646bae1 \
    -C client.private -O /tmp/prod.json

(venv) $ jq -r '.productConfigItems[] | select(.name=="KEK-IVCustomEncryption") | .value' < /tmp/prod.json
ZX75HIzTir2AsAst0BbCMw==
```

This is the same value that arrives back as the top-level `IV` in the encryption response. See
[Clients](https://docs.laavat.io/usage/clientguide/clients/) for the full registration flow.

## Response payload

The download endpoint returns the ciphertext through a presigned S3 URL plus the unwrapping material in
`payload.service_provided_parameters`. The exact set of fields depends on the mode:

| Mode family       | `IV` (top level) — meaning                                         | `sealed_key`                                  | `sealed_key_iv`                                  | Encrypted file layout                |
|-------------------|--------------------------------------------------------------------|-----------------------------------------------|--------------------------------------------------|--------------------------------------|
| `AES-CBC-*`       | Product KEK-IV used to encrypt the payload (16 B; constant per product) | --                                            | --                                               | `ciphertext` (PKCS#7-padded)         |
| `AES-CBC-*-PSKEK` | Product KEK-IV used to **wrap the data key** with the product KEK (16 B) | data key wrapped under KEK (CBC, 16 / 32 B)   | Per-request 16 B payload IV                      | `ciphertext` (PKCS#7-padded)         |
| `AES-GCM-*`       | 12 B payload nonce (informational; same as the prefix of the file) | --                                            | --                                               | `nonce ‖ ciphertext ‖ tag` (12 + N + 16 B) |
| `AES-GCM-*-PSKEK` | 12 B payload nonce (informational; same as the prefix of the file) | data key wrapped under KEK as `nonce ‖ ct ‖ tag` | empty (the wrap nonce lives inside `sealed_key`) | `nonce ‖ ciphertext ‖ tag` (12 + N + 16 B) |

For the GCM variants the encrypted file is **self-contained**: the 12-byte nonce is prepended to the ciphertext and
the 16-byte authentication tag is appended. Readers should split the file as
`nonce = file[:12]`, `tag = file[len-16:]`, `ct = file[12:len-16]` before calling their GCM implementation.

A truncated example response for an `AES-CBC-256-PSKEK` request is:

```json
{
    "id": "22e2e069-335c-4e8d-8706-675d06723ef5",
    "id_product": "b508d600-977a-46f4-9070-5cf56646bae1",
    "id_product_operation": "8736e553-d8d3-442a-a56b-4d243cf22585",
    "name": "firmware-v1.6.44",
    "payload": {
        "id": "6ea19a17-284a-4170-84e4-ea0368330424",
        "name": "firmware.bin",
        "s3_url": "<presigned-S3-URL-for-the-ciphertext>",
        "service_provided_parameters": [
            {
                "aes_mode": "AES-CBC-256-PSKEK"
            },
            {
                "sealed_key": "NhgPZDXsZyG89hhPUAOAYw=="
            }
        ]
    },
    "state": 16
}
```

For an `AES-GCM-256-PSKEK` request the same response carries a self-contained sealed key blob and an empty
`sealed_key_iv`:

```json
{
    "payload": {
        "service_provided_parameters": [
            {
                "aes_mode": "AES-GCM-256-PSKEK"
            },
            {
                "sealed_key": "<base64 of 12-byte nonce ‖ wrapped-key ciphertext ‖ 16-byte tag>"
            }
        ]
    },
    "state": 16
}
```

The full set of optional response fields is documented in the
[Encryption response payload reference](https://docs.laavat.io/usage/signing-encryption/encryption-payload/) page and in the API specification under
`OptionalParameters`.

## Audit trail

Each successful `EncryptImageWithAES` run produces an `OperationCompleted` audit event tagged with the product ID and
the matching `productOperationType`. See [Audit Events](https://docs.laavat.io/appendixes/audit-events/) for the field schema.

## Comparison with `EncryptImageWithAESEscrowCustom`

`EncryptImageWithAESEscrowCustom` predates `EncryptImageWithAES` and is limited to the four `AES-CBC-*` modes. It is
still available for backwards compatibility, but new product operations should use `EncryptImageWithAES` so that
GCM modes and AAD binding are available without re-creating the product.
