This commit is contained in:
Jeffrey Paul 2025-05-29 11:02:22 -07:00
parent 345709a306
commit e95609ce69
15 changed files with 1693 additions and 1588 deletions

View File

@ -1,7 +1,7 @@
package main package main
import "git.eeqj.de/sneak/secret/internal/secret" import "git.eeqj.de/sneak/secret/internal/cli"
func main() { func main() {
secret.CLIEntry() cli.CLIEntry()
} }

91
internal/cli/cli.go Normal file
View 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
}

View File

@ -1,10 +1,11 @@
package secret package cli
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/spf13/afero" "github.com/spf13/afero"
) )
@ -34,32 +35,32 @@ func TestCLIInstanceWithFs(t *testing.T) {
} }
func TestDetermineStateDir(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 // Save original environment and restore it after test
originalStateDir := os.Getenv(EnvStateDir) originalStateDir := os.Getenv(secret.EnvStateDir)
defer func() { defer func() {
if originalStateDir == "" { if originalStateDir == "" {
os.Unsetenv(EnvStateDir) os.Unsetenv(secret.EnvStateDir)
} else { } else {
os.Setenv(EnvStateDir, originalStateDir) os.Setenv(secret.EnvStateDir, originalStateDir)
} }
}() }()
// Test with environment variable set // Test with environment variable set
testEnvDir := "/test-env-dir" testEnvDir := "/test-env-dir"
os.Setenv(EnvStateDir, testEnvDir) os.Setenv(secret.EnvStateDir, testEnvDir)
stateDir := determineStateDir("") stateDir := secret.DetermineStateDir("")
if stateDir != testEnvDir { if stateDir != testEnvDir {
t.Errorf("Expected state directory %q from environment, got %q", testEnvDir, stateDir) t.Errorf("Expected state directory %q from environment, got %q", testEnvDir, stateDir)
} }
// Test with custom config dir // Test with custom config dir
os.Unsetenv(EnvStateDir) os.Unsetenv(secret.EnvStateDir)
customConfigDir := "/custom-config" customConfigDir := "/custom-config"
stateDir = determineStateDir(customConfigDir) stateDir = secret.DetermineStateDir(customConfigDir)
expectedDir := filepath.Join(customConfigDir, AppID) expectedDir := filepath.Join(customConfigDir, secret.AppID)
if stateDir != expectedDir { if stateDir != expectedDir {
t.Errorf("Expected state directory %q with custom config, got %q", expectedDir, stateDir) t.Errorf("Expected state directory %q with custom config, got %q", expectedDir, stateDir)
} }

243
internal/cli/crypto.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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

View 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"
)

View File

@ -11,8 +11,8 @@ import (
"golang.org/x/term" "golang.org/x/term"
) )
// encryptToRecipient encrypts data to a recipient using age // EncryptToRecipient encrypts data to a recipient using age
func encryptToRecipient(data []byte, recipient age.Recipient) ([]byte, error) { func EncryptToRecipient(data []byte, recipient age.Recipient) ([]byte, error) {
Debug("encryptToRecipient starting", "data_length", len(data)) Debug("encryptToRecipient starting", "data_length", len(data))
var buf bytes.Buffer var buf bytes.Buffer
@ -43,6 +43,11 @@ func encryptToRecipient(data []byte, recipient age.Recipient) ([]byte, error) {
return result, nil 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 // decryptWithIdentity decrypts data with an identity using age
func decryptWithIdentity(data []byte, identity age.Identity) ([]byte, error) { func decryptWithIdentity(data []byte, identity age.Identity) ([]byte, error) {
r, err := age.Decrypt(bytes.NewReader(data), identity) 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 // readPassphrase reads a passphrase securely from the terminal without echoing
// This version is for unlocking and doesn't require confirmation // This version is for unlocking and doesn't require confirmation
func readPassphrase(prompt string) (string, error) { func readPassphrase(prompt string) (string, error) {
// Check if stderr is a terminal - if not, we can't prompt interactively
if !term.IsTerminal(int(syscall.Stderr)) {
return "", fmt.Errorf("cannot prompt for passphrase: stderr is not a terminal (running in non-interactive mode)")
}
// Check if stdin is a terminal // Check if stdin is a terminal
if !term.IsTerminal(int(syscall.Stdin)) { if !term.IsTerminal(int(syscall.Stdin)) {
// Not a terminal - use shared line reader to avoid buffering conflicts // Not a terminal - never read passphrases from piped input for security reasons
return readLineFromStdin(prompt) 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 fmt.Fprint(os.Stderr, prompt) // Write prompt to stderr, not stdout
passphrase, err := term.ReadPassword(int(syscall.Stdin)) passphrase, err := term.ReadPassword(int(syscall.Stdin))
if err != nil { if err != nil {

View File

@ -33,7 +33,7 @@ func initDebugLogging() {
} }
// Disable stderr buffering for immediate debug output when debugging is enabled // 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 // Check if STDERR is a TTY
isTTY := term.IsTerminal(int(syscall.Stderr)) isTTY := term.IsTerminal(int(syscall.Stderr))

View 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)
}