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)
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/