Compare commits
	
		
			2 Commits
		
	
	
		
			345709a306
			...
			c33385be6c
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| c33385be6c | |||
| e95609ce69 | 
@ -1,7 +1,7 @@
 | 
			
		||||
package main
 | 
			
		||||
 | 
			
		||||
import "git.eeqj.de/sneak/secret/internal/secret"
 | 
			
		||||
import "git.eeqj.de/sneak/secret/internal/cli"
 | 
			
		||||
 | 
			
		||||
func main() {
 | 
			
		||||
	secret.CLIEntry()
 | 
			
		||||
	cli.CLIEntry()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										91
									
								
								internal/cli/cli.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										91
									
								
								internal/cli/cli.go
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,91 @@
 | 
			
		||||
package cli
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"bufio"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"os"
 | 
			
		||||
	"strings"
 | 
			
		||||
	"syscall"
 | 
			
		||||
 | 
			
		||||
	"git.eeqj.de/sneak/secret/internal/secret"
 | 
			
		||||
	"github.com/spf13/afero"
 | 
			
		||||
	"golang.org/x/term"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// Global scanner for consistent stdin reading
 | 
			
		||||
var stdinScanner *bufio.Scanner
 | 
			
		||||
 | 
			
		||||
// CLIInstance encapsulates all CLI functionality and state
 | 
			
		||||
type CLIInstance struct {
 | 
			
		||||
	fs       afero.Fs
 | 
			
		||||
	stateDir string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// NewCLIInstance creates a new CLI instance with the real filesystem
 | 
			
		||||
func NewCLIInstance() *CLIInstance {
 | 
			
		||||
	fs := afero.NewOsFs()
 | 
			
		||||
	stateDir := secret.DetermineStateDir("")
 | 
			
		||||
	return &CLIInstance{
 | 
			
		||||
		fs:       fs,
 | 
			
		||||
		stateDir: stateDir,
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// NewCLIInstanceWithFs creates a new CLI instance with the given filesystem (for testing)
 | 
			
		||||
func NewCLIInstanceWithFs(fs afero.Fs) *CLIInstance {
 | 
			
		||||
	stateDir := secret.DetermineStateDir("")
 | 
			
		||||
	return &CLIInstance{
 | 
			
		||||
		fs:       fs,
 | 
			
		||||
		stateDir: stateDir,
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// NewCLIInstanceWithStateDir creates a new CLI instance with custom state directory (for testing)
 | 
			
		||||
func NewCLIInstanceWithStateDir(fs afero.Fs, stateDir string) *CLIInstance {
 | 
			
		||||
	return &CLIInstance{
 | 
			
		||||
		fs:       fs,
 | 
			
		||||
		stateDir: stateDir,
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// SetFilesystem sets the filesystem for this CLI instance (for testing)
 | 
			
		||||
func (cli *CLIInstance) SetFilesystem(fs afero.Fs) {
 | 
			
		||||
	cli.fs = fs
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// SetStateDir sets the state directory for this CLI instance (for testing)
 | 
			
		||||
func (cli *CLIInstance) SetStateDir(stateDir string) {
 | 
			
		||||
	cli.stateDir = stateDir
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// GetStateDir returns the state directory for this CLI instance
 | 
			
		||||
func (cli *CLIInstance) GetStateDir() string {
 | 
			
		||||
	return cli.stateDir
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// getStdinScanner returns a shared scanner for stdin to avoid buffering issues
 | 
			
		||||
func getStdinScanner() *bufio.Scanner {
 | 
			
		||||
	if stdinScanner == nil {
 | 
			
		||||
		stdinScanner = bufio.NewScanner(os.Stdin)
 | 
			
		||||
	}
 | 
			
		||||
	return stdinScanner
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// readLineFromStdin reads a single line from stdin with a prompt
 | 
			
		||||
// Uses a shared scanner to avoid buffering issues between multiple calls
 | 
			
		||||
func readLineFromStdin(prompt string) (string, error) {
 | 
			
		||||
	// Check if stderr is a terminal - if not, we can't prompt interactively
 | 
			
		||||
	if !term.IsTerminal(int(syscall.Stderr)) {
 | 
			
		||||
		return "", fmt.Errorf("cannot prompt for input: stderr is not a terminal (running in non-interactive mode)")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	fmt.Fprint(os.Stderr, prompt) // Write prompt to stderr, not stdout
 | 
			
		||||
	scanner := getStdinScanner()
 | 
			
		||||
	if !scanner.Scan() {
 | 
			
		||||
		if err := scanner.Err(); err != nil {
 | 
			
		||||
			return "", fmt.Errorf("failed to read from stdin: %w", err)
 | 
			
		||||
		}
 | 
			
		||||
		return "", fmt.Errorf("failed to read from stdin: EOF")
 | 
			
		||||
	}
 | 
			
		||||
	return strings.TrimSpace(scanner.Text()), nil
 | 
			
		||||
}
 | 
			
		||||
@ -1,10 +1,11 @@
 | 
			
		||||
package secret
 | 
			
		||||
package cli
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"os"
 | 
			
		||||
	"path/filepath"
 | 
			
		||||
	"testing"
 | 
			
		||||
 | 
			
		||||
	"git.eeqj.de/sneak/secret/internal/secret"
 | 
			
		||||
	"github.com/spf13/afero"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
@ -34,32 +35,32 @@ func TestCLIInstanceWithFs(t *testing.T) {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestDetermineStateDir(t *testing.T) {
 | 
			
		||||
	// Test the determineStateDir function
 | 
			
		||||
	// Test the determineStateDir function from the secret package
 | 
			
		||||
 | 
			
		||||
	// Save original environment and restore it after test
 | 
			
		||||
	originalStateDir := os.Getenv(EnvStateDir)
 | 
			
		||||
	originalStateDir := os.Getenv(secret.EnvStateDir)
 | 
			
		||||
	defer func() {
 | 
			
		||||
		if originalStateDir == "" {
 | 
			
		||||
			os.Unsetenv(EnvStateDir)
 | 
			
		||||
			os.Unsetenv(secret.EnvStateDir)
 | 
			
		||||
		} else {
 | 
			
		||||
			os.Setenv(EnvStateDir, originalStateDir)
 | 
			
		||||
			os.Setenv(secret.EnvStateDir, originalStateDir)
 | 
			
		||||
		}
 | 
			
		||||
	}()
 | 
			
		||||
 | 
			
		||||
	// Test with environment variable set
 | 
			
		||||
	testEnvDir := "/test-env-dir"
 | 
			
		||||
	os.Setenv(EnvStateDir, testEnvDir)
 | 
			
		||||
	os.Setenv(secret.EnvStateDir, testEnvDir)
 | 
			
		||||
 | 
			
		||||
	stateDir := determineStateDir("")
 | 
			
		||||
	stateDir := secret.DetermineStateDir("")
 | 
			
		||||
	if stateDir != testEnvDir {
 | 
			
		||||
		t.Errorf("Expected state directory %q from environment, got %q", testEnvDir, stateDir)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Test with custom config dir
 | 
			
		||||
	os.Unsetenv(EnvStateDir)
 | 
			
		||||
	os.Unsetenv(secret.EnvStateDir)
 | 
			
		||||
	customConfigDir := "/custom-config"
 | 
			
		||||
	stateDir = determineStateDir(customConfigDir)
 | 
			
		||||
	expectedDir := filepath.Join(customConfigDir, AppID)
 | 
			
		||||
	stateDir = secret.DetermineStateDir(customConfigDir)
 | 
			
		||||
	expectedDir := filepath.Join(customConfigDir, secret.AppID)
 | 
			
		||||
	if stateDir != expectedDir {
 | 
			
		||||
		t.Errorf("Expected state directory %q with custom config, got %q", expectedDir, stateDir)
 | 
			
		||||
	}
 | 
			
		||||
							
								
								
									
										243
									
								
								internal/cli/crypto.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										243
									
								
								internal/cli/crypto.go
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,243 @@
 | 
			
		||||
package cli
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"io"
 | 
			
		||||
	"os"
 | 
			
		||||
 | 
			
		||||
	"filippo.io/age"
 | 
			
		||||
	"git.eeqj.de/sneak/secret/internal/secret"
 | 
			
		||||
	"github.com/spf13/cobra"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func newEncryptCmd() *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.`,
 | 
			
		||||
		Args:  cobra.ExactArgs(1),
 | 
			
		||||
		RunE: func(cmd *cobra.Command, args []string) error {
 | 
			
		||||
			inputFile, _ := cmd.Flags().GetString("input")
 | 
			
		||||
			outputFile, _ := cmd.Flags().GetString("output")
 | 
			
		||||
 | 
			
		||||
			cli := NewCLIInstance()
 | 
			
		||||
			return cli.Encrypt(args[0], inputFile, outputFile)
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	cmd.Flags().StringP("input", "i", "", "Input file (default: stdin)")
 | 
			
		||||
	cmd.Flags().StringP("output", "o", "", "Output file (default: stdout)")
 | 
			
		||||
	return cmd
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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")
 | 
			
		||||
 | 
			
		||||
			cli := NewCLIInstance()
 | 
			
		||||
			return cli.Decrypt(args[0], inputFile, outputFile)
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	cmd.Flags().StringP("input", "i", "", "Input file (default: stdin)")
 | 
			
		||||
	cmd.Flags().StringP("output", "o", "", "Output file (default: stdout)")
 | 
			
		||||
	return cmd
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Encrypt encrypts data using an age secret key stored in a secret
 | 
			
		||||
func (cli *CLIInstance) Encrypt(secretName, inputFile, outputFile string) error {
 | 
			
		||||
	// Get current vault
 | 
			
		||||
	vault, err := secret.GetCurrentVault(cli.fs, cli.stateDir)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var ageSecretKey string
 | 
			
		||||
 | 
			
		||||
	// Check if secret exists
 | 
			
		||||
	secretObj := secret.NewSecret(vault, secretName)
 | 
			
		||||
	exists, err := secretObj.Exists()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return fmt.Errorf("failed to check if secret exists: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if exists {
 | 
			
		||||
		// Secret exists, get the age secret key from it
 | 
			
		||||
		var secretValue []byte
 | 
			
		||||
		if os.Getenv(secret.EnvMnemonic) != "" {
 | 
			
		||||
			secretValue, err = secretObj.GetValue(nil)
 | 
			
		||||
		} else {
 | 
			
		||||
			unlockKey, unlockErr := vault.GetCurrentUnlockKey()
 | 
			
		||||
			if unlockErr != nil {
 | 
			
		||||
				return fmt.Errorf("failed to get current unlock key: %w", unlockErr)
 | 
			
		||||
			}
 | 
			
		||||
			secretValue, err = secretObj.GetValue(unlockKey)
 | 
			
		||||
		}
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return fmt.Errorf("failed to get secret value: %w", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		ageSecretKey = string(secretValue)
 | 
			
		||||
 | 
			
		||||
		// 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)
 | 
			
		||||
		}
 | 
			
		||||
	} else {
 | 
			
		||||
		// Secret doesn't exist, generate a new age secret key
 | 
			
		||||
		identity, err := age.GenerateX25519Identity()
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return fmt.Errorf("failed to generate age secret key: %w", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		ageSecretKey = identity.String()
 | 
			
		||||
 | 
			
		||||
		// Store the new secret
 | 
			
		||||
		if err := vault.AddSecret(secretName, []byte(ageSecretKey), false); err != nil {
 | 
			
		||||
			return fmt.Errorf("failed to store age secret key: %w", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		fmt.Fprintf(os.Stderr, "Generated new age secret key and stored in secret '%s'\n", secretName)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Parse the age secret key to get the identity
 | 
			
		||||
	identity, err := age.ParseX25519Identity(ageSecretKey)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return fmt.Errorf("failed to parse age secret key: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Get the recipient (public key) for encryption
 | 
			
		||||
	recipient := identity.Recipient()
 | 
			
		||||
 | 
			
		||||
	// 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 file.Close()
 | 
			
		||||
		input = file
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Set up output writer
 | 
			
		||||
	var output io.Writer = os.Stdout
 | 
			
		||||
	if outputFile != "" {
 | 
			
		||||
		file, err := cli.fs.Create(outputFile)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return fmt.Errorf("failed to create output file: %w", err)
 | 
			
		||||
		}
 | 
			
		||||
		defer file.Close()
 | 
			
		||||
		output = file
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Encrypt the data
 | 
			
		||||
	encryptor, err := age.Encrypt(output, recipient)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return fmt.Errorf("failed to create age encryptor: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if _, err := io.Copy(encryptor, input); err != nil {
 | 
			
		||||
		return fmt.Errorf("failed to encrypt data: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if err := encryptor.Close(); err != nil {
 | 
			
		||||
		return fmt.Errorf("failed to finalize encryption: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Decrypt decrypts data using an age secret key stored in a secret
 | 
			
		||||
func (cli *CLIInstance) Decrypt(secretName, inputFile, outputFile string) error {
 | 
			
		||||
	// Get current vault
 | 
			
		||||
	vault, err := secret.GetCurrentVault(cli.fs, cli.stateDir)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Check if secret exists
 | 
			
		||||
	secretObj := secret.NewSecret(vault, 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)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Get the age secret key from the secret
 | 
			
		||||
	var secretValue []byte
 | 
			
		||||
	if os.Getenv(secret.EnvMnemonic) != "" {
 | 
			
		||||
		secretValue, err = secretObj.GetValue(nil)
 | 
			
		||||
	} else {
 | 
			
		||||
		unlockKey, unlockErr := vault.GetCurrentUnlockKey()
 | 
			
		||||
		if unlockErr != nil {
 | 
			
		||||
			return fmt.Errorf("failed to get current unlock key: %w", unlockErr)
 | 
			
		||||
		}
 | 
			
		||||
		secretValue, err = secretObj.GetValue(unlockKey)
 | 
			
		||||
	}
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return fmt.Errorf("failed to get secret value: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	ageSecretKey := string(secretValue)
 | 
			
		||||
 | 
			
		||||
	// 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 age secret key to get the identity
 | 
			
		||||
	identity, err := age.ParseX25519Identity(ageSecretKey)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return fmt.Errorf("failed to parse age secret key: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// 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 file.Close()
 | 
			
		||||
		input = file
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Set up output writer
 | 
			
		||||
	var output io.Writer = os.Stdout
 | 
			
		||||
	if outputFile != "" {
 | 
			
		||||
		file, err := cli.fs.Create(outputFile)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return fmt.Errorf("failed to create output file: %w", err)
 | 
			
		||||
		}
 | 
			
		||||
		defer file.Close()
 | 
			
		||||
		output = file
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Decrypt the data
 | 
			
		||||
	decryptor, err := age.Decrypt(input, identity)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return fmt.Errorf("failed to create age decryptor: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if _, err := io.Copy(output, decryptor); 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
 | 
			
		||||
func isValidAgeSecretKey(key string) bool {
 | 
			
		||||
	_, err := age.ParseX25519Identity(key)
 | 
			
		||||
	return err == nil
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										162
									
								
								internal/cli/generate.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										162
									
								
								internal/cli/generate.go
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,162 @@
 | 
			
		||||
package cli
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"crypto/rand"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"math/big"
 | 
			
		||||
	"os"
 | 
			
		||||
 | 
			
		||||
	"git.eeqj.de/sneak/secret/internal/secret"
 | 
			
		||||
	"github.com/spf13/cobra"
 | 
			
		||||
	"github.com/tyler-smith/go-bip39"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func newGenerateCmd() *cobra.Command {
 | 
			
		||||
	cmd := &cobra.Command{
 | 
			
		||||
		Use:   "generate",
 | 
			
		||||
		Short: "Generate random data",
 | 
			
		||||
		Long:  `Generate various types of random data including mnemonics and secrets.`,
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	cmd.AddCommand(newGenerateMnemonicCmd())
 | 
			
		||||
	cmd.AddCommand(newGenerateSecretCmd())
 | 
			
		||||
 | 
			
		||||
	return cmd
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func newGenerateMnemonicCmd() *cobra.Command {
 | 
			
		||||
	return &cobra.Command{
 | 
			
		||||
		Use:   "mnemonic",
 | 
			
		||||
		Short: "Generate a random BIP39 mnemonic phrase",
 | 
			
		||||
		Long:  `Generate a cryptographically secure random BIP39 mnemonic phrase that can be used with 'secret init' or 'secret import'.`,
 | 
			
		||||
		RunE: func(cmd *cobra.Command, args []string) error {
 | 
			
		||||
			cli := NewCLIInstance()
 | 
			
		||||
			return cli.GenerateMnemonic()
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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),
 | 
			
		||||
		RunE: func(cmd *cobra.Command, args []string) error {
 | 
			
		||||
			length, _ := cmd.Flags().GetInt("length")
 | 
			
		||||
			secretType, _ := cmd.Flags().GetString("type")
 | 
			
		||||
			force, _ := cmd.Flags().GetBool("force")
 | 
			
		||||
 | 
			
		||||
			cli := NewCLIInstance()
 | 
			
		||||
			return cli.GenerateSecret(args[0], length, secretType, force)
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	cmd.Flags().IntP("length", "l", 16, "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
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// GenerateMnemonic generates a random BIP39 mnemonic phrase
 | 
			
		||||
func (cli *CLIInstance) GenerateMnemonic() error {
 | 
			
		||||
	// Generate 128 bits of entropy for a 12-word mnemonic
 | 
			
		||||
	entropy, err := bip39.NewEntropy(128)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return fmt.Errorf("failed to generate entropy: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Create mnemonic from entropy
 | 
			
		||||
	mnemonic, err := bip39.NewMnemonic(entropy)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return fmt.Errorf("failed to generate mnemonic: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Output mnemonic to stdout
 | 
			
		||||
	fmt.Println(mnemonic)
 | 
			
		||||
 | 
			
		||||
	// Output helpful information to stderr
 | 
			
		||||
	fmt.Fprintln(os.Stderr, "")
 | 
			
		||||
	fmt.Fprintln(os.Stderr, "⚠️  IMPORTANT: Save this mnemonic phrase securely!")
 | 
			
		||||
	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, "")
 | 
			
		||||
	fmt.Fprintln(os.Stderr, "Use this mnemonic with:")
 | 
			
		||||
	fmt.Fprintln(os.Stderr, "   secret init    (to initialize a new secret manager)")
 | 
			
		||||
	fmt.Fprintln(os.Stderr, "   secret import  (to import into an existing vault)")
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// GenerateSecret generates a random secret and stores it in the vault
 | 
			
		||||
func (cli *CLIInstance) GenerateSecret(secretName string, length int, secretType string, force bool) error {
 | 
			
		||||
	if length < 1 {
 | 
			
		||||
		return fmt.Errorf("length must be at least 1")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var secretValue string
 | 
			
		||||
	var err error
 | 
			
		||||
 | 
			
		||||
	switch secretType {
 | 
			
		||||
	case "base58":
 | 
			
		||||
		secretValue, err = generateRandomBase58(length)
 | 
			
		||||
	case "alnum":
 | 
			
		||||
		secretValue, err = generateRandomAlnum(length)
 | 
			
		||||
	case "mnemonic":
 | 
			
		||||
		return fmt.Errorf("mnemonic type not supported for secret generation, use 'secret generate mnemonic' instead")
 | 
			
		||||
	default:
 | 
			
		||||
		return fmt.Errorf("unsupported type: %s (supported: base58, alnum)", secretType)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return fmt.Errorf("failed to generate random secret: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Store the secret in the vault
 | 
			
		||||
	vault, err := secret.GetCurrentVault(cli.fs, cli.stateDir)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if err := vault.AddSecret(secretName, []byte(secretValue), force); err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	fmt.Printf("Generated and stored %d-character %s secret: %s\n", length, secretType, secretName)
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// generateRandomBase58 generates a random base58 string of the specified length
 | 
			
		||||
func generateRandomBase58(length int) (string, error) {
 | 
			
		||||
	const base58Chars = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
 | 
			
		||||
	return generateRandomString(length, base58Chars)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// generateRandomAlnum generates a random alphanumeric string of the specified length
 | 
			
		||||
func generateRandomAlnum(length int) (string, error) {
 | 
			
		||||
	const alnumChars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
 | 
			
		||||
	return generateRandomString(length, alnumChars)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// 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")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	result := make([]byte, length)
 | 
			
		||||
	charsetLen := big.NewInt(int64(len(charset)))
 | 
			
		||||
 | 
			
		||||
	for i := 0; i < length; i++ {
 | 
			
		||||
		randomIndex, err := rand.Int(rand.Reader, charsetLen)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return "", fmt.Errorf("failed to generate random number: %w", err)
 | 
			
		||||
		}
 | 
			
		||||
		result[i] = charset[randomIndex.Int64()]
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return string(result), nil
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										216
									
								
								internal/cli/init.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										216
									
								
								internal/cli/init.go
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,216 @@
 | 
			
		||||
package cli
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"log/slog"
 | 
			
		||||
	"os"
 | 
			
		||||
	"path/filepath"
 | 
			
		||||
	"strings"
 | 
			
		||||
	"syscall"
 | 
			
		||||
 | 
			
		||||
	"filippo.io/age"
 | 
			
		||||
	"git.eeqj.de/sneak/secret/internal/secret"
 | 
			
		||||
	"git.eeqj.de/sneak/secret/pkg/agehd"
 | 
			
		||||
	"github.com/spf13/afero"
 | 
			
		||||
	"github.com/spf13/cobra"
 | 
			
		||||
	"github.com/tyler-smith/go-bip39"
 | 
			
		||||
	"golang.org/x/term"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
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: func(cmd *cobra.Command, args []string) error {
 | 
			
		||||
			cli := NewCLIInstance()
 | 
			
		||||
			return cli.Init(cmd)
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Init initializes the secrets manager
 | 
			
		||||
func (cli *CLIInstance) Init(cmd *cobra.Command) error {
 | 
			
		||||
	secret.Debug("Starting secret manager initialization")
 | 
			
		||||
 | 
			
		||||
	// Create state directory
 | 
			
		||||
	stateDir := cli.GetStateDir()
 | 
			
		||||
	secret.DebugWith("Creating state directory", slog.String("path", stateDir))
 | 
			
		||||
 | 
			
		||||
	if err := cli.fs.MkdirAll(stateDir, 0700); err != nil {
 | 
			
		||||
		secret.Debug("Failed to create state directory", "error", err)
 | 
			
		||||
		return fmt.Errorf("failed to create state directory: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if cmd != nil {
 | 
			
		||||
		cmd.Printf("Initialized secrets manager at: %s\n", stateDir)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// 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 from stdin using shared line reader
 | 
			
		||||
		var err error
 | 
			
		||||
		mnemonicStr, err = readLineFromStdin("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)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if mnemonicStr == "" {
 | 
			
		||||
		secret.Debug("Empty mnemonic provided")
 | 
			
		||||
		return fmt.Errorf("mnemonic cannot be empty")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Validate the mnemonic using BIP39
 | 
			
		||||
	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")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Derive long-term keypair from mnemonic
 | 
			
		||||
	secret.DebugWith("Deriving long-term key from mnemonic", slog.Int("index", 0))
 | 
			
		||||
	ltIdentity, err := agehd.DeriveIdentity(mnemonicStr, 0)
 | 
			
		||||
	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)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Create default vault
 | 
			
		||||
	secret.Debug("Creating default vault")
 | 
			
		||||
	vault, err := secret.CreateVault(cli.fs, cli.stateDir, "default")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		secret.Debug("Failed to create default vault", "error", err)
 | 
			
		||||
		return fmt.Errorf("failed to create default vault: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Set default vault as current
 | 
			
		||||
	secret.Debug("Setting default vault as current")
 | 
			
		||||
	if err := secret.SelectVault(cli.fs, cli.stateDir, "default"); err != nil {
 | 
			
		||||
		secret.Debug("Failed to select default vault", "error", err)
 | 
			
		||||
		return fmt.Errorf("failed to select default vault: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Store long-term public key in vault
 | 
			
		||||
	vaultDir := filepath.Join(stateDir, "vaults.d", "default")
 | 
			
		||||
	ltPubKey := ltIdentity.Recipient().String()
 | 
			
		||||
	secret.DebugWith("Storing long-term public key", slog.String("pubkey", ltPubKey), slog.String("vault_dir", vaultDir))
 | 
			
		||||
	if err := afero.WriteFile(cli.fs, filepath.Join(vaultDir, "pub.age"), []byte(ltPubKey), 0600); err != nil {
 | 
			
		||||
		secret.Debug("Failed to write long-term public key", "error", err)
 | 
			
		||||
		return fmt.Errorf("failed to write long-term public key: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Unlock the vault with the derived long-term key
 | 
			
		||||
	vault.Unlock(ltIdentity)
 | 
			
		||||
 | 
			
		||||
	// Prompt for passphrase for unlock key
 | 
			
		||||
	var passphraseStr string
 | 
			
		||||
	if envPassphrase := os.Getenv(secret.EnvUnlockPassphrase); envPassphrase != "" {
 | 
			
		||||
		secret.Debug("Using unlock passphrase from environment variable")
 | 
			
		||||
		passphraseStr = envPassphrase
 | 
			
		||||
	} else {
 | 
			
		||||
		secret.Debug("Prompting user for unlock passphrase")
 | 
			
		||||
		// Use secure passphrase input with confirmation
 | 
			
		||||
		passphraseStr, err = readSecurePassphrase("Enter passphrase for unlock key: ")
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			secret.Debug("Failed to read unlock passphrase", "error", err)
 | 
			
		||||
			return fmt.Errorf("failed to read passphrase: %w", err)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Create passphrase-protected unlock key
 | 
			
		||||
	secret.Debug("Creating passphrase-protected unlock key")
 | 
			
		||||
	passphraseKey, err := vault.CreatePassphraseKey(passphraseStr)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		secret.Debug("Failed to create unlock key", "error", err)
 | 
			
		||||
		return fmt.Errorf("failed to create unlock key: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Encrypt long-term private key to the unlock key
 | 
			
		||||
	unlockKeyDir := passphraseKey.GetDirectory()
 | 
			
		||||
 | 
			
		||||
	// Read unlock key public key
 | 
			
		||||
	unlockPubKeyData, err := afero.ReadFile(cli.fs, filepath.Join(unlockKeyDir, "pub.age"))
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return fmt.Errorf("failed to read unlock key public key: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	unlockRecipient, err := age.ParseX25519Recipient(string(unlockPubKeyData))
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return fmt.Errorf("failed to parse unlock key public key: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Encrypt long-term private key to unlock key
 | 
			
		||||
	ltPrivKeyData := []byte(ltIdentity.String())
 | 
			
		||||
	encryptedLtPrivKey, err := secret.EncryptToRecipient(ltPrivKeyData, unlockRecipient)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return fmt.Errorf("failed to encrypt long-term private key: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Write encrypted long-term private key
 | 
			
		||||
	if err := afero.WriteFile(cli.fs, filepath.Join(unlockKeyDir, "longterm.age"), encryptedLtPrivKey, 0600); err != nil {
 | 
			
		||||
		return fmt.Errorf("failed to write encrypted long-term private key: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if cmd != nil {
 | 
			
		||||
		cmd.Printf("\nDefault vault created and configured\n")
 | 
			
		||||
		cmd.Printf("Long-term public key: %s\n", ltPubKey)
 | 
			
		||||
		cmd.Printf("Unlock key ID: %s\n", passphraseKey.GetMetadata().ID)
 | 
			
		||||
		cmd.Println("\nYour secret manager is ready to use!")
 | 
			
		||||
		cmd.Println("Note: When using SB_SECRET_MNEMONIC environment variable,")
 | 
			
		||||
		cmd.Println("unlock keys are not required for secret operations.")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// readSecurePassphrase reads a passphrase securely from the terminal without echoing
 | 
			
		||||
// and prompts for confirmation. Falls back to regular input when not on a terminal.
 | 
			
		||||
func readSecurePassphrase(prompt string) (string, error) {
 | 
			
		||||
	// Check if stdin is a terminal
 | 
			
		||||
	if !term.IsTerminal(int(syscall.Stdin)) {
 | 
			
		||||
		// Not a terminal - never read passphrases from piped input for security reasons
 | 
			
		||||
		return "", fmt.Errorf("cannot read passphrase from non-terminal stdin (piped input or script). Please set the SB_UNLOCK_PASSPHRASE environment variable or run interactively")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Check if stderr is a terminal - if not, we can't prompt interactively
 | 
			
		||||
	if !term.IsTerminal(int(syscall.Stderr)) {
 | 
			
		||||
		return "", fmt.Errorf("cannot prompt for passphrase: stderr is not a terminal (running in non-interactive mode). Please set the SB_UNLOCK_PASSPHRASE environment variable")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Terminal input - use secure password reading with confirmation
 | 
			
		||||
	fmt.Fprint(os.Stderr, prompt) // Write prompt to stderr, not stdout
 | 
			
		||||
 | 
			
		||||
	// Read first passphrase
 | 
			
		||||
	passphrase1, err := term.ReadPassword(int(syscall.Stdin))
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return "", fmt.Errorf("failed to read passphrase: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
	fmt.Fprintln(os.Stderr) // Print newline to stderr since ReadPassword doesn't echo
 | 
			
		||||
 | 
			
		||||
	// Read confirmation passphrase
 | 
			
		||||
	fmt.Fprint(os.Stderr, "Confirm passphrase: ") // Write prompt to stderr, not stdout
 | 
			
		||||
	passphrase2, err := term.ReadPassword(int(syscall.Stdin))
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return "", fmt.Errorf("failed to read passphrase confirmation: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
	fmt.Fprintln(os.Stderr) // Print newline to stderr since ReadPassword doesn't echo
 | 
			
		||||
 | 
			
		||||
	// Compare passphrases
 | 
			
		||||
	if string(passphrase1) != string(passphrase2) {
 | 
			
		||||
		return "", fmt.Errorf("passphrases do not match")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if len(passphrase1) == 0 {
 | 
			
		||||
		return "", fmt.Errorf("passphrase cannot be empty")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return string(passphrase1), nil
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										323
									
								
								internal/cli/keys.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										323
									
								
								internal/cli/keys.go
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,323 @@
 | 
			
		||||
package cli
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"encoding/json"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"os"
 | 
			
		||||
	"path/filepath"
 | 
			
		||||
	"strings"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"git.eeqj.de/sneak/secret/internal/secret"
 | 
			
		||||
	"github.com/spf13/afero"
 | 
			
		||||
	"github.com/spf13/cobra"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func newKeysCmd() *cobra.Command {
 | 
			
		||||
	cmd := &cobra.Command{
 | 
			
		||||
		Use:   "keys",
 | 
			
		||||
		Short: "Manage unlock keys",
 | 
			
		||||
		Long:  `Create, list, and remove unlock keys for the current vault.`,
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	cmd.AddCommand(newKeysListCmd())
 | 
			
		||||
	cmd.AddCommand(newKeysAddCmd())
 | 
			
		||||
	cmd.AddCommand(newKeysRmCmd())
 | 
			
		||||
 | 
			
		||||
	return cmd
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func newKeysListCmd() *cobra.Command {
 | 
			
		||||
	cmd := &cobra.Command{
 | 
			
		||||
		Use:   "list",
 | 
			
		||||
		Short: "List unlock keys in the current vault",
 | 
			
		||||
		RunE: func(cmd *cobra.Command, args []string) error {
 | 
			
		||||
			jsonOutput, _ := cmd.Flags().GetBool("json")
 | 
			
		||||
 | 
			
		||||
			cli := NewCLIInstance()
 | 
			
		||||
			return cli.KeysList(jsonOutput)
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	cmd.Flags().Bool("json", false, "Output in JSON format")
 | 
			
		||||
	return cmd
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func newKeysAddCmd() *cobra.Command {
 | 
			
		||||
	cmd := &cobra.Command{
 | 
			
		||||
		Use:   "add <type>",
 | 
			
		||||
		Short: "Add a new unlock key",
 | 
			
		||||
		Long:  `Add a new unlock key of the specified type (passphrase, keychain, pgp).`,
 | 
			
		||||
		Args:  cobra.ExactArgs(1),
 | 
			
		||||
		RunE: func(cmd *cobra.Command, args []string) error {
 | 
			
		||||
			cli := NewCLIInstance()
 | 
			
		||||
			return cli.KeysAdd(args[0], cmd)
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	cmd.Flags().String("keyid", "", "GPG key ID for PGP unlock keys")
 | 
			
		||||
	return cmd
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func newKeysRmCmd() *cobra.Command {
 | 
			
		||||
	return &cobra.Command{
 | 
			
		||||
		Use:   "rm <key-id>",
 | 
			
		||||
		Short: "Remove an unlock key",
 | 
			
		||||
		Args:  cobra.ExactArgs(1),
 | 
			
		||||
		RunE: func(cmd *cobra.Command, args []string) error {
 | 
			
		||||
			cli := NewCLIInstance()
 | 
			
		||||
			return cli.KeysRemove(args[0])
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func newKeyCmd() *cobra.Command {
 | 
			
		||||
	cmd := &cobra.Command{
 | 
			
		||||
		Use:   "key",
 | 
			
		||||
		Short: "Manage current unlock key",
 | 
			
		||||
		Long:  `Select the current unlock key for operations.`,
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	cmd.AddCommand(newKeySelectSubCmd())
 | 
			
		||||
 | 
			
		||||
	return cmd
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func newKeySelectSubCmd() *cobra.Command {
 | 
			
		||||
	return &cobra.Command{
 | 
			
		||||
		Use:   "select <key-id>",
 | 
			
		||||
		Short: "Select an unlock key as current",
 | 
			
		||||
		Args:  cobra.ExactArgs(1),
 | 
			
		||||
		RunE: func(cmd *cobra.Command, args []string) error {
 | 
			
		||||
			cli := NewCLIInstance()
 | 
			
		||||
			return cli.KeySelect(args[0])
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// KeysList lists unlock keys in the current vault
 | 
			
		||||
func (cli *CLIInstance) KeysList(jsonOutput bool) error {
 | 
			
		||||
	// Get current vault
 | 
			
		||||
	vault, err := secret.GetCurrentVault(cli.fs, cli.stateDir)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Get the metadata first
 | 
			
		||||
	keyMetadataList, err := vault.ListUnlockKeys()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Load actual unlock key objects to get the proper IDs
 | 
			
		||||
	type KeyInfo struct {
 | 
			
		||||
		ID        string    `json:"id"`
 | 
			
		||||
		Type      string    `json:"type"`
 | 
			
		||||
		CreatedAt time.Time `json:"created_at"`
 | 
			
		||||
		Flags     []string  `json:"flags,omitempty"`
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var keys []KeyInfo
 | 
			
		||||
	for _, metadata := range keyMetadataList {
 | 
			
		||||
		// Create unlock key instance to get the proper ID
 | 
			
		||||
		vaultDir, err := vault.GetDirectory()
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			continue
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Find the key directory by type and created time
 | 
			
		||||
		unlockKeysDir := filepath.Join(vaultDir, "unlock.d")
 | 
			
		||||
		files, err := afero.ReadDir(cli.fs, unlockKeysDir)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			continue
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		var unlockKey secret.UnlockKey
 | 
			
		||||
		for _, file := range files {
 | 
			
		||||
			if !file.IsDir() {
 | 
			
		||||
				continue
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			keyDir := filepath.Join(unlockKeysDir, file.Name())
 | 
			
		||||
			metadataPath := filepath.Join(keyDir, "unlock-metadata.json")
 | 
			
		||||
 | 
			
		||||
			// Check if this is the right key by comparing metadata
 | 
			
		||||
			metadataBytes, err := afero.ReadFile(cli.fs, metadataPath)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				continue
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			var diskMetadata secret.UnlockKeyMetadata
 | 
			
		||||
			if err := json.Unmarshal(metadataBytes, &diskMetadata); err != nil {
 | 
			
		||||
				continue
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			// Match by type and creation time
 | 
			
		||||
			if diskMetadata.Type == metadata.Type && diskMetadata.CreatedAt.Equal(metadata.CreatedAt) {
 | 
			
		||||
				// Create the appropriate unlock key instance
 | 
			
		||||
				switch metadata.Type {
 | 
			
		||||
				case "passphrase":
 | 
			
		||||
					unlockKey = secret.NewPassphraseUnlockKey(cli.fs, keyDir, metadata)
 | 
			
		||||
				case "keychain":
 | 
			
		||||
					unlockKey = secret.NewKeychainUnlockKey(cli.fs, keyDir, metadata)
 | 
			
		||||
				case "pgp":
 | 
			
		||||
					unlockKey = secret.NewPGPUnlockKey(cli.fs, keyDir, metadata)
 | 
			
		||||
				}
 | 
			
		||||
				break
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Get the proper ID using the unlock key's ID() method
 | 
			
		||||
		var properID string
 | 
			
		||||
		if unlockKey != nil {
 | 
			
		||||
			properID = unlockKey.ID()
 | 
			
		||||
		} else {
 | 
			
		||||
			properID = metadata.ID // fallback to metadata ID
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		keyInfo := KeyInfo{
 | 
			
		||||
			ID:        properID,
 | 
			
		||||
			Type:      metadata.Type,
 | 
			
		||||
			CreatedAt: metadata.CreatedAt,
 | 
			
		||||
			Flags:     metadata.Flags,
 | 
			
		||||
		}
 | 
			
		||||
		keys = append(keys, keyInfo)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if jsonOutput {
 | 
			
		||||
		// JSON output
 | 
			
		||||
		output := map[string]interface{}{
 | 
			
		||||
			"keys": keys,
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		jsonBytes, err := json.MarshalIndent(output, "", "  ")
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return fmt.Errorf("failed to marshal JSON: %w", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		fmt.Println(string(jsonBytes))
 | 
			
		||||
	} else {
 | 
			
		||||
		// Pretty table output
 | 
			
		||||
		if len(keys) == 0 {
 | 
			
		||||
			fmt.Println("No unlock keys found in current vault.")
 | 
			
		||||
			fmt.Println("Run 'secret keys add passphrase' to create one.")
 | 
			
		||||
			return nil
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		fmt.Printf("%-18s %-12s %-20s %s\n", "KEY ID", "TYPE", "CREATED", "FLAGS")
 | 
			
		||||
		fmt.Printf("%-18s %-12s %-20s %s\n", "------", "----", "-------", "-----")
 | 
			
		||||
 | 
			
		||||
		for _, key := range keys {
 | 
			
		||||
			flags := ""
 | 
			
		||||
			if len(key.Flags) > 0 {
 | 
			
		||||
				flags = strings.Join(key.Flags, ",")
 | 
			
		||||
			}
 | 
			
		||||
			fmt.Printf("%-18s %-12s %-20s %s\n",
 | 
			
		||||
				key.ID,
 | 
			
		||||
				key.Type,
 | 
			
		||||
				key.CreatedAt.Format("2006-01-02 15:04:05"),
 | 
			
		||||
				flags)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		fmt.Printf("\nTotal: %d unlock key(s)\n", len(keys))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// KeysAdd adds a new unlock key
 | 
			
		||||
func (cli *CLIInstance) KeysAdd(keyType string, cmd *cobra.Command) error {
 | 
			
		||||
	switch keyType {
 | 
			
		||||
	case "passphrase":
 | 
			
		||||
		// Get current vault
 | 
			
		||||
		vault, err := secret.GetCurrentVault(cli.fs, cli.stateDir)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return fmt.Errorf("failed to get current vault: %w", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Try to unlock the vault if not already unlocked
 | 
			
		||||
		if vault.Locked() {
 | 
			
		||||
			_, err := vault.UnlockVault()
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				return fmt.Errorf("failed to unlock vault: %w", err)
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Check if passphrase is set in environment variable
 | 
			
		||||
		var passphraseStr string
 | 
			
		||||
		if envPassphrase := os.Getenv(secret.EnvUnlockPassphrase); envPassphrase != "" {
 | 
			
		||||
			passphraseStr = envPassphrase
 | 
			
		||||
		} else {
 | 
			
		||||
			// Use secure passphrase input with confirmation
 | 
			
		||||
			passphraseStr, err = readSecurePassphrase("Enter passphrase for unlock key: ")
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				return fmt.Errorf("failed to read passphrase: %w", err)
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		passphraseKey, err := vault.CreatePassphraseKey(passphraseStr)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return err
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		cmd.Printf("Created passphrase unlock key: %s\n", passphraseKey.GetMetadata().ID)
 | 
			
		||||
		return nil
 | 
			
		||||
 | 
			
		||||
	case "keychain":
 | 
			
		||||
		keychainKey, err := secret.CreateKeychainUnlockKey(cli.fs, cli.stateDir)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return fmt.Errorf("failed to create macOS Keychain unlock key: %w", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		cmd.Printf("Created macOS Keychain unlock key: %s\n", keychainKey.GetMetadata().ID)
 | 
			
		||||
		if keyName, err := keychainKey.GetKeychainItemName(); err == nil {
 | 
			
		||||
			cmd.Printf("Keychain Item Name: %s\n", keyName)
 | 
			
		||||
		}
 | 
			
		||||
		return nil
 | 
			
		||||
 | 
			
		||||
	case "pgp":
 | 
			
		||||
		// Get GPG key ID from flag or environment variable
 | 
			
		||||
		var gpgKeyID string
 | 
			
		||||
		if flagKeyID, _ := cmd.Flags().GetString("keyid"); flagKeyID != "" {
 | 
			
		||||
			gpgKeyID = flagKeyID
 | 
			
		||||
		} else if envKeyID := os.Getenv(secret.EnvGPGKeyID); envKeyID != "" {
 | 
			
		||||
			gpgKeyID = envKeyID
 | 
			
		||||
		} else {
 | 
			
		||||
			return fmt.Errorf("GPG key ID required: use --keyid flag or set SB_GPG_KEY_ID environment variable")
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		pgpKey, err := secret.CreatePGPUnlockKey(cli.fs, cli.stateDir, gpgKeyID)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return err
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		cmd.Printf("Created PGP unlock key: %s\n", pgpKey.GetMetadata().ID)
 | 
			
		||||
		cmd.Printf("GPG Key ID: %s\n", gpgKeyID)
 | 
			
		||||
		return nil
 | 
			
		||||
 | 
			
		||||
	default:
 | 
			
		||||
		return fmt.Errorf("unsupported key type: %s (supported: passphrase, keychain, pgp)", keyType)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// KeysRemove removes an unlock key
 | 
			
		||||
func (cli *CLIInstance) KeysRemove(keyID string) error {
 | 
			
		||||
	// Get current vault
 | 
			
		||||
	vault, err := secret.GetCurrentVault(cli.fs, cli.stateDir)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return vault.RemoveUnlockKey(keyID)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// KeySelect selects an unlock key as current
 | 
			
		||||
func (cli *CLIInstance) KeySelect(keyID string) error {
 | 
			
		||||
	// Get current vault
 | 
			
		||||
	vault, err := secret.GetCurrentVault(cli.fs, cli.stateDir)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return vault.SelectUnlockKey(keyID)
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										46
									
								
								internal/cli/root.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								internal/cli/root.go
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,46 @@
 | 
			
		||||
package cli
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"os"
 | 
			
		||||
 | 
			
		||||
	"git.eeqj.de/sneak/secret/internal/secret"
 | 
			
		||||
	"github.com/spf13/cobra"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// CLIEntry is the entry point for the secret CLI application
 | 
			
		||||
func CLIEntry() {
 | 
			
		||||
	secret.Debug("CLIEntry starting - debug output is working")
 | 
			
		||||
	cmd := newRootCmd()
 | 
			
		||||
	if err := cmd.Execute(); 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.`,
 | 
			
		||||
		// Ensure usage is shown after errors
 | 
			
		||||
		SilenceUsage:  false,
 | 
			
		||||
		SilenceErrors: false,
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	secret.Debug("Adding subcommands to root command")
 | 
			
		||||
	// Add subcommands
 | 
			
		||||
	cmd.AddCommand(newInitCmd())
 | 
			
		||||
	cmd.AddCommand(newGenerateCmd())
 | 
			
		||||
	cmd.AddCommand(newVaultCmd())
 | 
			
		||||
	cmd.AddCommand(newAddCmd())
 | 
			
		||||
	cmd.AddCommand(newGetCmd())
 | 
			
		||||
	cmd.AddCommand(newListCmd())
 | 
			
		||||
	cmd.AddCommand(newKeysCmd())
 | 
			
		||||
	cmd.AddCommand(newKeyCmd())
 | 
			
		||||
	cmd.AddCommand(newImportCmd())
 | 
			
		||||
	cmd.AddCommand(newEncryptCmd())
 | 
			
		||||
	cmd.AddCommand(newDecryptCmd())
 | 
			
		||||
 | 
			
		||||
	secret.Debug("newRootCmd completed")
 | 
			
		||||
	return cmd
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										269
									
								
								internal/cli/secrets.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										269
									
								
								internal/cli/secrets.go
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,269 @@
 | 
			
		||||
package cli
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"encoding/json"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"io"
 | 
			
		||||
	"os"
 | 
			
		||||
	"strings"
 | 
			
		||||
 | 
			
		||||
	"git.eeqj.de/sneak/secret/internal/secret"
 | 
			
		||||
	"github.com/spf13/afero"
 | 
			
		||||
	"github.com/spf13/cobra"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func newAddCmd() *cobra.Command {
 | 
			
		||||
	cmd := &cobra.Command{
 | 
			
		||||
		Use:   "add <secret-name>",
 | 
			
		||||
		Short: "Add a secret to the vault",
 | 
			
		||||
		Long:  `Add a secret to the current vault. The secret value is read from stdin.`,
 | 
			
		||||
		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)
 | 
			
		||||
 | 
			
		||||
			cli := NewCLIInstance()
 | 
			
		||||
			secret.Debug("Created CLI instance, calling AddSecret")
 | 
			
		||||
			return cli.AddSecret(args[0], force)
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	cmd.Flags().BoolP("force", "f", false, "Overwrite existing secret")
 | 
			
		||||
	return cmd
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func newGetCmd() *cobra.Command {
 | 
			
		||||
	return &cobra.Command{
 | 
			
		||||
		Use:   "get <secret-name>",
 | 
			
		||||
		Short: "Retrieve a secret from the vault",
 | 
			
		||||
		Args:  cobra.ExactArgs(1),
 | 
			
		||||
		RunE: func(cmd *cobra.Command, args []string) error {
 | 
			
		||||
			cli := NewCLIInstance()
 | 
			
		||||
			return cli.GetSecret(args[0])
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func newListCmd() *cobra.Command {
 | 
			
		||||
	cmd := &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),
 | 
			
		||||
		RunE: func(cmd *cobra.Command, args []string) error {
 | 
			
		||||
			jsonOutput, _ := cmd.Flags().GetBool("json")
 | 
			
		||||
 | 
			
		||||
			var filter string
 | 
			
		||||
			if len(args) > 0 {
 | 
			
		||||
				filter = args[0]
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			cli := NewCLIInstance()
 | 
			
		||||
			return cli.ListSecrets(jsonOutput, filter)
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	cmd.Flags().Bool("json", false, "Output in JSON format")
 | 
			
		||||
	return cmd
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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),
 | 
			
		||||
		RunE: func(cmd *cobra.Command, args []string) error {
 | 
			
		||||
			sourceFile, _ := cmd.Flags().GetString("source")
 | 
			
		||||
			force, _ := cmd.Flags().GetBool("force")
 | 
			
		||||
 | 
			
		||||
			cli := NewCLIInstance()
 | 
			
		||||
			return cli.ImportSecret(args[0], sourceFile, force)
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	cmd.Flags().StringP("source", "s", "", "Source file to import from (required)")
 | 
			
		||||
	cmd.Flags().BoolP("force", "f", false, "Overwrite existing secret")
 | 
			
		||||
	_ = cmd.MarkFlagRequired("source")
 | 
			
		||||
	return cmd
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// AddSecret adds a secret to the vault
 | 
			
		||||
func (cli *CLIInstance) AddSecret(secretName string, force bool) error {
 | 
			
		||||
	secret.Debug("CLI AddSecret starting", "secret_name", secretName, "force", force)
 | 
			
		||||
 | 
			
		||||
	// Get current vault
 | 
			
		||||
	secret.Debug("Getting current vault")
 | 
			
		||||
	vault, err := secret.GetCurrentVault(cli.fs, cli.stateDir)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		secret.Debug("Failed to get current vault", "error", err)
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
	secret.Debug("Got current vault", "vault_name", vault.Name)
 | 
			
		||||
 | 
			
		||||
	// Read secret value from stdin
 | 
			
		||||
	secret.Debug("Reading secret value from stdin")
 | 
			
		||||
	value, err := io.ReadAll(os.Stdin)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		secret.Debug("Failed to read secret from stdin", "error", err)
 | 
			
		||||
		return fmt.Errorf("failed to read secret from stdin: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
	secret.Debug("Read secret value from stdin", "value_length", len(value))
 | 
			
		||||
 | 
			
		||||
	// Remove trailing newline if present
 | 
			
		||||
	if len(value) > 0 && value[len(value)-1] == '\n' {
 | 
			
		||||
		value = value[:len(value)-1]
 | 
			
		||||
		secret.Debug("Removed trailing newline", "new_length", len(value))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	secret.Debug("Calling vault.AddSecret", "secret_name", secretName, "value_length", len(value), "force", force)
 | 
			
		||||
	err = vault.AddSecret(secretName, value, force)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		secret.Debug("vault.AddSecret failed", "error", err)
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
	secret.Debug("vault.AddSecret completed successfully")
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// GetSecret retrieves a secret from the vault
 | 
			
		||||
func (cli *CLIInstance) GetSecret(secretName string) error {
 | 
			
		||||
	// Get current vault
 | 
			
		||||
	vault, err := secret.GetCurrentVault(cli.fs, cli.stateDir)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Get the secret value using the vault's GetSecret method
 | 
			
		||||
	// This handles the per-secret key architecture internally
 | 
			
		||||
	value, err := vault.GetSecret(secretName)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	fmt.Print(string(value))
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// ListSecrets lists all secrets in the current vault
 | 
			
		||||
func (cli *CLIInstance) ListSecrets(jsonOutput bool, filter string) error {
 | 
			
		||||
	// Get current vault
 | 
			
		||||
	vault, err := secret.GetCurrentVault(cli.fs, cli.stateDir)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	secrets, err := vault.ListSecrets()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Filter secrets if filter is provided
 | 
			
		||||
	var filteredSecrets []string
 | 
			
		||||
	if filter != "" {
 | 
			
		||||
		for _, secretName := range secrets {
 | 
			
		||||
			if strings.Contains(secretName, filter) {
 | 
			
		||||
				filteredSecrets = append(filteredSecrets, secretName)
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	} else {
 | 
			
		||||
		filteredSecrets = secrets
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if jsonOutput {
 | 
			
		||||
		// 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 := vault.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.Println(string(jsonBytes))
 | 
			
		||||
	} else {
 | 
			
		||||
		// Pretty table output
 | 
			
		||||
		if len(filteredSecrets) == 0 {
 | 
			
		||||
			if filter != "" {
 | 
			
		||||
				fmt.Printf("No secrets found in vault '%s' matching filter '%s'.\n", vault.Name, filter)
 | 
			
		||||
			} else {
 | 
			
		||||
				fmt.Println("No secrets found in current vault.")
 | 
			
		||||
				fmt.Println("Run 'secret add <name>' to create one.")
 | 
			
		||||
			}
 | 
			
		||||
			return nil
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Get current vault name for display
 | 
			
		||||
		if filter != "" {
 | 
			
		||||
			fmt.Printf("Secrets in vault '%s' matching '%s':\n\n", vault.Name, filter)
 | 
			
		||||
		} else {
 | 
			
		||||
			fmt.Printf("Secrets in vault '%s':\n\n", vault.Name)
 | 
			
		||||
		}
 | 
			
		||||
		fmt.Printf("%-40s %-20s\n", "NAME", "LAST UPDATED")
 | 
			
		||||
		fmt.Printf("%-40s %-20s\n", "----", "------------")
 | 
			
		||||
 | 
			
		||||
		for _, secretName := range filteredSecrets {
 | 
			
		||||
			lastUpdated := "unknown"
 | 
			
		||||
			if secretObj, err := vault.GetSecretObject(secretName); err == nil {
 | 
			
		||||
				metadata := secretObj.GetMetadata()
 | 
			
		||||
				lastUpdated = metadata.UpdatedAt.Format("2006-01-02 15:04")
 | 
			
		||||
			}
 | 
			
		||||
			fmt.Printf("%-40s %-20s\n", secretName, lastUpdated)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		fmt.Printf("\nTotal: %d secret(s)", len(filteredSecrets))
 | 
			
		||||
		if filter != "" {
 | 
			
		||||
			fmt.Printf(" (filtered from %d)", len(secrets))
 | 
			
		||||
		}
 | 
			
		||||
		fmt.Println()
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// ImportSecret imports a secret from a file
 | 
			
		||||
func (cli *CLIInstance) ImportSecret(secretName, sourceFile string, force bool) error {
 | 
			
		||||
	// Get current vault
 | 
			
		||||
	vault, err := secret.GetCurrentVault(cli.fs, cli.stateDir)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Read secret value from the source file
 | 
			
		||||
	value, err := afero.ReadFile(cli.fs, sourceFile)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return fmt.Errorf("failed to read secret from file %s: %w", sourceFile, err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Store the secret in the vault
 | 
			
		||||
	if err := vault.AddSecret(secretName, value, force); err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	fmt.Printf("Successfully imported secret '%s' from file '%s'\n", secretName, sourceFile)
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										248
									
								
								internal/cli/vault.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										248
									
								
								internal/cli/vault.go
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,248 @@
 | 
			
		||||
package cli
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"encoding/json"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"os"
 | 
			
		||||
	"path/filepath"
 | 
			
		||||
	"syscall"
 | 
			
		||||
 | 
			
		||||
	"git.eeqj.de/sneak/secret/internal/secret"
 | 
			
		||||
	"git.eeqj.de/sneak/secret/pkg/agehd"
 | 
			
		||||
	"github.com/spf13/afero"
 | 
			
		||||
	"github.com/spf13/cobra"
 | 
			
		||||
	"github.com/tyler-smith/go-bip39"
 | 
			
		||||
	"golang.org/x/term"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func newVaultCmd() *cobra.Command {
 | 
			
		||||
	cmd := &cobra.Command{
 | 
			
		||||
		Use:   "vault",
 | 
			
		||||
		Short: "Manage vaults",
 | 
			
		||||
		Long:  `Create, list, and select vaults for organizing secrets.`,
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	cmd.AddCommand(newVaultListCmd())
 | 
			
		||||
	cmd.AddCommand(newVaultCreateCmd())
 | 
			
		||||
	cmd.AddCommand(newVaultSelectCmd())
 | 
			
		||||
	cmd.AddCommand(newVaultImportCmd())
 | 
			
		||||
 | 
			
		||||
	return cmd
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func newVaultListCmd() *cobra.Command {
 | 
			
		||||
	cmd := &cobra.Command{
 | 
			
		||||
		Use:   "list",
 | 
			
		||||
		Short: "List available vaults",
 | 
			
		||||
		RunE: func(cmd *cobra.Command, args []string) error {
 | 
			
		||||
			jsonOutput, _ := cmd.Flags().GetBool("json")
 | 
			
		||||
 | 
			
		||||
			cli := NewCLIInstance()
 | 
			
		||||
			return cli.VaultList(jsonOutput)
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	cmd.Flags().Bool("json", false, "Output in JSON format")
 | 
			
		||||
	return cmd
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func newVaultCreateCmd() *cobra.Command {
 | 
			
		||||
	return &cobra.Command{
 | 
			
		||||
		Use:   "create <name>",
 | 
			
		||||
		Short: "Create a new vault",
 | 
			
		||||
		Args:  cobra.ExactArgs(1),
 | 
			
		||||
		RunE: func(cmd *cobra.Command, args []string) error {
 | 
			
		||||
			cli := NewCLIInstance()
 | 
			
		||||
			return cli.VaultCreate(args[0])
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func newVaultSelectCmd() *cobra.Command {
 | 
			
		||||
	return &cobra.Command{
 | 
			
		||||
		Use:   "select <name>",
 | 
			
		||||
		Short: "Select a vault as current",
 | 
			
		||||
		Args:  cobra.ExactArgs(1),
 | 
			
		||||
		RunE: func(cmd *cobra.Command, args []string) error {
 | 
			
		||||
			cli := NewCLIInstance()
 | 
			
		||||
			return cli.VaultSelect(args[0])
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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).`,
 | 
			
		||||
		Args:  cobra.MaximumNArgs(1),
 | 
			
		||||
		RunE: func(cmd *cobra.Command, args []string) error {
 | 
			
		||||
			vaultName := "default"
 | 
			
		||||
			if len(args) > 0 {
 | 
			
		||||
				vaultName = args[0]
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			cli := NewCLIInstance()
 | 
			
		||||
			return cli.Import(vaultName)
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// VaultList lists available vaults
 | 
			
		||||
func (cli *CLIInstance) VaultList(jsonOutput bool) error {
 | 
			
		||||
	vaults, err := secret.ListVaults(cli.fs, cli.stateDir)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if jsonOutput {
 | 
			
		||||
		// JSON output
 | 
			
		||||
		output := map[string]interface{}{
 | 
			
		||||
			"vaults": vaults,
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		jsonBytes, err := json.MarshalIndent(output, "", "  ")
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return fmt.Errorf("failed to marshal JSON: %w", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		fmt.Println(string(jsonBytes))
 | 
			
		||||
	} else {
 | 
			
		||||
		// Pretty table output
 | 
			
		||||
		if len(vaults) == 0 {
 | 
			
		||||
			fmt.Println("No vaults found.")
 | 
			
		||||
			fmt.Println("Run 'secret init' to create the default vault.")
 | 
			
		||||
			return nil
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Get current vault for highlighting
 | 
			
		||||
		currentVault, err := secret.GetCurrentVault(cli.fs, cli.stateDir)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			fmt.Printf("%-20s %s\n", "VAULT", "STATUS")
 | 
			
		||||
			fmt.Printf("%-20s %s\n", "-----", "------")
 | 
			
		||||
 | 
			
		||||
			for _, vault := range vaults {
 | 
			
		||||
				fmt.Printf("%-20s %s\n", vault, "")
 | 
			
		||||
			}
 | 
			
		||||
		} else {
 | 
			
		||||
			fmt.Printf("%-20s %s\n", "VAULT", "STATUS")
 | 
			
		||||
			fmt.Printf("%-20s %s\n", "-----", "------")
 | 
			
		||||
 | 
			
		||||
			for _, vault := range vaults {
 | 
			
		||||
				status := ""
 | 
			
		||||
				if vault == currentVault.Name {
 | 
			
		||||
					status = "(current)"
 | 
			
		||||
				}
 | 
			
		||||
				fmt.Printf("%-20s %s\n", vault, status)
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		fmt.Printf("\nTotal: %d vault(s)\n", len(vaults))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// VaultCreate creates a new vault
 | 
			
		||||
func (cli *CLIInstance) VaultCreate(name string) error {
 | 
			
		||||
	_, err := secret.CreateVault(cli.fs, cli.stateDir, name)
 | 
			
		||||
	return err
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// VaultSelect selects a vault as current
 | 
			
		||||
func (cli *CLIInstance) VaultSelect(name string) error {
 | 
			
		||||
	return secret.SelectVault(cli.fs, cli.stateDir, name)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Import imports a mnemonic into a vault
 | 
			
		||||
func (cli *CLIInstance) Import(vaultName string) error {
 | 
			
		||||
	var mnemonicStr string
 | 
			
		||||
 | 
			
		||||
	// Check if mnemonic is set in environment variable
 | 
			
		||||
	if envMnemonic := os.Getenv(secret.EnvMnemonic); envMnemonic != "" {
 | 
			
		||||
		mnemonicStr = envMnemonic
 | 
			
		||||
	} else {
 | 
			
		||||
		// Read mnemonic from stdin using shared line reader
 | 
			
		||||
		var err error
 | 
			
		||||
		mnemonicStr, err = readLineFromStdin("Enter your BIP39 mnemonic phrase: ")
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return fmt.Errorf("failed to read mnemonic: %w", err)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if mnemonicStr == "" {
 | 
			
		||||
		return fmt.Errorf("mnemonic cannot be empty")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Validate the mnemonic using BIP39
 | 
			
		||||
	if !bip39.IsMnemonicValid(mnemonicStr) {
 | 
			
		||||
		return fmt.Errorf("invalid BIP39 mnemonic phrase\nRun 'secret generate mnemonic' to create a valid mnemonic")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return cli.importMnemonic(vaultName, mnemonicStr)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// importMnemonic imports a BIP39 mnemonic into the specified vault
 | 
			
		||||
func (cli *CLIInstance) importMnemonic(vaultName, mnemonic string) error {
 | 
			
		||||
	// Derive long-term keypair from mnemonic
 | 
			
		||||
	ltIdentity, err := agehd.DeriveIdentity(mnemonic, 0)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Check if vault exists
 | 
			
		||||
	stateDir := cli.GetStateDir()
 | 
			
		||||
	vaultDir := filepath.Join(stateDir, "vaults.d", vaultName)
 | 
			
		||||
	exists, err := afero.DirExists(cli.fs, vaultDir)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return fmt.Errorf("failed to check if vault exists: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
	if !exists {
 | 
			
		||||
		return fmt.Errorf("vault %s does not exist", vaultName)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Store long-term public key in vault
 | 
			
		||||
	ltPubKey := ltIdentity.Recipient().String()
 | 
			
		||||
	if err := afero.WriteFile(cli.fs, filepath.Join(vaultDir, "pub.age"), []byte(ltPubKey), 0600); err != nil {
 | 
			
		||||
		return fmt.Errorf("failed to write long-term public key: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Get the vault instance and unlock it
 | 
			
		||||
	vault := secret.NewVault(cli.fs, vaultName, cli.stateDir)
 | 
			
		||||
	vault.Unlock(ltIdentity)
 | 
			
		||||
 | 
			
		||||
	fmt.Printf("Successfully imported mnemonic into vault '%s'\n", vaultName)
 | 
			
		||||
	fmt.Printf("Long-term public key: %s\n", ltPubKey)
 | 
			
		||||
 | 
			
		||||
	// Try to create unlock key only if running interactively
 | 
			
		||||
	if term.IsTerminal(int(syscall.Stderr)) {
 | 
			
		||||
		// Get or create passphrase for unlock key
 | 
			
		||||
		var passphraseStr string
 | 
			
		||||
		if envPassphrase := os.Getenv(secret.EnvUnlockPassphrase); envPassphrase != "" {
 | 
			
		||||
			passphraseStr = envPassphrase
 | 
			
		||||
		} else {
 | 
			
		||||
			// Use secure passphrase input with confirmation
 | 
			
		||||
			passphraseStr, err = readSecurePassphrase("Enter passphrase for unlock key: ")
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				fmt.Printf("Warning: Failed to create unlock key: %v\n", err)
 | 
			
		||||
				fmt.Printf("You can create unlock keys later with 'secret keys add passphrase'\n")
 | 
			
		||||
				return nil
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Create passphrase-protected unlock key (vault is now unlocked)
 | 
			
		||||
		passphraseKey, err := vault.CreatePassphraseKey(passphraseStr)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			fmt.Printf("Warning: Failed to create unlock key: %v\n", err)
 | 
			
		||||
			fmt.Printf("You can create unlock keys later with 'secret keys add passphrase'\n")
 | 
			
		||||
			return nil
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		fmt.Printf("Unlock key ID: %s\n", passphraseKey.GetMetadata().ID)
 | 
			
		||||
	} else {
 | 
			
		||||
		fmt.Printf("Running in non-interactive mode - unlock key not created\n")
 | 
			
		||||
		fmt.Printf("You can create unlock keys later with 'secret keys add passphrase'\n")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										12
									
								
								internal/secret/constants.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								internal/secret/constants.go
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,12 @@
 | 
			
		||||
package secret
 | 
			
		||||
 | 
			
		||||
const (
 | 
			
		||||
	// AppID is the unique identifier for this application
 | 
			
		||||
	AppID = "berlin.sneak.pkg.secret"
 | 
			
		||||
 | 
			
		||||
	// Environment variable names
 | 
			
		||||
	EnvStateDir         = "SB_SECRET_STATE_DIR"
 | 
			
		||||
	EnvMnemonic         = "SB_SECRET_MNEMONIC"
 | 
			
		||||
	EnvUnlockPassphrase = "SB_UNLOCK_PASSPHRASE"
 | 
			
		||||
	EnvGPGKeyID         = "SB_GPG_KEY_ID"
 | 
			
		||||
)
 | 
			
		||||
@ -11,8 +11,8 @@ import (
 | 
			
		||||
	"golang.org/x/term"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// encryptToRecipient encrypts data to a recipient using age
 | 
			
		||||
func encryptToRecipient(data []byte, recipient age.Recipient) ([]byte, error) {
 | 
			
		||||
// EncryptToRecipient encrypts data to a recipient using age
 | 
			
		||||
func EncryptToRecipient(data []byte, recipient age.Recipient) ([]byte, error) {
 | 
			
		||||
	Debug("encryptToRecipient starting", "data_length", len(data))
 | 
			
		||||
 | 
			
		||||
	var buf bytes.Buffer
 | 
			
		||||
@ -43,6 +43,11 @@ func encryptToRecipient(data []byte, recipient age.Recipient) ([]byte, error) {
 | 
			
		||||
	return result, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// encryptToRecipient encrypts data to a recipient using age (internal version)
 | 
			
		||||
func encryptToRecipient(data []byte, recipient age.Recipient) ([]byte, error) {
 | 
			
		||||
	return EncryptToRecipient(data, recipient)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// decryptWithIdentity decrypts data with an identity using age
 | 
			
		||||
func decryptWithIdentity(data []byte, identity age.Identity) ([]byte, error) {
 | 
			
		||||
	r, err := age.Decrypt(bytes.NewReader(data), identity)
 | 
			
		||||
@ -81,18 +86,18 @@ func decryptWithPassphrase(encryptedData []byte, passphrase string) ([]byte, err
 | 
			
		||||
// readPassphrase reads a passphrase securely from the terminal without echoing
 | 
			
		||||
// This version is for unlocking and doesn't require confirmation
 | 
			
		||||
func readPassphrase(prompt string) (string, error) {
 | 
			
		||||
	// Check if stderr is a terminal - if not, we can't prompt interactively
 | 
			
		||||
	if !term.IsTerminal(int(syscall.Stderr)) {
 | 
			
		||||
		return "", fmt.Errorf("cannot prompt for passphrase: stderr is not a terminal (running in non-interactive mode)")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Check if stdin is a terminal
 | 
			
		||||
	if !term.IsTerminal(int(syscall.Stdin)) {
 | 
			
		||||
		// Not a terminal - use shared line reader to avoid buffering conflicts
 | 
			
		||||
		return readLineFromStdin(prompt)
 | 
			
		||||
		// Not a terminal - never read passphrases from piped input for security reasons
 | 
			
		||||
		return "", fmt.Errorf("cannot read passphrase from non-terminal stdin (piped input or script). Please set the SB_UNLOCK_PASSPHRASE environment variable or run interactively")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Terminal input - use secure password reading
 | 
			
		||||
	// stdin is a terminal, check if stderr is also a terminal for interactive prompting
 | 
			
		||||
	if !term.IsTerminal(int(syscall.Stderr)) {
 | 
			
		||||
		return "", fmt.Errorf("cannot prompt for passphrase: stderr is not a terminal (running in non-interactive mode). Please set the SB_UNLOCK_PASSPHRASE environment variable")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Both stdin and stderr are terminals - use secure password reading
 | 
			
		||||
	fmt.Fprint(os.Stderr, prompt) // Write prompt to stderr, not stdout
 | 
			
		||||
	passphrase, err := term.ReadPassword(int(syscall.Stdin))
 | 
			
		||||
	if err != nil {
 | 
			
		||||
 | 
			
		||||
@ -33,7 +33,7 @@ func initDebugLogging() {
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Disable stderr buffering for immediate debug output when debugging is enabled
 | 
			
		||||
	syscall.Syscall(syscall.SYS_FCNTL, os.Stderr.Fd(), syscall.F_SETFL, syscall.O_SYNC)
 | 
			
		||||
	_, _, _ = syscall.Syscall(syscall.SYS_FCNTL, os.Stderr.Fd(), syscall.F_SETFL, syscall.O_SYNC)
 | 
			
		||||
 | 
			
		||||
	// Check if STDERR is a TTY
 | 
			
		||||
	isTTY := term.IsTerminal(int(syscall.Stderr))
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										54
									
								
								internal/secret/helpers.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								internal/secret/helpers.go
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,54 @@
 | 
			
		||||
package secret
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"crypto/rand"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"math/big"
 | 
			
		||||
	"os"
 | 
			
		||||
	"path/filepath"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// 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")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	result := make([]byte, length)
 | 
			
		||||
	charsetLen := big.NewInt(int64(len(charset)))
 | 
			
		||||
 | 
			
		||||
	for i := 0; i < length; i++ {
 | 
			
		||||
		randomIndex, err := rand.Int(rand.Reader, charsetLen)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return "", fmt.Errorf("failed to generate random number: %w", err)
 | 
			
		||||
		}
 | 
			
		||||
		result[i] = charset[randomIndex.Int64()]
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return string(result), nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// DetermineStateDir determines the state directory based on environment variables and OS
 | 
			
		||||
func DetermineStateDir(customConfigDir string) string {
 | 
			
		||||
	// Check for environment variable first
 | 
			
		||||
	if envStateDir := os.Getenv(EnvStateDir); envStateDir != "" {
 | 
			
		||||
		return envStateDir
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Use custom config dir if provided
 | 
			
		||||
	if customConfigDir != "" {
 | 
			
		||||
		return filepath.Join(customConfigDir, AppID)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Use os.UserConfigDir() which handles platform-specific directories:
 | 
			
		||||
	// - On Unix systems, it returns $XDG_CONFIG_HOME or $HOME/.config
 | 
			
		||||
	// - On Darwin, it returns $HOME/Library/Application Support
 | 
			
		||||
	// - On Windows, it returns %AppData%
 | 
			
		||||
	configDir, err := os.UserConfigDir()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		// Fallback to a reasonable default if we can't determine user config dir
 | 
			
		||||
		homeDir, _ := os.UserHomeDir()
 | 
			
		||||
		return filepath.Join(homeDir, ".config", AppID)
 | 
			
		||||
	}
 | 
			
		||||
	return filepath.Join(configDir, AppID)
 | 
			
		||||
}
 | 
			
		||||
@ -21,6 +21,7 @@ export GODEBUG="berlin.sneak.pkg.secret"
 | 
			
		||||
echo -e "${BLUE}=== Secret Manager Comprehensive Test Script ===${NC}"
 | 
			
		||||
echo -e "${YELLOW}Using temporary directory: $TEMP_DIR${NC}"
 | 
			
		||||
echo -e "${YELLOW}Debug output enabled: GODEBUG=$GODEBUG${NC}"
 | 
			
		||||
echo -e "${YELLOW}Note: All tests use environment variables (no manual input)${NC}"
 | 
			
		||||
 | 
			
		||||
# Function to print test steps
 | 
			
		||||
print_step() {
 | 
			
		||||
@ -102,7 +103,6 @@ echo "  SB_SECRET_MNEMONIC=$TEST_MNEMONIC"
 | 
			
		||||
 | 
			
		||||
# Test 2: Initialize the secret manager (should create default vault)
 | 
			
		||||
print_step "2" "Initializing secret manager (creates default vault)"
 | 
			
		||||
# Set passphrase for init command only
 | 
			
		||||
export SB_UNLOCK_PASSPHRASE="$TEST_PASSPHRASE"
 | 
			
		||||
echo "Running: $SECRET_BINARY init"
 | 
			
		||||
if $SECRET_BINARY init; then
 | 
			
		||||
@ -110,7 +110,6 @@ if $SECRET_BINARY init; then
 | 
			
		||||
else
 | 
			
		||||
    print_error "Failed to initialize secret manager"
 | 
			
		||||
fi
 | 
			
		||||
# Unset passphrase after init
 | 
			
		||||
unset SB_UNLOCK_PASSPHRASE
 | 
			
		||||
 | 
			
		||||
# Verify directory structure was created
 | 
			
		||||
@ -172,13 +171,14 @@ else
 | 
			
		||||
    print_error "Failed to switch to 'work' vault"
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
# Test 4: Import functionality with different environment variable combinations
 | 
			
		||||
print_step "4" "Testing import functionality with different environment variable combinations"
 | 
			
		||||
# Test 4: Import functionality with environment variable combinations
 | 
			
		||||
print_step "4" "Testing import functionality with environment variable combinations"
 | 
			
		||||
 | 
			
		||||
# Test 4a: Import with mnemonic env var set, no passphrase env var
 | 
			
		||||
echo -e "\n${YELLOW}Test 4a: Import with SB_SECRET_MNEMONIC set, SB_UNLOCK_PASSPHRASE unset${NC}"
 | 
			
		||||
# Test 4a: Import with both env vars set (typical usage)
 | 
			
		||||
echo -e "\n${YELLOW}Test 4a: Import with both SB_SECRET_MNEMONIC and SB_UNLOCK_PASSPHRASE set${NC}"
 | 
			
		||||
reset_state
 | 
			
		||||
export SB_SECRET_MNEMONIC="$TEST_MNEMONIC"
 | 
			
		||||
export SB_UNLOCK_PASSPHRASE="$TEST_PASSPHRASE"
 | 
			
		||||
 | 
			
		||||
# Create a vault first
 | 
			
		||||
echo "Running: $SECRET_BINARY vault create test-vault"
 | 
			
		||||
@ -188,95 +188,17 @@ else
 | 
			
		||||
    print_error "Failed to create test-vault"
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
# Import should prompt for passphrase
 | 
			
		||||
echo "Importing with mnemonic env var set, should prompt for passphrase..."
 | 
			
		||||
echo "Running: echo \"$TEST_PASSPHRASE\" | $SECRET_BINARY vault import test-vault"
 | 
			
		||||
if echo "$TEST_PASSPHRASE" | $SECRET_BINARY vault import test-vault; then
 | 
			
		||||
    print_success "Import succeeded with mnemonic env var (prompted for passphrase)"
 | 
			
		||||
else
 | 
			
		||||
    print_error "Import failed with mnemonic env var"
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
# Test 4b: Import with passphrase env var set, no mnemonic env var
 | 
			
		||||
echo -e "\n${YELLOW}Test 4b: Import with SB_UNLOCK_PASSPHRASE set, SB_SECRET_MNEMONIC unset${NC}"
 | 
			
		||||
reset_state
 | 
			
		||||
export SB_UNLOCK_PASSPHRASE="$TEST_PASSPHRASE"
 | 
			
		||||
 | 
			
		||||
# Create a vault first
 | 
			
		||||
echo "Running: $SECRET_BINARY vault create test-vault2"
 | 
			
		||||
if $SECRET_BINARY vault create test-vault2; then
 | 
			
		||||
    print_success "Created test-vault2 for import testing"
 | 
			
		||||
else
 | 
			
		||||
    print_error "Failed to create test-vault2"
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
# Import should prompt for mnemonic
 | 
			
		||||
echo "Importing with passphrase env var set, should prompt for mnemonic..."
 | 
			
		||||
echo "Running: echo \"$TEST_MNEMONIC\" | $SECRET_BINARY vault import test-vault2"
 | 
			
		||||
if echo "$TEST_MNEMONIC" | $SECRET_BINARY vault import test-vault2; then
 | 
			
		||||
    print_success "Import succeeded with passphrase env var (prompted for mnemonic)"
 | 
			
		||||
else
 | 
			
		||||
    print_error "Import failed with passphrase env var"
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
# Test 4c: Import with both env vars set
 | 
			
		||||
echo -e "\n${YELLOW}Test 4c: Import with both SB_SECRET_MNEMONIC and SB_UNLOCK_PASSPHRASE set${NC}"
 | 
			
		||||
reset_state
 | 
			
		||||
export SB_SECRET_MNEMONIC="$TEST_MNEMONIC"
 | 
			
		||||
export SB_UNLOCK_PASSPHRASE="$TEST_PASSPHRASE"
 | 
			
		||||
 | 
			
		||||
# Create a vault first
 | 
			
		||||
echo "Running: $SECRET_BINARY vault create test-vault3"
 | 
			
		||||
if $SECRET_BINARY vault create test-vault3; then
 | 
			
		||||
    print_success "Created test-vault3 for import testing"
 | 
			
		||||
else
 | 
			
		||||
    print_error "Failed to create test-vault3"
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
# Import should not prompt for anything
 | 
			
		||||
echo "Importing with both env vars set, should not prompt..."
 | 
			
		||||
echo "Running: $SECRET_BINARY vault import test-vault3"
 | 
			
		||||
if $SECRET_BINARY vault import test-vault3; then
 | 
			
		||||
    print_success "Import succeeded with both env vars (no prompts)"
 | 
			
		||||
# Import should work without prompts
 | 
			
		||||
echo "Importing with both env vars set (automated)..."
 | 
			
		||||
echo "Running: $SECRET_BINARY vault import test-vault"
 | 
			
		||||
if $SECRET_BINARY vault import test-vault; then
 | 
			
		||||
    print_success "Import succeeded with both env vars (automated)"
 | 
			
		||||
else
 | 
			
		||||
    print_error "Import failed with both env vars"
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
# Test 4d: Import with neither env var set
 | 
			
		||||
echo -e "\n${YELLOW}Test 4d: Import with neither SB_SECRET_MNEMONIC nor SB_UNLOCK_PASSPHRASE set${NC}"
 | 
			
		||||
reset_state
 | 
			
		||||
 | 
			
		||||
# Create a vault first
 | 
			
		||||
echo "Running: $SECRET_BINARY vault create test-vault4"
 | 
			
		||||
if $SECRET_BINARY vault create test-vault4; then
 | 
			
		||||
    print_success "Created test-vault4 for import testing"
 | 
			
		||||
else
 | 
			
		||||
    print_error "Failed to create test-vault4"
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
# Import should prompt for both mnemonic and passphrase
 | 
			
		||||
echo "Importing with neither env var set, should prompt for both..."
 | 
			
		||||
if expect -c "
 | 
			
		||||
    spawn $SECRET_BINARY vault import test-vault4
 | 
			
		||||
    expect \"Enter your BIP39 mnemonic phrase:\"
 | 
			
		||||
    send \"$TEST_MNEMONIC\n\"
 | 
			
		||||
    expect \"Enter passphrase for unlock key:\"
 | 
			
		||||
    send \"$TEST_PASSPHRASE\n\"
 | 
			
		||||
    expect \"Confirm passphrase:\"
 | 
			
		||||
    send \"$TEST_PASSPHRASE\n\"
 | 
			
		||||
    expect eof
 | 
			
		||||
"; then
 | 
			
		||||
    print_success "Import succeeded with no env vars (prompted for both)"
 | 
			
		||||
else
 | 
			
		||||
    print_error "Import failed with no env vars"
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
# Test 4e: Import into non-existent vault (should fail)
 | 
			
		||||
echo -e "\n${YELLOW}Test 4e: Import into non-existent vault (should fail)${NC}"
 | 
			
		||||
reset_state
 | 
			
		||||
export SB_SECRET_MNEMONIC="$TEST_MNEMONIC"
 | 
			
		||||
export SB_UNLOCK_PASSPHRASE="$TEST_PASSPHRASE"
 | 
			
		||||
 | 
			
		||||
# Test 4b: Import into non-existent vault (should fail)
 | 
			
		||||
echo -e "\n${YELLOW}Test 4b: Import into non-existent vault (should fail)${NC}"
 | 
			
		||||
echo "Importing into non-existent vault (should fail)..."
 | 
			
		||||
if $SECRET_BINARY vault import nonexistent-vault; then
 | 
			
		||||
    print_error "Import should have failed for non-existent vault"
 | 
			
		||||
@ -284,22 +206,20 @@ else
 | 
			
		||||
    print_success "Import correctly failed for non-existent vault"
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
# Test 4f: Import with invalid mnemonic (should fail)
 | 
			
		||||
echo -e "\n${YELLOW}Test 4f: Import with invalid mnemonic (should fail)${NC}"
 | 
			
		||||
reset_state
 | 
			
		||||
# Test 4c: Import with invalid mnemonic (should fail)
 | 
			
		||||
echo -e "\n${YELLOW}Test 4c: Import with invalid mnemonic (should fail)${NC}"
 | 
			
		||||
export SB_SECRET_MNEMONIC="invalid mnemonic phrase that should not work"
 | 
			
		||||
export SB_UNLOCK_PASSPHRASE="$TEST_PASSPHRASE"
 | 
			
		||||
 | 
			
		||||
# Create a vault first
 | 
			
		||||
echo "Running: $SECRET_BINARY vault create test-vault5"
 | 
			
		||||
if $SECRET_BINARY vault create test-vault5; then
 | 
			
		||||
    print_success "Created test-vault5 for invalid mnemonic testing"
 | 
			
		||||
echo "Running: $SECRET_BINARY vault create test-vault2"
 | 
			
		||||
if $SECRET_BINARY vault create test-vault2; then
 | 
			
		||||
    print_success "Created test-vault2 for invalid mnemonic testing"
 | 
			
		||||
else
 | 
			
		||||
    print_error "Failed to create test-vault5"
 | 
			
		||||
    print_error "Failed to create test-vault2"
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
echo "Importing with invalid mnemonic (should fail)..."
 | 
			
		||||
if $SECRET_BINARY vault import test-vault5; then
 | 
			
		||||
if $SECRET_BINARY vault import test-vault2; then
 | 
			
		||||
    print_error "Import should have failed with invalid mnemonic"
 | 
			
		||||
else
 | 
			
		||||
    print_success "Import correctly failed with invalid mnemonic"
 | 
			
		||||
@ -309,67 +229,26 @@ fi
 | 
			
		||||
reset_state
 | 
			
		||||
export SB_SECRET_MNEMONIC="$TEST_MNEMONIC"
 | 
			
		||||
 | 
			
		||||
# Test 5: Original import functionality test (using mnemonic-based)
 | 
			
		||||
print_step "5" "Testing original import functionality"
 | 
			
		||||
# Test 5: Unlock key management
 | 
			
		||||
print_step "5" "Testing unlock key management"
 | 
			
		||||
 | 
			
		||||
# Initialize to create default vault
 | 
			
		||||
if (echo "$TEST_PASSPHRASE"; echo "$TEST_PASSPHRASE") | $SECRET_BINARY init; then
 | 
			
		||||
    print_success "Initialized for Step 5 testing"
 | 
			
		||||
else
 | 
			
		||||
    print_error "Failed to initialize for Step 5 testing"
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
# Create work vault for import testing
 | 
			
		||||
echo "Running: $SECRET_BINARY vault create work"
 | 
			
		||||
if $SECRET_BINARY vault create work; then
 | 
			
		||||
    print_success "Created work vault for import testing"
 | 
			
		||||
else
 | 
			
		||||
    print_error "Failed to create work vault"
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
# Switch to work vault
 | 
			
		||||
echo "Switching to 'work' vault..."
 | 
			
		||||
echo "Running: $SECRET_BINARY vault select work"
 | 
			
		||||
if $SECRET_BINARY vault select work; then
 | 
			
		||||
    print_success "Switched to 'work' vault"
 | 
			
		||||
else
 | 
			
		||||
    print_error "Failed to switch to 'work' vault"
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
# Import into work vault using mnemonic (should derive long-term key)
 | 
			
		||||
echo "Importing mnemonic into 'work' vault..."
 | 
			
		||||
# Set passphrase for import command only
 | 
			
		||||
export SB_UNLOCK_PASSPHRASE="$TEST_PASSPHRASE"
 | 
			
		||||
echo "Running: $SECRET_BINARY vault import work"
 | 
			
		||||
if $SECRET_BINARY vault import work; then
 | 
			
		||||
    print_success "Imported mnemonic into 'work' vault"
 | 
			
		||||
if $SECRET_BINARY init; then
 | 
			
		||||
    print_success "Initialized for unlock key testing"
 | 
			
		||||
else
 | 
			
		||||
    print_error "Failed to import mnemonic into 'work' vault"
 | 
			
		||||
    print_error "Failed to initialize for unlock key testing"
 | 
			
		||||
fi
 | 
			
		||||
# Unset passphrase after import
 | 
			
		||||
unset SB_UNLOCK_PASSPHRASE
 | 
			
		||||
 | 
			
		||||
# Switch back to default vault
 | 
			
		||||
echo "Switching back to 'default' vault..."
 | 
			
		||||
echo "Running: $SECRET_BINARY vault select default"
 | 
			
		||||
if $SECRET_BINARY vault select default; then
 | 
			
		||||
    print_success "Switched back to 'default' vault"
 | 
			
		||||
else
 | 
			
		||||
    print_error "Failed to switch back to 'default' vault"
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
# Test 6: Unlock key management
 | 
			
		||||
print_step "6" "Testing unlock key management"
 | 
			
		||||
 | 
			
		||||
# Create passphrase-protected unlock key
 | 
			
		||||
echo "Creating passphrase-protected unlock key..."
 | 
			
		||||
# Note: This test uses stdin input instead of environment variable to test the traditional approach
 | 
			
		||||
echo "Running: echo \"$TEST_PASSPHRASE\" | $SECRET_BINARY keys add passphrase"
 | 
			
		||||
if echo "$TEST_PASSPHRASE" | $SECRET_BINARY keys add passphrase; then
 | 
			
		||||
echo "Running: $SECRET_BINARY keys add passphrase (with SB_UNLOCK_PASSPHRASE set)"
 | 
			
		||||
if $SECRET_BINARY keys add passphrase; then
 | 
			
		||||
    print_success "Created passphrase-protected unlock key"
 | 
			
		||||
else
 | 
			
		||||
    print_error "Failed to create passphrase-protected unlock key"
 | 
			
		||||
fi
 | 
			
		||||
unset SB_UNLOCK_PASSPHRASE
 | 
			
		||||
 | 
			
		||||
# List unlock keys
 | 
			
		||||
echo "Listing unlock keys..."
 | 
			
		||||
@ -382,8 +261,8 @@ else
 | 
			
		||||
    print_error "Failed to list unlock keys"
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
# Test 7: Secret management with mnemonic (keyless operation)
 | 
			
		||||
print_step "7" "Testing mnemonic-based secret operations (keyless)"
 | 
			
		||||
# Test 6: Secret management with mnemonic (keyless operation)
 | 
			
		||||
print_step "6" "Testing mnemonic-based secret operations (keyless)"
 | 
			
		||||
 | 
			
		||||
# Add secrets using mnemonic (no unlock key required)
 | 
			
		||||
echo "Adding secrets using mnemonic-based long-term key..."
 | 
			
		||||
@ -461,8 +340,8 @@ else
 | 
			
		||||
    print_error "Failed to list secrets"
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
# Test 8: Secret management without mnemonic (traditional unlock key approach)
 | 
			
		||||
print_step "8" "Testing traditional unlock key approach"
 | 
			
		||||
# Test 7: Secret management without mnemonic (traditional unlock key approach)
 | 
			
		||||
print_step "7" "Testing traditional unlock key approach"
 | 
			
		||||
 | 
			
		||||
# Temporarily unset mnemonic to test traditional approach
 | 
			
		||||
unset SB_SECRET_MNEMONIC
 | 
			
		||||
@ -478,7 +357,9 @@ fi
 | 
			
		||||
 | 
			
		||||
# Retrieve secret using traditional unlock key approach
 | 
			
		||||
echo "Retrieving secret using traditional unlock key approach..."
 | 
			
		||||
RETRIEVED_TRADITIONAL=$(echo "$TEST_PASSPHRASE" | $SECRET_BINARY get "traditional/secret" 2>/dev/null)
 | 
			
		||||
export SB_UNLOCK_PASSPHRASE="$TEST_PASSPHRASE"
 | 
			
		||||
RETRIEVED_TRADITIONAL=$($SECRET_BINARY get "traditional/secret" 2>/dev/null)
 | 
			
		||||
unset SB_UNLOCK_PASSPHRASE
 | 
			
		||||
if [ "$RETRIEVED_TRADITIONAL" = "traditional-secret-value" ]; then
 | 
			
		||||
    print_success "Retrieved and verified traditional secret: traditional/secret"
 | 
			
		||||
else
 | 
			
		||||
@ -488,25 +369,8 @@ fi
 | 
			
		||||
# Re-enable mnemonic for remaining tests
 | 
			
		||||
export SB_SECRET_MNEMONIC="$TEST_MNEMONIC"
 | 
			
		||||
 | 
			
		||||
# Test 9: Advanced unlock key management
 | 
			
		||||
print_step "9" "Testing advanced unlock key management"
 | 
			
		||||
 | 
			
		||||
# Re-enable mnemonic for key operations
 | 
			
		||||
export SB_SECRET_MNEMONIC="$TEST_MNEMONIC"
 | 
			
		||||
 | 
			
		||||
# Create PGP unlock key (if GPG is available)
 | 
			
		||||
echo "Testing PGP unlock key creation..."
 | 
			
		||||
if command -v gpg >/dev/null 2>&1; then
 | 
			
		||||
    # This would require a GPG key ID - for testing we'll just check the command exists
 | 
			
		||||
    echo "Running: $SECRET_BINARY keys add pgp --help"
 | 
			
		||||
    if $SECRET_BINARY keys add pgp --help; then
 | 
			
		||||
        print_success "PGP unlock key command available"
 | 
			
		||||
    else
 | 
			
		||||
        print_warning "PGP unlock key command not yet implemented"
 | 
			
		||||
    fi
 | 
			
		||||
else
 | 
			
		||||
    print_warning "GPG not available for PGP unlock key testing"
 | 
			
		||||
fi
 | 
			
		||||
# Test 8: Advanced unlock key management
 | 
			
		||||
print_step "8" "Testing advanced unlock key management"
 | 
			
		||||
 | 
			
		||||
# Test Secure Enclave (macOS only)
 | 
			
		||||
if [[ "$OSTYPE" == "darwin"* ]]; then
 | 
			
		||||
@ -540,8 +404,8 @@ if $SECRET_BINARY keys list; then
 | 
			
		||||
    fi
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
# Test 10: Secret name validation and edge cases
 | 
			
		||||
print_step "10" "Testing secret name validation and edge cases"
 | 
			
		||||
# Test 9: Secret name validation and edge cases
 | 
			
		||||
print_step "9" "Testing secret name validation and edge cases"
 | 
			
		||||
 | 
			
		||||
# Test valid names
 | 
			
		||||
VALID_NAMES=("valid-name" "valid.name" "valid_name" "valid/path/name" "123valid" "a" "very-long-name-with-many-parts/and/paths")
 | 
			
		||||
@ -566,8 +430,8 @@ for name in "${INVALID_NAMES[@]}"; do
 | 
			
		||||
    fi
 | 
			
		||||
done
 | 
			
		||||
 | 
			
		||||
# Test 11: Overwrite protection and force flag
 | 
			
		||||
print_step "11" "Testing overwrite protection and force flag"
 | 
			
		||||
# Test 10: Overwrite protection and force flag
 | 
			
		||||
print_step "10" "Testing overwrite protection and force flag"
 | 
			
		||||
 | 
			
		||||
# Try to add existing secret without --force (should fail)
 | 
			
		||||
echo "Running: echo \"new-value\" | $SECRET_BINARY add \"database/password\""
 | 
			
		||||
@ -593,8 +457,28 @@ else
 | 
			
		||||
    print_error "Force overwrite failed - secret not overwritten with --force"
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
# Test 12: Cross-vault operations
 | 
			
		||||
print_step "12" "Testing cross-vault operations"
 | 
			
		||||
# Test 11: Cross-vault operations
 | 
			
		||||
print_step "11" "Testing cross-vault operations"
 | 
			
		||||
 | 
			
		||||
# First create and import mnemonic into work vault since it was destroyed by reset_state
 | 
			
		||||
echo "Creating work vault for cross-vault testing..."
 | 
			
		||||
echo "Running: $SECRET_BINARY vault create work"
 | 
			
		||||
if $SECRET_BINARY vault create work; then
 | 
			
		||||
    print_success "Created work vault for cross-vault testing"
 | 
			
		||||
else
 | 
			
		||||
    print_error "Failed to create work vault for cross-vault testing"
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
# Import mnemonic into work vault so it can store secrets
 | 
			
		||||
echo "Importing mnemonic into work vault..."
 | 
			
		||||
export SB_UNLOCK_PASSPHRASE="$TEST_PASSPHRASE"
 | 
			
		||||
echo "Running: $SECRET_BINARY vault import work"
 | 
			
		||||
if $SECRET_BINARY vault import work; then
 | 
			
		||||
    print_success "Imported mnemonic into work vault"
 | 
			
		||||
else
 | 
			
		||||
    print_error "Failed to import mnemonic into work vault"
 | 
			
		||||
fi
 | 
			
		||||
unset SB_UNLOCK_PASSPHRASE
 | 
			
		||||
 | 
			
		||||
# Switch to work vault and add secrets there
 | 
			
		||||
echo "Switching to 'work' vault for cross-vault testing..."
 | 
			
		||||
@ -640,8 +524,8 @@ else
 | 
			
		||||
    print_error "Failed to switch back to 'default' vault"
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
# Test 13: File structure verification
 | 
			
		||||
print_step "13" "Verifying file structure"
 | 
			
		||||
# Test 12: File structure verification
 | 
			
		||||
print_step "12" "Verifying file structure"
 | 
			
		||||
 | 
			
		||||
echo "Checking file structure in $TEMP_DIR..."
 | 
			
		||||
if [ -d "$TEMP_DIR/vaults.d/default/secrets.d" ]; then
 | 
			
		||||
@ -689,8 +573,8 @@ else
 | 
			
		||||
    print_error "Current vault link not found"
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
# Test 14: Environment variable error handling
 | 
			
		||||
print_step "14" "Testing environment variable error handling"
 | 
			
		||||
# Test 13: Environment variable error handling
 | 
			
		||||
print_step "13" "Testing environment variable error handling"
 | 
			
		||||
 | 
			
		||||
# Test with non-existent state directory
 | 
			
		||||
export SB_SECRET_STATE_DIR="$TEMP_DIR/nonexistent/directory"
 | 
			
		||||
@ -702,49 +586,20 @@ else
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
# Test init with non-existent directory (should work)
 | 
			
		||||
echo "Running: $SECRET_BINARY init"
 | 
			
		||||
echo "Running: $SECRET_BINARY init (with SB_UNLOCK_PASSPHRASE set)"
 | 
			
		||||
export SB_UNLOCK_PASSPHRASE="$TEST_PASSPHRASE"
 | 
			
		||||
if $SECRET_BINARY init; then
 | 
			
		||||
    print_success "Init works with non-existent state directory"
 | 
			
		||||
else
 | 
			
		||||
    print_error "Init should work with non-existent state directory"
 | 
			
		||||
fi
 | 
			
		||||
unset SB_UNLOCK_PASSPHRASE
 | 
			
		||||
 | 
			
		||||
# Reset to working directory
 | 
			
		||||
export SB_SECRET_STATE_DIR="$TEMP_DIR"
 | 
			
		||||
 | 
			
		||||
# Test 15: Unlock key removal
 | 
			
		||||
print_step "15" "Testing unlock key removal"
 | 
			
		||||
 | 
			
		||||
# Re-enable mnemonic
 | 
			
		||||
export SB_SECRET_MNEMONIC="$TEST_MNEMONIC"
 | 
			
		||||
 | 
			
		||||
# Create another unlock key for testing removal
 | 
			
		||||
echo "Creating additional unlock key for removal testing..."
 | 
			
		||||
# Use stdin input instead of environment variable
 | 
			
		||||
echo "Running: echo \"another-passphrase\" | $SECRET_BINARY keys add passphrase"
 | 
			
		||||
if echo "another-passphrase" | $SECRET_BINARY keys add passphrase; then
 | 
			
		||||
    print_success "Created additional unlock key"
 | 
			
		||||
    
 | 
			
		||||
    # Get the key ID and try to remove it
 | 
			
		||||
    echo "Running: $SECRET_BINARY keys list"
 | 
			
		||||
    if $SECRET_BINARY keys list; then
 | 
			
		||||
        KEY_TO_REMOVE=$($SECRET_BINARY keys list | tail -n1 | awk '{print $1}')
 | 
			
		||||
        if [ -n "$KEY_TO_REMOVE" ]; then
 | 
			
		||||
            echo "Attempting to remove unlock key: $KEY_TO_REMOVE"
 | 
			
		||||
            echo "Running: $SECRET_BINARY keys rm $KEY_TO_REMOVE"
 | 
			
		||||
            if $SECRET_BINARY keys rm "$KEY_TO_REMOVE"; then
 | 
			
		||||
                print_success "Removed unlock key: $KEY_TO_REMOVE"
 | 
			
		||||
            else
 | 
			
		||||
                print_warning "Unlock key removal not yet implemented"
 | 
			
		||||
            fi
 | 
			
		||||
        fi
 | 
			
		||||
    fi
 | 
			
		||||
else
 | 
			
		||||
    print_warning "Could not create additional unlock key for removal testing"
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
# Test 16: Mixed approach compatibility
 | 
			
		||||
print_step "16" "Testing mixed approach compatibility"
 | 
			
		||||
# Test 14: Mixed approach compatibility
 | 
			
		||||
print_step "14" "Testing mixed approach compatibility"
 | 
			
		||||
 | 
			
		||||
# Verify mnemonic can access traditional secrets
 | 
			
		||||
RETRIEVED_MIXED=$($SECRET_BINARY get "traditional/secret" 2>/dev/null)
 | 
			
		||||
@ -757,315 +612,24 @@ fi
 | 
			
		||||
# Test without mnemonic but with unlock key
 | 
			
		||||
unset SB_SECRET_MNEMONIC
 | 
			
		||||
echo "Testing traditional unlock key access to mnemonic-created secrets..."
 | 
			
		||||
echo "Running: echo \"$TEST_PASSPHRASE\" | $SECRET_BINARY get \"database/password\""
 | 
			
		||||
if echo "$TEST_PASSPHRASE" | $SECRET_BINARY get "database/password"; then
 | 
			
		||||
echo "Running: $SECRET_BINARY get \"database/password\" (with SB_UNLOCK_PASSPHRASE set)"
 | 
			
		||||
export SB_UNLOCK_PASSPHRASE="$TEST_PASSPHRASE"
 | 
			
		||||
if $SECRET_BINARY get "database/password"; then
 | 
			
		||||
    print_success "Traditional unlock key can access mnemonic-created secrets"
 | 
			
		||||
else
 | 
			
		||||
    print_warning "Traditional unlock key cannot access mnemonic-created secrets (may need implementation)"
 | 
			
		||||
fi
 | 
			
		||||
unset SB_UNLOCK_PASSPHRASE
 | 
			
		||||
 | 
			
		||||
# Re-enable mnemonic for final tests
 | 
			
		||||
export SB_SECRET_MNEMONIC="$TEST_MNEMONIC"
 | 
			
		||||
 | 
			
		||||
# Test 17: Architecture Refactoring - Separation of Concerns
 | 
			
		||||
print_step "17" "Testing refactored architecture - separation of concerns"
 | 
			
		||||
 | 
			
		||||
echo "Testing that secrets handle their own data access..."
 | 
			
		||||
# Create a test secret first
 | 
			
		||||
echo "Running: echo \"test-self-access\" | $SECRET_BINARY add \"test/self-access\""
 | 
			
		||||
if echo "test-self-access" | $SECRET_BINARY add "test/self-access"; then
 | 
			
		||||
    print_success "Created test secret for self-access testing"
 | 
			
		||||
    
 | 
			
		||||
    # Try to retrieve it (this tests that Secret.GetEncryptedData() works)
 | 
			
		||||
    echo "Running: $SECRET_BINARY get \"test/self-access\""
 | 
			
		||||
    if $SECRET_BINARY get "test/self-access"; then
 | 
			
		||||
        print_success "Secret correctly handles its own data access"
 | 
			
		||||
    else
 | 
			
		||||
        print_error "Secret failed to handle its own data access"
 | 
			
		||||
    fi
 | 
			
		||||
else
 | 
			
		||||
    print_error "Failed to create test secret"
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
echo "Testing unlock key delegation pattern..."
 | 
			
		||||
# Test that vault delegates to unlock keys for decryption
 | 
			
		||||
# This is tested implicitly by all our secret retrieval operations
 | 
			
		||||
echo "Running: $SECRET_BINARY get \"database/password\""
 | 
			
		||||
if $SECRET_BINARY get "database/password"; then
 | 
			
		||||
    print_success "Vault correctly delegates to unlock keys for decryption"
 | 
			
		||||
else
 | 
			
		||||
    print_error "Vault delegation pattern failed"
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
# Test 18: Interface Method Compliance
 | 
			
		||||
print_step "18" "Testing interface method compliance"
 | 
			
		||||
 | 
			
		||||
echo "Verifying all unlock key types implement required methods..."
 | 
			
		||||
 | 
			
		||||
# Create different types of unlock keys to test interface compliance
 | 
			
		||||
echo "Testing PassphraseUnlockKey interface compliance..."
 | 
			
		||||
echo "Running: echo \"interface-test-pass\" | $SECRET_BINARY keys add passphrase"
 | 
			
		||||
if echo "interface-test-pass" | $SECRET_BINARY keys add passphrase; then
 | 
			
		||||
    print_success "PassphraseUnlockKey created successfully"
 | 
			
		||||
    
 | 
			
		||||
    # Test that we can use it (this verifies GetIdentity and DecryptSecret work)
 | 
			
		||||
    echo "Running: echo \"interface-test-secret\" | $SECRET_BINARY add \"interface/test\""
 | 
			
		||||
    if echo "interface-test-secret" | $SECRET_BINARY add "interface/test"; then
 | 
			
		||||
        echo "Running: $SECRET_BINARY get \"interface/test\""
 | 
			
		||||
        if $SECRET_BINARY get "interface/test"; then
 | 
			
		||||
            print_success "PassphraseUnlockKey interface methods working"
 | 
			
		||||
        else
 | 
			
		||||
            print_error "PassphraseUnlockKey interface methods failed"
 | 
			
		||||
        fi
 | 
			
		||||
    else
 | 
			
		||||
        print_error "Failed to test PassphraseUnlockKey interface"
 | 
			
		||||
    fi
 | 
			
		||||
else
 | 
			
		||||
    print_warning "Could not create PassphraseUnlockKey for interface testing"
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
# Test Secure Enclave on macOS (if available)
 | 
			
		||||
if [[ "$OSTYPE" == "darwin"* ]]; then
 | 
			
		||||
    echo "Testing SEPUnlockKey interface compliance on macOS..."
 | 
			
		||||
    echo "Running: $SECRET_BINARY enroll sep"
 | 
			
		||||
    if $SECRET_BINARY enroll sep; then
 | 
			
		||||
        print_success "SEPUnlockKey created successfully"
 | 
			
		||||
        
 | 
			
		||||
        # Test that we can use it
 | 
			
		||||
        echo "Running: echo \"sep-test-secret\" | $SECRET_BINARY add \"sep/test\""
 | 
			
		||||
        if echo "sep-test-secret" | $SECRET_BINARY add "sep/test"; then
 | 
			
		||||
            echo "Running: $SECRET_BINARY get \"sep/test\""
 | 
			
		||||
            if $SECRET_BINARY get "sep/test"; then
 | 
			
		||||
                print_success "SEPUnlockKey interface methods working"
 | 
			
		||||
            else
 | 
			
		||||
                print_error "SEPUnlockKey interface methods failed"
 | 
			
		||||
            fi
 | 
			
		||||
        else
 | 
			
		||||
            print_error "Failed to test SEPUnlockKey interface"
 | 
			
		||||
        fi
 | 
			
		||||
    else
 | 
			
		||||
        print_warning "SEPUnlockKey creation not available for interface testing"
 | 
			
		||||
    fi
 | 
			
		||||
else
 | 
			
		||||
    print_warning "SEPUnlockKey only available on macOS"
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
# Test 19: Long-term Key Management Separation
 | 
			
		||||
print_step "19" "Testing long-term key access via different unlock key types"
 | 
			
		||||
 | 
			
		||||
echo "Testing that different unlock key types can access the same long-term key..."
 | 
			
		||||
 | 
			
		||||
# Switch between different unlock methods to verify each can access the long-term key
 | 
			
		||||
echo "Testing mnemonic-based long-term key access..."
 | 
			
		||||
export SB_SECRET_MNEMONIC="$TEST_MNEMONIC"
 | 
			
		||||
echo "Running: echo \"mnemonic-longterm-test\" | $SECRET_BINARY add \"longterm/mnemonic\""
 | 
			
		||||
if echo "mnemonic-longterm-test" | $SECRET_BINARY add "longterm/mnemonic"; then
 | 
			
		||||
    echo "Running: $SECRET_BINARY get \"longterm/mnemonic\""
 | 
			
		||||
    if $SECRET_BINARY get "longterm/mnemonic"; then
 | 
			
		||||
        print_success "Mnemonic-based long-term key access working"
 | 
			
		||||
    else
 | 
			
		||||
        print_error "Mnemonic-based long-term key access failed"
 | 
			
		||||
    fi
 | 
			
		||||
else
 | 
			
		||||
    print_error "Failed to test mnemonic-based long-term key access"
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
echo "Testing passphrase unlock key accessing long-term key..."
 | 
			
		||||
unset SB_SECRET_MNEMONIC
 | 
			
		||||
echo "Running: echo \"passphrase-unlock-test\" | $SECRET_BINARY add \"longterm/passphrase-unlock\""
 | 
			
		||||
if echo "passphrase-unlock-test" | $SECRET_BINARY add "longterm/passphrase-unlock"; then
 | 
			
		||||
    echo "Running: echo \"$TEST_PASSPHRASE\" | $SECRET_BINARY get \"longterm/passphrase-unlock\""
 | 
			
		||||
    if echo "$TEST_PASSPHRASE" | $SECRET_BINARY get "longterm/passphrase-unlock"; then
 | 
			
		||||
        print_success "Passphrase unlock key accessing long-term key working"
 | 
			
		||||
    else
 | 
			
		||||
        print_error "Passphrase unlock key accessing long-term key failed"
 | 
			
		||||
    fi
 | 
			
		||||
else
 | 
			
		||||
    print_error "Failed to test passphrase unlock key accessing long-term key"
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
# Re-enable mnemonic for remaining tests
 | 
			
		||||
export SB_SECRET_MNEMONIC="$TEST_MNEMONIC"
 | 
			
		||||
 | 
			
		||||
# Test 20: Directory Structure and File Access Patterns
 | 
			
		||||
print_step "20" "Testing directory structure and file access patterns"
 | 
			
		||||
 | 
			
		||||
echo "Verifying secrets access their own directory structure..."
 | 
			
		||||
 | 
			
		||||
# Check that secret directories contain the expected structure
 | 
			
		||||
SECRET_NAME="structure/test"
 | 
			
		||||
echo "Running: echo \"structure-test-value\" | $SECRET_BINARY add $SECRET_NAME"
 | 
			
		||||
if echo "structure-test-value" | $SECRET_BINARY add "$SECRET_NAME"; then
 | 
			
		||||
    print_success "Created secret for structure testing"
 | 
			
		||||
    
 | 
			
		||||
    # Convert secret name to directory name (URL encoding)
 | 
			
		||||
    ENCODED_NAME=$(echo "$SECRET_NAME" | sed 's|/|%|g')
 | 
			
		||||
    SECRET_DIR="$TEMP_DIR/vaults.d/default/secrets.d/$ENCODED_NAME"
 | 
			
		||||
    
 | 
			
		||||
    if [ -d "$SECRET_DIR" ]; then
 | 
			
		||||
        print_success "Secret directory structure created correctly"
 | 
			
		||||
        
 | 
			
		||||
        # Verify secret can access its own encrypted data
 | 
			
		||||
        echo "Running: $SECRET_BINARY get $SECRET_NAME"
 | 
			
		||||
        if $SECRET_BINARY get "$SECRET_NAME"; then
 | 
			
		||||
            print_success "Secret correctly accesses its own encrypted data"
 | 
			
		||||
        else
 | 
			
		||||
            print_error "Secret failed to access its own encrypted data"
 | 
			
		||||
        fi
 | 
			
		||||
    else
 | 
			
		||||
        print_error "Secret directory structure not found"
 | 
			
		||||
    fi
 | 
			
		||||
else
 | 
			
		||||
    print_error "Failed to create secret for structure testing"
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
echo "Verifying unlock keys manage their own key files..."
 | 
			
		||||
 | 
			
		||||
# Check unlock key directory structure
 | 
			
		||||
UNLOCK_KEYS_DIR="$TEMP_DIR/vaults.d/default/unlock-keys.d"
 | 
			
		||||
if [ -d "$UNLOCK_KEYS_DIR" ]; then
 | 
			
		||||
    print_success "Unlock keys directory exists"
 | 
			
		||||
    
 | 
			
		||||
    # Check for passphrase unlock key files
 | 
			
		||||
    for keydir in "$UNLOCK_KEYS_DIR"/*; do
 | 
			
		||||
        if [ -d "$keydir" ] && [ -f "$keydir/metadata.json" ]; then
 | 
			
		||||
            print_success "Found unlock key with proper structure: $(basename "$keydir")"
 | 
			
		||||
            
 | 
			
		||||
            # Check for required files
 | 
			
		||||
            if [ -f "$keydir/pub.age" ] && [ -f "$keydir/priv.age" ]; then
 | 
			
		||||
                print_success "Unlock key has required key files"
 | 
			
		||||
                
 | 
			
		||||
                # Check for long-term key management files
 | 
			
		||||
                if [ -f "$keydir/longterm.age" ]; then
 | 
			
		||||
                    print_success "Unlock key has long-term key file"
 | 
			
		||||
                else
 | 
			
		||||
                    print_warning "Unlock key missing long-term key file (may be mnemonic-only)"
 | 
			
		||||
                fi
 | 
			
		||||
            else
 | 
			
		||||
                print_error "Unlock key missing required key files"
 | 
			
		||||
            fi
 | 
			
		||||
        fi
 | 
			
		||||
    done
 | 
			
		||||
else
 | 
			
		||||
    print_error "Unlock keys directory not found"
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
# Test 21: Error Handling in Refactored Architecture
 | 
			
		||||
print_step "21" "Testing error handling in refactored architecture"
 | 
			
		||||
 | 
			
		||||
echo "Testing secret error handling..."
 | 
			
		||||
 | 
			
		||||
# Test non-existent secret
 | 
			
		||||
echo "Running: $SECRET_BINARY get \"nonexistent/secret\""
 | 
			
		||||
if $SECRET_BINARY get "nonexistent/secret"; then
 | 
			
		||||
    print_error "Should have failed for non-existent secret"
 | 
			
		||||
else
 | 
			
		||||
    print_success "Correctly handled non-existent secret"
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
echo "Testing unlock key error handling..."
 | 
			
		||||
 | 
			
		||||
# Test with corrupted state (temporarily rename a key file)
 | 
			
		||||
UNLOCK_KEYS_DIR="$TEMP_DIR/vaults.d/default/unlock-keys.d"
 | 
			
		||||
FIRST_KEY_DIR=$(find "$UNLOCK_KEYS_DIR" -type d -name "*" | head -n1)
 | 
			
		||||
if [ -d "$FIRST_KEY_DIR" ] && [ -f "$FIRST_KEY_DIR/priv.age" ]; then
 | 
			
		||||
    # Temporarily corrupt the key
 | 
			
		||||
    mv "$FIRST_KEY_DIR/priv.age" "$FIRST_KEY_DIR/priv.age.backup"
 | 
			
		||||
    
 | 
			
		||||
    # Temporarily disable mnemonic to force unlock key usage
 | 
			
		||||
    unset SB_SECRET_MNEMONIC
 | 
			
		||||
    
 | 
			
		||||
    echo "Running: $SECRET_BINARY get \"database/password\""
 | 
			
		||||
    if $SECRET_BINARY get "database/password"; then
 | 
			
		||||
        print_warning "Expected failure with corrupted unlock key, but succeeded (may have fallback)"
 | 
			
		||||
    else
 | 
			
		||||
        print_success "Correctly handled corrupted unlock key"
 | 
			
		||||
    fi
 | 
			
		||||
    
 | 
			
		||||
    # Restore the key
 | 
			
		||||
    mv "$FIRST_KEY_DIR/priv.age.backup" "$FIRST_KEY_DIR/priv.age"
 | 
			
		||||
    
 | 
			
		||||
    # Re-enable mnemonic
 | 
			
		||||
    export SB_SECRET_MNEMONIC="$TEST_MNEMONIC"
 | 
			
		||||
else
 | 
			
		||||
    print_warning "Could not test unlock key error handling - no key found"
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
# Test 22: Cross-Component Integration
 | 
			
		||||
print_step "22" "Testing cross-component integration"
 | 
			
		||||
 | 
			
		||||
echo "Testing vault-secret-unlock key integration..."
 | 
			
		||||
 | 
			
		||||
# Create a secret in one vault, switch vaults, create another secret, switch back
 | 
			
		||||
echo "Running: $SECRET_BINARY vault create integration-test"
 | 
			
		||||
if $SECRET_BINARY vault create integration-test; then
 | 
			
		||||
    print_success "Created integration test vault"
 | 
			
		||||
    
 | 
			
		||||
    # Add secret to default vault
 | 
			
		||||
    echo "Running: echo \"default-vault-secret\" | $SECRET_BINARY add \"integration/default\""
 | 
			
		||||
    if echo "default-vault-secret" | $SECRET_BINARY add "integration/default"; then
 | 
			
		||||
        print_success "Added secret to default vault"
 | 
			
		||||
        
 | 
			
		||||
        # Switch to integration-test vault
 | 
			
		||||
        echo "Running: $SECRET_BINARY vault select integration-test"
 | 
			
		||||
        if $SECRET_BINARY vault select integration-test; then
 | 
			
		||||
            print_success "Switched to integration-test vault"
 | 
			
		||||
            
 | 
			
		||||
            # Create unlock key in new vault
 | 
			
		||||
            echo "Running: echo \"integration-passphrase\" | $SECRET_BINARY keys add passphrase"
 | 
			
		||||
            if echo "integration-passphrase" | $SECRET_BINARY keys add passphrase; then
 | 
			
		||||
                print_success "Created unlock key in integration-test vault"
 | 
			
		||||
                
 | 
			
		||||
                # Add secret to integration-test vault
 | 
			
		||||
                echo "Running: echo \"integration-vault-secret\" | $SECRET_BINARY add \"integration/test\""
 | 
			
		||||
                if echo "integration-vault-secret" | $SECRET_BINARY add "integration/test"; then
 | 
			
		||||
                    print_success "Added secret to integration-test vault"
 | 
			
		||||
                    
 | 
			
		||||
                    # Verify secret retrieval works
 | 
			
		||||
                    echo "Running: $SECRET_BINARY get \"integration/test\""
 | 
			
		||||
                    if $SECRET_BINARY get "integration/test"; then
 | 
			
		||||
                        print_success "Cross-component integration working"
 | 
			
		||||
                    else
 | 
			
		||||
                        print_error "Cross-component integration failed"
 | 
			
		||||
                    fi
 | 
			
		||||
                else
 | 
			
		||||
                    print_error "Failed to add secret to integration-test vault"
 | 
			
		||||
                fi
 | 
			
		||||
            else
 | 
			
		||||
                print_error "Failed to create unlock key in integration-test vault"
 | 
			
		||||
            fi
 | 
			
		||||
            
 | 
			
		||||
            # Switch back to default vault
 | 
			
		||||
            echo "Running: $SECRET_BINARY vault select default"
 | 
			
		||||
            if $SECRET_BINARY vault select default; then
 | 
			
		||||
                print_success "Switched back to default vault"
 | 
			
		||||
                
 | 
			
		||||
                # Verify we can still access default vault secrets
 | 
			
		||||
                echo "Running: $SECRET_BINARY get \"integration/default\""
 | 
			
		||||
                if $SECRET_BINARY get "integration/default"; then
 | 
			
		||||
                    print_success "Can still access default vault secrets"
 | 
			
		||||
                else
 | 
			
		||||
                    print_error "Cannot access default vault secrets after switching"
 | 
			
		||||
                fi
 | 
			
		||||
            else
 | 
			
		||||
                print_error "Failed to switch back to default vault"
 | 
			
		||||
            fi
 | 
			
		||||
        else
 | 
			
		||||
            print_error "Failed to switch to integration-test vault"
 | 
			
		||||
        fi
 | 
			
		||||
    else
 | 
			
		||||
        print_error "Failed to add secret to default vault"
 | 
			
		||||
    fi
 | 
			
		||||
else
 | 
			
		||||
    print_error "Failed to create integration test vault"
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
# Final summary
 | 
			
		||||
echo -e "\n${GREEN}=== Test Summary ===${NC}"
 | 
			
		||||
echo -e "${GREEN}✓ Environment variable support (SB_SECRET_STATE_DIR, SB_SECRET_MNEMONIC)${NC}"
 | 
			
		||||
echo -e "${GREEN}✓ Secret manager initialization${NC}"
 | 
			
		||||
echo -e "${GREEN}✓ Vault management (create, list, select)${NC}"
 | 
			
		||||
echo -e "${GREEN}✓ Import functionality with all environment variable combinations${NC}"
 | 
			
		||||
echo -e "${GREEN}✓ Import functionality with environment variable combinations${NC}"
 | 
			
		||||
echo -e "${GREEN}✓ Import error handling (non-existent vault, invalid mnemonic)${NC}"
 | 
			
		||||
echo -e "${GREEN}✓ Unlock key management (passphrase, PGP, SEP)${NC}"
 | 
			
		||||
echo -e "${GREEN}✓ Mnemonic-based secret operations (keyless)${NC}"
 | 
			
		||||
@ -1074,17 +638,10 @@ echo -e "${GREEN}✓ Secret name validation${NC}"
 | 
			
		||||
echo -e "${GREEN}✓ Overwrite protection and force flag${NC}"
 | 
			
		||||
echo -e "${GREEN}✓ Cross-vault operations${NC}"
 | 
			
		||||
echo -e "${GREEN}✓ Per-secret key file structure${NC}"
 | 
			
		||||
echo -e "${GREEN}✓ Unlock key removal${NC}"
 | 
			
		||||
echo -e "${GREEN}✓ Mixed approach compatibility${NC}"
 | 
			
		||||
echo -e "${GREEN}✓ Error handling${NC}"
 | 
			
		||||
echo -e "${GREEN}✓ Refactored architecture - separation of concerns${NC}"
 | 
			
		||||
echo -e "${GREEN}✓ Interface method compliance${NC}"
 | 
			
		||||
echo -e "${GREEN}✓ Long-term key access via different unlock key types${NC}"
 | 
			
		||||
echo -e "${GREEN}✓ Directory structure and file access patterns${NC}"
 | 
			
		||||
echo -e "${GREEN}✓ Error handling in refactored architecture${NC}"
 | 
			
		||||
echo -e "${GREEN}✓ Cross-component integration${NC}"
 | 
			
		||||
 | 
			
		||||
echo -e "\n${GREEN}🎉 Comprehensive test completed with architecture verification!${NC}"
 | 
			
		||||
echo -e "\n${GREEN}🎉 Comprehensive test completed with environment variable automation!${NC}"
 | 
			
		||||
 | 
			
		||||
# Show usage examples for all implemented functionality
 | 
			
		||||
echo -e "\n${BLUE}=== Complete Usage Examples ===${NC}"
 | 
			
		||||
@ -1100,25 +657,14 @@ echo "secret vault list"
 | 
			
		||||
echo "secret vault create work"
 | 
			
		||||
echo "secret vault select work"
 | 
			
		||||
echo ""
 | 
			
		||||
echo -e "${YELLOW}# Import mnemonic (different combinations):${NC}"
 | 
			
		||||
echo "# With mnemonic env var set:"
 | 
			
		||||
echo "export SB_SECRET_MNEMONIC=\"abandon abandon...\""
 | 
			
		||||
echo "echo \"passphrase\" | secret import work"
 | 
			
		||||
echo ""
 | 
			
		||||
echo "# With passphrase env var set:"
 | 
			
		||||
echo "export SB_UNLOCK_PASSPHRASE=\"passphrase\""
 | 
			
		||||
echo "echo \"abandon abandon...\" | secret import work"
 | 
			
		||||
echo ""
 | 
			
		||||
echo "# With both env vars set:"
 | 
			
		||||
echo -e "${YELLOW}# Import mnemonic (automated with environment variables):${NC}"
 | 
			
		||||
echo "export SB_SECRET_MNEMONIC=\"abandon abandon...\""
 | 
			
		||||
echo "export SB_UNLOCK_PASSPHRASE=\"passphrase\""
 | 
			
		||||
echo "secret import work"
 | 
			
		||||
echo ""
 | 
			
		||||
echo "# With neither env var set:"
 | 
			
		||||
echo "(echo \"abandon abandon...\"; echo \"passphrase\") | secret import work"
 | 
			
		||||
echo "secret vault import work"
 | 
			
		||||
echo ""
 | 
			
		||||
echo -e "${YELLOW}# Unlock key management:${NC}"
 | 
			
		||||
echo "echo \"passphrase\" | secret keys add passphrase"
 | 
			
		||||
echo "export SB_UNLOCK_PASSPHRASE=\"passphrase\""
 | 
			
		||||
echo "secret keys add passphrase"
 | 
			
		||||
echo "secret keys add pgp <gpg-key-id>"
 | 
			
		||||
echo "secret enroll sep  # macOS only"
 | 
			
		||||
echo "secret keys list"
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user