Filippo Valsorda

Encrypting Files with Passkeys and age

Typage (age-encryption on npm) is a TypeScript1 implementation of the age file encryption format. It runs with Node.js, Deno, Bun, and browsers, and implements native age recipients, passphrase encryption, ASCII armoring, and supports custom recipient interfaces, like the Go implementation.

However, running in the browser affords us some special capabilities, such as access to the WebAuthn API. Since version 0.2.3, Typage supports symmetric encryption with passkeys and other WebAuthn credentials, and a companion age CLI plugin allows reusing credentials on hardware FIDO2 security keys outside the browser.

Let’s have a look at how encrypting files with passkeys works, and how it’s implemented in Typage.

Encrypting with passkeys

Passkeys are synced, discoverable WebAuthn credentials. They’re a phishing-resistant standard-based authentication mechanism. Credentials can be stored in platform authenticators (such as end-to-end encrypted iCloud Keychain), in password managers (such as 1Password), or on hardware FIDO2 tokens (such as YubiKeys, although these are not synced). I am a strong believer in passkeys, especially when paired with email magic links, as a strict improvement over passwords for average users and websites. If you want to learn more about passkeys and WebAuthn I can’t recommend Adam Langley’s A Tour of WebAuthn enough.

login with a passkey screenshot

The primary functionality of a WebAuthn credential is to cryptographically sign an origin-bound challenge. That’s not very useful for encryption. However, credentials with the prf extension can also compute a Pseudo-Random Function while producing an “assertion” (i.e. while logging in). You can think of a PRF as a keyed hash (and indeed for security keys it’s backed by the hmac-secret FIDO2 extension): a given input always maps to the same output, without the secret there’s no way to compute the mapping, and there’s no way to extract the secret.

Specifically, the WebAuthn PRF takes one or two inputs and returns a 32-byte output for each of them. That lets “relying parties” implement symmetric encryption by treating the PRF output as a key that’s only available when the credential is available. Using the PRF extension requires User Verification (i.e. PIN or biometrics). You can read more about the extension in Adam’s book.

Note that there’s no secure way to do asymmetric encryption: we could use the PRF extension to encrypt a private key, but then an attacker that observes that private key once can decrypt anything encrypted to its public key in the future, without needing access to the credential.

Support for the PRF extension landed in Chrome 132, macOS 15, iOS 18, and 1Password versions from July 2024.

The fido2prf age format

To encrypt an age file to a new type of recipient, we need to define how the random file key is encrypted and encoded into a header stanza. Here’s a stanza that wraps the file key with an ephemeral FIDO2 PRF output.

-> age-encryption.org/fido2prf Fv8VHh8kzhSlR14OviQ2OA
0Gw/JQEYrx5wPEUQzAh14nB6vTujga6VaboJ/vMKgWw

The first argument is a fixed string to recognize the stanza type. The second argument is a 128-bit nonce2 that’s used as the PRF input. The stanza body is the ChaCha20Poly1305 encryption of the file key using a wrapping key derived from the PRF output.

Each credential assertion (which requires a single User Presence check, e.g. a YubiKey touch) can compute two PRFs. This is meant for key rotation, but in our use case it’s actually a minor security issue: an attacker who compromised your system but not your credential could surreptitiously decrypt an “extra” file every time you intentionally decrypt or encrypt one. We mitigate this by using two PRF outputs to derive the wrapping key.

The WebAuthn PRF inputs are composed of a domain separation prefix, a counter, and the nonce.

"age-encryption.org/fido2prf" || 0x01 || nonce
"age-encryption.org/fido2prf" || 0x02 || nonce

The two 32-byte PRF outputs are concatenated and passed to HKDF-Extract-SHA-256 with age-encryption.org/fido2prf as salt to derive the ChaCha20Poly1305 wrapping key. That key is used with a zero nonce (since it’s used only once) to encrypt the file key.

This age recipient format has two important properties:

  • Per-file hardware binding: each file has its own PRF input(s), so you strictly need both the encrypted file and access to the credential to decrypt a file. You can’t precompute some intermediate value and use it later to decrypt arbitrary files.
  • Unlinkability: there is no way to tell that two files are encrypted to the same credential, or to link a file to a credential ID without being able to decrypt the file.3

WebAuthn and Typage

Now that we have a format, we need an implementation. Enter Typage 0.2.3.

npm install -s age-encryption@0.2.3

The WebAuthn API is pretty complex, at least in part because it started as a way to expose U2F security keys before passkeys were a thing, and grew organically over the years. However, Typage’s passkey support amounts to less than 300 lines, including a simple implementation of CTAP2’s CBOR subset.

Before any encryption or decryption operation, a new passkey must be created with a call to age.webauthn.createCredential.

await age.webauthn.createCredential({ keyName: "age encryption key 🦈" })

age.webauthn.createCredential calls navigator.credentials.create with a random user.id to avoid overwriting existing keys, authenticatorSelection.residentKey set to required to ask the authenticator to store a passkey, and of course extensions: { prf: {} }. Passkeys not generated by createCredential can also be used if they have the prf extension enabled.

a credential creation prompt

To encrypt or decrypt a file, you instantiate an age.webauthn.WebAuthnRecipient or age.webauthn.WebAuthnIdentity, which implement the new age.Recipient and age.Identity interfaces.

const e = new age.Encrypter()
e.addRecipient(new age.webauthn.WebAuthnRecipient())
const ciphertext = await e.encrypt("Hello, age!")
const armored = age.armor.encode(ciphertext)
console.log(armored)

const d = new age.Decrypter()
d.addIdentity(new age.webauthn.WebAuthnIdentity())
const decoded = age.armor.decode(armored)
const out = await d.decrypt(decoded, "text")
console.log(out)

