From 341428d9cabe178520b719e27d945724cce5985a Mon Sep 17 00:00:00 2001 From: clawbot Date: Sun, 8 Feb 2026 12:04:15 -0800 Subject: [PATCH 1/4] 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/4] 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 991b1a5a0ba69551db94ffd6cae068b8f20e65b6 Mon Sep 17 00:00:00 2001 From: clawbot Date: Sun, 8 Feb 2026 12:05:09 -0800 Subject: [PATCH 3/4] fix: remove redundant longterm.age encryption in Init command CreatePassphraseUnlocker already encrypts and writes the long-term private key to longterm.age. The Init command was doing this a second time, overwriting the file with a functionally equivalent but separately encrypted blob. This was wasteful and a maintenance hazard. --- internal/cli/init.go | 33 ++------------------------------- 1 file changed, 2 insertions(+), 31 deletions(-) diff --git a/internal/cli/init.go b/internal/cli/init.go index 1167f7c..bb733be 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -7,12 +7,10 @@ import ( "path/filepath" "strings" - "filippo.io/age" "git.eeqj.de/sneak/secret/internal/secret" "git.eeqj.de/sneak/secret/internal/vault" "git.eeqj.de/sneak/secret/pkg/agehd" "github.com/awnumar/memguard" - "github.com/spf13/afero" "github.com/spf13/cobra" "github.com/tyler-smith/go-bip39" ) @@ -154,35 +152,8 @@ func (cli *Instance) Init(cmd *cobra.Command) error { return fmt.Errorf("failed to create unlocker: %w", err) } - // Encrypt long-term private key to the unlocker - unlockerDir := passphraseUnlocker.GetDirectory() - - // Read unlocker public key - unlockerPubKeyData, err := afero.ReadFile(cli.fs, filepath.Join(unlockerDir, "pub.age")) - if err != nil { - return fmt.Errorf("failed to read unlocker public key: %w", err) - } - - unlockerRecipient, err := age.ParseX25519Recipient(string(unlockerPubKeyData)) - if err != nil { - return fmt.Errorf("failed to parse unlocker public key: %w", err) - } - - // Encrypt long-term private key to unlocker - // Use memguard to protect the private key in memory - ltPrivKeyBuffer := memguard.NewBufferFromBytes([]byte(ltIdentity.String())) - defer ltPrivKeyBuffer.Destroy() - - encryptedLtPrivKey, err := secret.EncryptToRecipient(ltPrivKeyBuffer, unlockerRecipient) - if err != nil { - return fmt.Errorf("failed to encrypt long-term private key: %w", err) - } - - // Write encrypted long-term private key - ltPrivKeyPath := filepath.Join(unlockerDir, "longterm.age") - if err := afero.WriteFile(cli.fs, ltPrivKeyPath, encryptedLtPrivKey, secret.FilePerms); err != nil { - return fmt.Errorf("failed to write encrypted long-term private key: %w", err) - } + // Note: CreatePassphraseUnlocker already encrypts and writes the long-term + // private key to longterm.age, so no need to do it again here. if cmd != nil { cmd.Printf("\nDefault vault created and configured\n") From 4419ef77304446cb51c0991cc9a9fc3b0ed93baa Mon Sep 17 00:00:00 2001 From: clawbot Date: Sun, 8 Feb 2026 12:05:38 -0800 Subject: [PATCH 4/4] 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 }