[RFC] Additional AEAD encryption path for SWUpdate: design and integration plan

75 views
Skip to first unread message

Raimar Sandner

unread,
Aug 3, 2026, 11:10:40 AMAug 3
to SWUpdate Group
Hello Stefano, all,

this is a follow-up to my previous email about an additional AEAD
encryption path for SWUpdate. The design and implementation plan that
follow are the work of Lukas Knöpfle, who is building the AEAD path as a
student project under my supervision. I am posting them here for
discussion and to address any questions or concerns ahead of the
implementation patches.

This draft is best viewed with a fixed-width font. If you prefer any other
format (markdown, PDF), please reach out to me.

§1 Motivation

The primary goal is to extend SWUpdate’s encryption capabilities to
support AEAD (Authenticated Encryption with Associated Data) algorithms.
Authenticated encryption is now the default choice for many new designs,
reflected in TLS 1.3 and in NIST guidance. Some deployments require it for
compliance, and an AEAD path lets SWUpdate serve those cases.

AEAD’s appeal rests on the stronger security proofs it offers. In this
context, authentication is not merely a second layer of integrity
protection (in the SWUpdate use case stacked on top of the asymmetric
signature), but strengthens the confidentiality guarantees of the
encryption itself.

§2 Design and Implementation Overview

The generic AEAD interface laid out in RFC 5116 [1] requires the
decryption function to atomically output either the complete plaintext
upon successful authentication, or an error, with no partial output. For a
streaming update which cannot buffer the entire plaintext, this means that
the format must be chunked, each chunk being treated as an independent
AEAD unit. This is a well-established design pattern, and is used in
streaming protocols like age and Tink.

Our proposed file format is cipher-agnostic, allowing for different AEAD
algorithms to be used. The first implementation will cover AES-GCM-128,
AES-GCM-192, and AES-GCM-256, which are available in every crypto backend
relevant for SWUpdate. The format also supports other families of AEAD
algorithms as future extensions, such as ChaCha20-Poly1305, by allowing
the cipher identifier to be specified in the file header.

The new AEAD encryption will be opt-in per artifact, not touching the
functionality of the existing AES-CBC mode of operation. Integrity
protection via asymmetric sw-description signatures will remain unchanged
as well.

Integration into the existing data flow will be done as a new aead_step,
parallel to the existing decrypt_step. This flow path will be selected by
the cipher named in the sw-description.

§3 Wire Format

The encrypted artifact is a cleartext header once, followed by a sequence
of AEAD chunks. The format is self-describing and cipher-agnostic. All
multi-byte fields are big-endian; fields are read explicitly (no struct
casts), so there are no alignment or padding requirements.

§3.1 Layout

Header (once, cleartext)
+----------------------------------+
| magic_number : 8 B | "SWU-AEAD" = 53 57 55 2D 41 45 41 44
| format_version : u16 2 B | = 1
| file_size : u64 8 B | total plaintext size, in bytes
| chunk_size : u32 4 B | plaintext bytes per chunk (see §3.2)
| cipher_id : u16 2 B | index into the AEAD cipher registry
| tag_size : u8 1 B | AEAD tag length, in bytes
| nonce_size : u8 1 B | nonce length, in bytes
| base_nonce : nonce_size B | random per-artifact base nonce
+----------------------------------+

Chunk stream (N chunks, layout ct || tag)
+----------------------------------------------------+
| Chunk 0 : ct (chunk_size B) || tag (tag_size B) |
| Chunk 1 : ct (chunk_size B) || tag (tag_size B) |
| ... |
| Chunk N-1 : ct (last_size B) || tag (tag_size B) |
+----------------------------------------------------+

last_size = file_size - (N-1)*chunk_size (may be shorter)

The header is 26 fixed bytes plus base_nonce. For AES-GCM: nonce_size =
12, tag_size = 16. Each chunk is ciphertext || tag.

Valid chunk sizes are all 4 KiB-aligned values in the range 4 KiB .. 1
MiB, inclusive. The chunk size is a trade-off between memory footprint and
overhead: smaller chunks reduce memory usage, but increase the number of
tags and thus the overhead on the wire (0.39% for 4 KiB chunks to 0.0015%
for 1 MiB chunks). The default chunk size is 64 KiB, which is a good
compromise for most use cases. The lower bound of 4 KiB is
security-relevant, as it caps the worst-case chunk count in the §4.3
analysis.

§3.2 Deriving the chunk schedule

The header carries the payload’s total length as file_size, the chunk
stream itself has no length fields. From file_size and chunk_size, both
encryptor and decryptor derive the full chunk schedule locally: the chunk
count N, each chunk’s plaintext length len(i), and the end marker
is_last(i).

These three quantities are (integer u64 arithmetic; // is integer
division):

