(WIP) RFC: Passkey (WebAuthn) credential storage
⚠️ WIP, during editing !!!⚠️
Updated (2026-08-28): As I dug deeper into WebAuthn and CTAP2, I ran into an issue: the protocols define quite a bit of data, such as the Credential ID, userHandle, and OtherUI fields (typically userName and userDisplayName), that needs to be accessible to the authenticator.
Given these constraints, in the context of gopass we seem to have two options:
- Find a way to store this data in plaintext within the gopass context.
- Trigger a decryption operation before the user has even selected an account.
Summary
pkg/passkey can create and use credentials in memory (added in #2814), but it cannot save them: there is no storage format and no store integration. This RFC proposes how to store passkey credentials in gopass
gopass acts as a software authenticator in the synced-passkey model (like iCloud Keychain or Bitwarden): the private key lives in the encrypted store and is shared, via git, by every device that clones it
Storage formats: how a credential is written inside a secret
Format 1: one passkey: line per credential (Recommended)
Each credential is one self-contained line: a flat key passkey whose value is single-line compact JSON. Only the binary fields are base64url
passkey: {"v":1,"rpId":"example.com","userName":"alice","userDisplayName":"Alice","userHandle":"YWxpY2U","credentialId":"k3J...","key":"MIGHAgEA...","signCount":0,"backupEligible":true,"backupState":true}passkey is registered as an unsafe key so gopass show masks it under safecontent. Writing a credential is sec.Add("passkey", json) + Store.Set(...) — encryption, git history and cross-device sync come from the store for free.
Pros:
- Holds multiple credentials, not just one. One line is one registration; a second registration adds a second line. WebAuthn sets no limit on credentials per account, and gopass AKV already supports repeated keys
- Same idiom as
otpauth: today: flat key, structured one-line value. no new naming convention - Maps 1:1 to the FIDO CXF interchange format
Cons:
- A long line, not meant for hand-editing (credentials are machine-generated anyway)
- Doesn't match our flat key naming convention
Format 2: flat passkey_* keys
Each field is its own KV key, like KeePassXC's KPEX_PASSKEY_* attributes:
passkey_rpId: example.com
passkey_userName: alice
passkey_key: MIGHAgEA...
passkey_signCount: 0Pros:
- Every field is human-readable and editable
signCountbecomes a sibling key, written back exactly like HOTP'scounter
Cons:
- Cannot express more than one credential per entry without indexed-key hacks (
passkey2_rpId:, …)
Directory structures: where the credential entry lives in the tree
Entry names are the only plaintext in the store — before decryption, only the name tree is visible. That fact decides this part
Structure 1: inside the login entry
websites/example.com/alice ← password + url + login + the passkey linesPros:
- Everything about one account in one entry
Cons:
- Before decryption you cannot tell which entries hold passkeys: a username-less login must decrypt every candidate under the site
- Slow with many accounts; for users without agent caching it can mean unlocking twice (once to list, once to sign)
Structure 2: dedicated subtree (Recommended)
websites/example.com/alice ← password entry, untouched
passkeys/example.com/alice ← credential entry: only the passkey linesPros:
- Passkey existence is visible in the plaintext tree. Discovery stays find by name, then decrypt one entry:
rpId→ls passkeys/<rpId>/(plaintext, no unlock) → user picks an account → decrypt that one entry → matchcredentialIdif the RP supplied one → sign - Signing decrypts exactly one entry — one unlock in total, even without an agent
- Login entries are never modified or renamed
- Type-based directories are already the wizard's convention (
websites/,pin/) - CXF import/export maps 1:1: one entry = one account's credentials
Cons:
- An account spans two entries, linked only by naming convention
- The credential entry's password line goes unused
Structure 3: login entry with a name marker
websites/example.com/alice+pk ← Structure 1 plus a marker in the entry namePros:
- Readable like Structure 1 (one entry per account)
- Name-tree visible like Structure 2 (the marker signals passkey presence pre-decryption)
Cons:
- Adding a first passkey would have to rename an existing entry — breaking scripts and
gopass://references - The marker cannot be parsed unambiguously: it attaches to a human username, and no character is guaranteed absent from real usernames, e.g.
alice+pkmay be a marked alice, or a literal account name that happens to end in+pk - The marker is only a hint: when it goes stale after hand-editing, listing silently degrades
Credential schema
| field | type | required | secret? | note |
|---|---|---|---|---|
v |
int | yes | no | schema version, starts at 1 |
credentialId |
base64url | yes | no | unique per registration; 32 random bytes with us; usable as a plaintext correlation key |
rpId |
string | yes | no | needed for signing (rpIdHash); routing data — browsers store it in the clear locally |
userName, userDisplayName |
string | yes | no | the account name as the RP knows it — may differ from the entry name; required for lossless CXF export; never returned in assertions. The default picker shows plaintext entry names; these fields are the display source only for the decrypt-candidates picker variant (see known issues) and passkey list. |
userHandle |
base64url | yes | yes | an opaque account identifier that is stable across sessions and potentially across sites if reused. It is treated as secret not because it is key material, but because such reuse makes it a cross-site tracking vector; therefore, it remains inside the ciphertext. |
key |
base64url, PKCS#8 DER | yes | yes | the private key — the actual secret; the whole reason the blob is encrypted. PKCS#8 DER (base64url) — CXF-mandated, native to Go's x509 package, self-describing across ES256/RS256/EdDSA |
signCount |
int | yes | no | defaults to 0 (synced-credential rule), and it stays 0. A synced credential cannot keep a monotonic counter across devices, and CXF drops counters on export. If a credential exists with a counter > 0, gopass increments and persists it after each assertion, like HOTP's counter |
coseAlg |
int | optional | no | convenience; PKCS#8 already self-describes the algorithm |
backupEligible / backupState |
bool | optional | no | synced credentials: true |
creationDate |
string | optional | no | audit / disambiguation |
Not stored: transports and AAGUID (filled at sign time), a discoverable flag (see Known issues), device labels (the entry name is the label)
Known issues
Pre-decryption visibility. Entry names are the only plaintext in the store, so "which entries hold passkeys" must be encoded in the name tree to be answerable before decryption. For the picker's account display, the options are plaintext entry names (default, zero unlock) or decrypting the few candidates in one directory to show the stored
userName/userDisplayNameAll credentials are assumed discoverable (shown in the picker). Every credential gopass stores carries the full account metadata, and there is no flag or setting to exclude one from listing: if a credential is in the store, it will appear in the account picker — the only way to keep one out of the picker is not to store it
WebAuthn does not require authenticators to offer non-discoverable storage, the distinction only matters for slot-limited hardware authenticators, and iCloud Keychain offers no such option either. Note also that a per-credential flag inside the ciphertext would defeat unlock-free enumeration: filtering on it requires decrypting first
If a real need for non-discoverable storage ever appears, two shapes could express it, both semantically equivalent: Parallel subtree
passkeys-hidden/<rpId>/<account>or filename marker<credentialId>+hidden.gpg(like structure 3)Skeleton bugs.
pkg/passkey/passkey.godiscards the error fromecdsa.GenerateKey(line 84) and checks the wrong variable after signing (line 123). Both must be fixed regardless of this RFC
Open questions
- Format: 1 or 2?
- Structure: 2, 1, or 3?
- YAML format support? No legacy data exists; YAML is only possible in user-manually edited data
signCount: the default is0. Should gopass expose an opt-in (API flag or config key) to create credentials with a non-zero counter, for callers that want counter semantics — or ship without the option?- If structure 2, use
passkeys/or ...? - Hardware keys support? Possible on follow ups via a key reference. The key field could hold a provider reference instead of inline key material e.g. making the signer pluggable while everything else stays in gopass. This is cheap because
passkey: {"v":1,"rpId":"example.com", ... ,"key":"yubikey://5c/12345678","signCount":0,"backupEligible":false,"backupState":false}credentialIdis the CTAP2 key handle, and URI indirection already has precedent in gopass (gopass://references). Four properties transfer to the device regardless of schema shape: the device asserts its own flags (BE=0for device-bound credentials) inside the authenticatorData it signs; the device owns the signature counter, so the signCount write-back machinery must not apply to such lines; user verification happens on the device (touch/PIN), not at store unlock; and the reference is not exportable, excluding the line from CXF. Where a key lives is decided at creation — an existing software credential cannot migrate onto a device; both flavors may coexist as separate lines in one entry. v1 keeps key = PKCS#8 only; the v version field leaves room to widen it.
Not the focus of this RFC
CLI sketch:
gopass passkey create example.com alice # generate a credential, store under passkeys/example.com/alice
gopass passkey import / export # CXF, signCount zeroed on export
gopass passkey assert passkeys/example.com/alice --challenge <b64url> --origin https://example.comUser verification is gopass's responsibility. As the authenticator, gopass owns UV:
- The store unlock (gpg/age passphrase) is the verification moment — positioned like a platform authenticator's biometrics/PIN: after account selection, at ceremony time. Under Structure 2, enumeration needs no unlock, so the whole flow costs one unlock even without agent caching; signing itself is in-process
- Every ceremony shows an explicit confirmation prompt ("Sign in to example.com as alice?") — the user-presence test, and the protection against drive-by signing
- The UV flag is set honestly: only when this ceremony actually involved a user secret or confirmation — never asserted merely because the agent cache happened to be warm
References
- FIDO CXF: https://fidoalliance.org/specs/cx/cxf-v1.0-rd-20250313.html
- CTAP 2.2: https://fidoalliance.org/specs/fido-v2.2-ps-20250714/fido-client-to-authenticator-protocol-v2.2-ps-20250714.html
- WebAuthn L3 §5.4.6 (ResidentKeyRequirement): https://www.w3.org/TR/webauthn-3/
- Current skeleton:
pkg/passkey/passkey.go(PR #2814)
Source: gopasspw/gopass