latest
This commit is contained in:
parent
345709a306
commit
e95609ce69
@ -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
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 (
|
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
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"
|
"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 {
|
||||||
|
@ -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))
|
||||||
|
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)
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user