N = max(1, (file_size + chunk_size - 1) // chunk_size)
len(i) = min(chunk_size, file_size - i*chunk_size)
is_last(i) = (i == N-1)

N is effectively the ceiling division of file_size by chunk_size, with a
floor of 1 so an empty artifact still forms exactly one chunk:
file_size = 0 gives N = 1 and len(0) = 0, one 0-byte chunk that is still
authenticated. len(i) clamps each chunk to the bytes still remaining, so
the last chunk may be short. Because file_size is known up front, neither
side needs read-ahead.

§3.3 AAD: what every chunk binds

Every chunk is sealed with the same-shaped Associated Data:

AAD_i = file_header || u64_BE(chunk_index_i) || u8(is_last_i)

where file_header is the entire cleartext header byte string (magic_number
through base_nonce), u64_BE(x) is x encoded as an 8-byte big-endian
integer, u8(x) is x as a single byte, and || is concatenation. So each
chunk authenticates the full artifact context plus its own position.

This subsection analyses the encrypted file as a standalone AEAD
container, i.e. when it is used outside the .swu context with its signed
sw-description sha256 integrity binding. It asks what an attacker who can
rewrite the container’s own bytes can do against the format alone. This is
what the AAD buys, and the attack each element stops:

-------------------------------------------------------------------------
Bound element Stops
------------------------------------ ------------------------------------
magic_number, format_version format confusion / downgrade

cipher_id cipher downgrade (same bytes
reinterpreted under another cipher)

file_size, chunk_size size tampering: it shifts chunk
boundaries and the is_last pattern,
breaking a tag

nonce_size, tag_size parameter tampering

base_nonce cross-artifact chunk splicing (also
gives a different nonce, §4.1)

chunk_index reordering, duplication, replay
within an artifact

is_last truncation and extension (see below)
-------------------------------------------------------------------------

Truncation / extension. is_last is locally computed and authenticated, not
sent on the wire. Artifact truncation (missing bytes): if a chunk is
missing, the decryptor hits upstream EOF before verifying the chunk sealed
with is_last = 1, and a bare EOF before that chunk never counts as
success. Moving the end earlier would force an earlier chunk to verify
with is_last = 1 though it was sealed with is_last = 0, breaking the tag.
Artifact extension (appended bytes): the decryptor reads exactly N chunks,
so trailing bytes are reported as an error.

§3.4 Validating the header before its values are used

The header is part of every chunk’s AAD, so it is authenticated by all
tags. Nevertheless, the decryptor must validate some fields before the
first authentication can happen, i.e. before acting on them. The following
fields are validated:

- cipher_id, tag_size, nonce_size: The authoritative cipher parameters
come from the registry (keyed by cipher_id); the header’s nonce_size
and tag_size are validated against that registry entry. They are
included in the header so that the file format is self-describing. For
AES-GCM the registry pins nonce_size = 12, which is the nonce length
AES-GCM is designed for and that NIST recommends (our §4.3 birthday
bound assumes this value). Other lengths are handled differently
inside GCM and carry a weaker security guarantee, so they are
excluded. The tag size is pinned to 16 bytes for AES-GCM, which
prevents a maliciously small tag_size from being used to bypass or
weaken authentication.

- chunk_size: must be a valid chunk size (4 KiB-aligned, 4 KiB .. 1
MiB). This check prevents a maliciously large memory allocation.

§4 Nonce hygiene

For AEAD the critical invariant is a unique nonce per (key, chunk). GCM
nonce reuse is catastrophic: a single (K, nonce) reuse leaks the XOR of
the two plaintexts and, via the forbidden attack, enables tag forgery
under that key.

§4.1 Nonce construction

The master key delivered by SWUpdate (-K keyfile) is used directly as the
AEAD key. This puts the entire burden of avoiding (K, nonce) collisions on
nonce uniqueness.

Per artifact, the encrypt tool draws a random 96-bit base_nonce from its
crypto backend and stores it in the header. The random source must be
cryptographically secure, e.g., OpenSSL RAND_bytes. The per-chunk nonce
is:

nonce_i = base_nonce XOR u96_BE(chunk_index_i)

i.e. the chunk index, zero-extended to nonce_size bytes, XORed into the
base nonce. This is the TLS 1.3 pattern (RFC 8446 [2] §5.3, static base
XOR sequence number).

§4.2 Nonce uniqueness guarantees on two axes

- Within an artifact: chunk_index is an injective counter from 0, so
base_nonce XOR 0, XOR 1, ... are pairwise distinct. All intra-artifact
collisions are removed deterministically. The design constraints
(maximum file size and minimal chunk size) ensure that the maximum
chunk index is well within the 96-bit nonce space, so there is no risk
of wraparound.

- Between artifacts: the master_key is shared, so uniqueness must hold
across all chunks of all artifacts under that key. This rests on the
randomness of base_nonce. It is a birthday problem, made precise by
the XOR structure (§4.3).

§4.3 Birthday bound on nonce collisions

Here we derive bounds for the number of artifacts that can be securely
encrypted under the same key. The analysis assumes the nonce is a uniform
96-bit value, which is exactly what a 12-byte AES-GCM nonce provides (the
registry pins this length, §3.4). This practice is considered secure as
long as the probability of a nonce collision remains acceptably low, 2^-32
being the NIST-recommended threshold.

First consider A artifacts with a single chunk. The standard birthday
bound for a nonce space of 2^n values is that A random draws have a
collision probability of less than 2^-32 when A < sqrt(2^(n-32)). With
n = 96, this gives A < 2^32 (as recommended by RFC 5116 §3.1).

Now let us consider N chunks per artifact (N being the maximum number of
chunks any artifact can have, for the sake of our analysis). Chunk i uses
base_nonce XOR i, and i < N touches only the low m = ceil(log2 N) bits.
Two artifacts a != a' collide iff
base_nonce_a XOR base_nonce_{a'} = i XOR j for some i, j. Since i XOR j is
confined to the low m bits, a collision occurs iff the two base nonces
agree on all 96 - m high bits. The problem reduces to a birthday collision
among A base nonces over a space of 2^(96 - m) values, i.e. n = 96 - m.
With

- A = artifacts under one key,
- N = chunks per artifact
- m = ceil(log2 N) = bits needed to represent the chunk index

the threshold from above becomes

A < sqrt(2^(96 - m - 32)) = 2^(32 - m/2)

Operational ceiling: Two artifact sizes, each at the 4 KiB floor (worst
case), the 64 KiB (default) and the 1 MiB (max) chunk sizes, give the
following ceilings on the number of artifacts per key:

--------------------------------------------------------------------------
Artifact size Chunk size N (chunks) m (bits of N) Max artifacts
per key
-------------- -------------- -------------- -------------- --------------
4 GiB 4 KiB (floor) 1,048,576 20 ~4 million

4 GiB 64 KiB 65,536 16 ~17 million
(default)

4 GiB 1 MiB (max) 4,096 12 ~67 million

16 MiB 4 KiB (floor) 4,096 12 ~67 million

16 MiB 64 KiB 256 8 ~270 million
(default)

16 MiB 1 MiB (max) 16 4 ~1 billion
--------------------------------------------------------------------------

Smaller artifacts and bigger chunks shift every figure the favorable way:
both lower N, which raises the max-artifact ceiling (A <= 2^(32 - m/2)).
The 4 GiB rows are at the conservative end; the ~4 M floor even pairs the
largest artifact with the smallest chunk, a maximal-overhead corner no one
configures, so real deployments sit well above it.

These figures correspond to a 2^-32 collision probability, a deliberately
conservative target. Should a deployment need to exceed these ceilings,
there are structural and per deployment mitigations available:

Per deployment mitigations:

- Rotate the master key when the ceiling is approached.
- Encrypt the sw-description with the master key and use different keys
for different artifacts, specified in the sw-description in the
aes-key field. The ceiling then applies to the number of .swu packages
encrypted under the same key, not the number of artifacts in the
fleet. Note: this requires support for AEAD encryption of the
sw-description itself, which is currently out of scope of this
project.

Structural mitigations:

- Add a cipher with larger nonce space (e.g., XChaCha20-Poly1305 with
192-bit nonces) to the registry, and use it for large deployments. The
birthday bound is then A < 2^(80 - m/2).
- Add a cipher with nonce-reuse resistance (e.g., AES-GCM-SIV) to the
registry.
- Add a format version which derives a per-artifact session key from the
master key and a 256-bit random salt.

§5 Encryption and decryption flow

Both directions operate per artifact. Notation: Seal/Open are the AEAD
primitive, || is concatenation, ^ is XOR.

Encryption (offline tool).

The encryption tool is in scope of this project and will be delivered as a
new C CLI tool under the tools directory. An additional Python CLI tool is
optional.

1. Inputs: plaintext P (the artifact bytes, already compressed if
compression is enabled), master_key K, chosen cipher_id, chosen
chunk_size.
2. Draw a fresh random base_nonce (nonce_size bytes) from the crypto
backend. Critical: must be a cryptographically secure source (§4).
3. Set file_size = |P|, take the chunk_size, and assemble file_header
with the values from the cipher registry (§3.1). Compute N and the
len(i)/is_last(i) schedule (§3.2).
4. For each chunk i = 0 .. N-1:
a. take the plaintext slice pt_i of length len(i);
b. nonce_i = base_nonce ^ u96_BE(i) (§4.1);
c. AAD_i = file_header || u64_BE(i) || u8(is_last(i)) (§3.3);
d. (ct_i, tag_i) = Seal(K, nonce_i, AAD_i, pt_i);
e. append ct_i || tag_i.

Decryption (on-device, streaming).

1. Read file_header from the stream.
2. Validate the header before any allocation or chunk read (§3.4):
magic_number/format_version; cipher_id, nonce_size, tag_size against
the registry; chunk_size in range and 4 KiB-aligned. On any mismatch:
-EBADMSG, abort, nothing written.
3. Compute N and the len(i)/is_last(i) schedule from file_size +
chunk_size.
4. For each chunk i = 0 .. N-1:
a. read len(i) ciphertext bytes and tag_size tag bytes;
b. nonce_i = base_nonce ^ u96_BE(i);
c. AAD_i = file_header || u64_BE(i) || u8(is_last(i));
d. pt_i = Open(K, nonce_i, AAD_i, ct_i, tag_i). On tag failure:
fail-stop, abort, discard, forward nothing from this chunk on;
e. only on success forward pt_i downstream (decompress -> handler ->
flash). This verify-before-write ordering is the whole point of
the chunked design. Steps 4.d and 4.e ensure the AEAD invariant of
atomicity required by RFC5116: either the entire chunk is verified
and forwarded, or a FAIL is returned (§2).
5. Termination: success requires that chunk N-1 (the one with
is_last = 1) verified. A bare upstream EOF before that chunk is
truncation and fails; bytes after chunk N-1 are reported as an error
(§3.3).

§6 Threat model summary

-------------------------------------------------------------------------
Attacker capability Protection
------------------------------------ ------------------------------------
Tamper artifact, no keys per-chunk AEAD tag, fail-stop before
write

Tamper cleartext header sanity-checked against the registry
and bounds before any allocation or
cryptographic operation (§3.4);
beyond that the whole header is
bound into every chunk’s AAD (§3.3),
so any surviving change breaks chunk
0’s tag: fail-stop before any write

Reorder / duplicate / replay chunks chunk_index in nonce and AAD

Truncate / extend the stream authenticated is_last +
file_size-derived N

Cipher / parameter downgrade cipher_id, sizes in AAD

Splice chunks between artifacts distinct random base_nonce per
artifact (also in AAD)

Swap a whole encrypted artifact for not defended at the AEAD layer (both
another under the same key artifacts seal validly); caught by
the CMS-signed sw-description, which
binds each filename/device slot to a
sha256 over that artifact’s
ciphertext, so a swapped ciphertext
fails the manifest hash

Has encryption key, not signing key confidentiality is lost, but update
integrity holds: without the signing
key no valid manifest (and no
matching sha256) can be produced, so
no manipulated update installs

Has signing key, not encryption key not defended: a valid signed
manifest means arbitrary code
execution on the device; AEAD only
keeps prior encrypted artifacts
confidential

Memory-DoS via huge chunk_size header bounds check before
allocation (§3.4)
-------------------------------------------------------------------------

The CMS-signed sw-description already prevents every tampering row above
today: its per-artifact sha256 over the ciphertext, verified under a
signature SWUpdate trusts, catches any modification. So the AEAD layer
adds no new trust anchor. Its value is to strengthen the confidentiality
guarantees of the encryption itself, through the AEAD invariant of
atomicity and the security proofs relying on that property.

§7 Compatibility with Delta Updates

SWUpdate supports delta updates via the ZCK format, where variable-length
chunks are selected by content-defined boundaries and then compressed. The
algorithm is optimized to keep the number of new-data chunks low between
versions. The device requests only the chunks it does not already have,
keeping the transfers small. If a larger patch of consecutive chunks has
to be transmitted, it is merged into a single HTTP byte-range request
instead of multiple per-chunk requests. This mode of operation is
unencrypted today.

One obvious and convenient way of handling encrypted delta updates is by
applying an offset-preserving stream cipher like AES-CTR, which was
discussed on-list in 2023 [3]. Data blocks can be requested and decrypted
at byte granularity in this mode, though without the additional security
guarantees provided by authenticated modes. This is orthogonal to the AEAD
support proposed here.

Our current design neither directly supports nor precludes a future AEAD
delta path. Due to limited project resources, we had to focus on the
normal install first. There is a general tension here: streaming AEAD
requires a chunked format with per-chunk authentication, while ZCK favors
random-access to arbitrary byte ranges. Two broad approaches exist,
differing in where the AEAD and ZCK chunk boundaries are reconciled:

(1) Producer-side: a version-2 variable-size frame whose boundaries align
to the ZCK chunks, so each frame decrypts exactly one ZCK chunk.

(2) Client-side: keep fixed-size frames, fetch the frames overlapping each
needed ZCK chunk, and trim the excess after decryption.

We have not worked out either approach in detail. Deciding which is
practical would need a closer look at ZCK and a careful security analysis,
which is future work.

§8 Implementation and integration details

This section covers how the AEAD path integrates into the existing
pipeline and the cipher identifier space that the wire format depends on.
The finer implementation detail arrives with the patches.

The decryption path is added as a new aead_step, parallel to the existing
decrypt_step. The sw-description encrypted name resolves to a cipher_t,
and the path is chosen by whether that cipher_t has an entry in the AEAD
registry below: AEAD ciphers route to aead_step, every other cipher,
including AES-CBC, stays on the decrypt_step with unchanged behavior. The
registry is the single source of truth for which ciphers are AEAD-capable.

The cipher_id field is part of the wire format (§3.1), so its value space
is fixed here. A cipher_id names an AEAD-capable cipher only. Non-AEAD
ciphers such as AES-CBC keep their existing path and are never assigned a
cipher_id.

cipher_id is a 2-byte value, a 1-byte family and a 1-byte cipher within
that family:

0x0000 - 0x00FF AES family
0x0100 - 0x01FF ChaCha family (future)
0x0200 - 0xEFFF reserved for SWUpdate
0xF000 - 0xFFFF private use, never assigned by SWUpdate

The value 0x0000 is left permanently unassigned, so a zero-initialized or
truncated header can never select a cipher by accident: an all-zero
cipher_id fails the registry lookup and the header is rejected (§3.4).

The first implementation assigns three values in the AES family, written
to the header today and frozen by this format version:

0x0001 aes-gcm-128
0x0002 aes-gcm-192
0x0003 aes-gcm-256

On the code side this reuses the existing cipher machinery. The cipher_t
enum gains three entries (AES_GCM_128, AES_GCM_192, AES_GCM_256). The
existing sw-description name table gains the matching names, each mapped
to its cipher_t:

static const map_cipher_t map_cipher[] = {
/* ... existing entries ... */
{ AES_GCM_128, "aes-gcm-128" },
{ AES_GCM_192, "aes-gcm-192" },
{ AES_GCM_256, "aes-gcm-256" },
};

A second registry maps each wire cipher_id to its cipher and its fixed
parameters:

typedef struct {
uint16_t cipher_id;
cipher_t cipher;
uint8_t tag_size;
uint8_t nonce_size;
uint8_t key_size;
} map_aead_params_t;

static const map_aead_params_t map_aead_params[] = {
{ 0x0001, AES_GCM_128, 16, 12, 16 },
{ 0x0002, AES_GCM_192, 16, 12, 24 },
{ 0x0003, AES_GCM_256, 16, 12, 32 },
};

Inside aead_step, the header cipher_id is looked up in the same registry
and must resolve to the cipher_t derived from the sw-description name. The
tag, nonce, and key sizes are taken from that registry entry. The header’s
own tag_size and nonce_size are validated against it, any mismatch is
rejected (§3.4).

There is one interface question we would like to raise early, while it is
still cheap to change. AEAD brings two operations the current CBC path has
no need for: setting a fresh nonce for each chunk, and verifying a tag.
Our plan is to add these to the existing decryption interface
(swupdate_decrypt_lib) as optional entry points, so a module that does not
do AEAD just leaves them NULL, and they sit idle whenever an AES-CBC
artifact is decrypted. Getting that to sit cleanly next to the existing
CBC handling may mean a little refactoring of the shared interface, which
we would keep behavior-preserving for what is already there. For the first
version we would implement the AEAD operations only for the OpenSSL AES
module.

That is the full design as it stands today. Both the format and the
integration ideas are open for discussion, we appreciate your input.

Raimar

[1] https://www.rfc-editor.org/rfc/rfc5116
[2] https://www.rfc-editor.org/rfc/rfc8446
[3] https://groups.google.com/g/swupdate/c/QXC_q0maiUo/m/ZZcnMAF3AAAJ


Mit freundlichen Grüßen / Best regards

i.A. Dr. Raimar Sandner
Expert in Industrial Cybersecurity | Autonomous Perception


_____________________________________________________________________________________
SICK AG | Erwin-Sick-Str. 1 | 79183 Waldkirch | Germany
P +49 7681 202-6404 | raimar....@sick.de | www.sick.de
_____________________________________________________________________________________

SICK AG | Sitz: Waldkirch i. Br. | Handelsregister: Freiburg i. Br. HRB 280355 | WEEE-Reg.-Nr. DE 14165396
Vorstand: Dr. Mats Gökstorp (Vorsitzender) | Jan-H. Eberhardt | Ulrike Kahle-Roth | Nicole Kurek | Markus Scaglioso | Dr. Niels Syassen
Aufsichtsrat: Dr. Robert Bauer (Vorsitzender)

Stefano Babic

unread,
Aug 5, 2026, 6:29:26 AMAug 5
to Raimar Sandner, SWUpdate Group
Hi Raimar,

On 8/3/26 17:10, 'Raimar Sandner' via swupdate wrote:
> Hello Stefano, all,
>
> this is a follow-up to my previous email about an additional AEAD
> encryption path for SWUpdate. The design and implementation plan that
> follow are the work of Lukas Knöpfle, who is building the AEAD path as a
> student project under my supervision. I am posting them here for
> discussion and to address any questions or concerns ahead of the
> implementation patches.
>
> This draft is best viewed with a fixed-width font. If you prefer any other
> format (markdown, PDF), please reach out to me.
>

Fine with me. Anyway, it will be nice if this description or part of it
will land into the documentation, so that also the reasons for the
implementation are explained.

> §1 Motivation
>
> The primary goal is to extend SWUpdate’s encryption capabilities to
> support AEAD (Authenticated Encryption with Associated Data) algorithms.
> Authenticated encryption is now the default choice for many new designs,
> reflected in TLS 1.3 and in NIST guidance. Some deployments require it for
> compliance, and an AEAD path lets SWUpdate serve those cases.
>
> AEAD’s appeal rests on the stronger security proofs it offers. In this
> context, authentication is not merely a second layer of integrity
> protection (in the SWUpdate use case stacked on top of the asymmetric
> signature), but strengthens the confidentiality guarantees of the
> encryption itself.

Ok - just as note. SWUpdate splits decryption and integrity check in
steps. Really, the final step in SWUpdate is the activation of the new
software, and if this does not happen, the software is treated as
garbage. Adding AHEAD forbids to install a manipulated encrypted
artifact in case the encryption key is lost during the write of the
chunk, while SWUpdate currently recognizes this just after having
decrypted the stream.

>
> §2 Design and Implementation Overview
>
> The generic AEAD interface laid out in RFC 5116 [1] requires the
> decryption function to atomically output either the complete plaintext
> upon successful authentication, or an error, with no partial output. For a
> streaming update which cannot buffer the entire plaintext, this means that
> the format must be chunked, each chunk being treated as an independent
> AEAD unit. This is a well-established design pattern, and is used in
> streaming protocols like age and Tink.
>
> Our proposed file format is cipher-agnostic, allowing for different AEAD
> algorithms to be used. The first implementation will cover AES-GCM-128,
> AES-GCM-192, and AES-GCM-256, which are available in every crypto backend
> relevant for SWUpdate.

I am aware we can encrypt / decrypt using GCM writing code and linking
to libressl, but are they supported out of the box ?

With OpenSSL 3.x, openssl returns "AEAD ciphers not supported". Does it
mean we have also to provide suitable code for meta-swupdate (better
without ad hoc and/or self written tools) and swugenerator ?

> The format also supports other families of AEAD
> algorithms as future extensions, such as ChaCha20-Poly1305, by allowing
> the cipher identifier to be specified in the file header.
>
> The new AEAD encryption will be opt-in per artifact, not touching the
> functionality of the existing AES-CBC mode of operation. Integrity
> protection via asymmetric sw-description signatures will remain unchanged
> as well.
>
> Integration into the existing data flow will be done as a new aead_step,
> parallel to the existing decrypt_step. This flow path will be selected by
> the cipher named in the sw-description.

So just to understand: you want to introduce a new file format, just for
AHEAD, instead of relying on the underlying cipher. I will bother that
this introduce a discontinuity in the project.

The master is sw-description. We can say that the same use case is for
the compression: we have zlib, xz, zst. Instead of using a special (but
maybe SWUpdate specific and then "proprietary" ?) header in front of the
compressed file, the correct compression algorithm must be set into
sw-description, and detection is done at runtime.

But as design, sw-description is the master. Upper tool like the classes
in meta-swupdate or swugenerator can automatically (well. just
swugenerator..) set the correct compressor or compress into the chosen
format.

Which is the add-on or the use case to introduce this ? A SWU is
generated once, do you see cases where same release is provided with
different encryption algs ?

>
> §3 Wire Format
>
> The encrypted artifact is a cleartext header once, followed by a sequence
> of AEAD chunks. The format is self-describing and cipher-agnostic. All
> multi-byte fields are big-endian; fields are read explicitly (no struct
> casts), so there are no alignment or padding requirements.

I am quite confused why we need a sort of pre-header, that at the end,
must be verified as well, when all meta-information inside
sw-description are verified before starting. Also using a binary format
for this header is not convincing me.

SWUpdate has a way to add new attributes that are collected if they are
not recognized by the core. So new attributes can be added using
"properties", like:

properties : {
chunk_size =...
cipher = "aes-gcm-256";
.....
}

But I will prefer a syntax like was done for compression, where the name
of compressor has replaced the simple boolean value. Instead of

compressed = true;

we have now:

compressed = "zstd";

And I supposed here we have:

encrypted = "aes-cbc-128";

or

encrypted = "aes-gc-256";


>
> §3.1 Layout
>
> Header (once, cleartext)
> +----------------------------------+
> | magic_number : 8 B | "SWU-AEAD" = 53 57 55 2D 41 45 41 44
> | format_version : u16 2 B | = 1
> | file_size : u64 8 B | total plaintext size, in bytes

==> there are already attributes for these, "size" and "decrtypted-size"

> | chunk_size : u32 4 B | plaintext bytes per chunk (see §3.2)
> | cipher_id : u16 2 B | index into the AEAD cipher registry
> | tag_size : u8 1 B | AEAD tag length, in bytes
> | nonce_size : u8 1 B | nonce length, in bytes
> | base_nonce : nonce_size B | random per-artifact base nonce
> +----------------------------------+

Is this random per artifact but stable ? We want to have reproducible
builds. If I regenerate after N years a release, I want to get the same
build, that is the hash of the SWU is the same. Where is coming the nonce ?

General: apart building the header to use it for AAD, where are coming
the values, that is chunk size, etc ?

>
> Chunk stream (N chunks, layout ct || tag)
> +----------------------------------------------------+
> | Chunk 0 : ct (chunk_size B) || tag (tag_size B) |
> | Chunk 1 : ct (chunk_size B) || tag (tag_size B) |
> | ... |
> | Chunk N-1 : ct (last_size B) || tag (tag_size B) |
> +----------------------------------------------------+
>
> last_size = file_size - (N-1)*chunk_size (may be shorter)
>

So each chunk has the same size and the tag is appended at the end of
the encrypted payload.


> The header is 26 fixed bytes plus base_nonce. For AES-GCM: nonce_size =
> 12, tag_size = 16. Each chunk is ciphertext || tag.

Ok

>
> Valid chunk sizes are all 4 KiB-aligned values in the range 4 KiB .. 1
> MiB, inclusive. The chunk size is a trade-off between memory footprint and
> overhead: smaller chunks reduce memory usage,

Easy to understand, but where is allocated the memory ? Is it done by
the crypto backend (EVP_ in openssl), or where ?

Does it work the current framework based on DECRYPT_init, DECRIPT_update
and DECRYPT_final entry points ?

> but increase the number of
> tags and thus the overhead on the wire (0.39% for 4 KiB chunks to 0.0015%
> for 1 MiB chunks). The default chunk size is 64 KiB, which is a good
> compromise for most use cases. The lower bound of 4 KiB is
> security-relevant, as it caps the worst-case chunk count in the §4.3
> analysis.
>

Ok - so let's say it is decision of the integrator to set up chunk size
according to own project's requirements.

> §3.2 Deriving the chunk schedule
>
> The header carries the payload’s total length as file_size, the chunk
> stream itself has no length fields. From file_size and chunk_size, both
> encryptor and decryptor derive the full chunk schedule locally: the chunk
> count N, each chunk’s plaintext length len(i), and the end marker
> is_last(i).
>
> These three quantities are (integer u64 arithmetic; // is integer
> division):
>
> N = max(1, (file_size + chunk_size - 1) // chunk_size)

==> should not be divided instead of sum ?

> len(i) = min(chunk_size, file_size - i*chunk_size)
> is_last(i) = (i == N-1)
>
> N is effectively the ceiling division of file_size by chunk_size, with a
> floor of 1 so an empty artifact still forms exactly one chunk:
> file_size = 0 gives N = 1 and len(0) = 0, one 0-byte chunk that is still
> authenticated. len(i) clamps each chunk to the bytes still remaining, so
> the last chunk may be short. Because file_size is known up front, neither
> side needs read-ahead.

Ok

>
> §3.3 AAD: what every chunk binds
>
> Every chunk is sealed with the same-shaped Associated Data:
>
> AAD_i = file_header || u64_BE(chunk_index_i) || u8(is_last_i)
>
> where file_header is the entire cleartext header byte string (magic_number
> through base_nonce), u64_BE(x) is x encoded as an 8-byte big-endian
> integer, u8(x) is x as a single byte, and || is concatenation. So each
> chunk authenticates the full artifact context plus its own position.
>
> This subsection analyses the encrypted file as a standalone AEAD
> container, i.e. when it is used outside the .swu context with its signed
> sw-description sha256 integrity binding.

Is it just a general / theoretical approach ? I mean, the use case is to
encrypt an artifact and deliver inside the SWU, and then most fields are
already verified as part of the authentication of sw-description.

So I understand you are here checking what is deploying the encrypted
file without SWUpdate (a little OT here).
Anyway, explanation is clear, thanks.

>
> §3.4 Validating the header before its values are used
>
> The header is part of every chunk’s AAD, so it is authenticated by all
> tags. Nevertheless, the decryptor must validate some fields before the
> first authentication can happen, i.e. before acting on them. The following
> fields are validated:
>
> - cipher_id, tag_size, nonce_size: The authoritative cipher parameters
> come from the registry (keyed by cipher_id); the header’s nonce_size
> and tag_size are validated against that registry entry. They are
> included in the header so that the file format is self-describing. For
> AES-GCM the registry pins nonce_size = 12, which is the nonce length
> AES-GCM is designed for and that NIST recommends (our §4.3 birthday
> bound assumes this value). Other lengths are handled differently
> inside GCM and carry a weaker security guarantee, so they are
> excluded. The tag size is pinned to 16 bytes for AES-GCM, which
> prevents a maliciously small tag_size from being used to bypass or
> weaken authentication.
>
> - chunk_size: must be a valid chunk size (4 KiB-aligned, 4 KiB .. 1
> MiB). This check prevents a maliciously large memory allocation.

I would like to understand this large memory allocation and which part
is responsible for it.

>
> §4 Nonce hygiene
>
> For AEAD the critical invariant is a unique nonce per (key, chunk). GCM
> nonce reuse is catastrophic: a single (K, nonce) reuse leaks the XOR of
> the two plaintexts and, via the forbidden attack, enables tag forgery
> under that key.
>
> §4.1 Nonce construction
>
> The master key delivered by SWUpdate (-K keyfile) is used directly as the
> AEAD key. This puts the entire burden of avoiding (K, nonce) collisions on
> nonce uniqueness.
>
> Per artifact, the encrypt tool draws a random 96-bit base_nonce

reproducibility ?

> from its
> crypto backend and stores it in the header. The random source must be
> cryptographically secure, e.g., OpenSSL RAND_bytes. The per-chunk nonce
> is:
>
> nonce_i = base_nonce XOR u96_BE(chunk_index_i)
>
> i.e. the chunk index, zero-extended to nonce_size bytes, XORed into the
> base nonce. This is the TLS 1.3 pattern (RFC 8446 [2] §5.3, static base
> XOR sequence number).

Ok
Automatically ??

> - Encrypt the sw-description with the master key and use different keys
> for different artifacts, specified in the sw-description in the
> aes-key field.

This is the current approach - even if I have to say, IVT are set, but
generally the encryption key is changed with the release but it is the
same for all artifacts inside the same SWU. But aes-key per artifact is
already supported.

> The ceiling then applies to the number of .swu packages
> encrypted under the same key, not the number of artifacts in the
> fleet. Note: this requires support for AEAD encryption of the
> sw-description itself, which is currently out of scope of this
> project.>> Structural mitigations:
>
> - Add a cipher with larger nonce space (e.g., XChaCha20-Poly1305 with
> 192-bit nonces) to the registry, and use it for large deployments. The
> birthday bound is then A < 2^(80 - m/2).
> - Add a cipher with nonce-reuse resistance (e.g., AES-GCM-SIV) to the
> registry.
> - Add a format version which derives a per-artifact session key from the
> master key and a 256-bit random salt.
>
> §5 Encryption and decryption flow
>
> Both directions operate per artifact. Notation: Seal/Open are the AEAD
> primitive, || is concatenation, ^ is XOR.
>
> Encryption (offline tool).
>
> The encryption tool is in scope of this project and will be delivered as a
> new C CLI tool under the tools directory. An additional Python CLI tool is
> optional.

well, I quite bother about this works inside the ecosystem. Building SWU
then depends on this external tool, on OE this requires to build
swupdate-native, too. I hoped we could use a standard tool instead of
new one.

For Python, I do not see it as optional but as part (extension) of
swugenerator.

>
> 1. Inputs: plaintext P (the artifact bytes, already compressed if
> compression is enabled), master_key K, chosen cipher_id, chosen
> chunk_size.
> 2. Draw a fresh random base_nonce (nonce_size bytes) from the crypto
> backend. Critical: must be a cryptographically secure source (§4).
> 3. Set file_size = |P|, take the chunk_size, and assemble file_header
> with the values from the cipher registry (§3.1). Compute N and the
> len(i)/is_last(i) schedule (§3.2).
> 4. For each chunk i = 0 .. N-1:
> a. take the plaintext slice pt_i of length len(i);
> b. nonce_i = base_nonce ^ u96_BE(i) (§4.1);
> c. AAD_i = file_header || u64_BE(i) || u8(is_last(i)) (§3.3);
> d. (ct_i, tag_i) = Seal(K, nonce_i, AAD_i, pt_i);
> e. append ct_i || tag_i.
>
> Decryption (on-device, streaming).
>
> 1. Read file_header from the stream.
> 2. Validate the header before any allocation or chunk read (§3.4):

Ok, I am lost. So the header in the front of the encrypted chunk (else
the name is "footer" and not "header"), but it is authenticated with the
tag that is *appended* at the end of the chunk. But then it depends on
something it will come later (or even it does not come), and the whole
chunk must be stored before decryption, while today it works on the fly
(exception is asymmetric decryption, but it is supported only for
sw-description with shorter file size than usual artifacts).
I think that somewhere should be added that one add-on value is that
decrypted data are not installed at all and then rejected as today, but
they are rejected before being copied to the storage.

>
> §7 Compatibility with Delta Updates
>

I do not see how this works...

> SWUpdate supports delta updates via the ZCK format, where variable-length
> chunks are selected by content-defined boundaries and then compressed. The
> algorithm is optimized to keep the number of new-data chunks low between
> versions. The device requests only the chunks it does not already have,
> keeping the transfers small. If a larger patch of consecutive chunks has
> to be transmitted, it is merged into a single HTTP byte-range request
> instead of multiple per-chunk requests. This mode of operation is
> unencrypted today.
>
> One obvious and convenient way of handling encrypted delta updates is by
> applying an offset-preserving stream cipher like AES-CTR, which was
> discussed on-list in 2023 [3]. Data blocks can be requested and decrypted
> at byte granularity in this mode, though without the additional security
> guarantees provided by authenticated modes. This is orthogonal to the AEAD
> support proposed here.

Sure - AES-CTR has the advantage that we do not need to extend the ZCK
project.

>
> Our current design neither directly supports nor precludes a future AEAD
> delta path. Due to limited project resources, we had to focus on the
> normal install first. There is a general tension here: streaming AEAD
> requires a chunked format with per-chunk authentication, while ZCK favors
> random-access to arbitrary byte ranges. Two broad approaches exist,
> differing in where the AEAD and ZCK chunk boundaries are reconciled:
>
> (1) Producer-side: a version-2 variable-size frame whose boundaries align
> to the ZCK chunks, so each frame decrypts exactly one ZCK chunk.
>
> (2) Client-side: keep fixed-size frames, fetch the frames overlapping each
> needed ZCK chunk, and trim the excess after decryption.

I guess that to introduce together with delta, ZCK should be extended
(as already done once for compressed chunk). The ZCK header should
contain meta information if a chunk is encrypted, which is the cipher,
etc. Everything flows then in the ZCK header. and its library.

SWUpdate is a consumer of the zcklib, and it must be extended to support
the new ZCK header. In the same time, work must be done together with
Jonathan (ZCK maintainer) to introduce the new format, as done
previously to work with compressed chunks.

>
> We have not worked out either approach in detail. Deciding which is
> practical would need a closer look at ZCK and a careful security analysis,
> which is future work.
>
> §8 Implementation and integration details
>
> This section covers how the AEAD path integrates into the existing
> pipeline and the cipher identifier space that the wire format depends on.
> The finer implementation detail arrives with the patches.
>
> The decryption path is added as a new aead_step, parallel to the existing
> decrypt_step.

Why is not decrypt_step where the type ic checked ?
In any case yes, the interface is thought to be extended.

> as optional entry points,

Fine.

> so a module that does not
> do AEAD just leaves them NULL, and they sit idle whenever an AES-CBC
> artifact is decrypted. Getting that to sit cleanly next to the existing
> CBC handling may mean a little refactoring of the shared interface, which
> we would keep behavior-preserving for what is already there. For the first
> version we would implement the AEAD operations only for the OpenSSL AES
> module.
>
> That is the full design as it stands today. Both the format and the
> integration ideas are open for discussion, we appreciate your input.

Let's wait for other input !
Best regards,
Stefano

-- _______________________________________________________________________
Nabla Software Engineering GmbH
Hirschstr. 111A | 86156 Augsburg | Tel: +49 821 45592596
Geschäftsführer : Stefano Babic | HRB 40522 Augsburg
E-Mail: sba...@nabladev.com

Raimar Sandner

unread,
Aug 7, 2026, 12:54:42 PMAug 7
to Stefano Babic, SWUpdate Group
Hello Stefano,

thanks for your detailed feedback on the AEAD design draft. I appreciate your
review and the time you took to provide your comments.

To address them in a structured way, I have clustered your comments into topics.

## Topic 1: Motivation and Documentation

Before §1:


> Fine with me. Anyway, it will be nice if this description or part of it will
> land into the documentation, so that also the reasons for the implementation
> are explained.

Agreed, a dedicated AEAD section will be added to
doc/source/encrypted_images.rst together with the implementation patches.


On §1:

> Ok - just as note. SWUpdate splits decryption and integrity check in steps.

> [...] Adding AHEAD forbids to install a manipulated encrypted artifact in


> case the encryption key is lost during the write of the chunk, while SWUpdate
> currently recognizes this just after having decrypted the stream.

On §6

> I think that somewhere should be added that one add-on value is that
> decrypted data are not installed at all and then rejected as today, but they
> are rejected before being copied to the storage.

Exactly, this is the core feature of any AEAD approach. Unverified plaintext
never leaves the decryption step. We will make sure to address this feature
explicitly in the documentation. On the event of an authentication failure in
chunk k, chunks 0..k-1 will have been written to storage, but chunk k will
never be released.


## Topic 2: File format

On §3.1:

> I am quite confused why we need a sort of pre-header, that at the end, must
> be verified as well, when all meta-information inside sw-description are
> verified before starting. Also using a binary format for this header is not
> convincing me.

On §3.3:

> Is it just a general / theoretical approach ? I mean, the use case is to
> encrypt an artifact and deliver inside the SWU, and then most fields are
> already verified as part of the authentication of sw-description.

Ok, the parameters move into the sw-description, the header disappears.
Advantages: No header to parse in the pipeline, the parameters become part of
the signed manifest (validated by design), and the artifact contains only the
encrypted payload and tags, which is as close as it gets to the current AES-CBC
design.

Manifest example:

images: (
{
filename = "rootfs.ext4.enc";
device = "/dev/mmcblk0p2";
sha256 = "<hash of the encrypted artifact>";
encrypted = "aes-gcm-256";
aead-base-nonce = "<24 hex chars, the GCM base nonce>";
properties: {
aead-format = "1";
decrypted-size = "104857600";
chunk-size = "65536";
};
}
);

Re-using the ivt field as base nonce also works after slight modifications to
its length check, your call.

The aead-format property in the manifest can default to 1, the only format
available in the beginning, so it can be omitted until a new format becomes
available and needs to be selected. This supports future extensions without
disruption.

The encrypted artifact rootfs.ext4.enc:

Chunk 0 : ct (chunk_size B) || tag (tag_size B)
Chunk 1 : ct (chunk_size B) || tag (tag_size B)
...
Chunk N-1 : ct (last_size B) || tag (tag_size B)

On §2:

> So just to understand: you want to introduce a new file format, just for
> AHEAD, instead of relying on the underlying cipher. I will bother that this
> introduce a discontinuity in the project.

The AEAD cipher emits one tag per invocation, and streaming forces more than
one invocation. With the binary header now gone, what remains is a minimal file
format (ciphertext + tags interleaved).

The AAD to bind into each chunk could be a canonical serialization of the
parameters, to prevent cipher / format confusion explicitly:

u16_BE(aead-format) || u16_BE(cipher_id) || u64_BE(chunk_index) || u8(is_last)

Note that these bytes do not have to be carried on the wire with each chunk,
they are known to the decryptor and only used as input to the cipher.

Binding these parameters as AAD is the minimum security-relevant set when there
are no verified parameters available. This situation could occur when the
sw-description itself should be AEAD-encrypted, which is currently out of scope
but a use case worth considering.

For a AEAD-encrypted sw-description, we would have to prepend the ciphertext
with the base nonce as a single 12B value for AES-GCM (the nonce has to be
fresh with each build, so it has to travel with the artifact).


## Topic 3: Tooling

On §5:

> well, I quite bother about this works inside the ecosystem. Building SWU then
> depends on this external tool, on OE this requires to build swupdate-native,
> too. I hoped we could use a standard tool instead of new one.

> For Python, I do not see it as optional but as part (extension) of
> swugenerator.

About the standard tool: You are right that 'openssl enc' does not support AEAD
the way it supports AES-CBC. We understand the appeal of offloading the
encryption to such a shell command. For chunked streaming AEAD however, we have
not found a suitable equivalent that emits a format compatible with all
SWUpdate crypto backends and supports the AAD we need to bind into each chunk.

Agreed to the Python commitment, we will deliver proper Python tooling as
patches to swugenerator. Swugenerator shells out to openssl today. Without a
shell tool for AEAD, an additional dependency on a crypto library is required.
We would prefer 'cryptography' [1], but any other library supporting AES-GCM
could be used. This dependency could be made optional (e.g. 'pip install
swugenerator[aead]' would then pull the additional dependency and install
swugenerator with AEAD support).

For a C tool: with header parsing no longer needed, the encryption tool would
end up being a very thin wrapper around the OpenSSL EVP API, only responsible
for chunking and encryption. Whether such a tool is needed additionally to
swugenerator depends on the planned meta-swupdate integration.

Two paths look viable to us: (1) a C tool in the swupdate tree and a
swupdate-native dependence in meta-swupdate (as optional dependency where AEAD
is actually needed, similar to the optional openssl-native dependency that
already exists). (2) Offering a native python3-swugenerator recipe depending on
python3-cryptography from oe-core.

We would propose to leave the meta-swupdate integration out of this first
series, since it depends on the encoder's command line interface, and that
should settle in review first. We are glad to do it afterwards, or inside this
series if you prefer.

Two questions:

- Do you want the meta-swupdate integration in this contribution?
- If yes, which of the two dependencies is more acceptable in your class?

Your answer decides which work packages we start now.


## Topic 4: Memory and Streaming

On §5:

> Ok, I am lost. So the header in the front of the encrypted chunk (else the
> name is "footer" and not "header"), but it is authenticated with the tag that
> is *appended* at the end of the chunk. But then it depends on something it
> will come later (or even it does not come), and the whole chunk must be
> stored before decryption, while today it works on the fly (exception is
> asymmetric decryption, but it is supported only for sw-description with
> shorter file size than usual artifacts).

Sorry for the confusion, our way of describing the AAD in §3.2 was misleading.
The header is not part of each chunk, it is static data (read once at the
beginning of the stream under the initial design) that is buffered and fed into
each chunk's cipher operation as additional authenticated data. This is also
true for the design above, without the binary header, where the AAD comes from
the manifest and locally computable data (in the case of is_last and
chunk_index).

Correct is that a whole chunk must be stored before decryption, or to be
precise, before authentication. This ties back to the property never to release
unverified plaintext, motivated in §1. The variable chunk size is the knob to
tune the memory footprint from low to high, depending on the available memory
and the performance requirements. 64 KiB is our design's default, 1 MiB is max.
Thinking about it, a minimum chunk size of 16KiB is probably a better fit than
the 4 KiB we had in the initial draft, because it matches the "in"-buffer used
today, and there is little reason to go lower.

Our decrypt step would call the upstream step in a loop, keeping book of the
chunk buffer fill level. Once the chunk buffer is filled with chunk_size bytes,
tag_size more bytes are read from upstream, the buffer is decrypted in-place,
and the tag is verified. On successful authentication only, the chunk is
released to the downstream step at its own pace.


On §3.1:

> Easy to understand, but where is allocated the memory ? Is it done by the
> crypto backend (EVP_ in openssl), or where ?

The memory (a single buffer of at most 1 MiB) is owned by the decrypt step,
allocated on the heap, passed to the crypto backend for in-place decryption and
freed on teardown. If you think 1 MiB is too much for some low-end devices
SWUpdate targets, we can add a compile-time cap. The decrypt step would then
allocate the chunk size given in the manifest, and reject an artifact asking
for more than the cap.


## Topic 5: Nonce handling and reproducible builds

On §3.1:

> Is this random per artifact but stable ? We want to have reproducible builds.
> If I regenerate after N years a release, I want to get the same build, that
> is the hash of the SWU is the same. Where is coming the nonce ?

The nonce is drawn fresh from a CSPRNG for every encryption, so an
AES-GCM-encrypted artifact differs on every build. Only images that opt into an
AES-GCM cipher become non-reproducible.

Deterministic encryption (required for reproducible builds) and proper nonce
hygiene for AES-GCM pull in opposite directions. If we allow the nonce to be
handled by the build system, the burden of nonce-uniqueness becomes the
responsibility of the environment. There is a very high risk of nonce reuse:
suppose the build after N years flips only a single bit and is inadvertently
encrypted with the same nonce as the original build, an attacker observing both
builds can trivially recover the (AES-GCM-internal) authentication key and from
there on forge tags under the AES master key. The encryption tool therefore
generates the base nonce itself. There is no option to supply one.

Reproducibility vs Probabilistic Encryption (encrypting the same plaintext
twice under the same key yields different outcomes) is a trade-off we have to
make. The probabilistic nature is a requirement of its own for some use cases,
i.e. the fact that version 5.0.1 of an artifact is identical to version 5.0.2
is not disclosed by inspecting the .swu.

AES-GCM is a good fit when probabilistic encryption is desired. For the
opposite case, AEAD encryption for reproducible builds, we can add a cipher
which natively supports deterministic encryption by design, e.g. AES-SIV (RFC
5297) with a nonce size of zero. The cipher registry is built to support such
an extension, it is however out of scope for the current project.

On §4.3:

> Automatically ??

No, the main point of our calculations is that the nonce ceiling is practically
unreachable for the use case of SWUpdate, so monitoring the nonce budget is not
required. A key rotation (which happens to reset the budget) is of course
always a carefully coordinated procedure outside of SWUpdate, and might be
required for a multitude of reasons, but reaching the nonce ceiling is not one
of them. Presenting it here without comment as mitigation did not add anything
useful, so we will remove it from the draft.


## Topic 6: Delta and ZCK

On §7:

> I do not see how this works...

> I guess that to introduce together with delta, ZCK should be extended (as


> already done once for compressed chunk). The ZCK header should contain meta
> information if a chunk is encrypted, which is the cipher, etc. Everything
> flows then in the ZCK header. and its library.

Agreed, the title was misleading, and your proposed approach of supporting AEAD
directly in ZCK is superior to both of our suggestions. We will update this
section accordingly.


## Topic 7: Technical details and Integration

On §3.2:

> ==> should not be divided instead of sum ?

Exactly right, the division is a bit hidden in our notation. We will make it clear by writing

N = max(1, ceil(file_size / chunk_size))

which is equivalent, but more explicit. The implementation might still use the
integer division, to stay clear of floating point operations and rounding
issues.

On §3.1:

> Does it work the current framework based on DECRYPT_init, DECRIPT_update and
> DECRYPT_final entry points ?

Yes those entry points are still used with the AEAD cipher, complemented by the
two additional entry points for setting the current chunk's nonce and verifying
the tag (§8).


On §7:

> Why is not decrypt_step where the type ic checked ?

Agreed, this is the more natural place for dispatching. We will update the
design accordingly.

---

We will fold these answers into a revised design document, which will come with
the patch series.

The two questions in the tooling section are the ones we need answered to plan
the next steps. The rest can wait for the patches.

Comments from anyone else on the list are welcome of course.

Best regards,
Raimar


[1] https://cryptography.io/en/latest/

Stefano Babic

unread,
Aug 18, 2026, 7:43:44 AMAug 18
to Raimar Sandner, Stefano Babic, SWUpdate Group
Hi Raimar,

On 8/7/26 18:54, 'Raimar Sandner' via swupdate wrote:
> Hello Stefano,
>
> thanks for your detailed feedback on the AEAD design draft. I appreciate your
> review and the time you took to provide your comments.
>
> To address them in a structured way, I have clustered your comments into topics.
>
> ## Topic 1: Motivation and Documentation
>
> Before §1:
>> Fine with me. Anyway, it will be nice if this description or part of it will
>> land into the documentation, so that also the reasons for the implementation
>> are explained.
>
> Agreed, a dedicated AEAD section will be added to
> doc/source/encrypted_images.rst together with the implementation patches.
>
>

+1

> On §1:
>
>> Ok - just as note. SWUpdate splits decryption and integrity check in steps.
>> [...] Adding AHEAD forbids to install a manipulated encrypted artifact in
>> case the encryption key is lost during the write of the chunk, while SWUpdate
>> currently recognizes this just after having decrypted the stream.
>
> On §6
>
>> I think that somewhere should be added that one add-on value is that
>> decrypted data are not installed at all and then rejected as today, but they
>> are rejected before being copied to the storage.
>
> Exactly, this is the core feature of any AEAD approach. Unverified plaintext
> never leaves the decryption step. We will make sure to address this feature
> explicitly in the documentation. On the event of an authentication failure in
> chunk k, chunks 0..k-1 will have been written to storage, but chunk k will
> never be released.

Ok - I want just to be sure that the added value is recognized, so this
should be also written in documentation. Feel free to add further files
to doc just to explain in details.

>
>
> ## Topic 2: File format
>
> On §3.1:
>
>> I am quite confused why we need a sort of pre-header, that at the end, must
>> be verified as well, when all meta-information inside sw-description are
>> verified before starting. Also using a binary format for this header is not
>> convincing me.
>
> On §3.3:
>
>> Is it just a general / theoretical approach ? I mean, the use case is to
>> encrypt an artifact and deliver inside the SWU, and then most fields are
>> already verified as part of the authentication of sw-description.
>
> Ok, the parameters move into the sw-description, the header disappears.

Very good.

> Advantages: No header to parse in the pipeline, the parameters become part of
> the signed manifest (validated by design), and the artifact contains only the
> encrypted payload and tags, which is as close as it gets to the current AES-CBC
> design.

Exactly.

>
> Manifest example:
>
> images: (
> {
> filename = "rootfs.ext4.enc";
> device = "/dev/mmcblk0p2";
> sha256 = "<hash of the encrypted artifact>";
> encrypted = "aes-gcm-256";
> aead-base-nonce = "<24 hex chars, the GCM base nonce>";

Just a nitpick - move aead-base-nonce to properties because this is not
a global parameter but it makes sense only when aes-gcm-256 is set.

> properties: {
> aead-format = "1";
> decrypted-size = "104857600";
> chunk-size = "65536";
> };
> }
> );
>
> Re-using the ivt field as base nonce also works after slight modifications to
> its length check, your call.
>
> The aead-format property in the manifest can default to 1, the only format
> available in the beginning, so it can be omitted until a new format becomes
> available and needs to be selected. This supports future extensions without
> disruption.
>

Ok

> The encrypted artifact rootfs.ext4.enc:
>
> Chunk 0 : ct (chunk_size B) || tag (tag_size B)
> Chunk 1 : ct (chunk_size B) || tag (tag_size B)
> ...
> Chunk N-1 : ct (last_size B) || tag (tag_size B)
>

Ok from my point of view.

> On §2:
>
>> So just to understand: you want to introduce a new file format, just for
>> AHEAD, instead of relying on the underlying cipher. I will bother that this
>> introduce a discontinuity in the project.
>
> The AEAD cipher emits one tag per invocation, and streaming forces more than
> one invocation. With the binary header now gone, what remains is a minimal file
> format (ciphertext + tags interleaved).
>

Ok

> The AAD to bind into each chunk could be a canonical serialization of the
> parameters, to prevent cipher / format confusion explicitly:
>
> u16_BE(aead-format) || u16_BE(cipher_id) || u64_BE(chunk_index) || u8(is_last)
>
> Note that these bytes do not have to be carried on the wire with each chunk,
> they are known to the decryptor and only used as input to the cipher.

Agree

>
> Binding these parameters as AAD is the minimum security-relevant set when there
> are no verified parameters available. This situation could occur when the
> sw-description itself should be AEAD-encrypted, which is currently out of scope
> but a use case worth considering.

I do not think this is worth. sw-description is not flashed but is is
interpreted by SWUpdate.

>
> For a AEAD-encrypted sw-description, we would have to prepend the ciphertext
> with the base nonce as a single 12B value for AES-GCM (the nonce has to be
> fresh with each build, so it has to travel with the artifact).

IMHO we do not need aead encrypted sw-description at all. Supported asym
encryption gives for sw-description many more advantages, and
sw-description is not taken at all if its verification fails.

>
>
> ## Topic 3: Tooling
>
> On §5:
>
>> well, I quite bother about this works inside the ecosystem. Building SWU then
>> depends on this external tool, on OE this requires to build swupdate-native,
>> too. I hoped we could use a standard tool instead of new one.
>
>> For Python, I do not see it as optional but as part (extension) of
>> swugenerator.
>
> About the standard tool: You are right that 'openssl enc' does not support AEAD
> the way it supports AES-CBC. We understand the appeal of offloading the
> encryption to such a shell command. For chunked streaming AEAD however, we have
> not found a suitable equivalent that emits a format compatible with all
> SWUpdate crypto backends and supports the AAD we need to bind into each chunk.

Agree, I do not see any tool, even if it seems straightforward to
implement it via libssl / EVP.

>
> Agreed to the Python commitment, we will deliver proper Python tooling as
> patches to swugenerator.

Ok- this could be also a main reason to get swugenerator merged into OE
and replace the classes of meta-swupdate.

> Swugenerator shells out to openssl today. Without a
> shell tool for AEAD, an additional dependency on a crypto library is required.
> We would prefer 'cryptography' [1],

cryptography is alse already supported in OE (oe-core), so this does not
forbids to build a swugenerator-native. So it looks ok at first glance.


> but any other library supporting AES-GCM
> could be used. This dependency could be made optional (e.g. 'pip install
> swugenerator[aead]' would then pull the additional dependency and install
> swugenerator with AEAD support).
>
> For a C tool: with header parsing no longer needed, the encryption tool would
> end up being a very thin wrapper around the OpenSSL EVP API, only responsible
> for chunking and encryption. Whether such a tool is needed additionally to
> swugenerator depends on the planned meta-swupdate integration.

Ok

>
> Two paths look viable to us: (1) a C tool in the swupdate tree and a
> swupdate-native dependence in meta-swupdate (as optional dependency where AEAD
> is actually needed, similar to the optional openssl-native dependency that
> already exists). (2) Offering a native python3-swugenerator recipe depending on
> python3-cryptography from oe-core.

2) will be nice.

>
> We would propose to leave the meta-swupdate integration out of this first
> series, since it depends on the encoder's command line interface, and that
> should settle in review first. We are glad to do it afterwards, or inside this
> series if you prefer.

Ok

>
> Two questions:
>
> - Do you want the meta-swupdate integration in this contribution?

No, it can be split.

> - If yes, which of the two dependencies is more acceptable in your class?
>
> Your answer decides which work packages we start now.
>
>
> ## Topic 4: Memory and Streaming
>
> On §5:
>
>> Ok, I am lost. So the header in the front of the encrypted chunk (else the
>> name is "footer" and not "header"), but it is authenticated with the tag that
>> is *appended* at the end of the chunk. But then it depends on something it
>> will come later (or even it does not come), and the whole chunk must be
>> stored before decryption, while today it works on the fly (exception is
>> asymmetric decryption, but it is supported only for sw-description with
>> shorter file size than usual artifacts).
>
> Sorry for the confusion, our way of describing the AAD in §3.2 was misleading.
> The header is not part of each chunk, it is static data (read once at the
> beginning of the stream under the initial design) that is buffered and fed into
> each chunk's cipher operation as additional authenticated data. This is also
> true for the design above, without the binary header, where the AAD comes from
> the manifest and locally computable data (in the case of is_last and
> chunk_index).

Ok

>
> Correct is that a whole chunk must be stored before decryption, or to be
> precise, before authentication. This ties back to the property never to release
> unverified plaintext, motivated in §1. The variable chunk size is the knob to
> tune the memory footprint from low to high, depending on the available memory
> and the performance requirements. 64 KiB is our design's default, 1 MiB is max.
> Thinking about it, a minimum chunk size of 16KiB

SWUpdate internally uses 16KiB buffers to load from network and then to
fill the buffers for decryption / decompression /etc.

> is probably a better fit than
> the 4 KiB we had in the initial draft, because it matches the "in"-buffer used
> today, and there is little reason to go lower.
>
> Our decrypt step would call the upstream step in a loop, keeping book of the
> chunk buffer fill level. Once the chunk buffer is filled with chunk_size bytes,
> tag_size more bytes are read from upstream, the buffer is decrypted in-place,
> and the tag is verified. On successful authentication only, the chunk is
> released to the downstream step at its own pace.

Ok, more than the patch will be published.

>
>
> On §3.1:
>
>> Easy to understand, but where is allocated the memory ? Is it done by the
>> crypto backend (EVP_ in openssl), or where ?
>
> The memory (a single buffer of at most 1 MiB) is owned by the decrypt step,
> allocated on the heap, passed to the crypto backend for in-place decryption and
> freed on teardown. If you think 1 MiB is too much for some low-end devices
> SWUpdate targets, we can add a compile-time cap. The decrypt step would then
> allocate the chunk size given in the manifest, and reject an artifact asking
> for more than the cap.

Ok, it is clear now, thanks.
Ok

> A key rotation (which happens to reset the budget) is of course
> always a carefully coordinated procedure outside of SWUpdate, and might be
> required for a multitude of reasons, but reaching the nonce ceiling is not one
> of them. Presenting it here without comment as mitigation did not add anything
> useful, so we will remove it from the draft.
>

Ok

>
> ## Topic 6: Delta and ZCK
>
> On §7:
>
>> I do not see how this works...
>
>> I guess that to introduce together with delta, ZCK should be extended (as
>> already done once for compressed chunk). The ZCK header should contain meta
>> information if a chunk is encrypted, which is the cipher, etc. Everything
>> flows then in the ZCK header. and its library.
>
> Agreed, the title was misleading, and your proposed approach of supporting AEAD
> directly in ZCK is superior to both of our suggestions. We will update this
> section accordingly.
>

Fine with me.

>
> ## Topic 7: Technical details and Integration
>
> On §3.2:
>
>> ==> should not be divided instead of sum ?
>
> Exactly right, the division is a bit hidden in our notation. We will make it clear by writing
>
> N = max(1, ceil(file_size / chunk_size))
>
> which is equivalent, but more explicit. The implementation might still use the
> integer division, to stay clear of floating point operations and rounding
> issues.
>

Ok

> On §3.1:
>
>> Does it work the current framework based on DECRYPT_init, DECRIPT_update and
>> DECRYPT_final entry points ?
>
> Yes those entry points are still used with the AEAD cipher, complemented by the
> two additional entry points for setting the current chunk's nonce and verifying
> the tag (§8).
>
>
> On §7:
>
>> Why is not decrypt_step where the type ic checked ?
>
> Agreed, this is the more natural place for dispatching. We will update the
> design accordingly.
>

Ok

> ---
>
> We will fold these answers into a revised design document, which will come with
> the patch series.
>
> The two questions in the tooling section are the ones we need answered to plan
> the next steps. The rest can wait for the patches.
>
> Comments from anyone else on the list are welcome of course.


Best regards,
Stefano


>
> Best regards,
> Raimar
>
>
> [1] https://cryptography.io/en/latest/
>
>
> Mit freundlichen Grüßen / Best regards
>
> i.A. Dr. Raimar Sandner
> Expert in Industrial Cybersecurity | Autonomous Perception
>
>
> _____________________________________________________________________________________
> SICK AG | Erwin-Sick-Str. 1 | 79183 Waldkirch | Germany
> P +49 7681 202-6404 | raimar....@sick.de | www.sick.de
> _____________________________________________________________________________________
>
> SICK AG | Sitz: Waldkirch i. Br. | Handelsregister: Freiburg i. Br. HRB 280355 | WEEE-Reg.-Nr. DE 14165396
> Vorstand: Dr. Mats Gökstorp (Vorsitzender) | Jan-H. Eberhardt | Ulrike Kahle-Roth | Nicole Kurek | Markus Scaglioso | Dr. Niels Syassen
> Aufsichtsrat: Dr. Robert Bauer (Vorsitzender)
>

Reply all
Reply to author
Forward
0 new messages