Compare commits
	
		
			2 Commits
		
	
	
		
			0b31fba663
			...
			985d79d3c0
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 985d79d3c0 | |||
| 004dce5472 | 
							
								
								
									
										99
									
								
								.golangci.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										99
									
								
								.golangci.yml
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,99 @@
 | 
				
			|||||||
 | 
					run:
 | 
				
			||||||
 | 
					  timeout: 5m
 | 
				
			||||||
 | 
					  go: "1.22"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					linters:
 | 
				
			||||||
 | 
					  enable:
 | 
				
			||||||
 | 
					    # Additional linters requested
 | 
				
			||||||
 | 
					    - testifylint      # Checks usage of github.com/stretchr/testify
 | 
				
			||||||
 | 
					    - usetesting       # usetesting is an analyzer that detects using os.Setenv instead of t.Setenv since Go 1.17
 | 
				
			||||||
 | 
					    - tagliatelle      # Checks the struct tags
 | 
				
			||||||
 | 
					    - nlreturn         # nlreturn checks for a new line before return and branch statements
 | 
				
			||||||
 | 
					    - nilnil           # Checks that there is no simultaneous return of nil error and an invalid value
 | 
				
			||||||
 | 
					    - nestif           # Reports deeply nested if statements
 | 
				
			||||||
 | 
					    - mnd              # An analyzer to detect magic numbers
 | 
				
			||||||
 | 
					    - lll              # Reports long lines
 | 
				
			||||||
 | 
					    - intrange         # intrange is a linter to find places where for loops could make use of an integer range
 | 
				
			||||||
 | 
					    - gofumpt          # Gofumpt checks whether code was gofumpt-ed
 | 
				
			||||||
 | 
					    - gochecknoglobals # Check that no global variables exist
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Default/existing linters that are commonly useful
 | 
				
			||||||
 | 
					    - govet
 | 
				
			||||||
 | 
					    - errcheck
 | 
				
			||||||
 | 
					    - staticcheck
 | 
				
			||||||
 | 
					    - unused
 | 
				
			||||||
 | 
					    - gosimple
 | 
				
			||||||
 | 
					    - ineffassign
 | 
				
			||||||
 | 
					    - typecheck
 | 
				
			||||||
 | 
					    - gofmt
 | 
				
			||||||
 | 
					    - goimports
 | 
				
			||||||
 | 
					    - misspell
 | 
				
			||||||
 | 
					    - revive
 | 
				
			||||||
 | 
					    - gosec
 | 
				
			||||||
 | 
					    - unconvert
 | 
				
			||||||
 | 
					    - unparam
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					linters-settings:
 | 
				
			||||||
 | 
					  lll:
 | 
				
			||||||
 | 
					    line-length: 120
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  mnd:
 | 
				
			||||||
 | 
					    # List of enabled checks, see https://github.com/tommy-muehle/go-mnd/#checks for description.
 | 
				
			||||||
 | 
					    checks:
 | 
				
			||||||
 | 
					      - argument
 | 
				
			||||||
 | 
					      - case
 | 
				
			||||||
 | 
					      - condition
 | 
				
			||||||
 | 
					      - operation
 | 
				
			||||||
 | 
					      - return
 | 
				
			||||||
 | 
					      - assign
 | 
				
			||||||
 | 
					    ignored-numbers:
 | 
				
			||||||
 | 
					      - '0'
 | 
				
			||||||
 | 
					      - '1'
 | 
				
			||||||
 | 
					      - '2'
 | 
				
			||||||
 | 
					      - '8'
 | 
				
			||||||
 | 
					      - '16'
 | 
				
			||||||
 | 
					      - '40'  # GPG fingerprint length
 | 
				
			||||||
 | 
					      - '64'
 | 
				
			||||||
 | 
					      - '128'
 | 
				
			||||||
 | 
					      - '256'
 | 
				
			||||||
 | 
					      - '512'
 | 
				
			||||||
 | 
					      - '1024'
 | 
				
			||||||
 | 
					      - '2048'
 | 
				
			||||||
 | 
					      - '4096'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  nestif:
 | 
				
			||||||
 | 
					    min-complexity: 4
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  nlreturn:
 | 
				
			||||||
 | 
					    block-size: 2
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  tagliatelle:
 | 
				
			||||||
 | 
					    case:
 | 
				
			||||||
 | 
					      rules:
 | 
				
			||||||
 | 
					        json: snake
 | 
				
			||||||
 | 
					        yaml: snake
 | 
				
			||||||
 | 
					        xml: snake
 | 
				
			||||||
 | 
					        bson: snake
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  testifylint:
 | 
				
			||||||
 | 
					    enable-all: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  usetesting:
 | 
				
			||||||
 | 
					    strict: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					issues:
 | 
				
			||||||
 | 
					  exclude-rules:
 | 
				
			||||||
 | 
					    # Exclude some linters from running on tests files
 | 
				
			||||||
 | 
					    - path: _test\.go
 | 
				
			||||||
 | 
					      linters:
 | 
				
			||||||
 | 
					        - gochecknoglobals
 | 
				
			||||||
 | 
					        - mnd
 | 
				
			||||||
 | 
					        - unparam
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Allow long lines in generated code or test data
 | 
				
			||||||
 | 
					    - path: ".*_gen\\.go"
 | 
				
			||||||
 | 
					      linters:
 | 
				
			||||||
 | 
					        - lll
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  max-issues-per-linter: 0
 | 
				
			||||||
 | 
					  max-same-issues: 0
 | 
				
			||||||
