Compare commits

...

7 Commits

Author SHA1 Message Date
4b59d6fb82 fix: Update integration test script for new architecture - Update file checks to expect value.age instead of secret.age - Add debug output support with GODEBUG environment variable - Remove output redirections to show command execution and debug info - Fix test expectations to match per-secret key file structure 2025-05-29 09:52:39 -07:00
5ca657c104 feat: Enhance debug logging system - Add TTY detection for colorized vs JSON output - Disable stderr buffering when debug is enabled for immediate output - Add comprehensive debug functions with structured logging support - Improve debugging experience during development and troubleshooting 2025-05-29 09:52:32 -07:00
bbaf1cbd97 fix: Prevent hanging in non-interactive environments - Add terminal detection to readPassphrase, readSecurePassphrase, and readLineFromStdin - Return clear error messages when stderr is not a terminal instead of hanging - Improves automation and CI/CD reliability 2025-05-29 09:52:26 -07:00
f838c8cb98 feat: Implement per-secret key architecture with individual keypairs - Each secret now has its own encryption keypair stored as pub.age, priv.age, value.age - Secret private keys are encrypted to vault long-term public key - Values stored as value.age instead of secret.age for new architecture 2025-05-29 09:52:18 -07:00
43767c725f chore: Update .gitignore to exclude .DS_Store files and built binary 2025-05-29 09:52:11 -07:00
b26794e21a test: Add comprehensive test suite for secret manager - CLI, debug, secret, and vault tests with in-memory filesystem for fast isolated testing 2025-05-29 09:52:05 -07:00
7dc14da4af simplify 2025-05-29 08:33:06 -07:00
12 changed files with 1666 additions and 415 deletions

3
.gitignore vendored
View File

@ -0,0 +1,3 @@
.DS_Store
**/.DS_Store
/secret

View File

@ -1,93 +1,22 @@
# Makefile for Secret Manager - Simple Go CLI Tool default: check
# Configuration
BINARY_NAME = secret
default: build
# Simple build (no code signing needed) # Simple build (no code signing needed)
build: clean ./secret:
@echo "Building secret manager..." go build -v -o $@ cmd/secret/main.go
go build -o $(BINARY_NAME) cmd/secret/main.go
@echo "Build complete: ./$(BINARY_NAME)"
# Build with verbose output
build-verbose: clean
@echo "Building with verbose output..."
go build -v -o $(BINARY_NAME) cmd/secret/main.go
@echo "Build complete: ./$(BINARY_NAME)"
# Vet the code
vet: vet:
@echo "Running go vet..."
go vet ./... go vet ./...
# Test with linting and vetting test:
test: vet lint
@echo "Running go tests..."
go test -v ./... go test -v ./...
bash test_secret_manager.sh
# Run comprehensive test script
test-comprehensive: build
@echo "Running comprehensive test script..."
@chmod +x test_secret_manager.sh
@./test_secret_manager.sh
# Run all tests (unit tests + comprehensive tests)
test-all: test test-comprehensive
# Lint the code
lint: lint:
@echo "Running linter..."
golangci-lint run --timeout 5m golangci-lint run --timeout 5m
# Check all code quality (build + vet + lint + unit tests) # Check all code quality (build + vet + lint + unit tests)
check: build vet lint test check: ./secret vet lint test
# Clean build artifacts # Clean build artifacts
clean: clean:
rm -f ./$(BINARY_NAME) rm -f ./secret
# Install to /usr/local/bin
install: build
@echo "Installing to /usr/local/bin..."
sudo cp $(BINARY_NAME) /usr/local/bin/
@echo "Installed to /usr/local/bin/$(BINARY_NAME)"
# Uninstall from /usr/local/bin
uninstall:
@echo "Removing from /usr/local/bin..."
sudo rm -f /usr/local/bin/$(BINARY_NAME)
@echo "Uninstalled $(BINARY_NAME)"
# Test keychain functionality
test-keychain:
@echo "Testing keychain functionality..."
@./$(BINARY_NAME) --help > /dev/null 2>&1 && echo "Binary runs successfully" || echo "Binary failed to run"
# Help target
help:
@echo "Secret Manager - Simple Go CLI Tool"
@echo "===================================="
@echo ""
@echo "Available targets:"
@echo " build - Build the secret manager (default)"
@echo " build-verbose - Build with verbose output"
@echo " vet - Run go vet"
@echo " lint - Run linter only"
@echo " test - Run unit tests with vet and lint"
@echo " test-comprehensive - Run comprehensive test script"
@echo " test-all - Run both unit tests and comprehensive tests"
@echo " check - Run all code quality checks"
@echo " clean - Remove build artifacts"
@echo " install - Install to /usr/local/bin"
@echo " uninstall - Remove from /usr/local/bin"
@echo " test-keychain - Test basic functionality"
@echo " help - Show this help"
@echo ""
@echo "Usage:"
@echo " make build && ./secret --help"
@echo " make test-all # Run all tests"
@echo " make check # Run all quality checks"
.PHONY: default build build-verbose vet test test-comprehensive test-all lint check clean install uninstall test-keychain help

View File

