Age Encryption with PKCS11 tokens update, again

I came across this pull request in rage, the Rust implementation of age. There’s been some discussion of building a plugin system for age, and the rage implementer has started work for using a PIV device to store an age-compatible key. When the plugin system for age is decided, this will likely be the first implementation.

Looking at it, parts of it are remarkably similar to what I came up with, which is reassuring to me, as I was at least heading down a similar path. It works with P-256 curves, so we were going down the same path there. There’s a new recipient, which is of the form Bech32("age1piv", SEC-1-C(public key)), where SEC-1-C is the compressed representation of the public key curve.

A recipient line looks like this:

-> piv encode(SHA-256(recipient)[:4]) encode(SEC-1-C(ECDH(ephemeral secret, p256-basepoint)))\n
encode(encrypt[HKDF[salt, label](ECDH(ephemeral secret, public key))](file key))\n

Let’s unpack that. First, piv is the recipient type identifier. encode(SHA-256(recipient)[:4]) is used as a hint for the target recipient — PIV usage is rather expensive, and the normal age method of “try all recipient types which match yours and see if it works” would be painful on a device which is slow and requires you to touch it to perform an action. There’s a tradeoff, of course, in which you’ve now tied the recipient to the file publicly.

The next part is there because it’s the first part of an elliptic curve Diffie-Hellman exchange, multiplying the ephemeral secret against the P-256 basepoint (or generator G). This gets us another point on the P-256 curve.

Finally, we get to encode(encrypt[HKDF[salt, label](ECDH(ephemeral secret, public key))](file key)). Let’s unpack this, working from the inside out. First, we do the second half of the Diffie-Hellman exchange, multiplying the ephemeral secret against the recipient’s public key. This gets us a point on the P-256 curve. Stretch that into an actual key with an HKDF, the same one in the age v1 standard. That HKDF is salted with the point generated in the last paragraph, and given a context with an application specific label This all further confounds the key. Finally, use that key to encrypt the per-file encryption key.

If you are the recipient, you know ECDH(ephemeral secret, p256-basepoint), that’s given to you, and you know your private key. Those two things will give you the exact same point on the P-256 curve as ECDH(ephemeral secret, public key) does, which means you can perform the very same HKDF operation to get the symmetric key, and use it to decode the file key, and thence, the file.

This is a nice setup. By extending the age recipient format, you can use the key already in your PIV card, and you don’t have to keep a key “handle” as in my scheme. It’s also pretty similar, using ECDH to derive a known point on the P-256 curve and making a key out of that. My scheme does have the niceity that it essentially re-creates the private key in the form of an already defined age X25519 key type, so age doesn’t have to be extended, and it doesn’t leak any information about the file’s recipients in the file. This scheme, however, wins hands down in UX, in that most people are going to think “my age key is in my PIV, I need to decrypt a file all I need is my PIV, not also some random ‘handle’ file I need to keep around”. Also, it’s actually been looked at by more people.

This whole experiment was fun and useful for me, however, as I’m much more familiar with both this and with Go coding in general. I may try my hand at writing a Golang implementation of a key plugin, once the plugin protocol is decided.