FIDO2Device interface abstracts hardware interaction (Register, Derive, Available, MatchesCredential). Real libfido2 implementation deferred; mock device used for full test coverage. AddFIDO2Slot: registers FIDO2 credential, derives KEK via HMAC-secret, wraps DEK, adds fido2/<label> slot to manifest. UnlockDEK: tries all fido2/* slots first (checks credential_id against connected device), falls back to passphrase. User never specifies which method. 6 tests: add slot, reject duplicate, unlock via FIDO2, fallback to passphrase when device unavailable, slot persistence, encrypted round-trip unlocked via FIDO2. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
264 lines
5.7 KiB
Go
264 lines
5.7 KiB
Go
package garden
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
// mockFIDO2 simulates a FIDO2 device for testing.
|
|
type mockFIDO2 struct {
|
|
deviceSecret []byte // fixed secret for HMAC derivation
|
|
credentials map[string]bool
|
|
available bool
|
|
}
|
|
|
|
func newMockFIDO2() *mockFIDO2 {
|
|
secret := make([]byte, 32)
|
|
_, _ = rand.Read(secret)
|
|
return &mockFIDO2{
|
|
deviceSecret: secret,
|
|
credentials: make(map[string]bool),
|
|
available: true,
|
|
}
|
|
}
|
|
|
|
func (m *mockFIDO2) Register(salt []byte) ([]byte, []byte, error) {
|
|
// Generate a random credential ID.
|
|
credID := make([]byte, 32)
|
|
_, _ = rand.Read(credID)
|
|
m.credentials[string(credID)] = true
|
|
|
|
// Derive HMAC-secret.
|
|
mac := hmac.New(sha256.New, m.deviceSecret)
|
|
mac.Write(salt)
|
|
return credID, mac.Sum(nil), nil
|
|
}
|
|
|
|
func (m *mockFIDO2) Derive(credentialID []byte, salt []byte) ([]byte, error) {
|
|
mac := hmac.New(sha256.New, m.deviceSecret)
|
|
mac.Write(salt)
|
|
return mac.Sum(nil), nil
|
|
}
|
|
|
|
func (m *mockFIDO2) Available() bool {
|
|
return m.available
|
|
}
|
|
|
|
func (m *mockFIDO2) MatchesCredential(credentialID []byte) bool {
|
|
return m.credentials[string(credentialID)]
|
|
}
|
|
|
|
func TestAddFIDO2Slot(t *testing.T) {
|
|
root := t.TempDir()
|
|
repoDir := filepath.Join(root, "repo")
|
|
|
|
g, err := Init(repoDir)
|
|
if err != nil {
|
|
t.Fatalf("Init: %v", err)
|
|
}
|
|
|
|
if err := g.EncryptInit("passphrase"); err != nil {
|
|
t.Fatalf("EncryptInit: %v", err)
|
|
}
|
|
|
|
device := newMockFIDO2()
|
|
if err := g.AddFIDO2Slot(device, "test-key"); err != nil {
|
|
t.Fatalf("AddFIDO2Slot: %v", err)
|
|
}
|
|
|
|
slot, ok := g.manifest.Encryption.KekSlots["fido2/test-key"]
|
|
if !ok {
|
|
t.Fatal("fido2/test-key slot should exist")
|
|
}
|
|
if slot.Type != "fido2" {
|
|
t.Errorf("slot type = %s, want fido2", slot.Type)
|
|
}
|
|
if slot.CredentialID == "" {
|
|
t.Error("slot should have credential_id")
|
|
}
|
|
if slot.Salt == "" || slot.WrappedDEK == "" {
|
|
t.Error("slot should have salt and wrapped DEK")
|
|
}
|
|
}
|
|
|
|
func TestAddFIDO2SlotDuplicateRejected(t *testing.T) {
|
|
root := t.TempDir()
|
|
repoDir := filepath.Join(root, "repo")
|
|
|
|
g, err := Init(repoDir)
|
|
if err != nil {
|
|
t.Fatalf("Init: %v", err)
|
|
}
|
|
|
|
if err := g.EncryptInit("passphrase"); err != nil {
|
|
t.Fatalf("EncryptInit: %v", err)
|
|
}
|
|
|
|
device := newMockFIDO2()
|
|
if err := g.AddFIDO2Slot(device, "mykey"); err != nil {
|
|
t.Fatalf("first AddFIDO2Slot: %v", err)
|
|
}
|
|
|
|
if err := g.AddFIDO2Slot(device, "mykey"); err == nil {
|
|
t.Fatal("duplicate AddFIDO2Slot should fail")
|
|
}
|
|
}
|
|
|
|
func TestUnlockViaFIDO2(t *testing.T) {
|
|
root := t.TempDir()
|
|
repoDir := filepath.Join(root, "repo")
|
|
|
|
g, err := Init(repoDir)
|
|
if err != nil {
|
|
t.Fatalf("Init: %v", err)
|
|
}
|
|
|
|
if err := g.EncryptInit("passphrase"); err != nil {
|
|
t.Fatalf("EncryptInit: %v", err)
|
|
}
|
|
|
|
device := newMockFIDO2()
|
|
if err := g.AddFIDO2Slot(device, "test-key"); err != nil {
|
|
t.Fatalf("AddFIDO2Slot: %v", err)
|
|
}
|
|
|
|
// Re-open (DEK not cached).
|
|
g2, err := Open(repoDir)
|
|
if err != nil {
|
|
t.Fatalf("Open: %v", err)
|
|
}
|
|
|
|
// Unlock via FIDO2 — should succeed without passphrase prompt.
|
|
err = g2.UnlockDEK(nil, device)
|
|
if err != nil {
|
|
t.Fatalf("UnlockDEK via FIDO2: %v", err)
|
|
}
|
|
|
|
if g2.dek == nil {
|
|
t.Error("DEK should be cached after FIDO2 unlock")
|
|
}
|
|
}
|
|
|
|
func TestFIDO2FallbackToPassphrase(t *testing.T) {
|
|
root := t.TempDir()
|
|
repoDir := filepath.Join(root, "repo")
|
|
|
|
g, err := Init(repoDir)
|
|
if err != nil {
|
|
t.Fatalf("Init: %v", err)
|
|
}
|
|
|
|
if err := g.EncryptInit("passphrase"); err != nil {
|
|
t.Fatalf("EncryptInit: %v", err)
|
|
}
|
|
|
|
device := newMockFIDO2()
|
|
if err := g.AddFIDO2Slot(device, "test-key"); err != nil {
|
|
t.Fatalf("AddFIDO2Slot: %v", err)
|
|
}
|
|
|
|
// Re-open.
|
|
g2, err := Open(repoDir)
|
|
if err != nil {
|
|
t.Fatalf("Open: %v", err)
|
|
}
|
|
|
|
// FIDO2 device is "unavailable" — should fall back to passphrase.
|
|
unavailable := newMockFIDO2()
|
|
unavailable.available = false
|
|
|
|
err = g2.UnlockDEK(
|
|
func() (string, error) { return "passphrase", nil },
|
|
unavailable,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("UnlockDEK fallback to passphrase: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestFIDO2SlotPersists(t *testing.T) {
|
|
root := t.TempDir()
|
|
repoDir := filepath.Join(root, "repo")
|
|
|
|
g, err := Init(repoDir)
|
|
if err != nil {
|
|
t.Fatalf("Init: %v", err)
|
|
}
|
|
|
|
if err := g.EncryptInit("passphrase"); err != nil {
|
|
t.Fatalf("EncryptInit: %v", err)
|
|
}
|
|
|
|
device := newMockFIDO2()
|
|
if err := g.AddFIDO2Slot(device, "test-key"); err != nil {
|
|
t.Fatalf("AddFIDO2Slot: %v", err)
|
|
}
|
|
|
|
// Re-open and verify slot persisted.
|
|
g2, err := Open(repoDir)
|
|
if err != nil {
|
|
t.Fatalf("Open: %v", err)
|
|
}
|
|
|
|
if _, ok := g2.manifest.Encryption.KekSlots["fido2/test-key"]; !ok {
|
|
t.Fatal("FIDO2 slot should persist after re-open")
|
|
}
|
|
}
|
|
|
|
func TestEncryptedRoundTripWithFIDO2(t *testing.T) {
|
|
root := t.TempDir()
|
|
repoDir := filepath.Join(root, "repo")
|
|
|
|
g, err := Init(repoDir)
|
|
if err != nil {
|
|
t.Fatalf("Init: %v", err)
|
|
}
|
|
|
|
if err := g.EncryptInit("passphrase"); err != nil {
|
|
t.Fatalf("EncryptInit: %v", err)
|
|
}
|
|
|
|
device := newMockFIDO2()
|
|
if err := g.AddFIDO2Slot(device, "test-key"); err != nil {
|
|
t.Fatalf("AddFIDO2Slot: %v", err)
|
|
}
|
|
|
|
// Add an encrypted file.
|
|
content := []byte("fido2-protected secret\n")
|
|
secretFile := filepath.Join(root, "secret")
|
|
if err := os.WriteFile(secretFile, content, 0o600); err != nil {
|
|
t.Fatalf("writing: %v", err)
|
|
}
|
|
|
|
if err := g.Add([]string{secretFile}, true); err != nil {
|
|
t.Fatalf("Add: %v", err)
|
|
}
|
|
|
|
// Re-open, unlock via FIDO2, restore.
|
|
g2, err := Open(repoDir)
|
|
if err != nil {
|
|
t.Fatalf("Open: %v", err)
|
|
}
|
|
|
|
if err := g2.UnlockDEK(nil, device); err != nil {
|
|
t.Fatalf("UnlockDEK: %v", err)
|
|
}
|
|
|
|
_ = os.Remove(secretFile)
|
|
if err := g2.Restore(nil, true, nil); err != nil {
|
|
t.Fatalf("Restore: %v", err)
|
|
}
|
|
|
|
got, err := os.ReadFile(secretFile)
|
|
if err != nil {
|
|
t.Fatalf("reading restored: %v", err)
|
|
}
|
|
if string(got) != string(content) {
|
|
t.Errorf("content = %q, want %q", got, content)
|
|
}
|
|
}
|