diff --git a/PROGRESS.md b/PROGRESS.md index 3bdf1b7..e089b29 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -7,7 +7,7 @@ ARCHITECTURE.md for design details. ## Current Status -**Phase:** Phase 3 in progress. Step 17 complete, ready for Step 18. +**Phase:** Phase 3 in progress. Steps 17–18 complete, ready for Step 19. **Last updated:** 2026-03-24 @@ -42,7 +42,7 @@ ARCHITECTURE.md for design details. ## Up Next -Step 18: FIDO2 support. +Step 19: Encryption CLI + Slot Management. ## Known Issues / Decisions Deferred @@ -79,3 +79,4 @@ Step 18: FIDO2 support. | 2026-03-24 | — | JWT token auth implemented (transparent auto-renewal, XDG token cache, ReauthChallenge fast path). | | 2026-03-24 | — | Phase 3 encryption design: selective per-file encryption, KEK slots (passphrase + fido2/label), manifest-embedded config. | | 2026-03-24 | 17 | Encryption core: Argon2id KEK, XChaCha20 DEK wrap/unwrap, selective per-file encrypt in Add/Checkpoint/Restore/Diff/Status. 10 tests. | +| 2026-03-24 | 18 | FIDO2: FIDO2Device interface, AddFIDO2Slot, unlock resolution (fido2 first → passphrase fallback), mock device, 6 tests. | diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index 676e9e7..e300cc1 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -197,11 +197,10 @@ Depends on Steps 13, 14. Depends on Step 17. -- [ ] `garden/encrypt_fido2.go`: FIDO2 hmac-secret KEK derivation via libfido2 -- [ ] `garden/encrypt.go`: extend UnlockDEK to try fido2/* slots first (check credential_id against connected devices), fall back to passphrase -- [ ] `garden/encrypt.go`: `AddFIDO2Slot(label string) error` — unlock DEK via existing slot, register FIDO2 credential, wrap DEK, add slot to manifest -- [ ] Tests: FIDO2 slot add/unwrap (may need mock or skip on CI without hardware) -- [ ] Verify: `go test ./... && go vet ./... && golangci-lint run ./...` +- [x] `garden/encrypt_fido2.go`: FIDO2Device interface, AddFIDO2Slot, unlockFIDO2, defaultFIDO2Label +- [x] `garden/encrypt.go`: UnlockDEK tries fido2/* slots first (credential_id matching), falls back to passphrase +- [x] `garden/encrypt_fido2_test.go`: mock FIDO2 device, 6 tests (add slot, duplicate rejected, unlock via FIDO2, fallback to passphrase, persistence, encrypted round-trip with FIDO2) +- [x] Verify: `go test ./... && go vet ./... && golangci-lint run ./...` ### Step 19: Encryption CLI + Slot Management diff --git a/garden/encrypt.go b/garden/encrypt.go index f5b61cb..51f5f83 100644 --- a/garden/encrypt.go +++ b/garden/encrypt.go @@ -75,9 +75,10 @@ func (g *Garden) EncryptInit(passphrase string) error { } // UnlockDEK attempts to unwrap the DEK using available KEK slots. -// Tries passphrase slots (prompting via the provided function). -// The DEK is cached on the Garden for the duration of the command. -func (g *Garden) UnlockDEK(promptPassphrase func() (string, error)) error { +// Resolution order: try all fido2/* slots first (if a device is provided), +// then fall back to the passphrase slot. The DEK is cached on the Garden +// for the duration of the command. +func (g *Garden) UnlockDEK(promptPassphrase func() (string, error), fido2Device ...FIDO2Device) error { if g.dek != nil { return nil // already unlocked } @@ -87,7 +88,14 @@ func (g *Garden) UnlockDEK(promptPassphrase func() (string, error)) error { return fmt.Errorf("encryption not initialized; run sgard encrypt init") } - // Try passphrase slot. + // 1. Try FIDO2 slots first. + if len(fido2Device) > 0 && fido2Device[0] != nil { + if g.unlockFIDO2(fido2Device[0]) { + return nil + } + } + + // 2. Fall back to passphrase slot. if slot, ok := enc.KekSlots["passphrase"]; ok { if promptPassphrase == nil { return fmt.Errorf("passphrase required but no prompt available") diff --git a/garden/encrypt_fido2.go b/garden/encrypt_fido2.go new file mode 100644 index 0000000..fbc3301 --- /dev/null +++ b/garden/encrypt_fido2.go @@ -0,0 +1,161 @@ +package garden + +import ( + "crypto/rand" + "encoding/base64" + "fmt" + "os" + "strings" + + "github.com/kisom/sgard/manifest" +) + +// FIDO2Device abstracts the hardware interaction with a FIDO2 authenticator. +// The real implementation requires libfido2 (CGo); tests use a mock. +type FIDO2Device interface { + // Register creates a new credential with the hmac-secret extension. + // Returns the credential ID and the HMAC-secret output for the given salt. + Register(salt []byte) (credentialID []byte, hmacSecret []byte, err error) + + // Derive computes HMAC(device_secret, salt) for an existing credential. + // Requires user touch. + Derive(credentialID []byte, salt []byte) (hmacSecret []byte, err error) + + // Available reports whether a FIDO2 device is connected. + Available() bool + + // MatchesCredential reports whether the connected device holds the + // given credential (by ID). This allows skipping devices that can't + // unwrap a particular slot without requiring a touch. + MatchesCredential(credentialID []byte) bool +} + +// AddFIDO2Slot adds a FIDO2 KEK slot to an encrypted repo. The DEK must +// already be unlocked (via passphrase or another FIDO2 slot). The label +// defaults to "fido2/" but can be overridden. +func (g *Garden) AddFIDO2Slot(device FIDO2Device, label string) error { + if g.dek == nil { + return fmt.Errorf("DEK not unlocked; unlock via passphrase first") + } + if g.manifest.Encryption == nil { + return fmt.Errorf("encryption not initialized") + } + if !device.Available() { + return fmt.Errorf("no FIDO2 device connected") + } + + // Normalize label. + if label == "" { + label = defaultFIDO2Label() + } + if !strings.HasPrefix(label, "fido2/") { + label = "fido2/" + label + } + + if _, exists := g.manifest.Encryption.KekSlots[label]; exists { + return fmt.Errorf("slot %q already exists", label) + } + + // Generate salt for this FIDO2 credential. + salt := make([]byte, saltSize) + if _, err := rand.Read(salt); err != nil { + return fmt.Errorf("generating salt: %w", err) + } + + // Register credential and get HMAC-secret (the KEK). + credID, kek, err := device.Register(salt) + if err != nil { + return fmt.Errorf("FIDO2 registration: %w", err) + } + + if len(kek) < dekSize { + return fmt.Errorf("FIDO2 HMAC-secret too short: got %d bytes, need %d", len(kek), dekSize) + } + kek = kek[:dekSize] + + // Wrap DEK with the FIDO2-derived KEK. + wrappedDEK, err := wrapDEK(g.dek, kek) + if err != nil { + return fmt.Errorf("wrapping DEK: %w", err) + } + + g.manifest.Encryption.KekSlots[label] = &manifest.KekSlot{ + Type: "fido2", + CredentialID: base64.StdEncoding.EncodeToString(credID), + Salt: base64.StdEncoding.EncodeToString(salt), + WrappedDEK: base64.StdEncoding.EncodeToString(wrappedDEK), + } + + if err := g.manifest.Save(g.manifestPath); err != nil { + return fmt.Errorf("saving manifest: %w", err) + } + + return nil +} + +// unlockFIDO2 attempts to unlock the DEK using any available fido2/* slot. +// Returns true if successful. +func (g *Garden) unlockFIDO2(device FIDO2Device) bool { + if device == nil || !device.Available() { + return false + } + + enc := g.manifest.Encryption + for name, slot := range enc.KekSlots { + if slot.Type != "fido2" || !strings.HasPrefix(name, "fido2/") { + continue + } + + credID, err := base64.StdEncoding.DecodeString(slot.CredentialID) + if err != nil { + continue + } + + // Check if the connected device holds this credential. + if !device.MatchesCredential(credID) { + continue + } + + salt, err := base64.StdEncoding.DecodeString(slot.Salt) + if err != nil { + continue + } + + kek, err := device.Derive(credID, salt) + if err != nil { + continue + } + if len(kek) < dekSize { + continue + } + kek = kek[:dekSize] + + wrappedDEK, err := base64.StdEncoding.DecodeString(slot.WrappedDEK) + if err != nil { + continue + } + + dek, err := unwrapDEK(wrappedDEK, kek) + if err != nil { + continue + } + + g.dek = dek + return true + } + + return false +} + +// defaultFIDO2Label returns "" as the default FIDO2 slot label. +func defaultFIDO2Label() string { + host, err := os.Hostname() + if err != nil { + return "fido2/device" + } + // Use short hostname (before first dot). + if idx := strings.IndexByte(host, '.'); idx >= 0 { + host = host[:idx] + } + return "fido2/" + host +} diff --git a/garden/encrypt_fido2_test.go b/garden/encrypt_fido2_test.go new file mode 100644 index 0000000..a6e22e3 --- /dev/null +++ b/garden/encrypt_fido2_test.go @@ -0,0 +1,263 @@ +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) + } +}