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.
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.
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.
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.
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.
-
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. ↩
-
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. ↩
-
This is actually a tradeoff: it means we can’t tell the user a decryption is not going to work before asking them the PIN of the credential. I considered adding a tag like the one being considered for
p256tag
stanzas or like theage-plugin-yubikey
one. The problem is that the WebAuthn API only lets us specify acceptable credential IDs upfront, there is no “is this credential ID acceptable” callback, so we’d have to put the whole credential ID in the stanza. This is undesirable both for privacy reasons, and because the credential ID (encoded in the identity string) can otherwise function as a “second factor” with security keys. ↩ -
Selected mostly for ecosystem consistency and because it’s a couple hundred lines to handroll. ↩