X25519 Encryption in Age

I’ve been dealing a lot with the age encryption protocol lately, and had a rough idea of how the scheme worked, but I finally wanted to sit down and work it out until it actually made sense.

As background, we have two parties, a sender, someone who wants to encrypt and send a file. We denote that party as U. Second, we have the recipient, that will receive that file and be able to decrypt it. We denote that party as V. The file should have a per-file unique encryption key, and we need some way of getting that key from U to V without having to have already exchanged that key, or, indeed, having any secret information shared between the two parties.

Age, an many other schemes with similar goals, achieves this by relying on public key cryptography. Each party has a private key, which they keep secret. That private key has an associated public key, which they can distribute freely without compromising the value of the private key. Age in particular uses elliptic curve cryptography, a set of mathematical operations over a finite field known as an elliptic curve, the mathematics of which provides some useful properties which can be turned into encryption primatives. I won’t go into the details of those properties, but do encourage you to check out the many resources online, including this Computerphile video.

We’re going to work through the mechanics of how age builds up a shared secret between U and V, without either of those parties having previously exchanged any secret information. I’m going to do that know with some annotated code, in particular, the file age/internal/age/x25519.go. All of the code here is from that file as it appears in commit f0f8092d60.

Let’s start on the sender’s side, U. Wrapping the per-file encryption key is done in this function

func (r *X25519Recipient) Wrap(fileKey []byte) (*format.Recipient, error)

First we make an ephemeral secret by reading some random bits in:

ephemeral := make([]byte, curve25519.ScalarSize)
if _, err := rand.Read(ephemeral); err != nil {
        return nil, err
}

In crypto terms, this is dU, and that’s all a private key is: simply enough random bits to fit the elliptic curve size — here, a specific curve called https://en.wikipedia.org/wiki/Curve25519.

Next, figure out the public side of the ephemeral key:

ourPublicKey, err := curve25519.X25519(ephemeral, curve25519.Basepoint)

This is performing a scalar multiplication against the Curve25519 basepoint (sometimes called a generator, G). The basepoint is simply one of the constants that defines this particular elliptic curve. In mathematical terms, we now have the sender’s public key, dUG.

Now we create the shared secret:

sharedSecret, err := curve25519.X25519(ephemeral, r.theirPublicKey)

r in this case is the recipient, so r.theirPublicKey is, in our terms, dVG, and the value of the shared secret is dUdVG.

The shared secret is good as that, but not as a key. A good key should be something that basically looks like a random number, but we know that dUdVG is some point that lives on the curve G. It needs to be stretched into a good key. The method age takes is a venerable one, using an HKDF, a hashed key derivation formula:

salt := make([]byte, 0, len(ourPublicKey)+len(r.theirPublicKey))
salt = append(salt, ourPublicKey...)
salt = append(salt, r.theirPublicKey...)
h := hkdf.New(sha256.New, sharedSecret, salt, []byte(x25519Label))
wrappingKey := make([]byte, chacha20poly1305.KeySize)

salt and x25519Label are things there to tie this key to a specific context. The salt is both public keys concatenated together, and the x25519Label is just a string “age-encryption.org/v1/X25519”. The function of all of this is to turn the shared secret into something functionally indistinguishable from random noise. Primarily, by going through this we no longer guarantee that wrappingKey is a point on Curve25519.

Finally, we use wrappingKey as a symmetric key to encrypt the per-file key: wrappedKey, err := aeadEncrypt(wrappingKey, fileKey).

Now let’s look at the recipient V side, handled in

func (i *X25519Identity) Unwrap(block *format.Recipient) ([]byte, error)

After some sanity checking, we extract the sender’s public key, which was sent along with the encrypted file:

publicKey, err := format.DecodeString(block.Args[0])

This gives us dUG, by definition. V already knows its secret key, which is dV. Now we use that information to derive the shared secret:

sharedSecret, err := curve25519.X25519(i.secretKey, publicKey)

This is dVdUG, which, because multiplication is associative on this curve, can be re-arranged into dUdVG, which is the exact same sharedSecret that U used. Do the same HKDF key derivation (V knows both public keys and x25519Label is a constant) and we get the same wrappingKey and can now decrypt the fileKey and use that to decrypt the file.

And that’s it.