1
0
forked from sneak/secret

Compare commits

...

11 Commits
main ... main

Author SHA1 Message Date
6ff00c696a Merge pull request 'Remove redundant longterm.age encryption in Init command (closes #6)' (#11) from clawbot/secret:fix/issue-6 into main
Reviewed-on: sneak/secret#11
2026-02-09 02:39:55 +01:00
c6551e4901 Merge branch 'main' into fix/issue-6 2026-02-09 02:39:41 +01:00
b06d7fa3f4 Merge pull request 'Fix NumSecrets() always returning 0 (closes #4)' (#9) from clawbot/secret:fix/issue-4 into main
Reviewed-on: sneak/secret#9
2026-02-09 02:39:30 +01:00
16d5b237d2 Merge branch 'main' into fix/issue-4 2026-02-09 02:26:20 +01:00
660de5716a Merge pull request 'Non-darwin KeychainUnlocker stub returns errors instead of panicking (closes #7)' (#12) from clawbot/secret:fix/issue-7 into main
Reviewed-on: sneak/secret#12
2026-02-09 02:20:14 +01:00
51fb2805fd Merge branch 'main' into fix/issue-7 2026-02-09 02:19:56 +01:00
6ffb24b544 Merge pull request 'Zero plaintext after copying to memguard in DecryptWithIdentity (closes #5)' (#10) from clawbot/secret:fix/issue-5 into main
Reviewed-on: sneak/secret#10
2026-02-09 02:18:06 +01:00
clawbot
4419ef7730 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.
2026-02-08 12:05:38 -08:00
clawbot
991b1a5a0b 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.
2026-02-08 12:05:09 -08:00
clawbot
fd77a047f9 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.
2026-02-08 12:04:38 -08:00
clawbot
341428d9ca 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.
2026-02-08 12:04:15 -08:00
5 changed files with 61 additions and 62 deletions

View File

@ -7,12 +7,10 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"filippo.io/age"
"git.eeqj.de/sneak/secret/internal/secret" "git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault" "git.eeqj.de/sneak/secret/internal/vault"
"git.eeqj.de/sneak/secret/pkg/agehd" "git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/tyler-smith/go-bip39" "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) return fmt.Errorf("failed to create unlocker: %w", err)
} }
// Encrypt long-term private key to the unlocker // Note: CreatePassphraseUnlocker already encrypts and writes the long-term
unlockerDir := passphraseUnlocker.GetDirectory() // private key to longterm.age, so no need to do it again here.
// 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)
}
if cmd != nil { if cmd != nil {
cmd.Printf("\nDefault vault created and configured\n") cmd.Printf("\nDefault vault created and configured\n")

View File

@ -68,6 +68,11 @@ func DecryptWithIdentity(data []byte, identity age.Identity) (*memguard.LockedBu
// Create a secure buffer for the decrypted data // Create a secure buffer for the decrypted data
resultBuffer := memguard.NewBufferFromBytes(result) 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 return resultBuffer, nil
} }

View File

@ -4,6 +4,8 @@
package secret package secret
import ( import (
"fmt"
"filippo.io/age" "filippo.io/age"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
@ -22,52 +24,59 @@ type KeychainUnlocker struct {
fs afero.Fs 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) { 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 { 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 { 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 { func (k *KeychainUnlocker) GetDirectory() string {
panic("keychain unlockers are only supported on macOS") return k.Directory
} }
// GetID returns the unlocker ID // GetID returns the unlocker ID
func (k *KeychainUnlocker) GetID() string { 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) { 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 { 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 { 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 // CreateKeychainUnlocker returns an error on non-Darwin platforms
func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, error) { func CreateKeychainUnlocker(_ afero.Fs, _ string) (*KeychainUnlocker, error) {
panic("keychain unlockers are only supported on macOS") return nil, errKeychainNotSupported
} }
// getLongTermPrivateKey panics on non-Darwin platforms // getLongTermPrivateKey returns an error on non-Darwin platforms
func getLongTermPrivateKey(fs afero.Fs, vault VaultInterface) (*memguard.LockedBuffer, error) { func getLongTermPrivateKey(_ afero.Fs, _ VaultInterface) (*memguard.LockedBuffer, error) {
panic("keychain unlockers are only supported on macOS") return nil, errKeychainNotSupported
} }

View File

@ -227,27 +227,23 @@ func (v *Vault) NumSecrets() (int, error) {
return 0, fmt.Errorf("failed to read secrets directory: %w", err) 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 count := 0
for _, entry := range entries { for _, entry := range entries {
if !entry.IsDir() { if !entry.IsDir() {
continue 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()) 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 { if err != nil {
continue // Skip directories we can't read continue // Skip directories we can't read
} }
// Look for at least one version file (excluding "current" symlink) if exists {
for _, vFile := range versionFiles {
if !vFile.IsDir() && vFile.Name() != "current" {
count++ count++
break // Found at least one version, count this secret
}
} }
} }

View File

@ -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 // Test unlocker operations
t.Run("UnlockerOperations", func(t *testing.T) { t.Run("UnlockerOperations", func(t *testing.T) {
vlt, err := GetCurrentVault(fs, stateDir) vlt, err := GetCurrentVault(fs, stateDir)