A Modern and Simple Alternative to GPG: age and the Cryptographic Engineering Behind It
🇬🇧 By examining the source code of the 'age' file encryption tool, which is a modern alternative to the complex structure of GPG, we discuss the cryptographic engineering, code architecture, and post-quantum capabilities behind it.
For years, when it comes to file encryption and signing, the first (and often only) tool that comes to mind has been GPG (GnuPG). However, GPG’s complex configuration options, cumbersome key management (keyring), and stretched codebase are frequently criticized in modern use cases.
This is exactly where age, designed by Filippo Valsorda, comes into play. age is a file encryption tool and format that features “small, explicit keys, no configuration options, and UNIX-style composability.”
In this article, we will dive into the source code of the age repository to explore the elegant cryptographic engineering, code architecture, and post-quantum (PQ) encryption capabilities behind this tool.
A Brief Comparison with GPG
Why was a new tool needed? Here is the concrete difference:
Encrypting a file with GPG:
1
2
3
4
5
6
gpg --gen-key # Interactive key generation wizard
gpg --keyserver keys.openpgp.org \
--search-keys recipient@example.com # Search on a keyserver
gpg --fingerprint recipient@example.com # Fingerprint verification
gpg --sign-key recipient@example.com # Signing the web of trust
gpg --encrypt -r recipient@example.com file.txt # Encryption
The same process with age:
1
2
3
age-keygen -o key.txt # Key generation
age -r age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j... \
file.txt > file.age # Encryption
While GPG has the complexity of Web of Trust, keyring management, and keyservers; age has none of these — intentionally.
Core Design Philosophy
When we examine the source code of age (especially the cmd/age and age.go files), we see that its design is based on the following foundations:
- No Global Keyring: Unlike GPG,
agedoes not maintain a hidden database in the background. Keys are simple text files, just like SSH keys. - Do One Thing, Do It Well: The tool only encrypts and decrypts. The signing functionality was intentionally excluded from its scope (you can use
minisignorsshfor that). - Malleability Protection: Encrypted files are protected by AEAD (Authenticated Encryption with Associated Data). If even a single bit of the file changes, the decryption process will fail.
Quick Start
1
2
3
4
5
6
7
8
9
10
11
12
# Generating a key pair
age-keygen -o ~/.age/key.txt
# Output: Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j...
# Encryption
age -r age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j... secret.txt > secret.age
# Decryption
age -d -i ~/.age/key.txt secret.age > secret.txt
# Password encryption (without an asymmetric key)
age -p secret.txt > secret.age
Architecture: File Format and Encryption Process
When a file is encrypted with age, the process happens in two main stages: Header creation and Payload encryption.
1. File Key
For every encryption operation, age generates a random 16-byte (128-bit) File Key. 128-bit provides 2^128 brute-force resistance against classical computers. The File Key is not used directly as the encryption key; it is expanded using HKDF, and the actual encryption is done with a 256-bit Stream Key. Therefore, a 128-bit File Key is sufficient.
This 16-byte File Key is then converted into a 32-byte (256-bit) Stream Key using the HKDF algorithm, and the actual payload is encrypted with this derived key.
For each recipient intended to read the file, the original 16-byte File Key is encrypted with the recipient’s public key and added to the header section.
graph TD
A[Original File] --> STREAM[STREAM Encryption - ChaCha20Poly1305]
B[16-byte Random File Key] --> STREAM
STREAM --> C[Encrypted Payload]
B --> W1[Encrypt for Recipient 1 - X25519]
B --> W2[Encrypt for Recipient 2 - SSH-RSA]
B --> W3[Encrypt for Recipient 3 - Post-Quantum]
W1 --> H[Header]
W2 --> H
W3 --> H
H --> F[Final .age File]
C --> F
2. Header and Stanza Structure
When we look at the internal/format package, we see that the header is plain text (ASCII) based. The header always begins with the age-encryption.org/v1 version info.
A Stanza is added to the header for each recipient. For example, a Stanza for an X25519 recipient looks like this:
-> X25519 [Ephemeral_Public_Key] followed by the Base64-encoded, encrypted File Key on the next line.
The header is subjected to an integrity check with an HMAC (Header MAC, specifically HMAC-SHA256) appended after all stanzas. This prevents attackers from adding or removing malicious recipients to the header.
3. Payload and STREAM Encryption
One of the real engineering marvels lies in the internal/stream/stream.go file. Instead of encrypting huge files all at once and exhausting memory (RAM), age uses a chunking mechanism called STREAM.
- The file is divided into 64 KB chunks.
- ChaCha20-Poly1305 is used as the encryption algorithm. It runs faster on systems without hardware acceleration (AES-NI) compared to AES-GCM; furthermore, it is inherently resistant to cache-timing side-channel attacks.
- The Stream Key is derived from the File Key and a random nonce using HKDF-SHA256.
- The nonce value of each chunk is incremented according to the chunk’s sequence number. This prevents reordering or dropping chunks.
- The last byte of the nonce for the final chunk is marked as
0x01. This allows immediate detection of file truncation attacks.
sequenceDiagram
participant Plaintext
participant Chunk as 64KB Chunk
participant Cipher as ChaCha20-Poly1305
participant Output as Encrypted File
Plaintext->>Chunk: Data Stream
loop For each 64 KB
Chunk->>Cipher: Data + StreamKey + Nonce (Counter: N)
Cipher->>Output: Encrypted Data + Poly1305 MAC
end
Note over Cipher: For the last chunk, the last byte<br/>of the Nonce is set to 0x01.
Supported Key and Recipient Types
A polymorphic structure has been established in the codebase using the Recipient and Identity interfaces.
A. X25519 (The Default Modern Key)
These are the standard age keys. It is located in x25519.go in the code. To prevent people from miscopying or typing keys incorrectly, it uses the Bech32 format (internal/bech32), known from Bitcoin.
- Public Key: Starts with
age1... - Secret Key: Starts with
AGE-SECRET-KEY-1...
B. SSH Key Compatibility
To facilitate ecosystem integration, the agessh/ package allows encrypting using existing ssh-rsa and ssh-ed25519 keys. Encrypting to the keys of a user on GitHub is as simple as:
1
2
curl -o torvalds.keys https://github.com/torvalds.keys
age -R torvalds.keys secret_document.txt > document.age
C. Scrypt (Password Protection)
If you do not want to use an asymmetric key, you can do password-based encryption with age -p. The scrypt.go file uses the golang.org/x/crypto/scrypt algorithm to increase the work factor against brute-force attacks on the password.
D. Post-Quantum Encryption (Hybrid ML-KEM-768 + X25519)
One of the exciting features of age is the hybrid structure that combines the NIST standard ML-KEM-768 with X25519 to protect the data against future “harvest now, decrypt later” attacks by quantum computers. This feature is located in the pq.go file and can be used through the plugin system such as age-plugin-pq or directly via the main library.
Note: We recommend checking the official GitHub repository before relying on PQ support in stable releases, as this area is under active development. PQ public keys begin with
age1pq1...(and are approximately 2000 characters long).
Plugin System and YubiKey Integration
The plugin/ folder demonstrates how extensible age is using UNIX pipes and stdin/stdout streams.
If age reads an unsupported stanza name like -> yubikey-piv in the header while decrypting the file, it looks for a binary (executable) named age-plugin-yubikey-piv in your $PATH. When it finds it, it launches this subprocess and sends the encrypted File Key to this tool over stdout, waiting for it to decrypt and return.
Thanks to this architecture, independent plugins can be developed for hardware tokens (YubiKey, Trezor), hardware security modules (KMS), or cloud providers without bloating the core age codebase.
Ecosystem: rage (The Rust Implementation)
Alongside the original age written in Go, a Rust implementation named rage is also under active development. Fully compatible in terms of format and protocol, rage is a good alternative for developers who want to incorporate Rust’s memory safety guarantees into their toolchain. Both implementations can smoothly read and write .age files.
Conclusion
age, with its code quality, minimalism, and secure defaults, is a perfect response to GPG’s clunkiness. Backed by the power of the Go language and modern cryptography standards (ChaCha20-Poly1305, X25519, ML-KEM), it has become a must-have tool in the arsenal of security researchers, software developers, and system administrators.
If you would like to read a detailed analysis of the codebase, you can check out the GitHub repository.
