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:
156
garden/fido2_hardware.go
Normal file
156
garden/fido2_hardware.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user