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