A simple file encryption tool & format
Filippo Valsorda (@FiloSottile) — Ben Cartwright-Cox (@Benjojo12)
Designed at the Recurse Center during NGW 2019
This is a design for a simple file encryption CLI tool, Go library, and format.
It’s meant to replace the use of gpg for encrypting files, backups, streams, etc.
It’s going to be called “age”, which might be an acronym for Actually Good Encryption, and it’s pronounced like the Japanese 上げ (with a hard g).
$ age -generate > key.txt
$ cat key.txt
# created: 2006-01-02T15:04:05Z07:00
$ echo "_o/" | age pubkey:98W5ph53zfPGOzEOH-fMojQ4jUY7VLEmtmozREqnw4I > hello.txt.age
$ age -d key.txt < hello.txt.age
$ tar cv ~/xxx | age github:Benjojo github:FiloSottile | nc 192.0.2.0 1234
$ age -generate >> ~/.config/age/keys.txt
$ cat ~/.config/age/keys.txt
# created: 2006-01-02T15:04:05Z07:00
Encryption to a public key
$ echo "_o/" | age -o hello.age pubkey:98W5ph53zfPGOzEOH-fMojQ4jUY7VLEmtmozREqnw4I
Encryption to multiple public keys (with default output to stdout)
$ echo "_o/" | age pubkey:98W5ph53zfPGOzEOH-fMojQ4jUY7VLEmtmozREqnw4I pubkey:jqmfMHBjlb7HoIjjTsCQ9NHIk_q53Uy_ZxmXBhdIpx4 > hello.age
Encryption with a password (interactive only, use public keys for batch!)
$ age -i hello.txt -o hello.txt.age -p
Encryption to a list of recipients in a file
$ echo pubkey:98W5ph53zfPGOzEOH-fMojQ4jUY7VLEmtmozREqnw4I >> recipients.txt
$ echo pubkey:jqmfMHBjlb7HoIjjTsCQ9NHIk_q53Uy_ZxmXBhdIpx4 >> recipients.txt
$ tar cv ~/xxx | age recipients.txt > xxx.tar.age
Encryption to an SSH public key
$ tar cv ~/xxx | age ~/.ssh/id_rsa.pub > xxx.tar.age
Encryption to a list of recipients at a URL (detects both SSH and age keys)
$ echo "_o/" | age -o hello.age https://github.com/FiloSottile.keys > hello.age
$ echo "_o/" | age -o hello.age https://filippo.io/.well-known/age.keys > hello.age
Encryption to a GitHub user (equivalent to https://github.com/FiloSottile.keys)
$ echo "_o/" | age github:FiloSottile | nc 192.0.2.0 1234
Encryption to an alias (stored at ~/.config/age/aliases.txt, change with -aliases)
$ cat ~/.config/age/aliases.txt
ben: pubkey:ZAE2ZnRdItykp0ncAZJ2FAzIIfTvmGcgIx/759QhnQw github:Benjojo
$ tar cv ~/xxx | age alias:filippo > xxx.tar.age
Decryption with keys at ~/.config/age/keys.txt and ~/.ssh/id_* (no agent support)
$ age -decrypt -i hello.age
Decryption with custom keys
$ age -d -o hello -i hello.age keyA.txt keyB.txt
Encryption refuses to print to stdout if it is bound to a TTY, and so does decryption unless the payload is short and printable. Password input is only supported if a TTY is available. If key files and encrypted files are mixed up (in particular in decryption mode), a helpful error is printed. Duplicated aliases are both ignored and a warning is printed. Key generation checks the permissions of the output and prints a warning if world readable.
The file starts with a textual header that declares the lowest version of age which can decrypt the message, and encapsulates the 128-bit master file key for each recipient.
This is a file encrypted with age-tool.com, version 1
-> X25519 CJM36AHmTbdHSuOQL-NESqyVQE75f2e610iRdLPEN20
-> X25519 ytazqsbmUnPwVWMVx0c1X9iUtGdY4yAB08UQTY2hNCI
-> scrypt bBjlhJVYZeE4aqUdmtRHfw 32768
-> ssh-rsa mhir0Q
-> ssh-ed25519 BjH7FA RO-wV4kbbl4NtSmp56lQcfRdRp3dEFpdQmWkaoiw6lY
--- ChaChaPoly fgMiVLJHMlg9fW7CVG+hPS5EAU4Zeg19LyCP7SoH5nA
[BINARY ENCRYPTED PAYLOAD]
Each recipient line starts with -> and its type name and can be followed by any number of arguments and additional lines. Unknown recipient types are ignored.
encode(data) is base64url from RFC 4648 without padding, wrapped at 57 characters.
encrypt[key](plaintext) is ChaCha20-Poly1305 from RFC 7539 with a zero nonce.
X25519(secret, point) is from RFC 7748, including the all-zeroes output check.
HKDF[salt, label](key, len) is HKDF from RFC 5869 with SHA-256.
HMAC[key](message) is HMAC from RFC 2104 with SHA-256.
scrypt[salt, N](password) is from RFC 7914 with r = 8 and P = 1.
RSAES-OAEP[label](plaintext) is from RFC 8017 with SHA-256 and MGF1.
random(n) is a string of n bytes read from a CSPRNG like /dev/urandom.
An X25519 recipient line is
-> X25519 encode(X25519(ephemeral secret, basepoint))
encode(encrypt[HKDF[salt, label](X25519(ephemeral secret, public key), 32)](file key))
where ephemeral secret is random(32) and MUST be new for every new file key,
salt is X25519(ephemeral secret, basepoint) || public key,
and label is "age-tool.com X25519".
An scrypt recipient line is
-> scrypt encode(salt) N
encode(encrypt[scrypt[salt, N](password)](file key))
where salt is random(16), and N is the scrypt cost parameter in decimal. A new salt MUST be generated for every new file key.
Note that if an scrypt recipient is present it SHOULD be the only recipient: every recipient can tamper with the message, but with passwords there might be a stronger expectation of authentication.
An ssh-rsa recipient line is
-> ssh-rsa encode(SHA-256(SSH key)[:4])
encode(RSAES-OAEP["age-tool.com ssh-rsa"](file key))
where SSH key is the binary encoding of the SSH public key from RFC 8332. (Note that OpenSSH public key lines are "ssh-rsa " || base64(SSH key) in this notation.)
An ssh-ed25519 recipient line is
-> ssh-ed25519 encode(SHA-256(SSH key)[:4]) rest
where SSH key is the binary encoding of the SSH public key from draft-ietf-curdle-ssh-ed25519-ed448-08, and rest are the same arguments and payload as an X25519 recipient key.
The public key for a ssh-ed25519 recipient is X25519(tweak, converted key)
where tweak is reduce(HKDF[SSH key, "age-tool.com ssh-ed25519"]("", 64)))
and converted key is the Ed25519 public key converted to the Montgomery curve.
reduce is a reduction modulo the prime order of Curve25519. Note that there’s no need for it to be constant time, as its input is public.
The corresponding private key is the ed25519 private scalar converted to the Montgomery curve, and multiplied by tweak. This multiplication in the scalar field must be executed in constant time.
(I know I am using signing keys for encryption, which is unholy. I’m sorry? It would be nice to check further for cross-protocol attacks but it looks like we'll be ok. The first X25519 with the tweak is meant to generate a derived key for some attack mitigation.)
The header ends with the following line
--- AEAD encode(HMAC[HKDF["", "header"](file key, 32)](header))
where AEAD is the function used for encrypt and STREAM (always ChaChaPoly, currently) and header is the whole header up to the AEAD value included.
(To add a recipient, the master key needs to be available anyway, so it can be used to regenerate the HMAC. Removing a recipient without access to the key is not possible.)
After the header the binary payload is
nonce || STREAM[HKDF[nonce, "payload"](file key, 32)](plaintext)
where nonce is random(16) and STREAM is from Online Authenticated-Encryption and its Nonce-Reuse Misuse-Resistance with ChaCha20-Poly1305 in 64KiB chunks and a nonce structure of 11 bytes of big endian counter, and 1 byte of last block flag (0x00 / 0x01).
(The STREAM scheme is similar to the one Tink and Miscreant use, but without nonce prefix as we use HKDF, and with ChaCha20-Poly1305 instead of AES-GCM because the latter is unreasonably hard to do well or fast without hardware support.)
2019-05-16: added “created” comment to generated keys. Via @BenLaurie.
2019-05-16: added RSA-OAEP label. Via @feministPLT.
2019-05-16: moved ~/.config/age.keys to ~/.config/age/keys.txt and added aliases. Via @BenLaurie and @__agwa.
2019-05-19: added Ed25519 tweak and switched to SHA-512 everywhere for consistency. Via kwantam.
2019-05-19: removed public key hash from header to get recipient privacy like gpg’s --throw-keyid. Via private DM.
2019-05-19: replaced egocentric GitHub link with age-tool.com.
2019-05-26: reintroduced public key hash for SSH keys to identify encrypted and hardware keys. Via private DM. (For better privacy, use native keys.)
2019-05-26: included X25519 shares in derived key according to RFC 7748, Section 6.1 by using HKDF as suggested in RFC 5869, Section 3.1.
2019-05-26: documented that aliases can expand to multiple keys.
2019-05-26: swapped scrypt for Argon2 in the name of implementation ubiquity. Switched back to SHA-256 to match the scrypt core hash.
2019-05-26: rewrote the Format section in terms of RFCs. Made minor changes to accommodate that, most importantly now using X25519 to apply the ssh-ed25519 tweak scalar.
2019-06-06: added “Maybe in v2” section, moved PKCS#11 to it.
2019-06-06: added header HMAC. Via @lasagnasec.
2019-06-12: added a nonce to the HKDF payload key derivation, making the file key reusable. (Mostly for misuse resistance.)
2019-06-12: introduced requirement for an scrypt recipient to be the only one.
2019-06-24: settled the important question, the pronunciation. It’s “g” like in “gif”.
2019-07-11: made the ssh-ed25519 tweak 64 bytes to reduce bias. (Which is free because the reduction doesn’t have to be constant time.) Pointed out at a Bar Pitti table, chose to donate £50 to ProPublica.
2019-07-20: added AEAD field to the closing of the header.