age encryption + secretspec
September 7, 2026 · View on GitHub
Encrypt files — and a project's secrets — to a key that only opens with the
device in your hand. Two layers, and this page is in that order: age on its
own, then secretspec on top of it.
RS-Key drives both over FIDO2, not the smart card. The plugin asks for the
CTAP hmac-secret extension over CTAPHID, so nothing here wants PKCS#11,
OpenSC, or a particular reader name, and the stock build works as shipped.
PIV is the other route to age and buys something different — the
trade-off is at the end of this page.
What you are trusting.
hmac-secretunwraps anageidentity into host memory. Whoever lifts it from there decrypts your past and future files with no token at all. The device gates access; it is not a wall the key never crosses. On the PIV route the private key does stay on the card. threat-model.md draws that line, and this page sits on the permissive side of it.
What the token actually does
FIDO2 has no asymmetric decryption to offer, so nothing here is "decrypted by
the key". hmac-secret returns 32 deterministic bytes for a given (credential,
salt) pair; the plugin uses them to unwrap an age identity it stored in the
recipient, or in the encrypted file's own stanza.
What it costs on the device: nothing persistent. The credential is
non-discoverable, so it takes no slot in the credential store and shows up
in no passkey list. Its relying party is age-encryption.org, and the same
token mints as many independent recipients as you care to ask for.
Install
nix shell nixpkgs#age nixpkgs#age-plugin-fido2-hmac
Or from source — it is Go plus libfido2:
go install github.com/olastor/age-plugin-fido2-hmac/cmd/age-plugin-fido2-hmac@latest
age finds the plugin by name on PATH; there is nothing to configure. On
Linux you need the FIDO udev rules, same as for SSH keys — see
linux.md. The plugin is pre-1.0 and ships no Windows build.
Create an identity
age-plugin-fido2-hmac -g > identity.txt
It walks you through, in this order:
- finds the token (it waits up to 50 s, so plug in whenever),
- asks for the FIDO PIN, but only if one is set on the device,
- asks for a touch, and mints the credential,
- asks whether to require the PIN for decryption — again only if a PIN is set, otherwise decryption is touch-only,
- asks whether you want a separate identity. Yes gives you a file to keep; no is "data-less", where the credential id travels inside every encrypted file and anyone holding one can read it.
Its yes/no questions want a digit, not a letter — 1 for yes, 2 for no —
and it reads them straight from /dev/tty, so the prompts survive redirecting
stdout to a file but a pipe on stdin will not answer them.
-g writes the recipients into the file as comment lines. Pull one out and
encrypt to it:
grep '^# public key:' identity.txt | sed 's/^.*: //' > recipient.txt
echo 'secret' | age -R recipient.txt -o secret.age
age -d -i identity.txt secret.age # with an identity file
age -d -j fido2-hmac secret.age # data-less
An ordinary run prints two of them — a plain age1… X25519 recipient and,
because deriving it is nearly free, an age1pq… post-quantum hybrid on a
# public key (pq safe): line. Take one or the other, never both: age refuses
a roster that mixes post-quantum and classic recipients. The grep above anchors
on the colon so it picks the classic one; swap the pattern to
'^# public key (pq safe):' for the hybrid.
Do not pattern-match on age1fido2-hmac1 — that prefix appears only in the
symmetric mode. To recover a recipient from an identity later,
age-plugin-fido2-hmac -y identity.txt derives it, but that one needs the token.
Encryption needs no token. The recipient carries an X25519 public key, so
age -R runs on a machine that has never seen the device. Only decryption
touches it. (-s/--symmetric is the exception: it re-salts every encryption
and wants the token both ways.)
Algorithms RS-Key accepts
-a picks the credential's signature algorithm. It never signs anything here —
hmac-secret is the whole point — but the device still has to accept it at
enrollment:
-a | COSE | On RS-Key |
|---|---|---|
es256 | −7 | the default, works |
eddsa | −8 | works on every shipped build |
rs256 | −257 | refused. RS-Key advertises ES256, ES384, ES512 and EdDSA — no RSA |
If you pick rs256 the enrollment fails with an unsupported-algorithm error
before anything is written. Nothing is left behind; just rerun with es256.
PIN and touch
| Action | FIDO PIN | Touch |
|---|---|---|
-g enrollment, no PIN on the device | — | once |
-g enrollment, PIN set | once | once |
Encrypt (age -R) | — | — |
| Decrypt, require-PIN answered no | — | once |
| Decrypt, require-PIN answered yes | once | once |
On an
always-uvbuild, answer yes. The shipped image leavesalwaysUvoff, so the touch-only path above is the default. Build with--features always-uv(or flip it withykman fido config toggle-always-uv) and amakeCredentialcarrying no PIN token is refused whatever else it asks for — seetests/16_always_uv_gate.py. So set a FIDO PIN first, then answer yes to the require-PIN question.
RS-Key packs the whole credential into the credential id, because a
non-discoverable credential has nowhere else to live. That id rides inside the
identity string, and inside each encrypted file's stanza when you go data-less,
so both run long. The recipient does not carry it — an ordinary run hands you a
plain X25519 age1…, the same length as any other.
secretspec on top
secretspec separates which secrets a
project needs from where they live. The declaration goes in secretspec.toml
and gets committed; the values go to a provider. Its age provider keeps them
in one age-encrypted blob, which is exactly the file the plugin above can
lock.
[project]
name = "my-app"
revision = "1.0"
[providers]
vault = "age://secrets.age?identity=identity.txt&recipients-file=recipient.txt"
[profiles.default]
DATABASE_URL = { description = "PostgreSQL connection string", providers = ["vault"] }
secretspec set DATABASE_URL # no token — writes to the recipient
secretspec get DATABASE_URL # touch (and PIN, if required)
secretspec run -- ./my-app # same, secrets exported as env vars
The asymmetry is the useful part: writing a secret never needs the key, so
CI and colleagues can add values against the committed recipient file, and only
reading asks for the token. Commit secrets.age and recipient.txt; keep
identity.txt off the repo, or drop it entirely and let the stanza carry the
credential id.
Two things that will bite you:
- secretspec can ask for a
--reason. Itsrequire_reasonpolicy defaults to"agents", and every command above is refused in an environment it reads as one until you pass--reason "<why>"or setSECRETSPEC_REASON. Putrequire_reason = falsein[project]to switch it off. - Everyone on the roster reads the whole blob.
agerecipients decrypt the file, not individual keys. Split anything with a narrower audience into its own blob with its own recipients file.
Which route
Both end at age; they differ in where the key is when it is used.
FIDO2 hmac-secret (this page) | PIV + age-plugin-yubikey | |
|---|---|---|
| Transport | CTAPHID | CCID / PKCS#11 |
| Stock build | works | wants the VIDPID=Yubikey5 build, or opensc-pkcs11.so — the plugin matches on the reader name |
| Key at decryption | identity in host RAM | private key stays on the card |
| Device state | none — non-discoverable credential | occupies a PIV slot |
| Recipients per token | unlimited | one per slot |
Pick hmac-secret for convenience on a stock key and for secrets you would
otherwise leave in a .env. Pick PIV when "the key never leaves the card" is
the property you actually need.
What was measured
The CTAP exchange below is the plugin's own, taken from its source at c490421
and replayed call for call.
On a board. RS-Key's getInfo advertises hmac-secret (and
hmac-secret-mc), so the plugin's device filter accepts it. With alwaysUv
turned on and a PIN set, the plugin's makeCredential carrying no PIN token is
refused PUAT_REQUIRED — the warning above, on hardware — while rs256 is
refused UNSUPPORTED_ALGORITHM first, so the algorithm check runs ahead of the
UV gate. An authenticatorReset brings the shipped image back with alwaysUv
off, which is the state the touch-only path assumes.
On the software emulator (tools/emu, see testing.md):
makeCredential(relying partyage-encryption.org, non-discoverable,hmac-secret) is served fores256andeddsaand refused forrs256. Credential ids came back at 131 and 135 bytes.getAssertionreturns a 32-bytehmac-secretoutput, identical across calls for one salt and different for another — the contract the plugin checks before it will unwrap anything.tests/24_extensions.pyasserts the same thing as part of the suite.- secretspec's
ageprovider round-tripsset/get/run/checkwith a plain identity and the provider declared insecretspec.tomlas above, and it spawnsage-plugin-fido2-hmac --age-plugin=identity-v1when the identity is a plugin one — so plugin identities are wired through it.
Still unmeasured: a full round trip driven by the plugin itself — enroll, encrypt, decrypt — and the same through secretspec. Every step of it needs a physical touch, so it takes someone at the device; nothing in the exchange is in doubt, only the end-to-end run.