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)