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

27 views
Skip to first unread message

Raimar Sandner

unread,
Aug 3, 2026, 11:10:40 AM (2 days ago) Aug 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,
6:29 AM (2 hours ago) 6:29 AM
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

Reply all
Reply to author
Forward
0 new messages