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

# pubkey:98W5ph53zfPGOzEOH-fMojQ4jUY7VLEmtmozREqnw4I

AGE_SECRET_KEY_RQvvHYA29yZk8Lelpiz8lW7QdlxkE4djb1NOjLgeUFg

$ echo "_o/" | age pubkey:98W5ph53zfPGOzEOH-fMojQ4jUY7VLEmtmozREqnw4I > hello.txt.age

$ age -d key.txt < hello.txt.age

_o/

$ tar cv ~/xxx | age github:Benjojo github:FiloSottile | nc 192.0.2.0 1234

Goals

  • An extremely simple CLI that composes well with UNIX pipes, and that works well as a backend for other programs
  • Small copy-pasteable keys, with optional textual keyrings
  • Support for public/private key pairs and passwords, with multiple recipients
  • The option to encrypt to SSH keys, with built-in GitHub .keys support
  • “Have one joint and keep it well oiled”, no configuration or (much) algorithm agility
  • A good seekable streaming encryption scheme based on modern chunked AEADs, reusable as a general encryption format

Later

  • A password-store backend!
  • YubiKey PIV support via PKCS#11 (sigh), maybe TouchBar
  • Support for a Pond-style shared secret PAKE server
  • Dictionary word encoded mnemonics for keys
  • An ASCII armored format
  • Support for AES-GCM in alternative to ChaCha20-Poly1305
  • Maybe native support for key wrapping (to implement password-protected keys)
  • age-mount(1), a tool to mount encrypted files or archives
    (also satisfying the agent use case by key wrapping)

Out of scope

  • Archival (that is, reinventing zips)
  • Any kind of signing (which is not a tooling problem, but a trust and key distribution problem, and to the extent that tools matter you should just use signify/minisign, and for keys we should probably use SSH ones)
  • git commit signing, in particular (leave that to GitHub to solve) or releases and package signing (which is better solved at scale by transparency)
  • Anything about emails (which are a fundamentally unsecurable medium)
  • The web of trust, or key distribution really

Command line interface

Key generation

$ age -generate >> ~/.config/age/keys.txt

$ cat ~/.config/age/keys.txt

# created: 2006-01-02T15:04:05Z07:00

# pubkey:98W5ph53zfPGOzEOH-fMojQ4jUY7VLEmtmozREqnw4I

AGE_SECRET_KEY_RQvvHYA29yZk8Lelpiz8lW7QdlxkE4djb1NOjLgeUFg

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

Type passphrase:

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

filippo: pubkey:jqmfMHBjlb7HoIjjTsCQ9NHIk_q53Uy_ZxmXBhdIpx4

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

_o/

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.

Format

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

C3ZAeY64NXS4QFrksLm3EGz-uPRyI0eQsWw7LWbbYig

-> X25519 ytazqsbmUnPwVWMVx0c1X9iUtGdY4yAB08UQTY2hNCI

N3pgrXkbIn_RrVt0T0G3sQr1wGWuclqKxTSWHSqGdkc

-> scrypt bBjlhJVYZeE4aqUdmtRHfw 32768

ZV_AhotwSGqaPCU43cepl4WYUouAa17a3xpu4G2yi5k

-> ssh-rsa mhir0Q

xD7o4VEOu1t7KZQ1gDgq2FPzBEeSRqbnqvQEXdLRYy143BxR6oFxsUUJC

RB0ErXAmgmZq7tIm5ZyY89OmqZztOgG2tEB1TZvX3Q8oXESBuFjBBQkKa

MLkaqh5GjcGRrZe5MmTXRdEyNPRl8qpystNZR1q2rEDUHSEJInVLW8Otv

QRG8P303VpjnOUU53FSBwyXxDtzxKxeloceFubn_HWGcR0mHU-1e9l39m

yQEUZjIoqFIELXvh9o6RUgYzaAI-m_uPLMQdlIkiOOdbsrE6tFesRLZNH

AYspeRKI9MJ--Xg9i7rutU34ZM-1BL6KgZfJ9FSm-GFHiVWpr1MfYCo_w

-> ssh-ed25519 BjH7FA RO-wV4kbbl4NtSmp56lQcfRdRp3dEFpdQmWkaoiw6lY

51eEu5Oo2JYAG7OU4oamH03FDRP18_GnzeCrY7Z-sa8

--- 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.)

Changes

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.