Update golangci-lint to v2.12.2 with canonical config
All checks were successful
check / check (push) Successful in 1m8s
All checks were successful
check / check (push) Successful in 1m8s
- Replace .golangci.yml with the canonical strict config (all linters enabled except the standard disable list; lll 88, funlen 80/50, cyclop 15, dupl 100; test files now linted) - Pin the Dockerfile lint stage to golangci/golangci-lint:v2.12.2 by tag and digest (Debian-based) - Fix all ~1550 findings surfaced by the new config: line wrapping, wsl_v5/nlreturn blank lines, noinlineerr splits, err113 sentinel errors, perfsprint/modernize rewrites, goconst constants, thelper, testifylint, noctx CommandContext, testpackage conversions, t.Parallel() where safe, and complexity/dupl helper extraction - Record the change and follow-up items in TODO.md
This commit is contained in:
@@ -19,6 +19,7 @@ type Instance struct {
|
||||
// NewCLIInstance creates a new CLI instance with the real filesystem
|
||||
func NewCLIInstance() (*Instance, error) {
|
||||
fs := afero.NewOsFs()
|
||||
|
||||
stateDir, err := secret.DetermineStateDir("")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot determine state directory: %w", err)
|
||||
@@ -30,7 +31,8 @@ func NewCLIInstance() (*Instance, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewCLIInstanceWithFs creates a new CLI instance with the given filesystem (for testing)
|
||||
// NewCLIInstanceWithFs creates a new CLI instance with the given
|
||||
// filesystem (for testing)
|
||||
func NewCLIInstanceWithFs(fs afero.Fs) (*Instance, error) {
|
||||
stateDir, err := secret.DetermineStateDir("")
|
||||
if err != nil {
|
||||
@@ -43,7 +45,8 @@ func NewCLIInstanceWithFs(fs afero.Fs) (*Instance, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewCLIInstanceWithStateDir creates a new CLI instance with custom state directory (for testing)
|
||||
// NewCLIInstanceWithStateDir creates a new CLI instance with custom state
|
||||
// directory (for testing)
|
||||
func NewCLIInstanceWithStateDir(fs afero.Fs, stateDir string) *Instance {
|
||||
return &Instance{
|
||||
fs: fs,
|
||||
@@ -67,6 +70,6 @@ func (cli *Instance) GetStateDir() string {
|
||||
}
|
||||
|
||||
// Print outputs to the command's configured output writer
|
||||
func (cli *Instance) Print(a ...interface{}) (n int, err error) {
|
||||
func (cli *Instance) Print(a ...any) (int, error) {
|
||||
return fmt.Fprint(cli.cmd.OutOrStdout(), a...)
|
||||
}
|
||||
|
||||
@@ -1,37 +1,43 @@
|
||||
package cli
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.eeqj.de/sneak/secret/internal/cli"
|
||||
"git.eeqj.de/sneak/secret/internal/secret"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
func TestCLIInstanceStateDir(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Test the CLI instance state directory functionality
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
// Create a test state directory
|
||||
testStateDir := "/test-state-dir"
|
||||
cli := NewCLIInstanceWithStateDir(fs, testStateDir)
|
||||
instance := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
|
||||
|
||||
if cli.GetStateDir() != testStateDir {
|
||||
t.Errorf("Expected state directory %q, got %q", testStateDir, cli.GetStateDir())
|
||||
got := instance.GetStateDir()
|
||||
if got != testStateDir {
|
||||
t.Errorf("Expected state directory %q, got %q", testStateDir, got)
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:paralleltest // reads process environment to determine the state dir
|
||||
func TestCLIInstanceWithFs(t *testing.T) {
|
||||
// Test creating CLI instance with custom filesystem
|
||||
fs := afero.NewMemMapFs()
|
||||
cli, err := NewCLIInstanceWithFs(fs)
|
||||
|
||||
instance, err := cli.NewCLIInstanceWithFs(fs)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to initialize CLI: %v", err)
|
||||
}
|
||||
|
||||
// The state directory should be determined automatically
|
||||
stateDir := cli.GetStateDir()
|
||||
stateDir := instance.GetStateDir()
|
||||
if stateDir == "" {
|
||||
t.Error("Expected non-empty state directory")
|
||||
}
|
||||
@@ -48,6 +54,7 @@ func TestDetermineStateDir(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if stateDir != testEnvDir {
|
||||
t.Errorf("Expected state directory %q from environment, got %q", testEnvDir, stateDir)
|
||||
}
|
||||
@@ -55,12 +62,15 @@ func TestDetermineStateDir(t *testing.T) {
|
||||
// Test with custom config dir
|
||||
_ = os.Unsetenv(secret.EnvStateDir)
|
||||
customConfigDir := "/custom-config"
|
||||
|
||||
stateDir, err = secret.DetermineStateDir(customConfigDir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
expectedDir := filepath.Join(customConfigDir, secret.AppID)
|
||||
if stateDir != expectedDir {
|
||||
t.Errorf("Expected state directory %q with custom config, got %q", expectedDir, stateDir)
|
||||
t.Errorf("Expected state directory %q with custom config, got %q",
|
||||
expectedDir, stateDir)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// errUnsupportedShell is returned for unknown shell completion targets
|
||||
var errUnsupportedShell = errors.New("unsupported shell type")
|
||||
|
||||
func newCompletionCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "completion [bash|zsh|fish|powershell]",
|
||||
@@ -55,7 +59,7 @@ PowerShell:
|
||||
case "powershell":
|
||||
return cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout)
|
||||
default:
|
||||
return fmt.Errorf("unsupported shell type: %s", args[0])
|
||||
return fmt.Errorf("%w: %s", errUnsupportedShell, args[0])
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.eeqj.de/sneak/secret/internal/secret"
|
||||
"git.eeqj.de/sneak/secret/internal/vault"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// getSecretNamesCompletionFunc returns a completion function that provides secret names
|
||||
// getSecretNamesCompletionFunc returns a completion function that provides
|
||||
// secret names
|
||||
func getSecretNamesCompletionFunc(fs afero.Fs, stateDir string) func(
|
||||
cmd *cobra.Command, args []string, toComplete string,
|
||||
) ([]string, cobra.ShellCompDirective) {
|
||||
return func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return func(
|
||||
_ *cobra.Command, _ []string, toComplete string,
|
||||
) ([]string, cobra.ShellCompDirective) {
|
||||
// Get current vault
|
||||
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||
if err != nil {
|
||||
@@ -30,6 +31,7 @@ func getSecretNamesCompletionFunc(fs afero.Fs, stateDir string) func(
|
||||
|
||||
// Filter secrets based on what user has typed
|
||||
var completions []string
|
||||
|
||||
for _, secret := range secrets {
|
||||
if strings.HasPrefix(secret, toComplete) {
|
||||
completions = append(completions, secret)
|
||||
@@ -40,11 +42,14 @@ func getSecretNamesCompletionFunc(fs afero.Fs, stateDir string) func(
|
||||
}
|
||||
}
|
||||
|
||||
// getUnlockerIDsCompletionFunc returns a completion function that provides unlocker IDs
|
||||
// getUnlockerIDsCompletionFunc returns a completion function that provides
|
||||
// unlocker IDs
|
||||
func getUnlockerIDsCompletionFunc(fs afero.Fs, stateDir string) func(
|
||||
cmd *cobra.Command, args []string, toComplete string,
|
||||
) ([]string, cobra.ShellCompDirective) {
|
||||
return func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return func(
|
||||
_ *cobra.Command, _ []string, toComplete string,
|
||||
) ([]string, cobra.ShellCompDirective) {
|
||||
// Get current vault
|
||||
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||
if err != nil {
|
||||
@@ -66,61 +71,13 @@ func getUnlockerIDsCompletionFunc(fs afero.Fs, stateDir string) func(
|
||||
// Collect unlocker IDs
|
||||
var completions []string
|
||||
|
||||
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
||||
|
||||
for _, metadata := range unlockerMetadataList {
|
||||
// Get the actual unlocker ID by creating the unlocker instance
|
||||
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
||||
files, err := afero.ReadDir(fs, unlockersDir)
|
||||
if err != nil {
|
||||
secret.Warn("Could not read unlockers directory during completion", "error", err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
if !file.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
unlockerDir := filepath.Join(unlockersDir, file.Name())
|
||||
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
|
||||
|
||||
// Check if this is the right unlocker by comparing metadata
|
||||
metadataBytes, err := afero.ReadFile(fs, metadataPath)
|
||||
if err != nil {
|
||||
secret.Warn("Could not read unlocker metadata during completion", "path", metadataPath, "error", err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
var diskMetadata secret.UnlockerMetadata
|
||||
if err := json.Unmarshal(metadataBytes, &diskMetadata); err != nil {
|
||||
secret.Warn("Could not parse unlocker metadata during completion", "path", metadataPath, "error", err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// Match by type and creation time
|
||||
if diskMetadata.Type == metadata.Type && diskMetadata.CreatedAt.Equal(metadata.CreatedAt) {
|
||||
// Create the appropriate unlocker instance
|
||||
var unlocker secret.Unlocker
|
||||
switch metadata.Type {
|
||||
case "passphrase":
|
||||
unlocker = secret.NewPassphraseUnlocker(fs, unlockerDir, diskMetadata)
|
||||
case "keychain":
|
||||
unlocker = secret.NewKeychainUnlocker(fs, unlockerDir, diskMetadata)
|
||||
case "pgp":
|
||||
unlocker = secret.NewPGPUnlocker(fs, unlockerDir, diskMetadata)
|
||||
}
|
||||
|
||||
if unlocker != nil {
|
||||
id := unlocker.GetID()
|
||||
if strings.HasPrefix(id, toComplete) {
|
||||
completions = append(completions, id)
|
||||
}
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
id := findUnlockerIDByMetadata(fs, unlockersDir, metadata, false)
|
||||
if id != "" && strings.HasPrefix(id, toComplete) {
|
||||
completions = append(completions, id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,17 +85,21 @@ func getUnlockerIDsCompletionFunc(fs afero.Fs, stateDir string) func(
|
||||
}
|
||||
}
|
||||
|
||||
// getVaultNamesCompletionFunc returns a completion function that provides vault names
|
||||
// getVaultNamesCompletionFunc returns a completion function that provides
|
||||
// vault names
|
||||
func getVaultNamesCompletionFunc(fs afero.Fs, stateDir string) func(
|
||||
cmd *cobra.Command, args []string, toComplete string,
|
||||
) ([]string, cobra.ShellCompDirective) {
|
||||
return func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return func(
|
||||
_ *cobra.Command, _ []string, toComplete string,
|
||||
) ([]string, cobra.ShellCompDirective) {
|
||||
vaults, err := vault.ListVaults(fs, stateDir)
|
||||
if err != nil {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
|
||||
var completions []string
|
||||
|
||||
for _, v := range vaults {
|
||||
if strings.HasPrefix(v, toComplete) {
|
||||
completions = append(completions, v)
|
||||
@@ -149,57 +110,81 @@ func getVaultNamesCompletionFunc(fs afero.Fs, stateDir string) func(
|
||||
}
|
||||
}
|
||||
|
||||
// getVaultSecretCompletionFunc returns a completion function for vault:secret format
|
||||
// It completes vault names with ":" suffix, and after ":" it completes secrets from that vault
|
||||
// completeVaultQualifiedSecrets completes "vault:secret" references once a
|
||||
// colon is present in the input
|
||||
func completeVaultQualifiedSecrets(
|
||||
fs afero.Fs, stateDir, toComplete string,
|
||||
) []string {
|
||||
var completions []string
|
||||
|
||||
// Complete secret names for the specified vault
|
||||
parts := strings.SplitN(toComplete, ":", vaultSecretParts)
|
||||
vaultName := parts[0]
|
||||
secretPrefix := parts[1]
|
||||
|
||||
vlt := vault.NewVault(fs, stateDir, vaultName)
|
||||
|
||||
secrets, err := vlt.ListSecrets()
|
||||
if err == nil {
|
||||
for _, secretName := range secrets {
|
||||
if strings.HasPrefix(secretName, secretPrefix) {
|
||||
completions = append(completions, vaultName+":"+secretName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return completions
|
||||
}
|
||||
|
||||
// completeUnqualifiedVaultSecrets completes vault names (with a ":"
|
||||
// suffix) and secrets from the current vault
|
||||
func completeUnqualifiedVaultSecrets(
|
||||
fs afero.Fs, stateDir, toComplete string,
|
||||
) []string {
|
||||
var completions []string
|
||||
|
||||
// Complete vault names with ":" suffix
|
||||
vaults, err := vault.ListVaults(fs, stateDir)
|
||||
if err == nil {
|
||||
for _, v := range vaults {
|
||||
if strings.HasPrefix(v, toComplete) {
|
||||
completions = append(completions, v+":")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also complete secrets from current vault (for within-vault moves)
|
||||
currentVlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||
if err == nil {
|
||||
secrets, err := currentVlt.ListSecrets()
|
||||
if err == nil {
|
||||
for _, secretName := range secrets {
|
||||
if strings.HasPrefix(secretName, toComplete) {
|
||||
completions = append(completions, secretName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return completions
|
||||
}
|
||||
|
||||
// getVaultSecretCompletionFunc returns a completion function for the
|
||||
// vault:secret format. It completes vault names with ":" suffix, and
|
||||
// after ":" it completes secrets from that vault.
|
||||
func getVaultSecretCompletionFunc(fs afero.Fs, stateDir string) func(
|
||||
cmd *cobra.Command, args []string, toComplete string,
|
||||
) ([]string, cobra.ShellCompDirective) {
|
||||
return func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
var completions []string
|
||||
|
||||
return func(
|
||||
_ *cobra.Command, _ []string, toComplete string,
|
||||
) ([]string, cobra.ShellCompDirective) {
|
||||
// Check if we're completing after a vault: prefix
|
||||
if strings.Contains(toComplete, ":") {
|
||||
// Complete secret names for the specified vault
|
||||
const vaultSecretParts = 2
|
||||
parts := strings.SplitN(toComplete, ":", vaultSecretParts)
|
||||
vaultName := parts[0]
|
||||
secretPrefix := parts[1]
|
||||
|
||||
vlt := vault.NewVault(fs, stateDir, vaultName)
|
||||
secrets, err := vlt.ListSecrets()
|
||||
if err == nil {
|
||||
for _, secretName := range secrets {
|
||||
if strings.HasPrefix(secretName, secretPrefix) {
|
||||
completions = append(completions, vaultName+":"+secretName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return completions, cobra.ShellCompDirectiveNoFileComp
|
||||
return completeVaultQualifiedSecrets(fs, stateDir, toComplete),
|
||||
cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
|
||||
// Complete vault names with ":" suffix
|
||||
vaults, err := vault.ListVaults(fs, stateDir)
|
||||
if err == nil {
|
||||
for _, v := range vaults {
|
||||
if strings.HasPrefix(v, toComplete) {
|
||||
completions = append(completions, v+":")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also complete secrets from current vault (for within-vault moves)
|
||||
if currentVlt, err := vault.GetCurrentVault(fs, stateDir); err == nil {
|
||||
secrets, err := currentVlt.ListSecrets()
|
||||
if err == nil {
|
||||
for _, secretName := range secrets {
|
||||
if strings.HasPrefix(secretName, toComplete) {
|
||||
completions = append(completions, secretName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return completions, cobra.ShellCompDirectiveNoSpace
|
||||
return completeUnqualifiedVaultSecrets(fs, stateDir, toComplete),
|
||||
cobra.ShellCompDirectiveNoSpace
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -12,11 +13,22 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newEncryptCmd() *cobra.Command {
|
||||
// Sentinel errors for encrypt/decrypt operations
|
||||
var (
|
||||
errNotAgeSecretKey = errors.New(
|
||||
"does not contain a valid age secret key")
|
||||
errSecretDoesNotExist = errors.New("does not exist")
|
||||
)
|
||||
|
||||
// newCryptoCmd builds an encrypt/decrypt command with input/output flags
|
||||
func newCryptoCmd(
|
||||
use, short, long string,
|
||||
run func(cli *Instance, secretName, inputFile, outputFile string) error,
|
||||
) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "encrypt <secret-name>",
|
||||
Short: "Encrypt data using an age secret key stored in a secret",
|
||||
Long: `Encrypt data using an age secret key. If the secret doesn't exist, a new age key is generated and stored.`,
|
||||
Use: use,
|
||||
Short: short,
|
||||
Long: long,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
inputFile, _ := cmd.Flags().GetString("input")
|
||||
@@ -26,9 +38,10 @@ func newEncryptCmd() *cobra.Command {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||
}
|
||||
|
||||
cli.cmd = cmd
|
||||
|
||||
return cli.Encrypt(args[0], inputFile, outputFile)
|
||||
return run(cli, args[0], inputFile, outputFile)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -38,30 +51,73 @@ func newEncryptCmd() *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newEncryptCmd() *cobra.Command {
|
||||
return newCryptoCmd(
|
||||
"encrypt <secret-name>",
|
||||
"Encrypt data using an age secret key stored in a secret",
|
||||
"Encrypt data using an age secret key. If the secret doesn't "+
|
||||
"exist, a new age key is generated and stored.",
|
||||
(*Instance).Encrypt,
|
||||
)
|
||||
}
|
||||
|
||||
func newDecryptCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "decrypt <secret-name>",
|
||||
Short: "Decrypt data using an age secret key stored in a secret",
|
||||
Long: `Decrypt data using an age secret key stored in the specified secret.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
inputFile, _ := cmd.Flags().GetString("input")
|
||||
outputFile, _ := cmd.Flags().GetString("output")
|
||||
return newCryptoCmd(
|
||||
"decrypt <secret-name>",
|
||||
"Decrypt data using an age secret key stored in a secret",
|
||||
"Decrypt data using an age secret key stored in the specified secret.",
|
||||
(*Instance).Decrypt,
|
||||
)
|
||||
}
|
||||
|
||||
cli, err := NewCLIInstance()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||
}
|
||||
cli.cmd = cmd
|
||||
// resolveEncryptionKey returns a secure buffer holding the age secret key
|
||||
// for the named secret, generating and storing a new key if the secret
|
||||
// does not exist. The caller must destroy the returned buffer.
|
||||
func (cli *Instance) resolveEncryptionKey(
|
||||
vlt *vault.Vault, secretName string,
|
||||
) (*memguard.LockedBuffer, error) {
|
||||
// Check if secret exists
|
||||
secretObj := secret.NewSecret(vlt, secretName)
|
||||
|
||||
return cli.Decrypt(args[0], inputFile, outputFile)
|
||||
},
|
||||
exists, err := secretObj.Exists()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check if secret exists: %w", err)
|
||||
}
|
||||
|
||||
cmd.Flags().StringP("input", "i", "", "Input file (default: stdin)")
|
||||
cmd.Flags().StringP("output", "o", "", "Output file (default: stdout)")
|
||||
if !exists {
|
||||
// Secret doesn't exist, generate new age key and store it
|
||||
identity, err := age.GenerateX25519Identity()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate age key: %w", err)
|
||||
}
|
||||
|
||||
return cmd
|
||||
// Store the generated key directly in a secure buffer
|
||||
secureBuffer := memguard.NewBufferFromBytes([]byte(identity.String()))
|
||||
|
||||
err = vlt.AddSecret(secretName, secureBuffer, false)
|
||||
if err != nil {
|
||||
secureBuffer.Destroy()
|
||||
|
||||
return nil, fmt.Errorf("failed to store age key: %w", err)
|
||||
}
|
||||
|
||||
return secureBuffer, nil
|
||||
}
|
||||
|
||||
// Secret exists, get the age secret key from it
|
||||
secretBuffer, err := cli.getSecretValue(vlt, secretObj)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get secret value: %w", err)
|
||||
}
|
||||
|
||||
// Validate that it's a valid age secret key
|
||||
if !isValidAgeSecretKey(secretBuffer.String()) {
|
||||
secretBuffer.Destroy()
|
||||
|
||||
return nil, fmt.Errorf("secret '%s' %w", secretName, errNotAgeSecretKey)
|
||||
}
|
||||
|
||||
return secretBuffer, nil
|
||||
}
|
||||
|
||||
// Encrypt encrypts data using an age secret key stored in a secret
|
||||
@@ -72,55 +128,15 @@ func (cli *Instance) Encrypt(secretName, inputFile, outputFile string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
var ageSecretKey string
|
||||
|
||||
// Check if secret exists
|
||||
secretObj := secret.NewSecret(vlt, secretName)
|
||||
exists, err := secretObj.Exists()
|
||||
// Get or create the age secret key for this secret
|
||||
keyBuffer, err := cli.resolveEncryptionKey(vlt, secretName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if secret exists: %w", err)
|
||||
return err
|
||||
}
|
||||
defer keyBuffer.Destroy()
|
||||
|
||||
if !exists { //nolint:nestif // Clear conditional logic for secret generation vs retrieval
|
||||
// Secret doesn't exist, generate new age key and store it
|
||||
identity, err := age.GenerateX25519Identity()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate age key: %w", err)
|
||||
}
|
||||
|
||||
// Store the generated key directly in a secure buffer
|
||||
identityStr := identity.String()
|
||||
secureBuffer := memguard.NewBufferFromBytes([]byte(identityStr))
|
||||
defer secureBuffer.Destroy()
|
||||
|
||||
// Set ageSecretKey for later use (we need it for encryption)
|
||||
ageSecretKey = identityStr
|
||||
|
||||
err = vlt.AddSecret(secretName, secureBuffer, false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to store age key: %w", err)
|
||||
}
|
||||
} else {
|
||||
// Secret exists, get the age secret key from it
|
||||
secretBuffer, err := cli.getSecretValue(vlt, secretObj)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get secret value: %w", err)
|
||||
}
|
||||
defer secretBuffer.Destroy()
|
||||
|
||||
ageSecretKey = secretBuffer.String()
|
||||
|
||||
// Validate that it's a valid age secret key
|
||||
if !isValidAgeSecretKey(ageSecretKey) {
|
||||
return fmt.Errorf("secret '%s' does not contain a valid age secret key", secretName)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the secret key using secure buffer
|
||||
finalSecureBuffer := memguard.NewBufferFromBytes([]byte(ageSecretKey))
|
||||
defer finalSecureBuffer.Destroy()
|
||||
|
||||
identity, err := age.ParseX25519Identity(finalSecureBuffer.String())
|
||||
// Parse the secret key
|
||||
identity, err := age.ParseX25519Identity(keyBuffer.String())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse age secret key: %w", err)
|
||||
}
|
||||
@@ -130,23 +146,27 @@ func (cli *Instance) Encrypt(secretName, inputFile, outputFile string) error {
|
||||
|
||||
// Set up input reader
|
||||
var input io.Reader = os.Stdin
|
||||
|
||||
if inputFile != "" {
|
||||
file, err := cli.fs.Open(inputFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open input file: %w", err)
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
|
||||
input = file
|
||||
}
|
||||
|
||||
// Set up output writer
|
||||
output := cli.cmd.OutOrStdout()
|
||||
|
||||
if outputFile != "" {
|
||||
file, err := cli.fs.Create(outputFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create output file: %w", err)
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
|
||||
output = file
|
||||
}
|
||||
|
||||
@@ -156,11 +176,13 @@ func (cli *Instance) Encrypt(secretName, inputFile, outputFile string) error {
|
||||
return fmt.Errorf("failed to create age encryptor: %w", err)
|
||||
}
|
||||
|
||||
if _, err := io.Copy(encryptor, input); err != nil {
|
||||
_, err = io.Copy(encryptor, input)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt data: %w", err)
|
||||
}
|
||||
|
||||
if err := encryptor.Close(); err != nil {
|
||||
err = encryptor.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to finalize encryption: %w", err)
|
||||
}
|
||||
|
||||
@@ -177,26 +199,18 @@ func (cli *Instance) Decrypt(secretName, inputFile, outputFile string) error {
|
||||
|
||||
// Check if secret exists
|
||||
secretObj := secret.NewSecret(vlt, secretName)
|
||||
|
||||
exists, err := secretObj.Exists()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if secret exists: %w", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return fmt.Errorf("secret '%s' does not exist", secretName)
|
||||
return fmt.Errorf("secret '%s' %w", secretName, errSecretDoesNotExist)
|
||||
}
|
||||
|
||||
// Get the age secret key from the secret
|
||||
var secretBuffer *memguard.LockedBuffer
|
||||
if os.Getenv(secret.EnvMnemonic) != "" {
|
||||
secretBuffer, err = secretObj.GetValue(nil)
|
||||
} else {
|
||||
unlocker, unlockErr := vlt.GetCurrentUnlocker()
|
||||
if unlockErr != nil {
|
||||
return fmt.Errorf("failed to get current unlocker: %w", unlockErr)
|
||||
}
|
||||
secretBuffer, err = secretObj.GetValue(unlocker)
|
||||
}
|
||||
secretBuffer, err := cli.getSecretValue(vlt, secretObj)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get secret value: %w", err)
|
||||
}
|
||||
@@ -204,7 +218,7 @@ func (cli *Instance) Decrypt(secretName, inputFile, outputFile string) error {
|
||||
|
||||
// Validate that it's a valid age secret key
|
||||
if !isValidAgeSecretKey(secretBuffer.String()) {
|
||||
return fmt.Errorf("secret '%s' does not contain a valid age secret key", secretName)
|
||||
return fmt.Errorf("secret '%s' %w", secretName, errNotAgeSecretKey)
|
||||
}
|
||||
|
||||
// Parse the age secret key to get the identity
|
||||
@@ -215,23 +229,27 @@ func (cli *Instance) Decrypt(secretName, inputFile, outputFile string) error {
|
||||
|
||||
// Set up input reader
|
||||
var input io.Reader = os.Stdin
|
||||
|
||||
if inputFile != "" {
|
||||
file, err := cli.fs.Open(inputFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open input file: %w", err)
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
|
||||
input = file
|
||||
}
|
||||
|
||||
// Set up output writer
|
||||
output := cli.cmd.OutOrStdout()
|
||||
|
||||
if outputFile != "" {
|
||||
file, err := cli.fs.Create(outputFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create output file: %w", err)
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
|
||||
output = file
|
||||
}
|
||||
|
||||
@@ -241,22 +259,27 @@ func (cli *Instance) Decrypt(secretName, inputFile, outputFile string) error {
|
||||
return fmt.Errorf("failed to create age decryptor: %w", err)
|
||||
}
|
||||
|
||||
if _, err := io.Copy(output, decryptor); err != nil {
|
||||
_, err = io.Copy(output, decryptor)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decrypt data: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// isValidAgeSecretKey checks if a string is a valid age secret key by attempting to parse it
|
||||
// isValidAgeSecretKey checks if a string is a valid age secret key by
|
||||
// attempting to parse it
|
||||
func isValidAgeSecretKey(key string) bool {
|
||||
_, err := age.ParseX25519Identity(key)
|
||||
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// getSecretValue retrieves the value of a secret using the appropriate unlocker
|
||||
func (cli *Instance) getSecretValue(vlt *vault.Vault, secretObj *secret.Secret) (*memguard.LockedBuffer, error) {
|
||||
// getSecretValue retrieves the value of a secret using the appropriate
|
||||
// unlocker
|
||||
func (cli *Instance) getSecretValue(
|
||||
vlt *vault.Vault, secretObj *secret.Secret,
|
||||
) (*memguard.LockedBuffer, error) {
|
||||
if os.Getenv(secret.EnvMnemonic) != "" {
|
||||
return secretObj.GetValue(nil)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package cli
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"os"
|
||||
@@ -17,6 +18,16 @@ const (
|
||||
mnemonicEntropyBits = 128
|
||||
)
|
||||
|
||||
// Sentinel errors for secret generation
|
||||
var (
|
||||
errLengthTooSmall = errors.New("length must be at least 1")
|
||||
errLengthNotPositive = errors.New("length must be positive")
|
||||
errMnemonicTypeNotSupported = errors.New(
|
||||
"mnemonic type not supported for secret generation, " +
|
||||
"use 'secret generate mnemonic' instead")
|
||||
errUnsupportedSecretType = errors.New("unsupported type")
|
||||
)
|
||||
|
||||
func newGenerateCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "generate",
|
||||
@@ -52,8 +63,9 @@ func newGenerateSecretCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "secret <name>",
|
||||
Short: "Generate a random secret and store it in the vault",
|
||||
Long: `Generate a cryptographically secure random secret and store it in the current vault under the given name.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
Long: `Generate a cryptographically secure random secret and ` +
|
||||
`store it in the current vault under the given name.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
length, _ := cmd.Flags().GetInt("length")
|
||||
secretType, _ := cmd.Flags().GetString("type")
|
||||
@@ -68,8 +80,10 @@ func newGenerateSecretCmd() *cobra.Command {
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().IntP("length", "l", defaultSecretLength, "Length of the generated secret (default 16)")
|
||||
cmd.Flags().StringP("type", "t", "base58", "Type of secret to generate (base58, alnum)")
|
||||
cmd.Flags().IntP("length", "l", defaultSecretLength,
|
||||
"Length of the generated secret (default 16)")
|
||||
cmd.Flags().StringP("type", "t", "base58",
|
||||
"Type of secret to generate (base58, alnum)")
|
||||
cmd.Flags().BoolP("force", "f", false, "Overwrite existing secret")
|
||||
|
||||
return cmd
|
||||
@@ -98,7 +112,8 @@ func (cli *Instance) GenerateMnemonic(cmd *cobra.Command) error {
|
||||
fmt.Fprintln(os.Stderr, " • Write it down on paper and store it safely")
|
||||
fmt.Fprintln(os.Stderr, " • Do not store it digitally or share it with anyone")
|
||||
fmt.Fprintln(os.Stderr, " • You will need this phrase to recover your secrets")
|
||||
fmt.Fprintln(os.Stderr, " • If you lose this phrase, your secrets cannot be recovered")
|
||||
fmt.Fprintln(os.Stderr,
|
||||
" • If you lose this phrase, your secrets cannot be recovered")
|
||||
fmt.Fprintln(os.Stderr, "")
|
||||
fmt.Fprintln(os.Stderr, "Use this mnemonic with:")
|
||||
fmt.Fprintln(os.Stderr, " secret init (to initialize a new secret manager)")
|
||||
@@ -116,11 +131,13 @@ func (cli *Instance) GenerateSecret(
|
||||
force bool,
|
||||
) error {
|
||||
if length < 1 {
|
||||
return fmt.Errorf("length must be at least 1")
|
||||
return errLengthTooSmall
|
||||
}
|
||||
|
||||
var secretValue string
|
||||
var err error
|
||||
var (
|
||||
secretValue string
|
||||
err error
|
||||
)
|
||||
|
||||
switch secretType {
|
||||
case "base58":
|
||||
@@ -128,9 +145,10 @@ func (cli *Instance) GenerateSecret(
|
||||
case "alnum":
|
||||
secretValue, err = generateRandomAlnum(length)
|
||||
case "mnemonic":
|
||||
return fmt.Errorf("mnemonic type not supported for secret generation, use 'secret generate mnemonic' instead")
|
||||
return errMnemonicTypeNotSupported
|
||||
default:
|
||||
return fmt.Errorf("unsupported type: %s (supported: base58, alnum)", secretType)
|
||||
return fmt.Errorf("%w: %s (supported: base58, alnum)",
|
||||
errUnsupportedSecretType, secretType)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
@@ -147,11 +165,13 @@ func (cli *Instance) GenerateSecret(
|
||||
secretBuffer := memguard.NewBufferFromBytes([]byte(secretValue))
|
||||
defer secretBuffer.Destroy()
|
||||
|
||||
if err := vlt.AddSecret(secretName, secretBuffer, force); err != nil {
|
||||
err = vlt.AddSecret(secretName, secretBuffer, force)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.Printf("Generated and stored %d-character %s secret: %s\n", length, secretType, secretName)
|
||||
cmd.Printf("Generated and stored %d-character %s secret: %s\n",
|
||||
length, secretType, secretName)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -170,10 +190,11 @@ func generateRandomAlnum(length int) (string, error) {
|
||||
return generateRandomString(length, alnumChars)
|
||||
}
|
||||
|
||||
// generateRandomString generates a random string of the specified length using the given character set
|
||||
// generateRandomString generates a random string of the specified length
|
||||
// using the given character set
|
||||
func generateRandomString(length int, charset string) (string, error) {
|
||||
if length <= 0 {
|
||||
return "", fmt.Errorf("length must be positive")
|
||||
return "", errLengthNotPositive
|
||||
}
|
||||
|
||||
result := make([]byte, length)
|
||||
@@ -184,6 +205,7 @@ func generateRandomString(length int, charset string) (string, error) {
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to generate random number: %w", err)
|
||||
}
|
||||
|
||||
result[i] = charset[randomIndex.Int64()]
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
)
|
||||
|
||||
// Version info - these are set at build time
|
||||
var ( //nolint:gochecknoglobals // Set at build time
|
||||
var (
|
||||
Version = "dev" //nolint:gochecknoglobals // Set at build time
|
||||
GitCommit = "unknown" //nolint:gochecknoglobals // Set at build time
|
||||
)
|
||||
@@ -35,8 +35,8 @@ type InfoOutput struct {
|
||||
NumVaults int `json:"numVaults"`
|
||||
NumSecrets int `json:"numSecrets"`
|
||||
TotalSize int64 `json:"totalSizeBytes"`
|
||||
OldestSecret time.Time `json:"oldestSecret,omitempty"`
|
||||
LatestSecret time.Time `json:"latestSecret,omitempty"`
|
||||
OldestSecret time.Time `json:"oldestSecret"`
|
||||
LatestSecret time.Time `json:"latestSecret"`
|
||||
}
|
||||
|
||||
// newInfoCmd returns the info command
|
||||
@@ -51,7 +51,8 @@ func newInfoCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "info",
|
||||
Short: "Display system information",
|
||||
Long: "Display information about the secret system including version, vault statistics, and storage usage",
|
||||
Long: "Display information about the secret system including " +
|
||||
"version, vault statistics, and storage usage",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return cli.Info(cmd, jsonOutput)
|
||||
},
|
||||
@@ -81,6 +82,7 @@ func (cli *Instance) Info(cmd *cobra.Command, jsonOutput bool) error {
|
||||
|
||||
// Count vaults
|
||||
vaultsDir := filepath.Join(cli.stateDir, "vaults.d")
|
||||
|
||||
vaultEntries, err := afero.ReadDir(cli.fs, vaultsDir)
|
||||
if err == nil {
|
||||
for _, entry := range vaultEntries {
|
||||
@@ -92,12 +94,15 @@ func (cli *Instance) Info(cmd *cobra.Command, jsonOutput bool) error {
|
||||
|
||||
// Gather statistics from all vaults
|
||||
if info.NumVaults > 0 {
|
||||
totalSecrets, totalSize, oldestTime, latestTime, _ := gatherVaultStats(cli.fs, vaultsDir)
|
||||
totalSecrets, totalSize, oldestTime, latestTime, _ := gatherVaultStats(
|
||||
cli.fs, vaultsDir)
|
||||
info.NumSecrets = totalSecrets
|
||||
info.TotalSize = totalSize
|
||||
|
||||
if !oldestTime.IsZero() {
|
||||
info.OldestSecret = oldestTime
|
||||
}
|
||||
|
||||
if !latestTime.IsZero() {
|
||||
info.LatestSecret = latestTime
|
||||
}
|
||||
@@ -144,19 +149,24 @@ func prettyPrintInfo(w io.Writer, info InfoOutput) error {
|
||||
_, _ = fmt.Fprintln(w, strings.Repeat("─", separatorLength))
|
||||
|
||||
_, _ = fmt.Fprintf(w, "🗂️ Vaults: %s\n", bold.Sprint(info.NumVaults))
|
||||
|
||||
_, _ = fmt.Fprintf(w, "🔑 Secrets: %s\n", bold.Sprint(info.NumSecrets))
|
||||
|
||||
if info.TotalSize >= 0 {
|
||||
//nolint:gosec // TotalSize is always >= 0
|
||||
_, _ = fmt.Fprintf(w, "💾 Total Size: %s\n", bold.Sprint(humanize.Bytes(uint64(info.TotalSize))))
|
||||
_, _ = fmt.Fprintf(w, "💾 Total Size: %s\n",
|
||||
bold.Sprint(humanize.Bytes(uint64(info.TotalSize))))
|
||||
} else {
|
||||
_, _ = fmt.Fprintf(w, "💾 Total Size: %s\n", bold.Sprint("0 B"))
|
||||
}
|
||||
|
||||
if !info.OldestSecret.IsZero() {
|
||||
_, _ = fmt.Fprintf(w, "🕰️ Oldest Secret: %s\n", info.OldestSecret.Format("2006-01-02 15:04:05"))
|
||||
_, _ = fmt.Fprintf(w, "🕰️ Oldest Secret: %s\n",
|
||||
info.OldestSecret.Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
|
||||
if !info.LatestSecret.IsZero() {
|
||||
_, _ = fmt.Fprintf(w, "✨ Latest Secret: %s\n", info.LatestSecret.Format("2006-01-02 15:04:05"))
|
||||
_, _ = fmt.Fprintf(w, "✨ Latest Secret: %s\n",
|
||||
info.LatestSecret.Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(w)
|
||||
|
||||
@@ -8,81 +8,115 @@ import (
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
// gatherVaultStats collects statistics from all vaults
|
||||
// vaultStats accumulates statistics while walking vault directories
|
||||
type vaultStats struct {
|
||||
totalSecrets int
|
||||
totalSize int64
|
||||
oldestTime time.Time
|
||||
latestTime time.Time
|
||||
}
|
||||
|
||||
// addVersion accumulates size and timestamp info for one version directory
|
||||
func (s *vaultStats) addVersion(fs afero.Fs, versionPath string) {
|
||||
// Add size of encrypted data
|
||||
dataPath := filepath.Join(versionPath, "data.age")
|
||||
|
||||
stat, err := fs.Stat(dataPath)
|
||||
if err == nil {
|
||||
s.totalSize += stat.Size()
|
||||
}
|
||||
|
||||
// Add size of metadata
|
||||
metaPath := filepath.Join(versionPath, "metadata.age")
|
||||
|
||||
stat, err = fs.Stat(metaPath)
|
||||
if err == nil {
|
||||
s.totalSize += stat.Size()
|
||||
}
|
||||
|
||||
// Track timestamps
|
||||
stat, err = fs.Stat(versionPath)
|
||||
if err == nil {
|
||||
modTime := stat.ModTime()
|
||||
if s.oldestTime.IsZero() || modTime.Before(s.oldestTime) {
|
||||
s.oldestTime = modTime
|
||||
}
|
||||
|
||||
if s.latestTime.IsZero() || modTime.After(s.latestTime) {
|
||||
s.latestTime = modTime
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// addSecret accumulates stats for one secret directory
|
||||
func (s *vaultStats) addSecret(fs afero.Fs, secretsPath, secretName string) {
|
||||
s.totalSecrets++
|
||||
secretPath := filepath.Join(secretsPath, secretName)
|
||||
|
||||
// Get size and timestamps from all versions
|
||||
versionsPath := filepath.Join(secretPath, "versions")
|
||||
|
||||
versionEntries, err := afero.ReadDir(fs, versionsPath)
|
||||
if err != nil {
|
||||
secret.Warn("Could not read versions directory for secret",
|
||||
"secret", secretName, "error", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
for _, versionEntry := range versionEntries {
|
||||
if !versionEntry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
s.addVersion(fs, filepath.Join(versionsPath, versionEntry.Name()))
|
||||
}
|
||||
}
|
||||
|
||||
// addVault accumulates stats for one vault directory
|
||||
func (s *vaultStats) addVault(fs afero.Fs, vaultsDir, vaultName string) {
|
||||
vaultPath := filepath.Join(vaultsDir, vaultName)
|
||||
secretsPath := filepath.Join(vaultPath, "secrets.d")
|
||||
|
||||
// Count secrets in this vault
|
||||
secretEntries, err := afero.ReadDir(fs, secretsPath)
|
||||
if err != nil {
|
||||
secret.Warn("Could not read secrets directory for vault",
|
||||
"vault", vaultName, "error", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
for _, secretEntry := range secretEntries {
|
||||
if !secretEntry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
s.addSecret(fs, secretsPath, secretEntry.Name())
|
||||
}
|
||||
}
|
||||
|
||||
// gatherVaultStats collects statistics from all vaults, returning the
|
||||
// total secret count, total size, and oldest/latest secret timestamps
|
||||
func gatherVaultStats(
|
||||
fs afero.Fs,
|
||||
vaultsDir string,
|
||||
) (totalSecrets int, totalSize int64, oldestTime, latestTime time.Time, err error) {
|
||||
) (int, int64, time.Time, time.Time, error) {
|
||||
vaultEntries, err := afero.ReadDir(fs, vaultsDir)
|
||||
if err != nil {
|
||||
return 0, 0, time.Time{}, time.Time{}, err
|
||||
}
|
||||
|
||||
var stats vaultStats
|
||||
|
||||
for _, vaultEntry := range vaultEntries {
|
||||
if !vaultEntry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
vaultPath := filepath.Join(vaultsDir, vaultEntry.Name())
|
||||
secretsPath := filepath.Join(vaultPath, "secrets.d")
|
||||
|
||||
// Count secrets in this vault
|
||||
secretEntries, err := afero.ReadDir(fs, secretsPath)
|
||||
if err != nil {
|
||||
secret.Warn("Could not read secrets directory for vault", "vault", vaultEntry.Name(), "error", err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
for _, secretEntry := range secretEntries {
|
||||
if !secretEntry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
totalSecrets++
|
||||
secretPath := filepath.Join(secretsPath, secretEntry.Name())
|
||||
|
||||
// Get size and timestamps from all versions
|
||||
versionsPath := filepath.Join(secretPath, "versions")
|
||||
versionEntries, err := afero.ReadDir(fs, versionsPath)
|
||||
if err != nil {
|
||||
secret.Warn("Could not read versions directory for secret", "secret", secretEntry.Name(), "error", err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
for _, versionEntry := range versionEntries {
|
||||
if !versionEntry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
versionPath := filepath.Join(versionsPath, versionEntry.Name())
|
||||
|
||||
// Add size of encrypted data
|
||||
dataPath := filepath.Join(versionPath, "data.age")
|
||||
if stat, err := fs.Stat(dataPath); err == nil {
|
||||
totalSize += stat.Size()
|
||||
}
|
||||
|
||||
// Add size of metadata
|
||||
metaPath := filepath.Join(versionPath, "metadata.age")
|
||||
if stat, err := fs.Stat(metaPath); err == nil {
|
||||
totalSize += stat.Size()
|
||||
}
|
||||
|
||||
// Track timestamps
|
||||
if stat, err := fs.Stat(versionPath); err == nil {
|
||||
modTime := stat.ModTime()
|
||||
if oldestTime.IsZero() || modTime.Before(oldestTime) {
|
||||
oldestTime = modTime
|
||||
}
|
||||
if latestTime.IsZero() || modTime.After(latestTime) {
|
||||
latestTime = modTime
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
stats.addVault(fs, vaultsDir, vaultEntry.Name())
|
||||
}
|
||||
|
||||
return totalSecrets, totalSize, oldestTime, latestTime, nil
|
||||
return stats.totalSecrets, stats.totalSize,
|
||||
stats.oldestTime, stats.latestTime, nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
@@ -8,6 +9,7 @@ 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"
|
||||
@@ -16,13 +18,17 @@ import (
|
||||
"github.com/tyler-smith/go-bip39"
|
||||
)
|
||||
|
||||
// errPassphraseMismatch is returned when passphrase confirmation fails
|
||||
var errPassphraseMismatch = errors.New("passphrases do not match")
|
||||
|
||||
// NewInitCmd creates the init command
|
||||
func NewInitCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "init",
|
||||
Short: "Initialize the secrets manager",
|
||||
Long: `Create the necessary directory structure for storing secrets and generate encryption keys.`,
|
||||
RunE: RunInit,
|
||||
Long: `Create the necessary directory structure for storing ` +
|
||||
`secrets and generate encryption keys.`,
|
||||
RunE: RunInit,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +42,67 @@ func RunInit(cmd *cobra.Command, _ []string) error {
|
||||
return cli.Init(cmd)
|
||||
}
|
||||
|
||||
// promptMnemonic reads the mnemonic from the environment or interactively.
|
||||
// The returned cleanup function must be deferred by the caller.
|
||||
func promptMnemonic() (string, func(), error) {
|
||||
if envMnemonic := os.Getenv(secret.EnvMnemonic); envMnemonic != "" {
|
||||
secret.Debug("Using mnemonic from environment variable")
|
||||
|
||||
return envMnemonic, func() {}, nil
|
||||
}
|
||||
|
||||
secret.Debug("Prompting user for mnemonic phrase")
|
||||
|
||||
// Read mnemonic securely without echo
|
||||
mnemonicBuffer, err := secret.ReadPassphrase("Enter your BIP39 mnemonic phrase: ")
|
||||
if err != nil {
|
||||
secret.Debug("Failed to read mnemonic from stdin", "error", err)
|
||||
|
||||
return "", nil, fmt.Errorf("failed to read mnemonic: %w", err)
|
||||
}
|
||||
|
||||
fmt.Fprintln(os.Stderr) // Add newline after hidden input
|
||||
|
||||
return mnemonicBuffer.String(), mnemonicBuffer.Destroy, nil
|
||||
}
|
||||
|
||||
// setupDefaultVault creates the default vault and derives its long-term
|
||||
// identity from the mnemonic
|
||||
func (cli *Instance) setupDefaultVault(
|
||||
stateDir, mnemonicStr string,
|
||||
) (*vault.Vault, *age.X25519Identity, error) {
|
||||
// Create the default vault - it will handle key derivation internally
|
||||
secret.Debug("Creating default vault")
|
||||
|
||||
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, "default")
|
||||
if err != nil {
|
||||
secret.Debug("Failed to create default vault", "error", err)
|
||||
|
||||
return nil, nil, fmt.Errorf("failed to create default vault: %w", err)
|
||||
}
|
||||
|
||||
// Get the vault metadata to retrieve the derivation index
|
||||
vaultDir := filepath.Join(stateDir, "vaults.d", "default")
|
||||
|
||||
metadata, err := vault.LoadVaultMetadata(cli.fs, vaultDir)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to load vault metadata", "error", err)
|
||||
|
||||
return nil, nil, fmt.Errorf("failed to load vault metadata: %w", err)
|
||||
}
|
||||
|
||||
// Derive the long-term key using the same index that CreateVault used
|
||||
ltIdentity, err := agehd.DeriveIdentity(mnemonicStr, metadata.DerivationIndex)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to derive long-term key", "error", err)
|
||||
|
||||
return nil, nil, fmt.Errorf(
|
||||
"failed to derive long-term key from mnemonic: %w", err)
|
||||
}
|
||||
|
||||
return vlt, ltIdentity, nil
|
||||
}
|
||||
|
||||
// Init initializes the secret manager
|
||||
func (cli *Instance) Init(cmd *cobra.Command) error {
|
||||
secret.Debug("Starting secret manager initialization")
|
||||
@@ -44,7 +111,8 @@ func (cli *Instance) Init(cmd *cobra.Command) error {
|
||||
stateDir := cli.GetStateDir()
|
||||
secret.DebugWith("Creating state directory", slog.String("path", stateDir))
|
||||
|
||||
if err := cli.fs.MkdirAll(stateDir, secret.DirPerms); err != nil {
|
||||
err := cli.fs.MkdirAll(stateDir, secret.DirPerms)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to create state directory", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to create state directory: %w", err)
|
||||
@@ -55,100 +123,55 @@ func (cli *Instance) Init(cmd *cobra.Command) error {
|
||||
}
|
||||
|
||||
// Prompt for mnemonic
|
||||
var mnemonicStr string
|
||||
|
||||
if envMnemonic := os.Getenv(secret.EnvMnemonic); envMnemonic != "" {
|
||||
secret.Debug("Using mnemonic from environment variable")
|
||||
mnemonicStr = envMnemonic
|
||||
} else {
|
||||
secret.Debug("Prompting user for mnemonic phrase")
|
||||
// Read mnemonic securely without echo
|
||||
mnemonicBuffer, err := secret.ReadPassphrase("Enter your BIP39 mnemonic phrase: ")
|
||||
if err != nil {
|
||||
secret.Debug("Failed to read mnemonic from stdin", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to read mnemonic: %w", err)
|
||||
}
|
||||
defer mnemonicBuffer.Destroy()
|
||||
|
||||
mnemonicStr = mnemonicBuffer.String()
|
||||
fmt.Fprintln(os.Stderr) // Add newline after hidden input
|
||||
mnemonicStr, cleanupMnemonic, err := promptMnemonic()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cleanupMnemonic()
|
||||
|
||||
if mnemonicStr == "" {
|
||||
secret.Debug("Empty mnemonic provided")
|
||||
|
||||
return fmt.Errorf("mnemonic cannot be empty")
|
||||
return errMnemonicEmpty
|
||||
}
|
||||
|
||||
// Validate the mnemonic using BIP39
|
||||
secret.DebugWith("Validating BIP39 mnemonic", slog.Int("word_count", len(strings.Fields(mnemonicStr))))
|
||||
secret.DebugWith("Validating BIP39 mnemonic",
|
||||
slog.Int("word_count", len(strings.Fields(mnemonicStr))))
|
||||
|
||||
if !bip39.IsMnemonicValid(mnemonicStr) {
|
||||
secret.Debug("Invalid BIP39 mnemonic provided")
|
||||
|
||||
return fmt.Errorf("invalid BIP39 mnemonic phrase\nRun 'secret generate mnemonic' to create a valid mnemonic")
|
||||
return fmt.Errorf(
|
||||
"%w\nRun 'secret generate mnemonic' to create a valid mnemonic",
|
||||
errInvalidMnemonicPhrase)
|
||||
}
|
||||
|
||||
// Set mnemonic in environment for CreateVault to use
|
||||
originalMnemonic := os.Getenv(secret.EnvMnemonic)
|
||||
_ = os.Setenv(secret.EnvMnemonic, mnemonicStr)
|
||||
defer func() {
|
||||
if originalMnemonic != "" {
|
||||
_ = os.Setenv(secret.EnvMnemonic, originalMnemonic)
|
||||
} else {
|
||||
_ = os.Unsetenv(secret.EnvMnemonic)
|
||||
}
|
||||
}()
|
||||
restoreMnemonicEnv := setMnemonicEnv(mnemonicStr)
|
||||
defer restoreMnemonicEnv()
|
||||
|
||||
// Create the default vault - it will handle key derivation internally
|
||||
secret.Debug("Creating default vault")
|
||||
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, "default")
|
||||
// Create the default vault and derive its long-term key
|
||||
vlt, ltIdentity, err := cli.setupDefaultVault(stateDir, mnemonicStr)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to create default vault", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to create default vault: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Get the vault metadata to retrieve the derivation index
|
||||
vaultDir := filepath.Join(stateDir, "vaults.d", "default")
|
||||
metadata, err := vault.LoadVaultMetadata(cli.fs, vaultDir)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to load vault metadata", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to load vault metadata: %w", err)
|
||||
}
|
||||
|
||||
// Derive the long-term key using the same index that CreateVault used
|
||||
ltIdentity, err := agehd.DeriveIdentity(mnemonicStr, metadata.DerivationIndex)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to derive long-term key", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
|
||||
}
|
||||
ltPubKey := ltIdentity.Recipient().String()
|
||||
|
||||
// Unlock the vault with the derived long-term key
|
||||
vlt.Unlock(ltIdentity)
|
||||
|
||||
// Prompt for passphrase for unlocker
|
||||
var passphraseBuffer *memguard.LockedBuffer
|
||||
if envPassphrase := os.Getenv(secret.EnvUnlockPassphrase); envPassphrase != "" {
|
||||
secret.Debug("Using unlock passphrase from environment variable")
|
||||
passphraseBuffer = memguard.NewBufferFromBytes([]byte(envPassphrase))
|
||||
} else {
|
||||
secret.Debug("Prompting user for unlock passphrase")
|
||||
// Use secure passphrase input with confirmation
|
||||
passphraseBuffer, err = readSecurePassphrase("Enter passphrase for unlocker: ")
|
||||
if err != nil {
|
||||
secret.Debug("Failed to read unlock passphrase", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to read passphrase: %w", err)
|
||||
}
|
||||
passphraseBuffer, err := resolvePassphrase()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer passphraseBuffer.Destroy()
|
||||
|
||||
// Create passphrase-protected unlocker
|
||||
secret.Debug("Creating passphrase-protected unlocker")
|
||||
|
||||
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to create unlocker", "error", err)
|
||||
@@ -194,7 +217,7 @@ func readSecurePassphrase(prompt string) (*memguard.LockedBuffer, error) {
|
||||
passphraseBuffer1.Destroy()
|
||||
passphraseBuffer2.Destroy()
|
||||
|
||||
return nil, fmt.Errorf("passphrases do not match")
|
||||
return nil, errPassphraseMismatch
|
||||
}
|
||||
|
||||
// Clean up the second buffer, we'll return the first
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,17 +10,21 @@ import (
|
||||
// Entry is the entry point for the secret CLI application
|
||||
func Entry() {
|
||||
cmd := newRootCmd()
|
||||
if err := cmd.Execute(); err != nil {
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func newRootCmd() *cobra.Command {
|
||||
secret.Debug("newRootCmd starting")
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "secret",
|
||||
Short: "A simple secrets manager",
|
||||
Long: `A simple secrets manager to store and retrieve sensitive information securely.`,
|
||||
Long: `A simple secrets manager to store and retrieve sensitive ` +
|
||||
`information securely.`,
|
||||
// Ensure usage is shown after errors
|
||||
SilenceUsage: false,
|
||||
SilenceErrors: false,
|
||||
|
||||
@@ -2,10 +2,12 @@ package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"git.eeqj.de/sneak/secret/internal/secret"
|
||||
@@ -20,12 +22,36 @@ const (
|
||||
vaultSecretSeparator = ":"
|
||||
// vaultSecretParts is the number of parts when splitting vault:secret
|
||||
vaultSecretParts = 2
|
||||
|
||||
// initialBufferSize is the starting size for secret read buffers (4KB)
|
||||
initialBufferSize = 4 * 1024
|
||||
// maxSecretSize is the maximum allowed size of a secret (100MB)
|
||||
maxSecretSize = 100 * 1024 * 1024
|
||||
)
|
||||
|
||||
// Sentinel errors for secret operations
|
||||
var (
|
||||
errSecretTooLarge = errors.New("secret too large: exceeds 100MB limit")
|
||||
errSecretFileTooLarge = errors.New(
|
||||
"secret file too large: exceeds 100MB limit")
|
||||
errSecretNotFound = errors.New("not found")
|
||||
errSecretExistsNoForce = errors.New(
|
||||
"already exists (use --force to overwrite)")
|
||||
errVaultDoesNotExist = errors.New("does not exist")
|
||||
errCrossVaultSourceUnqualified = errors.New(
|
||||
"source must specify vault (e.g., vault:secret) for cross-vault move")
|
||||
)
|
||||
|
||||
// bufferInfo tracks a protected buffer and the number of bytes used in it
|
||||
type bufferInfo struct {
|
||||
buffer *memguard.LockedBuffer
|
||||
used int
|
||||
}
|
||||
|
||||
// ParseVaultSecretRef parses a "vault:secret" or just "secret" reference
|
||||
// Returns (vaultName, secretName, isQualified)
|
||||
// If no vault is specified, returns empty vaultName and isQualified=false
|
||||
func ParseVaultSecretRef(ref string) (vaultName, secretName string, isQualified bool) {
|
||||
func ParseVaultSecretRef(ref string) (string, string, bool) {
|
||||
parts := strings.SplitN(ref, vaultSecretSeparator, vaultSecretParts)
|
||||
if len(parts) == vaultSecretParts {
|
||||
return parts[0], parts[1], true
|
||||
@@ -42,6 +68,7 @@ func newAddCmd() *cobra.Command {
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
secret.Debug("Add command RunE starting", "secret_name", args[0])
|
||||
|
||||
force, _ := cmd.Flags().GetBool("force")
|
||||
secret.Debug("Got force flag", "force", force)
|
||||
|
||||
@@ -49,7 +76,9 @@ func newAddCmd() *cobra.Command {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||
}
|
||||
|
||||
cli.cmd = cmd // Set the command for stdin access
|
||||
|
||||
secret.Debug("Created CLI instance, calling AddSecret")
|
||||
|
||||
return cli.AddSecret(args[0], force)
|
||||
@@ -66,6 +95,7 @@ func newGetCmd() *cobra.Command {
|
||||
if err != nil {
|
||||
log.Fatalf("failed to initialize CLI: %v", err)
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "get <secret-name>",
|
||||
Short: "Retrieve a secret from the vault",
|
||||
@@ -73,6 +103,7 @@ func newGetCmd() *cobra.Command {
|
||||
ValidArgsFunction: getSecretNamesCompletionFunc(cli.fs, cli.stateDir),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
version, _ := cmd.Flags().GetString("version")
|
||||
|
||||
cli, err := NewCLIInstance()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||
@@ -92,8 +123,9 @@ func newListCmd() *cobra.Command {
|
||||
Use: "list [filter]",
|
||||
Aliases: []string{"ls"},
|
||||
Short: "List all secrets in the current vault",
|
||||
Long: `List all secrets in the current vault. Optionally filter by substring match in secret name.`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
Long: `List all secrets in the current vault. Optionally filter ` +
|
||||
`by substring match in secret name.`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
jsonOutput, _ := cmd.Flags().GetBool("json")
|
||||
quietOutput, _ := cmd.Flags().GetBool("quiet")
|
||||
@@ -122,8 +154,9 @@ func newImportCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "import <secret-name>",
|
||||
Short: "Import a secret from a file",
|
||||
Long: `Import a secret from a file and store it in the current vault under the given name.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
Long: `Import a secret from a file and store it in the current ` +
|
||||
`vault under the given name.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
sourceFile, _ := cmd.Flags().GetString("source")
|
||||
force, _ := cmd.Flags().GetBool("force")
|
||||
@@ -149,12 +182,13 @@ func newRemoveCmd() *cobra.Command {
|
||||
if err != nil {
|
||||
log.Fatalf("failed to initialize CLI: %v", err)
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "remove <secret-name>",
|
||||
Aliases: []string{"rm"},
|
||||
Short: "Remove a secret from the vault",
|
||||
Long: `Remove a secret and all its versions from the current vault. This action is permanent and ` +
|
||||
`cannot be undone.`,
|
||||
Long: `Remove a secret and all its versions from the current ` +
|
||||
`vault. This action is permanent and cannot be undone.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
ValidArgsFunction: getSecretNamesCompletionFunc(cli.fs, cli.stateDir),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -175,6 +209,7 @@ func newMoveCmd() *cobra.Command {
|
||||
if err != nil {
|
||||
log.Fatalf("failed to initialize CLI: %v", err)
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "move <source> <destination>",
|
||||
Aliases: []string{"mv", "rename"},
|
||||
@@ -190,13 +225,16 @@ For cross-vault moves:
|
||||
|
||||
Cross-vault moves copy ALL versions of the secret, preserving history.
|
||||
The source secret is deleted after successful copy.`,
|
||||
Args: cobra.ExactArgs(2), //nolint:mnd // Command requires exactly 2 arguments: source and destination
|
||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
Args: cobra.ExactArgs(2), //nolint:mnd // source and destination args
|
||||
ValidArgsFunction: func(
|
||||
cmd *cobra.Command, args []string, toComplete string,
|
||||
) ([]string, cobra.ShellCompDirective) {
|
||||
// Complete vault:secret format
|
||||
return getVaultSecretCompletionFunc(cli.fs, cli.stateDir)(cmd, args, toComplete)
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
force, _ := cmd.Flags().GetBool("force")
|
||||
|
||||
cli, err := NewCLIInstance()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||
@@ -206,16 +244,20 @@ The source secret is deleted after successful copy.`,
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolP("force", "f", false, "Overwrite if destination secret already exists")
|
||||
cmd.Flags().BoolP("force", "f", false,
|
||||
"Overwrite if destination secret already exists")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// updateBufferSize updates the buffer size based on usage pattern
|
||||
func updateBufferSize(currentSize int, sameSize *int) int {
|
||||
const (
|
||||
doubleAfterBuffers = 2
|
||||
growthFactor = 2
|
||||
)
|
||||
|
||||
*sameSize++
|
||||
const doubleAfterBuffers = 2
|
||||
const growthFactor = 2
|
||||
if *sameSize >= doubleAfterBuffers {
|
||||
*sameSize = 0
|
||||
|
||||
@@ -225,40 +267,21 @@ func updateBufferSize(currentSize int, sameSize *int) int {
|
||||
return currentSize
|
||||
}
|
||||
|
||||
// AddSecret adds a secret to the current vault
|
||||
func (cli *Instance) AddSecret(secretName string, force bool) error {
|
||||
secret.Debug("CLI AddSecret starting", "secret_name", secretName, "force", force)
|
||||
|
||||
// Get current vault
|
||||
secret.Debug("Getting current vault")
|
||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
secret.Debug("Got current vault", "vault_name", vlt.GetName())
|
||||
|
||||
// Read secret value directly into protected buffers
|
||||
secret.Debug("Reading secret value from stdin into protected buffers")
|
||||
|
||||
const initialSize = 4 * 1024 // 4KB initial buffer
|
||||
const maxSize = 100 * 1024 * 1024 // 100MB max
|
||||
|
||||
type bufferInfo struct {
|
||||
buffer *memguard.LockedBuffer
|
||||
used int
|
||||
// destroyBuffers destroys every buffer in the list
|
||||
func destroyBuffers(buffers []bufferInfo) {
|
||||
for _, b := range buffers {
|
||||
b.buffer.Destroy()
|
||||
}
|
||||
}
|
||||
|
||||
// readSecretFromReader reads all data from reader into protected buffers,
|
||||
// enforcing the maximum secret size. On failure the accumulated buffers
|
||||
// are destroyed; on success the caller must destroy them.
|
||||
func readSecretFromReader(reader io.Reader) ([]bufferInfo, int, error) {
|
||||
var buffers []bufferInfo
|
||||
defer func() {
|
||||
for _, b := range buffers {
|
||||
b.buffer.Destroy()
|
||||
}
|
||||
}()
|
||||
|
||||
reader := cli.cmd.InOrStdin()
|
||||
totalSize := 0
|
||||
currentBufferSize := initialSize
|
||||
currentBufferSize := initialBufferSize
|
||||
sameSize := 0
|
||||
|
||||
for {
|
||||
@@ -273,8 +296,10 @@ func (cli *Instance) AddSecret(secretName string, force bool) error {
|
||||
buffers = append(buffers, bufferInfo{buffer: buffer, used: n})
|
||||
totalSize += n
|
||||
|
||||
if totalSize > maxSize {
|
||||
return fmt.Errorf("secret too large: exceeds 100MB limit")
|
||||
if totalSize > maxSecretSize {
|
||||
destroyBuffers(buffers)
|
||||
|
||||
return nil, 0, errSecretTooLarge
|
||||
}
|
||||
|
||||
// If we filled the buffer, consider growing for next iteration
|
||||
@@ -283,13 +308,59 @@ func (cli *Instance) AddSecret(secretName string, force bool) error {
|
||||
}
|
||||
}
|
||||
|
||||
if err == io.EOF || err == io.ErrUnexpectedEOF {
|
||||
if err == io.EOF || errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
break
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("failed to read secret value: %w", err)
|
||||
destroyBuffers(buffers)
|
||||
|
||||
return nil, 0, err
|
||||
}
|
||||
}
|
||||
|
||||
return buffers, totalSize, nil
|
||||
}
|
||||
|
||||
// combineBuffers copies the used portions of buffers into a single
|
||||
// protected buffer of totalSize bytes
|
||||
func combineBuffers(buffers []bufferInfo, totalSize int) *memguard.LockedBuffer {
|
||||
valueBuffer := memguard.NewBuffer(totalSize)
|
||||
|
||||
offset := 0
|
||||
for _, b := range buffers {
|
||||
copy(valueBuffer.Bytes()[offset:], b.buffer.Bytes()[:b.used])
|
||||
offset += b.used
|
||||
}
|
||||
|
||||
return valueBuffer
|
||||
}
|
||||
|
||||
// AddSecret adds a secret to the current vault
|
||||
func (cli *Instance) AddSecret(secretName string, force bool) error {
|
||||
secret.Debug("CLI AddSecret starting", "secret_name", secretName, "force", force)
|
||||
|
||||
// Get current vault
|
||||
secret.Debug("Getting current vault")
|
||||
|
||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
secret.Debug("Got current vault", "vault_name", vlt.GetName())
|
||||
|
||||
// Read secret value directly into protected buffers
|
||||
secret.Debug("Reading secret value from stdin into protected buffers")
|
||||
|
||||
buffers, totalSize, err := readSecretFromReader(cli.cmd.InOrStdin())
|
||||
if err != nil {
|
||||
if errors.Is(err, errSecretTooLarge) {
|
||||
return err
|
||||
}
|
||||
|
||||
return fmt.Errorf("failed to read secret value: %w", err)
|
||||
}
|
||||
defer destroyBuffers(buffers)
|
||||
|
||||
// Check for trailing newline in the last buffer
|
||||
if len(buffers) > 0 && totalSize > 0 {
|
||||
lastBuffer := &buffers[len(buffers)-1]
|
||||
@@ -299,21 +370,19 @@ func (cli *Instance) AddSecret(secretName string, force bool) error {
|
||||
}
|
||||
}
|
||||
|
||||
secret.Debug("Read secret value from stdin", "value_length", totalSize, "buffers", len(buffers))
|
||||
secret.Debug("Read secret value from stdin",
|
||||
"value_length", totalSize, "buffers", len(buffers))
|
||||
|
||||
// Combine all buffers into a single protected buffer
|
||||
valueBuffer := memguard.NewBuffer(totalSize)
|
||||
valueBuffer := combineBuffers(buffers, totalSize)
|
||||
defer valueBuffer.Destroy()
|
||||
|
||||
offset := 0
|
||||
for _, b := range buffers {
|
||||
copy(valueBuffer.Bytes()[offset:], b.buffer.Bytes()[:b.used])
|
||||
offset += b.used
|
||||
}
|
||||
|
||||
// Add the secret to the vault
|
||||
secret.Debug("Calling vault.AddSecret", "secret_name", secretName, "value_length", valueBuffer.Size(), "force", force)
|
||||
if err := vlt.AddSecret(secretName, valueBuffer, force); err != nil {
|
||||
secret.Debug("Calling vault.AddSecret", "secret_name", secretName,
|
||||
"value_length", valueBuffer.Size(), "force", force)
|
||||
|
||||
err = vlt.AddSecret(secretName, valueBuffer, force)
|
||||
if err != nil {
|
||||
secret.Debug("vault.AddSecret failed", "error", err)
|
||||
|
||||
return err
|
||||
@@ -330,8 +399,11 @@ func (cli *Instance) GetSecret(cmd *cobra.Command, secretName string) error {
|
||||
}
|
||||
|
||||
// GetSecretWithVersion retrieves and prints a specific version of a secret
|
||||
func (cli *Instance) GetSecretWithVersion(cmd *cobra.Command, secretName string, version string) error {
|
||||
secret.Debug("GetSecretWithVersion called", "secretName", secretName, "version", version)
|
||||
func (cli *Instance) GetSecretWithVersion(
|
||||
cmd *cobra.Command, secretName string, version string,
|
||||
) error {
|
||||
secret.Debug("GetSecretWithVersion called",
|
||||
"secretName", secretName, "version", version)
|
||||
|
||||
// Store the command for output
|
||||
cli.cmd = cmd
|
||||
@@ -351,6 +423,7 @@ func (cli *Instance) GetSecretWithVersion(cmd *cobra.Command, secretName string,
|
||||
} else {
|
||||
value, err = vlt.GetSecretVersion(secretName, version)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
secret.Debug("Failed to get secret", "error", err)
|
||||
|
||||
@@ -361,6 +434,7 @@ func (cli *Instance) GetSecretWithVersion(cmd *cobra.Command, secretName string,
|
||||
|
||||
// Print the secret value to stdout
|
||||
_, _ = cli.Print(string(value))
|
||||
|
||||
secret.Debug("Printed value to stdout")
|
||||
|
||||
// Debug: Log what we're actually printing
|
||||
@@ -375,7 +449,9 @@ func (cli *Instance) GetSecretWithVersion(cmd *cobra.Command, secretName string,
|
||||
}
|
||||
|
||||
// ListSecrets lists all secrets in the current vault
|
||||
func (cli *Instance) ListSecrets(cmd *cobra.Command, jsonOutput bool, quietOutput bool, filter string) error {
|
||||
func (cli *Instance) ListSecrets(
|
||||
cmd *cobra.Command, jsonOutput bool, quietOutput bool, filter string,
|
||||
) error {
|
||||
// Get current vault
|
||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
@@ -390,6 +466,7 @@ func (cli *Instance) ListSecrets(cmd *cobra.Command, jsonOutput bool, quietOutpu
|
||||
|
||||
// Filter secrets if filter is provided
|
||||
var filteredSecrets []string
|
||||
|
||||
if filter != "" {
|
||||
for _, secretName := range secrets {
|
||||
if strings.Contains(secretName, filter) {
|
||||
@@ -400,100 +477,132 @@ func (cli *Instance) ListSecrets(cmd *cobra.Command, jsonOutput bool, quietOutpu
|
||||
filteredSecrets = secrets
|
||||
}
|
||||
|
||||
if jsonOutput { //nolint:nestif // Separate JSON and table output formatting logic
|
||||
// For JSON output, get metadata for each secret
|
||||
secretsWithMetadata := make([]map[string]interface{}, 0, len(filteredSecrets))
|
||||
|
||||
for _, secretName := range filteredSecrets {
|
||||
secretInfo := map[string]interface{}{
|
||||
"name": secretName,
|
||||
}
|
||||
|
||||
// Try to get metadata using GetSecretObject
|
||||
if secretObj, err := vlt.GetSecretObject(secretName); err == nil {
|
||||
metadata := secretObj.GetMetadata()
|
||||
secretInfo["created_at"] = metadata.CreatedAt
|
||||
secretInfo["updated_at"] = metadata.UpdatedAt
|
||||
}
|
||||
|
||||
secretsWithMetadata = append(secretsWithMetadata, secretInfo)
|
||||
}
|
||||
|
||||
output := map[string]interface{}{
|
||||
"secrets": secretsWithMetadata,
|
||||
}
|
||||
if filter != "" {
|
||||
output["filter"] = filter
|
||||
}
|
||||
|
||||
jsonBytes, err := json.MarshalIndent(output, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal JSON: %w", err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), string(jsonBytes))
|
||||
} else if quietOutput {
|
||||
switch {
|
||||
case jsonOutput:
|
||||
return printSecretsJSON(cmd, vlt, filteredSecrets, filter)
|
||||
case quietOutput:
|
||||
// Quiet output - just secret names
|
||||
for _, secretName := range filteredSecrets {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), secretName)
|
||||
}
|
||||
} else {
|
||||
// Pretty table output
|
||||
out := cmd.OutOrStdout()
|
||||
if len(filteredSecrets) == 0 {
|
||||
if filter != "" {
|
||||
_, _ = fmt.Fprintf(out, "No secrets found in vault '%s' matching filter '%s'.\n", vlt.GetName(), filter)
|
||||
} else {
|
||||
_, _ = fmt.Fprintln(out, "No secrets found in current vault.")
|
||||
_, _ = fmt.Fprintln(out, "Run 'secret add <name>' to create one.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get current vault name for display
|
||||
if filter != "" {
|
||||
_, _ = fmt.Fprintf(out, "Secrets in vault '%s' matching '%s':\n\n", vlt.GetName(), filter)
|
||||
} else {
|
||||
_, _ = fmt.Fprintf(out, "Secrets in vault '%s':\n\n", vlt.GetName())
|
||||
}
|
||||
|
||||
// Calculate the maximum name length for proper column alignment
|
||||
maxNameLen := len("NAME") // Start with header length
|
||||
for _, secretName := range filteredSecrets {
|
||||
if len(secretName) > maxNameLen {
|
||||
maxNameLen = len(secretName)
|
||||
}
|
||||
}
|
||||
// Add some padding
|
||||
maxNameLen += 2
|
||||
|
||||
// Print headers with dynamic width
|
||||
nameFormat := fmt.Sprintf("%%-%ds", maxNameLen)
|
||||
_, _ = fmt.Fprintf(out, nameFormat+" %-20s\n", "NAME", "LAST UPDATED")
|
||||
_, _ = fmt.Fprintf(out, nameFormat+" %-20s\n", strings.Repeat("-", len("NAME")), "------------")
|
||||
|
||||
for _, secretName := range filteredSecrets {
|
||||
lastUpdated := "unknown"
|
||||
if secretObj, err := vlt.GetSecretObject(secretName); err == nil {
|
||||
metadata := secretObj.GetMetadata()
|
||||
lastUpdated = metadata.UpdatedAt.Format("2006-01-02 15:04")
|
||||
}
|
||||
_, _ = fmt.Fprintf(out, nameFormat+" %-20s\n", secretName, lastUpdated)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(out, "\nTotal: %d secret(s)", len(filteredSecrets))
|
||||
if filter != "" {
|
||||
_, _ = fmt.Fprintf(out, " (filtered from %d)", len(secrets))
|
||||
}
|
||||
_, _ = fmt.Fprintln(out)
|
||||
return nil
|
||||
default:
|
||||
return printSecretsTable(cmd, vlt, filteredSecrets, filter, len(secrets))
|
||||
}
|
||||
}
|
||||
|
||||
// printSecretsJSON prints the filtered secrets with metadata as JSON
|
||||
func printSecretsJSON(
|
||||
cmd *cobra.Command, vlt *vault.Vault, filteredSecrets []string, filter string,
|
||||
) error {
|
||||
// For JSON output, get metadata for each secret
|
||||
secretsWithMetadata := make([]map[string]any, 0, len(filteredSecrets))
|
||||
|
||||
for _, secretName := range filteredSecrets {
|
||||
secretInfo := map[string]any{
|
||||
"name": secretName,
|
||||
}
|
||||
|
||||
// Try to get metadata using GetSecretObject
|
||||
secretObj, err := vlt.GetSecretObject(secretName)
|
||||
if err == nil {
|
||||
metadata := secretObj.GetMetadata()
|
||||
secretInfo["created_at"] = metadata.CreatedAt
|
||||
secretInfo["updated_at"] = metadata.UpdatedAt
|
||||
}
|
||||
|
||||
secretsWithMetadata = append(secretsWithMetadata, secretInfo)
|
||||
}
|
||||
|
||||
output := map[string]any{
|
||||
"secrets": secretsWithMetadata,
|
||||
}
|
||||
if filter != "" {
|
||||
output["filter"] = filter
|
||||
}
|
||||
|
||||
jsonBytes, err := json.MarshalIndent(output, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal JSON: %w", err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), string(jsonBytes))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// printSecretsTable prints the filtered secrets as a formatted table
|
||||
func printSecretsTable(
|
||||
cmd *cobra.Command, vlt *vault.Vault,
|
||||
filteredSecrets []string, filter string, totalCount int,
|
||||
) error {
|
||||
// Pretty table output
|
||||
out := cmd.OutOrStdout()
|
||||
|
||||
if len(filteredSecrets) == 0 {
|
||||
if filter != "" {
|
||||
_, _ = fmt.Fprintf(out,
|
||||
"No secrets found in vault '%s' matching filter '%s'.\n",
|
||||
vlt.GetName(), filter)
|
||||
} else {
|
||||
_, _ = fmt.Fprintln(out, "No secrets found in current vault.")
|
||||
_, _ = fmt.Fprintln(out, "Run 'secret add <name>' to create one.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get current vault name for display
|
||||
if filter != "" {
|
||||
_, _ = fmt.Fprintf(out, "Secrets in vault '%s' matching '%s':\n\n",
|
||||
vlt.GetName(), filter)
|
||||
} else {
|
||||
_, _ = fmt.Fprintf(out, "Secrets in vault '%s':\n\n", vlt.GetName())
|
||||
}
|
||||
|
||||
// Calculate the maximum name length for proper column alignment
|
||||
maxNameLen := len("NAME") // Start with header length
|
||||
for _, secretName := range filteredSecrets {
|
||||
if len(secretName) > maxNameLen {
|
||||
maxNameLen = len(secretName)
|
||||
}
|
||||
}
|
||||
// Add some padding
|
||||
maxNameLen += 2
|
||||
|
||||
// Print headers with dynamic width
|
||||
nameFormat := fmt.Sprintf("%%-%ds", maxNameLen)
|
||||
_, _ = fmt.Fprintf(out, nameFormat+" %-20s\n", "NAME", "LAST UPDATED")
|
||||
_, _ = fmt.Fprintf(out, nameFormat+" %-20s\n",
|
||||
strings.Repeat("-", len("NAME")), "------------")
|
||||
|
||||
for _, secretName := range filteredSecrets {
|
||||
lastUpdated := "unknown"
|
||||
|
||||
secretObj, err := vlt.GetSecretObject(secretName)
|
||||
if err == nil {
|
||||
metadata := secretObj.GetMetadata()
|
||||
lastUpdated = metadata.UpdatedAt.Format("2006-01-02 15:04")
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(out, nameFormat+" %-20s\n", secretName, lastUpdated)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(out, "\nTotal: %d secret(s)", len(filteredSecrets))
|
||||
if filter != "" {
|
||||
_, _ = fmt.Fprintf(out, " (filtered from %d)", totalCount)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(out)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ImportSecret imports a secret from a file
|
||||
func (cli *Instance) ImportSecret(cmd *cobra.Command, secretName, sourceFile string, force bool) error {
|
||||
func (cli *Instance) ImportSecret(
|
||||
cmd *cobra.Command, secretName, sourceFile string, force bool,
|
||||
) error {
|
||||
// Get current vault
|
||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
@@ -506,75 +615,34 @@ func (cli *Instance) ImportSecret(cmd *cobra.Command, secretName, sourceFile str
|
||||
return fmt.Errorf("failed to open file %s: %w", sourceFile, err)
|
||||
}
|
||||
defer func() {
|
||||
if err := file.Close(); err != nil {
|
||||
secret.Warn("Failed to close file", "error", err)
|
||||
closeErr := file.Close()
|
||||
if closeErr != nil {
|
||||
secret.Warn("Failed to close file", "error", closeErr)
|
||||
}
|
||||
}()
|
||||
|
||||
const initialSize = 4 * 1024 // 4KB initial buffer
|
||||
const maxSize = 100 * 1024 * 1024 // 100MB max
|
||||
buffers, totalSize, err := readSecretFromReader(file)
|
||||
if err != nil {
|
||||
if errors.Is(err, errSecretTooLarge) {
|
||||
return errSecretFileTooLarge
|
||||
}
|
||||
|
||||
type bufferInfo struct {
|
||||
buffer *memguard.LockedBuffer
|
||||
used int
|
||||
}
|
||||
|
||||
var buffers []bufferInfo
|
||||
defer func() {
|
||||
for _, b := range buffers {
|
||||
b.buffer.Destroy()
|
||||
}
|
||||
}()
|
||||
|
||||
totalSize := 0
|
||||
currentBufferSize := initialSize
|
||||
sameSize := 0
|
||||
|
||||
for {
|
||||
// Create a new buffer
|
||||
buffer := memguard.NewBuffer(currentBufferSize)
|
||||
n, err := io.ReadFull(file, buffer.Bytes())
|
||||
|
||||
if n == 0 {
|
||||
// No data read, destroy the unused buffer
|
||||
buffer.Destroy()
|
||||
} else {
|
||||
buffers = append(buffers, bufferInfo{buffer: buffer, used: n})
|
||||
totalSize += n
|
||||
|
||||
if totalSize > maxSize {
|
||||
return fmt.Errorf("secret file too large: exceeds 100MB limit")
|
||||
}
|
||||
|
||||
// If we filled the buffer, consider growing for next iteration
|
||||
if n == currentBufferSize {
|
||||
currentBufferSize = updateBufferSize(currentBufferSize, &sameSize)
|
||||
}
|
||||
}
|
||||
|
||||
if err == io.EOF || err == io.ErrUnexpectedEOF {
|
||||
break
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("failed to read secret from file %s: %w", sourceFile, err)
|
||||
}
|
||||
return fmt.Errorf("failed to read secret from file %s: %w", sourceFile, err)
|
||||
}
|
||||
defer destroyBuffers(buffers)
|
||||
|
||||
// Combine all buffers into a single protected buffer
|
||||
valueBuffer := memguard.NewBuffer(totalSize)
|
||||
valueBuffer := combineBuffers(buffers, totalSize)
|
||||
defer valueBuffer.Destroy()
|
||||
|
||||
offset := 0
|
||||
for _, b := range buffers {
|
||||
copy(valueBuffer.Bytes()[offset:], b.buffer.Bytes()[:b.used])
|
||||
offset += b.used
|
||||
}
|
||||
|
||||
// Store the secret in the vault
|
||||
if err := vlt.AddSecret(secretName, valueBuffer, force); err != nil {
|
||||
err = vlt.AddSecret(secretName, valueBuffer, force)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.Printf("Successfully imported secret '%s' from file '%s'\n", secretName, sourceFile)
|
||||
cmd.Printf("Successfully imported secret '%s' from file '%s'\n",
|
||||
secretName, sourceFile)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -600,29 +668,36 @@ func (cli *Instance) RemoveSecret(cmd *cobra.Command, secretName string, _ bool)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if secret exists: %w", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return fmt.Errorf("secret '%s' not found", secretName)
|
||||
return fmt.Errorf("secret '%s' %w", secretName, errSecretNotFound)
|
||||
}
|
||||
|
||||
// Count versions for information
|
||||
versionsDir := filepath.Join(secretDir, "versions")
|
||||
versionCount := 0
|
||||
if entries, err := afero.ReadDir(cli.fs, versionsDir); err == nil {
|
||||
|
||||
entries, err := afero.ReadDir(cli.fs, versionsDir)
|
||||
if err == nil {
|
||||
versionCount = len(entries)
|
||||
}
|
||||
|
||||
// Remove the secret directory
|
||||
if err := cli.fs.RemoveAll(secretDir); err != nil {
|
||||
err = cli.fs.RemoveAll(secretDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove secret: %w", err)
|
||||
}
|
||||
|
||||
cmd.Printf("Removed secret '%s' (%d version(s) deleted)\n", secretName, versionCount)
|
||||
cmd.Printf("Removed secret '%s' (%d version(s) deleted)\n",
|
||||
secretName, versionCount)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MoveSecret moves or renames a secret (within or across vaults)
|
||||
func (cli *Instance) MoveSecret(cmd *cobra.Command, source, dest string, force bool) error {
|
||||
func (cli *Instance) MoveSecret(
|
||||
cmd *cobra.Command, source, dest string, force bool,
|
||||
) error {
|
||||
// Parse source and destination
|
||||
srcVaultName, srcSecretName, srcQualified := ParseVaultSecretRef(source)
|
||||
destVaultName, destSecretName, destQualified := ParseVaultSecretRef(dest)
|
||||
@@ -634,25 +709,20 @@ func (cli *Instance) MoveSecret(cmd *cobra.Command, source, dest string, force b
|
||||
|
||||
// Cross-vault move requires source to be qualified
|
||||
if !srcQualified {
|
||||
return fmt.Errorf("source must specify vault (e.g., vault:secret) for cross-vault move")
|
||||
return errCrossVaultSourceUnqualified
|
||||
}
|
||||
|
||||
// If destination is not qualified (no colon), check if it's a vault name
|
||||
// Format: "work:secret default" means move to vault "default"
|
||||
// Format: "work:secret default:newname" means move to vault "default" with new name
|
||||
// Format: "work:secret default:newname" means move to vault "default"
|
||||
// with a new name
|
||||
if !destQualified {
|
||||
// Check if dest is actually a vault name
|
||||
vaults, err := vault.ListVaults(cli.fs, cli.stateDir)
|
||||
if err == nil {
|
||||
for _, v := range vaults {
|
||||
if v == dest {
|
||||
// dest is a vault name, use source secret name
|
||||
destVaultName = dest
|
||||
destSecretName = srcSecretName
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
if err == nil && slices.Contains(vaults, dest) {
|
||||
// dest is a vault name, use source secret name
|
||||
destVaultName = dest
|
||||
destSecretName = srcSecretName
|
||||
}
|
||||
|
||||
// If destVaultName is still empty, dest is a secret name in source vault
|
||||
@@ -670,7 +740,8 @@ func (cli *Instance) MoveSecret(cmd *cobra.Command, source, dest string, force b
|
||||
// Same vault? Use simple rename if possible (optimization)
|
||||
if srcVaultName == destVaultName {
|
||||
// Select the vault and do a simple move
|
||||
if err := vault.SelectVault(cli.fs, cli.stateDir, srcVaultName); err != nil {
|
||||
err := vault.SelectVault(cli.fs, cli.stateDir, srcVaultName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to select vault '%s': %w", srcVaultName, err)
|
||||
}
|
||||
|
||||
@@ -678,11 +749,14 @@ func (cli *Instance) MoveSecret(cmd *cobra.Command, source, dest string, force b
|
||||
}
|
||||
|
||||
// Cross-vault move
|
||||
return cli.moveSecretCrossVault(cmd, srcVaultName, srcSecretName, destVaultName, destSecretName, force)
|
||||
return cli.moveSecretCrossVault(
|
||||
cmd, srcVaultName, srcSecretName, destVaultName, destSecretName, force)
|
||||
}
|
||||
|
||||
// moveSecretWithinVault handles rename within the current vault
|
||||
func (cli *Instance) moveSecretWithinVault(cmd *cobra.Command, source, dest string, force bool) error {
|
||||
func (cli *Instance) moveSecretWithinVault(
|
||||
cmd *cobra.Command, source, dest string, force bool,
|
||||
) error {
|
||||
currentVlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -702,7 +776,7 @@ func (cli *Instance) moveSecretWithinVault(cmd *cobra.Command, source, dest stri
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return fmt.Errorf("secret '%s' not found", source)
|
||||
return fmt.Errorf("secret '%s' %w", source, errSecretNotFound)
|
||||
}
|
||||
|
||||
destEncoded := strings.ReplaceAll(dest, "/", "%")
|
||||
@@ -715,15 +789,17 @@ func (cli *Instance) moveSecretWithinVault(cmd *cobra.Command, source, dest stri
|
||||
|
||||
if exists {
|
||||
if !force {
|
||||
return fmt.Errorf("secret '%s' already exists (use --force to overwrite)", dest)
|
||||
return fmt.Errorf("secret '%s' %w", dest, errSecretExistsNoForce)
|
||||
}
|
||||
|
||||
if err := cli.fs.RemoveAll(destDir); err != nil {
|
||||
err = cli.fs.RemoveAll(destDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove existing destination: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := cli.fs.Rename(sourceDir, destDir); err != nil {
|
||||
err = cli.fs.Rename(sourceDir, destDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to move secret: %w", err)
|
||||
}
|
||||
|
||||
@@ -741,8 +817,8 @@ func (cli *Instance) moveSecretCrossVault(
|
||||
) error {
|
||||
// Get source vault
|
||||
srcVault := vault.NewVault(cli.fs, cli.stateDir, srcVaultName)
|
||||
srcVaultDir, err := srcVault.GetDirectory()
|
||||
|
||||
srcVaultDir, err := srcVault.GetDirectory()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get source vault directory: %w", err)
|
||||
}
|
||||
@@ -750,7 +826,7 @@ func (cli *Instance) moveSecretCrossVault(
|
||||
// Verify source vault exists
|
||||
exists, err := afero.DirExists(cli.fs, srcVaultDir)
|
||||
if err != nil || !exists {
|
||||
return fmt.Errorf("source vault '%s' does not exist", srcVaultName)
|
||||
return fmt.Errorf("source vault '%s' %w", srcVaultName, errVaultDoesNotExist)
|
||||
}
|
||||
|
||||
// Verify source secret exists
|
||||
@@ -759,13 +835,14 @@ func (cli *Instance) moveSecretCrossVault(
|
||||
|
||||
exists, err = afero.DirExists(cli.fs, srcSecretDir)
|
||||
if err != nil || !exists {
|
||||
return fmt.Errorf("secret '%s' not found in vault '%s'", srcSecretName, srcVaultName)
|
||||
return fmt.Errorf("secret '%s' %w in vault '%s'",
|
||||
srcSecretName, errSecretNotFound, srcVaultName)
|
||||
}
|
||||
|
||||
// Get destination vault
|
||||
destVault := vault.NewVault(cli.fs, cli.stateDir, destVaultName)
|
||||
destVaultDir, err := destVault.GetDirectory()
|
||||
|
||||
destVaultDir, err := destVault.GetDirectory()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get destination vault directory: %w", err)
|
||||
}
|
||||
@@ -773,7 +850,8 @@ func (cli *Instance) moveSecretCrossVault(
|
||||
// Verify destination vault exists
|
||||
exists, err = afero.DirExists(cli.fs, destVaultDir)
|
||||
if err != nil || !exists {
|
||||
return fmt.Errorf("destination vault '%s' does not exist", destVaultName)
|
||||
return fmt.Errorf("destination vault '%s' %w",
|
||||
destVaultName, errVaultDoesNotExist)
|
||||
}
|
||||
|
||||
// Unlock destination vault (will fail if neither mnemonic nor unlocker available)
|
||||
@@ -787,12 +865,15 @@ func (cli *Instance) moveSecretCrossVault(
|
||||
versionCount := len(versions)
|
||||
|
||||
// Copy all versions
|
||||
if err := destVault.CopySecretAllVersions(srcVault, srcSecretName, destSecretName, force); err != nil {
|
||||
err = destVault.CopySecretAllVersions(
|
||||
srcVault, srcSecretName, destSecretName, force)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete source secret
|
||||
if err := cli.fs.RemoveAll(srcSecretDir); err != nil {
|
||||
err = cli.fs.RemoveAll(srcSecretDir)
|
||||
if err != nil {
|
||||
// Copy succeeded but delete failed - warn but don't fail
|
||||
cmd.Printf("Warning: copied secret but failed to remove source: %v\n", err)
|
||||
cmd.Printf("Moved secret '%s:%s' to '%s:%s' (%d version(s))\n",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
//nolint:testpackage // white-box test of unexported internals
|
||||
package cli
|
||||
|
||||
import (
|
||||
@@ -18,7 +19,144 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// testVaultName is the vault name used by the size tests.
|
||||
const testVaultName = "test-vault"
|
||||
|
||||
// newSizeTestVault creates an in-memory vault unlocked with the test
|
||||
// mnemonic and returns the filesystem and vault.
|
||||
//
|
||||
//nolint:ireturn // afero.Fs is the filesystem abstraction used throughout
|
||||
func newSizeTestVault(t *testing.T) (afero.Fs, *vault.Vault) {
|
||||
t.Helper()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
// Set test mnemonic
|
||||
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
||||
|
||||
// Create vault
|
||||
_, err := vault.CreateVault(fs, testStateDir, testVaultName)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Set current vault
|
||||
currentVaultPath := filepath.Join(testStateDir, "currentvault")
|
||||
vaultPath := filepath.Join(testStateDir, "vaults.d", testVaultName)
|
||||
err = afero.WriteFile(fs, currentVaultPath, []byte(vaultPath), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get vault and set up long-term key
|
||||
vlt, err := vault.GetCurrentVault(fs, testStateDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
ltIdentity, err := agehd.DeriveIdentity(testMnemonic, 0)
|
||||
require.NoError(t, err)
|
||||
vlt.Unlock(ltIdentity)
|
||||
|
||||
return fs, vlt
|
||||
}
|
||||
|
||||
// runAddSecretSizeCase adds a secret of the given size through stdin and
|
||||
// verifies the outcome.
|
||||
func runAddSecretSizeCase(t *testing.T, size int, wantErr bool, errMsg string) {
|
||||
t.Helper()
|
||||
|
||||
fs, vlt := newSizeTestVault(t)
|
||||
|
||||
// Generate test data of specified size
|
||||
testData := make([]byte, size)
|
||||
_, err := rand.Read(testData)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Add newline that will be stripped
|
||||
testDataWithNewline := make([]byte, 0, len(testData)+1)
|
||||
testDataWithNewline = append(testDataWithNewline, testData...)
|
||||
testDataWithNewline = append(testDataWithNewline, '\n')
|
||||
|
||||
// Create command with fake stdin
|
||||
cmd := &cobra.Command{}
|
||||
cmd.SetIn(bytes.NewReader(testDataWithNewline))
|
||||
|
||||
// Create CLI instance
|
||||
cli, err := NewCLIInstance()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to initialize CLI: %v", err)
|
||||
}
|
||||
|
||||
cli.fs = fs
|
||||
cli.stateDir = testStateDir
|
||||
cli.cmd = cmd
|
||||
|
||||
// Test adding the secret
|
||||
secretName := fmt.Sprintf("test-secret-%d", size)
|
||||
err = cli.AddSecret(secretName, false)
|
||||
|
||||
if wantErr {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), errMsg)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the secret was stored correctly
|
||||
retrievedValue, err := vlt.GetSecret(secretName)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testData, retrievedValue,
|
||||
"Retrieved secret should match original (without newline)")
|
||||
}
|
||||
|
||||
// runImportSecretSizeCase imports a secret file of the given size and
|
||||
// verifies the outcome.
|
||||
func runImportSecretSizeCase(t *testing.T, size int, wantErr bool, errMsg string) {
|
||||
t.Helper()
|
||||
|
||||
fs, vlt := newSizeTestVault(t)
|
||||
|
||||
// Generate test data of specified size
|
||||
testData := make([]byte, size)
|
||||
_, err := rand.Read(testData)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Write test data to file
|
||||
testFile := fmt.Sprintf("/test/secret-%d.bin", size)
|
||||
err = afero.WriteFile(fs, testFile, testData, 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create command
|
||||
cmd := &cobra.Command{}
|
||||
|
||||
// Create CLI instance
|
||||
cli, err := NewCLIInstance()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to initialize CLI: %v", err)
|
||||
}
|
||||
|
||||
cli.fs = fs
|
||||
cli.stateDir = testStateDir
|
||||
|
||||
// Test importing the secret
|
||||
secretName := fmt.Sprintf("imported-secret-%d", size)
|
||||
err = cli.ImportSecret(cmd, secretName, testFile, false)
|
||||
|
||||
if wantErr {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), errMsg)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the secret was stored correctly
|
||||
retrievedValue, err := vlt.GetSecret(secretName)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testData, retrievedValue, "Retrieved secret should match original")
|
||||
}
|
||||
|
||||
// TestAddSecretVariousSizes tests adding secrets of various sizes through stdin
|
||||
//
|
||||
//nolint:paralleltest // subtests use t.Setenv via newSizeTestVault
|
||||
func TestAddSecretVariousSizes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -71,76 +209,14 @@ func TestAddSecretVariousSizes(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Set up test environment
|
||||
fs := afero.NewMemMapFs()
|
||||
stateDir := "/test/state"
|
||||
|
||||
// Set test mnemonic
|
||||
t.Setenv(secret.EnvMnemonic, "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about")
|
||||
|
||||
// Create vault
|
||||
vaultName := "test-vault"
|
||||
_, err := vault.CreateVault(fs, stateDir, vaultName)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Set current vault
|
||||
currentVaultPath := filepath.Join(stateDir, "currentvault")
|
||||
vaultPath := filepath.Join(stateDir, "vaults.d", vaultName)
|
||||
err = afero.WriteFile(fs, currentVaultPath, []byte(vaultPath), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get vault and set up long-term key
|
||||
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
ltIdentity, err := agehd.DeriveIdentity("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", 0)
|
||||
require.NoError(t, err)
|
||||
vlt.Unlock(ltIdentity)
|
||||
|
||||
// Generate test data of specified size
|
||||
testData := make([]byte, tt.size)
|
||||
_, err = rand.Read(testData)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Add newline that will be stripped
|
||||
testDataWithNewline := append(testData, '\n')
|
||||
|
||||
// Create fake stdin
|
||||
stdin := bytes.NewReader(testDataWithNewline)
|
||||
|
||||
// Create command with fake stdin
|
||||
cmd := &cobra.Command{}
|
||||
cmd.SetIn(stdin)
|
||||
|
||||
// Create CLI instance
|
||||
cli, err := NewCLIInstance()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to initialize CLI: %v", err)
|
||||
}
|
||||
cli.fs = fs
|
||||
cli.stateDir = stateDir
|
||||
cli.cmd = cmd
|
||||
|
||||
// Test adding the secret
|
||||
secretName := fmt.Sprintf("test-secret-%d", tt.size)
|
||||
err = cli.AddSecret(secretName, false)
|
||||
|
||||
if tt.shouldError {
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.errorMsg)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the secret was stored correctly
|
||||
retrievedValue, err := vlt.GetSecret(secretName)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testData, retrievedValue, "Retrieved secret should match original (without newline)")
|
||||
}
|
||||
runAddSecretSizeCase(t, tt.size, tt.shouldError, tt.errorMsg)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestImportSecretVariousSizes tests importing secrets of various sizes from files
|
||||
//
|
||||
//nolint:paralleltest // subtests use t.Setenv via newSizeTestVault
|
||||
func TestImportSecretVariousSizes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -193,73 +269,14 @@ func TestImportSecretVariousSizes(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Set up test environment
|
||||
fs := afero.NewMemMapFs()
|
||||
stateDir := "/test/state"
|
||||
|
||||
// Set test mnemonic
|
||||
t.Setenv(secret.EnvMnemonic, "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about")
|
||||
|
||||
// Create vault
|
||||
vaultName := "test-vault"
|
||||
_, err := vault.CreateVault(fs, stateDir, vaultName)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Set current vault
|
||||
currentVaultPath := filepath.Join(stateDir, "currentvault")
|
||||
vaultPath := filepath.Join(stateDir, "vaults.d", vaultName)
|
||||
err = afero.WriteFile(fs, currentVaultPath, []byte(vaultPath), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get vault and set up long-term key
|
||||
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
ltIdentity, err := agehd.DeriveIdentity("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", 0)
|
||||
require.NoError(t, err)
|
||||
vlt.Unlock(ltIdentity)
|
||||
|
||||
// Generate test data of specified size
|
||||
testData := make([]byte, tt.size)
|
||||
_, err = rand.Read(testData)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Write test data to file
|
||||
testFile := fmt.Sprintf("/test/secret-%d.bin", tt.size)
|
||||
err = afero.WriteFile(fs, testFile, testData, 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create command
|
||||
cmd := &cobra.Command{}
|
||||
|
||||
// Create CLI instance
|
||||
cli, err := NewCLIInstance()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to initialize CLI: %v", err)
|
||||
}
|
||||
cli.fs = fs
|
||||
cli.stateDir = stateDir
|
||||
|
||||
// Test importing the secret
|
||||
secretName := fmt.Sprintf("imported-secret-%d", tt.size)
|
||||
err = cli.ImportSecret(cmd, secretName, testFile, false)
|
||||
|
||||
if tt.shouldError {
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.errorMsg)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the secret was stored correctly
|
||||
retrievedValue, err := vlt.GetSecret(secretName)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testData, retrievedValue, "Retrieved secret should match original")
|
||||
}
|
||||
runImportSecretSizeCase(t, tt.size, tt.shouldError, tt.errorMsg)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddSecretBufferGrowth tests that our buffer growth strategy works correctly
|
||||
//
|
||||
//nolint:paralleltest // subtests use t.Setenv via newSizeTestVault
|
||||
func TestAddSecretBufferGrowth(t *testing.T) {
|
||||
// Test various sizes that should trigger buffer growth
|
||||
sizes := []int{
|
||||
@@ -283,31 +300,7 @@ func TestAddSecretBufferGrowth(t *testing.T) {
|
||||
|
||||
for _, size := range sizes {
|
||||
t.Run(fmt.Sprintf("size_%d", size), func(t *testing.T) {
|
||||
// Set up test environment
|
||||
fs := afero.NewMemMapFs()
|
||||
stateDir := "/test/state"
|
||||
|
||||
// Set test mnemonic
|
||||
t.Setenv(secret.EnvMnemonic, "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about")
|
||||
|
||||
// Create vault
|
||||
vaultName := "test-vault"
|
||||
_, err := vault.CreateVault(fs, stateDir, vaultName)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Set current vault
|
||||
currentVaultPath := filepath.Join(stateDir, "currentvault")
|
||||
vaultPath := filepath.Join(stateDir, "vaults.d", vaultName)
|
||||
err = afero.WriteFile(fs, currentVaultPath, []byte(vaultPath), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get vault and set up long-term key
|
||||
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
ltIdentity, err := agehd.DeriveIdentity("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", 0)
|
||||
require.NoError(t, err)
|
||||
vlt.Unlock(ltIdentity)
|
||||
fs, vlt := newSizeTestVault(t)
|
||||
|
||||
// Create test data of exactly the specified size
|
||||
// Use a pattern that's easy to verify
|
||||
@@ -316,20 +309,18 @@ func TestAddSecretBufferGrowth(t *testing.T) {
|
||||
testData[i] = byte(i % 256)
|
||||
}
|
||||
|
||||
// Create fake stdin without newline
|
||||
stdin := bytes.NewReader(testData)
|
||||
|
||||
// Create command with fake stdin
|
||||
// Create command with fake stdin (no newline)
|
||||
cmd := &cobra.Command{}
|
||||
cmd.SetIn(stdin)
|
||||
cmd.SetIn(bytes.NewReader(testData))
|
||||
|
||||
// Create CLI instance
|
||||
cli, err := NewCLIInstance()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to initialize CLI: %v", err)
|
||||
}
|
||||
|
||||
cli.fs = fs
|
||||
cli.stateDir = stateDir
|
||||
cli.stateDir = testStateDir
|
||||
cli.cmd = cmd
|
||||
|
||||
// Test adding the secret
|
||||
@@ -340,58 +331,38 @@ func TestAddSecretBufferGrowth(t *testing.T) {
|
||||
// Verify the secret was stored correctly
|
||||
retrievedValue, err := vlt.GetSecret(secretName)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testData, retrievedValue, "Retrieved secret should match original exactly")
|
||||
assert.Equal(t, testData, retrievedValue,
|
||||
"Retrieved secret should match original exactly")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddSecretStreamingBehavior tests that we handle streaming input correctly
|
||||
//
|
||||
//nolint:paralleltest // uses t.Setenv via newSizeTestVault
|
||||
func TestAddSecretStreamingBehavior(t *testing.T) {
|
||||
// Set up test environment
|
||||
fs := afero.NewMemMapFs()
|
||||
stateDir := "/test/state"
|
||||
|
||||
// Set test mnemonic
|
||||
t.Setenv(secret.EnvMnemonic, "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about")
|
||||
|
||||
// Create vault
|
||||
vaultName := "test-vault"
|
||||
_, err := vault.CreateVault(fs, stateDir, vaultName)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Set current vault
|
||||
currentVaultPath := filepath.Join(stateDir, "currentvault")
|
||||
vaultPath := filepath.Join(stateDir, "vaults.d", vaultName)
|
||||
err = afero.WriteFile(fs, currentVaultPath, []byte(vaultPath), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get vault and set up long-term key
|
||||
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
ltIdentity, err := agehd.DeriveIdentity("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", 0)
|
||||
require.NoError(t, err)
|
||||
vlt.Unlock(ltIdentity)
|
||||
fs, vlt := newSizeTestVault(t)
|
||||
|
||||
// Create a custom reader that simulates slow streaming input
|
||||
// This will help verify our buffer handling works correctly with partial reads
|
||||
testData := []byte(strings.Repeat("Hello, World! ", 1000)) // ~14KB
|
||||
slowReader := &slowReader{
|
||||
streamingStdin := &slowReader{
|
||||
data: testData,
|
||||
chunkSize: 1000, // Read 1KB at a time
|
||||
}
|
||||
|
||||
// Create command with slow reader as stdin
|
||||
cmd := &cobra.Command{}
|
||||
cmd.SetIn(slowReader)
|
||||
cmd.SetIn(streamingStdin)
|
||||
|
||||
// Create CLI instance
|
||||
cli, err := NewCLIInstance()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to initialize CLI: %v", err)
|
||||
}
|
||||
|
||||
cli.fs = fs
|
||||
cli.stateDir = stateDir
|
||||
cli.stateDir = testStateDir
|
||||
cli.cmd = cmd
|
||||
|
||||
// Test adding the secret
|
||||
@@ -411,27 +382,22 @@ type slowReader struct {
|
||||
chunkSize int
|
||||
}
|
||||
|
||||
func (r *slowReader) Read(p []byte) (n int, err error) {
|
||||
func (r *slowReader) Read(p []byte) (int, error) {
|
||||
if r.offset >= len(r.data) {
|
||||
return 0, io.EOF
|
||||
}
|
||||
|
||||
// Read at most chunkSize bytes
|
||||
// Read at most chunkSize bytes, bounded by the remaining data and
|
||||
// the destination buffer
|
||||
remaining := len(r.data) - r.offset
|
||||
toRead := r.chunkSize
|
||||
if toRead > remaining {
|
||||
toRead = remaining
|
||||
}
|
||||
if toRead > len(p) {
|
||||
toRead = len(p)
|
||||
}
|
||||
toRead := min(r.chunkSize, remaining, len(p))
|
||||
|
||||
n = copy(p, r.data[r.offset:r.offset+toRead])
|
||||
n := copy(p, r.data[r.offset:r.offset+toRead])
|
||||
r.offset += n
|
||||
|
||||
if r.offset >= len(r.data) {
|
||||
err = io.EOF
|
||||
return n, io.EOF
|
||||
}
|
||||
|
||||
return n, err
|
||||
return n, nil
|
||||
}
|
||||
|
||||
@@ -7,57 +7,64 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.eeqj.de/sneak/secret/internal/secret"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestGetCommandOutputsToStdout tests that 'secret get' outputs the secret value to stdout, not stderr
|
||||
// TestGetCommandOutputsToStdout tests that 'secret get' outputs the secret
|
||||
// value to stdout, not stderr
|
||||
func TestGetCommandOutputsToStdout(t *testing.T) {
|
||||
// Create a temporary directory for our vault
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Set environment variables for the test
|
||||
t.Setenv("SB_SECRET_STATE_DIR", tempDir)
|
||||
t.Setenv(secret.EnvStateDir, tempDir)
|
||||
|
||||
// Find the secret binary path
|
||||
wd, err := filepath.Abs("../..")
|
||||
require.NoError(t, err, "should get working directory")
|
||||
secretPath := filepath.Join(wd, "secret")
|
||||
|
||||
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||
secretPath := filepath.Join(wd, "secret")
|
||||
testPassphrase := "test-passphrase"
|
||||
|
||||
// Initialize vault
|
||||
cmd := exec.Command(secretPath, "init")
|
||||
//nolint:gosec // G204: test executes the freshly built secret binary
|
||||
cmd := exec.CommandContext(t.Context(), secretPath, "init")
|
||||
cmd.Env = []string{
|
||||
"SB_SECRET_STATE_DIR=" + tempDir,
|
||||
"SB_SECRET_MNEMONIC=" + testMnemonic,
|
||||
"SB_UNLOCK_PASSPHRASE=" + testPassphrase,
|
||||
secret.EnvStateDir + "=" + tempDir,
|
||||
secret.EnvMnemonic + "=" + testMnemonic,
|
||||
secret.EnvUnlockPassphrase + "=" + testPassphrase,
|
||||
"PATH=" + "/usr/bin:/bin",
|
||||
}
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
require.NoError(t, err, "init should succeed: %s", string(output))
|
||||
|
||||
// Add a secret
|
||||
cmd = exec.Command(secretPath, "add", "test/secret")
|
||||
//nolint:gosec // G204: test executes the freshly built secret binary
|
||||
cmd = exec.CommandContext(t.Context(), secretPath, "add", "test/secret")
|
||||
cmd.Env = []string{
|
||||
"SB_SECRET_STATE_DIR=" + tempDir,
|
||||
"SB_SECRET_MNEMONIC=" + testMnemonic,
|
||||
secret.EnvStateDir + "=" + tempDir,
|
||||
secret.EnvMnemonic + "=" + testMnemonic,
|
||||
"PATH=" + "/usr/bin:/bin",
|
||||
}
|
||||
cmd.Stdin = strings.NewReader("test-secret-value")
|
||||
|
||||
output, err = cmd.CombinedOutput()
|
||||
require.NoError(t, err, "add should succeed: %s", string(output))
|
||||
|
||||
// Test that 'secret get' outputs to stdout, not stderr
|
||||
cmd = exec.Command(secretPath, "get", "test/secret")
|
||||
//nolint:gosec // G204: test executes the freshly built secret binary
|
||||
cmd = exec.CommandContext(t.Context(), secretPath, "get", "test/secret")
|
||||
cmd.Env = []string{
|
||||
"SB_SECRET_STATE_DIR=" + tempDir,
|
||||
"SB_SECRET_MNEMONIC=" + testMnemonic,
|
||||
secret.EnvStateDir + "=" + tempDir,
|
||||
secret.EnvMnemonic + "=" + testMnemonic,
|
||||
"PATH=" + "/usr/bin:/bin",
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
@@ -65,7 +72,8 @@ func TestGetCommandOutputsToStdout(t *testing.T) {
|
||||
require.NoError(t, err, "get should succeed")
|
||||
|
||||
// The secret value should be in stdout
|
||||
assert.Equal(t, "test-secret-value", strings.TrimSpace(stdout.String()), "secret value should be in stdout")
|
||||
assert.Equal(t, "test-secret-value", strings.TrimSpace(stdout.String()),
|
||||
"secret value should be in stdout")
|
||||
|
||||
// Nothing should be in stderr
|
||||
assert.Empty(t, stderr.String(), "stderr should be empty")
|
||||
|
||||
@@ -9,7 +9,9 @@ import (
|
||||
)
|
||||
|
||||
// ExecuteCommandInProcess executes a CLI command in-process for testing
|
||||
func ExecuteCommandInProcess(args []string, stdin string, env map[string]string) (string, error) {
|
||||
func ExecuteCommandInProcess(
|
||||
args []string, stdin string, env map[string]string,
|
||||
) (string, error) {
|
||||
secret.Debug("ExecuteCommandInProcess called", "args", args)
|
||||
|
||||
// Save current environment
|
||||
@@ -43,11 +45,13 @@ func ExecuteCommandInProcess(args []string, stdin string, env map[string]string)
|
||||
err := rootCmd.Execute()
|
||||
|
||||
output := buf.String()
|
||||
secret.Debug("Command execution completed", "error", err, "outputLength", len(output), "output", output)
|
||||
secret.Debug("Command execution completed",
|
||||
"error", err, "outputLength", len(output), "output", output)
|
||||
|
||||
// Add debug info for troubleshooting
|
||||
if len(output) == 0 && err == nil {
|
||||
secret.Debug("Warning: Command executed successfully but produced no output", "args", args)
|
||||
secret.Debug("Warning: Command executed successfully but produced no output",
|
||||
"args", args)
|
||||
}
|
||||
|
||||
// Restore environment
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
package cli
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.eeqj.de/sneak/secret/internal/cli"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
//nolint:paralleltest // executes the CLI in-process against shared state
|
||||
func TestOutputCapture(t *testing.T) {
|
||||
// Test vault list command which we fixed
|
||||
output, err := ExecuteCommandInProcess([]string{"vault", "list"}, "", nil)
|
||||
output, err := cli.ExecuteCommandInProcess([]string{"vault", "list"}, "", nil)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, output, "Available vaults", "should capture vault list output")
|
||||
t.Logf("vault list output: %q", output)
|
||||
|
||||
// Test help command
|
||||
output, err = ExecuteCommandInProcess([]string{"--help"}, "", nil)
|
||||
output, err = cli.ExecuteCommandInProcess([]string{"--help"}, "", nil)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, output, "help output should not be empty")
|
||||
t.Logf("help output length: %d", len(output))
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -18,6 +21,35 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Unlocker type names and platform identifiers shared across the CLI
|
||||
const (
|
||||
unlockerTypePassphrase = "passphrase"
|
||||
unlockerTypeKeychain = "keychain"
|
||||
unlockerTypePGP = "pgp"
|
||||
unlockerTypeSecureEnclave = "secure-enclave"
|
||||
|
||||
platformDarwin = "darwin"
|
||||
|
||||
cmdUseList = "list"
|
||||
)
|
||||
|
||||
// Sentinel errors for unlocker operations
|
||||
var (
|
||||
errNoGPGSecretKeys = errors.New("no GPG secret keys found")
|
||||
errInvalidUnlockerType = errors.New("invalid unlocker type")
|
||||
errKeyIDOnlyForPGP = errors.New(
|
||||
"--keyid flag is only valid for PGP unlockers")
|
||||
errKeychainMacOSOnly = errors.New(
|
||||
"keychain unlockers are only supported on macOS")
|
||||
errSecureEnclaveMacOSOnly = errors.New(
|
||||
"secure enclave unlockers are only supported on macOS")
|
||||
errGPGKeyAlreadyUnlocker = errors.New(
|
||||
"GPG key is already added as an unlocker")
|
||||
errUnsupportedUnlockerType = errors.New("unsupported unlocker type")
|
||||
errLastUnlocker = errors.New("refusing to remove last unlocker")
|
||||
errUnlockerExists = errors.New("unlocker already exists")
|
||||
)
|
||||
|
||||
// UnlockerInfo represents unlocker information for display
|
||||
type UnlockerInfo struct {
|
||||
ID string `json:"id"`
|
||||
@@ -37,12 +69,14 @@ const (
|
||||
|
||||
// getDefaultGPGKey returns the default GPG key ID if available
|
||||
func getDefaultGPGKey() (string, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
// First try to get the configured default key using gpgconf
|
||||
cmd := exec.Command("gpgconf", "--list-options", "gpg")
|
||||
cmd := exec.CommandContext(ctx, "gpgconf", "--list-options", "gpg")
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err == nil {
|
||||
lines := strings.Split(string(output), "\n")
|
||||
for _, line := range lines {
|
||||
for line := range strings.SplitSeq(string(output), "\n") {
|
||||
fields := strings.Split(line, ":")
|
||||
if len(fields) > 9 && fields[0] == "default-key" && fields[9] != "" {
|
||||
// The default key is in field 10 (index 9)
|
||||
@@ -52,15 +86,15 @@ func getDefaultGPGKey() (string, error) {
|
||||
}
|
||||
|
||||
// If no default key is configured, get the first secret key
|
||||
cmd = exec.Command("gpg", "--list-secret-keys", "--with-colons")
|
||||
cmd = exec.CommandContext(ctx, "gpg", "--list-secret-keys", "--with-colons")
|
||||
|
||||
output, err = cmd.Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to list GPG keys: %w", err)
|
||||
}
|
||||
|
||||
// Parse output to find the first usable secret key
|
||||
lines := strings.Split(string(output), "\n")
|
||||
for _, line := range lines {
|
||||
for line := range strings.SplitSeq(string(output), "\n") {
|
||||
// sec line indicates a secret key
|
||||
if strings.HasPrefix(line, "sec:") {
|
||||
fields := strings.Split(line, ":")
|
||||
@@ -71,7 +105,7 @@ func getDefaultGPGKey() (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no GPG secret keys found")
|
||||
return "", errNoGPGSecretKeys
|
||||
}
|
||||
|
||||
func newUnlockerCmd() *cobra.Command {
|
||||
@@ -91,7 +125,7 @@ func newUnlockerCmd() *cobra.Command {
|
||||
|
||||
func newUnlockerListCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Use: cmdUseList,
|
||||
Aliases: []string{"ls"},
|
||||
Short: "List unlockers in the current vault",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
@@ -101,6 +135,7 @@ func newUnlockerListCmd() *cobra.Command {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||
}
|
||||
|
||||
cli.cmd = cmd
|
||||
|
||||
return cli.UnlockersList(jsonOutput)
|
||||
@@ -112,53 +147,80 @@ func newUnlockerListCmd() *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newUnlockerAddCmd() *cobra.Command {
|
||||
// unlockerAddHelp returns the supported unlocker types list and their
|
||||
// descriptions for the current platform
|
||||
func unlockerAddHelp() (string, string) {
|
||||
// Build the supported types list based on platform
|
||||
supportedTypes := "passphrase, pgp"
|
||||
typeDescriptions := `Available unlocker types:
|
||||
typeDescriptions := "Available unlocker types:\n" +
|
||||
"\n" +
|
||||
" passphrase - Traditional password-based encryption\n" +
|
||||
" Prompts for a passphrase that will be used to " +
|
||||
"encrypt/decrypt the vault's master key.\n" +
|
||||
" The passphrase is never stored in plaintext.\n" +
|
||||
"\n" +
|
||||
" pgp - GNU Privacy Guard (GPG) key-based encryption \n" +
|
||||
" Uses your existing GPG key to encrypt/decrypt " +
|
||||
"the vault's master key.\n" +
|
||||
" Requires gpg to be installed and configured " +
|
||||
"with at least one secret key.\n" +
|
||||
" Use --keyid to specify a particular key, " +
|
||||
"otherwise uses your default GPG key."
|
||||
|
||||
passphrase - Traditional password-based encryption
|
||||
Prompts for a passphrase that will be used to encrypt/decrypt the vault's master key.
|
||||
The passphrase is never stored in plaintext.
|
||||
|
||||
pgp - GNU Privacy Guard (GPG) key-based encryption
|
||||
Uses your existing GPG key to encrypt/decrypt the vault's master key.
|
||||
Requires gpg to be installed and configured with at least one secret key.
|
||||
Use --keyid to specify a particular key, otherwise uses your default GPG key.`
|
||||
|
||||
if runtime.GOOS == "darwin" {
|
||||
if runtime.GOOS == platformDarwin {
|
||||
supportedTypes = "passphrase, keychain, pgp, secure-enclave"
|
||||
typeDescriptions = `Available unlocker types:
|
||||
|
||||
passphrase - Traditional password-based encryption
|
||||
Prompts for a passphrase that will be used to encrypt/decrypt the vault's master key.
|
||||
The passphrase is never stored in plaintext.
|
||||
|
||||
keychain - macOS Keychain integration (macOS only)
|
||||
Stores the vault's master key in the macOS Keychain, protected by your login password.
|
||||
Automatically unlocks when your Keychain is unlocked (e.g., after login).
|
||||
Provides seamless integration with macOS security features like Touch ID.
|
||||
|
||||
pgp - GNU Privacy Guard (GPG) key-based encryption
|
||||
Uses your existing GPG key to encrypt/decrypt the vault's master key.
|
||||
Requires gpg to be installed and configured with at least one secret key.
|
||||
Use --keyid to specify a particular key, otherwise uses your default GPG key.
|
||||
|
||||
secure-enclave - Apple Secure Enclave hardware protection (macOS only)
|
||||
Stores the vault's master key encrypted by a non-exportable P-256 key
|
||||
held in the Secure Enclave. The key never leaves the hardware.
|
||||
Uses ECIES encryption; decryption is performed inside the SE.`
|
||||
typeDescriptions = "Available unlocker types:\n" +
|
||||
"\n" +
|
||||
" passphrase - Traditional password-based encryption\n" +
|
||||
" Prompts for a passphrase that will be " +
|
||||
"used to encrypt/decrypt the vault's master key.\n" +
|
||||
" The passphrase is never stored in " +
|
||||
"plaintext.\n" +
|
||||
"\n" +
|
||||
" keychain - macOS Keychain integration (macOS only)\n" +
|
||||
" Stores the vault's master key in the " +
|
||||
"macOS Keychain, protected by your login password.\n" +
|
||||
" Automatically unlocks when your Keychain " +
|
||||
"is unlocked (e.g., after login).\n" +
|
||||
" Provides seamless integration with macOS " +
|
||||
"security features like Touch ID.\n" +
|
||||
"\n" +
|
||||
" pgp - GNU Privacy Guard (GPG) key-based " +
|
||||
"encryption\n" +
|
||||
" Uses your existing GPG key to " +
|
||||
"encrypt/decrypt the vault's master key.\n" +
|
||||
" Requires gpg to be installed and " +
|
||||
"configured with at least one secret key.\n" +
|
||||
" Use --keyid to specify a particular key, " +
|
||||
"otherwise uses your default GPG key.\n" +
|
||||
"\n" +
|
||||
" secure-enclave - Apple Secure Enclave hardware protection " +
|
||||
"(macOS only)\n" +
|
||||
" Stores the vault's master key encrypted " +
|
||||
"by a non-exportable P-256 key\n" +
|
||||
" held in the Secure Enclave. The key " +
|
||||
"never leaves the hardware.\n" +
|
||||
" Uses ECIES encryption; decryption is " +
|
||||
"performed inside the SE."
|
||||
}
|
||||
|
||||
return supportedTypes, typeDescriptions
|
||||
}
|
||||
|
||||
func newUnlockerAddCmd() *cobra.Command {
|
||||
supportedTypes, typeDescriptions := unlockerAddHelp()
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "add <type>",
|
||||
Short: "Add a new unlocker",
|
||||
Long: fmt.Sprintf(`Add a new unlocker to the current vault.
|
||||
|
||||
%s
|
||||
|
||||
Each vault can have multiple unlockers, allowing different authentication methods
|
||||
to access the same vault. This provides flexibility and backup access options.`, typeDescriptions),
|
||||
Long: "Add a new unlocker to the current vault.\n" +
|
||||
"\n" +
|
||||
typeDescriptions + "\n" +
|
||||
"\n" +
|
||||
"Each vault can have multiple unlockers, allowing different " +
|
||||
"authentication methods\n" +
|
||||
"to access the same vault. This provides flexibility and " +
|
||||
"backup access options.",
|
||||
Args: cobra.ExactArgs(1),
|
||||
ValidArgs: strings.Split(supportedTypes, ", "),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -166,33 +228,28 @@ to access the same vault. This provides flexibility and backup access options.`,
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||
}
|
||||
|
||||
unlockerType := args[0]
|
||||
|
||||
// Validate unlocker type
|
||||
validTypes := strings.Split(supportedTypes, ", ")
|
||||
valid := false
|
||||
for _, t := range validTypes {
|
||||
if unlockerType == t {
|
||||
valid = true
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
if !valid {
|
||||
return fmt.Errorf("invalid unlocker type '%s'\n\nSupported types: %s\n\n"+
|
||||
"Run 'secret unlocker add --help' for detailed descriptions", unlockerType, supportedTypes)
|
||||
if !slices.Contains(validTypes, unlockerType) {
|
||||
return fmt.Errorf("%w '%s'\n\nSupported types: %s\n\n"+
|
||||
"Run 'secret unlocker add --help' for detailed descriptions",
|
||||
errInvalidUnlockerType, unlockerType, supportedTypes)
|
||||
}
|
||||
|
||||
// Check if --keyid was used with non-PGP type
|
||||
if unlockerType != "pgp" && cmd.Flags().Changed("keyid") {
|
||||
return fmt.Errorf("--keyid flag is only valid for PGP unlockers")
|
||||
if unlockerType != unlockerTypePGP && cmd.Flags().Changed("keyid") {
|
||||
return errKeyIDOnlyForPGP
|
||||
}
|
||||
|
||||
return cli.UnlockersAdd(unlockerType, cmd)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().String("keyid", "", "GPG key ID for PGP unlockers (optional, uses default key if not specified)")
|
||||
cmd.Flags().String("keyid", "",
|
||||
"GPG key ID for PGP unlockers (optional, uses default key if not specified)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -202,17 +259,20 @@ func newUnlockerRemoveCmd() *cobra.Command {
|
||||
if err != nil {
|
||||
log.Fatalf("failed to initialize CLI: %v", err)
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "remove <unlocker-id>",
|
||||
Aliases: []string{"rm"},
|
||||
Short: "Remove an unlocker",
|
||||
Long: `Remove an unlocker from the current vault. Cannot remove the last unlocker if the vault has ` +
|
||||
`secrets unless --force is used. Warning: Without unlockers and without your mnemonic, vault data ` +
|
||||
`will be permanently inaccessible.`,
|
||||
Long: `Remove an unlocker from the current vault. Cannot remove ` +
|
||||
`the last unlocker if the vault has secrets unless --force is ` +
|
||||
`used. Warning: Without unlockers and without your mnemonic, ` +
|
||||
`vault data will be permanently inaccessible.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
ValidArgsFunction: getUnlockerIDsCompletionFunc(cli.fs, cli.stateDir),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
force, _ := cmd.Flags().GetBool("force")
|
||||
|
||||
cli, err := NewCLIInstance()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||
@@ -222,7 +282,8 @@ func newUnlockerRemoveCmd() *cobra.Command {
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolP("force", "f", false, "Force removal of last unlocker even if vault has secrets")
|
||||
cmd.Flags().BoolP("force", "f", false,
|
||||
"Force removal of last unlocker even if vault has secrets")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -249,6 +310,88 @@ func newUnlockerSelectCmd() *cobra.Command {
|
||||
}
|
||||
}
|
||||
|
||||
// unlockerIDFromDir constructs an unlocker of the given metadata type
|
||||
// rooted at unlockerDir and returns its ID. Returns "" for unknown types
|
||||
// and, when includeSecureEnclave is false, for secure enclave unlockers.
|
||||
func unlockerIDFromDir(
|
||||
fs afero.Fs, unlockerDir string, metadata secret.UnlockerMetadata,
|
||||
includeSecureEnclave bool,
|
||||
) string {
|
||||
// Create the appropriate unlocker instance
|
||||
var unlocker secret.Unlocker
|
||||
|
||||
switch metadata.Type {
|
||||
case unlockerTypePassphrase:
|
||||
unlocker = secret.NewPassphraseUnlocker(fs, unlockerDir, metadata)
|
||||
case unlockerTypeKeychain:
|
||||
unlocker = secret.NewKeychainUnlocker(fs, unlockerDir, metadata)
|
||||
case unlockerTypePGP:
|
||||
unlocker = secret.NewPGPUnlocker(fs, unlockerDir, metadata)
|
||||
case unlockerTypeSecureEnclave:
|
||||
if includeSecureEnclave {
|
||||
unlocker = secret.NewSecureEnclaveUnlocker(fs, unlockerDir, metadata)
|
||||
}
|
||||
}
|
||||
|
||||
if unlocker == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return unlocker.GetID()
|
||||
}
|
||||
|
||||
// findUnlockerIDByMetadata scans unlockersDir for the directory whose
|
||||
// stored metadata matches the given type and creation time and returns
|
||||
// the matching unlocker's ID. Returns "" if no match is found.
|
||||
func findUnlockerIDByMetadata(
|
||||
fs afero.Fs, unlockersDir string, metadata secret.UnlockerMetadata,
|
||||
includeSecureEnclave bool,
|
||||
) string {
|
||||
files, err := afero.ReadDir(fs, unlockersDir)
|
||||
if err != nil {
|
||||
secret.Warn("Could not read unlockers directory", "error", err)
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
if !file.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
unlockerDir := filepath.Join(unlockersDir, file.Name())
|
||||
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
|
||||
|
||||
// Check if this is the right unlocker by comparing metadata
|
||||
metadataBytes, err := afero.ReadFile(fs, metadataPath)
|
||||
if err != nil {
|
||||
secret.Warn("Could not read unlocker metadata file",
|
||||
"path", metadataPath, "error", err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
var diskMetadata secret.UnlockerMetadata
|
||||
|
||||
err = json.Unmarshal(metadataBytes, &diskMetadata)
|
||||
if err != nil {
|
||||
secret.Warn("Could not parse unlocker metadata file",
|
||||
"path", metadataPath, "error", err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// Match by type and creation time
|
||||
if diskMetadata.Type == metadata.Type &&
|
||||
diskMetadata.CreatedAt.Equal(metadata.CreatedAt) {
|
||||
return unlockerIDFromDir(fs, unlockerDir, diskMetadata,
|
||||
includeSecureEnclave)
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// UnlockersList lists unlockers in the current vault
|
||||
func (cli *Instance) UnlockersList(jsonOutput bool) error {
|
||||
// Get current vault
|
||||
@@ -259,6 +402,7 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
|
||||
|
||||
// Get the current unlocker ID
|
||||
var currentUnlockerID string
|
||||
|
||||
currentUnlocker, err := vlt.GetCurrentUnlocker()
|
||||
if err == nil {
|
||||
currentUnlockerID = currentUnlocker.GetID()
|
||||
@@ -272,74 +416,31 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
|
||||
|
||||
// Load actual unlocker objects to get the proper IDs
|
||||
var unlockers []UnlockerInfo
|
||||
|
||||
for _, metadata := range unlockerMetadataList {
|
||||
// Create unlocker instance to get the proper ID
|
||||
vaultDir, err := vlt.GetDirectory()
|
||||
if err != nil {
|
||||
secret.Warn("Could not get vault directory while listing unlockers", "error", err)
|
||||
secret.Warn("Could not get vault directory while listing unlockers",
|
||||
"error", err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// Find the unlocker directory by type and created time
|
||||
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
||||
files, err := afero.ReadDir(cli.fs, unlockersDir)
|
||||
if err != nil {
|
||||
secret.Warn("Could not read unlockers directory", "error", err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
var unlocker secret.Unlocker
|
||||
for _, file := range files {
|
||||
if !file.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
unlockerDir := filepath.Join(unlockersDir, file.Name())
|
||||
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
|
||||
|
||||
// Check if this is the right unlocker by comparing metadata
|
||||
metadataBytes, err := afero.ReadFile(cli.fs, metadataPath)
|
||||
if err != nil {
|
||||
secret.Warn("Could not read unlocker metadata file", "path", metadataPath, "error", err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
var diskMetadata secret.UnlockerMetadata
|
||||
if err := json.Unmarshal(metadataBytes, &diskMetadata); err != nil {
|
||||
secret.Warn("Could not parse unlocker metadata file", "path", metadataPath, "error", err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// Match by type and creation time
|
||||
if diskMetadata.Type == metadata.Type && diskMetadata.CreatedAt.Equal(metadata.CreatedAt) {
|
||||
// Create the appropriate unlocker instance
|
||||
switch metadata.Type {
|
||||
case "passphrase":
|
||||
unlocker = secret.NewPassphraseUnlocker(cli.fs, unlockerDir, diskMetadata)
|
||||
case "keychain":
|
||||
unlocker = secret.NewKeychainUnlocker(cli.fs, unlockerDir, diskMetadata)
|
||||
case "pgp":
|
||||
unlocker = secret.NewPGPUnlocker(cli.fs, unlockerDir, diskMetadata)
|
||||
case "secure-enclave":
|
||||
unlocker = secret.NewSecureEnclaveUnlocker(cli.fs, unlockerDir, diskMetadata)
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
unlockerID := findUnlockerIDByMetadata(cli.fs, unlockersDir, metadata, true)
|
||||
|
||||
// Get the proper ID using the unlocker's ID() method
|
||||
var properID string
|
||||
if unlocker != nil {
|
||||
properID = unlocker.GetID()
|
||||
if unlockerID != "" {
|
||||
properID = unlockerID
|
||||
} else {
|
||||
// Generate ID as fallback
|
||||
properID = fmt.Sprintf("%s-%s", metadata.CreatedAt.Format("2006-01-02.15.04"), metadata.Type)
|
||||
secret.Warn("Could not create unlocker instance, using fallback ID", "fallback_id", properID, "type", metadata.Type)
|
||||
properID = fmt.Sprintf("%s-%s",
|
||||
metadata.CreatedAt.Format("2006-01-02.15.04"), metadata.Type)
|
||||
secret.Warn("Could not create unlocker instance, using fallback ID",
|
||||
"fallback_id", properID, "type", metadata.Type)
|
||||
}
|
||||
|
||||
unlockerInfo := UnlockerInfo{
|
||||
@@ -360,8 +461,10 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
|
||||
}
|
||||
|
||||
// printUnlockersJSON prints unlockers in JSON format
|
||||
func (cli *Instance) printUnlockersJSON(unlockers []UnlockerInfo, currentUnlockerID string) error {
|
||||
output := map[string]interface{}{
|
||||
func (cli *Instance) printUnlockersJSON(
|
||||
unlockers []UnlockerInfo, currentUnlockerID string,
|
||||
) error {
|
||||
output := map[string]any{
|
||||
"unlockers": unlockers,
|
||||
"currentUnlockerID": currentUnlockerID,
|
||||
}
|
||||
@@ -395,10 +498,12 @@ func (cli *Instance) printUnlockersTable(unlockers []UnlockerInfo) error {
|
||||
if len(unlocker.Flags) > 0 {
|
||||
flags = strings.Join(unlocker.Flags, ",")
|
||||
}
|
||||
|
||||
prefix := " "
|
||||
if unlocker.IsCurrent {
|
||||
prefix = "* "
|
||||
}
|
||||
|
||||
cli.cmd.Printf("%s%-40s %-12s %-20s %s\n",
|
||||
prefix,
|
||||
unlocker.ID,
|
||||
@@ -414,164 +519,186 @@ func (cli *Instance) printUnlockersTable(unlockers []UnlockerInfo) error {
|
||||
|
||||
// UnlockersAdd adds a new unlocker
|
||||
func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error {
|
||||
// Build the supported types list based on platform
|
||||
supportedTypes := "passphrase, pgp"
|
||||
if runtime.GOOS == "darwin" {
|
||||
supportedTypes = "passphrase, keychain, pgp, secure-enclave"
|
||||
}
|
||||
|
||||
switch unlockerType {
|
||||
case "passphrase":
|
||||
// Get current vault
|
||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current vault: %w", err)
|
||||
}
|
||||
|
||||
// For passphrase unlockers, we don't need the vault to be unlocked
|
||||
// The CreatePassphraseUnlocker method will handle getting the long-term key
|
||||
|
||||
// Check if passphrase is set in environment variable
|
||||
var passphraseBuffer *memguard.LockedBuffer
|
||||
if envPassphrase := os.Getenv(secret.EnvUnlockPassphrase); envPassphrase != "" {
|
||||
passphraseBuffer = memguard.NewBufferFromBytes([]byte(envPassphrase))
|
||||
} else {
|
||||
// Use secure passphrase input with confirmation
|
||||
passphraseBuffer, err = readSecurePassphrase("Enter passphrase for unlocker: ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read passphrase: %w", err)
|
||||
}
|
||||
}
|
||||
defer passphraseBuffer.Destroy()
|
||||
|
||||
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.Printf("Created passphrase unlocker: %s\n", passphraseUnlocker.GetID())
|
||||
|
||||
// Auto-select the newly created unlocker
|
||||
if err := vlt.SelectUnlocker(passphraseUnlocker.GetID()); err != nil {
|
||||
cmd.Printf("Warning: Failed to auto-select new unlocker: %v\n", err)
|
||||
} else {
|
||||
cmd.Printf("Automatically selected as current unlocker\n")
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
case "keychain":
|
||||
if runtime.GOOS != "darwin" {
|
||||
return fmt.Errorf("keychain unlockers are only supported on macOS")
|
||||
}
|
||||
|
||||
keychainUnlocker, err := secret.CreateKeychainUnlocker(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create macOS Keychain unlocker: %w", err)
|
||||
}
|
||||
|
||||
cmd.Printf("Created macOS Keychain unlocker: %s\n", keychainUnlocker.GetID())
|
||||
if keyName, err := keychainUnlocker.GetKeychainItemName(); err == nil {
|
||||
cmd.Printf("Keychain Item Name: %s\n", keyName)
|
||||
}
|
||||
|
||||
// Auto-select the newly created unlocker
|
||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current vault: %w", err)
|
||||
}
|
||||
if err := vlt.SelectUnlocker(keychainUnlocker.GetID()); err != nil {
|
||||
cmd.Printf("Warning: Failed to auto-select new unlocker: %v\n", err)
|
||||
} else {
|
||||
cmd.Printf("Automatically selected as current unlocker\n")
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
case "secure-enclave":
|
||||
if runtime.GOOS != "darwin" {
|
||||
return fmt.Errorf("secure enclave unlockers are only supported on macOS")
|
||||
}
|
||||
|
||||
seUnlocker, err := secret.CreateSecureEnclaveUnlocker(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create Secure Enclave unlocker: %w", err)
|
||||
}
|
||||
|
||||
cmd.Printf("Created Secure Enclave unlocker: %s\n", seUnlocker.GetID())
|
||||
|
||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current vault: %w", err)
|
||||
}
|
||||
|
||||
if err := vlt.SelectUnlocker(seUnlocker.GetID()); err != nil {
|
||||
cmd.Printf("Warning: Failed to auto-select new unlocker: %v\n", err)
|
||||
} else {
|
||||
cmd.Printf("Automatically selected as current unlocker\n")
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
case "pgp":
|
||||
// Get GPG key ID from flag, environment, or default key
|
||||
var gpgKeyID string
|
||||
if flagKeyID, _ := cmd.Flags().GetString("keyid"); flagKeyID != "" {
|
||||
gpgKeyID = flagKeyID
|
||||
} else if envKeyID := os.Getenv(secret.EnvGPGKeyID); envKeyID != "" {
|
||||
gpgKeyID = envKeyID
|
||||
} else {
|
||||
// Try to get the default GPG key
|
||||
defaultKeyID, err := getDefaultGPGKey()
|
||||
if err != nil {
|
||||
return fmt.Errorf("no GPG key specified and no default key found: %w", err)
|
||||
}
|
||||
gpgKeyID = defaultKeyID
|
||||
cmd.Printf("Using default GPG key: %s\n", gpgKeyID)
|
||||
}
|
||||
|
||||
// Check if this key is already added as an unlocker
|
||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current vault: %w", err)
|
||||
}
|
||||
|
||||
// Resolve the GPG key ID to its fingerprint
|
||||
fingerprint, err := secret.ResolveGPGKeyFingerprint(gpgKeyID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve GPG key fingerprint: %w", err)
|
||||
}
|
||||
|
||||
// Check if this GPG key is already added
|
||||
expectedID := fmt.Sprintf("pgp-%s", fingerprint)
|
||||
if err := cli.checkUnlockerExists(vlt, expectedID); err != nil {
|
||||
return fmt.Errorf("GPG key %s is already added as an unlocker", gpgKeyID)
|
||||
}
|
||||
|
||||
pgpUnlocker, err := secret.CreatePGPUnlocker(cli.fs, cli.stateDir, gpgKeyID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.Printf("Created PGP unlocker: %s\n", pgpUnlocker.GetID())
|
||||
cmd.Printf("GPG Key ID: %s\n", gpgKeyID)
|
||||
|
||||
// Auto-select the newly created unlocker
|
||||
if err := vlt.SelectUnlocker(pgpUnlocker.GetID()); err != nil {
|
||||
cmd.Printf("Warning: Failed to auto-select new unlocker: %v\n", err)
|
||||
} else {
|
||||
cmd.Printf("Automatically selected as current unlocker\n")
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
case unlockerTypePassphrase:
|
||||
return cli.addPassphraseUnlocker(cmd)
|
||||
case unlockerTypeKeychain:
|
||||
return cli.addKeychainUnlocker(cmd)
|
||||
case unlockerTypeSecureEnclave:
|
||||
return cli.addSecureEnclaveUnlocker(cmd)
|
||||
case unlockerTypePGP:
|
||||
return cli.addPGPUnlocker(cmd)
|
||||
default:
|
||||
return fmt.Errorf("unsupported unlocker type: %s (supported: %s)", unlockerType, supportedTypes)
|
||||
// Build the supported types list based on platform
|
||||
supportedTypes := "passphrase, pgp"
|
||||
if runtime.GOOS == platformDarwin {
|
||||
supportedTypes = "passphrase, keychain, pgp, secure-enclave"
|
||||
}
|
||||
|
||||
return fmt.Errorf("%w: %s (supported: %s)",
|
||||
errUnsupportedUnlockerType, unlockerType, supportedTypes)
|
||||
}
|
||||
}
|
||||
|
||||
// autoSelectUnlocker selects the newly created unlocker as current,
|
||||
// printing a warning if selection fails
|
||||
func autoSelectUnlocker(cmd *cobra.Command, vlt *vault.Vault, unlockerID string) {
|
||||
err := vlt.SelectUnlocker(unlockerID)
|
||||
if err != nil {
|
||||
cmd.Printf("Warning: Failed to auto-select new unlocker: %v\n", err)
|
||||
} else {
|
||||
cmd.Printf("Automatically selected as current unlocker\n")
|
||||
}
|
||||
}
|
||||
|
||||
// addPassphraseUnlocker creates a passphrase unlocker in the current vault
|
||||
func (cli *Instance) addPassphraseUnlocker(cmd *cobra.Command) error {
|
||||
// Get current vault
|
||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current vault: %w", err)
|
||||
}
|
||||
|
||||
// For passphrase unlockers, we don't need the vault to be unlocked
|
||||
// The CreatePassphraseUnlocker method will handle getting the
|
||||
// long-term key
|
||||
|
||||
// Check if passphrase is set in environment variable
|
||||
var passphraseBuffer *memguard.LockedBuffer
|
||||
if envPassphrase := os.Getenv(secret.EnvUnlockPassphrase); envPassphrase != "" {
|
||||
passphraseBuffer = memguard.NewBufferFromBytes([]byte(envPassphrase))
|
||||
} else {
|
||||
// Use secure passphrase input with confirmation
|
||||
passphraseBuffer, err = readSecurePassphrase("Enter passphrase for unlocker: ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read passphrase: %w", err)
|
||||
}
|
||||
}
|
||||
defer passphraseBuffer.Destroy()
|
||||
|
||||
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.Printf("Created passphrase unlocker: %s\n", passphraseUnlocker.GetID())
|
||||
|
||||
// Auto-select the newly created unlocker
|
||||
autoSelectUnlocker(cmd, vlt, passphraseUnlocker.GetID())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// addKeychainUnlocker creates a macOS Keychain unlocker in the current vault
|
||||
func (cli *Instance) addKeychainUnlocker(cmd *cobra.Command) error {
|
||||
if runtime.GOOS != platformDarwin {
|
||||
return errKeychainMacOSOnly
|
||||
}
|
||||
|
||||
keychainUnlocker, err := secret.CreateKeychainUnlocker(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create macOS Keychain unlocker: %w", err)
|
||||
}
|
||||
|
||||
cmd.Printf("Created macOS Keychain unlocker: %s\n", keychainUnlocker.GetID())
|
||||
|
||||
keyName, err := keychainUnlocker.GetKeychainItemName()
|
||||
if err == nil {
|
||||
cmd.Printf("Keychain Item Name: %s\n", keyName)
|
||||
}
|
||||
|
||||
// Auto-select the newly created unlocker
|
||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current vault: %w", err)
|
||||
}
|
||||
|
||||
autoSelectUnlocker(cmd, vlt, keychainUnlocker.GetID())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// addSecureEnclaveUnlocker creates a Secure Enclave unlocker in the
|
||||
// current vault
|
||||
func (cli *Instance) addSecureEnclaveUnlocker(cmd *cobra.Command) error {
|
||||
if runtime.GOOS != platformDarwin {
|
||||
return errSecureEnclaveMacOSOnly
|
||||
}
|
||||
|
||||
seUnlocker, err := secret.CreateSecureEnclaveUnlocker(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create Secure Enclave unlocker: %w", err)
|
||||
}
|
||||
|
||||
cmd.Printf("Created Secure Enclave unlocker: %s\n", seUnlocker.GetID())
|
||||
|
||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current vault: %w", err)
|
||||
}
|
||||
|
||||
autoSelectUnlocker(cmd, vlt, seUnlocker.GetID())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// addPGPUnlocker creates a PGP unlocker in the current vault
|
||||
func (cli *Instance) addPGPUnlocker(cmd *cobra.Command) error {
|
||||
// Get GPG key ID from flag, environment, or default key
|
||||
var gpgKeyID string
|
||||
if flagKeyID, _ := cmd.Flags().GetString("keyid"); flagKeyID != "" {
|
||||
gpgKeyID = flagKeyID
|
||||
} else if envKeyID := os.Getenv(secret.EnvGPGKeyID); envKeyID != "" {
|
||||
gpgKeyID = envKeyID
|
||||
} else {
|
||||
// Try to get the default GPG key
|
||||
defaultKeyID, err := getDefaultGPGKey()
|
||||
if err != nil {
|
||||
return fmt.Errorf("no GPG key specified and no default key found: %w", err)
|
||||
}
|
||||
|
||||
gpgKeyID = defaultKeyID
|
||||
cmd.Printf("Using default GPG key: %s\n", gpgKeyID)
|
||||
}
|
||||
|
||||
// Check if this key is already added as an unlocker
|
||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current vault: %w", err)
|
||||
}
|
||||
|
||||
// Resolve the GPG key ID to its fingerprint
|
||||
fingerprint, err := secret.ResolveGPGKeyFingerprint(gpgKeyID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve GPG key fingerprint: %w", err)
|
||||
}
|
||||
|
||||
// Check if this GPG key is already added
|
||||
expectedID := "pgp-" + fingerprint
|
||||
|
||||
err = cli.checkUnlockerExists(vlt, expectedID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %s", errGPGKeyAlreadyUnlocker, gpgKeyID)
|
||||
}
|
||||
|
||||
pgpUnlocker, err := secret.CreatePGPUnlocker(cli.fs, cli.stateDir, gpgKeyID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.Printf("Created PGP unlocker: %s\n", pgpUnlocker.GetID())
|
||||
cmd.Printf("GPG Key ID: %s\n", gpgKeyID)
|
||||
|
||||
// Auto-select the newly created unlocker
|
||||
autoSelectUnlocker(cmd, vlt, pgpUnlocker.GetID())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnlockersRemove removes an unlocker with safety checks
|
||||
func (cli *Instance) UnlockersRemove(unlockerID string, force bool, cmd *cobra.Command) error {
|
||||
func (cli *Instance) UnlockersRemove(
|
||||
unlockerID string, force bool, cmd *cobra.Command,
|
||||
) error {
|
||||
// Get current vault
|
||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
@@ -593,20 +720,24 @@ func (cli *Instance) UnlockersRemove(unlockerID string, force bool, cmd *cobra.C
|
||||
}
|
||||
|
||||
if numSecrets > 0 && !force {
|
||||
cmd.Println("ERROR: Cannot remove the last unlocker when the vault contains secrets.")
|
||||
cmd.Println("WARNING: Without unlockers, you MUST have your mnemonic phrase to decrypt the vault.")
|
||||
cmd.Println("ERROR: Cannot remove the last unlocker when the " +
|
||||
"vault contains secrets.")
|
||||
cmd.Println("WARNING: Without unlockers, you MUST have your " +
|
||||
"mnemonic phrase to decrypt the vault.")
|
||||
cmd.Println("If you want to proceed anyway, use --force")
|
||||
|
||||
return fmt.Errorf("refusing to remove last unlocker")
|
||||
return errLastUnlocker
|
||||
}
|
||||
|
||||
if numSecrets > 0 && force {
|
||||
cmd.Println("WARNING: Removing the last unlocker. You MUST have your mnemonic phrase to access this vault again!")
|
||||
cmd.Println("WARNING: Removing the last unlocker. You MUST " +
|
||||
"have your mnemonic phrase to access this vault again!")
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the unlocker
|
||||
if err := vlt.RemoveUnlocker(unlockerID); err != nil {
|
||||
err = vlt.RemoveUnlocker(unlockerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -639,65 +770,20 @@ func (cli *Instance) checkUnlockerExists(vlt *vault.Vault, unlockerID string) er
|
||||
// Get vault directory to construct unlocker instances
|
||||
vaultDir, err := vlt.GetDirectory()
|
||||
if err != nil {
|
||||
secret.Warn("Could not get vault directory during duplicate check", "error", err)
|
||||
secret.Warn("Could not get vault directory during duplicate check",
|
||||
"error", err)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check each unlocker's ID
|
||||
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
||||
|
||||
for _, metadata := range unlockers {
|
||||
// Construct the unlocker based on type to get its ID
|
||||
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
||||
files, err := afero.ReadDir(cli.fs, unlockersDir)
|
||||
if err != nil {
|
||||
secret.Warn("Could not read unlockers directory during duplicate check", "error", err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
if !file.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
unlockerDir := filepath.Join(unlockersDir, file.Name())
|
||||
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
|
||||
|
||||
// Check if this matches our metadata
|
||||
metadataBytes, err := afero.ReadFile(cli.fs, metadataPath)
|
||||
if err != nil {
|
||||
secret.Warn("Could not read unlocker metadata during duplicate check", "path", metadataPath, "error", err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
var diskMetadata secret.UnlockerMetadata
|
||||
if err := json.Unmarshal(metadataBytes, &diskMetadata); err != nil {
|
||||
secret.Warn("Could not parse unlocker metadata during duplicate check", "path", metadataPath, "error", err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// Match by type and creation time
|
||||
if diskMetadata.Type == metadata.Type && diskMetadata.CreatedAt.Equal(metadata.CreatedAt) {
|
||||
var unlocker secret.Unlocker
|
||||
switch metadata.Type {
|
||||
case "passphrase":
|
||||
unlocker = secret.NewPassphraseUnlocker(cli.fs, unlockerDir, diskMetadata)
|
||||
case "keychain":
|
||||
unlocker = secret.NewKeychainUnlocker(cli.fs, unlockerDir, diskMetadata)
|
||||
case "pgp":
|
||||
unlocker = secret.NewPGPUnlocker(cli.fs, unlockerDir, diskMetadata)
|
||||
case "secure-enclave":
|
||||
unlocker = secret.NewSecureEnclaveUnlocker(cli.fs, unlockerDir, diskMetadata)
|
||||
}
|
||||
|
||||
if unlocker != nil && unlocker.GetID() == unlockerID {
|
||||
return fmt.Errorf("unlocker already exists")
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
// Construct the unlocker matching this metadata to get its ID
|
||||
id := findUnlockerIDByMetadata(cli.fs, unlockersDir, metadata, true)
|
||||
if id != "" && id == unlockerID {
|
||||
return errUnlockerExists
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,12 @@ package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -18,6 +20,22 @@ import (
|
||||
"github.com/tyler-smith/go-bip39"
|
||||
)
|
||||
|
||||
// Sentinel errors for vault operations
|
||||
var (
|
||||
errMnemonicEmpty = errors.New("mnemonic cannot be empty")
|
||||
errInvalidMnemonicPhrase = errors.New("invalid BIP39 mnemonic phrase")
|
||||
errInvalidMnemonic = errors.New("invalid BIP39 mnemonic")
|
||||
errVaultHasLongTermKey = errors.New(
|
||||
"already has a long-term key configured")
|
||||
errMnemonicEnvNotSet = errors.New(
|
||||
"SB_SECRET_MNEMONIC environment variable not set")
|
||||
errPassphraseEnvNotSet = errors.New(
|
||||
"SB_UNLOCK_PASSPHRASE environment variable not set")
|
||||
errCannotRemoveLastVault = errors.New("cannot remove the last vault")
|
||||
errVaultContainsSecrets = errors.New(
|
||||
"contains secrets; use --force to remove")
|
||||
)
|
||||
|
||||
func newVaultCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "vault",
|
||||
@@ -36,7 +54,7 @@ func newVaultCmd() *cobra.Command {
|
||||
|
||||
func newVaultListCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Use: cmdUseList,
|
||||
Aliases: []string{"ls"},
|
||||
Short: "List available vaults",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
@@ -101,9 +119,10 @@ func newVaultImportCmd() *cobra.Command {
|
||||
}
|
||||
|
||||
return &cobra.Command{
|
||||
Use: "import <vault-name>",
|
||||
Short: "Import a mnemonic into a vault",
|
||||
Long: `Import a BIP39 mnemonic phrase into the specified vault (default if not specified).`,
|
||||
Use: "import <vault-name>",
|
||||
Short: "Import a mnemonic into a vault",
|
||||
Long: `Import a BIP39 mnemonic phrase into the specified vault ` +
|
||||
`(default if not specified).`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
ValidArgsFunction: getVaultNamesCompletionFunc(cli.fs, cli.stateDir),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -127,16 +146,19 @@ func newVaultRemoveCmd() *cobra.Command {
|
||||
if err != nil {
|
||||
log.Fatalf("failed to initialize CLI: %v", err)
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "remove <name>",
|
||||
Aliases: []string{"rm"},
|
||||
Short: "Remove a vault",
|
||||
Long: `Remove a vault. Requires --force if the vault contains secrets. Will automatically ` +
|
||||
`switch to another vault if removing the currently selected one.`,
|
||||
Long: `Remove a vault. Requires --force if the vault contains ` +
|
||||
`secrets. Will automatically switch to another vault if ` +
|
||||
`removing the currently selected one.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
ValidArgsFunction: getVaultNamesCompletionFunc(cli.fs, cli.stateDir),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
force, _ := cmd.Flags().GetBool("force")
|
||||
|
||||
cli, err := NewCLIInstance()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||
@@ -161,11 +183,13 @@ func (cli *Instance) ListVaults(cmd *cobra.Command, jsonOutput bool) error {
|
||||
if jsonOutput { //nolint:nestif // Separate JSON and text output formatting logic
|
||||
// Get current vault name for context
|
||||
currentVault := ""
|
||||
if currentVlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir); err == nil {
|
||||
|
||||
currentVlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||
if err == nil {
|
||||
currentVault = currentVlt.GetName()
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
result := map[string]any{
|
||||
"vaults": vaults,
|
||||
"currentVault": currentVault,
|
||||
}
|
||||
@@ -174,16 +198,20 @@ func (cli *Instance) ListVaults(cmd *cobra.Command, jsonOutput bool) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.Println(string(jsonBytes))
|
||||
} else {
|
||||
// Text output
|
||||
cmd.Println("Available vaults:")
|
||||
|
||||
if len(vaults) == 0 {
|
||||
cmd.Println(" (none)")
|
||||
} else {
|
||||
// Try to get current vault for marking
|
||||
currentVault := ""
|
||||
if currentVlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir); err == nil {
|
||||
|
||||
currentVlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||
if err == nil {
|
||||
currentVault = currentVlt.GetName()
|
||||
}
|
||||
|
||||
@@ -200,19 +228,57 @@ func (cli *Instance) ListVaults(cmd *cobra.Command, jsonOutput bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// setMnemonicEnv sets the mnemonic environment variable and returns a
|
||||
// function that restores the previous value
|
||||
func setMnemonicEnv(mnemonicStr string) func() {
|
||||
originalMnemonic := os.Getenv(secret.EnvMnemonic)
|
||||
_ = os.Setenv(secret.EnvMnemonic, mnemonicStr)
|
||||
|
||||
return func() {
|
||||
if originalMnemonic != "" {
|
||||
_ = os.Setenv(secret.EnvMnemonic, originalMnemonic)
|
||||
} else {
|
||||
_ = os.Unsetenv(secret.EnvMnemonic)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// resolvePassphrase returns the unlock passphrase from the environment or
|
||||
// prompts the user for it with confirmation
|
||||
func resolvePassphrase() (*memguard.LockedBuffer, error) {
|
||||
if envPassphrase := os.Getenv(secret.EnvUnlockPassphrase); envPassphrase != "" {
|
||||
secret.Debug("Using unlock passphrase from environment variable")
|
||||
|
||||
return memguard.NewBufferFromBytes([]byte(envPassphrase)), nil
|
||||
}
|
||||
|
||||
secret.Debug("Prompting user for unlock passphrase")
|
||||
|
||||
// Use secure passphrase input with confirmation
|
||||
passphraseBuffer, err := readSecurePassphrase("Enter passphrase for unlocker: ")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read passphrase: %w", err)
|
||||
}
|
||||
|
||||
return passphraseBuffer, nil
|
||||
}
|
||||
|
||||
// CreateVault creates a new vault
|
||||
func (cli *Instance) CreateVault(cmd *cobra.Command, name string) error {
|
||||
secret.Debug("Creating new vault", "name", name, "state_dir", cli.stateDir)
|
||||
|
||||
// Get or prompt for mnemonic
|
||||
var mnemonicStr string
|
||||
|
||||
if envMnemonic := os.Getenv(secret.EnvMnemonic); envMnemonic != "" {
|
||||
secret.Debug("Using mnemonic from environment variable")
|
||||
|
||||
mnemonicStr = envMnemonic
|
||||
} else {
|
||||
secret.Debug("Prompting user for mnemonic phrase")
|
||||
// Read mnemonic securely without echo
|
||||
mnemonicBuffer, err := secret.ReadPassphrase("Enter your BIP39 mnemonic phrase: ")
|
||||
mnemonicBuffer, err := secret.ReadPassphrase(
|
||||
"Enter your BIP39 mnemonic phrase: ")
|
||||
if err != nil {
|
||||
secret.Debug("Failed to read mnemonic from stdin", "error", err)
|
||||
|
||||
@@ -221,30 +287,25 @@ func (cli *Instance) CreateVault(cmd *cobra.Command, name string) error {
|
||||
defer mnemonicBuffer.Destroy()
|
||||
|
||||
mnemonicStr = mnemonicBuffer.String()
|
||||
|
||||
fmt.Fprintln(os.Stderr) // Add newline after hidden input
|
||||
}
|
||||
|
||||
if mnemonicStr == "" {
|
||||
return fmt.Errorf("mnemonic cannot be empty")
|
||||
return errMnemonicEmpty
|
||||
}
|
||||
|
||||
// Validate the mnemonic
|
||||
mnemonicWords := strings.Fields(mnemonicStr)
|
||||
secret.Debug("Validating BIP39 mnemonic", "word_count", len(mnemonicWords))
|
||||
|
||||
if !bip39.IsMnemonicValid(mnemonicStr) {
|
||||
return fmt.Errorf("invalid BIP39 mnemonic phrase")
|
||||
return errInvalidMnemonicPhrase
|
||||
}
|
||||
|
||||
// Set mnemonic in environment for CreateVault to use
|
||||
originalMnemonic := os.Getenv(secret.EnvMnemonic)
|
||||
_ = os.Setenv(secret.EnvMnemonic, mnemonicStr)
|
||||
defer func() {
|
||||
if originalMnemonic != "" {
|
||||
_ = os.Setenv(secret.EnvMnemonic, originalMnemonic)
|
||||
} else {
|
||||
_ = os.Unsetenv(secret.EnvMnemonic)
|
||||
}
|
||||
}()
|
||||
restoreMnemonicEnv := setMnemonicEnv(mnemonicStr)
|
||||
defer restoreMnemonicEnv()
|
||||
|
||||
// Create the vault - it will handle key derivation internally
|
||||
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, name)
|
||||
@@ -254,6 +315,7 @@ func (cli *Instance) CreateVault(cmd *cobra.Command, name string) error {
|
||||
|
||||
// Get the vault metadata to retrieve the derivation index
|
||||
vaultDir := filepath.Join(cli.stateDir, "vaults.d", name)
|
||||
|
||||
metadata, err := vault.LoadVaultMetadata(cli.fs, vaultDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load vault metadata: %w", err)
|
||||
@@ -269,22 +331,15 @@ func (cli *Instance) CreateVault(cmd *cobra.Command, name string) error {
|
||||
vlt.Unlock(ltIdentity)
|
||||
|
||||
// Get or prompt for passphrase
|
||||
var passphraseBuffer *memguard.LockedBuffer
|
||||
if envPassphrase := os.Getenv(secret.EnvUnlockPassphrase); envPassphrase != "" {
|
||||
secret.Debug("Using unlock passphrase from environment variable")
|
||||
passphraseBuffer = memguard.NewBufferFromBytes([]byte(envPassphrase))
|
||||
} else {
|
||||
secret.Debug("Prompting user for unlock passphrase")
|
||||
// Use secure passphrase input with confirmation
|
||||
passphraseBuffer, err = readSecurePassphrase("Enter passphrase for unlocker: ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read passphrase: %w", err)
|
||||
}
|
||||
passphraseBuffer, err := resolvePassphrase()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer passphraseBuffer.Destroy()
|
||||
|
||||
// Create passphrase-protected unlocker
|
||||
secret.Debug("Creating passphrase-protected unlocker")
|
||||
|
||||
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create unlocker: %w", err)
|
||||
@@ -299,7 +354,8 @@ func (cli *Instance) CreateVault(cmd *cobra.Command, name string) error {
|
||||
|
||||
// SelectVault selects a vault as the current one
|
||||
func (cli *Instance) SelectVault(cmd *cobra.Command, name string) error {
|
||||
if err := vault.SelectVault(cli.fs, cli.stateDir, name); err != nil {
|
||||
err := vault.SelectVault(cli.fs, cli.stateDir, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -308,84 +364,60 @@ func (cli *Instance) SelectVault(cmd *cobra.Command, name string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// VaultImport imports a mnemonic into a specific vault
|
||||
func (cli *Instance) VaultImport(cmd *cobra.Command, vaultName string) error {
|
||||
secret.Debug("Importing mnemonic into vault", "vault_name", vaultName, "state_dir", cli.stateDir)
|
||||
|
||||
// Get the specific vault by name
|
||||
vlt := vault.NewVault(cli.fs, cli.stateDir, vaultName)
|
||||
|
||||
// vaultImportPreflight verifies the vault exists without a long-term key
|
||||
// and returns the vault directory, public key path, and validated mnemonic
|
||||
func (cli *Instance) vaultImportPreflight(
|
||||
vlt *vault.Vault, vaultName string,
|
||||
) (string, string, string, error) {
|
||||
// Check if vault exists
|
||||
vaultDir, err := vlt.GetDirectory()
|
||||
if err != nil {
|
||||
return err
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
exists, err := afero.DirExists(cli.fs, vaultDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if vault exists: %w", err)
|
||||
return "", "", "", fmt.Errorf("failed to check if vault exists: %w", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return fmt.Errorf("vault '%s' does not exist", vaultName)
|
||||
return "", "", "", fmt.Errorf("vault '%s' %w",
|
||||
vaultName, errVaultDoesNotExist)
|
||||
}
|
||||
|
||||
// Check if vault already has a public key
|
||||
pubKeyPath := fmt.Sprintf("%s/pub.age", vaultDir)
|
||||
if _, err := cli.fs.Stat(pubKeyPath); err == nil {
|
||||
return fmt.Errorf("vault '%s' already has a long-term key configured", vaultName)
|
||||
pubKeyPath := vaultDir + "/pub.age"
|
||||
|
||||
_, err = cli.fs.Stat(pubKeyPath)
|
||||
if err == nil {
|
||||
return "", "", "", fmt.Errorf("vault '%s' %w",
|
||||
vaultName, errVaultHasLongTermKey)
|
||||
}
|
||||
|
||||
// Get mnemonic from environment
|
||||
mnemonic := os.Getenv(secret.EnvMnemonic)
|
||||
if mnemonic == "" {
|
||||
return fmt.Errorf("SB_SECRET_MNEMONIC environment variable not set")
|
||||
return "", "", "", errMnemonicEnvNotSet
|
||||
}
|
||||
|
||||
// Validate the mnemonic
|
||||
mnemonicWords := strings.Fields(mnemonic)
|
||||
secret.Debug("Validating BIP39 mnemonic", "word_count", len(mnemonicWords))
|
||||
|
||||
if !bip39.IsMnemonicValid(mnemonic) {
|
||||
return fmt.Errorf("invalid BIP39 mnemonic")
|
||||
return "", "", "", errInvalidMnemonic
|
||||
}
|
||||
|
||||
// Get the next available derivation index for this mnemonic
|
||||
derivationIndex, err := vault.GetNextDerivationIndex(cli.fs, cli.stateDir, mnemonic)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to get next derivation index", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to get next derivation index: %w", err)
|
||||
}
|
||||
secret.Debug("Using derivation index", "index", derivationIndex)
|
||||
|
||||
// Derive long-term key from mnemonic with the appropriate index
|
||||
secret.Debug("Deriving long-term key from mnemonic", "index", derivationIndex)
|
||||
ltIdentity, err := agehd.DeriveIdentity(mnemonic, derivationIndex)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to derive long-term key: %w", err)
|
||||
}
|
||||
|
||||
// Store long-term public key in vault
|
||||
ltPublicKey := ltIdentity.Recipient().String()
|
||||
secret.Debug("Storing long-term public key", "pubkey", ltPublicKey, "vault_dir", vaultDir)
|
||||
|
||||
if err := afero.WriteFile(cli.fs, pubKeyPath, []byte(ltPublicKey), secret.FilePerms); err != nil {
|
||||
return fmt.Errorf("failed to store long-term public key: %w", err)
|
||||
}
|
||||
|
||||
// Calculate public key hash from the actual derivation index being used
|
||||
// This is used to verify that the derived key matches what was stored
|
||||
publicKeyHash := vault.ComputeDoubleSHA256([]byte(ltIdentity.Recipient().String()))
|
||||
|
||||
// Calculate family hash from index 0 (same for all vaults with this mnemonic)
|
||||
// This is used to identify which vaults belong to the same mnemonic family
|
||||
identity0, err := agehd.DeriveIdentity(mnemonic, 0)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to derive identity for index 0: %w", err)
|
||||
}
|
||||
familyHash := vault.ComputeDoubleSHA256([]byte(identity0.Recipient().String()))
|
||||
return vaultDir, pubKeyPath, mnemonic, nil
|
||||
}
|
||||
|
||||
// updateVaultImportMetadata stores the derivation info in vault metadata
|
||||
func updateVaultImportMetadata(
|
||||
fs afero.Fs, vaultDir string, derivationIndex uint32,
|
||||
publicKeyHash, familyHash string,
|
||||
) error {
|
||||
// Load existing metadata
|
||||
existingMetadata, err := vault.LoadVaultMetadata(cli.fs, vaultDir)
|
||||
existingMetadata, err := vault.LoadVaultMetadata(fs, vaultDir)
|
||||
if err != nil {
|
||||
// If metadata doesn't exist, create new
|
||||
existingMetadata = &vault.Metadata{
|
||||
@@ -398,17 +430,83 @@ func (cli *Instance) VaultImport(cmd *cobra.Command, vaultName string) error {
|
||||
existingMetadata.PublicKeyHash = publicKeyHash
|
||||
existingMetadata.MnemonicFamilyHash = familyHash
|
||||
|
||||
if err := vault.SaveVaultMetadata(cli.fs, vaultDir, existingMetadata); err != nil {
|
||||
err = vault.SaveVaultMetadata(fs, vaultDir, existingMetadata)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to save vault metadata", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to save vault metadata: %w", err)
|
||||
}
|
||||
|
||||
secret.Debug("Saved vault metadata with derivation index and public key hash")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// VaultImport imports a mnemonic into a specific vault
|
||||
func (cli *Instance) VaultImport(cmd *cobra.Command, vaultName string) error {
|
||||
secret.Debug("Importing mnemonic into vault",
|
||||
"vault_name", vaultName, "state_dir", cli.stateDir)
|
||||
|
||||
// Get the specific vault by name
|
||||
vlt := vault.NewVault(cli.fs, cli.stateDir, vaultName)
|
||||
|
||||
vaultDir, pubKeyPath, mnemonic, err := cli.vaultImportPreflight(vlt, vaultName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get the next available derivation index for this mnemonic
|
||||
derivationIndex, err := vault.GetNextDerivationIndex(cli.fs, cli.stateDir, mnemonic)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to get next derivation index", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to get next derivation index: %w", err)
|
||||
}
|
||||
|
||||
secret.Debug("Using derivation index", "index", derivationIndex)
|
||||
|
||||
// Derive long-term key from mnemonic with the appropriate index
|
||||
secret.Debug("Deriving long-term key from mnemonic", "index", derivationIndex)
|
||||
|
||||
ltIdentity, err := agehd.DeriveIdentity(mnemonic, derivationIndex)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to derive long-term key: %w", err)
|
||||
}
|
||||
|
||||
// Store long-term public key in vault
|
||||
ltPublicKey := ltIdentity.Recipient().String()
|
||||
secret.Debug("Storing long-term public key",
|
||||
"pubkey", ltPublicKey, "vault_dir", vaultDir)
|
||||
|
||||
err = afero.WriteFile(cli.fs, pubKeyPath, []byte(ltPublicKey), secret.FilePerms)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to store long-term public key: %w", err)
|
||||
}
|
||||
|
||||
// Calculate public key hash from the actual derivation index being used
|
||||
// This is used to verify that the derived key matches what was stored
|
||||
publicKeyHash := vault.ComputeDoubleSHA256([]byte(ltIdentity.Recipient().String()))
|
||||
|
||||
// Calculate family hash from index 0 (same for all vaults with this
|
||||
// mnemonic). This is used to identify which vaults belong to the same
|
||||
// mnemonic family.
|
||||
identity0, err := agehd.DeriveIdentity(mnemonic, 0)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to derive identity for index 0: %w", err)
|
||||
}
|
||||
|
||||
familyHash := vault.ComputeDoubleSHA256([]byte(identity0.Recipient().String()))
|
||||
|
||||
err = updateVaultImportMetadata(
|
||||
cli.fs, vaultDir, derivationIndex, publicKeyHash, familyHash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get passphrase from environment variable
|
||||
passphraseStr := os.Getenv(secret.EnvUnlockPassphrase)
|
||||
if passphraseStr == "" {
|
||||
return fmt.Errorf("SB_UNLOCK_PASSPHRASE environment variable not set")
|
||||
return errPassphraseEnvNotSet
|
||||
}
|
||||
|
||||
secret.Debug("Using unlock passphrase from environment variable")
|
||||
@@ -422,6 +520,7 @@ func (cli *Instance) VaultImport(cmd *cobra.Command, vaultName string) error {
|
||||
|
||||
// Create passphrase-protected unlocker
|
||||
secret.Debug("Creating passphrase-protected unlocker")
|
||||
|
||||
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to create unlocker", "error", err)
|
||||
@@ -436,6 +535,46 @@ func (cli *Instance) VaultImport(cmd *cobra.Command, vaultName string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// vaultHasSecrets reports whether the vault directory contains any secrets
|
||||
func (cli *Instance) vaultHasSecrets(vaultDir string) bool {
|
||||
secretsDir := filepath.Join(vaultDir, "secrets.d")
|
||||
|
||||
exists, _ := afero.DirExists(cli.fs, secretsDir)
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
|
||||
entries, err := afero.ReadDir(cli.fs, secretsDir)
|
||||
|
||||
return err == nil && len(entries) > 0
|
||||
}
|
||||
|
||||
// switchAwayFromVault selects another vault as current before removal
|
||||
func (cli *Instance) switchAwayFromVault(
|
||||
cmd *cobra.Command, vaults []string, name string,
|
||||
) error {
|
||||
// Find another vault to switch to
|
||||
var newVault string
|
||||
|
||||
for _, v := range vaults {
|
||||
if v != name {
|
||||
newVault = v
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Switch to the new vault
|
||||
err := vault.SelectVault(cli.fs, cli.stateDir, newVault)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to switch to vault '%s': %w", newVault, err)
|
||||
}
|
||||
|
||||
cmd.Printf("Switched current vault to '%s'\n", newVault)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveVault removes a vault with safety checks
|
||||
func (cli *Instance) RemoveVault(cmd *cobra.Command, name string, force bool) error {
|
||||
// Get list of all vaults
|
||||
@@ -445,21 +584,13 @@ func (cli *Instance) RemoveVault(cmd *cobra.Command, name string, force bool) er
|
||||
}
|
||||
|
||||
// Check if vault exists
|
||||
vaultExists := false
|
||||
for _, v := range vaults {
|
||||
if v == name {
|
||||
vaultExists = true
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
if !vaultExists {
|
||||
return fmt.Errorf("vault '%s' does not exist", name)
|
||||
if !slices.Contains(vaults, name) {
|
||||
return fmt.Errorf("vault '%s' %w", name, errVaultDoesNotExist)
|
||||
}
|
||||
|
||||
// Don't allow removing the last vault
|
||||
if len(vaults) == 1 {
|
||||
return fmt.Errorf("cannot remove the last vault")
|
||||
return errCannotRemoveLastVault
|
||||
}
|
||||
|
||||
// Check if this is the current vault
|
||||
@@ -467,57 +598,44 @@ func (cli *Instance) RemoveVault(cmd *cobra.Command, name string, force bool) er
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current vault: %w", err)
|
||||
}
|
||||
|
||||
isCurrentVault := currentVault.GetName() == name
|
||||
|
||||
// Load the vault to check for secrets
|
||||
vlt := vault.NewVault(cli.fs, cli.stateDir, name)
|
||||
|
||||
vaultDir, err := vlt.GetDirectory()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get vault directory: %w", err)
|
||||
}
|
||||
|
||||
// Check if vault has secrets
|
||||
secretsDir := filepath.Join(vaultDir, "secrets.d")
|
||||
hasSecrets := false
|
||||
if exists, _ := afero.DirExists(cli.fs, secretsDir); exists {
|
||||
entries, err := afero.ReadDir(cli.fs, secretsDir)
|
||||
if err == nil && len(entries) > 0 {
|
||||
hasSecrets = true
|
||||
}
|
||||
}
|
||||
hasSecrets := cli.vaultHasSecrets(vaultDir)
|
||||
|
||||
// Require --force if vault has secrets
|
||||
if hasSecrets && !force {
|
||||
return fmt.Errorf("vault '%s' contains secrets; use --force to remove", name)
|
||||
return fmt.Errorf("vault '%s' %w", name, errVaultContainsSecrets)
|
||||
}
|
||||
|
||||
// If removing current vault, switch to another vault first
|
||||
if isCurrentVault {
|
||||
// Find another vault to switch to
|
||||
var newVault string
|
||||
for _, v := range vaults {
|
||||
if v != name {
|
||||
newVault = v
|
||||
|
||||
break
|
||||
}
|
||||
err = cli.switchAwayFromVault(cmd, vaults, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Switch to the new vault
|
||||
if err := vault.SelectVault(cli.fs, cli.stateDir, newVault); err != nil {
|
||||
return fmt.Errorf("failed to switch to vault '%s': %w", newVault, err)
|
||||
}
|
||||
cmd.Printf("Switched current vault to '%s'\n", newVault)
|
||||
}
|
||||
|
||||
// Remove the vault directory
|
||||
if err := cli.fs.RemoveAll(vaultDir); err != nil {
|
||||
err = cli.fs.RemoveAll(vaultDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove vault directory: %w", err)
|
||||
}
|
||||
|
||||
cmd.Printf("Removed vault '%s'\n", name)
|
||||
|
||||
if hasSecrets {
|
||||
cmd.Printf("Warning: Vault contained secrets that have been permanently deleted\n")
|
||||
cmd.Printf("Warning: Vault contained secrets that have been " +
|
||||
"permanently deleted\n")
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"filippo.io/age"
|
||||
"git.eeqj.de/sneak/secret/internal/secret"
|
||||
"git.eeqj.de/sneak/secret/internal/vault"
|
||||
"github.com/spf13/afero"
|
||||
@@ -17,6 +21,12 @@ const (
|
||||
tabWriterPadding = 2
|
||||
)
|
||||
|
||||
// Sentinel errors for version operations
|
||||
var (
|
||||
errVersionNotFound = errors.New("not found for secret")
|
||||
errCannotRemoveCurrentVersion = errors.New("promote another version first")
|
||||
)
|
||||
|
||||
// newVersionCmd returns the version management command
|
||||
func newVersionCmd() *cobra.Command {
|
||||
cli, err := NewCLIInstance()
|
||||
@@ -32,7 +42,8 @@ func VersionCommands(cli *Instance) *cobra.Command {
|
||||
versionCmd := &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Manage secret versions",
|
||||
Long: "Commands for managing secret versions including listing, promoting, and retrieving specific versions",
|
||||
Long: "Commands for managing secret versions including listing, " +
|
||||
"promoting, and retrieving specific versions",
|
||||
}
|
||||
|
||||
// List versions command
|
||||
@@ -51,14 +62,17 @@ func VersionCommands(cli *Instance) *cobra.Command {
|
||||
promoteCmd := &cobra.Command{
|
||||
Use: "promote <secret-name> <version>",
|
||||
Short: "Promote a specific version to current",
|
||||
Long: "Updates the current symlink to point to the specified version without modifying timestamps",
|
||||
Args: cobra.ExactArgs(2), //nolint:mnd // Command requires exactly 2 arguments: secret-name and version
|
||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
Long: "Updates the current symlink to point to the specified " +
|
||||
"version without modifying timestamps",
|
||||
Args: cobra.ExactArgs(2), //nolint:mnd // secret-name and version args
|
||||
ValidArgsFunction: func(
|
||||
cmd *cobra.Command, args []string, toComplete string,
|
||||
) ([]string, cobra.ShellCompDirective) {
|
||||
// Complete secret name for first arg
|
||||
if len(args) == 0 {
|
||||
return getSecretNamesCompletionFunc(cli.fs, cli.stateDir)(cmd, args, toComplete)
|
||||
}
|
||||
// TODO: Complete version numbers for second arg
|
||||
// Version number completion for the second arg is not implemented
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -71,14 +85,17 @@ func VersionCommands(cli *Instance) *cobra.Command {
|
||||
Use: "remove <secret-name> <version>",
|
||||
Aliases: []string{"rm"},
|
||||
Short: "Remove a specific version of a secret",
|
||||
Long: "Remove a specific version of a secret. Cannot remove the current version.",
|
||||
Args: cobra.ExactArgs(2), //nolint:mnd // Command requires exactly 2 arguments: secret-name and version
|
||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
Long: "Remove a specific version of a secret. Cannot remove the " +
|
||||
"current version.",
|
||||
Args: cobra.ExactArgs(2), //nolint:mnd // secret-name and version args
|
||||
ValidArgsFunction: func(
|
||||
cmd *cobra.Command, args []string, toComplete string,
|
||||
) ([]string, cobra.ShellCompDirective) {
|
||||
// Complete secret name for first arg
|
||||
if len(args) == 0 {
|
||||
return getSecretNamesCompletionFunc(cli.fs, cli.stateDir)(cmd, args, toComplete)
|
||||
}
|
||||
// TODO: Complete version numbers for second arg
|
||||
// Version number completion for the second arg is not implemented
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -121,10 +138,11 @@ func (cli *Instance) ListVersions(cmd *cobra.Command, secretName string) error {
|
||||
|
||||
return fmt.Errorf("failed to check if secret exists: %w", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
secret.Debug("Secret not found", "secret_name", secretName)
|
||||
|
||||
return fmt.Errorf("secret '%s' not found", secretName)
|
||||
return fmt.Errorf("secret '%s' %w", secretName, errSecretNotFound)
|
||||
}
|
||||
|
||||
// List all versions
|
||||
@@ -145,6 +163,7 @@ func (cli *Instance) ListVersions(cmd *cobra.Command, secretName string) error {
|
||||
currentVersion, err := secret.GetCurrentVersion(cli.fs, secretDir)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to get current version", "error", err)
|
||||
|
||||
currentVersion = ""
|
||||
}
|
||||
|
||||
@@ -160,44 +179,7 @@ func (cli *Instance) ListVersions(cmd *cobra.Command, secretName string) error {
|
||||
|
||||
// Load and display each version's metadata
|
||||
for _, version := range versions {
|
||||
sv := secret.NewVersion(vlt, secretName, version)
|
||||
|
||||
// Load metadata
|
||||
if err := sv.LoadMetadata(ltIdentity); err != nil {
|
||||
secret.Warn("Failed to load version metadata", "version", version, "error", err)
|
||||
// Display version with error
|
||||
status := "error"
|
||||
if version == currentVersion {
|
||||
status = "current (error)"
|
||||
}
|
||||
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", version, "-", status, "-", "-")
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// Determine status
|
||||
status := "expired"
|
||||
if version == currentVersion {
|
||||
status = "current"
|
||||
}
|
||||
|
||||
// Format timestamps
|
||||
createdAt := "-"
|
||||
if sv.Metadata.CreatedAt != nil {
|
||||
createdAt = sv.Metadata.CreatedAt.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
notBefore := "-"
|
||||
if sv.Metadata.NotBefore != nil {
|
||||
notBefore = sv.Metadata.NotBefore.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
notAfter := "-"
|
||||
if sv.Metadata.NotAfter != nil {
|
||||
notAfter = sv.Metadata.NotAfter.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", version, createdAt, status, notBefore, notAfter)
|
||||
printVersionRow(w, vlt, secretName, version, currentVersion, ltIdentity)
|
||||
}
|
||||
|
||||
_ = w.Flush()
|
||||
@@ -205,8 +187,58 @@ func (cli *Instance) ListVersions(cmd *cobra.Command, secretName string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// printVersionRow loads one version's metadata and writes its table row
|
||||
func printVersionRow(
|
||||
w io.Writer, vlt *vault.Vault,
|
||||
secretName, version, currentVersion string,
|
||||
ltIdentity *age.X25519Identity,
|
||||
) {
|
||||
sv := secret.NewVersion(vlt, secretName, version)
|
||||
|
||||
// Load metadata
|
||||
err := sv.LoadMetadata(ltIdentity)
|
||||
if err != nil {
|
||||
secret.Warn("Failed to load version metadata",
|
||||
"version", version, "error", err)
|
||||
// Display version with error
|
||||
status := "error"
|
||||
if version == currentVersion {
|
||||
status = "current (error)"
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", version, "-", status, "-", "-")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Determine status
|
||||
status := "expired"
|
||||
if version == currentVersion {
|
||||
status = "current"
|
||||
}
|
||||
|
||||
// Format timestamps
|
||||
createdAt := formatVersionTime(sv.Metadata.CreatedAt)
|
||||
notBefore := formatVersionTime(sv.Metadata.NotBefore)
|
||||
notAfter := formatVersionTime(sv.Metadata.NotAfter)
|
||||
|
||||
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
|
||||
version, createdAt, status, notBefore, notAfter)
|
||||
}
|
||||
|
||||
// formatVersionTime formats an optional version timestamp, "-" when unset
|
||||
func formatVersionTime(t *time.Time) string {
|
||||
if t == nil {
|
||||
return "-"
|
||||
}
|
||||
|
||||
return t.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
// PromoteVersion promotes a specific version to current
|
||||
func (cli *Instance) PromoteVersion(cmd *cobra.Command, secretName string, version string) error {
|
||||
func (cli *Instance) PromoteVersion(
|
||||
cmd *cobra.Command, secretName string, version string,
|
||||
) error {
|
||||
// Get current vault
|
||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
@@ -224,16 +256,20 @@ func (cli *Instance) PromoteVersion(cmd *cobra.Command, secretName string, versi
|
||||
|
||||
// Check if version exists
|
||||
versionDir := filepath.Join(secretDir, "versions", version)
|
||||
|
||||
exists, err := afero.DirExists(cli.fs, versionDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if version exists: %w", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return fmt.Errorf("version '%s' not found for secret '%s'", version, secretName)
|
||||
return fmt.Errorf("version '%s' %w '%s'",
|
||||
version, errVersionNotFound, secretName)
|
||||
}
|
||||
|
||||
// Update the current symlink using the proper function
|
||||
if err := secret.SetCurrentVersion(cli.fs, secretDir, version); err != nil {
|
||||
err = secret.SetCurrentVersion(cli.fs, secretDir, version)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update current version: %w", err)
|
||||
}
|
||||
|
||||
@@ -243,7 +279,9 @@ func (cli *Instance) PromoteVersion(cmd *cobra.Command, secretName string, versi
|
||||
}
|
||||
|
||||
// RemoveVersion removes a specific version of a secret
|
||||
func (cli *Instance) RemoveVersion(cmd *cobra.Command, secretName string, version string) error {
|
||||
func (cli *Instance) RemoveVersion(
|
||||
cmd *cobra.Command, secretName string, version string,
|
||||
) error {
|
||||
// Get current vault
|
||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
@@ -264,18 +302,22 @@ func (cli *Instance) RemoveVersion(cmd *cobra.Command, secretName string, versio
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if secret exists: %w", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return fmt.Errorf("secret '%s' not found", secretName)
|
||||
return fmt.Errorf("secret '%s' %w", secretName, errSecretNotFound)
|
||||
}
|
||||
|
||||
// Check if version exists
|
||||
versionDir := filepath.Join(secretDir, "versions", version)
|
||||
|
||||
exists, err = afero.DirExists(cli.fs, versionDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if version exists: %w", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return fmt.Errorf("version '%s' not found for secret '%s'", version, secretName)
|
||||
return fmt.Errorf("version '%s' %w '%s'",
|
||||
version, errVersionNotFound, secretName)
|
||||
}
|
||||
|
||||
// Get current version
|
||||
@@ -286,11 +328,13 @@ func (cli *Instance) RemoveVersion(cmd *cobra.Command, secretName string, versio
|
||||
|
||||
// Don't allow removing the current version
|
||||
if version == currentVersion {
|
||||
return fmt.Errorf("cannot remove the current version '%s'; promote another version first", version)
|
||||
return fmt.Errorf("cannot remove the current version '%s'; %w",
|
||||
version, errCannotRemoveCurrentVersion)
|
||||
}
|
||||
|
||||
// Remove the version directory
|
||||
if err := cli.fs.RemoveAll(versionDir); err != nil {
|
||||
err = cli.fs.RemoveAll(versionDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove version: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
// - setupTestVault(): CLI test helper for vault initialization
|
||||
// - Uses consistent test mnemonic for reproducible testing
|
||||
|
||||
//nolint:testpackage // white-box test of unexported internals
|
||||
package cli
|
||||
|
||||
import (
|
||||
@@ -32,29 +33,41 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Helper function to add a secret to vault with proper buffer protection
|
||||
func addTestSecret(t *testing.T, vlt *vault.Vault, name string, value []byte, force bool) {
|
||||
const (
|
||||
// testMnemonic is the standard BIP39 mnemonic used for CLI tests.
|
||||
//nolint:dupword // BIP39 test mnemonic intentionally repeats a word
|
||||
testMnemonic = "abandon abandon abandon abandon abandon abandon " +
|
||||
"abandon abandon abandon abandon abandon about"
|
||||
|
||||
// testStateDir is the in-memory state directory used by CLI tests.
|
||||
testStateDir = "/test/state"
|
||||
)
|
||||
|
||||
// Helper function to add a version of the "test/secret" secret to the
|
||||
// vault with proper buffer protection
|
||||
func addTestSecret(t *testing.T, vlt *vault.Vault, value []byte, force bool) {
|
||||
t.Helper()
|
||||
|
||||
buffer := memguard.NewBufferFromBytes(value)
|
||||
defer buffer.Destroy()
|
||||
err := vlt.AddSecret(name, buffer, force)
|
||||
|
||||
err := vlt.AddSecret("test/secret", buffer, force)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Helper function to set up a vault with long-term key
|
||||
func setupTestVault(t *testing.T, fs afero.Fs, stateDir string) {
|
||||
// Helper function to set up a vault with long-term key in testStateDir
|
||||
func setupTestVault(t *testing.T, fs afero.Fs) {
|
||||
t.Helper()
|
||||
|
||||
// Set mnemonic for testing
|
||||
testMnemonic := "abandon abandon abandon abandon abandon abandon " +
|
||||
"abandon abandon abandon abandon abandon about"
|
||||
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
||||
|
||||
// Create vault
|
||||
vlt, err := vault.CreateVault(fs, stateDir, "default")
|
||||
vlt, err := vault.CreateVault(fs, testStateDir, "default")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Derive and store long-term key from mnemonic
|
||||
mnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||
ltIdentity, err := agehd.DeriveIdentity(mnemonic, 0)
|
||||
ltIdentity, err := agehd.DeriveIdentity(testMnemonic, 0)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Store long-term public key in vault
|
||||
@@ -64,30 +77,32 @@ func setupTestVault(t *testing.T, fs afero.Fs, stateDir string) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// Select vault
|
||||
err = vault.SelectVault(fs, stateDir, "default")
|
||||
err = vault.SelectVault(fs, testStateDir, "default")
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
//nolint:paralleltest // uses t.Setenv via setupTestVault
|
||||
func TestListVersionsCommand(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
stateDir := "/test/state"
|
||||
stateDir := testStateDir
|
||||
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
||||
|
||||
// Set up vault with long-term key
|
||||
setupTestVault(t, fs, stateDir)
|
||||
setupTestVault(t, fs)
|
||||
|
||||
// Add a secret with multiple versions
|
||||
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
addTestSecret(t, vlt, "test/secret", []byte("version-1"), false)
|
||||
addTestSecret(t, vlt, []byte("version-1"), false)
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
addTestSecret(t, vlt, "test/secret", []byte("version-2"), true)
|
||||
addTestSecret(t, vlt, []byte("version-2"), true)
|
||||
|
||||
// Create a command for output capture
|
||||
cmd := newRootCmd()
|
||||
|
||||
var buf bytes.Buffer
|
||||
cmd.SetOut(&buf)
|
||||
cmd.SetErr(&buf)
|
||||
@@ -112,24 +127,28 @@ func TestListVersionsCommand(t *testing.T) {
|
||||
// Should have two version entries
|
||||
lines := strings.Split(outputStr, "\n")
|
||||
versionLines := 0
|
||||
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, ".001") || strings.Contains(line, ".002") {
|
||||
versionLines++
|
||||
}
|
||||
}
|
||||
|
||||
assert.Equal(t, 2, versionLines)
|
||||
}
|
||||
|
||||
//nolint:paralleltest // uses t.Setenv via setupTestVault
|
||||
func TestListVersionsNonExistentSecret(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
stateDir := "/test/state"
|
||||
stateDir := testStateDir
|
||||
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
||||
|
||||
// Set up vault with long-term key
|
||||
setupTestVault(t, fs, stateDir)
|
||||
setupTestVault(t, fs)
|
||||
|
||||
// Create a command for output capture
|
||||
cmd := newRootCmd()
|
||||
|
||||
var buf bytes.Buffer
|
||||
cmd.SetOut(&buf)
|
||||
cmd.SetErr(&buf)
|
||||
@@ -140,23 +159,24 @@ func TestListVersionsNonExistentSecret(t *testing.T) {
|
||||
assert.Contains(t, err.Error(), "not found")
|
||||
}
|
||||
|
||||
//nolint:paralleltest // uses t.Setenv via setupTestVault
|
||||
func TestPromoteVersionCommand(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
stateDir := "/test/state"
|
||||
stateDir := testStateDir
|
||||
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
||||
|
||||
// Set up vault with long-term key
|
||||
setupTestVault(t, fs, stateDir)
|
||||
setupTestVault(t, fs)
|
||||
|
||||
// Add a secret with multiple versions
|
||||
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
addTestSecret(t, vlt, "test/secret", []byte("version-1"), false)
|
||||
addTestSecret(t, vlt, []byte("version-1"), false)
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
addTestSecret(t, vlt, "test/secret", []byte("version-2"), true)
|
||||
addTestSecret(t, vlt, []byte("version-2"), true)
|
||||
|
||||
// Get versions
|
||||
vaultDir, _ := vlt.GetDirectory()
|
||||
@@ -175,6 +195,7 @@ func TestPromoteVersionCommand(t *testing.T) {
|
||||
|
||||
// Create a command for output capture
|
||||
cmd := newRootCmd()
|
||||
|
||||
var buf bytes.Buffer
|
||||
cmd.SetOut(&buf)
|
||||
cmd.SetErr(&buf)
|
||||
@@ -195,22 +216,24 @@ func TestPromoteVersionCommand(t *testing.T) {
|
||||
assert.Equal(t, []byte("version-1"), value)
|
||||
}
|
||||
|
||||
//nolint:paralleltest // uses t.Setenv via setupTestVault
|
||||
func TestPromoteNonExistentVersion(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
stateDir := "/test/state"
|
||||
stateDir := testStateDir
|
||||
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
||||
|
||||
// Set up vault with long-term key
|
||||
setupTestVault(t, fs, stateDir)
|
||||
setupTestVault(t, fs)
|
||||
|
||||
// Add a secret
|
||||
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
addTestSecret(t, vlt, "test/secret", []byte("value"), false)
|
||||
addTestSecret(t, vlt, []byte("value"), false)
|
||||
|
||||
// Create a command for output capture
|
||||
cmd := newRootCmd()
|
||||
|
||||
var buf bytes.Buffer
|
||||
cmd.SetOut(&buf)
|
||||
cmd.SetErr(&buf)
|
||||
@@ -221,23 +244,24 @@ func TestPromoteNonExistentVersion(t *testing.T) {
|
||||
assert.Contains(t, err.Error(), "not found")
|
||||
}
|
||||
|
||||
//nolint:paralleltest // uses t.Setenv via setupTestVault
|
||||
func TestGetSecretWithVersion(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
stateDir := "/test/state"
|
||||
stateDir := testStateDir
|
||||
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
||||
|
||||
// Set up vault with long-term key
|
||||
setupTestVault(t, fs, stateDir)
|
||||
setupTestVault(t, fs)
|
||||
|
||||
// Add a secret with multiple versions
|
||||
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
addTestSecret(t, vlt, "test/secret", []byte("version-1"), false)
|
||||
addTestSecret(t, vlt, []byte("version-1"), false)
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
addTestSecret(t, vlt, "test/secret", []byte("version-2"), true)
|
||||
addTestSecret(t, vlt, []byte("version-2"), true)
|
||||
|
||||
// Get versions
|
||||
vaultDir, _ := vlt.GetDirectory()
|
||||
@@ -248,6 +272,7 @@ func TestGetSecretWithVersion(t *testing.T) {
|
||||
|
||||
// Create a command for output capture
|
||||
cmd := newRootCmd()
|
||||
|
||||
var buf bytes.Buffer
|
||||
cmd.SetOut(&buf)
|
||||
|
||||
@@ -258,18 +283,21 @@ func TestGetSecretWithVersion(t *testing.T) {
|
||||
|
||||
// Test getting specific version
|
||||
buf.Reset()
|
||||
|
||||
firstVersion := versions[1] // Older version
|
||||
err = cli.GetSecretWithVersion(cmd, "test/secret", firstVersion)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "version-1", buf.String())
|
||||
}
|
||||
|
||||
//nolint:paralleltest // reads process environment to determine the state dir
|
||||
func TestVersionCommandStructure(t *testing.T) {
|
||||
// Test that version commands are properly structured
|
||||
cli, err := NewCLIInstance()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to initialize CLI: %v", err)
|
||||
}
|
||||
|
||||
cmd := VersionCommands(cli)
|
||||
|
||||
assert.Equal(t, "version", cmd.Use)
|
||||
@@ -285,13 +313,14 @@ func TestVersionCommandStructure(t *testing.T) {
|
||||
assert.Equal(t, "Promote a specific version to current", promoteCmd.Short)
|
||||
}
|
||||
|
||||
//nolint:paralleltest // uses t.Setenv via setupTestVault
|
||||
func TestListVersionsEmptyOutput(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
stateDir := "/test/state"
|
||||
stateDir := testStateDir
|
||||
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
||||
|
||||
// Set up vault with long-term key
|
||||
setupTestVault(t, fs, stateDir)
|
||||
setupTestVault(t, fs)
|
||||
|
||||
// Create a secret directory without versions (edge case)
|
||||
vaultDir := stateDir + "/vaults.d/default"
|
||||
@@ -301,6 +330,7 @@ func TestListVersionsEmptyOutput(t *testing.T) {
|
||||
|
||||
// Create a command for output capture
|
||||
cmd := newRootCmd()
|
||||
|
||||
var buf bytes.Buffer
|
||||
cmd.SetOut(&buf)
|
||||
cmd.SetErr(&buf)
|
||||
|
||||
Reference in New Issue
Block a user