Step 25: Real FIDO2 hardware key support.

HardwareFIDO2 implements FIDO2Device via go-libfido2 (CGo bindings to
Yubico's libfido2). Gated behind //go:build fido2 tag to keep default
builds CGo-free. Nix flake adds sgard-fido2 package variant.

CLI changes: --fido2-pin flag, unlockDEK helper tries FIDO2 first,
add-fido2/encrypt init --fido2 use real hardware, auto-unlock added
to restore/checkpoint/diff for encrypted entries.

Tested manually: add-fido2, add --encrypt, restore, checkpoint, diff
all work with hardware FIDO2 key (touch-to-unlock, no passphrase).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-24 12:40:46 -07:00
parent 5529fff649
commit 490db0599c
17 changed files with 358 additions and 34 deletions

156
garden/fido2_hardware.go Normal file
View File

@@ -0,0 +1,156 @@
//go:build fido2
package garden
import (
"crypto/sha256"
"fmt"
libfido2 "github.com/keys-pub/go-libfido2"
)
const rpID = "sgard"
// HardwareFIDO2 implements FIDO2Device using a real hardware authenticator
// via libfido2.
type HardwareFIDO2 struct {
pin string // device PIN (empty if no PIN set)
}
// NewHardwareFIDO2 creates a HardwareFIDO2 device. The PIN is needed for
// operations on PIN-protected authenticators.
func NewHardwareFIDO2(pin string) *HardwareFIDO2 {
return &HardwareFIDO2{pin: pin}
}
// Available reports whether a FIDO2 device is connected.
func (h *HardwareFIDO2) Available() bool {
locs, err := libfido2.DeviceLocations()
if err != nil {
return false
}
return len(locs) > 0
}
// Register creates a new credential with the hmac-secret extension.
// Returns the credential ID and the HMAC-secret output for the given salt.
func (h *HardwareFIDO2) Register(salt []byte) ([]byte, []byte, error) {
dev, err := h.deviceForPath()
if err != nil {
return nil, nil, err
}
cdh := sha256.Sum256(salt)
// CTAP2 hmac-secret extension requires a 32-byte salt.
hmacSalt := fido2Salt(salt)
userID := sha256.Sum256([]byte("sgard-user"))
attest, err := dev.MakeCredential(
cdh[:],
libfido2.RelyingParty{ID: rpID, Name: "sgard"},
libfido2.User{ID: userID[:], Name: "sgard"},
libfido2.ES256,
h.pin,
&libfido2.MakeCredentialOpts{
Extensions: []libfido2.Extension{libfido2.HMACSecretExtension},
RK: libfido2.False,
},
)
if err != nil {
return nil, nil, fmt.Errorf("fido2 make credential: %w", err)
}
// Do an assertion to get the HMAC-secret for this salt.
assertion, err := dev.Assertion(
rpID,
cdh[:],
[][]byte{attest.CredentialID},
h.pin,
&libfido2.AssertionOpts{
Extensions: []libfido2.Extension{libfido2.HMACSecretExtension},
HMACSalt: hmacSalt,
UP: libfido2.True,
},
)
if err != nil {
return nil, nil, fmt.Errorf("fido2 assertion for hmac-secret: %w", err)
}
return attest.CredentialID, assertion.HMACSecret, nil
}
// Derive computes HMAC(device_secret, salt) for an existing credential.
// Requires user touch.
func (h *HardwareFIDO2) Derive(credentialID []byte, salt []byte) ([]byte, error) {
dev, err := h.deviceForPath()
if err != nil {
return nil, err
}
cdh := sha256.Sum256(salt)
hmacSalt := fido2Salt(salt)
assertion, err := dev.Assertion(
rpID,
cdh[:],
[][]byte{credentialID},
h.pin,
&libfido2.AssertionOpts{
Extensions: []libfido2.Extension{libfido2.HMACSecretExtension},
HMACSalt: hmacSalt,
UP: libfido2.True,
},
)
if err != nil {
return nil, fmt.Errorf("fido2 assertion: %w", err)
}
return assertion.HMACSecret, nil
}
// MatchesCredential reports whether the connected device might hold the
// given credential. Since probing without user presence is unreliable
// across devices, we optimistically return true and let Derive handle
// the actual verification (which requires a touch).
func (h *HardwareFIDO2) MatchesCredential(_ []byte) bool {
return h.Available()
}
// fido2Salt returns a 32-byte salt suitable for the CTAP2 hmac-secret
// extension. If the input is already 32 bytes, it is returned as-is.
// Otherwise, SHA-256 is used to derive a 32-byte value deterministically.
func fido2Salt(salt []byte) []byte {
if len(salt) == 32 {
return salt
}
h := sha256.Sum256(salt)
return h[:]
}
// deviceForPath returns a Device handle for the first connected FIDO2
// device. The library manages open/close internally per operation.
func (h *HardwareFIDO2) deviceForPath() (*libfido2.Device, error) {
locs, err := libfido2.DeviceLocations()
if err != nil {
return nil, fmt.Errorf("listing fido2 devices: %w", err)
}
if len(locs) == 0 {
return nil, fmt.Errorf("no fido2 device found")
}
dev, err := libfido2.NewDevice(locs[0].Path)
if err != nil {
return nil, fmt.Errorf("opening fido2 device %s: %w", locs[0].Path, err)
}
return dev, nil
}
// DetectHardwareFIDO2 returns a HardwareFIDO2 device if hardware is available,
// or nil if no device is connected.
func DetectHardwareFIDO2(pin string) FIDO2Device {
d := NewHardwareFIDO2(pin)
if d.Available() {
return d
}
return nil
}