From 341428d9cabe178520b719e27d945724cce5985a Mon Sep 17 00:00:00 2001 From: clawbot Date: Sun, 8 Feb 2026 12:04:15 -0800 Subject: [PATCH 1/3] fix: NumSecrets() now correctly counts secrets by checking for current file NumSecrets() previously looked for non-directory, non-'current' files directly under each secret directory, but the only children are 'current' (file, excluded) and 'versions' (directory, excluded), so it always returned 0. Now checks for the existence of the 'current' file, which is the canonical indicator that a secret exists and has an active version. This fixes the safety check in UnlockersRemove that was always allowing removal of the last unlocker. --- internal/vault/vault.go | 16 ++++++---------- internal/vault/vault_test.go | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/internal/vault/vault.go b/internal/vault/vault.go index b535317..2243dc7 100644 --- a/internal/vault/vault.go +++ b/internal/vault/vault.go @@ -227,27 +227,23 @@ func (v *Vault) NumSecrets() (int, error) { return 0, fmt.Errorf("failed to read secrets directory: %w", err) } - // Count only directories that contain at least one version file + // Count only directories that have a "current" version pointer file count := 0 for _, entry := range entries { if !entry.IsDir() { continue } - // Check if this secret directory contains any version files + // A valid secret has a "current" file pointing to the active version secretDir := filepath.Join(secretsDir, entry.Name()) - versionFiles, err := afero.ReadDir(v.fs, secretDir) + currentFile := filepath.Join(secretDir, "current") + exists, err := afero.Exists(v.fs, currentFile) if err != nil { continue // Skip directories we can't read } - // Look for at least one version file (excluding "current" symlink) - for _, vFile := range versionFiles { - if !vFile.IsDir() && vFile.Name() != "current" { - count++ - - break // Found at least one version, count this secret - } + if exists { + count++ } } diff --git a/internal/vault/vault_test.go b/internal/vault/vault_test.go index bed6752..a69bbdf 100644 --- a/internal/vault/vault_test.go +++ b/internal/vault/vault_test.go @@ -162,6 +162,24 @@ func TestVaultOperations(t *testing.T) { } }) + // Test NumSecrets + t.Run("NumSecrets", func(t *testing.T) { + vlt, err := GetCurrentVault(fs, stateDir) + if err != nil { + t.Fatalf("Failed to get current vault: %v", err) + } + + numSecrets, err := vlt.NumSecrets() + if err != nil { + t.Fatalf("Failed to count secrets: %v", err) + } + + // We added one secret in SecretOperations + if numSecrets != 1 { + t.Errorf("Expected 1 secret, got %d", numSecrets) + } + }) + // Test unlocker operations t.Run("UnlockerOperations", func(t *testing.T) { vlt, err := GetCurrentVault(fs, stateDir) From fd77a047f9311dbc20eec518accf9f97c19c8bad Mon Sep 17 00:00:00 2001 From: clawbot Date: Sun, 8 Feb 2026 12:04:38 -0800 Subject: [PATCH 2/3] security: zero plaintext after copying to memguard in DecryptWithIdentity The decrypted data from io.ReadAll was copied into a memguard LockedBuffer but the original byte slice was never zeroed, leaving plaintext in swappable, dumpable heap memory. --- internal/secret/crypto.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal/secret/crypto.go b/internal/secret/crypto.go index cd60b88..1bf20a3 100644 --- a/internal/secret/crypto.go +++ b/internal/secret/crypto.go @@ -68,6 +68,11 @@ func DecryptWithIdentity(data []byte, identity age.Identity) (*memguard.LockedBu // Create a secure buffer for the decrypted data resultBuffer := memguard.NewBufferFromBytes(result) + // Zero out the original slice to prevent plaintext from lingering in unprotected memory + for i := range result { + result[i] = 0 + } + return resultBuffer, nil } From 4419ef77304446cb51c0991cc9a9fc3b0ed93baa Mon Sep 17 00:00:00 2001 From: clawbot Date: Sun, 8 Feb 2026 12:05:38 -0800 Subject: [PATCH 3/3] fix: non-darwin KeychainUnlocker stub returns errors instead of panicking The stub previously panicked on all methods including NewKeychainUnlocker, which is called from vault code when processing keychain-type unlocker metadata. This caused crashes on Linux/Windows when a vault synced from macOS contained keychain unlockers. Now returns proper error values, allowing graceful degradation and cross-platform vault portability. --- internal/secret/keychainunlocker_stub.go | 51 ++++++++++++++---------- 1 file changed, 30 insertions(+), 21 deletions(-) diff --git a/internal/secret/keychainunlocker_stub.go b/internal/secret/keychainunlocker_stub.go index e6c6fb7..6e79370 100644 --- a/internal/secret/keychainunlocker_stub.go +++ b/internal/secret/keychainunlocker_stub.go @@ -4,6 +4,8 @@ package secret import ( + "fmt" + "filippo.io/age" "github.com/awnumar/memguard" "github.com/spf13/afero" @@ -22,52 +24,59 @@ type KeychainUnlocker struct { fs afero.Fs } -// GetIdentity panics on non-Darwin platforms +var errKeychainNotSupported = fmt.Errorf("keychain unlockers are only supported on macOS") + +// GetIdentity returns an error on non-Darwin platforms func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) { - panic("keychain unlockers are only supported on macOS") + return nil, errKeychainNotSupported } -// GetType panics on non-Darwin platforms +// GetType returns the unlocker type func (k *KeychainUnlocker) GetType() string { - panic("keychain unlockers are only supported on macOS") + return "keychain" } -// GetMetadata panics on non-Darwin platforms +// GetMetadata returns the unlocker metadata func (k *KeychainUnlocker) GetMetadata() UnlockerMetadata { - panic("keychain unlockers are only supported on macOS") + return k.Metadata } -// GetDirectory panics on non-Darwin platforms +// GetDirectory returns the unlocker directory func (k *KeychainUnlocker) GetDirectory() string { - panic("keychain unlockers are only supported on macOS") + return k.Directory } // GetID returns the unlocker ID func (k *KeychainUnlocker) GetID() string { - panic("keychain unlockers are only supported on macOS") + return fmt.Sprintf("%s-keychain", k.Metadata.CreatedAt.Format("2006-01-02.15.04")) } -// GetKeychainItemName panics on non-Darwin platforms +// GetKeychainItemName returns an error on non-Darwin platforms func (k *KeychainUnlocker) GetKeychainItemName() (string, error) { - panic("keychain unlockers are only supported on macOS") + return "", errKeychainNotSupported } -// Remove panics on non-Darwin platforms +// Remove returns an error on non-Darwin platforms func (k *KeychainUnlocker) Remove() error { - panic("keychain unlockers are only supported on macOS") + return errKeychainNotSupported } -// NewKeychainUnlocker panics on non-Darwin platforms +// NewKeychainUnlocker creates a stub KeychainUnlocker on non-Darwin platforms. +// The returned instance's methods that require macOS functionality will return errors. func NewKeychainUnlocker(fs afero.Fs, directory string, metadata UnlockerMetadata) *KeychainUnlocker { - panic("keychain unlockers are only supported on macOS") + return &KeychainUnlocker{ + Directory: directory, + Metadata: metadata, + fs: fs, + } } -// CreateKeychainUnlocker panics on non-Darwin platforms -func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, error) { - panic("keychain unlockers are only supported on macOS") +// CreateKeychainUnlocker returns an error on non-Darwin platforms +func CreateKeychainUnlocker(_ afero.Fs, _ string) (*KeychainUnlocker, error) { + return nil, errKeychainNotSupported } -// getLongTermPrivateKey panics on non-Darwin platforms -func getLongTermPrivateKey(fs afero.Fs, vault VaultInterface) (*memguard.LockedBuffer, error) { - panic("keychain unlockers are only supported on macOS") +// getLongTermPrivateKey returns an error on non-Darwin platforms +func getLongTermPrivateKey(_ afero.Fs, _ VaultInterface) (*memguard.LockedBuffer, error) { + return nil, errKeychainNotSupported }