#3565·gopass

(WIP) RFC: Passkey (WebAuthn) credential storage

Author: yzx9Created Aug 24, 2026Updated Aug 28, 2026

⚠️ 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:

  1. Find a way to store this data in plaintext within the gopass context.
  2. 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: 0

Pros:

  • Every field is human-readable and editable
  • signCount becomes a sibling key, written back exactly like HOTP's counter

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 lines

Pros:

  • 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 lines

Pros:

  • Passkey existence is visible in the plaintext tree. Discovery stays find by name, then decrypt one entry: rpIdls passkeys/<rpId>/ (plaintext, no unlock) → user picks an account → decrypt that one entry → match credentialId if 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 name

Pros:

  • 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+pk may 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

  1. 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/userDisplayName

  2. All 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)

  3. Skeleton bugs. pkg/passkey/passkey.go discards the error from ecdsa.GenerateKey (line 84) and checks the wrong variable after signing (line 123). Both must be fixed regardless of this RFC

Open questions

  1. Format: 1 or 2?
  2. Structure: 2, 1, or 3?
  3. YAML format support? No legacy data exists; YAML is only possible in user-manually edited data
  4. signCount: the default is 0. 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?
  5. If structure 2, use passkeys/ or ...?
  6. 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.
    passkey: {"v":1,"rpId":"example.com", ... ,"key":"yubikey://5c/12345678","signCount":0,"backupEligible":false,"backupState":false}
    making the signer pluggable while everything else stays in gopass. This is cheap because credentialId is 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=0 for 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.com

User 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