> Markdown version of https://docs.laavat.io/exampleflows/aesgcmencryption/ from the LAAVAT PKI and Signing Platform documentation. All pages: https://docs.laavat.io/llms.txt

# AES-GCM-256 image encryption example

This example shows how to create a product that supports `EncryptImageWithAES`, encrypt an image with the
**AES-GCM-256** mode (with [Additional Authenticated Data](https://docs.laavat.io/usage/signing-encryption/aes-encryption/#additional-authenticated-data-aad)),
fetch the AES key out of the platform with a `SecurityEngineer` client, and decrypt the result offline.

This example uses the reference python client (`signing-tool`) for every step that talks to the platform, and the GUI
for the approvals.

The example is divided into the following stages:

- How to get an authentication token
- Creating the product from a template
- Approving the product and looking up the operation ID
- Encrypting an image with `AES-GCM-256` and an AAD
- Approving the encryption request
- Downloading the encrypted payload
- Exporting the AES key with a `SecurityEngineer` client
- Decrypting the payload offline

## Authentication

In order to use the reference python client a valid JWT token is needed. How to obtain one is explained in more detail
in the [authentication chapter.](https://docs.laavat.io/api/authentication/) A new token must be requested if the current token expires
(roughly 1 hour of validity).

```bash
(venv) $ az login --allow-no-subscriptions --only-show-error
A web browser has been opened at https://login.microsoftonline.com/organizations/oauth2/v2.0/authorize. Please continue the login in the web browser. If no web browser is available or if the web browser fails to open, use device code flow with `az login --use-device-code`.

[1]    N/A(tenant level account)  <redacted>  <redacted>

(venv) $ az account get-access-token --resource api://<redacted>
{
  "accessToken": "<redacted>",
  "expiresOn": "2026-05-04 15:18:10.000000",
  "expires_on": 1762339490,
  "subscription": "<redacted>",
  "tenant": "<redacted>",
  "tokenType": "Bearer"
}
(venv) $ az account get-access-token --resource api://<redacted> \
    --query accessToken -o tsv > user.token
```

Rather than putting the token on the command line, write it to a file and let `config-init` reference it. This
creates a `test.ini` that stores a `token_file` path (not the token itself):

```bash
(venv) $ config-init -n test.ini -t @user.token -a https://app.laavat.io/<CustomerName>/api/v1
```

The same flow is used to obtain the approver's token for a user in the approver group — write it to `approver.token`
and create an `approver.ini` the same way. The approver config is used later for approving the encryption request from
the command line if you prefer not to use the GUI. See [Reference Client › Usage](https://docs.laavat.io/democlient/usage/) for the
secure token-input forms (`-t @file`, `-t @-`).

## Create the product

Here is the used [product template](https://docs.laavat.io/producttemplates/product-aesenc-256.template). It defines a single
`EncryptImageWithAES` operation backed by an HSM-resident **AES-256** key. The `extractable: true` flag is what lets a
`SecurityEngineer` client later wrap the key out — without it, the key would be sealed inside the HSM and the
download could only be decrypted by the platform itself.

`product.json`:

```json
{
    "name": "$PRODUCTNAME",
    "description": "Product for testing image encryption",
    "productType": "RnD",
    "enabled": true,
    "caInfo": [],
    "rndKeys": [],
    "productOperations": [
        {
            "name": "Content encryption operation",
            "description": "Used to encrypt image and the AES key $PRODUCTNAME",
            "operationType": "EncryptImageWithAES",
            "approvalRule": {
                "name": "Test rule2",
                "description": "Rule used for testing for $PRODUCTNAME",
                "allowedGroups": [
                    "$LAAVAT_WRITERGROUP"
                ],
                "approvalGroups": [
                    "$LAAVAT_APPROVERGROUP"
                ],
                "blanketGroups": []
            },
            "token": {
                "name": "Key for image encryption",
                "description": "HSM AES key encryption and fusemap embedding. Extractable is set to true. For $PRODUCTNAME",
                "keyType": "AES256",
                "extractable": true
            }
        }
    ]
}
```

Replace `$PRODUCTNAME`, `$LAAVAT_WRITERGROUP`, and `$LAAVAT_APPROVERGROUP` with your values
(`envsubst < product.json.tmpl > product.json` is one way) before submitting.
More information about rules can be found from [approval rules.](https://docs.laavat.io/usage/productguide/product/#approval-rules)

Submit the product with the python tool:

```bash
(venv) $ signing-tool -n test.ini product add -T product.json
Product:
{
    "ca_info": [],
    "description": "Product for testing image encryption",
    "enabled": true,
    "external_request_id": null,
    "id": null,
    "name": "AES-GCM-256-Demo",
    "product_config_items": null,
    "product_operations": [
        {
            "approval_rule": {
                "allowed_groups": ["b716abb1-2e3b-47a9-bca5-a3669faa50a6"],
                "approval_groups": ["f65a3ea9-60db-47a4-9ad8-c6915735ec5f"],
                "blanket_groups": [],
                "description": "Rule used for testing for AES-GCM-256-Demo",
                "name": "Test rule2"
            },
            "ca_use_case": null,
            "description": "Used to encrypt image and the AES key AES-GCM-256-Demo",
            "id": null,
            "name": "Content encryption operation",
            "operation_type": "EncryptImageWithAES",
            "profile_id": null,
            "token": {
                "description": "HSM AES key encryption and fusemap embedding. Extractable is set to true. For AES-GCM-256-Demo",
                "extractable": true,
                "key_override_id": null,
                "key_type": "AES256",
                "name": "Key for image encryption",
                "public_key": null
            }
        }
    ],
    "product_type": "RnD",
    "rnd_keys": [],
    "state": null
}
Is product ok(Y/N): y
{
    "ca_info": [],
    "description": "Product for testing image encryption",
    "id": "9c6f8a51-2b1f-4d4e-9e3a-66e0b0c3e711",
    "name": "AES-GCM-256-Demo",
    "product_config_items": null,
    "product_operations": [],
    "product_type": null,
    "rnd_keys": [],
    "state": 2
}
Product Add request sent. Request ID: 9c6f8a51-2b1f-4d4e-9e3a-66e0b0c3e711 state: ApprovalRequired
```

## Approve the product and capture the IDs

Approve the product from the GUI. Then poll until the state is `16` (Ready) and read off the IDs you will need for
encryption:

```bash
(venv) $ signing-tool -n test.ini product \
    get -I 9c6f8a51-2b1f-4d4e-9e3a-66e0b0c3e711
{
    "ca_info": [],
    "description": "Product for testing image encryption",
    "id": "9c6f8a51-2b1f-4d4e-9e3a-66e0b0c3e711",
    "name": "AES-GCM-256-Demo",
    "product_config_items": [
        { "name": "KEK-IVCustomEncryption", "value": "ZX75HIzTir2AsAst0BbCMw==" }
    ],
    "product_operations": [
        {
            "approval_rule": { "...": "..." },
            "ca_use_case": null,
            "description": "Used to encrypt image and the AES key AES-GCM-256-Demo",
            "id": "f1e4b2ac-93a5-4aa3-8cba-2d1f0f6f749e",
            "name": "Content encryption operation",
            "operation_type": "EncryptImageWithAES",
            "profile_id": "00000000-0000-0000-0000-000000000000",
            "token": {
                "description": "HSM AES key encryption and fusemap embedding. Extractable is set to true. For AES-GCM-256-Demo",
                "extractable": true,
                "key_override_id": null,
                "key_type": "AES256",
                "name": "Key for image encryption",
                "public_key": null
            }
        }
    ],
    "product_type": "RnD",
    "rnd_keys": [],
    "state": 16
}
```

Capture:

- productID = `9c6f8a51-2b1f-4d4e-9e3a-66e0b0c3e711`
- operationID for `EncryptImageWithAES` = `f1e4b2ac-93a5-4aa3-8cba-2d1f0f6f749e`
- The `KEK-IVCustomEncryption` config item is the product's KEK-IV. It is **not** used by GCM modes (the platform
  generates the 12-byte nonce itself), but it is shown here for completeness — if you later switch this product to a
  CBC mode the same config item is what the device uses to wrap or unwrap the data key. See
  [IV and nonce handling](https://docs.laavat.io/usage/signing-encryption/aes-encryption/#iv-and-nonce-handling).

## Encrypt the image

Pick the file to encrypt (`firmware.bin` in this example) and decide on the AAD that the device will reproduce at
decryption time. The AAD does not need to be secret; the only requirement is that the device can compute the **exact
same bytes** independently. Tying it to the firmware version is a common choice:

```bash
(venv) $ AAD_B64=$(printf 'firmware-version=v1.6.44' | base64)
(venv) $ echo "$AAD_B64"
ZmlybXdhcmUtdmVyc2lvbj12MS42LjQ0
```

Send the encryption request:

```bash
(venv) $ signing-tool -n test.ini imagesigning add EncryptImageWithAES \
    -P 9c6f8a51-2b1f-4d4e-9e3a-66e0b0c3e711 \
    --operid f1e4b2ac-93a5-4aa3-8cba-2d1f0f6f749e \
    -N firmware-v1.6.44 -D "AES-GCM-256 demo" \
    -F firmware.bin -M AES-GCM-256 \
    --aad "$AAD_B64"
{
    "call_back_url": null,
    "description": "AES-GCM-256 demo",
    "id": "8c19a30b-6428-48dd-a8ef-6e480a07b9c5",
    "id_product": "9c6f8a51-2b1f-4d4e-9e3a-66e0b0c3e711",
    "id_product_operation": "f1e4b2ac-93a5-4aa3-8cba-2d1f0f6f749e",
    "name": "firmware-v1.6.44",
    "payload": {
        "id": null,
        "metadata": [{"name": "first", "value": "val"}],
        "modified_sha256": null,
        "name": "firmware.bin",
        "original_sha256": null,
        "s3_url": null,
        "service_provided_parameters": null
    },
    "state": 1
}
Encrypt request sent. Request ID: 8c19a30b-6428-48dd-a8ef-6e480a07b9c5, state: Created
```

The reference client uploads the plaintext to S3 first and then issues the encryption request. If you peek at the
on-the-wire request the upload is what the presigned URL sees; the API call only carries the metadata, the AES mode,
and the AAD — never the plaintext or any IV.

> **Warning: AES-GCM-256 single-shot size limit**
> GCM modes encrypt the whole payload in a single `Seal` call. With plain `AES-GCM-256` and the HSM-resident key
> used in this example the seal runs inside the HSM, and AWS CloudHSM seals at most 16 248 bytes in one
> `CKM_AES_GCM` operation. The AAD counts against those bytes, rounded up to a multiple of 16: the 24-byte AAD of
> this example takes 32, so `firmware.bin` must be at most 16 216 bytes. A larger payload fails with the error status
> `aesGcmPayloadTooLarge`. For larger images use `AES-GCM-256-PSKEK`, which seals the payload in software under a
> per-request data key and accepts up to 4 GiB, or a CBC mode. See
> [GCM modes](https://docs.laavat.io/usage/signing-encryption/aes-encryption/#gcm-modes-aes-gcm-aes-gcm-pskek).

## Approve the encryption request

The approval rule on this product was set so members of `$LAAVAT_APPROVERGROUP` approve. Approve from the GUI, or use
an approver token from the CLI:

```bash
(venv) $ signing-tool -n approver.ini imagesigning approve \
    -I 8c19a30b-6428-48dd-a8ef-6e480a07b9c5
Request approved
```

## Download the encrypted payload

After approval the platform encrypts the file and stores the ciphertext on S3. Poll until `state == 16` and download:

```bash
(venv) $ signing-tool -n test.ini imagesigning get \
    -I 8c19a30b-6428-48dd-a8ef-6e480a07b9c5 \
    -O firmware.bin.enc
{
    "call_back_url": null,
    "description": "AES-GCM-256 demo",
    "id": "8c19a30b-6428-48dd-a8ef-6e480a07b9c5",
    "id_product": "9c6f8a51-2b1f-4d4e-9e3a-66e0b0c3e711",
    "id_product_operation": "f1e4b2ac-93a5-4aa3-8cba-2d1f0f6f749e",
    "name": "firmware-v1.6.44",
    "payload": {
        "id": "6ea19a17-284a-4170-84e4-ea0368330424",
        "metadata": [],
        "name": "firmware.bin",
        "s3_url": "<presigned-S3-URL>",
        "service_provided_parameters": [
            {
                "aes_mode": "AES-GCM-256"
            },
            {
                "aad": "ZmlybXdhcmUtdmVyc2lvbj12MS42LjQ0"
            }
        ]
    },
    "state": 16
}
Downloading encrypted binary to: firmware.bin.enc
File Downloaded
```

`firmware.bin.enc` is laid out as `nonce ‖ ciphertext ‖ tag` (12 + N + 16 bytes). The 12-byte nonce was generated by
the platform — for non-PSKEK GCM modes it is **not** returned as a separate field, it lives in the file prefix.

```bash
$ ls -l firmware.bin firmware.bin.enc
-rw-r--r-- 1 user user 1048576 May  4 12:00 firmware.bin
-rw-r--r-- 1 user user 1048604 May  4 12:01 firmware.bin.enc

$ python3 -c "import sys; b=open('firmware.bin.enc','rb').read(); \
    print('nonce =', b[:12].hex()); \
    print('tag   =', b[-16:].hex()); \
    print('ct    =', len(b)-12-16, 'bytes')"
nonce = a3f1d8c0e9b246c1f8c2d5e0
tag   = 7f1c4a2b8d6e9c0a3b5f8e2d1c4a7b9e
ct    = 1048576 bytes
```

## Export the AES key with a SecurityEngineer client

Because the operation token was created with `extractable: true`, the platform AES key can be wrapped out with a
client of type `SecurityEngineer`. The platform wraps each extractable key with the client's registered EC P-256
public key using ECDH+HKDF and AES-KWP, then puts the result inside an outer JWE addressed to the same key. See
[Exporting AES keys](https://docs.laavat.io/usage/clientguide/clients/#exporting-aes-keys-with-a-securityengineer-client)
for the full wrapping scheme.

### Generate an EC P-256 client keypair

`SecurityEngineer` clients **must** register an EC P-256 public key — the server performs ECDH against it.

```bash
(venv) $ openssl ecparam -name prime256v1 -genkey -noout -out client.ec.private
(venv) $ openssl ec -in client.ec.private -pubout -out client.ec.public
```

### Register and approve the SecurityEngineer client

```bash
(venv) $ signing-tool -n test.ini client add \
    -N SECENG-AES-DEMO -D "Export AES key for AES-GCM-256-Demo product" \
    -K client.ec.public -U "oid:<your-object-id>" -T SecurityEngineer \
    -p 9c6f8a51-2b1f-4d4e-9e3a-66e0b0c3e711
{
    "client_type": "SecurityEngineer",
    "description": "Export AES key for AES-GCM-256-Demo product",
    "id": "1f6c0d7a-1234-4e5b-91aa-7d8c2b0f6e3a",
    "id_product": "9c6f8a51-2b1f-4d4e-9e3a-66e0b0c3e711",
    "name": "SECENG-AES-DEMO",
    "state": 2
}
Client Add request sent. Request ID: 1f6c0d7a-1234-4e5b-91aa-7d8c2b0f6e3a state: ApprovalRequired
```

Approve the client from the GUI (or with `signing-tool client approve` using an approver token):

```bash
(venv) $ signing-tool -n approver.ini client approve \
    -I 1f6c0d7a-1234-4e5b-91aa-7d8c2b0f6e3a
Client approved
```

### Fetch and unwrap the AES key

`signing-tool secrets getkeys` performs the full fetch-and-unwrap flow (outer JWE decrypt, ECDH+HKDF KEK derivation,
AES-KWP unwrap of each wrapped key) and writes the unwrapped keys to disk. For an `AES256` token it writes the raw
32 bytes:

```bash
(venv) $ signing-tool -n test.ini secrets getkeys \
    -P 9c6f8a51-2b1f-4d4e-9e3a-66e0b0c3e711 \
    -C client.ec.private -O /tmp/aesgcm.keys
[+] inner payload: wrapped_count=1
[+] /tmp/aesgcm.keys0.bin (name='Key for image encryption' type=AES256 tokenID=...): symmetric key (32 bytes)
```

`/tmp/aesgcm.keys0.bin` now holds the raw 32-byte AES-256 key that the platform used to encrypt `firmware.bin`. Treat
it like any other long-lived secret — keep it off disk in production, restrict the registered client to a small set of
trusted operators, and pair the export with the audit-log review (the call also produces a `GetProductWrappedKeys`
event; see [Audit Events](https://docs.laavat.io/appendixes/audit-events/)).

> **Tip: AWS CloudHSM deployments**
> If your platform deployment is backed by AWS CloudHSM, add `--cloudhsm` to the `secrets getkeys` invocation so
> the client unwraps with `CKM_CLOUDHSM_AES_KEY_WRAP_PKCS5_PAD` (RFC 3394 + PKCS#5) instead of the default RFC 5649
> AES-KWP. See [Clients](https://docs.laavat.io/usage/clientguide/clients/#exporting-aes-keys-with-a-securityengineer-client).

> **Warning: Treat the exported key securely**
> Once exported, you must implement appropriate safeguards to protect these materials against unauthorized access, loss, or compromise. LAAVAT cannot be held responsible for any security incidents, data breaches, or damages resulting from the handling, storage, or use of exported keys.
> We strongly recommend treating exported secrets with the highest level of security and limiting their distribution.

## Decrypt the payload offline

You now have everything needed to decrypt `firmware.bin.enc` outside the platform:

- The AES-256 key, in `/tmp/aesgcm.keys0.bin` (raw 32 bytes).
- The 12-byte nonce, sitting at the start of the encrypted file.
- The 16-byte authentication tag, sitting at the end.
- The AAD bytes the device must reproduce (here: `firmware-version=v1.6.44`).

The simplest cross-platform decrypter is a few lines of Python using the `cryptography` package. Save the script as
`decrypt-aesgcm.py`:

```python
#!/usr/bin/env python3
"""Decrypt an EncryptImageWithAES / AES-GCM-256 payload.

Usage:
    decrypt-aesgcm.py <key.bin> <ciphertext.enc> <aad-utf8> <output.bin>
"""
import sys
from pathlib import Path
from cryptography.hazmat.primitives.ciphers.aead import AESGCM


def main() -> None:
    key = Path(sys.argv[1]).read_bytes()
    blob = Path(sys.argv[2]).read_bytes()
    aad = sys.argv[3].encode("utf-8")
    out = Path(sys.argv[4])

    if len(key) not in (16, 32):
        raise SystemExit(f"unexpected key length: {len(key)}")
    if len(blob) < 12 + 16:
        raise SystemExit("ciphertext too short for GCM (need at least nonce+tag)")

    nonce, ct_and_tag = blob[:12], blob[12:]
    plaintext = AESGCM(key).decrypt(nonce, ct_and_tag, aad)
    out.write_bytes(plaintext)
    print(f"Decrypted {len(plaintext)} bytes to {out}")


if __name__ == "__main__":
    main()
```

Run it:

```bash
(venv) $ python3 decrypt-aesgcm.py \
    /tmp/aesgcm.keys0.bin firmware.bin.enc 'firmware-version=v1.6.44' firmware.bin.dec
Decrypted 1048576 bytes to firmware.bin.dec

(venv) $ sha256sum firmware.bin firmware.bin.dec
3b8f1d... firmware.bin
3b8f1d... firmware.bin.dec
```

Mismatched bytes in the AAD — for example because the device passes `v1.6.44` instead of `firmware-version=v1.6.44` —
will fail GCM tag verification:

```bash
(venv) $ python3 decrypt-aesgcm.py \
    /tmp/aesgcm.keys0.bin firmware.bin.enc 'v1.6.44' firmware.bin.dec
Traceback (most recent call last):
  ...
cryptography.exceptions.InvalidTag
```

This is the property AAD gives you: the device cannot silently decrypt with the wrong context. If the build pipeline
mislabels the firmware version or the device reads the wrong slot's metadata, decryption fails loudly instead of
returning malformed plaintext.

## Recap

The end-to-end flow used three roles:

|Stage|Actor|Action|
|-----|-----|------|
|Product creation|Writer|Submitted the template, picked AES-256 with `extractable: true`|
|Product approval|Approver|Approved the product from the GUI.|
|Encrypt request|Writer|Issued `imagesigning add EncryptImageWithAES -M AES-GCM-256 --aad ...`.|
|Encrypt approval|Approver|Approved the encryption request.|
|Download|Writer|Pulled `firmware.bin.enc` (`nonce ‖ ct ‖ tag`).|
|Key export|Security engineer|Registered a `SecurityEngineer` client with EC P-256, ran `secrets getkeys` to retrieve the raw 32-byte AES key.|
|Offline decrypt|Security engineer / device|Sliced the nonce and tag off the file, decrypted with the AES key and the same AAD bytes.|

For other modes — including the CBC variants that reuse the product KEK-IV and the GCM-PSKEK variant that adds a
sealed-key layer — see [AES Image Encryption](https://docs.laavat.io/usage/signing-encryption/aes-encryption/).
