Post

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.

A Modern and Simple Alternative to GPG: age and the Cryptographic Engineering 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, age does 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 minisign or ssh for 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.

This post is licensed under CC BY 4.0 by the author.