@ -9,6 +9,7 @@ import (
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
	"git.eeqj.de/sneak/secret/internal/secret"
 | 
						"git.eeqj.de/sneak/secret/internal/secret"
 | 
				
			||||||
	"github.com/spf13/afero"
 | 
						"github.com/spf13/afero"
 | 
				
			||||||
 | 
						"github.com/spf13/cobra"
 | 
				
			||||||
	"golang.org/x/term"
 | 
						"golang.org/x/term"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -19,6 +20,7 @@ var stdinScanner *bufio.Scanner
 | 
				
			|||||||
type CLIInstance struct {
 | 
					type CLIInstance struct {
 | 
				
			||||||
	fs       afero.Fs
 | 
						fs       afero.Fs
 | 
				
			||||||
	stateDir string
 | 
						stateDir string
 | 
				
			||||||
 | 
						cmd      *cobra.Command
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// NewCLIInstance creates a new CLI instance with the real filesystem
 | 
					// NewCLIInstance creates a new CLI instance with the real filesystem
 | 
				
			||||||
 | 
				
			|||||||
@ -22,6 +22,7 @@ func newEncryptCmd() *cobra.Command {
 | 
				
			|||||||
			outputFile, _ := cmd.Flags().GetString("output")
 | 
								outputFile, _ := cmd.Flags().GetString("output")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			cli := NewCLIInstance()
 | 
								cli := NewCLIInstance()
 | 
				
			||||||
 | 
								cli.cmd = cmd
 | 
				
			||||||
			return cli.Encrypt(args[0], inputFile, outputFile)
 | 
								return cli.Encrypt(args[0], inputFile, outputFile)
 | 
				
			||||||
		},
 | 
							},
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@ -42,6 +43,7 @@ func newDecryptCmd() *cobra.Command {
 | 
				
			|||||||
			outputFile, _ := cmd.Flags().GetString("output")
 | 
								outputFile, _ := cmd.Flags().GetString("output")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			cli := NewCLIInstance()
 | 
								cli := NewCLIInstance()
 | 
				
			||||||
 | 
								cli.cmd = cmd
 | 
				
			||||||
			return cli.Decrypt(args[0], inputFile, outputFile)
 | 
								return cli.Decrypt(args[0], inputFile, outputFile)
 | 
				
			||||||
		},
 | 
							},
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@ -127,7 +129,7 @@ func (cli *CLIInstance) Encrypt(secretName, inputFile, outputFile string) error
 | 
				
			|||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Set up output writer
 | 
						// Set up output writer
 | 
				
			||||||
	var output io.Writer = os.Stdout
 | 
						var output io.Writer = cli.cmd.OutOrStdout()
 | 
				
			||||||
	if outputFile != "" {
 | 
						if outputFile != "" {
 | 
				
			||||||
		file, err := cli.fs.Create(outputFile)
 | 
							file, err := cli.fs.Create(outputFile)
 | 
				
			||||||
		if err != nil {
 | 
							if err != nil {
 | 
				
			||||||
@ -213,7 +215,7 @@ func (cli *CLIInstance) Decrypt(secretName, inputFile, outputFile string) error
 | 
				
			|||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Set up output writer
 | 
						// Set up output writer
 | 
				
			||||||
	var output io.Writer = os.Stdout
 | 
						var output io.Writer = cli.cmd.OutOrStdout()
 | 
				
			||||||
	if outputFile != "" {
 | 
						if outputFile != "" {
 | 
				
			||||||
		file, err := cli.fs.Create(outputFile)
 | 
							file, err := cli.fs.Create(outputFile)
 | 
				
			||||||
		if err != nil {
 | 
							if err != nil {
 | 
				
			||||||
 | 
				
			|||||||
@ -11,6 +11,7 @@ import (
 | 
				
			|||||||
	"time"
 | 
						"time"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	"git.eeqj.de/sneak/secret/internal/cli"
 | 
						"git.eeqj.de/sneak/secret/internal/cli"
 | 
				
			||||||
 | 
						"git.eeqj.de/sneak/secret/internal/secret"
 | 
				
			||||||
	"git.eeqj.de/sneak/secret/pkg/agehd"
 | 
						"git.eeqj.de/sneak/secret/pkg/agehd"
 | 
				
			||||||
	"github.com/stretchr/testify/assert"
 | 
						"github.com/stretchr/testify/assert"
 | 
				
			||||||
	"github.com/stretchr/testify/require"
 | 
						"github.com/stretchr/testify/require"
 | 
				
			||||||
@ -50,10 +51,13 @@ func TestMain(m *testing.M) {
 | 
				
			|||||||
// all functionality of the secret manager using a real filesystem in a temporary directory.
 | 
					// all functionality of the secret manager using a real filesystem in a temporary directory.
 | 
				
			||||||
// This test serves as both validation and documentation of the program's behavior.
 | 
					// This test serves as both validation and documentation of the program's behavior.
 | 
				
			||||||
func TestSecretManagerIntegration(t *testing.T) {
 | 
					func TestSecretManagerIntegration(t *testing.T) {
 | 
				
			||||||
	// Enable debug logging to diagnose test failures
 | 
						// Enable debug logging to diagnose issues
 | 
				
			||||||
	os.Setenv("GODEBUG", "berlin.sneak.pkg.secret")
 | 
						os.Setenv("GODEBUG", "berlin.sneak.pkg.secret")
 | 
				
			||||||
	defer os.Unsetenv("GODEBUG")
 | 
						defer os.Unsetenv("GODEBUG")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Reinitialize debug logging to pick up the environment variable change
 | 
				
			||||||
 | 
						secret.InitDebugLogging()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Test configuration
 | 
						// Test configuration
 | 
				
			||||||
	testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
 | 
						testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
 | 
				
			||||||
	testPassphrase := "test-passphrase-123"
 | 
						testPassphrase := "test-passphrase-123"
 | 
				
			||||||
@ -349,10 +353,18 @@ func test01Initialize(t *testing.T, tempDir, testMnemonic, testPassphrase string
 | 
				
			|||||||
	currentUnlockerFile := filepath.Join(defaultVaultDir, "current-unlocker")
 | 
						currentUnlockerFile := filepath.Join(defaultVaultDir, "current-unlocker")
 | 
				
			||||||
	verifyFileExists(t, currentUnlockerFile)
 | 
						verifyFileExists(t, currentUnlockerFile)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Read the current-unlocker file to see what it contains
 | 
						// Read the current-unlocker symlink to see what it points to
 | 
				
			||||||
 | 
						symlinkTarget, err := os.Readlink(currentUnlockerFile)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							t.Logf("DEBUG: failed to read symlink %s: %v", currentUnlockerFile, err)
 | 
				
			||||||
 | 
							// Fallback to reading as file if it's not a symlink
 | 
				
			||||||
		currentUnlockerContent := readFile(t, currentUnlockerFile)
 | 
							currentUnlockerContent := readFile(t, currentUnlockerFile)
 | 
				
			||||||
	// The file likely contains the unlocker ID
 | 
							t.Logf("DEBUG: current-unlocker file content: %q", string(currentUnlockerContent))
 | 
				
			||||||
		assert.Contains(t, string(currentUnlockerContent), "passphrase", "current unlocker should be passphrase type")
 | 
							assert.Contains(t, string(currentUnlockerContent), "passphrase", "current unlocker should be passphrase type")
 | 
				
			||||||
 | 
						} else {
 | 
				
			||||||
 | 
							t.Logf("DEBUG: current-unlocker symlink points to: %q", symlinkTarget)
 | 
				
			||||||
 | 
							assert.Contains(t, symlinkTarget, "passphrase", "current unlocker should be passphrase type")
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Verify vault-metadata.json in vault
 | 
						// Verify vault-metadata.json in vault
 | 
				
			||||||
	vaultMetadata := filepath.Join(defaultVaultDir, "vault-metadata.json")
 | 
						vaultMetadata := filepath.Join(defaultVaultDir, "vault-metadata.json")
 | 
				
			||||||
@ -1006,6 +1018,7 @@ func test13UnlockerManagement(t *testing.T, tempDir, testMnemonic string, runSec
 | 
				
			|||||||
	// List unlockers
 | 
						// List unlockers
 | 
				
			||||||
	output, err := runSecret("unlockers", "list")
 | 
						output, err := runSecret("unlockers", "list")
 | 
				
			||||||
	require.NoError(t, err, "unlockers list should succeed")
 | 
						require.NoError(t, err, "unlockers list should succeed")
 | 
				
			||||||
 | 
						t.Logf("DEBUG: unlockers list output: %q", output)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Should have the passphrase unlocker created during init
 | 
						// Should have the passphrase unlocker created during init
 | 
				
			||||||
	assert.Contains(t, output, "passphrase", "should have passphrase unlocker")
 | 
						assert.Contains(t, output, "passphrase", "should have passphrase unlocker")
 | 
				
			||||||
@ -1034,6 +1047,7 @@ func test13UnlockerManagement(t *testing.T, tempDir, testMnemonic string, runSec
 | 
				
			|||||||
	}
 | 
						}
 | 
				
			||||||
	// Note: This might still show 1 if the implementation doesn't support multiple passphrase unlockers
 | 
						// Note: This might still show 1 if the implementation doesn't support multiple passphrase unlockers
 | 
				
			||||||
	// Just verify we have at least 1
 | 
						// Just verify we have at least 1
 | 
				
			||||||
 | 
						t.Logf("DEBUG: passphrase count: %d, output lines: %v", passphraseCount, lines)
 | 
				
			||||||
	assert.GreaterOrEqual(t, passphraseCount, 1, "should have at least 1 passphrase unlocker")
 | 
						assert.GreaterOrEqual(t, passphraseCount, 1, "should have at least 1 passphrase unlocker")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Test JSON output
 | 
						// Test JSON output
 | 
				
			||||||
@ -1309,6 +1323,7 @@ func test18AgeKeyOperations(t *testing.T, tempDir, secretPath, testMnemonic stri
 | 
				
			|||||||
		"SB_SECRET_MNEMONIC": testMnemonic,
 | 
							"SB_SECRET_MNEMONIC": testMnemonic,
 | 
				
			||||||
	}, "encrypt", "encryption/key", "--input", testFile)
 | 
						}, "encrypt", "encryption/key", "--input", testFile)
 | 
				
			||||||
	require.NoError(t, err, "encrypt to stdout should succeed")
 | 
						require.NoError(t, err, "encrypt to stdout should succeed")
 | 
				
			||||||
 | 
						t.Logf("DEBUG: encrypt output: %q", output)
 | 
				
			||||||
	assert.Contains(t, output, "age-encryption.org", "should output age format")
 | 
						assert.Contains(t, output, "age-encryption.org", "should output age format")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Test that the age key was stored as a secret
 | 
						// Test that the age key was stored as a secret
 | 
				
			||||||
@ -1804,10 +1819,10 @@ func test28VaultMetadata(t *testing.T, tempDir string) {
 | 
				
			|||||||
	require.NoError(t, err, "default vault metadata should be valid JSON")
 | 
						require.NoError(t, err, "default vault metadata should be valid JSON")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Verify required fields
 | 
						// Verify required fields
 | 
				
			||||||
	assert.Equal(t, "default", defaultMetadata["name"])
 | 
					 | 
				
			||||||
	assert.Equal(t, float64(0), defaultMetadata["derivation_index"])
 | 
						assert.Equal(t, float64(0), defaultMetadata["derivation_index"])
 | 
				
			||||||
	assert.Contains(t, defaultMetadata, "createdAt")
 | 
						assert.Contains(t, defaultMetadata, "createdAt")
 | 
				
			||||||
	assert.Contains(t, defaultMetadata, "public_key_hash")
 | 
						assert.Contains(t, defaultMetadata, "public_key_hash")
 | 
				
			||||||
 | 
						assert.Contains(t, defaultMetadata, "mnemonic_family_hash")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Check work vault metadata
 | 
						// Check work vault metadata
 | 
				
			||||||
	workMetadataPath := filepath.Join(tempDir, "vaults.d", "work", "vault-metadata.json")
 | 
						workMetadataPath := filepath.Join(tempDir, "vaults.d", "work", "vault-metadata.json")
 | 
				
			||||||
@ -1819,13 +1834,12 @@ func test28VaultMetadata(t *testing.T, tempDir string) {
 | 
				
			|||||||
	require.NoError(t, err, "work vault metadata should be valid JSON")
 | 
						require.NoError(t, err, "work vault metadata should be valid JSON")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Work vault should have different derivation index
 | 
						// Work vault should have different derivation index
 | 
				
			||||||
	assert.Equal(t, "work", workMetadata["name"])
 | 
					 | 
				
			||||||
	workIndex := workMetadata["derivation_index"].(float64)
 | 
						workIndex := workMetadata["derivation_index"].(float64)
 | 
				
			||||||
	assert.NotEqual(t, float64(0), workIndex, "work vault should have non-zero derivation index")
 | 
						assert.NotEqual(t, float64(0), workIndex, "work vault should have non-zero derivation index")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Both vaults created with same mnemonic should have same public_key_hash
 | 
						// Both vaults created with same mnemonic should have same mnemonic_family_hash
 | 
				
			||||||
	assert.Equal(t, defaultMetadata["public_key_hash"], workMetadata["public_key_hash"],
 | 
						assert.Equal(t, defaultMetadata["mnemonic_family_hash"], workMetadata["mnemonic_family_hash"],
 | 
				
			||||||
		"vaults from same mnemonic should have same public_key_hash")
 | 
							"vaults from same mnemonic should have same mnemonic_family_hash")
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func test29SymlinkHandling(t *testing.T, tempDir, secretPath, testMnemonic string) {
 | 
					func test29SymlinkHandling(t *testing.T, tempDir, secretPath, testMnemonic string) {
 | 
				
			||||||
@ -2025,7 +2039,7 @@ func test31EnvMnemonicUsesVaultDerivationIndex(t *testing.T, tempDir, secretPath
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
		// This is the expected behavior with the current bug
 | 
							// This is the expected behavior with the current bug
 | 
				
			||||||
		assert.Error(t, err, "get should fail due to wrong derivation index")
 | 
							assert.Error(t, err, "get should fail due to wrong derivation index")
 | 
				
			||||||
		assert.Contains(t, getOutput, "failed to decrypt", "should indicate decryption failure")
 | 
							assert.Contains(t, getOutput, "derived public key does not match vault", "should indicate key derivation failure")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		// Document what should happen when the bug is fixed
 | 
							// Document what should happen when the bug is fixed
 | 
				
			||||||
		t.Log("When the bug is fixed, GetValue should read vault metadata and use derivation index 1")
 | 
							t.Log("When the bug is fixed, GetValue should read vault metadata and use derivation index 1")
 | 
				
			||||||
 | 
				
			|||||||
@ -4,7 +4,6 @@ import (
 | 
				
			|||||||
	"encoding/json"
 | 
						"encoding/json"
 | 
				
			||||||
	"fmt"
 | 
						"fmt"
 | 
				
			||||||
	"io"
 | 
						"io"
 | 
				
			||||||
	"os"
 | 
					 | 
				
			||||||
	"strings"
 | 
						"strings"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	"git.eeqj.de/sneak/secret/internal/secret"
 | 
						"git.eeqj.de/sneak/secret/internal/secret"
 | 
				
			||||||
@ -25,6 +24,7 @@ func newAddCmd() *cobra.Command {
 | 
				
			|||||||
			secret.Debug("Got force flag", "force", force)
 | 
								secret.Debug("Got force flag", "force", force)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			cli := NewCLIInstance()
 | 
								cli := NewCLIInstance()
 | 
				
			||||||
 | 
								cli.cmd = cmd // Set the command for stdin access
 | 
				
			||||||
			secret.Debug("Created CLI instance, calling AddSecret")
 | 
								secret.Debug("Created CLI instance, calling AddSecret")
 | 
				
			||||||
			return cli.AddSecret(args[0], force)
 | 
								return cli.AddSecret(args[0], force)
 | 
				
			||||||
		},
 | 
							},
 | 
				
			||||||
@ -110,7 +110,7 @@ func (cli *CLIInstance) AddSecret(secretName string, force bool) error {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
	// Read secret value from stdin
 | 
						// Read secret value from stdin
 | 
				
			||||||
	secret.Debug("Reading secret value from stdin")
 | 
						secret.Debug("Reading secret value from stdin")
 | 
				
			||||||
	value, err := io.ReadAll(os.Stdin)
 | 
						value, err := io.ReadAll(cli.cmd.InOrStdin())
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return fmt.Errorf("failed to read secret value: %w", err)
 | 
							return fmt.Errorf("failed to read secret value: %w", err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@ -167,6 +167,15 @@ func (cli *CLIInstance) GetSecretWithVersion(cmd *cobra.Command, secretName stri
 | 
				
			|||||||
	// Print the secret value to stdout
 | 
						// Print the secret value to stdout
 | 
				
			||||||
	cmd.Print(string(value))
 | 
						cmd.Print(string(value))
 | 
				
			||||||
	secret.Debug("Printed value to cmd")
 | 
						secret.Debug("Printed value to cmd")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Debug: Log what we're actually printing
 | 
				
			||||||
 | 
						secret.Debug("Secret retrieval debug info",
 | 
				
			||||||
 | 
							"secretName", secretName,
 | 
				
			||||||
 | 
							"version", version,
 | 
				
			||||||
 | 
							"valueLength", len(value),
 | 
				
			||||||
 | 
							"valueAsString", string(value),
 | 
				
			||||||
 | 
							"isEmpty", len(value) == 0)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	return nil
 | 
						return nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -45,6 +45,11 @@ func ExecuteCommandInProcess(args []string, stdin string, env map[string]string)
 | 
				
			|||||||
	output := buf.String()
 | 
						output := buf.String()
 | 
				
			||||||
	secret.Debug("Command execution completed", "error", err, "outputLength", len(output), "output", output)
 | 
						secret.Debug("Command execution completed", "error", err, "outputLength", len(output), "output", output)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Add debug info for troubleshooting
 | 
				
			||||||
 | 
						if len(output) == 0 && err == nil {
 | 
				
			||||||
 | 
							secret.Debug("Warning: Command executed successfully but produced no output", "args", args)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Restore environment
 | 
						// Restore environment
 | 
				
			||||||
	for k, v := range savedEnv {
 | 
						for k, v := range savedEnv {
 | 
				
			||||||
		if v == "" {
 | 
							if v == "" {
 | 
				
			||||||
 | 
				
			|||||||
@ -40,6 +40,7 @@ func newUnlockersListCmd() *cobra.Command {
 | 
				
			|||||||
			jsonOutput, _ := cmd.Flags().GetBool("json")
 | 
								jsonOutput, _ := cmd.Flags().GetBool("json")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			cli := NewCLIInstance()
 | 
								cli := NewCLIInstance()
 | 
				
			||||||
 | 
								cli.cmd = cmd
 | 
				
			||||||
			return cli.UnlockersList(jsonOutput)
 | 
								return cli.UnlockersList(jsonOutput)
 | 
				
			||||||
		},
 | 
							},
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@ -201,31 +202,31 @@ func (cli *CLIInstance) UnlockersList(jsonOutput bool) error {
 | 
				
			|||||||
			return fmt.Errorf("failed to marshal JSON: %w", err)
 | 
								return fmt.Errorf("failed to marshal JSON: %w", err)
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		fmt.Println(string(jsonBytes))
 | 
							cli.cmd.Println(string(jsonBytes))
 | 
				
			||||||
	} else {
 | 
						} else {
 | 
				
			||||||
		// Pretty table output
 | 
							// Pretty table output
 | 
				
			||||||
		if len(unlockers) == 0 {
 | 
							if len(unlockers) == 0 {
 | 
				
			||||||
			fmt.Println("No unlockers found in current vault.")
 | 
								cli.cmd.Println("No unlockers found in current vault.")
 | 
				
			||||||
			fmt.Println("Run 'secret unlockers add passphrase' to create one.")
 | 
								cli.cmd.Println("Run 'secret unlockers add passphrase' to create one.")
 | 
				
			||||||
			return nil
 | 
								return nil
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		fmt.Printf("%-18s %-12s %-20s %s\n", "UNLOCKER ID", "TYPE", "CREATED", "FLAGS")
 | 
							cli.cmd.Printf("%-18s %-12s %-20s %s\n", "UNLOCKER ID", "TYPE", "CREATED", "FLAGS")
 | 
				
			||||||
		fmt.Printf("%-18s %-12s %-20s %s\n", "-----------", "----", "-------", "-----")
 | 
							cli.cmd.Printf("%-18s %-12s %-20s %s\n", "-----------", "----", "-------", "-----")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		for _, unlocker := range unlockers {
 | 
							for _, unlocker := range unlockers {
 | 
				
			||||||
			flags := ""
 | 
								flags := ""
 | 
				
			||||||
			if len(unlocker.Flags) > 0 {
 | 
								if len(unlocker.Flags) > 0 {
 | 
				
			||||||
				flags = strings.Join(unlocker.Flags, ",")
 | 
									flags = strings.Join(unlocker.Flags, ",")
 | 
				
			||||||
			}
 | 
								}
 | 
				
			||||||
			fmt.Printf("%-18s %-12s %-20s %s\n",
 | 
								cli.cmd.Printf("%-18s %-12s %-20s %s\n",
 | 
				
			||||||
				unlocker.ID,
 | 
									unlocker.ID,
 | 
				
			||||||
				unlocker.Type,
 | 
									unlocker.Type,
 | 
				
			||||||
				unlocker.CreatedAt.Format("2006-01-02 15:04:05"),
 | 
									unlocker.CreatedAt.Format("2006-01-02 15:04:05"),
 | 
				
			||||||
				flags)
 | 
									flags)
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		fmt.Printf("\nTotal: %d unlocker(s)\n", len(unlockers))
 | 
							cli.cmd.Printf("\nTotal: %d unlocker(s)\n", len(unlockers))
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	return nil
 | 
						return nil
 | 
				
			||||||
 | 
				
			|||||||
@ -223,13 +223,17 @@ func (cli *CLIInstance) VaultImport(cmd *cobra.Command, vaultName string) error
 | 
				
			|||||||
		return fmt.Errorf("failed to store long-term public key: %w", err)
 | 
							return fmt.Errorf("failed to store long-term public key: %w", err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Calculate public key hash from index 0 (same for all vaults with this mnemonic)
 | 
						// Calculate public key hash from the actual derivation index being used
 | 
				
			||||||
 | 
						// This is used to verify that the derived key matches what was stored
 | 
				
			||||||
 | 
						publicKeyHash := vault.ComputeDoubleSHA256([]byte(ltIdentity.Recipient().String()))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Calculate family hash from index 0 (same for all vaults with this mnemonic)
 | 
				
			||||||
	// This is used to identify which vaults belong to the same mnemonic family
 | 
						// This is used to identify which vaults belong to the same mnemonic family
 | 
				
			||||||
	identity0, err := agehd.DeriveIdentity(mnemonic, 0)
 | 
						identity0, err := agehd.DeriveIdentity(mnemonic, 0)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return fmt.Errorf("failed to derive identity for index 0: %w", err)
 | 
							return fmt.Errorf("failed to derive identity for index 0: %w", err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	publicKeyHash := vault.ComputeDoubleSHA256([]byte(identity0.Recipient().String()))
 | 
						familyHash := vault.ComputeDoubleSHA256([]byte(identity0.Recipient().String()))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Load existing metadata
 | 
						// Load existing metadata
 | 
				
			||||||
	existingMetadata, err := vault.LoadVaultMetadata(cli.fs, vaultDir)
 | 
						existingMetadata, err := vault.LoadVaultMetadata(cli.fs, vaultDir)
 | 
				
			||||||
@ -243,6 +247,7 @@ func (cli *CLIInstance) VaultImport(cmd *cobra.Command, vaultName string) error
 | 
				
			|||||||
	// Update metadata with new derivation info
 | 
						// Update metadata with new derivation info
 | 
				
			||||||
	existingMetadata.DerivationIndex = derivationIndex
 | 
						existingMetadata.DerivationIndex = derivationIndex
 | 
				
			||||||
	existingMetadata.PublicKeyHash = publicKeyHash
 | 
						existingMetadata.PublicKeyHash = publicKeyHash
 | 
				
			||||||
 | 
						existingMetadata.MnemonicFamilyHash = familyHash
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if err := vault.SaveVaultMetadata(cli.fs, vaultDir, existingMetadata); err != nil {
 | 
						if err := vault.SaveVaultMetadata(cli.fs, vaultDir, existingMetadata); err != nil {
 | 
				
			||||||
		secret.Debug("Failed to save vault metadata", "error", err)
 | 
							secret.Debug("Failed to save vault metadata", "error", err)
 | 
				
			||||||
 | 
				
			|||||||
@ -2,7 +2,6 @@ package cli
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
import (
 | 
					import (
 | 
				
			||||||
	"fmt"
 | 
						"fmt"
 | 
				
			||||||
	"os"
 | 
					 | 
				
			||||||
	"path/filepath"
 | 
						"path/filepath"
 | 
				
			||||||
	"strings"
 | 
						"strings"
 | 
				
			||||||
	"text/tabwriter"
 | 
						"text/tabwriter"
 | 
				
			||||||
@ -110,7 +109,7 @@ func (cli *CLIInstance) ListVersions(cmd *cobra.Command, secretName string) erro
 | 
				
			|||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Create table writer
 | 
						// Create table writer
 | 
				
			||||||
	w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
 | 
						w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0)
 | 
				
			||||||
	fmt.Fprintln(w, "VERSION\tCREATED\tSTATUS\tNOT_BEFORE\tNOT_AFTER")
 | 
						fmt.Fprintln(w, "VERSION\tCREATED\tSTATUS\tNOT_BEFORE\tNOT_AFTER")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Load and display each version's metadata
 | 
						// Load and display each version's metadata
 | 
				
			||||||
@ -185,15 +184,8 @@ func (cli *CLIInstance) PromoteVersion(cmd *cobra.Command, secretName string, ve
 | 
				
			|||||||
		return fmt.Errorf("version '%s' not found for secret '%s'", version, secretName)
 | 
							return fmt.Errorf("version '%s' not found for secret '%s'", version, secretName)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Update the current symlink
 | 
						// Update the current symlink using the proper function
 | 
				
			||||||
	currentLink := filepath.Join(secretDir, "current")
 | 
						if err := secret.SetCurrentVersion(cli.fs, secretDir, version); err != nil {
 | 
				
			||||||
 | 
					 | 
				
			||||||
	// Remove existing symlink
 | 
					 | 
				
			||||||
	_ = cli.fs.Remove(currentLink)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	// Create new symlink to the selected version
 | 
					 | 
				
			||||||
	relativePath := filepath.Join("versions", version)
 | 
					 | 
				
			||||||
	if err := afero.WriteFile(cli.fs, currentLink, []byte(relativePath), 0644); err != nil {
 | 
					 | 
				
			||||||
		return fmt.Errorf("failed to update current version: %w", err)
 | 
							return fmt.Errorf("failed to update current version: %w", err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -18,11 +18,11 @@ var (
 | 
				
			|||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func init() {
 | 
					func init() {
 | 
				
			||||||
	initDebugLogging()
 | 
						InitDebugLogging()
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// initDebugLogging initializes the debug logging system based on GODEBUG environment variable
 | 
					// InitDebugLogging initializes the debug logging system based on current GODEBUG environment variable
 | 
				
			||||||
func initDebugLogging() {
 | 
					func InitDebugLogging() {
 | 
				
			||||||
	godebug := os.Getenv("GODEBUG")
 | 
						godebug := os.Getenv("GODEBUG")
 | 
				
			||||||
	debugEnabled = strings.Contains(godebug, "berlin.sneak.pkg.secret")
 | 
						debugEnabled = strings.Contains(godebug, "berlin.sneak.pkg.secret")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -21,7 +21,7 @@ func TestDebugLogging(t *testing.T) {
 | 
				
			|||||||
			os.Setenv("GODEBUG", originalGodebug)
 | 
								os.Setenv("GODEBUG", originalGodebug)
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
		// Re-initialize debug system with original setting
 | 
							// Re-initialize debug system with original setting
 | 
				
			||||||
		initDebugLogging()
 | 
							InitDebugLogging()
 | 
				
			||||||
	}()
 | 
						}()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	tests := []struct {
 | 
						tests := []struct {
 | 
				
			||||||
@ -61,7 +61,7 @@ func TestDebugLogging(t *testing.T) {
 | 
				
			|||||||
			}
 | 
								}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			// Re-initialize debug system
 | 
								// Re-initialize debug system
 | 
				
			||||||
			initDebugLogging()
 | 
								InitDebugLogging()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			// Test if debug is enabled
 | 
								// Test if debug is enabled
 | 
				
			||||||
			enabled := IsDebugEnabled()
 | 
								enabled := IsDebugEnabled()
 | 
				
			||||||
@ -112,10 +112,10 @@ func TestDebugFunctions(t *testing.T) {
 | 
				
			|||||||
		} else {
 | 
							} else {
 | 
				
			||||||
			os.Setenv("GODEBUG", originalGodebug)
 | 
								os.Setenv("GODEBUG", originalGodebug)
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
		initDebugLogging()
 | 
							InitDebugLogging()
 | 
				
			||||||
	}()
 | 
						}()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	initDebugLogging()
 | 
						InitDebugLogging()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if !IsDebugEnabled() {
 | 
						if !IsDebugEnabled() {
 | 
				
			||||||
		t.Log("Debug not enabled, but continuing with debug function tests anyway")
 | 
							t.Log("Debug not enabled, but continuing with debug function tests anyway")
 | 
				
			||||||
 | 
				
			|||||||
@ -8,6 +8,7 @@ import (
 | 
				
			|||||||
	"os"
 | 
						"os"
 | 
				
			||||||
	"os/exec"
 | 
						"os/exec"
 | 
				
			||||||
	"path/filepath"
 | 
						"path/filepath"
 | 
				
			||||||
 | 
						"regexp"
 | 
				
			||||||
	"time"
 | 
						"time"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	"filippo.io/age"
 | 
						"filippo.io/age"
 | 
				
			||||||
@ -15,6 +16,12 @@ import (
 | 
				
			|||||||
	"github.com/spf13/afero"
 | 
						"github.com/spf13/afero"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					var (
 | 
				
			||||||
 | 
						// keychainItemNameRegex validates keychain item names
 | 
				
			||||||
 | 
						// Allows alphanumeric characters, dots, hyphens, and underscores only
 | 
				
			||||||
 | 
						keychainItemNameRegex = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// KeychainUnlockerMetadata extends UnlockerMetadata with keychain-specific data
 | 
					// KeychainUnlockerMetadata extends UnlockerMetadata with keychain-specific data
 | 
				
			||||||
type KeychainUnlockerMetadata struct {
 | 
					type KeychainUnlockerMetadata struct {
 | 
				
			||||||
	UnlockerMetadata
 | 
						UnlockerMetadata
 | 
				
			||||||
@ -392,9 +399,25 @@ func checkMacOSAvailable() error {
 | 
				
			|||||||
	return nil
 | 
						return nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// validateKeychainItemName validates that a keychain item name is safe for command execution
 | 
				
			||||||
 | 
					func validateKeychainItemName(itemName string) error {
 | 
				
			||||||
 | 
						if itemName == "" {
 | 
				
			||||||
 | 
							return fmt.Errorf("keychain item name cannot be empty")
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if !keychainItemNameRegex.MatchString(itemName) {
 | 
				
			||||||
 | 
							return fmt.Errorf("invalid keychain item name format: %s", itemName)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// storeInKeychain stores data in the macOS keychain using the security command
 | 
					// storeInKeychain stores data in the macOS keychain using the security command
 | 
				
			||||||
func storeInKeychain(itemName string, data []byte) error {
 | 
					func storeInKeychain(itemName string, data []byte) error {
 | 
				
			||||||
	cmd := exec.Command("/usr/bin/security", "add-generic-password",
 | 
						if err := validateKeychainItemName(itemName); err != nil {
 | 
				
			||||||
 | 
							return fmt.Errorf("invalid keychain item name: %w", err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						cmd := exec.Command("/usr/bin/security", "add-generic-password", //nolint:gosec // Input validated by validateKeychainItemName
 | 
				
			||||||
		"-a", itemName,
 | 
							"-a", itemName,
 | 
				
			||||||
		"-s", itemName,
 | 
							"-s", itemName,
 | 
				
			||||||
		"-w", string(data),
 | 
							"-w", string(data),
 | 
				
			||||||
@ -409,7 +432,11 @@ func storeInKeychain(itemName string, data []byte) error {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
// retrieveFromKeychain retrieves data from the macOS keychain using the security command
 | 
					// retrieveFromKeychain retrieves data from the macOS keychain using the security command
 | 
				
			||||||
func retrieveFromKeychain(itemName string) ([]byte, error) {
 | 
					func retrieveFromKeychain(itemName string) ([]byte, error) {
 | 
				
			||||||
	cmd := exec.Command("/usr/bin/security", "find-generic-password",
 | 
						if err := validateKeychainItemName(itemName); err != nil {
 | 
				
			||||||
 | 
							return nil, fmt.Errorf("invalid keychain item name: %w", err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						cmd := exec.Command("/usr/bin/security", "find-generic-password", //nolint:gosec // Input validated by validateKeychainItemName
 | 
				
			||||||
		"-a", itemName,
 | 
							"-a", itemName,
 | 
				
			||||||
		"-s", itemName,
 | 
							"-s", itemName,
 | 
				
			||||||
		"-w") // Return password only
 | 
							"-w") // Return password only
 | 
				
			||||||
@ -429,7 +456,11 @@ func retrieveFromKeychain(itemName string) ([]byte, error) {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
// deleteFromKeychain removes an item from the macOS keychain using the security command
 | 
					// deleteFromKeychain removes an item from the macOS keychain using the security command
 | 
				
			||||||
func deleteFromKeychain(itemName string) error {
 | 
					func deleteFromKeychain(itemName string) error {
 | 
				
			||||||
	cmd := exec.Command("/usr/bin/security", "delete-generic-password",
 | 
						if err := validateKeychainItemName(itemName); err != nil {
 | 
				
			||||||
 | 
							return fmt.Errorf("invalid keychain item name: %w", err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						cmd := exec.Command("/usr/bin/security", "delete-generic-password", //nolint:gosec // Input validated by validateKeychainItemName
 | 
				
			||||||
		"-a", itemName,
 | 
							"-a", itemName,
 | 
				
			||||||
		"-s", itemName)
 | 
							"-s", itemName)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -9,7 +9,8 @@ type VaultMetadata struct {
 | 
				
			|||||||
	CreatedAt          time.Time `json:"createdAt"`
 | 
						CreatedAt          time.Time `json:"createdAt"`
 | 
				
			||||||
	Description        string    `json:"description,omitempty"`
 | 
						Description        string    `json:"description,omitempty"`
 | 
				
			||||||
	DerivationIndex    uint32    `json:"derivation_index"`
 | 
						DerivationIndex    uint32    `json:"derivation_index"`
 | 
				
			||||||
	PublicKeyHash   string    `json:"public_key_hash,omitempty"` // Double SHA256 hash of the long-term public key
 | 
						PublicKeyHash      string    `json:"public_key_hash,omitempty"`      // Double SHA256 hash of the actual long-term public key
 | 
				
			||||||
 | 
						MnemonicFamilyHash string    `json:"mnemonic_family_hash,omitempty"` // Double SHA256 hash of index-0 key (for grouping vaults from same mnemonic)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// UnlockerMetadata contains information about an unlocker
 | 
					// UnlockerMetadata contains information about an unlocker
 | 
				
			||||||
 | 
				
			|||||||
@ -189,21 +189,26 @@ Passphrase: ` + testPassphrase + `
 | 
				
			|||||||
	}
 | 
						}
 | 
				
			||||||
	t.Log("GPG key generated successfully")
 | 
						t.Log("GPG key generated successfully")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Get the key ID
 | 
						// Get the key ID and fingerprint
 | 
				
			||||||
	output, err := runGPGWithPassphrase(gnupgHomeDir, testPassphrase,
 | 
						output, err := runGPGWithPassphrase(gnupgHomeDir, testPassphrase,
 | 
				
			||||||
		[]string{"--list-secret-keys", "--with-colons"}, nil)
 | 
							[]string{"--list-secret-keys", "--with-colons", "--fingerprint"}, nil)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		t.Fatalf("Failed to list GPG keys: %v", err)
 | 
							t.Fatalf("Failed to list GPG keys: %v", err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Parse output to get key ID
 | 
						// Parse output to get key ID and fingerprint
 | 
				
			||||||
	var keyID string
 | 
						var keyID, fingerprint string
 | 
				
			||||||
	lines := strings.Split(string(output), "\n")
 | 
						lines := strings.Split(string(output), "\n")
 | 
				
			||||||
	for _, line := range lines {
 | 
						for _, line := range lines {
 | 
				
			||||||
		if strings.HasPrefix(line, "sec:") {
 | 
							if strings.HasPrefix(line, "sec:") {
 | 
				
			||||||
			fields := strings.Split(line, ":")
 | 
								fields := strings.Split(line, ":")
 | 
				
			||||||
			if len(fields) >= 5 {
 | 
								if len(fields) >= 5 {
 | 
				
			||||||
				keyID = fields[4]
 | 
									keyID = fields[4]
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
							} else if strings.HasPrefix(line, "fpr:") {
 | 
				
			||||||
 | 
								fields := strings.Split(line, ":")
 | 
				
			||||||
 | 
								if len(fields) >= 10 && fields[9] != "" {
 | 
				
			||||||
 | 
									fingerprint = fields[9]
 | 
				
			||||||
				break
 | 
									break
 | 
				
			||||||
			}
 | 
								}
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
@ -212,7 +217,11 @@ Passphrase: ` + testPassphrase + `
 | 
				
			|||||||
	if keyID == "" {
 | 
						if keyID == "" {
 | 
				
			||||||
		t.Fatalf("Failed to find GPG key ID in output: %s", output)
 | 
							t.Fatalf("Failed to find GPG key ID in output: %s", output)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
						if fingerprint == "" {
 | 
				
			||||||
 | 
							t.Fatalf("Failed to find GPG fingerprint in output: %s", output)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
	t.Logf("Generated GPG key ID: %s", keyID)
 | 
						t.Logf("Generated GPG key ID: %s", keyID)
 | 
				
			||||||
 | 
						t.Logf("Generated GPG fingerprint: %s", fingerprint)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Set the GPG_AGENT_INFO to empty to ensure gpg-agent doesn't interfere
 | 
						// Set the GPG_AGENT_INFO to empty to ensure gpg-agent doesn't interfere
 | 
				
			||||||
	oldAgentInfo := os.Getenv("GPG_AGENT_INFO")
 | 
						oldAgentInfo := os.Getenv("GPG_AGENT_INFO")
 | 
				
			||||||
@ -326,9 +335,9 @@ Passphrase: ` + testPassphrase + `
 | 
				
			|||||||
			t.Errorf("Expected PGP unlock key type 'pgp', got '%s'", pgpUnlocker.GetType())
 | 
								t.Errorf("Expected PGP unlock key type 'pgp', got '%s'", pgpUnlocker.GetType())
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		// Check if the key ID includes the GPG key ID
 | 
							// Check if the key ID includes the GPG fingerprint
 | 
				
			||||||
		if !strings.Contains(pgpUnlocker.GetID(), keyID) {
 | 
							if !strings.Contains(pgpUnlocker.GetID(), fingerprint) {
 | 
				
			||||||
			t.Errorf("PGP unlock key ID '%s' does not contain GPG key ID '%s'", pgpUnlocker.GetID(), keyID)
 | 
								t.Errorf("PGP unlock key ID '%s' does not contain GPG fingerprint '%s'", pgpUnlocker.GetID(), fingerprint)
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		// Check if the key directory exists
 | 
							// Check if the key directory exists
 | 
				
			||||||
@ -400,8 +409,8 @@ Passphrase: ` + testPassphrase + `
 | 
				
			|||||||
			t.Errorf("Expected metadata type 'pgp', got '%s'", metadata.Type)
 | 
								t.Errorf("Expected metadata type 'pgp', got '%s'", metadata.Type)
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		if metadata.GPGKeyID != keyID {
 | 
							if metadata.GPGKeyID != fingerprint {
 | 
				
			||||||
			t.Errorf("Expected GPG key ID '%s', got '%s'", keyID, metadata.GPGKeyID)
 | 
								t.Errorf("Expected GPG fingerprint '%s', got '%s'", fingerprint, metadata.GPGKeyID)
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	})
 | 
						})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -431,7 +440,7 @@ Passphrase: ` + testPassphrase + `
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
		pgpMetadata := PGPUnlockerMetadata{
 | 
							pgpMetadata := PGPUnlockerMetadata{
 | 
				
			||||||
			UnlockerMetadata: metadata,
 | 
								UnlockerMetadata: metadata,
 | 
				
			||||||
			GPGKeyID:         keyID,
 | 
								GPGKeyID:         fingerprint,
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		// Write metadata file
 | 
							// Write metadata file
 | 
				
			||||||
@ -450,9 +459,9 @@ Passphrase: ` + testPassphrase + `
 | 
				
			|||||||
			t.Fatalf("Failed to get GPG key ID: %v", err)
 | 
								t.Fatalf("Failed to get GPG key ID: %v", err)
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		// Verify key ID
 | 
							// Verify key ID (should be the fingerprint)
 | 
				
			||||||
		if retrievedKeyID != keyID {
 | 
							if retrievedKeyID != fingerprint {
 | 
				
			||||||
			t.Errorf("Expected GPG key ID '%s', got '%s'", keyID, retrievedKeyID)
 | 
								t.Errorf("Expected GPG fingerprint '%s', got '%s'", fingerprint, retrievedKeyID)
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	})
 | 
						})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -7,6 +7,7 @@ import (
 | 
				
			|||||||
	"os"
 | 
						"os"
 | 
				
			||||||
	"os/exec"
 | 
						"os/exec"
 | 
				
			||||||
	"path/filepath"
 | 
						"path/filepath"
 | 
				
			||||||
 | 
						"regexp"
 | 
				
			||||||
	"strings"
 | 
						"strings"
 | 
				
			||||||
	"time"
 | 
						"time"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -24,6 +25,19 @@ var (
 | 
				
			|||||||
	// GPGDecryptFunc is the function used for GPG decryption
 | 
						// GPGDecryptFunc is the function used for GPG decryption
 | 
				
			||||||
	// Can be overridden in tests to provide a non-interactive implementation
 | 
						// Can be overridden in tests to provide a non-interactive implementation
 | 
				
			||||||
	GPGDecryptFunc = gpgDecryptDefault
 | 
						GPGDecryptFunc = gpgDecryptDefault
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// gpgKeyIDRegex validates GPG key IDs
 | 
				
			||||||
 | 
						// Allows either:
 | 
				
			||||||
 | 
						// 1. Email addresses (user@domain.tld format)
 | 
				
			||||||
 | 
						// 2. Short key IDs (8 hex characters)
 | 
				
			||||||
 | 
						// 3. Long key IDs (16 hex characters)
 | 
				
			||||||
 | 
						// 4. Full fingerprints (40 hex characters)
 | 
				
			||||||
 | 
						gpgKeyIDRegex = regexp.MustCompile(
 | 
				
			||||||
 | 
							`^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$|` +
 | 
				
			||||||
 | 
								`^[A-Fa-f0-9]{8}$|` +
 | 
				
			||||||
 | 
								`^[A-Fa-f0-9]{16}$|` +
 | 
				
			||||||
 | 
								`^[A-Fa-f0-9]{40}$`,
 | 
				
			||||||
 | 
						)
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// PGPUnlockerMetadata extends UnlockerMetadata with PGP-specific data
 | 
					// PGPUnlockerMetadata extends UnlockerMetadata with PGP-specific data
 | 
				
			||||||
@ -285,14 +299,20 @@ func CreatePGPUnlocker(fs afero.Fs, stateDir string, gpgKeyID string) (*PGPUnloc
 | 
				
			|||||||
		return nil, fmt.Errorf("failed to write encrypted age private key: %w", err)
 | 
							return nil, fmt.Errorf("failed to write encrypted age private key: %w", err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Step 9: Create and write enhanced metadata
 | 
						// Step 9: Resolve the GPG key ID to its full fingerprint
 | 
				
			||||||
 | 
						fingerprint, err := resolveGPGKeyFingerprint(gpgKeyID)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return nil, fmt.Errorf("failed to resolve GPG key fingerprint: %w", err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Step 10: Create and write enhanced metadata with full fingerprint
 | 
				
			||||||
	pgpMetadata := PGPUnlockerMetadata{
 | 
						pgpMetadata := PGPUnlockerMetadata{
 | 
				
			||||||
		UnlockerMetadata: UnlockerMetadata{
 | 
							UnlockerMetadata: UnlockerMetadata{
 | 
				
			||||||
			Type:      "pgp",
 | 
								Type:      "pgp",
 | 
				
			||||||
			CreatedAt: time.Now(),
 | 
								CreatedAt: time.Now(),
 | 
				
			||||||
			Flags:     []string{"gpg", "encrypted"},
 | 
								Flags:     []string{"gpg", "encrypted"},
 | 
				
			||||||
		},
 | 
							},
 | 
				
			||||||
		GPGKeyID: gpgKeyID,
 | 
							GPGKeyID: fingerprint,
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	metadataBytes, err := json.MarshalIndent(pgpMetadata, "", "  ")
 | 
						metadataBytes, err := json.MarshalIndent(pgpMetadata, "", "  ")
 | 
				
			||||||
@ -311,6 +331,46 @@ func CreatePGPUnlocker(fs afero.Fs, stateDir string, gpgKeyID string) (*PGPUnloc
 | 
				
			|||||||
	}, nil
 | 
						}, nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// validateGPGKeyID validates that a GPG key ID is safe for command execution
 | 
				
			||||||
 | 
					func validateGPGKeyID(keyID string) error {
 | 
				
			||||||
 | 
						if keyID == "" {
 | 
				
			||||||
 | 
							return fmt.Errorf("GPG key ID cannot be empty")
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if !gpgKeyIDRegex.MatchString(keyID) {
 | 
				
			||||||
 | 
							return fmt.Errorf("invalid GPG key ID format: %s", keyID)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// resolveGPGKeyFingerprint resolves any GPG key identifier to its full fingerprint
 | 
				
			||||||
 | 
					func resolveGPGKeyFingerprint(keyID string) (string, error) {
 | 
				
			||||||
 | 
						if err := validateGPGKeyID(keyID); err != nil {
 | 
				
			||||||
 | 
							return "", fmt.Errorf("invalid GPG key ID: %w", err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Use GPG to get the full fingerprint for the key
 | 
				
			||||||
 | 
						cmd := exec.Command("gpg", "--list-keys", "--with-colons", "--fingerprint", keyID)
 | 
				
			||||||
 | 
						output, err := cmd.Output()
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return "", fmt.Errorf("failed to resolve GPG key fingerprint: %w", err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Parse the output to extract the fingerprint
 | 
				
			||||||
 | 
						lines := strings.Split(string(output), "\n")
 | 
				
			||||||
 | 
						for _, line := range lines {
 | 
				
			||||||
 | 
							if strings.HasPrefix(line, "fpr:") {
 | 
				
			||||||
 | 
								fields := strings.Split(line, ":")
 | 
				
			||||||
 | 
								if len(fields) >= 10 && fields[9] != "" {
 | 
				
			||||||
 | 
									return fields[9], nil
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return "", fmt.Errorf("could not find fingerprint for GPG key: %s", keyID)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// checkGPGAvailable verifies that GPG is available
 | 
					// checkGPGAvailable verifies that GPG is available
 | 
				
			||||||
func checkGPGAvailable() error {
 | 
					func checkGPGAvailable() error {
 | 
				
			||||||
	cmd := exec.Command("gpg", "--version")
 | 
						cmd := exec.Command("gpg", "--version")
 | 
				
			||||||
@ -322,6 +382,10 @@ func checkGPGAvailable() error {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
// gpgEncryptDefault is the default implementation of GPG encryption
 | 
					// gpgEncryptDefault is the default implementation of GPG encryption
 | 
				
			||||||
func gpgEncryptDefault(data []byte, keyID string) ([]byte, error) {
 | 
					func gpgEncryptDefault(data []byte, keyID string) ([]byte, error) {
 | 
				
			||||||
 | 
						if err := validateGPGKeyID(keyID); err != nil {
 | 
				
			||||||
 | 
							return nil, fmt.Errorf("invalid GPG key ID: %w", err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	cmd := exec.Command("gpg", "--trust-model", "always", "--armor", "--encrypt", "-r", keyID)
 | 
						cmd := exec.Command("gpg", "--trust-model", "always", "--armor", "--encrypt", "-r", keyID)
 | 
				
			||||||
	cmd.Stdin = strings.NewReader(string(data))
 | 
						cmd.Stdin = strings.NewReader(string(data))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										297
									
								
								internal/secret/validation_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										297
									
								
								internal/secret/validation_test.go
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,297 @@
 | 
				
			|||||||
 | 
					package secret
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import (
 | 
				
			||||||
 | 
						"testing"
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func TestValidateGPGKeyID(t *testing.T) {
 | 
				
			||||||
 | 
						tests := []struct {
 | 
				
			||||||
 | 
							name    string
 | 
				
			||||||
 | 
							keyID   string
 | 
				
			||||||
 | 
							wantErr bool
 | 
				
			||||||
 | 
						}{
 | 
				
			||||||
 | 
							// Valid cases
 | 
				
			||||||
 | 
							{
 | 
				
			||||||
 | 
								name:    "valid email address",
 | 
				
			||||||
 | 
								keyID:   "test@example.com",
 | 
				
			||||||
 | 
								wantErr: false,
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
							{
 | 
				
			||||||
 | 
								name:    "valid email with dots and hyphens",
 | 
				
			||||||
 | 
								keyID:   "test.user-name@example-domain.co.uk",
 | 
				
			||||||
 | 
								wantErr: false,
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
							{
 | 
				
			||||||
 | 
								name:    "valid email with plus",
 | 
				
			||||||
 | 
								keyID:   "test+tag@example.com",
 | 
				
			||||||
 | 
								wantErr: false,
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
							{
 | 
				
			||||||
 | 
								name:    "valid short key ID (8 hex chars)",
 | 
				
			||||||
 | 
								keyID:   "ABCDEF12",
 | 
				
			||||||
 | 
								wantErr: false,
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
							{
 | 
				
			||||||
 | 
								name:    "valid long key ID (16 hex chars)",
 | 
				
			||||||
 | 
								keyID:   "ABCDEF1234567890",
 | 
				
			||||||
 | 
								wantErr: false,
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
							{
 | 
				
			||||||
 | 
								name:    "valid fingerprint (40 hex chars)",
 | 
				
			||||||
 | 
								keyID:   "ABCDEF1234567890ABCDEF1234567890ABCDEF12",
 | 
				
			||||||
 | 
								wantErr: false,
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
							{
 | 
				
			||||||
 | 
								name:    "valid lowercase hex fingerprint",
 | 
				
			||||||
 | 
								keyID:   "abcdef1234567890abcdef1234567890abcdef12",
 | 
				
			||||||
 | 
								wantErr: false,
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
							{
 | 
				
			||||||
 | 
								name:    "valid mixed case hex",
 | 
				
			||||||
 | 
								keyID:   "AbCdEf1234567890",
 | 
				
			||||||
 | 
								wantErr: false,
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							// Invalid cases
 | 
				
			||||||
 | 
							{
 | 
				
			||||||
 | 
								name:    "empty key ID",
 | 
				
			||||||
 | 
								keyID:   "",
 | 
				
			||||||
 | 
								wantErr: true,
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
							{
 | 
				
			||||||
 | 
								name:    "key ID with spaces",
 | 
				
			||||||
 | 
								keyID:   "test user@example.com",
 | 
				
			||||||
 | 
								wantErr: true,
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
							{
 | 
				
			||||||
 | 
								name:    "key ID with semicolon (command injection)",
 | 
				
			||||||
 | 
								keyID:   "test@example.com; rm -rf /",
 | 
				
			||||||
 | 
								wantErr: true,
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
							{
 | 
				
			||||||
 | 
								name:    "key ID with pipe (command injection)",
 | 
				
			||||||
 | 
								keyID:   "test@example.com | cat /etc/passwd",
 | 
				
			||||||
 | 
								wantErr: true,
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
							{
 | 
				
			||||||
 | 
								name:    "key ID with backticks (command injection)",
 | 
				
			||||||
 | 
								keyID:   "test@example.com`whoami`",
 | 
				
			||||||
 | 
								wantErr: true,
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
							{
 | 
				
			||||||
 | 
								name:    "key ID with dollar sign (command injection)",
 | 
				
			||||||
 | 
								keyID:   "test@example.com$(whoami)",
 | 
				
			||||||
 | 
								wantErr: true,
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
							{
 | 
				
			||||||
 | 
								name:    "key ID with quotes",
 | 
				
			||||||
 | 
								keyID:   "test\"@example.com",
 | 
				
			||||||
 | 
								wantErr: true,
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
							{
 | 
				
			||||||
 | 
								name:    "key ID with single quotes",
 | 
				
			||||||
 | 
								keyID:   "test'@example.com",
 | 
				
			||||||
 | 
								wantErr: true,
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
							{
 | 
				
			||||||
 | 
								name:    "key ID with backslash",
 | 
				
			||||||
 | 
								keyID:   "test\\@example.com",
 | 
				
			||||||
 | 
								wantErr: true,
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
							{
 | 
				
			||||||
 | 
								name:    "key ID with newline",
 | 
				
			||||||
 | 
								keyID:   "test@example.com\nrm -rf /",
 | 
				
			||||||
 | 
								wantErr: true,
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
							{
 | 
				
			||||||
 | 
								name:    "key ID with carriage return",
 | 
				
			||||||
 | 
								keyID:   "test@example.com\rrm -rf /",
 | 
				
			||||||
 | 
								wantErr: true,
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
							{
 | 
				
			||||||
 | 
								name:    "hex with invalid length (7 chars)",
 | 
				
			||||||
 | 
								keyID:   "ABCDEF1",
 | 
				
			||||||
 | 
								wantErr: true,
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
							{
 | 
				
			||||||
 | 
								name:    "hex with invalid length (9 chars)",
 | 
				
			||||||
 | 
								keyID:   "ABCDEF123",
 | 
				
			||||||
 | 
								wantErr: true,
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
							{
 | 
				
			||||||
 | 
								name:    "hex with non-hex characters",
 | 
				
			||||||
 | 
								keyID:   "ABCDEFGH",
 | 
				
			||||||
 | 
								wantErr: true,
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
							{
 | 
				
			||||||
 | 
								name:    "mixed format (email with hex)",
 | 
				
			||||||
 | 
								keyID:   "test@ABCDEF12",
 | 
				
			||||||
 | 
								wantErr: true,
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
							{
 | 
				
			||||||
 | 
								name:    "key ID with ampersand",
 | 
				
			||||||
 | 
								keyID:   "test@example.com & echo test",
 | 
				
			||||||
 | 
								wantErr: true,
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
							{
 | 
				
			||||||
 | 
								name:    "key ID with redirect",
 | 
				
			||||||
 | 
								keyID:   "test@example.com > /tmp/test",
 | 
				
			||||||
 | 
								wantErr: true,
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
							{
 | 
				
			||||||
 | 
								name:    "key ID with null byte",
 | 
				
			||||||
 | 
								keyID:   "test@example.com\x00",
 | 
				
			||||||
 | 
								wantErr: true,
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						for _, tt := range tests {
 | 
				
			||||||
 | 
							t.Run(tt.name, func(t *testing.T) {
 | 
				
			||||||
 | 
								err := validateGPGKeyID(tt.keyID)
 | 
				
			||||||
 | 
								if (err != nil) != tt.wantErr {
 | 
				
			||||||
 | 
									t.Errorf("validateGPGKeyID() error = %v, wantErr %v", err, tt.wantErr)
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
							})
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func TestValidateKeychainItemName(t *testing.T) {
 | 
				
			||||||
 | 
						tests := []struct {
 | 
				
			||||||
 | 
							name     string
 | 
				
			||||||
 | 
							itemName string
 | 
				
			||||||
 | 
							wantErr  bool
 | 
				
			||||||
 | 
						}{
 | 
				
			||||||
 | 
							// Valid cases
 | 
				
			||||||
 | 
							{
 | 
				
			||||||
 | 
								name:     "valid simple name",
 | 
				
			||||||
 | 
								itemName: "my-secret-key",
 | 
				
			||||||
 | 
								wantErr:  false,
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
							{
 | 
				
			||||||
 | 
								name:     "valid name with dots",
 | 
				
			||||||
 | 
								itemName: "com.example.app.key",
 | 
				
			||||||
 | 
								wantErr:  false,
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
							{
 | 
				
			||||||
 | 
								name:     "valid name with underscores",
 | 
				
			||||||
 | 
								itemName: "my_secret_key_123",
 | 
				
			||||||
 | 
								wantErr:  false,
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
							{
 | 
				
			||||||
 | 
								name:     "valid alphanumeric",
 | 
				
			||||||
 | 
								itemName: "Secret123Key",
 | 
				
			||||||
 | 
								wantErr:  false,
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
							{
 | 
				
			||||||
 | 
								name:     "valid with hyphen at start",
 | 
				
			||||||
 | 
								itemName: "-my-key",
 | 
				
			||||||
 | 
								wantErr:  false,
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
							{
 | 
				
			||||||
 | 
								name:     "valid with dot at start",
 | 
				
			||||||
 | 
								itemName: ".hidden-key",
 | 
				
			||||||
 | 
								wantErr:  false,
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							// Invalid cases
 | 
				
			||||||
 | 
							{
 | 
				
			||||||
 | 
								name:     "empty item name",
 | 
				
			||||||
 | 
								itemName: "",
 | 
				
			||||||
 | 
								wantErr:  true,
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
							{
 | 
				
			||||||
 | 
								name:     "item name with spaces",
 | 
				
			||||||
 | 
								itemName: "my secret key",
 | 
				
			||||||
 | 
								wantErr:  true,
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
							{
 | 
				
			||||||
 | 
								name:     "item name with semicolon",
 | 
				
			||||||
 | 
								itemName: "key;rm -rf /",
 | 
				
			||||||
 | 
								wantErr:  true,
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
							{
 | 
				
			||||||
 | 
								name:     "item name with pipe",
 | 
				
			||||||
 | 
								itemName: "key|cat /etc/passwd",
 | 
				
			||||||
 | 
								wantErr:  true,
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
							{
 | 
				
			||||||
 | 
								name:     "item name with backticks",
 | 
				
			||||||
 | 
								itemName: "key`whoami`",
 | 
				
			||||||
 | 
								wantErr:  true,
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
							{
 | 
				
			||||||
 | 
								name:     "item name with dollar sign",
 | 
				
			||||||
 | 
								itemName: "key$(whoami)",
 | 
				
			||||||
 | 
								wantErr:  true,
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
							{
 | 
				
			||||||
 | 
								name:     "item name with quotes",
 | 
				
			||||||
 | 
								itemName: "key\"name",
 | 
				
			||||||
 | 
								wantErr:  true,
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
							{
 | 
				
			||||||
 | 
								name:     "item name with single quotes",
 | 
				
			||||||
 | 
								itemName: "key'name",
 | 
				
			||||||
 | 
								wantErr:  true,
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
							{
 | 
				
			||||||
 | 
								name:     "item name with backslash",
 | 
				
			||||||
 | 
								itemName: "key\\name",
 | 
				
			||||||
 | 
								wantErr:  true,
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
							{
 | 
				
			||||||
 | 
								name:     "item name with newline",
 | 
				
			||||||
 | 
								itemName: "key\nname",
 | 
				
			||||||
 | 
								wantErr:  true,
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
							{
 | 
				
			||||||
 | 
								name:     "item name with carriage return",
 | 
				
			||||||
 | 
								itemName: "key\rname",
 | 
				
			||||||
 | 
								wantErr:  true,
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
							{
 | 
				
			||||||
 | 
								name:     "item name with ampersand",
 | 
				
			||||||
 | 
								itemName: "key&echo test",
 | 
				
			||||||
 | 
								wantErr:  true,
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
							{
 | 
				
			||||||
 | 
								name:     "item name with redirect",
 | 
				
			||||||
 | 
								itemName: "key>/tmp/test",
 | 
				
			||||||
 | 
								wantErr:  true,
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
							{
 | 
				
			||||||
 | 
								name:     "item name with null byte",
 | 
				
			||||||
 | 
								itemName: "key\x00name",
 | 
				
			||||||
 | 
								wantErr:  true,
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
							{
 | 
				
			||||||
 | 
								name:     "item name with parentheses",
 | 
				
			||||||
 | 
								itemName: "key(test)",
 | 
				
			||||||
 | 
								wantErr:  true,
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
							{
 | 
				
			||||||
 | 
								name:     "item name with brackets",
 | 
				
			||||||
 | 
								itemName: "key[test]",
 | 
				
			||||||
 | 
								wantErr:  true,
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
							{
 | 
				
			||||||
 | 
								name:     "item name with asterisk",
 | 
				
			||||||
 | 
								itemName: "key*",
 | 
				
			||||||
 | 
								wantErr:  true,
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
							{
 | 
				
			||||||
 | 
								name:     "item name with question mark",
 | 
				
			||||||
 | 
								itemName: "key?",
 | 
				
			||||||
 | 
								wantErr:  true,
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						for _, tt := range tests {
 | 
				
			||||||
 | 
							t.Run(tt.name, func(t *testing.T) {
 | 
				
			||||||
 | 
								err := validateKeychainItemName(tt.itemName)
 | 
				
			||||||
 | 
								if (err != nil) != tt.wantErr {
 | 
				
			||||||
 | 
									t.Errorf("validateKeychainItemName() error = %v, wantErr %v", err, tt.wantErr)
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
							})
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -287,22 +287,33 @@ func (sv *SecretVersion) GetValue(ltIdentity *age.X25519Identity) ([]byte, error
 | 
				
			|||||||
		slog.String("version", sv.Version),
 | 
							slog.String("version", sv.Version),
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Debug: Log the directory and long-term key info
 | 
				
			||||||
 | 
						Debug("SecretVersion GetValue debug info",
 | 
				
			||||||
 | 
							"secret_name", sv.SecretName,
 | 
				
			||||||
 | 
							"version", sv.Version,
 | 
				
			||||||
 | 
							"directory", sv.Directory,
 | 
				
			||||||
 | 
							"lt_identity_public_key", ltIdentity.Recipient().String())
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	fs := sv.vault.GetFilesystem()
 | 
						fs := sv.vault.GetFilesystem()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Step 1: Read encrypted version private key
 | 
						// Step 1: Read encrypted version private key
 | 
				
			||||||
	encryptedPrivKeyPath := filepath.Join(sv.Directory, "priv.age")
 | 
						encryptedPrivKeyPath := filepath.Join(sv.Directory, "priv.age")
 | 
				
			||||||
 | 
						Debug("Reading encrypted version private key", "path", encryptedPrivKeyPath)
 | 
				
			||||||
	encryptedPrivKey, err := afero.ReadFile(fs, encryptedPrivKeyPath)
 | 
						encryptedPrivKey, err := afero.ReadFile(fs, encryptedPrivKeyPath)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		Debug("Failed to read encrypted version private key", "error", err, "path", encryptedPrivKeyPath)
 | 
							Debug("Failed to read encrypted version private key", "error", err, "path", encryptedPrivKeyPath)
 | 
				
			||||||
		return nil, fmt.Errorf("failed to read encrypted version private key: %w", err)
 | 
							return nil, fmt.Errorf("failed to read encrypted version private key: %w", err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
						Debug("Successfully read encrypted version private key", "path", encryptedPrivKeyPath, "size", len(encryptedPrivKey))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Step 2: Decrypt version private key using long-term key
 | 
						// Step 2: Decrypt version private key using long-term key
 | 
				
			||||||
 | 
						Debug("Decrypting version private key with long-term identity", "version", sv.Version)
 | 
				
			||||||
	versionPrivKeyData, err := DecryptWithIdentity(encryptedPrivKey, ltIdentity)
 | 
						versionPrivKeyData, err := DecryptWithIdentity(encryptedPrivKey, ltIdentity)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		Debug("Failed to decrypt version private key", "error", err, "version", sv.Version)
 | 
							Debug("Failed to decrypt version private key", "error", err, "version", sv.Version)
 | 
				
			||||||
		return nil, fmt.Errorf("failed to decrypt version private key: %w", err)
 | 
							return nil, fmt.Errorf("failed to decrypt version private key: %w", err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
						Debug("Successfully decrypted version private key", "version", sv.Version, "size", len(versionPrivKeyData))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Step 3: Parse version private key
 | 
						// Step 3: Parse version private key
 | 
				
			||||||
	versionIdentity, err := age.ParseX25519Identity(string(versionPrivKeyData))
 | 
						versionIdentity, err := age.ParseX25519Identity(string(versionPrivKeyData))
 | 
				
			||||||
@ -313,20 +324,26 @@ func (sv *SecretVersion) GetValue(ltIdentity *age.X25519Identity) ([]byte, error
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
	// Step 4: Read encrypted value
 | 
						// Step 4: Read encrypted value
 | 
				
			||||||
	encryptedValuePath := filepath.Join(sv.Directory, "value.age")
 | 
						encryptedValuePath := filepath.Join(sv.Directory, "value.age")
 | 
				
			||||||
 | 
						Debug("Reading encrypted value", "path", encryptedValuePath)
 | 
				
			||||||
	encryptedValue, err := afero.ReadFile(fs, encryptedValuePath)
 | 
						encryptedValue, err := afero.ReadFile(fs, encryptedValuePath)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		Debug("Failed to read encrypted version value", "error", err, "path", encryptedValuePath)
 | 
							Debug("Failed to read encrypted version value", "error", err, "path", encryptedValuePath)
 | 
				
			||||||
		return nil, fmt.Errorf("failed to read encrypted version value: %w", err)
 | 
							return nil, fmt.Errorf("failed to read encrypted version value: %w", err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
						Debug("Successfully read encrypted value", "path", encryptedValuePath, "size", len(encryptedValue))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Step 5: Decrypt value using version key
 | 
						// Step 5: Decrypt value using version key
 | 
				
			||||||
 | 
						Debug("Decrypting value with version identity", "version", sv.Version)
 | 
				
			||||||
	value, err := DecryptWithIdentity(encryptedValue, versionIdentity)
 | 
						value, err := DecryptWithIdentity(encryptedValue, versionIdentity)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		Debug("Failed to decrypt version value", "error", err, "version", sv.Version)
 | 
							Debug("Failed to decrypt version value", "error", err, "version", sv.Version)
 | 
				
			||||||
		return nil, fmt.Errorf("failed to decrypt version value: %w", err)
 | 
							return nil, fmt.Errorf("failed to decrypt version value: %w", err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	Debug("Successfully retrieved version value", "version", sv.Version, "value_length", len(value))
 | 
						Debug("Successfully retrieved version value",
 | 
				
			||||||
 | 
							"version", sv.Version,
 | 
				
			||||||
 | 
							"value_length", len(value),
 | 
				
			||||||
 | 
							"is_empty", len(value) == 0)
 | 
				
			||||||
	return value, nil
 | 
						return value, nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -207,6 +207,7 @@ func CreateVault(fs afero.Fs, stateDir string, name string) (*Vault, error) {
 | 
				
			|||||||
	mnemonic := os.Getenv(secret.EnvMnemonic)
 | 
						mnemonic := os.Getenv(secret.EnvMnemonic)
 | 
				
			||||||
	var derivationIndex uint32
 | 
						var derivationIndex uint32
 | 
				
			||||||
	var publicKeyHash string
 | 
						var publicKeyHash string
 | 
				
			||||||
 | 
						var familyHash string
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if mnemonic != "" {
 | 
						if mnemonic != "" {
 | 
				
			||||||
		secret.Debug("Mnemonic found in environment, deriving long-term key", "vault", name)
 | 
							secret.Debug("Mnemonic found in environment, deriving long-term key", "vault", name)
 | 
				
			||||||
@ -232,13 +233,16 @@ func CreateVault(fs afero.Fs, stateDir string, name string) (*Vault, error) {
 | 
				
			|||||||
		}
 | 
							}
 | 
				
			||||||
		secret.Debug("Wrote long-term public key", "path", ltPubKeyPath)
 | 
							secret.Debug("Wrote long-term public key", "path", ltPubKeyPath)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		// Compute public key hash from index 0 (same for all vaults with this mnemonic)
 | 
							// Compute verification hash from actual derivation index
 | 
				
			||||||
 | 
							publicKeyHash = ComputeDoubleSHA256([]byte(ltIdentity.Recipient().String()))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							// Compute family hash from index 0 (same for all vaults with this mnemonic)
 | 
				
			||||||
		// This is used to identify which vaults belong to the same mnemonic family
 | 
							// This is used to identify which vaults belong to the same mnemonic family
 | 
				
			||||||
		identity0, err := agehd.DeriveIdentity(mnemonic, 0)
 | 
							identity0, err := agehd.DeriveIdentity(mnemonic, 0)
 | 
				
			||||||
		if err != nil {
 | 
							if err != nil {
 | 
				
			||||||
			return nil, fmt.Errorf("failed to derive identity for index 0: %w", err)
 | 
								return nil, fmt.Errorf("failed to derive identity for index 0: %w", err)
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
		publicKeyHash = ComputeDoubleSHA256([]byte(identity0.Recipient().String()))
 | 
							familyHash = ComputeDoubleSHA256([]byte(identity0.Recipient().String()))
 | 
				
			||||||
	} else {
 | 
						} else {
 | 
				
			||||||
		secret.Debug("No mnemonic in environment, vault created without long-term key", "vault", name)
 | 
							secret.Debug("No mnemonic in environment, vault created without long-term key", "vault", name)
 | 
				
			||||||
		// Use 0 for derivation index when no mnemonic is provided
 | 
							// Use 0 for derivation index when no mnemonic is provided
 | 
				
			||||||
@ -250,6 +254,7 @@ func CreateVault(fs afero.Fs, stateDir string, name string) (*Vault, error) {
 | 
				
			|||||||
		CreatedAt:          time.Now(),
 | 
							CreatedAt:          time.Now(),
 | 
				
			||||||
		DerivationIndex:    derivationIndex,
 | 
							DerivationIndex:    derivationIndex,
 | 
				
			||||||
		PublicKeyHash:      publicKeyHash,
 | 
							PublicKeyHash:      publicKeyHash,
 | 
				
			||||||
 | 
							MnemonicFamilyHash: familyHash,
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	if err := SaveVaultMetadata(fs, vaultDir, metadata); err != nil {
 | 
						if err := SaveVaultMetadata(fs, vaultDir, metadata); err != nil {
 | 
				
			||||||
		return nil, fmt.Errorf("failed to save vault metadata: %w", err)
 | 
							return nil, fmt.Errorf("failed to save vault metadata: %w", err)
 | 
				
			||||||
 | 
				
			|||||||
@ -75,8 +75,8 @@ func GetNextDerivationIndex(fs afero.Fs, stateDir string, mnemonic string) (uint
 | 
				
			|||||||
			continue
 | 
								continue
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		// Check if this vault uses the same mnemonic by comparing public key hashes
 | 
							// Check if this vault uses the same mnemonic by comparing family hashes
 | 
				
			||||||
		if metadata.PublicKeyHash == pubKeyHash {
 | 
							if metadata.MnemonicFamilyHash == pubKeyHash {
 | 
				
			||||||
			usedIndices[metadata.DerivationIndex] = true
 | 
								usedIndices[metadata.DerivationIndex] = true
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
				
			|||||||
@ -72,7 +72,8 @@ func TestVaultMetadata(t *testing.T) {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
		metadata1 := &VaultMetadata{
 | 
							metadata1 := &VaultMetadata{
 | 
				
			||||||
			DerivationIndex:    0,
 | 
								DerivationIndex:    0,
 | 
				
			||||||
			PublicKeyHash:   pubKeyHash0,
 | 
								PublicKeyHash:      pubKeyHash0, // Hash of the actual key (index 0)
 | 
				
			||||||
 | 
								MnemonicFamilyHash: pubKeyHash0, // Hash of index 0 key (for family identification)
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
		if err := SaveVaultMetadata(fs, vaultDir, metadata1); err != nil {
 | 
							if err := SaveVaultMetadata(fs, vaultDir, metadata1); err != nil {
 | 
				
			||||||
			t.Fatalf("Failed to save metadata: %v", err)
 | 
								t.Fatalf("Failed to save metadata: %v", err)
 | 
				
			||||||
@ -115,9 +116,13 @@ func TestVaultMetadata(t *testing.T) {
 | 
				
			|||||||
			t.Fatalf("Failed to write public key: %v", err)
 | 
								t.Fatalf("Failed to write public key: %v", err)
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							// Compute the hash for index 5 key
 | 
				
			||||||
 | 
							pubKeyHash5 := ComputeDoubleSHA256([]byte(pubKey5))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		metadata2 := &VaultMetadata{
 | 
							metadata2 := &VaultMetadata{
 | 
				
			||||||
			DerivationIndex:    5,
 | 
								DerivationIndex:    5,
 | 
				
			||||||
			PublicKeyHash:   pubKeyHash0, // Same hash since it's from the same mnemonic
 | 
								PublicKeyHash:      pubKeyHash5, // Hash of the actual key (index 5)
 | 
				
			||||||
 | 
								MnemonicFamilyHash: pubKeyHash0, // Same family hash since it's from the same mnemonic
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
		if err := SaveVaultMetadata(fs, vaultDir2, metadata2); err != nil {
 | 
							if err := SaveVaultMetadata(fs, vaultDir2, metadata2); err != nil {
 | 
				
			||||||
			t.Fatalf("Failed to save metadata: %v", err)
 | 
								t.Fatalf("Failed to save metadata: %v", err)
 | 
				
			||||||
 | 
				
			|||||||
@ -351,6 +351,7 @@ func (v *Vault) GetSecretVersion(name string, version string) ([]byte, error) {
 | 
				
			|||||||
	)
 | 
						)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Get the version's value
 | 
						// Get the version's value
 | 
				
			||||||
 | 
						secret.Debug("About to call secretVersion.GetValue", "version", version, "secret_name", name)
 | 
				
			||||||
	decryptedValue, err := secretVersion.GetValue(longTermIdentity)
 | 
						decryptedValue, err := secretVersion.GetValue(longTermIdentity)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		secret.Debug("Failed to decrypt version value", "error", err, "version", version, "secret_name", name)
 | 
							secret.Debug("Failed to decrypt version value", "error", err, "version", version, "secret_name", name)
 | 
				
			||||||
@ -364,6 +365,13 @@ func (v *Vault) GetSecretVersion(name string, version string) ([]byte, error) {
 | 
				
			|||||||
		slog.Int("decrypted_length", len(decryptedValue)),
 | 
							slog.Int("decrypted_length", len(decryptedValue)),
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Debug: Log metadata about the decrypted value without exposing the actual secret
 | 
				
			||||||
 | 
						secret.Debug("Vault secret decryption debug info",
 | 
				
			||||||
 | 
							"secret_name", name,
 | 
				
			||||||
 | 
							"version", version,
 | 
				
			||||||
 | 
							"decrypted_value_length", len(decryptedValue),
 | 
				
			||||||
 | 
							"is_empty", len(decryptedValue) == 0)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	return decryptedValue, nil
 | 
						return decryptedValue, nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -34,17 +34,23 @@ func (v *Vault) GetCurrentUnlocker() (secret.Unlocker, error) {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
	// Resolve the symlink to get the target directory
 | 
						// Resolve the symlink to get the target directory
 | 
				
			||||||
	var unlockerDir string
 | 
						var unlockerDir string
 | 
				
			||||||
	if _, ok := v.fs.(*afero.OsFs); ok {
 | 
						if linkReader, ok := v.fs.(afero.LinkReader); ok {
 | 
				
			||||||
		secret.Debug("Resolving unlocker symlink (real filesystem)")
 | 
							secret.Debug("Resolving unlocker symlink using afero")
 | 
				
			||||||
		// For real filesystems, resolve the symlink properly
 | 
							// Try to read as symlink first
 | 
				
			||||||
		unlockerDir, err = ResolveVaultSymlink(v.fs, currentUnlockerPath)
 | 
							unlockerDir, err = linkReader.ReadlinkIfPossible(currentUnlockerPath)
 | 
				
			||||||
		if err != nil {
 | 
							if err != nil {
 | 
				
			||||||
			secret.Debug("Failed to resolve unlocker symlink", "error", err, "symlink_path", currentUnlockerPath)
 | 
								secret.Debug("Failed to read symlink, falling back to file contents", "error", err, "symlink_path", currentUnlockerPath)
 | 
				
			||||||
			return nil, fmt.Errorf("failed to resolve current unlocker symlink: %w", err)
 | 
								// Fallback: read the path from file contents
 | 
				
			||||||
 | 
								unlockerDirBytes, err := afero.ReadFile(v.fs, currentUnlockerPath)
 | 
				
			||||||
 | 
								if err != nil {
 | 
				
			||||||
 | 
									secret.Debug("Failed to read unlocker path file", "error", err, "path", currentUnlockerPath)
 | 
				
			||||||
 | 
									return nil, fmt.Errorf("failed to read current unlocker: %w", err)
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
								unlockerDir = strings.TrimSpace(string(unlockerDirBytes))
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	} else {
 | 
						} else {
 | 
				
			||||||
		secret.Debug("Reading unlocker path (mock filesystem)")
 | 
							secret.Debug("Reading unlocker path (filesystem doesn't support symlinks)")
 | 
				
			||||||
		// Fallback for mock filesystems: read the path from file contents
 | 
							// Fallback for filesystems that don't support symlinks: read the path from file contents
 | 
				
			||||||
		unlockerDirBytes, err := afero.ReadFile(v.fs, currentUnlockerPath)
 | 
							unlockerDirBytes, err := afero.ReadFile(v.fs, currentUnlockerPath)
 | 
				
			||||||
		if err != nil {
 | 
							if err != nil {
 | 
				
			||||||
			secret.Debug("Failed to read unlocker path file", "error", err, "path", currentUnlockerPath)
 | 
								secret.Debug("Failed to read unlocker path file", "error", err, "path", currentUnlockerPath)
 | 
				
			||||||
@ -319,8 +325,21 @@ func (v *Vault) SelectUnlocker(unlockerID string) error {
 | 
				
			|||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Create new symlink
 | 
						// Create new symlink using afero's SymlinkIfPossible
 | 
				
			||||||
	return afero.WriteFile(v.fs, currentUnlockerPath, []byte(targetUnlockerDir), secret.FilePerms)
 | 
						if linker, ok := v.fs.(afero.Linker); ok {
 | 
				
			||||||
 | 
							secret.Debug("Creating unlocker symlink", "target", targetUnlockerDir, "link", currentUnlockerPath)
 | 
				
			||||||
 | 
							if err := linker.SymlinkIfPossible(targetUnlockerDir, currentUnlockerPath); err != nil {
 | 
				
			||||||
 | 
								return fmt.Errorf("failed to create unlocker symlink: %w", err)
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						} else {
 | 
				
			||||||
 | 
							// Fallback: create a regular file with the target path for filesystems that don't support symlinks
 | 
				
			||||||
 | 
							secret.Debug("Fallback: creating regular file with target path", "target", targetUnlockerDir)
 | 
				
			||||||
 | 
							if err := afero.WriteFile(v.fs, currentUnlockerPath, []byte(targetUnlockerDir), secret.FilePerms); err != nil {
 | 
				
			||||||
 | 
								return fmt.Errorf("failed to create unlocker symlink file: %w", err)
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// CreatePassphraseUnlocker creates a new passphrase-protected unlocker
 | 
					// CreatePassphraseUnlocker creates a new passphrase-protected unlocker
 | 
				
			||||||
 | 
				
			|||||||
@ -84,6 +84,17 @@ func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
 | 
				
			|||||||
			return nil, fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
 | 
								return nil, fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							// Verify that the derived key matches the stored public key hash
 | 
				
			||||||
 | 
							derivedPubKeyHash := ComputeDoubleSHA256([]byte(ltIdentity.Recipient().String()))
 | 
				
			||||||
 | 
							if derivedPubKeyHash != metadata.PublicKeyHash {
 | 
				
			||||||
 | 
								secret.Debug("Derived public key hash does not match stored hash",
 | 
				
			||||||
 | 
									"vault_name", v.Name,
 | 
				
			||||||
 | 
									"derived_hash", derivedPubKeyHash,
 | 
				
			||||||
 | 
									"stored_hash", metadata.PublicKeyHash,
 | 
				
			||||||
 | 
									"derivation_index", metadata.DerivationIndex)
 | 
				
			||||||
 | 
								return nil, fmt.Errorf("derived public key does not match vault: mnemonic may be incorrect")
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		secret.DebugWith("Successfully derived long-term key from mnemonic",
 | 
							secret.DebugWith("Successfully derived long-term key from mnemonic",
 | 
				
			||||||
			slog.String("vault_name", v.Name),
 | 
								slog.String("vault_name", v.Name),
 | 
				
			||||||
			slog.String("public_key", ltIdentity.Recipient().String()),
 | 
								slog.String("public_key", ltIdentity.Recipient().String()),
 | 
				
			||||||
 | 
				
			|||||||
@ -22,19 +22,19 @@ import (
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
const (
 | 
					const (
 | 
				
			||||||
	// BIP85_MASTER_PATH is the derivation path prefix for all BIP85 applications
 | 
						// BIP85_MASTER_PATH is the derivation path prefix for all BIP85 applications
 | 
				
			||||||
	BIP85_MASTER_PATH = "m/83696968'"
 | 
						BIP85_MASTER_PATH = "m/83696968'" //nolint:revive // ALL_CAPS used for BIP85 constants
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// BIP85_KEY_HMAC_KEY is the HMAC key used for deriving the entropy
 | 
						// BIP85_KEY_HMAC_KEY is the HMAC key used for deriving the entropy
 | 
				
			||||||
	BIP85_KEY_HMAC_KEY = "bip-entropy-from-k"
 | 
						BIP85_KEY_HMAC_KEY = "bip-entropy-from-k" //nolint:revive // ALL_CAPS used for BIP85 constants
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Application numbers
 | 
						// Application numbers
 | 
				
			||||||
	APP_BIP39  = 39 // BIP39 mnemonics
 | 
						APP_BIP39  = 39 // BIP39 mnemonics //nolint:revive // ALL_CAPS used for BIP85 constants
 | 
				
			||||||
	APP_HD_WIF = 2  // WIF for Bitcoin Core
 | 
						APP_HD_WIF = 2  // WIF for Bitcoin Core //nolint:revive // ALL_CAPS used for BIP85 constants
 | 
				
			||||||
	APP_XPRV   = 32 // Extended private key
 | 
						APP_XPRV   = 32 // Extended private key //nolint:revive // ALL_CAPS used for BIP85 constants
 | 
				
			||||||
	APP_HEX    = 128169
 | 
						APP_HEX    = 128169 //nolint:revive // ALL_CAPS used for BIP85 constants
 | 
				
			||||||
	APP_PWD64  = 707764 // Base64 passwords
 | 
						APP_PWD64  = 707764 // Base64 passwords //nolint:revive // ALL_CAPS used for BIP85 constants
 | 
				
			||||||
	APP_PWD85  = 707785 // Base85 passwords
 | 
						APP_PWD85  = 707785 // Base85 passwords //nolint:revive // ALL_CAPS used for BIP85 constants
 | 
				
			||||||
	APP_RSA    = 828365
 | 
						APP_RSA    = 828365 //nolint:revive // ALL_CAPS used for BIP85 constants
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Version bytes for extended keys
 | 
					// Version bytes for extended keys
 | 
				
			||||||
 | 
				
			|||||||
@ -1,5 +1,7 @@
 | 
				
			|||||||
package bip85
 | 
					package bip85
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					//nolint:gosec,revive,unparam // Test file with hardcoded test vectors
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import (
 | 
					import (
 | 
				
			||||||
	"bytes"
 | 
						"bytes"
 | 
				
			||||||
	"encoding/hex"
 | 
						"encoding/hex"
 | 
				
			||||||
 | 
				
			|||||||
@ -1,688 +0,0 @@
 | 
				
			|||||||
#!/bin/bash
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
set -e  # Exit on any error
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Colors for output
 | 
					 | 
				
			||||||
RED='\033[0;31m'
 | 
					 | 
				
			||||||
GREEN='\033[0;32m'
 | 
					 | 
				
			||||||
YELLOW='\033[1;33m'
 | 
					 | 
				
			||||||
BLUE='\033[0;34m'
 | 
					 | 
				
			||||||
NC='\033[0m' # No Color
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Test configuration
 | 
					 | 
				
			||||||
TEST_MNEMONIC="abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
 | 
					 | 
				
			||||||
TEST_PASSPHRASE="test-passphrase-123"
 | 
					 | 
				
			||||||
TEMP_DIR="$(mktemp -d)"
 | 
					 | 
				
			||||||
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 "${YELLOW}Using temporary directory: $TEMP_DIR${NC}"
 | 
					 | 
				
			||||||
echo -e "${YELLOW}Debug output enabled: GODEBUG=$GODEBUG${NC}"
 | 
					 | 
				
			||||||
echo -e "${YELLOW}Note: All tests use environment variables (no manual input)${NC}"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Function to print test steps
 | 
					 | 
				
			||||||
print_step() {
 | 
					 | 
				
			||||||
    echo -e "\n${BLUE}Step $1: $2${NC}"
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Function to print success
 | 
					 | 
				
			||||||
print_success() {
 | 
					 | 
				
			||||||
    echo -e "${GREEN}✓ $1${NC}"
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Function to print error and exit
 | 
					 | 
				
			||||||
print_error() {
 | 
					 | 
				
			||||||
    echo -e "${RED}✗ $1${NC}"
 | 
					 | 
				
			||||||
    exit 1
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Function to print warning (for expected failures)
 | 
					 | 
				
			||||||
print_warning() {
 | 
					 | 
				
			||||||
    echo -e "${YELLOW}⚠ $1${NC}"
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Function to clear state directory and reset environment
 | 
					 | 
				
			||||||
reset_state() {
 | 
					 | 
				
			||||||
    echo -e "${YELLOW}Resetting state directory...${NC}"
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    # Safety checks before removing anything
 | 
					 | 
				
			||||||
    if [ -z "$TEMP_DIR" ]; then
 | 
					 | 
				
			||||||
        print_error "TEMP_DIR is not set, cannot reset state safely"
 | 
					 | 
				
			||||||
    fi
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    if [ ! -d "$TEMP_DIR" ]; then
 | 
					 | 
				
			||||||
        print_error "TEMP_DIR ($TEMP_DIR) is not a directory, cannot reset state safely"
 | 
					 | 
				
			||||||
    fi
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    # Additional safety: ensure TEMP_DIR looks like a temp directory
 | 
					 | 
				
			||||||
    case "$TEMP_DIR" in
 | 
					 | 
				
			||||||
        /tmp/* | /var/folders/* | */tmp/*)
 | 
					 | 
				
			||||||
            # Looks like a reasonable temp directory path
 | 
					 | 
				
			||||||
            ;;
 | 
					 | 
				
			||||||
        *)
 | 
					 | 
				
			||||||
            print_error "TEMP_DIR ($TEMP_DIR) does not look like a safe temporary directory path"
 | 
					 | 
				
			||||||
            ;;
 | 
					 | 
				
			||||||
    esac
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    # Now it's safe to remove contents - use find to avoid glob expansion issues
 | 
					 | 
				
			||||||
    find "${TEMP_DIR:?}" -mindepth 1 -delete 2>/dev/null || true
 | 
					 | 
				
			||||||
    unset SB_SECRET_MNEMONIC
 | 
					 | 
				
			||||||
    unset SB_UNLOCK_PASSPHRASE
 | 
					 | 
				
			||||||
    export SB_SECRET_STATE_DIR="$TEMP_DIR"
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Cleanup function
 | 
					 | 
				
			||||||
cleanup() {
 | 
					 | 
				
			||||||
    echo -e "\n${YELLOW}Cleaning up...${NC}"
 | 
					 | 
				
			||||||
    rm -rf "$TEMP_DIR"
 | 
					 | 
				
			||||||
    unset SB_SECRET_STATE_DIR
 | 
					 | 
				
			||||||
    unset SB_SECRET_MNEMONIC
 | 
					 | 
				
			||||||
    unset SB_UNLOCK_PASSPHRASE
 | 
					 | 
				
			||||||
    unset GODEBUG
 | 
					 | 
				
			||||||
    echo -e "${GREEN}Cleanup complete${NC}"
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Set cleanup trap
 | 
					 | 
				
			||||||
trap cleanup EXIT
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Check that the secret binary exists
 | 
					 | 
				
			||||||
if [ ! -f "$SECRET_BINARY" ]; then
 | 
					 | 
				
			||||||
    print_error "Secret binary not found at $SECRET_BINARY. Please run 'make build' first."
 | 
					 | 
				
			||||||
fi
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Test 1: Set up environment variables
 | 
					 | 
				
			||||||
print_step "1" "Setting up environment variables"
 | 
					 | 
				
			||||||
export SB_SECRET_STATE_DIR="$TEMP_DIR"
 | 
					 | 
				
			||||||
export SB_SECRET_MNEMONIC="$TEST_MNEMONIC"
 | 
					 | 
				
			||||||
print_success "Environment variables set"
 | 
					 | 
				
			||||||
echo "  SB_SECRET_STATE_DIR=$SB_SECRET_STATE_DIR"
 | 
					 | 
				
			||||||
echo "  SB_SECRET_MNEMONIC=$TEST_MNEMONIC"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Test 2: Initialize the secret manager (should create default vault)
 | 
					 | 
				
			||||||
print_step "2" "Initializing secret manager (creates default vault)"
 | 
					 | 
				
			||||||
export SB_UNLOCK_PASSPHRASE="$TEST_PASSPHRASE"
 | 
					 | 
				
			||||||
echo "  SB_UNLOCK_PASSPHRASE=$SB_UNLOCK_PASSPHRASE"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Verify environment variables are exported and visible to subprocesses
 | 
					 | 
				
			||||||
echo "Verifying environment variables are exported:"
 | 
					 | 
				
			||||||
env | grep -E "^SB_" || true
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
echo "Running: $SECRET_BINARY init"
 | 
					 | 
				
			||||||
# Run with explicit environment to ensure variables are passed
 | 
					 | 
				
			||||||
if SB_SECRET_STATE_DIR="$SB_SECRET_STATE_DIR" \
 | 
					 | 
				
			||||||
   SB_SECRET_MNEMONIC="$SB_SECRET_MNEMONIC" \
 | 
					 | 
				
			||||||
   SB_UNLOCK_PASSPHRASE="$SB_UNLOCK_PASSPHRASE" \
 | 
					 | 
				
			||||||
   GODEBUG="$GODEBUG" \
 | 
					 | 
				
			||||||
   $SECRET_BINARY init </dev/null; then
 | 
					 | 
				
			||||||
    print_success "Secret manager initialized with default vault"
 | 
					 | 
				
			||||||
else
 | 
					 | 
				
			||||||
    print_error "Failed to initialize secret manager"
 | 
					 | 
				
			||||||
fi
 | 
					 | 
				
			||||||
unset SB_UNLOCK_PASSPHRASE
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Verify directory structure was created
 | 
					 | 
				
			||||||
if [ -d "$TEMP_DIR" ]; then
 | 
					 | 
				
			||||||
    print_success "State directory created: $TEMP_DIR"
 | 
					 | 
				
			||||||
else
 | 
					 | 
				
			||||||
    print_error "State directory was not created"
 | 
					 | 
				
			||||||
fi
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Test 3: Vault management
 | 
					 | 
				
			||||||
print_step "3" "Testing vault management"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# List vaults (should show default)
 | 
					 | 
				
			||||||
echo "Listing vaults..."
 | 
					 | 
				
			||||||
echo "Running: $SECRET_BINARY vault list"
 | 
					 | 
				
			||||||
if $SECRET_BINARY vault list; then
 | 
					 | 
				
			||||||
    VAULTS=$($SECRET_BINARY vault list)
 | 
					 | 
				
			||||||
    echo "Available vaults: $VAULTS"
 | 
					 | 
				
			||||||
    print_success "Listed vaults successfully"
 | 
					 | 
				
			||||||
else
 | 
					 | 
				
			||||||
    print_error "Failed to list vaults"
 | 
					 | 
				
			||||||
fi
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Create a new vault
 | 
					 | 
				
			||||||
echo "Creating new vault 'work'..."
 | 
					 | 
				
			||||||
echo "Running: $SECRET_BINARY vault create work"
 | 
					 | 
				
			||||||
if $SECRET_BINARY vault create work; then
 | 
					 | 
				
			||||||
    print_success "Created vault 'work'"
 | 
					 | 
				
			||||||
else
 | 
					 | 
				
			||||||
    print_error "Failed to create vault 'work'"
 | 
					 | 
				
			||||||
fi
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Create another vault
 | 
					 | 
				
			||||||
echo "Creating new vault 'personal'..."
 | 
					 | 
				
			||||||
echo "Running: $SECRET_BINARY vault create personal"
 | 
					 | 
				
			||||||
if $SECRET_BINARY vault create personal; then
 | 
					 | 
				
			||||||
    print_success "Created vault 'personal'"
 | 
					 | 
				
			||||||
else
 | 
					 | 
				
			||||||
    print_error "Failed to create vault 'personal'"
 | 
					 | 
				
			||||||
fi
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# List vaults again (should show default, work, personal)
 | 
					 | 
				
			||||||
echo "Listing vaults after creation..."
 | 
					 | 
				
			||||||
echo "Running: $SECRET_BINARY vault list"
 | 
					 | 
				
			||||||
if $SECRET_BINARY vault list; then
 | 
					 | 
				
			||||||
    VAULTS=$($SECRET_BINARY vault list)
 | 
					 | 
				
			||||||
    echo "Available vaults: $VAULTS"
 | 
					 | 
				
			||||||
    print_success "Listed vaults after creation"
 | 
					 | 
				
			||||||
else
 | 
					 | 
				
			||||||
    print_error "Failed to list vaults after creation"
 | 
					 | 
				
			||||||
fi
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Switch to work vault
 | 
					 | 
				
			||||||
echo "Switching to 'work' vault..."
 | 
					 | 
				
			||||||
echo "Running: $SECRET_BINARY vault select work"
 | 
					 | 
				
			||||||
if $SECRET_BINARY vault select work; then
 | 
					 | 
				
			||||||
    print_success "Switched to 'work' vault"
 | 
					 | 
				
			||||||
else
 | 
					 | 
				
			||||||
    print_error "Failed to switch to 'work' vault"
 | 
					 | 
				
			||||||
fi
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Test 4: Import functionality with environment variable combinations
 | 
					 | 
				
			||||||
print_step "4" "Testing import functionality with environment variable combinations"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Test 4a: Import with both env vars set (typical usage)
 | 
					 | 
				
			||||||
echo -e "\n${YELLOW}Test 4a: Import with both SB_SECRET_MNEMONIC and SB_UNLOCK_PASSPHRASE set${NC}"
 | 
					 | 
				
			||||||
reset_state
 | 
					 | 
				
			||||||
export SB_SECRET_MNEMONIC="$TEST_MNEMONIC"
 | 
					 | 
				
			||||||
export SB_UNLOCK_PASSPHRASE="$TEST_PASSPHRASE"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Create a vault first
 | 
					 | 
				
			||||||
echo "Running: $SECRET_BINARY vault create test-vault"
 | 
					 | 
				
			||||||
if $SECRET_BINARY vault create test-vault; then
 | 
					 | 
				
			||||||
    print_success "Created test-vault for import testing"
 | 
					 | 
				
			||||||
else
 | 
					 | 
				
			||||||
    print_error "Failed to create test-vault"
 | 
					 | 
				
			||||||
fi
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Import should work without prompts
 | 
					 | 
				
			||||||
echo "Importing with both env vars set (automated)..."
 | 
					 | 
				
			||||||
echo "Running: $SECRET_BINARY vault import test-vault"
 | 
					 | 
				
			||||||
if $SECRET_BINARY vault import test-vault; then
 | 
					 | 
				
			||||||
    print_success "Import succeeded with both env vars (automated)"
 | 
					 | 
				
			||||||
else
 | 
					 | 
				
			||||||
    print_error "Import failed with both env vars"
 | 
					 | 
				
			||||||
fi
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Test 4b: Import into non-existent vault (should fail)
 | 
					 | 
				
			||||||
echo -e "\n${YELLOW}Test 4b: Import into non-existent vault (should fail)${NC}"
 | 
					 | 
				
			||||||
echo "Importing into non-existent vault (should fail)..."
 | 
					 | 
				
			||||||
if $SECRET_BINARY vault import nonexistent-vault; then
 | 
					 | 
				
			||||||
    print_error "Import should have failed for non-existent vault"
 | 
					 | 
				
			||||||
else
 | 
					 | 
				
			||||||
    print_success "Import correctly failed for non-existent vault"
 | 
					 | 
				
			||||||
fi
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Test 4c: Import with invalid mnemonic (should fail)
 | 
					 | 
				
			||||||
echo -e "\n${YELLOW}Test 4c: Import with invalid mnemonic (should fail)${NC}"
 | 
					 | 
				
			||||||
export SB_SECRET_MNEMONIC="invalid mnemonic phrase that should not work"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Create a vault first
 | 
					 | 
				
			||||||
echo "Running: $SECRET_BINARY vault create test-vault2"
 | 
					 | 
				
			||||||
if $SECRET_BINARY vault create test-vault2; then
 | 
					 | 
				
			||||||
    print_success "Created test-vault2 for invalid mnemonic testing"
 | 
					 | 
				
			||||||
else
 | 
					 | 
				
			||||||
    print_error "Failed to create test-vault2"
 | 
					 | 
				
			||||||
fi
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
echo "Importing with invalid mnemonic (should fail)..."
 | 
					 | 
				
			||||||
if $SECRET_BINARY vault import test-vault2; then
 | 
					 | 
				
			||||||
    print_error "Import should have failed with invalid mnemonic"
 | 
					 | 
				
			||||||
else
 | 
					 | 
				
			||||||
    print_success "Import correctly failed with invalid mnemonic"
 | 
					 | 
				
			||||||
fi
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Reset state for remaining tests
 | 
					 | 
				
			||||||
reset_state
 | 
					 | 
				
			||||||
export SB_SECRET_MNEMONIC="$TEST_MNEMONIC"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Test 5: Unlocker management
 | 
					 | 
				
			||||||
print_step "5" "Testing unlocker management"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Initialize with mnemonic and passphrase
 | 
					 | 
				
			||||||
export SB_UNLOCK_PASSPHRASE="$TEST_PASSPHRASE"
 | 
					 | 
				
			||||||
echo "Running: $SECRET_BINARY init (with SB_SECRET_MNEMONIC and SB_UNLOCK_PASSPHRASE set)"
 | 
					 | 
				
			||||||
if $SECRET_BINARY init; then
 | 
					 | 
				
			||||||
    print_success "Initialized for unlocker testing"
 | 
					 | 
				
			||||||
else
 | 
					 | 
				
			||||||
    print_error "Failed to initialize for unlocker testing"
 | 
					 | 
				
			||||||
fi
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Create passphrase-protected unlocker
 | 
					 | 
				
			||||||
echo "Creating passphrase-protected unlocker..."
 | 
					 | 
				
			||||||
echo "Running: $SECRET_BINARY unlockers add passphrase (with SB_UNLOCK_PASSPHRASE set)"
 | 
					 | 
				
			||||||
if $SECRET_BINARY unlockers add passphrase; then
 | 
					 | 
				
			||||||
    print_success "Created passphrase-protected unlocker"
 | 
					 | 
				
			||||||
else
 | 
					 | 
				
			||||||
    print_error "Failed to create passphrase-protected unlocker"
 | 
					 | 
				
			||||||
    exit 1
 | 
					 | 
				
			||||||
fi
 | 
					 | 
				
			||||||
unset SB_UNLOCK_PASSPHRASE
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# List unlockers
 | 
					 | 
				
			||||||
echo "Listing unlockers..."
 | 
					 | 
				
			||||||
echo "Running: $SECRET_BINARY unlockers list"
 | 
					 | 
				
			||||||
if $SECRET_BINARY unlockers list; then
 | 
					 | 
				
			||||||
    UNLOCKERS=$($SECRET_BINARY unlockers list)
 | 
					 | 
				
			||||||
    echo "Available unlockers: $UNLOCKERS"
 | 
					 | 
				
			||||||
    print_success "Listed unlockers"
 | 
					 | 
				
			||||||
else
 | 
					 | 
				
			||||||
    print_error "Failed to list unlockers"
 | 
					 | 
				
			||||||
    exit 1
 | 
					 | 
				
			||||||
fi
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Test 6: Secret management with mnemonic (keyless operation)
 | 
					 | 
				
			||||||
print_step "6" "Testing mnemonic-based secret operations (keyless)"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Add secrets using mnemonic (no unlocker required)
 | 
					 | 
				
			||||||
echo "Adding secrets using mnemonic-based long-term key..."
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Test secret 1
 | 
					 | 
				
			||||||
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"
 | 
					 | 
				
			||||||
else
 | 
					 | 
				
			||||||
    print_error "Failed to add secret: database/password"
 | 
					 | 
				
			||||||
fi
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Test secret 2
 | 
					 | 
				
			||||||
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"
 | 
					 | 
				
			||||||
else
 | 
					 | 
				
			||||||
    print_error "Failed to add secret: api/key"
 | 
					 | 
				
			||||||
fi
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Test secret 3 (with path)
 | 
					 | 
				
			||||||
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"
 | 
					 | 
				
			||||||
else
 | 
					 | 
				
			||||||
    print_error "Failed to add secret: ssh/private-key"
 | 
					 | 
				
			||||||
fi
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Test secret 4 (with dots and underscores)
 | 
					 | 
				
			||||||
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"
 | 
					 | 
				
			||||||
else
 | 
					 | 
				
			||||||
    print_error "Failed to add secret: app.config_jwt_secret"
 | 
					 | 
				
			||||||
fi
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Retrieve secrets using mnemonic
 | 
					 | 
				
			||||||
echo "Retrieving secrets using mnemonic-based long-term key..."
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Retrieve and verify secret 1
 | 
					 | 
				
			||||||
RETRIEVED_SECRET1=$($SECRET_BINARY get "database/password" 2>/dev/null)
 | 
					 | 
				
			||||||
if [ "$RETRIEVED_SECRET1" = "my-super-secret-password" ]; then
 | 
					 | 
				
			||||||
    print_success "Retrieved and verified secret: database/password"
 | 
					 | 
				
			||||||
else
 | 
					 | 
				
			||||||
    print_error "Failed to retrieve or verify secret: database/password"
 | 
					 | 
				
			||||||
fi
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Retrieve and verify secret 2
 | 
					 | 
				
			||||||
RETRIEVED_SECRET2=$($SECRET_BINARY get "api/key" 2>/dev/null)
 | 
					 | 
				
			||||||
if [ "$RETRIEVED_SECRET2" = "api-key-12345" ]; then
 | 
					 | 
				
			||||||
    print_success "Retrieved and verified secret: api/key"
 | 
					 | 
				
			||||||
else
 | 
					 | 
				
			||||||
    print_error "Failed to retrieve or verify secret: api/key"
 | 
					 | 
				
			||||||
fi
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Retrieve and verify secret 3
 | 
					 | 
				
			||||||
RETRIEVED_SECRET3=$($SECRET_BINARY get "ssh/private-key" 2>/dev/null)
 | 
					 | 
				
			||||||
if [ "$RETRIEVED_SECRET3" = "ssh-private-key-content" ]; then
 | 
					 | 
				
			||||||
    print_success "Retrieved and verified secret: ssh/private-key"
 | 
					 | 
				
			||||||
else
 | 
					 | 
				
			||||||
    print_error "Failed to retrieve or verify secret: ssh/private-key"
 | 
					 | 
				
			||||||
fi
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# List all secrets
 | 
					 | 
				
			||||||
echo "Listing all secrets..."
 | 
					 | 
				
			||||||
echo "Running: $SECRET_BINARY list"
 | 
					 | 
				
			||||||
if $SECRET_BINARY list; then
 | 
					 | 
				
			||||||
    SECRETS=$($SECRET_BINARY list)
 | 
					 | 
				
			||||||
    echo "Secrets in current vault:"
 | 
					 | 
				
			||||||
    echo "$SECRETS" | while read -r secret; do
 | 
					 | 
				
			||||||
        echo "  - $secret"
 | 
					 | 
				
			||||||
    done
 | 
					 | 
				
			||||||
    print_success "Listed all secrets"
 | 
					 | 
				
			||||||
else
 | 
					 | 
				
			||||||
    print_error "Failed to list secrets"
 | 
					 | 
				
			||||||
fi
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Test 7: Secret management without mnemonic (traditional unlocker approach)
 | 
					 | 
				
			||||||
print_step "7" "Testing traditional unlocker approach"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Create a new vault without mnemonic
 | 
					 | 
				
			||||||
echo "Running: $SECRET_BINARY vault create traditional"
 | 
					 | 
				
			||||||
$SECRET_BINARY vault create traditional
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Add a secret using traditional unlocker approach
 | 
					 | 
				
			||||||
echo "Adding secret using traditional unlocker..."
 | 
					 | 
				
			||||||
echo "Running: echo 'traditional-secret' | $SECRET_BINARY add traditional/secret"
 | 
					 | 
				
			||||||
if echo "traditional-secret" | $SECRET_BINARY add traditional/secret; then
 | 
					 | 
				
			||||||
    print_success "Added secret with traditional approach"
 | 
					 | 
				
			||||||
else
 | 
					 | 
				
			||||||
    print_error "Failed to add secret with traditional approach"
 | 
					 | 
				
			||||||
fi
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Retrieve secret using traditional unlocker approach
 | 
					 | 
				
			||||||
echo "Retrieving secret using traditional unlocker approach..."
 | 
					 | 
				
			||||||
echo "Running: $SECRET_BINARY get traditional/secret"
 | 
					 | 
				
			||||||
if RETRIEVED=$($SECRET_BINARY get traditional/secret 2>&1); then
 | 
					 | 
				
			||||||
    print_success "Retrieved: $RETRIEVED"
 | 
					 | 
				
			||||||
else
 | 
					 | 
				
			||||||
    print_error "Failed to retrieve secret with traditional approach"
 | 
					 | 
				
			||||||
fi
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Test 8: Advanced unlocker management
 | 
					 | 
				
			||||||
print_step "8" "Testing advanced unlocker management"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
if [ "$PLATFORM" = "darwin" ]; then
 | 
					 | 
				
			||||||
    # macOS only: Test Secure Enclave
 | 
					 | 
				
			||||||
    echo "Testing Secure Enclave unlocker creation..."
 | 
					 | 
				
			||||||
    if $SECRET_BINARY unlockers add sep; then
 | 
					 | 
				
			||||||
        print_success "Created Secure Enclave unlocker"
 | 
					 | 
				
			||||||
    else
 | 
					 | 
				
			||||||
        print_warning "Secure Enclave unlocker creation not yet implemented"
 | 
					 | 
				
			||||||
    fi
 | 
					 | 
				
			||||||
fi
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Get current unlocker ID for testing
 | 
					 | 
				
			||||||
echo "Getting current unlocker for testing..."
 | 
					 | 
				
			||||||
echo "Running: $SECRET_BINARY unlockers list"
 | 
					 | 
				
			||||||
if $SECRET_BINARY unlockers list; then
 | 
					 | 
				
			||||||
    CURRENT_UNLOCKER_ID=$($SECRET_BINARY unlockers list | head -n1 | awk '{print $1}')
 | 
					 | 
				
			||||||
    if [ -n "$CURRENT_UNLOCKER_ID" ]; then
 | 
					 | 
				
			||||||
        print_success "Found unlocker ID: $CURRENT_UNLOCKER_ID"
 | 
					 | 
				
			||||||
        
 | 
					 | 
				
			||||||
        # Test unlocker selection
 | 
					 | 
				
			||||||
        echo "Testing unlocker selection..."
 | 
					 | 
				
			||||||
        echo "Running: $SECRET_BINARY unlocker select $CURRENT_UNLOCKER_ID"
 | 
					 | 
				
			||||||
        if $SECRET_BINARY unlocker select "$CURRENT_UNLOCKER_ID"; then
 | 
					 | 
				
			||||||
            print_success "Selected unlocker: $CURRENT_UNLOCKER_ID"
 | 
					 | 
				
			||||||
        else
 | 
					 | 
				
			||||||
            print_warning "Unlocker selection not yet implemented"
 | 
					 | 
				
			||||||
        fi
 | 
					 | 
				
			||||||
    fi
 | 
					 | 
				
			||||||
fi
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Test 9: Secret name validation and edge cases
 | 
					 | 
				
			||||||
print_step "9" "Testing secret name validation and edge cases"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Test valid names
 | 
					 | 
				
			||||||
VALID_NAMES=("valid-name" "valid.name" "valid_name" "valid/path/name" "123valid" "a" "very-long-name-with-many-parts/and/paths")
 | 
					 | 
				
			||||||
for name in "${VALID_NAMES[@]}"; do
 | 
					 | 
				
			||||||
    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"
 | 
					 | 
				
			||||||
    else
 | 
					 | 
				
			||||||
        print_error "Valid name rejected: $name"
 | 
					 | 
				
			||||||
    fi
 | 
					 | 
				
			||||||
done
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Test invalid names (these should fail)
 | 
					 | 
				
			||||||
echo "Testing invalid names (should fail)..."
 | 
					 | 
				
			||||||
INVALID_NAMES=("Invalid-Name" "invalid name" "invalid@name" "invalid#name" "invalid%name" "")
 | 
					 | 
				
			||||||
for name in "${INVALID_NAMES[@]}"; do
 | 
					 | 
				
			||||||
    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'"
 | 
					 | 
				
			||||||
    else
 | 
					 | 
				
			||||||
        print_success "Invalid name correctly rejected: '$name'"
 | 
					 | 
				
			||||||
    fi
 | 
					 | 
				
			||||||
done
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Test 10: Overwrite protection and force flag
 | 
					 | 
				
			||||||
print_step "10" "Testing overwrite protection and force flag"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Try to add existing secret without --force (should fail)
 | 
					 | 
				
			||||||
echo "Running: echo \"new-value\" | $SECRET_BINARY add \"database/password\""
 | 
					 | 
				
			||||||
if echo "new-value" | $SECRET_BINARY add "database/password"; then
 | 
					 | 
				
			||||||
    print_error "Overwrite protection failed - secret was overwritten without --force"
 | 
					 | 
				
			||||||
else
 | 
					 | 
				
			||||||
    print_success "Overwrite protection working - secret not overwritten without --force"
 | 
					 | 
				
			||||||
fi
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Try to add existing secret with --force (should succeed)
 | 
					 | 
				
			||||||
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"
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    # Verify the new value
 | 
					 | 
				
			||||||
    RETRIEVED_NEW=$($SECRET_BINARY get "database/password" 2>/dev/null)
 | 
					 | 
				
			||||||
    if [ "$RETRIEVED_NEW" = "new-password-value" ]; then
 | 
					 | 
				
			||||||
        print_success "Overwritten secret has correct new value"
 | 
					 | 
				
			||||||
    else
 | 
					 | 
				
			||||||
        print_error "Overwritten secret has incorrect value"
 | 
					 | 
				
			||||||
    fi
 | 
					 | 
				
			||||||
else
 | 
					 | 
				
			||||||
    print_error "Force overwrite failed - secret not overwritten with --force"
 | 
					 | 
				
			||||||
fi
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Test 11: Cross-vault operations
 | 
					 | 
				
			||||||
print_step "11" "Testing cross-vault operations"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# First create and import mnemonic into work vault since it was destroyed by reset_state
 | 
					 | 
				
			||||||
echo "Creating work vault for cross-vault testing..."
 | 
					 | 
				
			||||||
echo "Running: $SECRET_BINARY vault create work"
 | 
					 | 
				
			||||||
if $SECRET_BINARY vault create work; then
 | 
					 | 
				
			||||||
    print_success "Created work vault for cross-vault testing"
 | 
					 | 
				
			||||||
else
 | 
					 | 
				
			||||||
    print_error "Failed to create work vault for cross-vault testing"
 | 
					 | 
				
			||||||
fi
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Import mnemonic into work vault so it can store secrets
 | 
					 | 
				
			||||||
echo "Importing mnemonic into work vault..."
 | 
					 | 
				
			||||||
export SB_UNLOCK_PASSPHRASE="$TEST_PASSPHRASE"
 | 
					 | 
				
			||||||
echo "Running: $SECRET_BINARY vault import work"
 | 
					 | 
				
			||||||
if $SECRET_BINARY vault import work; then
 | 
					 | 
				
			||||||
    print_success "Imported mnemonic into work vault"
 | 
					 | 
				
			||||||
else
 | 
					 | 
				
			||||||
    print_error "Failed to import mnemonic into work vault"
 | 
					 | 
				
			||||||
fi
 | 
					 | 
				
			||||||
unset SB_UNLOCK_PASSPHRASE
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Switch to work vault and add secrets there
 | 
					 | 
				
			||||||
echo "Switching to 'work' vault for cross-vault testing..."
 | 
					 | 
				
			||||||
echo "Running: $SECRET_BINARY vault select work"
 | 
					 | 
				
			||||||
if $SECRET_BINARY vault select work; then
 | 
					 | 
				
			||||||
    print_success "Switched to 'work' vault"
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    # Add work-specific secrets
 | 
					 | 
				
			||||||
    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"
 | 
					 | 
				
			||||||
    else
 | 
					 | 
				
			||||||
        print_error "Failed to add work-specific secret"
 | 
					 | 
				
			||||||
    fi
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    # List secrets in work vault
 | 
					 | 
				
			||||||
    echo "Running: $SECRET_BINARY list"
 | 
					 | 
				
			||||||
    if $SECRET_BINARY list; then
 | 
					 | 
				
			||||||
        WORK_SECRETS=$($SECRET_BINARY list)
 | 
					 | 
				
			||||||
        echo "Secrets in work vault: $WORK_SECRETS"
 | 
					 | 
				
			||||||
        print_success "Listed work vault secrets"
 | 
					 | 
				
			||||||
    else
 | 
					 | 
				
			||||||
        print_error "Failed to list work vault secrets"
 | 
					 | 
				
			||||||
    fi
 | 
					 | 
				
			||||||
else
 | 
					 | 
				
			||||||
    print_error "Failed to switch to 'work' vault"
 | 
					 | 
				
			||||||
fi
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Switch back to default vault
 | 
					 | 
				
			||||||
echo "Switching back to 'default' vault..."
 | 
					 | 
				
			||||||
echo "Running: $SECRET_BINARY vault select default"
 | 
					 | 
				
			||||||
if $SECRET_BINARY vault select default; then
 | 
					 | 
				
			||||||
    print_success "Switched back to 'default' vault"
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    # Verify default vault secrets are still there
 | 
					 | 
				
			||||||
    echo "Running: $SECRET_BINARY get \"database/password\""
 | 
					 | 
				
			||||||
    if $SECRET_BINARY get "database/password"; then
 | 
					 | 
				
			||||||
        print_success "Default vault secrets still accessible"
 | 
					 | 
				
			||||||
    else
 | 
					 | 
				
			||||||
        print_error "Default vault secrets not accessible"
 | 
					 | 
				
			||||||
    fi
 | 
					 | 
				
			||||||
else
 | 
					 | 
				
			||||||
    print_error "Failed to switch back to 'default' vault"
 | 
					 | 
				
			||||||
fi
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Test 12: File structure verification
 | 
					 | 
				
			||||||
print_step "12" "Verifying file structure"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
echo "Checking file structure in $TEMP_DIR..."
 | 
					 | 
				
			||||||
if [ -d "$TEMP_DIR/vaults.d/default/secrets.d" ]; then
 | 
					 | 
				
			||||||
    print_success "Default vault structure exists"
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    # Check a specific secret's file structure
 | 
					 | 
				
			||||||
    SECRET_DIR="$TEMP_DIR/vaults.d/default/secrets.d/database%password"
 | 
					 | 
				
			||||||
    if [ -d "$SECRET_DIR" ]; then
 | 
					 | 
				
			||||||
        print_success "Secret directory exists: database%password"
 | 
					 | 
				
			||||||
        
 | 
					 | 
				
			||||||
        # Check required files for per-secret key architecture
 | 
					 | 
				
			||||||
        FILES=("value.age" "pub.age" "priv.age" "secret-metadata.json")
 | 
					 | 
				
			||||||
        for file in "${FILES[@]}"; do
 | 
					 | 
				
			||||||
            if [ -f "$SECRET_DIR/$file" ]; then
 | 
					 | 
				
			||||||
                print_success "Required file exists: $file"
 | 
					 | 
				
			||||||
            else
 | 
					 | 
				
			||||||
                print_error "Required file missing: $file"
 | 
					 | 
				
			||||||
            fi
 | 
					 | 
				
			||||||
        done
 | 
					 | 
				
			||||||
    else
 | 
					 | 
				
			||||||
        print_error "Secret directory not found"
 | 
					 | 
				
			||||||
    fi
 | 
					 | 
				
			||||||
else
 | 
					 | 
				
			||||||
    print_error "Default vault structure not found"
 | 
					 | 
				
			||||||
fi
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Check work vault structure
 | 
					 | 
				
			||||||
if [ -d "$TEMP_DIR/vaults.d/work" ]; then
 | 
					 | 
				
			||||||
    print_success "Work vault structure exists"
 | 
					 | 
				
			||||||
else
 | 
					 | 
				
			||||||
    print_error "Work vault structure not found"
 | 
					 | 
				
			||||||
fi
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Check configuration files
 | 
					 | 
				
			||||||
if [ -f "$TEMP_DIR/configuration.json" ]; then
 | 
					 | 
				
			||||||
    print_success "Global configuration file exists"
 | 
					 | 
				
			||||||
else
 | 
					 | 
				
			||||||
    print_warning "Global configuration file not found (may not be implemented yet)"
 | 
					 | 
				
			||||||
fi
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Check current vault symlink
 | 
					 | 
				
			||||||
if [ -L "$TEMP_DIR/currentvault" ] || [ -f "$TEMP_DIR/currentvault" ]; then
 | 
					 | 
				
			||||||
    print_success "Current vault link exists"
 | 
					 | 
				
			||||||
else
 | 
					 | 
				
			||||||
    print_error "Current vault link not found"
 | 
					 | 
				
			||||||
fi
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Test 13: Environment variable error handling
 | 
					 | 
				
			||||||
print_step "13" "Testing environment variable error handling"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Test with non-existent state directory
 | 
					 | 
				
			||||||
export SB_SECRET_STATE_DIR="$TEMP_DIR/nonexistent/directory"
 | 
					 | 
				
			||||||
echo "Running: $SECRET_BINARY get \"database/password\""
 | 
					 | 
				
			||||||
if $SECRET_BINARY get "database/password"; then
 | 
					 | 
				
			||||||
    print_error "Should have failed with non-existent state directory"
 | 
					 | 
				
			||||||
else
 | 
					 | 
				
			||||||
    print_success "Correctly failed with non-existent state directory"
 | 
					 | 
				
			||||||
fi
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Test init with non-existent directory (should work)
 | 
					 | 
				
			||||||
echo "Running: $SECRET_BINARY init (with SB_UNLOCK_PASSPHRASE set)"
 | 
					 | 
				
			||||||
export SB_UNLOCK_PASSPHRASE="$TEST_PASSPHRASE"
 | 
					 | 
				
			||||||
if $SECRET_BINARY init; then
 | 
					 | 
				
			||||||
    print_success "Init works with non-existent state directory"
 | 
					 | 
				
			||||||
else
 | 
					 | 
				
			||||||
    print_error "Init should work with non-existent state directory"
 | 
					 | 
				
			||||||
fi
 | 
					 | 
				
			||||||
unset SB_UNLOCK_PASSPHRASE
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Reset to working directory
 | 
					 | 
				
			||||||
export SB_SECRET_STATE_DIR="$TEMP_DIR"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Test 14: Mixed approach compatibility
 | 
					 | 
				
			||||||
print_step "14" "Testing mixed approach compatibility"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Verify mnemonic can access traditional secrets
 | 
					 | 
				
			||||||
RETRIEVED_MIXED=$($SECRET_BINARY get "traditional/secret" 2>/dev/null)
 | 
					 | 
				
			||||||
if [ "$RETRIEVED_MIXED" = "traditional-secret-value" ]; then
 | 
					 | 
				
			||||||
    print_success "Mnemonic can access traditional secrets"
 | 
					 | 
				
			||||||
else
 | 
					 | 
				
			||||||
    print_error "Mnemonic cannot access traditional secrets"
 | 
					 | 
				
			||||||
fi
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Test without mnemonic but with unlocker
 | 
					 | 
				
			||||||
echo "Testing mnemonic-created vault access..."
 | 
					 | 
				
			||||||
echo "Testing traditional unlocker access to mnemonic-created secrets..."
 | 
					 | 
				
			||||||
echo "Running: $SECRET_BINARY get test/seed (with mnemonic set)"
 | 
					 | 
				
			||||||
if RETRIEVED=$($SECRET_BINARY get test/seed 2>&1); then
 | 
					 | 
				
			||||||
    print_success "Traditional unlocker can access mnemonic-created secrets"
 | 
					 | 
				
			||||||
else
 | 
					 | 
				
			||||||
    print_warning "Traditional unlocker cannot access mnemonic-created secrets (may need implementation)"
 | 
					 | 
				
			||||||
fi
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Re-enable mnemonic for final tests
 | 
					 | 
				
			||||||
export SB_SECRET_MNEMONIC="$TEST_MNEMONIC"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Final summary
 | 
					 | 
				
			||||||
echo -e "\n${GREEN}=== Test Summary ===${NC}"
 | 
					 | 
				
			||||||
echo -e "${GREEN}✓ Environment variable support (SB_SECRET_STATE_DIR, SB_SECRET_MNEMONIC)${NC}"
 | 
					 | 
				
			||||||
echo -e "${GREEN}✓ Secret manager initialization${NC}"
 | 
					 | 
				
			||||||
echo -e "${GREEN}✓ Vault management (create, list, select)${NC}"
 | 
					 | 
				
			||||||
echo -e "${GREEN}✓ Import functionality with environment variable combinations${NC}"
 | 
					 | 
				
			||||||
echo -e "${GREEN}✓ Import error handling (non-existent vault, invalid mnemonic)${NC}"
 | 
					 | 
				
			||||||
echo -e "${GREEN}✓ Unlocker management (passphrase, PGP, SEP)${NC}"
 | 
					 | 
				
			||||||
echo -e "${GREEN}✓ Secret generation and storage${NC}"
 | 
					 | 
				
			||||||
echo -e "${GREEN}✓ Traditional unlocker operations${NC}"
 | 
					 | 
				
			||||||
echo -e "${GREEN}✓ Secret name validation${NC}"
 | 
					 | 
				
			||||||
echo -e "${GREEN}✓ Overwrite protection and force flag${NC}"
 | 
					 | 
				
			||||||
echo -e "${GREEN}✓ Cross-vault operations${NC}"
 | 
					 | 
				
			||||||
echo -e "${GREEN}✓ Per-secret key file structure${NC}"
 | 
					 | 
				
			||||||
echo -e "${GREEN}✓ Mixed approach compatibility${NC}"
 | 
					 | 
				
			||||||
echo -e "${GREEN}✓ Error handling${NC}"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
echo -e "\n${GREEN}🎉 Comprehensive test completed with environment variable automation!${NC}"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Show usage examples for all implemented functionality
 | 
					 | 
				
			||||||
echo -e "\n${BLUE}=== Complete Usage Examples ===${NC}"
 | 
					 | 
				
			||||||
echo -e "${YELLOW}# Environment setup:${NC}"
 | 
					 | 
				
			||||||
echo "export SB_SECRET_STATE_DIR=\"/path/to/your/secrets\""
 | 
					 | 
				
			||||||
echo "export SB_SECRET_MNEMONIC=\"your twelve word mnemonic phrase here\""
 | 
					 | 
				
			||||||
echo ""
 | 
					 | 
				
			||||||
echo -e "${YELLOW}# Initialization:${NC}"
 | 
					 | 
				
			||||||
echo "secret init"
 | 
					 | 
				
			||||||
echo ""
 | 
					 | 
				
			||||||
echo -e "${YELLOW}# Vault management:${NC}"
 | 
					 | 
				
			||||||
echo "secret vault list"
 | 
					 | 
				
			||||||
echo "secret vault create work"
 | 
					 | 
				
			||||||
echo "secret vault select work"
 | 
					 | 
				
			||||||
echo ""
 | 
					 | 
				
			||||||
echo -e "${YELLOW}# Import mnemonic (automated with environment variables):${NC}"
 | 
					 | 
				
			||||||
echo "export SB_SECRET_MNEMONIC=\"abandon abandon...\""
 | 
					 | 
				
			||||||
echo "export SB_UNLOCK_PASSPHRASE=\"passphrase\""
 | 
					 | 
				
			||||||
echo "secret vault import work"
 | 
					 | 
				
			||||||
echo ""
 | 
					 | 
				
			||||||
echo -e "${YELLOW}# Unlocker management:${NC}"
 | 
					 | 
				
			||||||
echo "$SECRET_BINARY unlockers add <type>        # Add unlocker (passphrase, pgp, keychain)"
 | 
					 | 
				
			||||||
echo "$SECRET_BINARY unlockers add passphrase"
 | 
					 | 
				
			||||||
echo "$SECRET_BINARY unlockers add pgp <gpg-key-id>"
 | 
					 | 
				
			||||||
echo "$SECRET_BINARY unlockers add keychain        # macOS only"
 | 
					 | 
				
			||||||
echo "$SECRET_BINARY unlockers list               # List all unlockers"
 | 
					 | 
				
			||||||
echo "$SECRET_BINARY unlocker select <unlocker-id>  # Select current unlocker"
 | 
					 | 
				
			||||||
echo "$SECRET_BINARY unlockers rm <unlocker-id>     # Remove unlocker"
 | 
					 | 
				
			||||||
echo ""
 | 
					 | 
				
			||||||
echo -e "${YELLOW}# Secret management:${NC}"
 | 
					 | 
				
			||||||
echo "echo \"my-secret\" | secret add \"app/password\""
 | 
					 | 
				
			||||||
echo "echo \"my-secret\" | secret add \"app/password\" --force"
 | 
					 | 
				
			||||||
echo "secret get \"app/password\""
 | 
					 | 
				
			||||||
echo "secret list"
 | 
					 | 
				
			||||||
echo ""
 | 
					 | 
				
			||||||
echo -e "${YELLOW}# Cross-vault operations:${NC}"
 | 
					 | 
				
			||||||
echo "secret vault select work"
 | 
					 | 
				
			||||||
echo "echo \"work-secret\" | secret add \"work/database\""
 | 
					 | 
				
			||||||
echo "secret vault select default" 
 | 
					 | 
				
			||||||
		Loading…
	
		Reference in New Issue
	
	Block a user