@ -95,7 +95,12 @@ func getStdinScanner() *bufio.Scanner {
// readLineFromStdin reads a single line from stdin with a prompt // readLineFromStdin reads a single line from stdin with a prompt
// Uses a shared scanner to avoid buffering issues between multiple calls // Uses a shared scanner to avoid buffering issues between multiple calls
func readLineFromStdin(prompt string) (string, error) { func readLineFromStdin(prompt string) (string, error) {
fmt.Print(prompt) // 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() scanner := getStdinScanner()
if !scanner.Scan() { if !scanner.Scan() {
if err := scanner.Err(); err != nil { if err := scanner.Err(); err != nil {
@ -108,6 +113,7 @@ func readLineFromStdin(prompt string) (string, error) {
// CLIEntry is the entry point for the secret CLI application // CLIEntry is the entry point for the secret CLI application
func CLIEntry() { func CLIEntry() {
Debug("CLIEntry starting - debug output is working")
cmd := newRootCmd() cmd := newRootCmd()
if err := cmd.Execute(); err != nil { if err := cmd.Execute(); err != nil {
os.Exit(1) os.Exit(1)
@ -115,6 +121,7 @@ func CLIEntry() {
} }
func newRootCmd() *cobra.Command { func newRootCmd() *cobra.Command {
Debug("newRootCmd starting")
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "secret", Use: "secret",
Short: "A simple secrets manager", Short: "A simple secrets manager",
@ -124,6 +131,7 @@ func newRootCmd() *cobra.Command {
SilenceErrors: false, SilenceErrors: false,
} }
Debug("Adding subcommands to root command")
// Add subcommands // Add subcommands
cmd.AddCommand(newInitCmd()) cmd.AddCommand(newInitCmd())
cmd.AddCommand(newGenerateCmd()) cmd.AddCommand(newGenerateCmd())
@ -137,6 +145,7 @@ func newRootCmd() *cobra.Command {
cmd.AddCommand(newEncryptCmd()) cmd.AddCommand(newEncryptCmd())
cmd.AddCommand(newDecryptCmd()) cmd.AddCommand(newDecryptCmd())
Debug("newRootCmd completed")
return cmd return cmd
} }
@ -280,9 +289,12 @@ func newAddCmd() *cobra.Command {
Long: `Add a secret to the current vault. The secret value is read from stdin.`, Long: `Add a secret to the current vault. The secret value is read from stdin.`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
Debug("Add command RunE starting", "secret_name", args[0])
force, _ := cmd.Flags().GetBool("force") force, _ := cmd.Flags().GetBool("force")
Debug("Got force flag", "force", force)
cli := NewCLIInstance() cli := NewCLIInstance()
Debug("Created CLI instance, calling AddSecret")
return cli.AddSecret(args[0], force) return cli.AddSecret(args[0], force)
}, },
} }
@ -528,7 +540,7 @@ func (cli *CLIInstance) Init(cmd *cobra.Command) error {
// Create default vault // Create default vault
Debug("Creating default vault") Debug("Creating default vault")
_, err = CreateVault(cli.fs, cli.stateDir, "default") vault, err := CreateVault(cli.fs, cli.stateDir, "default")
if err != nil { if err != nil {
Debug("Failed to create default vault", "error", err) Debug("Failed to create default vault", "error", err)
return fmt.Errorf("failed to create default vault: %w", err) return fmt.Errorf("failed to create default vault: %w", err)
@ -550,6 +562,9 @@ func (cli *CLIInstance) Init(cmd *cobra.Command) error {
return fmt.Errorf("failed to write long-term public key: %w", 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 // Prompt for passphrase for unlock key
var passphraseStr string var passphraseStr string
if envPassphrase := os.Getenv(EnvUnlockPassphrase); envPassphrase != "" { if envPassphrase := os.Getenv(EnvUnlockPassphrase); envPassphrase != "" {
@ -567,7 +582,7 @@ func (cli *CLIInstance) Init(cmd *cobra.Command) error {
// Create passphrase-protected unlock key // Create passphrase-protected unlock key
Debug("Creating passphrase-protected unlock key") Debug("Creating passphrase-protected unlock key")
passphraseKey, err := CreatePassphraseKey(cli.fs, cli.stateDir, passphraseStr) passphraseKey, err := vault.CreatePassphraseKey(passphraseStr)
if err != nil { if err != nil {
Debug("Failed to create unlock key", "error", err) Debug("Failed to create unlock key", "error", err)
return fmt.Errorf("failed to create unlock key: %w", err) return fmt.Errorf("failed to create unlock key: %w", err)
@ -749,24 +764,41 @@ func (cli *CLIInstance) VaultSelect(name string) error {
// AddSecret adds a secret to the vault // AddSecret adds a secret to the vault
func (cli *CLIInstance) AddSecret(secretName string, force bool) error { func (cli *CLIInstance) AddSecret(secretName string, force bool) error {
Debug("CLI AddSecret starting", "secret_name", secretName, "force", force)
// Get current vault // Get current vault
Debug("Getting current vault")
vault, err := GetCurrentVault(cli.fs, cli.stateDir) vault, err := GetCurrentVault(cli.fs, cli.stateDir)
if err != nil { if err != nil {
Debug("Failed to get current vault", "error", err)
return err return err
} }
Debug("Got current vault", "vault_name", vault.Name)
// Read secret value from stdin // Read secret value from stdin
Debug("Reading secret value from stdin")
value, err := io.ReadAll(os.Stdin) value, err := io.ReadAll(os.Stdin)
if err != nil { if err != nil {
Debug("Failed to read secret from stdin", "error", err)
return fmt.Errorf("failed to read secret from stdin: %w", err) return fmt.Errorf("failed to read secret from stdin: %w", err)
} }
Debug("Read secret value from stdin", "value_length", len(value))
// Remove trailing newline if present // Remove trailing newline if present
if len(value) > 0 && value[len(value)-1] == '\n' { if len(value) > 0 && value[len(value)-1] == '\n' {
value = value[:len(value)-1] value = value[:len(value)-1]
Debug("Removed trailing newline", "new_length", len(value))
} }
return vault.AddSecret(secretName, value, force) Debug("Calling vault.AddSecret", "secret_name", secretName, "value_length", len(value), "force", force)
err = vault.AddSecret(secretName, value, force)
if err != nil {
Debug("vault.AddSecret failed", "error", err)
return err
}
Debug("vault.AddSecret completed successfully")
return nil
} }
// GetSecret retrieves a secret from the vault // GetSecret retrieves a secret from the vault
@ -777,27 +809,9 @@ func (cli *CLIInstance) GetSecret(secretName string) error {
return err return err
} }
// Get the secret object // Get the secret value using the vault's GetSecret method
secret, err := vault.GetSecretObject(secretName) // This handles the per-secret key architecture internally
if err != nil { value, err := vault.GetSecret(secretName)
return err
}
// Get the value using the current unlock key (or mnemonic if available)
var value []byte
if os.Getenv(EnvMnemonic) != "" {
// If mnemonic is available, GetValue can handle it without an unlock key
value, err = secret.GetValue(nil)
} else {
// Get the current unlock key
unlockKey, unlockErr := vault.GetCurrentUnlockKey()
if unlockErr != nil {
return fmt.Errorf("failed to get current unlock key: %w", unlockErr)
}
value, err = secret.GetValue(unlockKey)
}
if err != nil { if err != nil {
return err return err
} }
@ -1037,20 +1051,33 @@ func (cli *CLIInstance) KeysList(jsonOutput bool) error {
func (cli *CLIInstance) KeysAdd(keyType string, cmd *cobra.Command) error { func (cli *CLIInstance) KeysAdd(keyType string, cmd *cobra.Command) error {
switch keyType { switch keyType {
case "passphrase": case "passphrase":
// Get current vault
vault, err := 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 // Check if passphrase is set in environment variable
var passphraseStr string var passphraseStr string
if envPassphrase := os.Getenv(EnvUnlockPassphrase); envPassphrase != "" { if envPassphrase := os.Getenv(EnvUnlockPassphrase); envPassphrase != "" {
passphraseStr = envPassphrase passphraseStr = envPassphrase
} else { } else {
// Use secure passphrase input with confirmation // Use secure passphrase input with confirmation
var err error
passphraseStr, err = readSecurePassphrase("Enter passphrase for unlock key: ") passphraseStr, err = readSecurePassphrase("Enter passphrase for unlock key: ")
if err != nil { if err != nil {
return fmt.Errorf("failed to read passphrase: %w", err) return fmt.Errorf("failed to read passphrase: %w", err)
} }
} }
passphraseKey, err := CreatePassphraseKey(cli.fs, cli.stateDir, passphraseStr) passphraseKey, err := vault.CreatePassphraseKey(passphraseStr)
if err != nil { if err != nil {
return err return err
} }
@ -1374,6 +1401,11 @@ func isValidAgeSecretKey(key string) bool {
// readSecurePassphrase reads a passphrase securely from the terminal without echoing // readSecurePassphrase reads a passphrase securely from the terminal without echoing
// and prompts for confirmation. Falls back to regular input when not on a terminal. // and prompts for confirmation. Falls back to regular input when not on a terminal.
func readSecurePassphrase(prompt string) (string, error) { func readSecurePassphrase(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 // Check if stdin is a terminal
if !term.IsTerminal(int(syscall.Stdin)) { if !term.IsTerminal(int(syscall.Stdin)) {
// Not a terminal (piped input, testing, etc.) - use shared line reader // Not a terminal (piped input, testing, etc.) - use shared line reader
@ -1390,22 +1422,22 @@ func readSecurePassphrase(prompt string) (string, error) {
} }
// Terminal input - use secure password reading with confirmation // Terminal input - use secure password reading with confirmation
fmt.Print(prompt) fmt.Fprint(os.Stderr, prompt) // Write prompt to stderr, not stdout
// Read first passphrase // Read first passphrase
passphrase1, err := term.ReadPassword(int(syscall.Stdin)) passphrase1, err := term.ReadPassword(int(syscall.Stdin))
if err != nil { if err != nil {
return "", fmt.Errorf("failed to read passphrase: %w", err) return "", fmt.Errorf("failed to read passphrase: %w", err)
} }
fmt.Println() // Print newline since ReadPassword doesn't echo fmt.Fprintln(os.Stderr) // Print newline to stderr since ReadPassword doesn't echo
// Read confirmation passphrase // Read confirmation passphrase
fmt.Print("Confirm passphrase: ") fmt.Fprint(os.Stderr, "Confirm passphrase: ") // Write prompt to stderr, not stdout
passphrase2, err := term.ReadPassword(int(syscall.Stdin)) passphrase2, err := term.ReadPassword(int(syscall.Stdin))
if err != nil { if err != nil {
return "", fmt.Errorf("failed to read passphrase confirmation: %w", err) return "", fmt.Errorf("failed to read passphrase confirmation: %w", err)
} }
fmt.Println() // Print newline since ReadPassword doesn't echo fmt.Fprintln(os.Stderr) // Print newline to stderr since ReadPassword doesn't echo
// Compare passphrases // Compare passphrases
if string(passphrase1) != string(passphrase2) { if string(passphrase1) != string(passphrase2) {
@ -1444,6 +1476,10 @@ func (cli *CLIInstance) importMnemonic(vaultName, mnemonic string) error {
return fmt.Errorf("failed to write long-term public key: %w", err) return fmt.Errorf("failed to write long-term public key: %w", err)
} }
// Get the vault instance and unlock it
vault := NewVault(cli.fs, vaultName, cli.stateDir)
vault.Unlock(ltIdentity)
// Get or create passphrase for unlock key // Get or create passphrase for unlock key
var passphraseStr string var passphraseStr string
if envPassphrase := os.Getenv(EnvUnlockPassphrase); envPassphrase != "" { if envPassphrase := os.Getenv(EnvUnlockPassphrase); envPassphrase != "" {
@ -1456,38 +1492,12 @@ func (cli *CLIInstance) importMnemonic(vaultName, mnemonic string) error {
} }
} }
// Create passphrase-protected unlock key // Create passphrase-protected unlock key (vault is now unlocked)
passphraseKey, err := CreatePassphraseKey(cli.fs, cli.stateDir, passphraseStr) passphraseKey, err := vault.CreatePassphraseKey(passphraseStr)
if err != nil { if err != nil {
return fmt.Errorf("failed to create unlock key: %w", 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 := 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)
}
fmt.Printf("Successfully imported mnemonic into vault '%s'\n", vaultName) fmt.Printf("Successfully imported mnemonic into vault '%s'\n", vaultName)
fmt.Printf("Long-term public key: %s\n", ltPubKey) fmt.Printf("Long-term public key: %s\n", ltPubKey)
fmt.Printf("Unlock key ID: %s\n", passphraseKey.GetMetadata().ID) fmt.Printf("Unlock key ID: %s\n", passphraseKey.GetMetadata().ID)

View File

@ -0,0 +1,66 @@
package secret
import (
"os"
"path/filepath"
"testing"
"github.com/spf13/afero"
)
func TestCLIInstanceStateDir(t *testing.T) {
// Test the CLI instance state directory functionality
fs := afero.NewMemMapFs()
// Create a test state directory
testStateDir := "/test-state-dir"
cli := NewCLIInstanceWithStateDir(fs, testStateDir)
if cli.GetStateDir() != testStateDir {
t.Errorf("Expected state directory %q, got %q", testStateDir, cli.GetStateDir())
}
}
func TestCLIInstanceWithFs(t *testing.T) {
// Test creating CLI instance with custom filesystem
fs := afero.NewMemMapFs()
cli := NewCLIInstanceWithFs(fs)
// The state directory should be determined automatically
stateDir := cli.GetStateDir()
if stateDir == "" {
t.Error("Expected non-empty state directory")
}
}
func TestDetermineStateDir(t *testing.T) {
// Test the determineStateDir function
// Save original environment and restore it after test
originalStateDir := os.Getenv(EnvStateDir)
defer func() {
if originalStateDir == "" {
os.Unsetenv(EnvStateDir)
} else {
os.Setenv(EnvStateDir, originalStateDir)
}
}()
// Test with environment variable set
testEnvDir := "/test-env-dir"
os.Setenv(EnvStateDir, testEnvDir)
stateDir := determineStateDir("")
if stateDir != testEnvDir {
t.Errorf("Expected state directory %q from environment, got %q", testEnvDir, stateDir)
}
// Test with custom config dir
os.Unsetenv(EnvStateDir)
customConfigDir := "/custom-config"
stateDir = determineStateDir(customConfigDir)
expectedDir := filepath.Join(customConfigDir, AppID)
if stateDir != expectedDir {
t.Errorf("Expected state directory %q with custom config, got %q", expectedDir, stateDir)
}
}

View File

@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"fmt" "fmt"
"io" "io"
"os"
"syscall" "syscall"
"filippo.io/age" "filippo.io/age"
@ -12,21 +13,34 @@ import (
// encryptToRecipient encrypts data to a recipient using age // encryptToRecipient encrypts data to a recipient using age
func encryptToRecipient(data []byte, recipient age.Recipient) ([]byte, error) { func encryptToRecipient(data []byte, recipient age.Recipient) ([]byte, error) {
Debug("encryptToRecipient starting", "data_length", len(data))
var buf bytes.Buffer var buf bytes.Buffer
Debug("Creating age encryptor")
w, err := age.Encrypt(&buf, recipient) w, err := age.Encrypt(&buf, recipient)
if err != nil { if err != nil {
Debug("Failed to create encryptor", "error", err)
return nil, fmt.Errorf("failed to create encryptor: %w", err) return nil, fmt.Errorf("failed to create encryptor: %w", err)
} }
Debug("Created age encryptor successfully")
Debug("Writing data to encryptor")
if _, err := w.Write(data); err != nil { if _, err := w.Write(data); err != nil {
Debug("Failed to write data to encryptor", "error", err)
return nil, fmt.Errorf("failed to write data: %w", err) return nil, fmt.Errorf("failed to write data: %w", err)
} }
Debug("Wrote data to encryptor successfully")
Debug("Closing encryptor")
if err := w.Close(); err != nil { if err := w.Close(); err != nil {
Debug("Failed to close encryptor", "error", err)
return nil, fmt.Errorf("failed to close encryptor: %w", err) return nil, fmt.Errorf("failed to close encryptor: %w", err)
} }
Debug("Closed encryptor successfully")
return buf.Bytes(), nil result := buf.Bytes()
Debug("encryptToRecipient completed successfully", "result_length", len(result))
return result, nil
} }
// decryptWithIdentity decrypts data with an identity using age // decryptWithIdentity decrypts data with an identity using age
@ -67,25 +81,24 @@ func decryptWithPassphrase(encryptedData []byte, passphrase string) ([]byte, err
// readPassphrase reads a passphrase securely from the terminal without echoing // readPassphrase reads a passphrase securely from the terminal without echoing
// This version is for unlocking and doesn't require confirmation // This version is for unlocking and doesn't require confirmation
func readPassphrase(prompt string) (string, error) { 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 // Check if stdin is a terminal
if !term.IsTerminal(int(syscall.Stdin)) { if !term.IsTerminal(int(syscall.Stdin)) {
// Not a terminal - fall back to regular input // Not a terminal - use shared line reader to avoid buffering conflicts
fmt.Print(prompt) return readLineFromStdin(prompt)
var passphrase string
_, err := fmt.Scanln(&passphrase)
if err != nil {
return "", fmt.Errorf("failed to read passphrase: %w", err)
}
return passphrase, nil
} }
// Terminal input - use secure password reading // Terminal input - use secure password reading
fmt.Print(prompt) fmt.Fprint(os.Stderr, prompt) // Write prompt to stderr, not stdout
passphrase, err := term.ReadPassword(int(syscall.Stdin)) passphrase, err := term.ReadPassword(int(syscall.Stdin))
if err != nil { if err != nil {
return "", fmt.Errorf("failed to read passphrase: %w", err) return "", fmt.Errorf("failed to read passphrase: %w", err)
} }
fmt.Println() // Print newline since ReadPassword doesn't echo fmt.Fprintln(os.Stderr) // Print newline to stderr since ReadPassword doesn't echo
if len(passphrase) == 0 { if len(passphrase) == 0 {
return "", fmt.Errorf("passphrase cannot be empty") return "", fmt.Errorf("passphrase cannot be empty")

View File

@ -32,6 +32,9 @@ func initDebugLogging() {
return return
} }
// 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)
// Check if STDERR is a TTY // Check if STDERR is a TTY
isTTY := term.IsTerminal(int(syscall.Stderr)) isTTY := term.IsTerminal(int(syscall.Stderr))

View File

@ -0,0 +1,141 @@
package secret
import (
"bytes"
"log/slog"
"os"
"strings"
"syscall"
"testing"
"golang.org/x/term"
)
func TestDebugLogging(t *testing.T) {
// Save original GODEBUG and restore it
originalGodebug := os.Getenv("GODEBUG")
defer func() {
if originalGodebug == "" {
os.Unsetenv("GODEBUG")
} else {
os.Setenv("GODEBUG", originalGodebug)
}
// Re-initialize debug system with original setting
initDebugLogging()
}()
tests := []struct {
name string
godebug string
expectEnabled bool
}{
{
name: "debug enabled",
godebug: "berlin.sneak.pkg.secret",
expectEnabled: true,
},
{
name: "debug enabled with other flags",
godebug: "other=1,berlin.sneak.pkg.secret,another=value",
expectEnabled: true,
},
{
name: "debug disabled",
godebug: "other=1",
expectEnabled: false,
},
{
name: "debug disabled empty",
godebug: "",
expectEnabled: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Set GODEBUG
if tt.godebug == "" {
os.Unsetenv("GODEBUG")
} else {
os.Setenv("GODEBUG", tt.godebug)
}
// Re-initialize debug system
initDebugLogging()
// Test if debug is enabled
enabled := IsDebugEnabled()
if enabled != tt.expectEnabled {
t.Errorf("IsDebugEnabled() = %v, want %v", enabled, tt.expectEnabled)
}
// If debug should be enabled, test that debug output works
if tt.expectEnabled {
// Capture debug output by redirecting the colorized handler
var buf bytes.Buffer
// Override the debug logger for testing
oldLogger := debugLogger
if term.IsTerminal(int(syscall.Stderr)) {
// TTY: use colorized handler with our buffer
debugLogger = slog.New(newColorizedHandler(&buf))
} else {
// Non-TTY: use JSON handler with our buffer
debugLogger = slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{
Level: slog.LevelDebug,
}))
}
// Test debug output
Debug("test message", "key", "value")
// Restore original logger
debugLogger = oldLogger
// Check that output was generated
output := buf.String()
if !strings.Contains(output, "test message") {
t.Errorf("Debug output does not contain expected message. Got: %s", output)
}
}
})
}
}
func TestDebugFunctions(t *testing.T) {
// Enable debug for testing
originalGodebug := os.Getenv("GODEBUG")
os.Setenv("GODEBUG", "berlin.sneak.pkg.secret")
defer func() {
if originalGodebug == "" {
os.Unsetenv("GODEBUG")
} else {
os.Setenv("GODEBUG", originalGodebug)
}
initDebugLogging()
}()
initDebugLogging()
if !IsDebugEnabled() {
t.Skip("Debug not enabled, skipping debug function tests")
}
// Test that debug functions don't panic and can be called
t.Run("Debug", func(t *testing.T) {
Debug("test debug message")
Debug("test with args", "key", "value", "number", 42)
})
t.Run("DebugF", func(t *testing.T) {
DebugF("formatted message: %s %d", "test", 123)
})
t.Run("DebugWith", func(t *testing.T) {
DebugWith("structured message",
slog.String("string_key", "string_value"),
slog.Int("int_key", 42),
slog.Bool("bool_key", true),
)
})
}

View File

@ -225,7 +225,7 @@ func (s *Secret) GetEncryptedData() ([]byte, error) {
slog.String("vault_name", s.vault.Name), slog.String("vault_name", s.vault.Name),
) )
secretPath := filepath.Join(s.Directory, "secret.age") secretPath := filepath.Join(s.Directory, "value.age")
Debug("Reading encrypted secret file", "secret_path", secretPath) Debug("Reading encrypted secret file", "secret_path", secretPath)
@ -250,7 +250,7 @@ func (s *Secret) Exists() (bool, error) {
slog.String("vault_name", s.vault.Name), slog.String("vault_name", s.vault.Name),
) )
secretPath := filepath.Join(s.Directory, "secret.age") secretPath := filepath.Join(s.Directory, "value.age")
Debug("Checking secret file existence", "secret_path", secretPath) Debug("Checking secret file existence", "secret_path", secretPath)

View File

@ -0,0 +1,188 @@
package secret
import (
"bytes"
"os"
"path/filepath"
"testing"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/spf13/afero"
)
func TestPerSecretKeyFunctionality(t *testing.T) {
// Create an in-memory filesystem for testing
fs := afero.NewMemMapFs()
// Set up test environment variables
oldMnemonic := os.Getenv(EnvMnemonic)
defer func() {
if oldMnemonic == "" {
os.Unsetenv(EnvMnemonic)
} else {
os.Setenv(EnvMnemonic, oldMnemonic)
}
}()
// Set test mnemonic for direct encryption/decryption
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
os.Setenv(EnvMnemonic, testMnemonic)
// Set up a test vault structure
baseDir := "/test-config/berlin.sneak.pkg.secret"
stateDir := baseDir
vaultDir := filepath.Join(baseDir, "vaults.d", "test-vault")
// Create vault directory structure
err := fs.MkdirAll(filepath.Join(vaultDir, "secrets.d"), 0700)
if err != nil {
t.Fatalf("Failed to create vault directory: %v", err)
}
// Generate a long-term keypair for the vault using the test mnemonic
ltIdentity, err := agehd.DeriveIdentity(testMnemonic, 0)
if err != nil {
t.Fatalf("Failed to generate long-term identity: %v", err)
}
// Write long-term public key
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
err = afero.WriteFile(
fs,
ltPubKeyPath,
[]byte(ltIdentity.Recipient().String()),
0600,
)
if err != nil {
t.Fatalf("Failed to write long-term public key: %v", err)
}
// Set current vault
currentVaultPath := filepath.Join(baseDir, "currentvault")
err = afero.WriteFile(fs, currentVaultPath, []byte(vaultDir), 0600)
if err != nil {
t.Fatalf("Failed to set current vault: %v", err)
}
// Create vault instance
vault := NewVault(fs, "test-vault", stateDir)
// Test data
secretName := "test-secret"
secretValue := []byte("this is a test secret value")
// Test AddSecret
t.Run("AddSecret", func(t *testing.T) {
err := vault.AddSecret(secretName, secretValue, false)
if err != nil {
t.Fatalf("AddSecret failed: %v", err)
}
// Verify that all expected files were created
secretDir := filepath.Join(vaultDir, "secrets.d", secretName)
// Check value.age exists (the new per-secret key architecture format)
secretExists, err := afero.Exists(
fs,
filepath.Join(secretDir, "value.age"),
)
if err != nil || !secretExists {
t.Fatalf("value.age file was not created")
}
// Check metadata exists
metadataExists, err := afero.Exists(
fs,
filepath.Join(secretDir, "secret-metadata.json"),
)
if err != nil || !metadataExists {
t.Fatalf("secret-metadata.json file was not created")
}
t.Logf("All expected files created successfully")
})
// Test GetSecret
t.Run("GetSecret", func(t *testing.T) {
retrievedValue, err := vault.GetSecret(secretName)
if err != nil {
t.Fatalf("GetSecret failed: %v", err)
}
if !bytes.Equal(retrievedValue, secretValue) {
t.Fatalf(
"Retrieved value doesn't match original. Expected: %s, Got: %s",
string(secretValue),
string(retrievedValue),
)
}
t.Logf("Successfully retrieved secret: %s", string(retrievedValue))
})
// Test that different secrets get different keys
t.Run("DifferentSecretsGetDifferentKeys", func(t *testing.T) {
secretName2 := "test-secret-2"
secretValue2 := []byte("this is another test secret")
// Add second secret
err := vault.AddSecret(secretName2, secretValue2, false)
if err != nil {
t.Fatalf("Failed to add second secret: %v", err)
}
// Verify both secrets can be retrieved correctly
value1, err := vault.GetSecret(secretName)
if err != nil {
t.Fatalf("Failed to retrieve first secret: %v", err)
}
value2, err := vault.GetSecret(secretName2)
if err != nil {
t.Fatalf("Failed to retrieve second secret: %v", err)
}
if !bytes.Equal(value1, secretValue) {
t.Fatalf("First secret value mismatch")
}
if !bytes.Equal(value2, secretValue2) {
t.Fatalf("Second secret value mismatch")
}
t.Logf(
"Successfully verified that different secrets have different keys",
)
})
}
func TestSecretNameValidation(t *testing.T) {
tests := []struct {
name string
valid bool
}{
{"valid-name", true},
{"valid.name", true},
{"valid_name", true},
{"valid/path/name", true},
{"123valid", true},
{"", false},
{"Invalid-Name", false}, // uppercase not allowed
{"invalid name", false}, // space not allowed
{"invalid@name", false}, // @ not allowed
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result := isValidSecretName(test.name)
if result != test.valid {
t.Errorf(
"isValidSecretName(%q) = %v, want %v",
test.name,
result,
test.valid,
)
}
})
}
}

View File

@ -6,6 +6,7 @@ import (
"log/slog" "log/slog"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"strings" "strings"
"time" "time"
@ -49,6 +50,7 @@ type Vault struct {
Name string Name string
fs afero.Fs fs afero.Fs
stateDir string stateDir string
longTermKey *age.X25519Identity // In-memory long-term key when unlocked
} }
// NewVault creates a new Vault instance // NewVault creates a new Vault instance
@ -57,49 +59,208 @@ func NewVault(fs afero.Fs, name string, stateDir string) *Vault {
Name: name, Name: name,
fs: fs, fs: fs,
stateDir: stateDir, stateDir: stateDir,
longTermKey: nil,
} }
} }
// Locked returns true if the vault doesn't have a long-term key in memory
func (v *Vault) Locked() bool {
return v.longTermKey == nil
}
// Unlock sets the long-term key in memory, unlocking the vault
func (v *Vault) Unlock(key *age.X25519Identity) {
v.longTermKey = key
}
// GetLongTermKey returns the long-term key if available in memory
func (v *Vault) GetLongTermKey() *age.X25519Identity {
return v.longTermKey
}
// ClearLongTermKey removes the long-term key from memory (locks the vault)
func (v *Vault) ClearLongTermKey() {
v.longTermKey = nil
}
// GetOrDeriveLongTermKey gets the long-term key from memory or derives it from available sources
func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
// If we have it in memory, return it
if !v.Locked() {
return v.longTermKey, nil
}
Debug("Vault is locked, attempting to unlock", "vault_name", v.Name)
// Try to derive from environment mnemonic first
if envMnemonic := os.Getenv(EnvMnemonic); envMnemonic != "" {
Debug("Using mnemonic from environment for long-term key derivation", "vault_name", v.Name)
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, 0)
if err != nil {
Debug("Failed to derive long-term key from mnemonic", "error", err, "vault_name", v.Name)
return nil, fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
}
DebugWith("Successfully derived long-term key from mnemonic",
slog.String("vault_name", v.Name),
slog.String("public_key", ltIdentity.Recipient().String()),
)
return ltIdentity, nil
}
// No mnemonic available, try to use current unlock key
Debug("No mnemonic available, using current unlock key to unlock vault", "vault_name", v.Name)
// Get current unlock key
unlockKey, err := v.GetCurrentUnlockKey()
if err != nil {
Debug("Failed to get current unlock key", "error", err, "vault_name", v.Name)
return nil, fmt.Errorf("failed to get current unlock key: %w", err)
}
DebugWith("Retrieved current unlock key for vault unlock",
slog.String("vault_name", v.Name),
slog.String("unlock_key_type", unlockKey.GetType()),
slog.String("unlock_key_id", unlockKey.GetID()),
)
// Get unlock key identity
unlockIdentity, err := unlockKey.GetIdentity()
if err != nil {
Debug("Failed to get unlock key identity", "error", err, "unlock_key_type", unlockKey.GetType())
return nil, fmt.Errorf("failed to get unlock key identity: %w", err)
}
// Read encrypted long-term private key from unlock key directory
unlockKeyDir := unlockKey.GetDirectory()
encryptedLtPrivKeyPath := filepath.Join(unlockKeyDir, "longterm.age")
Debug("Reading encrypted long-term private key", "path", encryptedLtPrivKeyPath)
encryptedLtPrivKey, err := afero.ReadFile(v.fs, encryptedLtPrivKeyPath)
if err != nil {
Debug("Failed to read encrypted long-term private key", "error", err, "path", encryptedLtPrivKeyPath)
return nil, fmt.Errorf("failed to read encrypted long-term private key: %w", err)
}
DebugWith("Read encrypted long-term private key",
slog.String("vault_name", v.Name),
slog.String("unlock_key_type", unlockKey.GetType()),
slog.Int("encrypted_length", len(encryptedLtPrivKey)),
)
// Decrypt long-term private key using unlock key
Debug("Decrypting long-term private key with unlock key", "unlock_key_type", unlockKey.GetType())
ltPrivKeyData, err := decryptWithIdentity(encryptedLtPrivKey, unlockIdentity)
if err != nil {
Debug("Failed to decrypt long-term private key", "error", err, "unlock_key_type", unlockKey.GetType())
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
}
DebugWith("Successfully decrypted long-term private key",
slog.String("vault_name", v.Name),
slog.String("unlock_key_type", unlockKey.GetType()),
slog.Int("decrypted_length", len(ltPrivKeyData)),
)
// Parse long-term private key
Debug("Parsing long-term private key", "vault_name", v.Name)
ltIdentity, err := age.ParseX25519Identity(string(ltPrivKeyData))
if err != nil {
Debug("Failed to parse long-term private key", "error", err, "vault_name", v.Name)
return nil, fmt.Errorf("failed to parse long-term private key: %w", err)
}
DebugWith("Successfully obtained long-term identity via unlock key",
slog.String("vault_name", v.Name),
slog.String("unlock_key_type", unlockKey.GetType()),
slog.String("public_key", ltIdentity.Recipient().String()),
)
return ltIdentity, nil
}
// resolveVaultSymlink resolves the currentvault symlink by changing into it and getting the absolute path // resolveVaultSymlink resolves the currentvault symlink by changing into it and getting the absolute path
func resolveVaultSymlink(fs afero.Fs, symlinkPath string) (string, error) { func resolveVaultSymlink(fs afero.Fs, symlinkPath string) (string, error) {
Debug("resolveVaultSymlink starting", "symlink_path", symlinkPath)
// For real filesystems, we can use os.Chdir and os.Getwd // For real filesystems, we can use os.Chdir and os.Getwd
if _, ok := fs.(*afero.OsFs); ok { if _, ok := fs.(*afero.OsFs); ok {
Debug("Using real filesystem symlink resolution")
// Check what the symlink points to first
Debug("Checking symlink target", "symlink_path", symlinkPath)
linkTarget, err := os.Readlink(symlinkPath)
if err != nil {
Debug("Failed to read symlink target", "error", err, "symlink_path", symlinkPath)
// Maybe it's not a symlink, try reading as file
Debug("Trying to read as file instead of symlink")
targetBytes, err := os.ReadFile(symlinkPath)
if err != nil {
Debug("Failed to read as file", "error", err)
return "", fmt.Errorf("failed to read vault symlink or file: %w", err)
}
linkTarget = strings.TrimSpace(string(targetBytes))
Debug("Read vault path from file", "target", linkTarget)
return linkTarget, nil
}
Debug("Symlink points to", "target", linkTarget)
// Save current directory // Save current directory
Debug("Getting current directory")
originalDir, err := os.Getwd() originalDir, err := os.Getwd()
if err != nil { if err != nil {
Debug("Failed to get current directory", "error", err)
return "", fmt.Errorf("failed to get current directory: %w", err) return "", fmt.Errorf("failed to get current directory: %w", err)
} }
Debug("Got current directory", "original_dir", originalDir)
// Change to the symlink directory // Change to the symlink directory
Debug("Changing to symlink directory", "symlink_path", symlinkPath)
Debug("About to call os.Chdir - this might hang if symlink is broken")
err = os.Chdir(symlinkPath) err = os.Chdir(symlinkPath)
if err != nil { if err != nil {
Debug("Failed to change into vault symlink", "error", err, "symlink_path", symlinkPath)
return "", fmt.Errorf("failed to change into vault symlink: %w", err) return "", fmt.Errorf("failed to change into vault symlink: %w", err)
} }
Debug("Changed to symlink directory successfully - os.Chdir completed")
// Get absolute path of current directory // Get absolute path of current directory
Debug("Getting absolute path of current directory")
absolutePath, err := os.Getwd() absolutePath, err := os.Getwd()
if err != nil { if err != nil {
Debug("Failed to get absolute path", "error", err)
// Try to restore original directory before returning error // Try to restore original directory before returning error
if restoreErr := os.Chdir(originalDir); restoreErr != nil { if restoreErr := os.Chdir(originalDir); restoreErr != nil {
Debug("Failed to restore original directory", "restore_error", restoreErr)
return "", fmt.Errorf("failed to get absolute path: %w (and failed to restore directory: %v)", err, restoreErr) return "", fmt.Errorf("failed to get absolute path: %w (and failed to restore directory: %v)", err, restoreErr)
} }
return "", fmt.Errorf("failed to get absolute path: %w", err) return "", fmt.Errorf("failed to get absolute path: %w", err)
} }
Debug("Got absolute path", "absolute_path", absolutePath)
// Restore original directory // Restore original directory
Debug("Restoring original directory", "original_dir", originalDir)
err = os.Chdir(originalDir) err = os.Chdir(originalDir)
if err != nil { if err != nil {
Debug("Failed to restore original directory", "error", err, "original_dir", originalDir)
return "", fmt.Errorf("failed to restore original directory: %w", err) return "", fmt.Errorf("failed to restore original directory: %w", err)
} }
Debug("Restored original directory successfully")
Debug("resolveVaultSymlink completed successfully", "result", absolutePath)
return absolutePath, nil return absolutePath, nil
} else { } else {
Debug("Using mock filesystem fallback")
// Fallback for mock filesystems: read the path from file contents // Fallback for mock filesystems: read the path from file contents
targetBytes, err := afero.ReadFile(fs, symlinkPath) targetBytes, err := afero.ReadFile(fs, symlinkPath)
if err != nil { if err != nil {
Debug("Failed to read vault path from file", "error", err, "symlink_path", symlinkPath)
return "", fmt.Errorf("failed to read vault path: %w", err) return "", fmt.Errorf("failed to read vault path: %w", err)
} }
return strings.TrimSpace(string(targetBytes)), nil result := strings.TrimSpace(string(targetBytes))
Debug("Read vault path from file", "result", result)
return result, nil
} }
} }
@ -108,6 +269,7 @@ func GetCurrentVault(fs afero.Fs, stateDir string) (*Vault, error) {
DebugWith("Getting current vault", slog.String("state_dir", stateDir)) DebugWith("Getting current vault", slog.String("state_dir", stateDir))
currentVaultPath := filepath.Join(stateDir, "currentvault") currentVaultPath := filepath.Join(stateDir, "currentvault")
Debug("Checking current vault symlink", "path", currentVaultPath)
// Check if the symlink exists // Check if the symlink exists
_, err := fs.Stat(currentVaultPath) _, err := fs.Stat(currentVaultPath)
@ -115,24 +277,32 @@ func GetCurrentVault(fs afero.Fs, stateDir string) (*Vault, error) {
Debug("Failed to stat current vault symlink", "error", err, "path", currentVaultPath) Debug("Failed to stat current vault symlink", "error", err, "path", currentVaultPath)
return nil, fmt.Errorf("failed to read current vault symlink: %w", err) return nil, fmt.Errorf("failed to read current vault symlink: %w", err)
} }
Debug("Current vault symlink exists")
// Resolve the symlink to get the target directory // Resolve the symlink to get the target directory
Debug("Resolving vault symlink")
targetPath, err := resolveVaultSymlink(fs, currentVaultPath) targetPath, err := resolveVaultSymlink(fs, currentVaultPath)
if err != nil { if err != nil {
Debug("Failed to resolve vault symlink", "error", err, "symlink_path", currentVaultPath) Debug("Failed to resolve vault symlink", "error", err, "symlink_path", currentVaultPath)
return nil, err return nil, err
} }
Debug("Resolved vault symlink", "target_path", targetPath)
// Extract vault name from the target path // Extract vault name from the target path
// Target path should be something like "/state/vaults.d/vaultname" // Target path should be something like "/state/vaults.d/vaultname"
vaultName := filepath.Base(targetPath) vaultName := filepath.Base(targetPath)
Debug("Extracted vault name", "vault_name", vaultName)
DebugWith("Current vault resolved", DebugWith("Current vault resolved",
slog.String("vault_name", vaultName), slog.String("vault_name", vaultName),
slog.String("target_path", targetPath), slog.String("target_path", targetPath),
) )
return NewVault(fs, vaultName, stateDir), nil Debug("Creating NewVault instance")
vault := NewVault(fs, vaultName, stateDir)
Debug("Created NewVault instance successfully")
return vault, nil
} }
// ListVaults returns a list of all available vaults // ListVaults returns a list of all available vaults
@ -259,6 +429,7 @@ func SelectVault(fs afero.Fs, stateDir string, name string) error {
return fmt.Errorf("failed to create symlink for current vault: %w", err) return fmt.Errorf("failed to create symlink for current vault: %w", err)
} }
} else { } else {
// FIXME this code should not exist! we do not support the currentvaultpath not being a symlink. remove this!
Debug("Creating vault path file (symlinks not supported)", "target", vaultDir, "file", currentVaultPath) Debug("Creating vault path file (symlinks not supported)", "target", vaultDir, "file", currentVaultPath)
// Fallback: write the vault directory path as a regular file // Fallback: write the vault directory path as a regular file
if err := afero.WriteFile(fs, currentVaultPath, []byte(vaultDir), 0600); err != nil { if err := afero.WriteFile(fs, currentVaultPath, []byte(vaultDir), 0600); err != nil {
@ -324,6 +495,15 @@ func (v *Vault) ListSecrets() ([]string, error) {
return secrets, nil return secrets, nil
} }
// isValidSecretName validates secret names according to the format [a-z0-9\.\-\_\/]+
func isValidSecretName(name string) bool {
if name == "" {
return false
}
matched, _ := regexp.MatchString(`^[a-z0-9\.\-\_\/]+$`, name)
return matched
}
// AddSecret adds a secret to this vault // AddSecret adds a secret to this vault
func (v *Vault) AddSecret(name string, value []byte, force bool) error { func (v *Vault) AddSecret(name string, value []byte, force bool) error {
DebugWith("Adding secret to vault", DebugWith("Adding secret to vault",
@ -333,11 +513,20 @@ func (v *Vault) AddSecret(name string, value []byte, force bool) error {
slog.Bool("force", force), slog.Bool("force", force),
) )
// Validate secret name
if !isValidSecretName(name) {
Debug("Invalid secret name provided", "secret_name", name)
return fmt.Errorf("invalid secret name '%s': must match pattern [a-z0-9.\\-_/]+", name)
}
Debug("Secret name validation passed", "secret_name", name)
Debug("Getting vault directory")
vaultDir, err := v.GetDirectory() vaultDir, err := v.GetDirectory()
if err != nil { if err != nil {
Debug("Failed to get vault directory for secret addition", "error", err, "vault_name", v.Name) Debug("Failed to get vault directory for secret addition", "error", err, "vault_name", v.Name)
return err return err
} }
Debug("Got vault directory", "vault_dir", vaultDir)
// Convert slashes to percent signs for storage // Convert slashes to percent signs for storage
storageName := strings.ReplaceAll(name, "/", "%") storageName := strings.ReplaceAll(name, "/", "%")
@ -349,11 +538,14 @@ func (v *Vault) AddSecret(name string, value []byte, force bool) error {
) )
// Check if secret already exists // Check if secret already exists
Debug("Checking if secret already exists", "secret_dir", secretDir)
exists, err := afero.DirExists(v.fs, secretDir) exists, err := afero.DirExists(v.fs, secretDir)
if err != nil { if err != nil {
Debug("Failed to check if secret exists", "error", err, "secret_dir", secretDir) Debug("Failed to check if secret exists", "error", err, "secret_dir", secretDir)
return fmt.Errorf("failed to check if secret exists: %w", err) return fmt.Errorf("failed to check if secret exists: %w", err)
} }
Debug("Secret existence check complete", "exists", exists)
if exists && !force { if exists && !force {
Debug("Secret already exists and force not specified", "secret_name", name, "secret_dir", secretDir) Debug("Secret already exists and force not specified", "secret_name", name, "secret_dir", secretDir)
return fmt.Errorf("secret %s already exists (use --force to overwrite)", name) return fmt.Errorf("secret %s already exists (use --force to overwrite)", name)
@ -365,8 +557,56 @@ func (v *Vault) AddSecret(name string, value []byte, force bool) error {
Debug("Failed to create secret directory", "error", err, "secret_dir", secretDir) Debug("Failed to create secret directory", "error", err, "secret_dir", secretDir)
return fmt.Errorf("failed to create secret directory: %w", err) return fmt.Errorf("failed to create secret directory: %w", err)
} }
Debug("Created secret directory successfully")
// Get long-term public key for encryption // Step 1: Generate a new keypair for this secret
Debug("Generating secret-specific keypair", "secret_name", name)
secretIdentity, err := age.GenerateX25519Identity()
if err != nil {
Debug("Failed to generate secret keypair", "error", err, "secret_name", name)
return fmt.Errorf("failed to generate secret keypair: %w", err)
}
secretPublicKey := secretIdentity.Recipient().String()
secretPrivateKey := secretIdentity.String()
DebugWith("Generated secret keypair",
slog.String("secret_name", name),
slog.String("public_key", secretPublicKey),
)
// Step 2: Store the secret's public key
pubKeyPath := filepath.Join(secretDir, "pub.age")
Debug("Writing secret public key", "path", pubKeyPath)
if err := afero.WriteFile(v.fs, pubKeyPath, []byte(secretPublicKey), 0600); err != nil {
Debug("Failed to write secret public key", "error", err, "path", pubKeyPath)
return fmt.Errorf("failed to write secret public key: %w", err)
}
Debug("Wrote secret public key successfully")
// Step 3: Encrypt the secret value to the secret's public key
Debug("Encrypting secret value to secret's public key", "secret_name", name)
encryptedValue, err := encryptToRecipient(value, secretIdentity.Recipient())
if err != nil {
Debug("Failed to encrypt secret value", "error", err, "secret_name", name)
return fmt.Errorf("failed to encrypt secret value: %w", err)
}
DebugWith("Secret value encrypted",
slog.String("secret_name", name),
slog.Int("encrypted_length", len(encryptedValue)),
)
// Step 4: Store the encrypted secret value as value.age
valuePath := filepath.Join(secretDir, "value.age")
Debug("Writing encrypted secret value", "path", valuePath)
if err := afero.WriteFile(v.fs, valuePath, encryptedValue, 0600); err != nil {
Debug("Failed to write encrypted secret value", "error", err, "path", valuePath)
return fmt.Errorf("failed to write encrypted secret value: %w", err)
}
Debug("Wrote encrypted secret value successfully")
// Step 5: Get long-term public key for encrypting the secret's private key
ltPubKeyPath := filepath.Join(vaultDir, "pub.age") ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
Debug("Reading long-term public key", "path", ltPubKeyPath) Debug("Reading long-term public key", "path", ltPubKeyPath)
@ -375,7 +615,9 @@ func (v *Vault) AddSecret(name string, value []byte, force bool) error {
Debug("Failed to read long-term public key", "error", err, "path", ltPubKeyPath) Debug("Failed to read long-term public key", "error", err, "path", ltPubKeyPath)
return fmt.Errorf("failed to read long-term public key: %w", err) return fmt.Errorf("failed to read long-term public key: %w", err)
} }
Debug("Read long-term public key successfully", "key_length", len(ltPubKeyData))
Debug("Parsing long-term public key")
ltRecipient, err := age.ParseX25519Recipient(string(ltPubKeyData)) ltRecipient, err := age.ParseX25519Recipient(string(ltPubKeyData))
if err != nil { if err != nil {
Debug("Failed to parse long-term public key", "error", err) Debug("Failed to parse long-term public key", "error", err)
@ -384,25 +626,30 @@ func (v *Vault) AddSecret(name string, value []byte, force bool) error {
DebugWith("Parsed long-term public key", slog.String("recipient", ltRecipient.String())) DebugWith("Parsed long-term public key", slog.String("recipient", ltRecipient.String()))
// Encrypt secret data // Step 6: Encrypt the secret's private key to the long-term public key
Debug("Encrypting secret data") Debug("Encrypting secret private key to long-term public key", "secret_name", name)
encryptedData, err := encryptToRecipient(value, ltRecipient) encryptedPrivKey, err := encryptToRecipient([]byte(secretPrivateKey), ltRecipient)
if err != nil { if err != nil {
Debug("Failed to encrypt secret", "error", err) Debug("Failed to encrypt secret private key", "error", err, "secret_name", name)
return fmt.Errorf("failed to encrypt secret: %w", err) return fmt.Errorf("failed to encrypt secret private key: %w", err)
} }
DebugWith("Secret encrypted", slog.Int("encrypted_length", len(encryptedData))) DebugWith("Secret private key encrypted",
slog.String("secret_name", name),
slog.Int("encrypted_length", len(encryptedPrivKey)),
)
// Write encrypted secret // Step 7: Store the encrypted secret private key as priv.age
secretPath := filepath.Join(secretDir, "secret.age") privKeyPath := filepath.Join(secretDir, "priv.age")
Debug("Writing encrypted secret", "path", secretPath) Debug("Writing encrypted secret private key", "path", privKeyPath)
if err := afero.WriteFile(v.fs, secretPath, encryptedData, 0600); err != nil { if err := afero.WriteFile(v.fs, privKeyPath, encryptedPrivKey, 0600); err != nil {
Debug("Failed to write encrypted secret", "error", err, "path", secretPath) Debug("Failed to write encrypted secret private key", "error", err, "path", privKeyPath)
return fmt.Errorf("failed to write encrypted secret: %w", err) return fmt.Errorf("failed to write encrypted secret private key: %w", err)
} }
Debug("Wrote encrypted secret private key successfully")
// Create and write metadata // Step 8: Create and write metadata
Debug("Creating secret metadata")
now := time.Now() now := time.Now()
metadata := SecretMetadata{ metadata := SecretMetadata{
Name: name, Name: name,
@ -416,11 +663,13 @@ func (v *Vault) AddSecret(name string, value []byte, force bool) error {
slog.Time("updated_at", metadata.UpdatedAt), slog.Time("updated_at", metadata.UpdatedAt),
) )
Debug("Marshaling secret metadata")
metadataBytes, err := json.MarshalIndent(metadata, "", " ") metadataBytes, err := json.MarshalIndent(metadata, "", " ")
if err != nil { if err != nil {
Debug("Failed to marshal secret metadata", "error", err) Debug("Failed to marshal secret metadata", "error", err)
return fmt.Errorf("failed to marshal secret metadata: %w", err) return fmt.Errorf("failed to marshal secret metadata: %w", err)
} }
Debug("Marshaled secret metadata successfully")
metadataPath := filepath.Join(secretDir, "secret-metadata.json") metadataPath := filepath.Join(secretDir, "secret-metadata.json")
Debug("Writing secret metadata", "path", metadataPath) Debug("Writing secret metadata", "path", metadataPath)
@ -428,8 +677,9 @@ func (v *Vault) AddSecret(name string, value []byte, force bool) error {
Debug("Failed to write secret metadata", "error", err, "path", metadataPath) Debug("Failed to write secret metadata", "error", err, "path", metadataPath)
return fmt.Errorf("failed to write secret metadata: %w", err) return fmt.Errorf("failed to write secret metadata: %w", err)
} }
Debug("Wrote secret metadata successfully")
Debug("Successfully added secret to vault", "secret_name", name, "vault_name", v.Name) Debug("Successfully added secret to vault with per-secret key architecture", "secret_name", name, "vault_name", v.Name)
return nil return nil
} }
@ -440,18 +690,7 @@ func (v *Vault) GetSecret(name string) ([]byte, error) {
slog.String("secret_name", name), slog.String("secret_name", name),
) )
// Check if we have SB_SECRET_MNEMONIC environment variable for direct decryption // Create a secret object to handle file access
if envMnemonic := os.Getenv(EnvMnemonic); envMnemonic != "" {
Debug("Using mnemonic from environment for secret decryption")
// Use mnemonic directly to derive long-term key
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, 0)
if err != nil {
Debug("Failed to derive long-term key from environment mnemonic", "error", err)
return nil, fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
}
// Create a secret object to read the encrypted data
secret := NewSecret(v, name) secret := NewSecret(v, name)
// Check if secret exists // Check if secret exists
@ -465,80 +704,147 @@ func (v *Vault) GetSecret(name string) ([]byte, error) {
return nil, fmt.Errorf("secret %s not found", name) return nil, fmt.Errorf("secret %s not found", name)
} }
Debug("Secret exists, reading encrypted data", "secret_name", name) Debug("Secret exists, proceeding with vault unlock and decryption", "secret_name", name)
// Read encrypted secret data // Step 1: Unlock the vault (get long-term key in memory)
encryptedData, err := secret.GetEncryptedData() longTermIdentity, err := v.UnlockVault()
if err != nil { if err != nil {
Debug("Failed to get encrypted secret data", "error", err, "secret_name", name) Debug("Failed to unlock vault", "error", err, "vault_name", v.Name)
return nil, err return nil, fmt.Errorf("failed to unlock vault: %w", err)
} }
DebugWith("Retrieved encrypted secret data", DebugWith("Successfully unlocked vault",
slog.String("vault_name", v.Name),
slog.String("secret_name", name), slog.String("secret_name", name),
slog.Int("encrypted_length", len(encryptedData)), slog.String("long_term_public_key", longTermIdentity.Recipient().String()),
) )
// Decrypt secret data // Step 2: Use the unlocked vault to decrypt the secret
Debug("Decrypting secret with long-term key", "secret_name", name) decryptedValue, err := v.decryptSecretWithLongTermKey(name, longTermIdentity)
decryptedData, err := decryptWithIdentity(encryptedData, ltIdentity)
if err != nil { if err != nil {
Debug("Failed to decrypt secret", "error", err, "secret_name", name) Debug("Failed to decrypt secret with long-term key", "error", err, "secret_name", name)
return nil, fmt.Errorf("failed to decrypt secret: %w", err) return nil, fmt.Errorf("failed to decrypt secret: %w", err)
} }
DebugWith("Successfully decrypted secret", DebugWith("Successfully decrypted secret with per-secret key architecture",
slog.String("secret_name", name), slog.String("secret_name", name),
slog.Int("decrypted_length", len(decryptedData)), slog.String("vault_name", v.Name),
slog.Int("decrypted_length", len(decryptedValue)),
) )
return decryptedData, nil return decryptedValue, nil
} }
Debug("Using unlock key for secret decryption", "secret_name", name) // UnlockVault unlocks the vault and returns the long-term private key
func (v *Vault) UnlockVault() (*age.X25519Identity, error) {
Debug("Unlocking vault", "vault_name", v.Name)
// Use unlock key to decrypt the secret // If vault is already unlocked, return the cached key
unlockKey, err := v.GetCurrentUnlockKey() if !v.Locked() {
Debug("Vault already unlocked, returning cached long-term key", "vault_name", v.Name)
return v.longTermKey, nil
}
// Get or derive the long-term key (but don't store it yet)
longTermIdentity, err := v.GetOrDeriveLongTermKey()
if err != nil { if err != nil {
Debug("Failed to get current unlock key", "error", err, "vault_name", v.Name) Debug("Failed to get or derive long-term key", "error", err, "vault_name", v.Name)
return nil, fmt.Errorf("failed to get current unlock key: %w", err) return nil, fmt.Errorf("failed to get long-term key: %w", err)
} }
DebugWith("Retrieved current unlock key", // Now unlock the vault by storing the key in memory
slog.String("unlock_key_type", unlockKey.GetType()), v.Unlock(longTermIdentity)
slog.String("unlock_key_id", unlockKey.GetID()),
DebugWith("Successfully unlocked vault",
slog.String("vault_name", v.Name),
slog.String("public_key", longTermIdentity.Recipient().String()),
) )
// Create a secret object return longTermIdentity, nil
secret := NewSecret(v, name)
// Check if secret exists
exists, err := secret.Exists()
if err != nil {
Debug("Failed to check if secret exists via unlock key", "error", err, "secret_name", name)
return nil, fmt.Errorf("failed to check if secret exists: %w", err)
}
if !exists {
Debug("Secret not found via unlock key", "secret_name", name, "vault_name", v.Name)
return nil, fmt.Errorf("secret %s not found", name)
} }
Debug("Decrypting secret via unlock key", "secret_name", name, "unlock_key_type", unlockKey.GetType()) // decryptSecretWithLongTermKey decrypts a secret using the provided long-term key
func (v *Vault) decryptSecretWithLongTermKey(name string, longTermIdentity *age.X25519Identity) ([]byte, error) {
DebugWith("Decrypting secret with long-term key",
slog.String("secret_name", name),
slog.String("vault_name", v.Name),
)
// Let the unlock key handle decryption // Get vault and secret directories
decryptedData, err := unlockKey.DecryptSecret(secret) vaultDir, err := v.GetDirectory()
if err != nil { if err != nil {
Debug("Failed to decrypt secret via unlock key", "error", err, "secret_name", name, "unlock_key_type", unlockKey.GetType()) Debug("Failed to get vault directory", "error", err, "vault_name", v.Name)
return nil, err return nil, err
} }
DebugWith("Successfully decrypted secret via unlock key", storageName := strings.ReplaceAll(name, "/", "%")
secretDir := filepath.Join(vaultDir, "secrets.d", storageName)
// Step 1: Read the encrypted secret private key from priv.age
encryptedSecretPrivKeyPath := filepath.Join(secretDir, "priv.age")
Debug("Reading encrypted secret private key", "path", encryptedSecretPrivKeyPath)
encryptedSecretPrivKey, err := afero.ReadFile(v.fs, encryptedSecretPrivKeyPath)
if err != nil {
Debug("Failed to read encrypted secret private key", "error", err, "path", encryptedSecretPrivKeyPath)
return nil, fmt.Errorf("failed to read encrypted secret private key: %w", err)
}
DebugWith("Read encrypted secret private key",
slog.String("secret_name", name), slog.String("secret_name", name),
slog.String("unlock_key_type", unlockKey.GetType()), slog.Int("encrypted_length", len(encryptedSecretPrivKey)),
slog.Int("decrypted_length", len(decryptedData)),
) )
return decryptedData, nil // Step 2: Decrypt the secret's private key using the long-term private key
Debug("Decrypting secret private key with long-term key", "secret_name", name)
secretPrivKeyData, err := decryptWithIdentity(encryptedSecretPrivKey, longTermIdentity)
if err != nil {
Debug("Failed to decrypt secret private key", "error", err, "secret_name", name)
return nil, fmt.Errorf("failed to decrypt secret private key: %w", err)
}
// Step 3: Parse the secret's private key
Debug("Parsing secret private key", "secret_name", name)
secretIdentity, err := age.ParseX25519Identity(string(secretPrivKeyData))
if err != nil {
Debug("Failed to parse secret private key", "error", err, "secret_name", name)
return nil, fmt.Errorf("failed to parse secret private key: %w", err)
}
DebugWith("Successfully parsed secret identity",
slog.String("secret_name", name),
slog.String("public_key", secretIdentity.Recipient().String()),
)
// Step 4: Read the encrypted secret value from value.age
encryptedValuePath := filepath.Join(secretDir, "value.age")
Debug("Reading encrypted secret value", "path", encryptedValuePath)
encryptedValue, err := afero.ReadFile(v.fs, encryptedValuePath)
if err != nil {
Debug("Failed to read encrypted secret value", "error", err, "path", encryptedValuePath)
return nil, fmt.Errorf("failed to read encrypted secret value: %w", err)
}
DebugWith("Read encrypted secret value",
slog.String("secret_name", name),
slog.Int("encrypted_length", len(encryptedValue)),
)
// Step 5: Decrypt the secret value using the secret's private key
Debug("Decrypting secret value with secret's private key", "secret_name", name)
decryptedValue, err := decryptWithIdentity(encryptedValue, secretIdentity)
if err != nil {
Debug("Failed to decrypt secret value", "error", err, "secret_name", name)
return nil, fmt.Errorf("failed to decrypt secret value: %w", err)
}
DebugWith("Successfully decrypted secret value",
slog.String("secret_name", name),
slog.Int("decrypted_length", len(decryptedValue)),
)
return decryptedValue, nil
} }
// GetSecretObject retrieves a Secret object with metadata loaded from this vault // GetSecretObject retrieves a Secret object with metadata loaded from this vault
@ -873,7 +1179,12 @@ func (v *Vault) SelectUnlockKey(keyID string) error {
} }
// CreatePassphraseKey creates a new passphrase-protected unlock key for this vault // CreatePassphraseKey creates a new passphrase-protected unlock key for this vault
// The vault must be unlocked (have a long-term key in memory) before calling this method
func (v *Vault) CreatePassphraseKey(passphrase string) (*PassphraseUnlockKey, error) { func (v *Vault) CreatePassphraseKey(passphrase string) (*PassphraseUnlockKey, error) {
if v.Locked() {
return nil, fmt.Errorf("vault must be unlocked before creating passphrase key")
}
vaultDir, err := v.GetDirectory() vaultDir, err := v.GetDirectory()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get vault directory: %w", err) return nil, fmt.Errorf("failed to get vault directory: %w", err)
@ -923,78 +1234,11 @@ func (v *Vault) CreatePassphraseKey(passphrase string) (*PassphraseUnlockKey, er
return nil, fmt.Errorf("failed to write encrypted private key: %w", err) return nil, fmt.Errorf("failed to write encrypted private key: %w", err)
} }
// Get or derive the long-term private key // Get the long-term private key from memory (vault must be unlocked)
var ltPrivKeyData []byte ltPrivKey := []byte(v.longTermKey.String())
// Check if mnemonic is available in environment variable
if envMnemonic := os.Getenv(EnvMnemonic); envMnemonic != "" {
// Use mnemonic directly to derive long-term key
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, 0)
if err != nil {
return nil, fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
}
ltPrivKeyData = []byte(ltIdentity.String())
} else {
// Try to get the long-term private key from the current unlock key
currentUnlockKeyPath := filepath.Join(vaultDir, "current-unlock-key")
// Check if current unlock key exists
_, err := v.fs.Stat(currentUnlockKeyPath)
if err != nil {
return nil, fmt.Errorf("no current unlock key found and no mnemonic available in environment. Set SB_SECRET_MNEMONIC or ensure a current unlock key exists")
}
// Resolve the current unlock key path
var currentUnlockKeyDir string
if _, ok := v.fs.(*afero.OsFs); ok {
// For real filesystems, resolve the symlink properly
currentUnlockKeyDir, err = resolveVaultSymlink(v.fs, currentUnlockKeyPath)
if err != nil {
return nil, fmt.Errorf("failed to resolve current unlock key symlink: %w", err)
}
} else {
// Fallback for mock filesystems: read the path from file contents
currentUnlockKeyTarget, err := afero.ReadFile(v.fs, currentUnlockKeyPath)
if err != nil {
return nil, fmt.Errorf("failed to read current unlock key: %w", err)
}
currentUnlockKeyDir = strings.TrimSpace(string(currentUnlockKeyTarget))
}
// Read the current unlock key's encrypted private key
currentEncPrivKeyData, err := afero.ReadFile(v.fs, filepath.Join(currentUnlockKeyDir, "priv.age"))
if err != nil {
return nil, fmt.Errorf("failed to read current unlock key private key: %w", err)
}
// Decrypt the current unlock key private key with the same passphrase
// (assuming the user wants to use the same passphrase for the new key)
currentPrivKeyData, err := decryptWithPassphrase(currentEncPrivKeyData, passphrase)
if err != nil {
return nil, fmt.Errorf("failed to decrypt current unlock key private key: %w", err)
}
// Parse the current unlock key
currentIdentity, err := age.ParseX25519Identity(string(currentPrivKeyData))
if err != nil {
return nil, fmt.Errorf("failed to parse current unlock key: %w", err)
}
// Read the encrypted long-term private key
encryptedLtPrivKey, err := afero.ReadFile(v.fs, filepath.Join(currentUnlockKeyDir, "longterm.age"))
if err != nil {
return nil, fmt.Errorf("failed to read encrypted long-term private key: %w", err)
}
// Decrypt the long-term private key using the current unlock key
ltPrivKeyData, err = decryptWithIdentity(encryptedLtPrivKey, currentIdentity)
if err != nil {
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
}
}
// Encrypt the long-term private key to the new unlock key // Encrypt the long-term private key to the new unlock key
encryptedLtPrivKey, err := encryptToRecipient(ltPrivKeyData, identity.Recipient()) encryptedLtPrivKey, err := encryptToRecipient(ltPrivKey, identity.Recipient())
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to encrypt long-term private key to new unlock key: %w", err) return nil, fmt.Errorf("failed to encrypt long-term private key to new unlock key: %w", err)
} }

View File

@ -0,0 +1,574 @@
package secret
import (
"os"
"path/filepath"
"testing"
"time"
"filippo.io/age"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/spf13/afero"
)
const testMnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
// setupTestEnvironment sets up the test environment with mock filesystem and environment variables
func setupTestEnvironment(t *testing.T) (afero.Fs, func()) {
// Create mock filesystem
fs := afero.NewMemMapFs()
// Save original environment variables
oldMnemonic := os.Getenv(EnvMnemonic)
oldPassphrase := os.Getenv(EnvUnlockPassphrase)
oldStateDir := os.Getenv(EnvStateDir)
// Create a real temporary directory for the state directory
// This is needed because GetStateDir checks the real filesystem
realTempDir, err := os.MkdirTemp("", "secret-test-*")
if err != nil {
t.Fatalf("Failed to create real temp directory: %v", err)
}
// Set test environment variables
os.Setenv(EnvMnemonic, testMnemonic)
os.Setenv(EnvUnlockPassphrase, "test-passphrase")
os.Setenv(EnvStateDir, realTempDir)
// Also create the directory structure in the mock filesystem
err = fs.MkdirAll(realTempDir, 0700)
if err != nil {
t.Fatalf("Failed to create test state directory in mock fs: %v", err)
}
// Create vaults.d directory in both filesystems
vaultsDir := filepath.Join(realTempDir, "vaults.d")
err = os.MkdirAll(vaultsDir, 0700)
if err != nil {
t.Fatalf("Failed to create real vaults directory: %v", err)
}
err = fs.MkdirAll(vaultsDir, 0700)
if err != nil {
t.Fatalf("Failed to create mock vaults directory: %v", err)
}
// Return cleanup function
cleanup := func() {
// Clean up real temporary directory
os.RemoveAll(realTempDir)
// Restore environment variables
if oldMnemonic == "" {
os.Unsetenv(EnvMnemonic)
} else {
os.Setenv(EnvMnemonic, oldMnemonic)
}
if oldPassphrase == "" {
os.Unsetenv(EnvUnlockPassphrase)
} else {
os.Setenv(EnvUnlockPassphrase, oldPassphrase)
}
if oldStateDir == "" {
os.Unsetenv(EnvStateDir)
} else {
os.Setenv(EnvStateDir, oldStateDir)
}
}
return fs, cleanup
}
func TestCreateVault(t *testing.T) {
fs, cleanup := setupTestEnvironment(t)
defer cleanup()
stateDir := "/test-secret-state"
// Test creating a new vault
vault, err := CreateVault(fs, stateDir, "test-vault")
if err != nil {
t.Fatalf("Failed to create vault: %v", err)
}
if vault.Name != "test-vault" {
t.Errorf("Expected vault name 'test-vault', got '%s'", vault.Name)
}
// Check that vault directory was created
vaultDir, err := vault.GetDirectory()
if err != nil {
t.Fatalf("Failed to get vault directory: %v", err)
}
exists, err := afero.DirExists(fs, vaultDir)
if err != nil {
t.Fatalf("Error checking vault directory: %v", err)
}
if !exists {
t.Errorf("Vault directory was not created")
}
// Check that subdirectories were created
secretsDir := filepath.Join(vaultDir, "secrets.d")
exists, err = afero.DirExists(fs, secretsDir)
if err != nil {
t.Fatalf("Error checking secrets directory: %v", err)
}
if !exists {
t.Errorf("Secrets directory was not created")
}
unlockKeysDir := filepath.Join(vaultDir, "unlock.d")
exists, err = afero.DirExists(fs, unlockKeysDir)
if err != nil {
t.Fatalf("Error checking unlock keys directory: %v", err)
}
if !exists {
t.Errorf("Unlock keys directory was not created")
}
// Test creating a vault that already exists
_, err = CreateVault(fs, stateDir, "test-vault")
if err == nil {
t.Errorf("Expected error when creating vault that already exists")
}
}
func TestSelectVault(t *testing.T) {
fs, cleanup := setupTestEnvironment(t)
defer cleanup()
stateDir := "/test-secret-state"
// Create a vault first
_, err := CreateVault(fs, stateDir, "test-vault")
if err != nil {
t.Fatalf("Failed to create vault: %v", err)
}
// Test selecting the vault
err = SelectVault(fs, stateDir, "test-vault")
if err != nil {
t.Fatalf("Failed to select vault: %v", err)
}
// Check that currentvault symlink was created with correct target
currentVaultPath := filepath.Join(stateDir, "currentvault")
content, err := afero.ReadFile(fs, currentVaultPath)
if err != nil {
t.Fatalf("Failed to read currentvault symlink: %v", err)
}
expectedPath := filepath.Join(stateDir, "vaults.d", "test-vault")
if string(content) != expectedPath {
t.Errorf("Expected currentvault to point to '%s', got '%s'", expectedPath, string(content))
}
// Test selecting a vault that doesn't exist
err = SelectVault(fs, stateDir, "nonexistent-vault")
if err == nil {
t.Errorf("Expected error when selecting nonexistent vault")
}
}
func TestGetCurrentVault(t *testing.T) {
fs, cleanup := setupTestEnvironment(t)
defer cleanup()
stateDir := "/test-secret-state"
// Create and select a vault
_, err := CreateVault(fs, stateDir, "test-vault")
if err != nil {
t.Fatalf("Failed to create vault: %v", err)
}
err = SelectVault(fs, stateDir, "test-vault")
if err != nil {
t.Fatalf("Failed to select vault: %v", err)
}
// Test getting current vault
vault, err := GetCurrentVault(fs, stateDir)
if err != nil {
t.Fatalf("Failed to get current vault: %v", err)
}
if vault.Name != "test-vault" {
t.Errorf("Expected current vault name 'test-vault', got '%s'", vault.Name)
}
}
func TestListVaults(t *testing.T) {
fs, cleanup := setupTestEnvironment(t)
defer cleanup()
stateDir := "/test-secret-state"
// Initially no vaults
vaults, err := ListVaults(fs, stateDir)
if err != nil {
t.Fatalf("Failed to list vaults: %v", err)
}
if len(vaults) != 0 {
t.Errorf("Expected no vaults initially, got %d", len(vaults))
}
// Create multiple vaults
vaultNames := []string{"vault1", "vault2", "vault3"}
for _, name := range vaultNames {
_, err := CreateVault(fs, stateDir, name)
if err != nil {
t.Fatalf("Failed to create vault %s: %v", name, err)
}
}
// List vaults
vaults, err = ListVaults(fs, stateDir)
if err != nil {
t.Fatalf("Failed to list vaults: %v", err)
}
if len(vaults) != len(vaultNames) {
t.Errorf("Expected %d vaults, got %d", len(vaultNames), len(vaults))
}
// Check that all created vaults are in the list
vaultMap := make(map[string]bool)
for _, vault := range vaults {
vaultMap[vault] = true
}
for _, name := range vaultNames {
if !vaultMap[name] {
t.Errorf("Expected vault '%s' in list", name)
}
}
}
func TestVaultGetDirectory(t *testing.T) {
fs, cleanup := setupTestEnvironment(t)
defer cleanup()
stateDir := "/test-secret-state"
vault := NewVault(fs, "test-vault", stateDir)
dir, err := vault.GetDirectory()
if err != nil {
t.Fatalf("Failed to get vault directory: %v", err)
}
expectedDir := "/test-secret-state/vaults.d/test-vault"
if dir != expectedDir {
t.Errorf("Expected directory '%s', got '%s'", expectedDir, dir)
}
}
func TestAddSecret(t *testing.T) {
fs, cleanup := setupTestEnvironment(t)
defer cleanup()
stateDir := "/test-secret-state"
// Create vault and set up long-term key
vault, err := CreateVault(fs, stateDir, "test-vault")
if err != nil {
t.Fatalf("Failed to create vault: %v", err)
}
// We need to create a long-term public key for the vault
// This simulates what happens during vault initialization
err = setupVaultWithLongTermKey(fs, vault)
if err != nil {
t.Fatalf("Failed to setup vault with long-term key: %v", err)
}
// Test adding a secret
secretName := "test-secret"
secretValue := []byte("super secret value")
err = vault.AddSecret(secretName, secretValue, false)
if err != nil {
t.Fatalf("Failed to add secret: %v", err)
}
// Check that secret directory was created
vaultDir, _ := vault.GetDirectory()
secretDir := filepath.Join(vaultDir, "secrets.d", secretName)
exists, err := afero.DirExists(fs, secretDir)
if err != nil {
t.Fatalf("Error checking secret directory: %v", err)
}
if !exists {
t.Errorf("Secret directory was not created")
}
// Check that encrypted secret file exists
secretFile := filepath.Join(secretDir, "value.age")
exists, err = afero.Exists(fs, secretFile)
if err != nil {
t.Fatalf("Error checking secret file: %v", err)
}
if !exists {
t.Errorf("Secret file was not created")
}
// Check that metadata file exists
metadataFile := filepath.Join(secretDir, "secret-metadata.json")
exists, err = afero.Exists(fs, metadataFile)
if err != nil {
t.Fatalf("Error checking metadata file: %v", err)
}
if !exists {
t.Errorf("Metadata file was not created")
}
// Test adding a duplicate secret without force flag
err = vault.AddSecret(secretName, secretValue, false)
if err == nil {
t.Errorf("Expected error when adding duplicate secret without force flag")
}
// Test adding a duplicate secret with force flag
err = vault.AddSecret(secretName, []byte("new value"), true)
if err != nil {
t.Errorf("Failed to overwrite secret with force flag: %v", err)
}
// Test adding secret with slash in name (should be encoded)
err = vault.AddSecret("path/to/secret", []byte("value"), false)
if err != nil {
t.Fatalf("Failed to add secret with slash in name: %v", err)
}
// Check that the slash was encoded as percent
encodedSecretDir := filepath.Join(vaultDir, "secrets.d", "path%to%secret")
exists, err = afero.DirExists(fs, encodedSecretDir)
if err != nil {
t.Fatalf("Error checking encoded secret directory: %v", err)
}
if !exists {
t.Errorf("Encoded secret directory was not created")
}
}
func TestGetSecret(t *testing.T) {
fs, cleanup := setupTestEnvironment(t)
defer cleanup()
stateDir := "/test-secret-state"
// Create vault and set up long-term key
vault, err := CreateVault(fs, stateDir, "test-vault")
if err != nil {
t.Fatalf("Failed to create vault: %v", err)
}
err = setupVaultWithLongTermKey(fs, vault)
if err != nil {
t.Fatalf("Failed to setup vault with long-term key: %v", err)
}
// Add a secret
secretName := "test-secret"
secretValue := []byte("super secret value")
err = vault.AddSecret(secretName, secretValue, false)
if err != nil {
t.Fatalf("Failed to add secret: %v", err)
}
// Test getting the secret (using mnemonic environment variable)
retrievedValue, err := vault.GetSecret(secretName)
if err != nil {
t.Fatalf("Failed to get secret: %v", err)
}
if string(retrievedValue) != string(secretValue) {
t.Errorf("Expected secret value '%s', got '%s'", string(secretValue), string(retrievedValue))
}
// Test getting a nonexistent secret
_, err = vault.GetSecret("nonexistent-secret")
if err == nil {
t.Errorf("Expected error when getting nonexistent secret")
}
// Test getting secret with encoded name
encodedSecretName := "path/to/secret"
encodedSecretValue := []byte("encoded secret value")
err = vault.AddSecret(encodedSecretName, encodedSecretValue, false)
if err != nil {
t.Fatalf("Failed to add encoded secret: %v", err)
}
retrievedEncodedValue, err := vault.GetSecret(encodedSecretName)
if err != nil {
t.Fatalf("Failed to get encoded secret: %v", err)
}
if string(retrievedEncodedValue) != string(encodedSecretValue) {
t.Errorf("Expected encoded secret value '%s', got '%s'", string(encodedSecretValue), string(retrievedEncodedValue))
}
}
func TestListSecrets(t *testing.T) {
fs, cleanup := setupTestEnvironment(t)
defer cleanup()
stateDir := "/test-secret-state"
// Create vault and set up long-term key
vault, err := CreateVault(fs, stateDir, "test-vault")
if err != nil {
t.Fatalf("Failed to create vault: %v", err)
}
err = setupVaultWithLongTermKey(fs, vault)
if err != nil {
t.Fatalf("Failed to setup vault with long-term key: %v", err)
}
// Initially no secrets
secrets, err := vault.ListSecrets()
if err != nil {
t.Fatalf("Failed to list secrets: %v", err)
}
if len(secrets) != 0 {
t.Errorf("Expected no secrets initially, got %d", len(secrets))
}
// Add multiple secrets
secretNames := []string{"secret1", "secret2", "path/to/secret3"}
for _, name := range secretNames {
err := vault.AddSecret(name, []byte("value for "+name), false)
if err != nil {
t.Fatalf("Failed to add secret %s: %v", name, err)
}
}
// List secrets
secrets, err = vault.ListSecrets()
if err != nil {
t.Fatalf("Failed to list secrets: %v", err)
}
if len(secrets) != len(secretNames) {
t.Errorf("Expected %d secrets, got %d", len(secretNames), len(secrets))
}
// Check that all added secrets are in the list (names should be decoded)
secretMap := make(map[string]bool)
for _, secret := range secrets {
secretMap[secret] = true
}
for _, name := range secretNames {
if !secretMap[name] {
t.Errorf("Expected secret '%s' in list", name)
}
}
}
func TestGetSecretMetadata(t *testing.T) {
fs, cleanup := setupTestEnvironment(t)
defer cleanup()
stateDir := "/test-secret-state"
// Create vault and set up long-term key
vault, err := CreateVault(fs, stateDir, "test-vault")
if err != nil {
t.Fatalf("Failed to create vault: %v", err)
}
err = setupVaultWithLongTermKey(fs, vault)
if err != nil {
t.Fatalf("Failed to setup vault with long-term key: %v", err)
}
// Add a secret
secretName := "test-secret"
secretValue := []byte("super secret value")
beforeAdd := time.Now()
err = vault.AddSecret(secretName, secretValue, false)
if err != nil {
t.Fatalf("Failed to add secret: %v", err)
}
afterAdd := time.Now()
// Get secret object and its metadata
secretObj, err := vault.GetSecretObject(secretName)
if err != nil {
t.Fatalf("Failed to get secret object: %v", err)
}
metadata := secretObj.GetMetadata()
if metadata.Name != secretName {
t.Errorf("Expected metadata name '%s', got '%s'", secretName, metadata.Name)
}
// Check that timestamps are reasonable
if metadata.CreatedAt.Before(beforeAdd) || metadata.CreatedAt.After(afterAdd) {
t.Errorf("CreatedAt timestamp is out of expected range")
}
if metadata.UpdatedAt.Before(beforeAdd) || metadata.UpdatedAt.After(afterAdd) {
t.Errorf("UpdatedAt timestamp is out of expected range")
}
// Test getting metadata for nonexistent secret
_, err = vault.GetSecretObject("nonexistent-secret")
if err == nil {
t.Errorf("Expected error when getting secret object for nonexistent secret")
}
}
func TestListUnlockKeys(t *testing.T) {
fs, cleanup := setupTestEnvironment(t)
defer cleanup()
stateDir := "/test-secret-state"
// Create vault
vault, err := CreateVault(fs, stateDir, "test-vault")
if err != nil {
t.Fatalf("Failed to create vault: %v", err)
}
// Initially no unlock keys
keys, err := vault.ListUnlockKeys()
if err != nil {
t.Fatalf("Failed to list unlock keys: %v", err)
}
if len(keys) != 0 {
t.Errorf("Expected no unlock keys initially, got %d", len(keys))
}
}
// setupVaultWithLongTermKey sets up a vault with a long-term public key for testing
func setupVaultWithLongTermKey(fs afero.Fs, vault *Vault) error {
// This simulates what happens during vault initialization
// We derive a long-term keypair from the test mnemonic
ltIdentity, err := vault.deriveLongTermIdentity()
if err != nil {
return err
}
// Store the long-term public key in the vault
vaultDir, err := vault.GetDirectory()
if err != nil {
return err
}
ltPubKey := ltIdentity.Recipient().String()
return afero.WriteFile(fs, filepath.Join(vaultDir, "pub.age"), []byte(ltPubKey), 0600)
}
// deriveLongTermIdentity is a helper method to derive the long-term identity for testing
func (v *Vault) deriveLongTermIdentity() (*age.X25519Identity, error) {
// Use agehd.DeriveIdentity with the test mnemonic
return agehd.DeriveIdentity(testMnemonic, 0)
}

View File

@ -15,8 +15,12 @@ TEST_PASSPHRASE="test-passphrase-123"
TEMP_DIR="$(mktemp -d)" TEMP_DIR="$(mktemp -d)"
SECRET_BINARY="./secret" SECRET_BINARY="./secret"
# Enable debug output from the secret program
export GODEBUG="berlin.sneak.pkg.secret"
echo -e "${BLUE}=== Secret Manager Comprehensive Test Script ===${NC}" echo -e "${BLUE}=== Secret Manager Comprehensive Test Script ===${NC}"
echo -e "${YELLOW}Using temporary directory: $TEMP_DIR${NC}" echo -e "${YELLOW}Using temporary directory: $TEMP_DIR${NC}"
echo -e "${YELLOW}Debug output enabled: GODEBUG=$GODEBUG${NC}"
# Function to print test steps # Function to print test steps
print_step() { print_step() {
@ -76,6 +80,7 @@ cleanup() {
unset SB_SECRET_STATE_DIR unset SB_SECRET_STATE_DIR
unset SB_SECRET_MNEMONIC unset SB_SECRET_MNEMONIC
unset SB_UNLOCK_PASSPHRASE unset SB_UNLOCK_PASSPHRASE
unset GODEBUG
echo -e "${GREEN}Cleanup complete${NC}" echo -e "${GREEN}Cleanup complete${NC}"
} }
@ -99,7 +104,8 @@ echo " SB_SECRET_MNEMONIC=$TEST_MNEMONIC"
print_step "2" "Initializing secret manager (creates default vault)" print_step "2" "Initializing secret manager (creates default vault)"
# Set passphrase for init command only # Set passphrase for init command only
export SB_UNLOCK_PASSPHRASE="$TEST_PASSPHRASE" export SB_UNLOCK_PASSPHRASE="$TEST_PASSPHRASE"
if $SECRET_BINARY init > /dev/null 2>&1; then echo "Running: $SECRET_BINARY init"
if $SECRET_BINARY init; then
print_success "Secret manager initialized with default vault" print_success "Secret manager initialized with default vault"
else else
print_error "Failed to initialize secret manager" print_error "Failed to initialize secret manager"
@ -119,7 +125,8 @@ print_step "3" "Testing vault management"
# List vaults (should show default) # List vaults (should show default)
echo "Listing vaults..." echo "Listing vaults..."
if $SECRET_BINARY vault list > /dev/null 2>&1; then echo "Running: $SECRET_BINARY vault list"
if $SECRET_BINARY vault list; then
VAULTS=$($SECRET_BINARY vault list) VAULTS=$($SECRET_BINARY vault list)
echo "Available vaults: $VAULTS" echo "Available vaults: $VAULTS"
print_success "Listed vaults successfully" print_success "Listed vaults successfully"
@ -129,7 +136,8 @@ fi
# Create a new vault # Create a new vault
echo "Creating new vault 'work'..." echo "Creating new vault 'work'..."
if $SECRET_BINARY vault create work > /dev/null 2>&1; then echo "Running: $SECRET_BINARY vault create work"
if $SECRET_BINARY vault create work; then
print_success "Created vault 'work'" print_success "Created vault 'work'"
else else
print_error "Failed to create vault 'work'" print_error "Failed to create vault 'work'"
@ -137,7 +145,8 @@ fi
# Create another vault # Create another vault
echo "Creating new vault 'personal'..." echo "Creating new vault 'personal'..."
if $SECRET_BINARY vault create personal > /dev/null 2>&1; then echo "Running: $SECRET_BINARY vault create personal"
if $SECRET_BINARY vault create personal; then
print_success "Created vault 'personal'" print_success "Created vault 'personal'"
else else
print_error "Failed to create vault 'personal'" print_error "Failed to create vault 'personal'"
@ -145,7 +154,8 @@ fi
# List vaults again (should show default, work, personal) # List vaults again (should show default, work, personal)
echo "Listing vaults after creation..." echo "Listing vaults after creation..."
if $SECRET_BINARY vault list > /dev/null 2>&1; then echo "Running: $SECRET_BINARY vault list"
if $SECRET_BINARY vault list; then
VAULTS=$($SECRET_BINARY vault list) VAULTS=$($SECRET_BINARY vault list)
echo "Available vaults: $VAULTS" echo "Available vaults: $VAULTS"
print_success "Listed vaults after creation" print_success "Listed vaults after creation"
@ -155,7 +165,8 @@ fi
# Switch to work vault # Switch to work vault
echo "Switching to 'work' vault..." echo "Switching to 'work' vault..."
if $SECRET_BINARY vault select work > /dev/null 2>&1; then echo "Running: $SECRET_BINARY vault select work"
if $SECRET_BINARY vault select work; then
print_success "Switched to 'work' vault" print_success "Switched to 'work' vault"
else else
print_error "Failed to switch to 'work' vault" print_error "Failed to switch to 'work' vault"
@ -170,7 +181,8 @@ reset_state
export SB_SECRET_MNEMONIC="$TEST_MNEMONIC" export SB_SECRET_MNEMONIC="$TEST_MNEMONIC"
# Create a vault first # Create a vault first
if $SECRET_BINARY vault create test-vault > /dev/null 2>&1; then echo "Running: $SECRET_BINARY vault create test-vault"
if $SECRET_BINARY vault create test-vault; then
print_success "Created test-vault for import testing" print_success "Created test-vault for import testing"
else else
print_error "Failed to create test-vault" print_error "Failed to create test-vault"
@ -178,7 +190,8 @@ fi
# Import should prompt for passphrase # Import should prompt for passphrase
echo "Importing with mnemonic env var set, should prompt for passphrase..." echo "Importing with mnemonic env var set, should prompt for passphrase..."
if echo "$TEST_PASSPHRASE" | $SECRET_BINARY import test-vault > /dev/null 2>&1; then 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)" print_success "Import succeeded with mnemonic env var (prompted for passphrase)"
else else
print_error "Import failed with mnemonic env var" print_error "Import failed with mnemonic env var"
@ -190,7 +203,8 @@ reset_state
export SB_UNLOCK_PASSPHRASE="$TEST_PASSPHRASE" export SB_UNLOCK_PASSPHRASE="$TEST_PASSPHRASE"
# Create a vault first # Create a vault first
if $SECRET_BINARY vault create test-vault2 > /dev/null 2>&1; then echo "Running: $SECRET_BINARY vault create test-vault2"
if $SECRET_BINARY vault create test-vault2; then
print_success "Created test-vault2 for import testing" print_success "Created test-vault2 for import testing"
else else
print_error "Failed to create test-vault2" print_error "Failed to create test-vault2"
@ -198,7 +212,8 @@ fi
# Import should prompt for mnemonic # Import should prompt for mnemonic
echo "Importing with passphrase env var set, should prompt for mnemonic..." echo "Importing with passphrase env var set, should prompt for mnemonic..."
if echo "$TEST_MNEMONIC" | $SECRET_BINARY import test-vault2 > /dev/null 2>&1; then 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)" print_success "Import succeeded with passphrase env var (prompted for mnemonic)"
else else
print_error "Import failed with passphrase env var" print_error "Import failed with passphrase env var"
@ -211,7 +226,8 @@ export SB_SECRET_MNEMONIC="$TEST_MNEMONIC"
export SB_UNLOCK_PASSPHRASE="$TEST_PASSPHRASE" export SB_UNLOCK_PASSPHRASE="$TEST_PASSPHRASE"
# Create a vault first # Create a vault first
if $SECRET_BINARY vault create test-vault3 > /dev/null 2>&1; then echo "Running: $SECRET_BINARY vault create test-vault3"
if $SECRET_BINARY vault create test-vault3; then
print_success "Created test-vault3 for import testing" print_success "Created test-vault3 for import testing"
else else
print_error "Failed to create test-vault3" print_error "Failed to create test-vault3"
@ -219,7 +235,8 @@ fi
# Import should not prompt for anything # Import should not prompt for anything
echo "Importing with both env vars set, should not prompt..." echo "Importing with both env vars set, should not prompt..."
if $SECRET_BINARY import test-vault3 > /dev/null 2>&1; then 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)" print_success "Import succeeded with both env vars (no prompts)"
else else
print_error "Import failed with both env vars" print_error "Import failed with both env vars"
@ -230,7 +247,8 @@ echo -e "\n${YELLOW}Test 4d: Import with neither SB_SECRET_MNEMONIC nor SB_UNLOC
reset_state reset_state
# Create a vault first # Create a vault first
if $SECRET_BINARY vault create test-vault4 > /dev/null 2>&1; then echo "Running: $SECRET_BINARY vault create test-vault4"
if $SECRET_BINARY vault create test-vault4; then
print_success "Created test-vault4 for import testing" print_success "Created test-vault4 for import testing"
else else
print_error "Failed to create test-vault4" print_error "Failed to create test-vault4"
@ -239,7 +257,7 @@ fi
# Import should prompt for both mnemonic and passphrase # Import should prompt for both mnemonic and passphrase
echo "Importing with neither env var set, should prompt for both..." echo "Importing with neither env var set, should prompt for both..."
if expect -c " if expect -c "
spawn $SECRET_BINARY import test-vault4 spawn $SECRET_BINARY vault import test-vault4
expect \"Enter your BIP39 mnemonic phrase:\" expect \"Enter your BIP39 mnemonic phrase:\"
send \"$TEST_MNEMONIC\n\" send \"$TEST_MNEMONIC\n\"
expect \"Enter passphrase for unlock key:\" expect \"Enter passphrase for unlock key:\"
@ -247,7 +265,7 @@ if expect -c "
expect \"Confirm passphrase:\" expect \"Confirm passphrase:\"
send \"$TEST_PASSPHRASE\n\" send \"$TEST_PASSPHRASE\n\"
expect eof expect eof
" > /dev/null 2>&1; then "; then
print_success "Import succeeded with no env vars (prompted for both)" print_success "Import succeeded with no env vars (prompted for both)"
else else
print_error "Import failed with no env vars" print_error "Import failed with no env vars"
@ -260,7 +278,7 @@ export SB_SECRET_MNEMONIC="$TEST_MNEMONIC"
export SB_UNLOCK_PASSPHRASE="$TEST_PASSPHRASE" export SB_UNLOCK_PASSPHRASE="$TEST_PASSPHRASE"
echo "Importing into non-existent vault (should fail)..." echo "Importing into non-existent vault (should fail)..."
if $SECRET_BINARY import nonexistent-vault > /dev/null 2>&1; then if $SECRET_BINARY vault import nonexistent-vault; then
print_error "Import should have failed for non-existent vault" print_error "Import should have failed for non-existent vault"
else else
print_success "Import correctly failed for non-existent vault" print_success "Import correctly failed for non-existent vault"
@ -273,14 +291,15 @@ export SB_SECRET_MNEMONIC="invalid mnemonic phrase that should not work"
export SB_UNLOCK_PASSPHRASE="$TEST_PASSPHRASE" export SB_UNLOCK_PASSPHRASE="$TEST_PASSPHRASE"
# Create a vault first # Create a vault first
if $SECRET_BINARY vault create test-vault5 > /dev/null 2>&1; then 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" print_success "Created test-vault5 for invalid mnemonic testing"
else else
print_error "Failed to create test-vault5" print_error "Failed to create test-vault5"
fi fi
echo "Importing with invalid mnemonic (should fail)..." echo "Importing with invalid mnemonic (should fail)..."
if $SECRET_BINARY import test-vault5 > /dev/null 2>&1; then if $SECRET_BINARY vault import test-vault5; then
print_error "Import should have failed with invalid mnemonic" print_error "Import should have failed with invalid mnemonic"
else else
print_success "Import correctly failed with invalid mnemonic" print_success "Import correctly failed with invalid mnemonic"
@ -294,14 +313,15 @@ export SB_SECRET_MNEMONIC="$TEST_MNEMONIC"
print_step "5" "Testing original import functionality" print_step "5" "Testing original import functionality"
# Initialize to create default vault # Initialize to create default vault
if (echo "$TEST_PASSPHRASE"; echo "$TEST_PASSPHRASE") | $SECRET_BINARY init > /dev/null 2>&1; then if (echo "$TEST_PASSPHRASE"; echo "$TEST_PASSPHRASE") | $SECRET_BINARY init; then
print_success "Initialized for Step 5 testing" print_success "Initialized for Step 5 testing"
else else
print_error "Failed to initialize for Step 5 testing" print_error "Failed to initialize for Step 5 testing"
fi fi
# Create work vault for import testing # Create work vault for import testing
if $SECRET_BINARY vault create work > /dev/null 2>&1; then echo "Running: $SECRET_BINARY vault create work"
if $SECRET_BINARY vault create work; then
print_success "Created work vault for import testing" print_success "Created work vault for import testing"
else else
print_error "Failed to create work vault" print_error "Failed to create work vault"
@ -309,7 +329,8 @@ fi
# Switch to work vault # Switch to work vault
echo "Switching to 'work' vault..." echo "Switching to 'work' vault..."
if $SECRET_BINARY vault select work > /dev/null 2>&1; then echo "Running: $SECRET_BINARY vault select work"
if $SECRET_BINARY vault select work; then
print_success "Switched to 'work' vault" print_success "Switched to 'work' vault"
else else
print_error "Failed to switch to 'work' vault" print_error "Failed to switch to 'work' vault"
@ -319,7 +340,8 @@ fi
echo "Importing mnemonic into 'work' vault..." echo "Importing mnemonic into 'work' vault..."
# Set passphrase for import command only # Set passphrase for import command only
export SB_UNLOCK_PASSPHRASE="$TEST_PASSPHRASE" export SB_UNLOCK_PASSPHRASE="$TEST_PASSPHRASE"
if $SECRET_BINARY import work > /dev/null 2>&1; then echo "Running: $SECRET_BINARY vault import work"
if $SECRET_BINARY vault import work; then
print_success "Imported mnemonic into 'work' vault" print_success "Imported mnemonic into 'work' vault"
else else
print_error "Failed to import mnemonic into 'work' vault" print_error "Failed to import mnemonic into 'work' vault"
@ -329,7 +351,8 @@ unset SB_UNLOCK_PASSPHRASE
# Switch back to default vault # Switch back to default vault
echo "Switching back to 'default' vault..." echo "Switching back to 'default' vault..."
if $SECRET_BINARY vault select default > /dev/null 2>&1; then echo "Running: $SECRET_BINARY vault select default"
if $SECRET_BINARY vault select default; then
print_success "Switched back to 'default' vault" print_success "Switched back to 'default' vault"
else else
print_error "Failed to switch back to 'default' vault" print_error "Failed to switch back to 'default' vault"
@ -341,7 +364,8 @@ print_step "6" "Testing unlock key management"
# Create passphrase-protected unlock key # Create passphrase-protected unlock key
echo "Creating 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 # Note: This test uses stdin input instead of environment variable to test the traditional approach
if echo "$TEST_PASSPHRASE" | $SECRET_BINARY keys add passphrase > /dev/null 2>&1; then echo "Running: echo \"$TEST_PASSPHRASE\" | $SECRET_BINARY keys add passphrase"
if echo "$TEST_PASSPHRASE" | $SECRET_BINARY keys add passphrase; then
print_success "Created passphrase-protected unlock key" print_success "Created passphrase-protected unlock key"
else else
print_error "Failed to create passphrase-protected unlock key" print_error "Failed to create passphrase-protected unlock key"
@ -349,7 +373,8 @@ fi
# List unlock keys # List unlock keys
echo "Listing unlock keys..." echo "Listing unlock keys..."
if $SECRET_BINARY keys list > /dev/null 2>&1; then echo "Running: $SECRET_BINARY keys list"
if $SECRET_BINARY keys list; then
KEYS=$($SECRET_BINARY keys list) KEYS=$($SECRET_BINARY keys list)
echo "Available unlock keys: $KEYS" echo "Available unlock keys: $KEYS"
print_success "Listed unlock keys" print_success "Listed unlock keys"
@ -364,28 +389,32 @@ print_step "7" "Testing mnemonic-based secret operations (keyless)"
echo "Adding secrets using mnemonic-based long-term key..." echo "Adding secrets using mnemonic-based long-term key..."
# Test secret 1 # Test secret 1
if echo "my-super-secret-password" | $SECRET_BINARY add "database/password" > /dev/null 2>&1; then echo "Running: echo \"my-super-secret-password\" | $SECRET_BINARY add \"database/password\""
if echo "my-super-secret-password" | $SECRET_BINARY add "database/password"; then
print_success "Added secret: database/password" print_success "Added secret: database/password"
else else
print_error "Failed to add secret: database/password" print_error "Failed to add secret: database/password"
fi fi
# Test secret 2 # Test secret 2
if echo "api-key-12345" | $SECRET_BINARY add "api/key" > /dev/null 2>&1; then echo "Running: echo \"api-key-12345\" | $SECRET_BINARY add \"api/key\""
if echo "api-key-12345" | $SECRET_BINARY add "api/key"; then
print_success "Added secret: api/key" print_success "Added secret: api/key"
else else
print_error "Failed to add secret: api/key" print_error "Failed to add secret: api/key"
fi fi
# Test secret 3 (with path) # Test secret 3 (with path)
if echo "ssh-private-key-content" | $SECRET_BINARY add "ssh/private-key" > /dev/null 2>&1; then echo "Running: echo \"ssh-private-key-content\" | $SECRET_BINARY add \"ssh/private-key\""
if echo "ssh-private-key-content" | $SECRET_BINARY add "ssh/private-key"; then
print_success "Added secret: ssh/private-key" print_success "Added secret: ssh/private-key"
else else
print_error "Failed to add secret: ssh/private-key" print_error "Failed to add secret: ssh/private-key"
fi fi
# Test secret 4 (with dots and underscores) # Test secret 4 (with dots and underscores)
if echo "jwt-secret-token" | $SECRET_BINARY add "app.config_jwt_secret" > /dev/null 2>&1; then echo "Running: echo \"jwt-secret-token\" | $SECRET_BINARY add \"app.config_jwt_secret\""
if echo "jwt-secret-token" | $SECRET_BINARY add "app.config_jwt_secret"; then
print_success "Added secret: app.config_jwt_secret" print_success "Added secret: app.config_jwt_secret"
else else
print_error "Failed to add secret: app.config_jwt_secret" print_error "Failed to add secret: app.config_jwt_secret"
@ -420,7 +449,8 @@ fi
# List all secrets # List all secrets
echo "Listing all secrets..." echo "Listing all secrets..."
if $SECRET_BINARY list > /dev/null 2>&1; then echo "Running: $SECRET_BINARY list"
if $SECRET_BINARY list; then
SECRETS=$($SECRET_BINARY list) SECRETS=$($SECRET_BINARY list)
echo "Secrets in current vault:" echo "Secrets in current vault:"
echo "$SECRETS" | while read -r secret; do echo "$SECRETS" | while read -r secret; do
@ -439,20 +469,25 @@ unset SB_SECRET_MNEMONIC
# Add a secret using traditional unlock key approach # Add a secret using traditional unlock key approach
echo "Adding secret using traditional unlock key..." echo "Adding secret using traditional unlock key..."
if echo "traditional-secret-value" | $SECRET_BINARY add "traditional/secret" > /dev/null 2>&1; then echo "Running: echo \"traditional-secret-value\" | $SECRET_BINARY add \"traditional/secret\""
if echo "traditional-secret-value" | $SECRET_BINARY add "traditional/secret"; then
print_success "Added secret using traditional approach: traditional/secret" print_success "Added secret using traditional approach: traditional/secret"
else else
print_error "Failed to add secret using traditional approach" print_error "Failed to add secret using traditional approach"
fi fi
# Retrieve secret using traditional unlock key approach # Retrieve secret using traditional unlock key approach
RETRIEVED_TRADITIONAL=$($SECRET_BINARY get "traditional/secret" 2>/dev/null) echo "Retrieving secret using traditional unlock key approach..."
RETRIEVED_TRADITIONAL=$(echo "$TEST_PASSPHRASE" | $SECRET_BINARY get "traditional/secret" 2>/dev/null)
if [ "$RETRIEVED_TRADITIONAL" = "traditional-secret-value" ]; then if [ "$RETRIEVED_TRADITIONAL" = "traditional-secret-value" ]; then
print_success "Retrieved and verified traditional secret: traditional/secret" print_success "Retrieved and verified traditional secret: traditional/secret"
else else
print_error "Failed to retrieve or verify traditional secret" print_error "Failed to retrieve or verify traditional secret"
fi fi
# Re-enable mnemonic for remaining tests
export SB_SECRET_MNEMONIC="$TEST_MNEMONIC"
# Test 9: Advanced unlock key management # Test 9: Advanced unlock key management
print_step "9" "Testing advanced unlock key management" print_step "9" "Testing advanced unlock key management"
@ -463,7 +498,8 @@ export SB_SECRET_MNEMONIC="$TEST_MNEMONIC"
echo "Testing PGP unlock key creation..." echo "Testing PGP unlock key creation..."
if command -v gpg >/dev/null 2>&1; then 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 # This would require a GPG key ID - for testing we'll just check the command exists
if $SECRET_BINARY keys add pgp --help > /dev/null 2>&1; then echo "Running: $SECRET_BINARY keys add pgp --help"
if $SECRET_BINARY keys add pgp --help; then
print_success "PGP unlock key command available" print_success "PGP unlock key command available"
else else
print_warning "PGP unlock key command not yet implemented" print_warning "PGP unlock key command not yet implemented"
@ -475,7 +511,8 @@ fi
# Test Secure Enclave (macOS only) # Test Secure Enclave (macOS only)
if [[ "$OSTYPE" == "darwin"* ]]; then if [[ "$OSTYPE" == "darwin"* ]]; then
echo "Testing Secure Enclave unlock key creation..." echo "Testing Secure Enclave unlock key creation..."
if $SECRET_BINARY enroll sep > /dev/null 2>&1; then echo "Running: $SECRET_BINARY enroll sep"
if $SECRET_BINARY enroll sep; then
print_success "Created Secure Enclave unlock key" print_success "Created Secure Enclave unlock key"
else else
print_warning "Secure Enclave unlock key creation not yet implemented" print_warning "Secure Enclave unlock key creation not yet implemented"
@ -486,14 +523,16 @@ fi
# Get current unlock key ID for testing # Get current unlock key ID for testing
echo "Getting current unlock key for testing..." echo "Getting current unlock key for testing..."
if $SECRET_BINARY keys list > /dev/null 2>&1; then echo "Running: $SECRET_BINARY keys list"
if $SECRET_BINARY keys list; then
CURRENT_KEY_ID=$($SECRET_BINARY keys list | head -n1 | awk '{print $1}') CURRENT_KEY_ID=$($SECRET_BINARY keys list | head -n1 | awk '{print $1}')
if [ -n "$CURRENT_KEY_ID" ]; then if [ -n "$CURRENT_KEY_ID" ]; then
print_success "Found unlock key ID: $CURRENT_KEY_ID" print_success "Found unlock key ID: $CURRENT_KEY_ID"
# Test key selection # Test key selection
echo "Testing unlock key selection..." echo "Testing unlock key selection..."
if $SECRET_BINARY key select "$CURRENT_KEY_ID" > /dev/null 2>&1; then echo "Running: $SECRET_BINARY key select $CURRENT_KEY_ID"
if $SECRET_BINARY key select "$CURRENT_KEY_ID"; then
print_success "Selected unlock key: $CURRENT_KEY_ID" print_success "Selected unlock key: $CURRENT_KEY_ID"
else else
print_warning "Unlock key selection not yet implemented" print_warning "Unlock key selection not yet implemented"
@ -507,7 +546,8 @@ print_step "10" "Testing secret name validation and edge cases"
# Test valid names # Test valid names
VALID_NAMES=("valid-name" "valid.name" "valid_name" "valid/path/name" "123valid" "a" "very-long-name-with-many-parts/and/paths") VALID_NAMES=("valid-name" "valid.name" "valid_name" "valid/path/name" "123valid" "a" "very-long-name-with-many-parts/and/paths")
for name in "${VALID_NAMES[@]}"; do for name in "${VALID_NAMES[@]}"; do
if echo "test-value" | $SECRET_BINARY add "$name" --force > /dev/null 2>&1; then echo "Running: echo \"test-value\" | $SECRET_BINARY add $name --force"
if echo "test-value" | $SECRET_BINARY add "$name" --force; then
print_success "Valid name accepted: $name" print_success "Valid name accepted: $name"
else else
print_error "Valid name rejected: $name" print_error "Valid name rejected: $name"
@ -518,7 +558,8 @@ done
echo "Testing invalid names (should fail)..." echo "Testing invalid names (should fail)..."
INVALID_NAMES=("Invalid-Name" "invalid name" "invalid@name" "invalid#name" "invalid%name" "") INVALID_NAMES=("Invalid-Name" "invalid name" "invalid@name" "invalid#name" "invalid%name" "")
for name in "${INVALID_NAMES[@]}"; do for name in "${INVALID_NAMES[@]}"; do
if echo "test-value" | $SECRET_BINARY add "$name" > /dev/null 2>&1; then echo "Running: echo \"test-value\" | $SECRET_BINARY add $name"
if echo "test-value" | $SECRET_BINARY add "$name"; then
print_error "Invalid name accepted (should have been rejected): '$name'" print_error "Invalid name accepted (should have been rejected): '$name'"
else else
print_success "Invalid name correctly rejected: '$name'" print_success "Invalid name correctly rejected: '$name'"
@ -529,14 +570,16 @@ done
print_step "11" "Testing overwrite protection and force flag" print_step "11" "Testing overwrite protection and force flag"
# Try to add existing secret without --force (should fail) # Try to add existing secret without --force (should fail)
if echo "new-value" | $SECRET_BINARY add "database/password" > /dev/null 2>&1; then echo "Running: echo \"new-value\" | $SECRET_BINARY add \"database/password\""
if echo "new-value" | $SECRET_BINARY add "database/password"; then
print_error "Overwrite protection failed - secret was overwritten without --force" print_error "Overwrite protection failed - secret was overwritten without --force"
else else
print_success "Overwrite protection working - secret not overwritten without --force" print_success "Overwrite protection working - secret not overwritten without --force"
fi fi
# Try to add existing secret with --force (should succeed) # Try to add existing secret with --force (should succeed)
if echo "new-password-value" | $SECRET_BINARY add "database/password" --force > /dev/null 2>&1; then echo "Running: echo \"new-password-value\" | $SECRET_BINARY add \"database/password\" --force"
if echo "new-password-value" | $SECRET_BINARY add "database/password" --force; then
print_success "Force overwrite working - secret overwritten with --force" print_success "Force overwrite working - secret overwritten with --force"
# Verify the new value # Verify the new value
@ -555,18 +598,21 @@ print_step "12" "Testing cross-vault operations"
# Switch to work vault and add secrets there # Switch to work vault and add secrets there
echo "Switching to 'work' vault for cross-vault testing..." echo "Switching to 'work' vault for cross-vault testing..."
if $SECRET_BINARY vault select work > /dev/null 2>&1; then echo "Running: $SECRET_BINARY vault select work"
if $SECRET_BINARY vault select work; then
print_success "Switched to 'work' vault" print_success "Switched to 'work' vault"
# Add work-specific secrets # Add work-specific secrets
if echo "work-database-password" | $SECRET_BINARY add "work/database" > /dev/null 2>&1; then echo "Running: echo \"work-database-password\" | $SECRET_BINARY add \"work/database\""
if echo "work-database-password" | $SECRET_BINARY add "work/database"; then
print_success "Added work-specific secret" print_success "Added work-specific secret"
else else
print_error "Failed to add work-specific secret" print_error "Failed to add work-specific secret"
fi fi
# List secrets in work vault # List secrets in work vault
if $SECRET_BINARY list > /dev/null 2>&1; then echo "Running: $SECRET_BINARY list"
if $SECRET_BINARY list; then
WORK_SECRETS=$($SECRET_BINARY list) WORK_SECRETS=$($SECRET_BINARY list)
echo "Secrets in work vault: $WORK_SECRETS" echo "Secrets in work vault: $WORK_SECRETS"
print_success "Listed work vault secrets" print_success "Listed work vault secrets"
@ -579,11 +625,13 @@ fi
# Switch back to default vault # Switch back to default vault
echo "Switching back to 'default' vault..." echo "Switching back to 'default' vault..."
if $SECRET_BINARY vault select default > /dev/null 2>&1; then echo "Running: $SECRET_BINARY vault select default"
if $SECRET_BINARY vault select default; then
print_success "Switched back to 'default' vault" print_success "Switched back to 'default' vault"
# Verify default vault secrets are still there # Verify default vault secrets are still there
if $SECRET_BINARY get "database/password" > /dev/null 2>&1; then echo "Running: $SECRET_BINARY get \"database/password\""
if $SECRET_BINARY get "database/password"; then
print_success "Default vault secrets still accessible" print_success "Default vault secrets still accessible"
else else
print_error "Default vault secrets not accessible" print_error "Default vault secrets not accessible"
@ -645,15 +693,17 @@ fi
print_step "14" "Testing environment variable error handling" print_step "14" "Testing environment variable error handling"
# Test with non-existent state directory # Test with non-existent state directory
export SB_SECRET_STATE_DIR="/nonexistent/directory" export SB_SECRET_STATE_DIR="$TEMP_DIR/nonexistent/directory"
if $SECRET_BINARY get "database/password" > /dev/null 2>&1; then echo "Running: $SECRET_BINARY get \"database/password\""
if $SECRET_BINARY get "database/password"; then
print_error "Should have failed with non-existent state directory" print_error "Should have failed with non-existent state directory"
else else
print_success "Correctly failed with non-existent state directory" print_success "Correctly failed with non-existent state directory"
fi fi
# Test init with non-existent directory (should work) # Test init with non-existent directory (should work)
if $SECRET_BINARY init > /dev/null 2>&1; then echo "Running: $SECRET_BINARY init"
if $SECRET_BINARY init; then
print_success "Init works with non-existent state directory" print_success "Init works with non-existent state directory"
else else
print_error "Init should work with non-existent state directory" print_error "Init should work with non-existent state directory"
@ -671,15 +721,18 @@ export SB_SECRET_MNEMONIC="$TEST_MNEMONIC"
# Create another unlock key for testing removal # Create another unlock key for testing removal
echo "Creating additional unlock key for removal testing..." echo "Creating additional unlock key for removal testing..."
# Use stdin input instead of environment variable # Use stdin input instead of environment variable
if echo "another-passphrase" | $SECRET_BINARY keys add passphrase > /dev/null 2>&1; then 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" print_success "Created additional unlock key"
# Get the key ID and try to remove it # Get the key ID and try to remove it
if $SECRET_BINARY keys list > /dev/null 2>&1; then echo "Running: $SECRET_BINARY keys list"
if $SECRET_BINARY keys list; then
KEY_TO_REMOVE=$($SECRET_BINARY keys list | tail -n1 | awk '{print $1}') KEY_TO_REMOVE=$($SECRET_BINARY keys list | tail -n1 | awk '{print $1}')
if [ -n "$KEY_TO_REMOVE" ]; then if [ -n "$KEY_TO_REMOVE" ]; then
echo "Attempting to remove unlock key: $KEY_TO_REMOVE" echo "Attempting to remove unlock key: $KEY_TO_REMOVE"
if $SECRET_BINARY keys rm "$KEY_TO_REMOVE" > /dev/null 2>&1; then 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" print_success "Removed unlock key: $KEY_TO_REMOVE"
else else
print_warning "Unlock key removal not yet implemented" print_warning "Unlock key removal not yet implemented"
@ -703,7 +756,9 @@ fi
# Test without mnemonic but with unlock key # Test without mnemonic but with unlock key
unset SB_SECRET_MNEMONIC unset SB_SECRET_MNEMONIC
if $SECRET_BINARY get "database/password" > /dev/null 2>&1; then 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
print_success "Traditional unlock key can access mnemonic-created secrets" print_success "Traditional unlock key can access mnemonic-created secrets"
else else
print_warning "Traditional unlock key cannot access mnemonic-created secrets (may need implementation)" print_warning "Traditional unlock key cannot access mnemonic-created secrets (may need implementation)"
@ -717,11 +772,13 @@ print_step "17" "Testing refactored architecture - separation of concerns"
echo "Testing that secrets handle their own data access..." echo "Testing that secrets handle their own data access..."
# Create a test secret first # Create a test secret first
if echo "test-self-access" | $SECRET_BINARY add "test/self-access" > /dev/null 2>&1; then 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" print_success "Created test secret for self-access testing"
# Try to retrieve it (this tests that Secret.GetEncryptedData() works) # Try to retrieve it (this tests that Secret.GetEncryptedData() works)
if $SECRET_BINARY get "test/self-access" > /dev/null 2>&1; then 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" print_success "Secret correctly handles its own data access"
else else
print_error "Secret failed to handle its own data access" print_error "Secret failed to handle its own data access"
@ -733,7 +790,8 @@ fi
echo "Testing unlock key delegation pattern..." echo "Testing unlock key delegation pattern..."
# Test that vault delegates to unlock keys for decryption # Test that vault delegates to unlock keys for decryption
# This is tested implicitly by all our secret retrieval operations # This is tested implicitly by all our secret retrieval operations
if $SECRET_BINARY get "database/password" > /dev/null 2>&1; then echo "Running: $SECRET_BINARY get \"database/password\""
if $SECRET_BINARY get "database/password"; then
print_success "Vault correctly delegates to unlock keys for decryption" print_success "Vault correctly delegates to unlock keys for decryption"
else else
print_error "Vault delegation pattern failed" print_error "Vault delegation pattern failed"
@ -746,12 +804,15 @@ echo "Verifying all unlock key types implement required methods..."
# Create different types of unlock keys to test interface compliance # Create different types of unlock keys to test interface compliance
echo "Testing PassphraseUnlockKey interface compliance..." echo "Testing PassphraseUnlockKey interface compliance..."
if echo "interface-test-pass" | $SECRET_BINARY keys add passphrase > /dev/null 2>&1; then 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" print_success "PassphraseUnlockKey created successfully"
# Test that we can use it (this verifies GetIdentity and DecryptSecret work) # Test that we can use it (this verifies GetIdentity and DecryptSecret work)
if echo "interface-test-secret" | $SECRET_BINARY add "interface/test" > /dev/null 2>&1; then echo "Running: echo \"interface-test-secret\" | $SECRET_BINARY add \"interface/test\""
if $SECRET_BINARY get "interface/test" > /dev/null 2>&1; then 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" print_success "PassphraseUnlockKey interface methods working"
else else
print_error "PassphraseUnlockKey interface methods failed" print_error "PassphraseUnlockKey interface methods failed"
@ -766,12 +827,15 @@ fi
# Test Secure Enclave on macOS (if available) # Test Secure Enclave on macOS (if available)
if [[ "$OSTYPE" == "darwin"* ]]; then if [[ "$OSTYPE" == "darwin"* ]]; then
echo "Testing SEPUnlockKey interface compliance on macOS..." echo "Testing SEPUnlockKey interface compliance on macOS..."
if $SECRET_BINARY enroll sep > /dev/null 2>&1; then echo "Running: $SECRET_BINARY enroll sep"
if $SECRET_BINARY enroll sep; then
print_success "SEPUnlockKey created successfully" print_success "SEPUnlockKey created successfully"
# Test that we can use it # Test that we can use it
if echo "sep-test-secret" | $SECRET_BINARY add "sep/test" > /dev/null 2>&1; then echo "Running: echo \"sep-test-secret\" | $SECRET_BINARY add \"sep/test\""
if $SECRET_BINARY get "sep/test" > /dev/null 2>&1; then 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" print_success "SEPUnlockKey interface methods working"
else else
print_error "SEPUnlockKey interface methods failed" print_error "SEPUnlockKey interface methods failed"
@ -787,36 +851,40 @@ else
fi fi
# Test 19: Long-term Key Management Separation # Test 19: Long-term Key Management Separation
print_step "19" "Testing long-term key management separation" print_step "19" "Testing long-term key access via different unlock key types"
echo "Testing that unlock keys manage their own long-term keys..." echo "Testing that different unlock key types can access the same long-term key..."
# Switch between different unlock methods to verify each handles its own long-term keys # Switch between different unlock methods to verify each can access the long-term key
echo "Testing mnemonic-based long-term key management..." echo "Testing mnemonic-based long-term key access..."
export SB_SECRET_MNEMONIC="$TEST_MNEMONIC" export SB_SECRET_MNEMONIC="$TEST_MNEMONIC"
if echo "mnemonic-longterm-test" | $SECRET_BINARY add "longterm/mnemonic" > /dev/null 2>&1; then echo "Running: echo \"mnemonic-longterm-test\" | $SECRET_BINARY add \"longterm/mnemonic\""
if $SECRET_BINARY get "longterm/mnemonic" > /dev/null 2>&1; then if echo "mnemonic-longterm-test" | $SECRET_BINARY add "longterm/mnemonic"; then
print_success "Mnemonic-based long-term key management working" echo "Running: $SECRET_BINARY get \"longterm/mnemonic\""
if $SECRET_BINARY get "longterm/mnemonic"; then
print_success "Mnemonic-based long-term key access working"
else else
print_error "Mnemonic-based long-term key management failed" print_error "Mnemonic-based long-term key access failed"
fi fi
else else
print_error "Failed to test mnemonic-based long-term key management" print_error "Failed to test mnemonic-based long-term key access"
fi fi
echo "Testing passphrase-based long-term key management..." echo "Testing passphrase unlock key accessing long-term key..."
unset SB_SECRET_MNEMONIC unset SB_SECRET_MNEMONIC
if echo "passphrase-longterm-test" | $SECRET_BINARY add "longterm/passphrase" > /dev/null 2>&1; then echo "Running: echo \"passphrase-unlock-test\" | $SECRET_BINARY add \"longterm/passphrase-unlock\""
if $SECRET_BINARY get "longterm/passphrase" > /dev/null 2>&1; then if echo "passphrase-unlock-test" | $SECRET_BINARY add "longterm/passphrase-unlock"; then
print_success "Passphrase-based long-term key management working" 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 else
print_error "Passphrase-based long-term key management failed" print_error "Passphrase unlock key accessing long-term key failed"
fi fi
else else
print_error "Failed to test passphrase-based long-term key management" print_error "Failed to test passphrase unlock key accessing long-term key"
fi fi
# Re-enable mnemonic # Re-enable mnemonic for remaining tests
export SB_SECRET_MNEMONIC="$TEST_MNEMONIC" export SB_SECRET_MNEMONIC="$TEST_MNEMONIC"
# Test 20: Directory Structure and File Access Patterns # Test 20: Directory Structure and File Access Patterns
@ -826,7 +894,8 @@ echo "Verifying secrets access their own directory structure..."
# Check that secret directories contain the expected structure # Check that secret directories contain the expected structure
SECRET_NAME="structure/test" SECRET_NAME="structure/test"
if echo "structure-test-value" | $SECRET_BINARY add "$SECRET_NAME" > /dev/null 2>&1; then 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" print_success "Created secret for structure testing"
# Convert secret name to directory name (URL encoding) # Convert secret name to directory name (URL encoding)
@ -837,7 +906,8 @@ if echo "structure-test-value" | $SECRET_BINARY add "$SECRET_NAME" > /dev/null 2
print_success "Secret directory structure created correctly" print_success "Secret directory structure created correctly"
# Verify secret can access its own encrypted data # Verify secret can access its own encrypted data
if $SECRET_BINARY get "$SECRET_NAME" > /dev/null 2>&1; then echo "Running: $SECRET_BINARY get $SECRET_NAME"
if $SECRET_BINARY get "$SECRET_NAME"; then
print_success "Secret correctly accesses its own encrypted data" print_success "Secret correctly accesses its own encrypted data"
else else
print_error "Secret failed to access its own encrypted data" print_error "Secret failed to access its own encrypted data"
@ -886,7 +956,8 @@ print_step "21" "Testing error handling in refactored architecture"
echo "Testing secret error handling..." echo "Testing secret error handling..."
# Test non-existent secret # Test non-existent secret
if $SECRET_BINARY get "nonexistent/secret" > /dev/null 2>&1; then echo "Running: $SECRET_BINARY get \"nonexistent/secret\""
if $SECRET_BINARY get "nonexistent/secret"; then
print_error "Should have failed for non-existent secret" print_error "Should have failed for non-existent secret"
else else
print_success "Correctly handled non-existent secret" print_success "Correctly handled non-existent secret"
@ -904,7 +975,8 @@ if [ -d "$FIRST_KEY_DIR" ] && [ -f "$FIRST_KEY_DIR/priv.age" ]; then
# Temporarily disable mnemonic to force unlock key usage # Temporarily disable mnemonic to force unlock key usage
unset SB_SECRET_MNEMONIC unset SB_SECRET_MNEMONIC
if $SECRET_BINARY get "database/password" > /dev/null 2>&1; then 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)" print_warning "Expected failure with corrupted unlock key, but succeeded (may have fallback)"
else else
print_success "Correctly handled corrupted unlock key" print_success "Correctly handled corrupted unlock key"
@ -925,27 +997,33 @@ print_step "22" "Testing cross-component integration"
echo "Testing vault-secret-unlock key integration..." echo "Testing vault-secret-unlock key integration..."
# Create a secret in one vault, switch vaults, create another secret, switch back # Create a secret in one vault, switch vaults, create another secret, switch back
if $SECRET_BINARY vault create integration-test > /dev/null 2>&1; then echo "Running: $SECRET_BINARY vault create integration-test"
if $SECRET_BINARY vault create integration-test; then
print_success "Created integration test vault" print_success "Created integration test vault"
# Add secret to default vault # Add secret to default vault
if echo "default-vault-secret" | $SECRET_BINARY add "integration/default" > /dev/null 2>&1; then 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" print_success "Added secret to default vault"
# Switch to integration-test vault # Switch to integration-test vault
if $SECRET_BINARY vault select integration-test > /dev/null 2>&1; then echo "Running: $SECRET_BINARY vault select integration-test"
if $SECRET_BINARY vault select integration-test; then
print_success "Switched to integration-test vault" print_success "Switched to integration-test vault"
# Create unlock key in new vault # Create unlock key in new vault
if echo "integration-passphrase" | $SECRET_BINARY keys add passphrase > /dev/null 2>&1; then 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" print_success "Created unlock key in integration-test vault"
# Add secret to integration-test vault # Add secret to integration-test vault
if echo "integration-vault-secret" | $SECRET_BINARY add "integration/test" > /dev/null 2>&1; then 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" print_success "Added secret to integration-test vault"
# Verify secret retrieval works # Verify secret retrieval works
if $SECRET_BINARY get "integration/test" > /dev/null 2>&1; then echo "Running: $SECRET_BINARY get \"integration/test\""
if $SECRET_BINARY get "integration/test"; then
print_success "Cross-component integration working" print_success "Cross-component integration working"
else else
print_error "Cross-component integration failed" print_error "Cross-component integration failed"
@ -958,11 +1036,13 @@ if $SECRET_BINARY vault create integration-test > /dev/null 2>&1; then
fi fi
# Switch back to default vault # Switch back to default vault
if $SECRET_BINARY vault select default > /dev/null 2>&1; then echo "Running: $SECRET_BINARY vault select default"
if $SECRET_BINARY vault select default; then
print_success "Switched back to default vault" print_success "Switched back to default vault"
# Verify we can still access default vault secrets # Verify we can still access default vault secrets
if $SECRET_BINARY get "integration/default" > /dev/null 2>&1; then echo "Running: $SECRET_BINARY get \"integration/default\""
if $SECRET_BINARY get "integration/default"; then
print_success "Can still access default vault secrets" print_success "Can still access default vault secrets"
else else
print_error "Cannot access default vault secrets after switching" print_error "Cannot access default vault secrets after switching"
@ -999,7 +1079,7 @@ echo -e "${GREEN}✓ Mixed approach compatibility${NC}"
echo -e "${GREEN}✓ Error handling${NC}" echo -e "${GREEN}✓ Error handling${NC}"
echo -e "${GREEN}✓ Refactored architecture - separation of concerns${NC}" echo -e "${GREEN}✓ Refactored architecture - separation of concerns${NC}"
echo -e "${GREEN}✓ Interface method compliance${NC}" echo -e "${GREEN}✓ Interface method compliance${NC}"
echo -e "${GREEN}✓ Long-term key management separation${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}✓ Directory structure and file access patterns${NC}"
echo -e "${GREEN}✓ Error handling in refactored architecture${NC}" echo -e "${GREEN}✓ Error handling in refactored architecture${NC}"
echo -e "${GREEN}✓ Cross-component integration${NC}" echo -e "${GREEN}✓ Cross-component integration${NC}"