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),
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-256and an AAD - Approving the encryption request
- Downloading the encrypted payload
- Exporting the AES key with a
SecurityEngineerclient - 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. A new token must be requested if the current token expires (roughly 1 hour of validity).
(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) $ export TOKEN=<redacted>
The same flow is used to obtain $APPROVERTOKEN for a user that is in the approver group, used later for approving
the encryption request from the command line if you prefer not to use the GUI.
Create the product¶
Here is the used product 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:
{
"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.
Submit the product with the python tool:
(venv) $ signing-tool -c -t $TOKEN \
-a https://app.laavat.io/<CustomerName>/api/v1/ 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:
(venv) $ signing-tool -c -t $TOKEN \
-a https://app.laavat.io/<CustomerName>/api/v1/ 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-IVCustomEncryptionconfig 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 / 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:
(venv) $ AAD_B64=$(printf 'firmware-version=v1.6.44' | base64)
(venv) $ echo "$AAD_B64"
ZmlybXdhcmUtdmVyc2lvbj12MS42LjQ0
Send the encryption request:
(venv) $ signing-tool -c -t $TOKEN \
-a https://app.laavat.io/<CustomerName>/api/v1/ 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.
AES-GCM-256 single-shot 40 MB cap
GCM modes encrypt the whole payload in a single Seal call, so the platform rejects payloads larger than ~40 MB
(MAX_WORKBUF_SIZE = 4096 × 1000 × 10). If you need to encrypt larger images, switch to a CBC mode or chunk the
image. See Single-shot GCM size limit.
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:
(venv) $ signing-tool -c -t $APPROVERTOKEN \
-a https://app.laavat.io/<CustomerName>/api/v1/ 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:
(venv) $ signing-tool -c -t $TOKEN \
-a https://app.laavat.io/<CustomerName>/api/v1/ 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.
$ 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 extractable keys
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.
(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¶
(venv) $ signing-tool -c -t $TOKEN \
-a https://app.laavat.io/<CustomerName>/api/v1/ 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):
(venv) $ signing-tool -c -t $APPROVERTOKEN \
-a https://app.laavat.io/<CustomerName>/api/v1/ 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:
(venv) $ signing-tool -c -t $TOKEN \
-a https://app.laavat.io/<CustomerName>/api/v1/ 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).
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.
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:
#!/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:
(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:
(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.