The recipient and identity implementations call navigator.credentials.get with the PRF inputs to obtain the wrapping key and then parse or serialize the age-encryption.org/fido2prf format we described above.

a credential usage prompt

Aside from the key name, the only option you might want to set is the relying party ID. This defaults to the origin of the web page (e.g. app.example.com) but can also be a parent (e.g. example.com). Credentials are available to subdomains of the RP ID, but not to parents.

Since passkeys are usually synced, it means you can e.g. encrypt a file on macOS and then pick up your iPhone and decrypt it there, which is pretty cool. Also, you can use passkeys stored on your phone with a desktop browser thanks to the hybrid BLE protocol. It should even be possible to use the AirDrop passkey sharing mechanism to let other people decrypt files!

Security keys and age-plugin-fido2prf

You can store passkeys (discoverable or “resident” credentials) on recent enough FIDO2 hardware tokens (e.g. YubiKey 5). However, storage is limited and support still not universal. The alternative is for the hardware token to return all the credential’s state encrypted in the credential ID, which the client will need to give back to the token when using the credential.

This is limiting for web logins because you need to know who the user is (to look up the credential ID in the database) before you invoke the WebAuthn API. It can also be desirable for encryption, though: decrypting files this way requires both the hardware token and the credential ID, which can serve as an additional secret key, or a second factor if you’re into factors.

Rather than exposing all the layered WebAuthn nuances through the typage API, or precluding one flow, I decided to offer two profiles: by default, we’ll generate and expect discoverable passkeys, but if the security-key option is passed, we’ll request the credential is not stored on the authenticator and ask the browser to show UI for hardware tokens.

age.webauthn.createCredential returns an age identity string that encodes the credential ID, relying party ID, and transports as CTAP2 CBOR,4 in the format AGE-PLUGIN-FIDO2PRF-1.... This identity string is required for the security key flow, but can also be used as an optional hint when encrypting or decrypting using passkeys.

More specifically, the data encoded in the age identity string is a CBOR Sequence of

  • the version, always 1
  • the credential ID as a byte string
  • the RP ID as a text string
  • the transports as an array of text strings
1
h'C4A1C97CA40D358EAF4E5CDC51E5AE5F5472C3E6B8942652A9955C34CB5403CDE04B933430280F919220DA22467BBB2BC8D7EF4AE62BCDEBA77CC698A5703ED2'
"localhost"
["usb"]

One more thing… since FIDO2 hardware tokens are easily accessible outside the browser, too, we were able to build a age CLI plugin that interoperates with typage security key identity strings: age-plugin-fido2prf.

$ cat > identity.txt << EOF
AGE-PLUGIN-FIDO2PRF-1Q9VYP39PE972GRF436H5UHXU28J6UH65WTP7DWY5YEF2N92UXN94GQ7DUP9EXDPS9Q8ERY3QMG3YV7AM90YD0M62UC4UM6A80NRF3FTS8MFXJMR0VDSKC6R0WD6GZCM4WD3QKE7G3W
EOF
$ age -d -i identity.txt << EOF
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IGFnZS1lbmNyeXB0aW9uLm9yZy9maWRv
MnByZiA2dWVNRDBYNjRsVnorNnFVL1Rqb1hBCjYzSFVVRmFtUWJ6VXZLVy9nV2Zr
QlNkMVVrM0VuOGZmN1dQU2UyOWY5Q0EKLS0tIGRnUTV2T1Z4WE9zcGw4OEs3M0Rz
UHR3ektYeTVNUzIxZXBSQ1J4b2RuUzAKqDgfm0QMjJpOw+tzGClM9dPsjrWUCTaX
NoEA2tHtTerYo3683A==
-----END AGE ENCRYPTED FILE-----
EOF
Enter the security key PIN:
test

Since FIDO2 PRF only supports symmetric encryption, the identity string is used both for decryption and for encryption (with -e -i).

This was an opportunity to dogfood the age Go plugin framework, which easily turns an implementation of the Go age.Identity interface into a CLI plugin usable from age or rage, abstracting away all the details of the plugin protocol. The scaffolding turning the importable fido2prf Identity implementation into a plugin is just 50 lines.

For more details, refer to the typage README and JSDoc annotations. To stay up to date on the development of age and its ecosystem, follow me on Bluesky at @filippo.abyssdomain.expert or on Mastodon at @filippo@abyssdomain.expert.

The picture

On the last day of this year’s amazing CENTOPASSI motorcycle rallye, we watched the sun set over the plain below Castelluccio, and then rushed to find a place to sleep before the “engines out” time. Found an amazing residence where three cats kept us company while planning the next day.

A sunlit outdoor scene with a long, weathered wooden picnic table and three cats. One cat, black and white, is stretched out on the tabletop, while another calico cat is perched on the bench to the right, looking towards the third cat on the ground. Trees and bushes surround the area, and in the background, a house with a tiled roof is visible, along with distant green mountains under a clear blue sky.

Geomys, my Go open source maintenance organization, is funded by Smallstep, Ava Labs, Teleport, Tailscale, and Sentry. 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!

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


  1. It started as a way for me to experiment with the JavaScript ecosystem, and the amount of time I spent setting up things that we can take for granted in Go such as testing, benchmarks, formatting, linting, and API documentation is… incredible. It took even longer because I insisted on understanding what tools were doing and using defaults rather than copying dozens of config files. The language is nice, but the tooling for library authors is maddening. I also have opinions on the Web Crypto APIs now. But all this is for another post. 

  2. 128 bits would usually be a little tight for avoiding random collisions, but in this case we care only about never using the same PRF input with the same credential and, well, I doubt you’re getting any credential to compute more than 2⁴⁸ PRFs. 

  3. Selected mostly for ecosystem consistency and because it’s a couple hundred lines to handroll.