Salvium wallet decryption with hardware authenticators
hmac-secret extension. The mechanism substitutes for, or composes with, the existing PIN or passphrase based unlock. It works with any compliant external FIDO2 authenticator including YubiKey, SoloKey, Nitrokey, Feitian, and Token2.
The goal is to give wallet users a meaningful upgrade in at rest protection using hardware they already own, without requiring custom firmware, vendor specific integrations, or a third party security audit pipeline.
This specification deliberately excludes biometric authentication, including platform authenticators (iOS Face ID, Touch ID, and Android BiometricPrompt) and biometric verification on hardware authenticators. See section 10.4 for the rationale. Because platform authenticators are excluded, the user presence and user verification distinctions that operating system APIs blur on iOS and Android do not apply to this specification. The mechanism operates only against external CTAP2 authenticators, where these signals are well defined.
A wallet that supports this specification can offer the user a choice at wallet creation time, or as a setting on an existing wallet, between a PIN unlock, a hardware authenticator unlock, or both. The user touches a FIDO2 device once at enrollment to register it, and once at every unlock to authorise decryption.
$ whiskywallet open my-wallet
Touch your authenticator to unlock...
[touch detected, wallet unlocked]
The spend key is decrypted in memory only after the touch. The wallet behaves identically thereafter to a PIN unlocked wallet, with the same scanning, signing, and broadcasting paths.
This specification defines:
hmac-secret output is turned into a wrapping key that decrypts the wallet master key.This specification does not define:
This mechanism is designed to defend against the following:
The mechanism does not defend against the following, and users with these threat models should consider alternatives:
hmac-secret output without a touch breaks the model. Users should source authenticators from reputable vendors and verify firmware where possible.This is good security for normal users at low cost. It is not maximum security for hostile environments. Users with adversaries capable of running code on their host while they are signing should use a true hardware wallet when one is available, and treat this mechanism as an interim or supplementary defence.
The specification also assumes users who can manage seed phrase backup discipline. Loss of all enrolled authenticators without a recovery PIN entry, or loss of the wallet file together with all authenticators, forces recovery from seed. Users who cannot reliably store and protect a seed offline may be better served by custodial wallets or by designs that include guided recovery, because the failure mode of this specification is permanent inaccessibility rather than degraded access.
The wallet master key (WMK) is a single 32 byte key, generated once at wallet creation as fresh random bytes and never changed for the life of the wallet. The WMK encrypts the spend key and any other sensitive material at rest using XChaCha20-Poly1305 with a fresh nonce per encryption. What changes between unlock methods is not the WMK itself, but the wrapping key used to encrypt the WMK in each unlock entry.
Each unlock entry stores the WMK encrypted under a wrapping key that the entry's inputs reproduce at unlock time. The wrapping key is derived as follows for each method.
wrapping_key = Argon2id(passphrase, salt_pin, params)
hmac_output = authenticator.hmac_secret(credential_id, salt_fido2)
wrapping_key = HKDF-SHA256(hmac_output, info = "wwallet-fido2-v1")
k_pin = Argon2id(passphrase, salt_pin, params)
hmac_output = authenticator.hmac_secret(credential_id, salt_fido2)
wrapping_key = HKDF-SHA256(k_pin || hmac_output, info = "wwallet-pin-fido2-v1")
The wallet file stores the spend key encrypted under the WMK, the WMK encrypted under one or more wrapping keys (one per unlock entry), and the parameters needed to reproduce each wrapping key. An installation can therefore carry, for example, a primary YubiKey entry, a backup YubiKey entry, and a recovery PIN entry, and any one of them is sufficient to unlock by reproducing its wrapping key and decrypting the wrapped WMK. Wrapping keys themselves are ephemeral and exist only in memory during unlock.
This section specifies the additions to the wallet file. Existing fields not relevant to unlock are unchanged.
The wallet file gains an unlock section containing one or more unlock entries. Each entry is independently capable of unwrapping the wallet master key from the parameters it stores.
unlock:
version: 1
default_entry: "primary-yubikey"
entries:
- id: "primary-yubikey"
method: "fido2"
rp_id: "wallet.salvium.invalid"
credential_id: <bytes, base64>
salt: <32 bytes, base64>
kdf: "hkdf-sha256"
info: "wwallet-fido2-v1"
wmk_wrapped: <bytes, base64>
wmk_nonce: <bytes, base64>
- id: "backup-yubikey"
method: "fido2"
rp_id: "wallet.salvium.invalid"
credential_id: <bytes, base64>
salt: <32 bytes, base64>
kdf: "hkdf-sha256"
info: "wwallet-fido2-v1"
wmk_wrapped: <bytes, base64>
wmk_nonce: <bytes, base64>
- id: "recovery-pin"
method: "pin"
kdf: "argon2id"
argon2_salt: <16 bytes, base64>
argon2_params:
memory_kib: 262144
iterations: 3
parallelism: 1
wmk_wrapped: <bytes, base64>
wmk_nonce: <bytes, base64>
| Field | Description |
|---|---|
version | Schema version, always 1 for this revision. |
default_entry | The id of the entry the wallet should attempt first at unlock. The user can override at unlock time. |
id | A user readable label for the entry. The wallet uses it in prompts. |
method | One of fido2, pin, or pin+fido2. |
rp_id | The FIDO2 relying party identifier the credential was created under. |
credential_id | The opaque credential identifier returned by the authenticator at enrollment. |
salt | The 32 byte salt passed to the authenticator's hmac-secret evaluation. |
kdf | The key derivation function. hkdf-sha256 is required for all fido2 and pin+fido2 entries. |
info | The HKDF info string. Distinct values for fido2 and pin+fido2 provide domain separation. |
argon2_salt | The 16 byte salt for the Argon2id derivation. Present on pin and pin+fido2 entries. |
argon2_params | The Argon2id cost parameters: memory_kib (memory cost in kibibytes), iterations (time cost), and parallelism (lanes). Present on pin and pin+fido2 entries. |
wmk_wrapped | The wallet master key, encrypted under the wrapping key reproduced from the entry's unlock material. |
wmk_nonce | The nonce used for wmk_wrapped. |
Not every field applies to every method. The required fields per method are as follows.
fido2 entries require rp_id, credential_id, salt, kdf, info, wmk_wrapped, and wmk_nonce. The kdf must be hkdf-sha256.pin entries require kdf, argon2_salt, argon2_params, wmk_wrapped, and wmk_nonce. The kdf must be argon2id.pin+fido2 entries require the FIDO2 fields (rp_id, credential_id, salt, and info) and the PIN fields (argon2_salt and argon2_params) together with kdf, wmk_wrapped, and wmk_nonce. The kdf must be hkdf-sha256. The PIN derivation runs Argon2id over the passphrase with the entry's Argon2 parameters, the FIDO2 derivation runs hmac-secret on the authenticator with credential_id and salt, the two outputs are concatenated in the order PIN first and FIDO2 second, and HKDF-SHA256 with the entry's info string produces the wrapping key.The wallet file carries a stable identifier in its main header, distinct from the unlock section. The identifier is a 16 byte random value generated once at wallet creation, encoded as a hexadecimal string in the wallet file. It does not contain user identifying information and is not derived from the spend key or the public address. Its purpose is to bind authenticated material, including the associated data of every wrapped unlock entry and the FIDO2 user.id handle used at credential creation, to one specific wallet instance. An unlock entry copied from one wallet to another therefore cannot be silently substituted, because authenticated decryption fails when the destination wallet's identifier does not match the identifier bound into the entry's associated data.
The wrap is XChaCha20-Poly1305 with a 192 bit nonce. The associated data is the entry id concatenated with the wallet's stable identifier, both as UTF-8 bytes, separated by a single 0x00 byte. This binds the wrap to its entry and prevents an attacker from substituting a wrap from a different entry.
Enrollment is the act of binding an authenticator to the wallet by creating a credential and storing the unlock entry that references it.
primary-yubikey.hmac-secret extension enabled, using rp_id = "wallet.salvium.invalid" and user.id set to the 16 byte binary form of the wallet's stable identifier. The user verifies the touch.credential_id from the authenticator.hmac-secret evaluation against the new credential using the salt from step 1. The user verifies the touch a second time. This confirms the credential is usable for unlock and produces hmac_output.HKDF-SHA256(hmac_output, info = "wwallet-fido2-v1").wmk_wrapped and wmk_nonce.unlock.entries list with the values above.Before step 2, the wallet must display to the user that a new credential will be created on the authenticator, that the credential will be used to derive a key that decrypts the wallet, that loss of the authenticator without a backup will require recovery from the wallet seed, and the relying party id under which the credential will be registered. The user must explicitly confirm before the wallet proceeds.
Unlock is the act of decrypting the wallet master key from a stored unlock entry.
unlock section.unlock.entries list and select the default entry, or prompt the user to select an entry.method = "fido2": request an hmac-secret evaluation from the authenticator using the entry's credential_id and salt. The user verifies the touch. Derive the wrapping key as HKDF-SHA256(hmac_output, info = entry.info). Decrypt wmk_wrapped under the wrapping key. On authentication failure, present an error and offer to try a different entry.method = "pin": prompt the user for the passphrase. Derive the wrapping key as Argon2id(passphrase, argon2_salt, argon2_params). Decrypt wmk_wrapped under the wrapping key. On authentication failure, increment a local rate limit counter and prompt again.method = "pin+fido2", perform both derivations as in steps 2 and 3, concatenate the outputs in the order PIN first and FIDO2 second, derive the wrapping key as HKDF-SHA256 over the concatenation with the entry's info string, and decrypt wmk_wrapped under the wrapping key.If no enrolled authenticator is present, the wallet should present the list of enrolled entries with their labels, allow the user to select a different entry, and if only FIDO2 entries are enrolled and no authenticator is present, instruct the user to connect one or to use the recovery flow described in section 9. The wallet must not expose the existence of credential ids, salts, or other metadata to the user beyond the entry labels they chose at enrollment.
A user should enroll at least two authenticators. Loss or destruction of the only enrolled authenticator forces a recovery from seed, which is recoverable but disruptive.
Each authenticator gets its own unlock entry with its own credential_id and salt. Compromise of one entry does not affect the others, because each entry independently wraps the wallet master key under its own wrapping key.
Adding a new authenticator requires the wallet to be currently unlocked, because the wallet master key must be in memory to be wrapped under the new credential. The user opens the wallet by any existing entry, then runs the enrollment ceremony from section 6 for the new authenticator.
The user can remove an entry from the unlock.entries list at any time. The wallet should require a confirmation that names the entry being removed, and should refuse to remove the last remaining entry without an explicit warning that doing so will leave the wallet inaccessible.
The wallet seed remains the master recovery mechanism. If all enrolled authenticators are lost and no recovery PIN entry exists, or if the wallet file itself is lost, the user restores from seed.
A restored wallet is a fresh installation. The new installation has no unlock entries until the user enrolls them. The seed itself is not, and must not be, stored alongside the wallet file. It is held by the user offline.
This is unchanged from existing wallet practice. The FIDO2 unlock mechanism does not weaken the seed based recovery story; it only changes the path used during normal day to day unlock.
A wallet should expose three policy options to the user at wallet creation, and should allow them to be changed on an existing wallet provided the wallet is currently unlocked. A fourth subsection below documents methods that are deliberately excluded from this specification.
Equivalent to today's behaviour. A single pin entry. Recommended only for wallets that hold small amounts or for users who cannot use FIDO2 for accessibility or environmental reasons.
One or more fido2 entries, no pin entry. Loss of all authenticators forces recovery from seed. Recommended for users who maintain at least two authenticators in physically separated locations.
One or more pin+fido2 entries. The user must present both a passphrase and an authenticator touch to unlock. This raises the bar to require both factor types simultaneously and defends against the case where one factor is compromised.
A wallet may also offer a hybrid configuration in which a pin+fido2 entry is the default and a separate pin entry exists as a recovery path with a stronger passphrase. The user trades the convenience of a remembered short passphrase for the convenience of being able to unlock without the authenticator in extremis.
This specification deliberately does not support biometric authentication, neither through platform authenticators (iOS Face ID, Touch ID, and Android BiometricPrompt) nor through biometric verification on hardware authenticators, for example fingerprint security keys such as YubiKey Bio or Feitian K9.
Biometric authentication is excluded for the following reasons.
Wallet implementations of this specification must not enroll biometric credentials. The wallet must not call platform biometric APIs (ASAuthorization for biometric on iOS, BiometricPrompt on Android, and equivalent APIs on other platforms) for the purpose of unlocking the wallet.
Hardware authenticators that include biometric verification capabilities (fingerprint sensors) may be used only in modes that rely on the authenticator's own user presence verification through touch, not through biometric verification. Wallet implementations should configure FIDO2 requests in a way that does not solicit biometric user verification from the authenticator, and should warn users at enrollment time if they attempt to enroll a biometric equipped authenticator.
Users who require biometric unlock convenience are encouraged to use a different wallet that meets their threat model. This specification is not a complete solution for all users; it is a complete solution for users who share its assumptions.
Each unlock entry must use an independently generated 32 byte random salt. Reusing the salt across entries does not directly leak the wallet master key, because the entries are wrapped independently, but it weakens the domain separation between credentials and is unnecessary given that salts are cheap.
Argon2id parameters for pin and pin+fido2 entries should be selected to make a single derivation take roughly 250 to 500 milliseconds on the user's hardware. The defaults of memory_kib = 262144, iterations = 3, parallelism = 1 are reasonable for laptops and desktops in 2026 and should be revisited as hardware evolves.
HKDF-SHA256 is used in extract and expand mode. The info string provides domain separation between methods: wwallet-fido2-v1 for FIDO2 only, wwallet-pin-fido2-v1 for the composed method.
An attacker who obtains a copy of the wallet file containing a pin entry can attempt an offline Argon2id brute force against the passphrase, at their own pace and on their own hardware. The strength of the passphrase is therefore the floor on the at rest security of any wallet that includes a pure pin entry. A short or low entropy passphrase is recoverable by an attacker with modest GPU resources, regardless of the Argon2id parameters chosen, because the parameters only slow the attacker linearly while passphrase weakness reduces the search space exponentially. Wallets that include a pin entry should require, or at minimum strongly recommend, a passphrase of meaningful length and entropy. Pure fido2 entries and pin+fido2 entries are not exposed to this attack, because the FIDO2 component requires physical possession of the authenticator and cannot be brute forced offline.
The wrap of the wallet master key uses XChaCha20-Poly1305. The associated data binds the wrap to the entry id and the wallet identifier. Tampering with any of those fields invalidates the wrap. A wallet must reject a wrap whose authenticated decryption fails, and must not silently fall back to a different entry without explicit user direction.
The recommended rp_id is wallet.salvium.invalid. The .invalid top level domain is reserved by RFC 2606 and is guaranteed never to resolve, on the local network or on the public internet. The wallet performs CTAP2 operations directly against the authenticator over USB or NFC, so no DNS resolution or origin validation is involved at any point in enrollment or unlock. The rp_id serves only to namespace credentials, so that on authenticators with credential management user interfaces, the user can identify the credential as belonging to a Salvium wallet. Wallet implementers should agree on a single rp_id to allow a single authenticator to be used across multiple Salvium wallet implementations without conflict.
Earlier drafts of this specification used wallet.salvium.local. The .local suffix is reserved for multicast DNS by RFC 6762 and triggers mDNS lookups on systems that have it enabled, which leaks the chosen string on the local network and is therefore incorrect for a value that should never be resolved at all. The .invalid suffix is the correct choice for this purpose.
This choice is correct only for wallets that talk to authenticators directly over CTAP2, for example a desktop binary linked against libfido2, a Tauri or Electron application using the same path, or a command line utility. A future variant of this specification that targeted browser mediated WebAuthn flows through navigator.credentials, mobile platform FIDO2 APIs, or any environment in which the browser or operating system validates the relying party identifier against the page or application origin, could not reuse a reserved suffix such as .invalid. The browser or platform would reject the request with a security error before it ever reached the authenticator, because the relying party identifier in those environments must be a registrable domain on the Public Suffix List, with appropriate Apple Associated Domains or Android Asset Links bindings for mobile applications. A wallet variant of that kind would require a real domain that the implementer controls, and that change should be treated as a substantive design decision rather than a configuration adjustment.
The hmac-secret extension requires user presence on every evaluation: the authenticator must observe a physical touch before producing the HMAC output. The wallet must request user presence and must not configure the request in a way that suppresses it. A wallet should reject an authenticator that returns an hmac-secret output without observable user presence, where the authenticator's policy makes that detectable.
User verification, defined in FIDO terminology as a stronger check such as a PIN entered on the authenticator or a biometric scan, is a property distinct from user presence. Where the authenticator supports both touch based user presence and biometric user verification, the wallet must request only the touch based variant. The wallet must not require user verification on the request (the WebAuthn userVerification = "required" option, or equivalently the CTAP2 uv option), because requiring it on a biometric equipped authenticator would invoke the fingerprint sensor or other biometric subsystem. Authenticators that cannot satisfy a touch only request must be rejected at enrollment time with a clear message to the user explaining that biometric authentication is not supported by this specification.
A compliant authenticator for this specification must support CTAP 2.0 or later, must implement the hmac-secret extension, and must require a physical touch as the user presence signal on every credential operation. Authenticators that lack any of these properties are not usable and must be rejected at enrollment time with a clear message to the user.
External hardware authenticators known to be compatible with hmac-secret backed key derivation, based on community use with systemd-cryptenroll and age-plugin-fido2-hmac, include the YubiKey 5 series, the YubiKey Bio in touch only mode, the SoloKey 2, the Nitrokey 3, and Token2 series authenticators. Wallet implementers should verify behaviour against the specific firmware version in use, because vendor implementations of optional extensions vary, and should publish their own compatibility results so that users can select hardware with confidence.
Operating system integration is a separate concern from authenticator capability. Linux installations require a FIDO2 udev rule set so that a non root user can access the authenticator, Windows requires a current WebAuthn or libfido2 path for direct CTAP2 access, and macOS exposes external authenticators through its own framework. An authenticator that is technically compliant can still fail at the host integration layer if these prerequisites are not met, and wallet implementations should produce diagnostic output that distinguishes authenticator failure from host failure.
The credential should be created as a non discoverable (non resident) credential where possible. The credential_id is stored in the wallet file and presented to the authenticator at unlock, so discoverability is not required, and a non discoverable credential consumes no slot in the authenticator's resident credential storage.
The wallet file, including the unlock section, should not be uploaded to third party services without consideration. The credential id and salt do not leak the spend key, but they do reveal that the file is a Salvium wallet using FIDO2 unlock, and they bind the file to specific authenticators a user holds.
The wallet must avoid timing differences when handling authentication failures across entries. An attacker with access to the wallet file should not be able to learn which entry produced a successful decryption from observing the wallet's behaviour.
The unlock section is strictly additive. A wallet that does not implement this specification will encounter an unlock section it does not understand. The recommended behaviour for a non implementing wallet is to detect the presence of the unlock section, refuse to open the wallet and surface a clear error indicating that the wallet uses a newer unlock format the current wallet does not support, and direct the user to a wallet that does support the format, or to recover from seed into the current wallet's native format.
Wallet files that have never been migrated to the new format remain readable by older wallets without change.
A future version of this specification may define a migration path that allows a wallet to express both the old format and the new format simultaneously, at the cost of carrying two encryption schemes in the same file. The current version does not require this.
A user creates a new wallet with a single primary YubiKey and accepts the loss of the key as a recovery from seed event.
unlock:
version: 1
default_entry: "primary"
entries:
- id: "primary"
method: "fido2"
rp_id: "wallet.salvium.invalid"
credential_id: "AAEC..."
salt: "kJh..."
kdf: "hkdf-sha256"
info: "wwallet-fido2-v1"
wmk_wrapped: "..."
wmk_nonce: "..."
A user enrolls two authenticators and a strong recovery passphrase.
unlock:
version: 1
default_entry: "primary"
entries:
- id: "primary"
method: "fido2"
...
- id: "backup"
method: "fido2"
...
- id: "recovery"
method: "pin"
kdf: "argon2id"
argon2_salt: "..."
argon2_params:
memory_kib: 524288
iterations: 4
parallelism: 1
wmk_wrapped: "..."
wmk_nonce: "..."
A user wants both factors required at every unlock.
unlock:
version: 1
default_entry: "primary"
entries:
- id: "primary"
method: "pin+fido2"
rp_id: "wallet.salvium.invalid"
credential_id: "..."
salt: "..."
kdf: "hkdf-sha256"
info: "wwallet-pin-fido2-v1"
argon2_salt: "..."
argon2_params:
memory_kib: 262144
iterations: 3
parallelism: 1
wmk_wrapped: "..."
wmk_nonce: "..."
hmac-secret extension.systemd-cryptenroll documentation, as a reference implementation of FIDO2 hmac-secret for symmetric key derivation in disk encryption.age-plugin-fido2-hmac source, as a Rust reference implementation of FIDO2 backed key wrapping using hmac-secret.Comments and proposed revisions are welcome. This specification is a working draft maintained on whiskymine.io and intended for adoption by Salvium wallet implementers who wish to offer hardware gated unlock as an option to their users.