Applied Cryptography for Developers Module 7 – Symmetric encryption and AEAD
Encrypting files, streams and passphrase data
Authenticated chunks that refuse cut and shuffled files, keys from passphrases with their settings in the header, no early plaintext, and age.
What you will learn
- Encrypt large data in authenticated chunks that refuse truncation, reordering and added chunks
- Derive file keys from passphrases and store the KDF and its parameters in the header
- Avoid releasing plaintext before the final authentication check, in files and in streams
- Trace how a reader opens an age file, from its header to its last chunk
Before you start
What you’ll learn · Before you start
What you will learn
- Encrypt large data in authenticated chunks that refuse truncation, reordering and added chunks
- Derive file keys from passphrases and store the KDF and its parameters in the header
- Avoid releasing plaintext before the final authentication check, in files and in streams
- Trace how a reader opens an age file, from its header to its last chunk
Before you start
On this page
crypto.subtle.encrypt takes a whole message at once, and Web Crypto has no streaming AEAD, so a 2 GB video cannot be
one call on a phone. Cut it into 64 KiB chunks and seal each one, and a new problem appears: a file whose chunks are
sealed independently still decrypts after its last chunk is dropped, two chunks are swapped or a chunk is added.
Encrypting files safely takes a chunk layout that refuses all three, keys derived from passphrases with their settings kept in the header, and no unverified plaintext: Decrypted bytes handed out before the authentication tag has been checked. They may belong to a changed message, so they must not be written, shown or acted on until the check succeeds. in the output. This lesson builds each of them and then opens real age test files with Node.js’s standard library.
Files and streams are sealed in chunks that know their place
Encrypting files of any size therefore means encrypting them piece by piece, so that neither side holds the whole file in memory. The age format, a C2SP specification, shows the layout: a random file key: A random key made for one file and used for nothing else. It protects the file's contents, and is itself wrapped in the file's header, by a passphrase or by each recipient's key. for this file alone, a 16-byte stream nonce, a payload key from HKDF: The HMAC-based key derivation function of RFC 5869. HKDF-Extract turns input keying material and a salt into a pseudorandom key; HKDF-Expand turns that key and an info label into as many bytes as asked for, up to 255 hash blocks. More in “Deriving keys with HKDF” over both, and chunks of 64 KiB sealed with ChaCha20-Poly1305: The AEAD of RFC 8439: the ChaCha20 stream cipher with the Poly1305 authenticator, a 256-bit key, a 96-bit nonce and a 128-bit tag. It runs fast in plain software, without AES instructions. More in “Authenticated encryption: AES-GCM and ChaCha20”. Each chunk’s 12-byte nonce is an 11-byte chunk counter and a flag that is 1 only for the last chunk. The counter stops reordering; the flag stops a file from being cut short or extended. The age specification calls its payload a variant of STREAM construction: A way to encrypt a long message in chunks with an AEAD in which each chunk's nonce holds its position and a flag for the last chunk, so that a reader detects chunks that are dropped, reordered, repeated or added. The age format uses a variant., a construction from the research literature, and libsodium’s encrypted streams make the same promise with a final tag. Step through a 200,000-byte file:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- A key for one file. Every file gets its own random 16-byte file key, used for nothing else. A passphrase or a recipient key protects it in the header.
- One payload key. A fresh 16-byte stream nonce, stored at the front of the payload, and HKDF over the file key give the key that seals every chunk.
- Chunk 0. The first 64 KiB are sealed with ChaCha20-Poly1305 under a nonce that is the chunk counter, 0, and a flag byte of 0: not the last chunk.
- Chunks 1 and 2. Each next chunk raises the counter by one. A chunk moved to another place would be opened with another counter, and its tag would not check.
- The last chunk. The last 3,392 bytes are sealed with counter 3 and the flag set to 1. Only a chunk sealed as the last can end the file.
- Cut or shuffled. If the last chunk is dropped, chunk 2 must now open as the last, with flag 1, and fails. Swapped and added chunks fail their tags too, under the wrong counter or the wrong flag, so the reader refuses the file.
The program computes the layout for three sizes, with every chunk’s nonce:
# How a file is cut into authenticated chunks in the payload layout of the age format: a 16-byte random nonce in front,
# chunks of 64 KiB each sealed with a 16-byte tag, and a 12-byte nonce per chunk made of an 11-byte big-endian chunk
# counter and a last-chunk flag. A 200,000-byte file, an empty one and one of exactly two chunks.
CHUNK = 64 * 1024
TAG = 16
NONCE = 16
def chunk_nonce(counter, last):
return counter.to_bytes(11, "big") + (b"\x01" if last else b"\x00")
def layout(size):
"""(plaintext bytes, chunk nonce) for every chunk; an empty file is one empty final chunk."""
count = max(1, -(-size // CHUNK))
return [(min(CHUNK, size - i * CHUNK), chunk_nonce(i, i == count - 1)) for i in range(count)]
for size in (200_000, 0, 131_072):
chunks = layout(size)
total = NONCE + sum(length + TAG for length, _ in chunks)
print(f"{size:,} bytes: {len(chunks)} chunk{'s' if len(chunks) > 1 else ''}, {total:,} bytes stored, {total - size} of them overhead")
for length, nonce in chunks:
print(f" {length:>6,} bytes nonce {nonce.hex()}") Output
200,000 bytes: 4 chunks, 200,080 bytes stored, 80 of them overhead
65,536 bytes nonce 000000000000000000000000
65,536 bytes nonce 000000000000000000000100
65,536 bytes nonce 000000000000000000000200
3,392 bytes nonce 000000000000000000000301
0 bytes: 1 chunk, 32 bytes stored, 32 of them overhead
0 bytes nonce 000000000000000000000001
131,072 bytes: 2 chunks, 131,120 bytes stored, 48 of them overhead
65,536 bytes nonce 000000000000000000000000
65,536 bytes nonce 000000000000000000000101
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 chunk_layout.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The 200,000-byte file becomes three full chunks and one of 3,392 bytes, 80 bytes larger in all: the 16-byte stream nonce and a 16-byte tag per chunk. An empty file is still one empty final chunk, so its end is checked too, and a file of exactly two chunks ends with a full final chunk, never an empty extra one.
The same layout serves streams that never sit on a disk, such as an upload or a backup sent over the network, and those streams raise one more question: when to let plaintext out. Four other choices complete a design. Sealing chunks independently with random nonces protects each chunk but not their order or their number. Passphrase data needs a slow key-derivation function, such as scrypt or PBKDF2 at 600,000 iterations, before any key exists. The salt and cost of that function, such as an age file’s scrypt work factor: The cost setting of a password-based key derivation, such as scrypt's log₂ N in the age format, stored with the encrypted data so that each file opens with the cost it was made with., belong in the header of the passphrase data, where a reader finds them. And a streaming reader must not write or show plaintext until its tag, and for the whole file the final chunk, has checked.
Checkpoint
Check: Chunks that carry their position
Flashcards · 5 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
Why is sealing each chunk of a file independently not enough?
Web Crypto has no streaming AEAD, so a large file is cut into 64 KiB chunks. Chunks sealed independently with random nonces are each protected, but not their order or their number: the file still decrypts after its last chunk is dropped, two chunks are swapped or a chunk is added.
-
How does the age file format keep its chunks in place?
A random file key for this file alone, a 16-byte stream nonce, a payload key from HKDF over both, and 64 KiB chunks sealed with ChaCha20-Poly1305. Each chunk's 12-byte nonce is an 11-byte chunk counter and a flag that is 1 only for the last chunk. The counter stops reordering; the flag stops a file from being cut short or extended. The payload is a variant of STREAM.
-
How much larger is a file sealed in age-style chunks, and what about empty files?
A 200,000-byte file seals into three full 64 KiB chunks and a last one of 3,392 bytes, and grows by 80 bytes: a 16-byte stream nonce plus a 16-byte tag for each of four chunks. An empty file still has one empty final chunk, so that its end is checked, and a file of exactly two chunks ends in a full final chunk, never an extra empty one.
-
What does passphrase-protected data need before any key exists?
A slow key-derivation function, such as scrypt or PBKDF2 at 600,000 iterations. Its salt and cost, such as an age file's scrypt work factor, belong in the header of the passphrase data, where a reader finds them.
-
When may a streaming reader write or show plaintext?
Not until the chunk's tag has checked, and for the whole file not until the final chunk has checked. A reader that releases plaintext earlier hands out bytes that nobody has verified.
Chunks that refuse truncation, reordering and additions Pro: opens with a Pro, Premium or Ultimate pass
In this part:
- Keys from passphrases, with the parameters in the header
- No plaintext before the final check
- The age format, opened with Node.js’s standard library
- Practise: a stream that refuses cut files
- Chunked encryption that refuses cut and shuffled streams
- Interview questions
- Why are large files encrypted in chunks, and what must each chunk's nonce carry?
- Your backup tool encrypts each 1 MiB block of a file with AES-256-GCM under one key and a random nonce, and stores the blocks in order. Review it: what can go wrong without anyone noticing, and how do you change the format?
- A desktop app protects each user's vault with a key from PBKDF2-HMAC-SHA-256 at 100,000 iterations, a count hard-coded in the app and stored nowhere. Plan the move to stronger settings without locking anyone out.
- Your service decrypts encrypted 10 GB downloads on the fly with AES-GCM, piping the decipher's output into the HTTP response. Explain what is wrong and redesign the storage format and the streaming code.
- Key takeaways
1 diagram · 4 runnable examples (JavaScript) · 1 exercise with sample tests · 3 interview answers · about 30 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Terms in this lesson
- file key
- A random key made for one file and used for nothing else. It protects the file's contents, and is itself wrapped in the file's header, by a passphrase or by each recipient's key.
- STREAM construction
- A way to encrypt a long message in chunks with an AEAD in which each chunk's nonce holds its position and a flag for the last chunk, so that a reader detects chunks that are dropped, reordered, repeated or added. The age format uses a variant.
- unverified plaintext
- Decrypted bytes handed out before the authentication tag has been checked. They may belong to a changed message, so they must not be written, shown or acted on until the check succeeds.
- work factor
- The cost setting of a password-based key derivation, such as scrypt's log₂ N in the age format, stored with the encrypted data so that each file opens with the cost it was made with.
References
- The age file encryption format, version 1 (C2SP (Community Cryptography Specification Project))
- CCTV, the C2SP test vectors (age test files) (C2SP (Community Cryptography Specification Project))
- Online Authenticated-Encryption and its Nonce-Reuse Misuse-Resistance (the STREAM construction) (IACR Cryptology ePrint Archive (Hoang, Reyhanitabar, Rogaway and Vizár))
- libsodium documentation: encrypted streams and file encryption (secretstream) (libsodium)
- RFC 5869: HMAC-based Extract-and-Expand Key Derivation Function (HKDF) (Internet Engineering Task Force (IETF))
- RFC 7914: The scrypt Password-Based Key Derivation Function (Internet Engineering Task Force (IETF))
- OWASP Password Storage Cheat Sheet (PBKDF2 and scrypt settings) (OWASP Foundation)
- RFC 8439: ChaCha20 and Poly1305 for IETF Protocols (Internet Engineering Task Force (IETF))
- Node.js 24 documentation: Crypto (createDecipheriv, decipher.final, scryptSync, hkdfSync) (OpenJS Foundation (Node.js))
- Node.js 24 documentation: Web Crypto API (PBKDF2 and AES-GCM) (OpenJS Foundation (Node.js))
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress