age is a file encryption tool, library, and format. It lets you encrypt files to “recipients” and decrypt them with “identities”.
$ age-keygen -o key.txt
Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
$ tar cvz ~/data | age -r age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p > data.tar.gz.age
$ age --decrypt -i key.txt data.tar.gz.age > data.tar.gz
You can encrypt a file to multiple recipients and decrypt it with any of the corresponding identities. There are built-in recipients for public keys and for password encryption, but age supports third-party recipient types at the format, library, and tool levels. These recipient implementations can offer alternative algorithms, support for specific hardware, or even make use of remote APIs such as cloud KMS.
That’s the one “joint” in age, which otherwise aims for having no configurability.
At the format level, an age file starts with a header that includes “stanzas” each encrypting the file key to different recipients. The specification requires ignoring unrecognized stanzas, so third-party ones can coexist with native ones.[1]
Here’s for example the header of a file encrypted to both a native public key recipient, and to a YubiKey. Note the two stanzas, introduced by ->
.
age-encryption.org/v1
-> piv-p256 OIF48w A7onGmpObHNfTCVLkq0QA4r4GJmzQLc6aVMAZVhrdbKb
SZwqyoXyHDOkoIJqYvxbo2p6j6tLVHMurkLivzYFDm0
-> X25519 z2pytFfcbnyl/ARKy1VA1W7P41Otn4ei7dNnWkf/iWw
X4R193LCCdtkueqwJCSPRe/HifrrxfbO3Zu8E+OyDp8
--- iZ5zBQBDL2SzxIAM9iArGViXViYF4lqrvAh4WMLozUY
At the library level, there are two fundamental interfaces that provide this abstraction: Recipient
and Interface
.
// A Recipient is passed to Encrypt to wrap an opaque file key to one or more
// recipient stanza(s). It can be for example a public key like X25519Recipient,
// a plugin, or a custom implementation.
type Recipient interface {
Wrap(fileKey []byte) ([]*Stanza, error)
}
// Encrypt encrypts a file to one or more recipients.
func Encrypt(dst io.Writer, recipients ...Recipient) (io.WriteCloser, error)
// An Identity is passed to Decrypt to unwrap an opaque file key from a
// recipient stanza. It can be for example a secret key like X25519Identity, a
// plugin, or a custom implementation.
//
// Unwrap must return an error wrapping ErrIncorrectIdentity if none of the
// recipient stanzas match the identity, any other error will be considered
// fatal.
type Identity interface {
Unwrap(stanzas []*Stanza) (fileKey []byte, err error)
}
// Decrypt decrypts a file with one or more identities.
func Decrypt(src io.Reader, identities ...Identity) (io.Reader, error)
Any Go package can provide a type that implements a Wrap
or Unwrap
method, and pass it to age.Encrypt
or age.Decrypt
respectively.
That allows Go applications to use third-party recipients with the Go age library, but age is also a command-line tool. At the tool level, the age plugin system exposes the Recipient and Identity interfaces over a language-agnostic stdin/stdout protocol.
Plugins are selected based on the name of the recipient or identity encoding. For example if you use -r age1
yubikey
1qwt50d05nh5vutpdzmlg5wn80xq5negm4uj9ghv0snvdd3yysf5yw3rhl3t
the age CLI will invoke age-plugin-yubikey
from $PATH
to wrap the file key.[2] This means they can be mixed and matched with other plugins and native recipients.
Here’s an example transcript of decrypting the file from earlier with the YubiKey plugin, complete of the plugin asking the CLI to prompt the user for the PIN and of “grease” making sure the protocol doesn’t ossify. Note that age-plugin-yubikey is developed by str4d in Rust, while the age CLI is developed by me in Go.
$ AGEDEBUG=plugin age -d -i age-yubikey-identity-388178f3.txt < test.age
send: -> add-identity AGE-PLUGIN-YUBIKEY-1XQJ0QQY48ZQH3UC845XQL
send:
send: -> recipient-stanza 0 piv-p256 OIF48w A7onGmpObHNfTCVLkq0QA4r4GJmzQLc6aVMAZVhrdbKb
send: SZwqyoXyHDOkoIJqYvxbo2p6j6tLVHMurkLivzYFDm0
send: -> recipient-stanza 0 X25519 z2pytFfcbnyl/ARKy1VA1W7P41Otn4ei7dNnWkf/iWw
send: X4R193LCCdtkueqwJCSPRe/HifrrxfbO3Zu8E+OyDp8
send: -> done
send:
recv: -> A:zw-grease /S?j#y$ geD 3P. .|
recv: daN05YWjoDuf83JNSWc4mN/qb1suAEYWXF6VsQA1qzCixeOk8s1Uv0Bh+dqHMYM
send: -> unsupported
send:
recv: -> request-secret
recv: RW50ZXIgUElOIGZvciBZdWJpS2V5IHdpdGggc2VyaWFsIDE1NzM3OTA0
send: -> ok
send: [REDACTED]
recv: -> file-key 0
recv: GHzIb/dwF93v8SwMuxVdPQ
send: -> ok
send:
recv: -> qBZE~*,s-grease
recv: OLL8DDYeq6NvadvOLjy/GRljAFuKpBkyT3vLd1OJ+4ve02Fi
send: -> unsupported
send:
recv: -> done
recv:
Frood!
The protocol only covers encryption and decryption. Plugins are expected to handle key generation in whatever way suits them best, usually by having users invoke the plugin binary directly.
The plugin architecture is now a few years old, and there are some amazing plugins out there, including stable YubiKey and Apple Secure Enclave ones, and experimental TPM 2.0, symmetric FIDO2, and even Shamir's Secret Sharing ones.
If a Go program wishes to use a plugin with the age library, we provide Recipient and Identity implementations that automatically execute plugins to implement Wrap and Unwrap. Thanks to the careful mapping of the interface and plugin abstractions, the two are effectively interchangeable.
To complete the picture, last month Yolan Romailler and I designed an experimental Go framework to do the opposite: turn Recipient and Identity implementations into plugins, abstracting away the plugin protocol.
Assuming a library already implements NewRecipient
and NewIdentity
functions, building a plugin boils down to just a few lines in a main()
function.
func main() {
p, err := plugin.New("example")
if err != nil {
log.Fatal(err)
}
p.HandleRecipient(func(data []byte) (age.Recipient, error) {
return NewRecipient(data)
})
p.HandleIdentity(func(data []byte) (age.Identity, error) {
return NewIdentity(data)
})
os.Exit(p.Main())
}
The framework lets applications define their own CLI flags, for example for key generation, and supports the interactive features of the protocol such as displaying message or prompting the user.
It’s ready to try by go get
-ing filippo.io/age@filippo/plugin
, and was already adopted by age-plugin-tpm. Docs are on pkg.go.dev. Provide feedback in the pull request.
The Rust ecosystem already has its equivalents in the age::plugin module and age-plugin crate.
In related news, the recent v1.2.0 release of age introduced an extension to the Recipient interface—RecipientWithLabels—which lets the recipient communicate to the age implementation its preferences on being mixed with other recipients, as a set of labels. For example, a recipient that returns [postquantum]
could only be mixed with other post-quantum recipients, to avoid downgrade attacks, and a recipient that returns a random string could not be mixed with any others, allowing it to uphold its users’ expectations of authentication. This is exciting because it removes the last special case for native recipients in age, which was ensuring scrypt recipients could only be used alone. This extension is already supported in the Go plugin client and framework implementations, and we have a pull request to add it to the spec.
Open more thing! Speaking of plugins and specs, many hardware plugins had to redefine their own version of P-256 recipients with a tag (to avoid asking for useless PINs). We’re talking about defining a standard one, and supporting it on the recipient side natively in age, so that users can encrypt to various hardware-based recipients without installing the plugin.
If you got this far, you might also want to follow me on Bluesky at @filippo.abyssdomain.expert or on Mastodon at @[email protected].
Took a friend on her first scuba dive last year, and went looking for scorpionfishes in the shallow water. Ugly little fella. (This year marks the 20th anniversary of my first dive. I’m a bit unsettled by the idea that I’ve been doing this, or anything really, for two decades.)
All this work is funded by the awesome Geomys clients: Latacora, Interchain, Smallstep, Ava Labs, Teleport, SandboxAQ, Charm, and Tailscale. Through our retainer contracts they ensure the sustainability and reliability of our open source maintenance work and get a direct line to my expertise and that of the other Geomys maintainers. (Learn more in the Geomys announcement.)
Here are a few words from some of them!
Latacora — Latacora bootstraps security practices for startups. Instead of wasting your time trying to hire a security person who is good at everything from Android security to AWS IAM strategies to SOC2 and apparently has the time to answer all your security questionnaires plus never gets sick or takes a day off, you hire us. We provide a crack team of professionals prepped with processes and power tools, coupling individual security capabilities with strategic program management and tactical project management.
Teleport — For the past five years, attacks and compromises have been shifting from traditional malware and security breaches to identifying and compromising valid user accounts and credentials with social engineering, credential theft, or phishing. Teleport Identity Governance & Security is designed to eliminate weak access patterns through access monitoring, minimize attack surface with access requests, and purge unused permissions via mandatory access reviews.
Ava Labs — We at Ava Labs, maintainer of AvalancheGo (the most widely used client for interacting with the Avalanche Network), believe the sustainable maintenance and development of open source cryptographic protocols is critical to the broad adoption of blockchain technology. We are proud to support this necessary and impactful work through our ongoing sponsorship of Filippo and his team.
SandboxAQ — SandboxAQ’s AQtive Guard is a unified cryptographic management software platform that helps protect sensitive data and ensures compliance with authorities and customers. It provides a full range of capabilities to achieve cryptographic agility, acting as an essential cryptography inventory and data aggregation platform that applies current and future standardization organizations mandates. AQtive Guard automatically analyzes and reports on your cryptographic security posture and policy management, enabling your team to deploy and enforce new protocols, including quantum-resistant cryptography, without re-writing code or modifying your IT infrastructure.
Actually, stanzas are not strongly tied to the recipient type, so third-party recipients can produce native stanzas and third-party identities can decrypt them. This is useful for example if you want to store a native age key in a KMS system: you can use native recipients to encrypt to it, and a custom identity to decrypt them. This took us a few months to figure out as the right abstraction, and you can see a bit of vestiges of the previous design in the API. ↩︎
This also took some time to come up with. It feels obvious in retrospect, especially now that recipient types and header stanzas are not tightly coupled, but back then we spent a lot of time thinking with str4d about how to enable plugins in such a way that it wouldn’t give potentially untrusted files control over what plugins are executed. Having the recipient and identity encodings select the plugins has both good UX and good security, and they can produce/consume any stanzas, allowing “proxy” plugins and reuse of the native types. ↩︎