Fix vault creation to require mnemonic and set up initial unlocker

- Vault creation now prompts for mnemonic if not in environment
- Automatically creates passphrase unlocker during vault creation
- Prevents 'missing public key' error when adding secrets to new vaults
- Updates tests to reflect new vault creation flow
This commit is contained in:
Jeffrey Paul 2025-07-26 21:58:57 +02:00
parent a6f24e9581
commit 75c3d22b62
9 changed files with 558 additions and 90 deletions

View File

@ -0,0 +1,64 @@
package cli
import (
"fmt"
"os"
"github.com/spf13/cobra"
)
func newCompletionCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "completion [bash|zsh|fish|powershell]",
Short: "Generate completion script",
Long: `To load completions:
Bash:
$ source <(secret completion bash)
# To load completions for each session, execute once:
# Linux:
$ secret completion bash > /etc/bash_completion.d/secret
# macOS:
$ secret completion bash > $(brew --prefix)/etc/bash_completion.d/secret
Zsh:
# If shell completion is not already enabled in your environment,
# you will need to enable it. You can execute the following once:
$ echo "autoload -U compinit; compinit" >> ~/.zshrc
# To load completions for each session, execute once:
$ secret completion zsh > "${fpath[1]}/_secret"
# You will need to start a new shell for this setup to take effect.
Fish:
$ secret completion fish | source
# To load completions for each session, execute once:
$ secret completion fish > ~/.config/fish/completions/secret.fish
PowerShell:
PS> secret completion powershell | Out-String | Invoke-Expression
# To load completions for every new session, run:
PS> secret completion powershell > secret.ps1
# and source this file from your PowerShell profile.
`,
DisableFlagsInUseLine: true,
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
switch args[0] {
case "bash":
return cmd.Root().GenBashCompletion(os.Stdout)
case "zsh":
return cmd.Root().GenZshCompletion(os.Stdout)
case "fish":
return cmd.Root().GenFishCompletion(os.Stdout, true)
case "powershell":
return cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout)
default:
return fmt.Errorf("unsupported shell type: %s", args[0])
}
},
}
return cmd
}

144
internal/cli/completions.go Normal file
View File

@ -0,0 +1,144 @@
package cli
import (
"encoding/json"
"path/filepath"
"strings"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/spf13/afero"
"github.com/spf13/cobra"
)
// getSecretNamesCompletionFunc returns a completion function that provides secret names
func getSecretNamesCompletionFunc(fs afero.Fs, stateDir string) func(
cmd *cobra.Command, args []string, toComplete string,
) ([]string, cobra.ShellCompDirective) {
return func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
// Get current vault
vlt, err := vault.GetCurrentVault(fs, stateDir)
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
// Get list of secrets
secrets, err := vlt.ListSecrets()
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
// Filter secrets based on what user has typed
var completions []string
for _, secret := range secrets {
if strings.HasPrefix(secret, toComplete) {
completions = append(completions, secret)
}
}
return completions, cobra.ShellCompDirectiveNoFileComp
}
}
// getUnlockerIDsCompletionFunc returns a completion function that provides unlocker IDs
func getUnlockerIDsCompletionFunc(fs afero.Fs, stateDir string) func(
cmd *cobra.Command, args []string, toComplete string,
) ([]string, cobra.ShellCompDirective) {
return func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
// Get current vault
vlt, err := vault.GetCurrentVault(fs, stateDir)
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
// Get unlocker metadata list
unlockerMetadataList, err := vlt.ListUnlockers()
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
// Get vault directory
vaultDir, err := vlt.GetDirectory()
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
// Collect unlocker IDs
var completions []string
for _, metadata := range unlockerMetadataList {
// Get the actual unlocker ID by creating the unlocker instance
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
files, err := afero.ReadDir(fs, unlockersDir)
if err != nil {
continue
}
for _, file := range files {
if !file.IsDir() {
continue
}
unlockerDir := filepath.Join(unlockersDir, file.Name())
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
// Check if this is the right unlocker by comparing metadata
metadataBytes, err := afero.ReadFile(fs, metadataPath)
if err != nil {
continue
}
var diskMetadata secret.UnlockerMetadata
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 unlocker instance
var unlocker secret.Unlocker
switch metadata.Type {
case "passphrase":
unlocker = secret.NewPassphraseUnlocker(fs, unlockerDir, diskMetadata)
case "keychain":
unlocker = secret.NewKeychainUnlocker(fs, unlockerDir, diskMetadata)
case "pgp":
unlocker = secret.NewPGPUnlocker(fs, unlockerDir, diskMetadata)
}
if unlocker != nil {
id := unlocker.GetID()
if strings.HasPrefix(id, toComplete) {
completions = append(completions, id)
}
}
break
}
}
}
return completions, cobra.ShellCompDirectiveNoFileComp
}
}
// getVaultNamesCompletionFunc returns a completion function that provides vault names
func getVaultNamesCompletionFunc(fs afero.Fs, stateDir string) func(
cmd *cobra.Command, args []string, toComplete string,
) ([]string, cobra.ShellCompDirective) {
return func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
vaults, err := vault.ListVaults(fs, stateDir)
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
var completions []string
for _, v := range vaults {
if strings.HasPrefix(v, toComplete) {
completions = append(completions, v)
}
}
return completions, cobra.ShellCompDirectiveNoFileComp
}
}

View File

@ -18,6 +18,11 @@ import (
"github.com/stretchr/testify/require"
)
const (
// testMnemonic is a standard BIP39 mnemonic used for testing
testMnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
)
// TestMain runs before all tests and ensures the binary is built
func TestMain(m *testing.M) {
// Get the current working directory
@ -60,7 +65,6 @@ func TestSecretManagerIntegration(t *testing.T) {
}
// Test configuration
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
testPassphrase := "test-passphrase-123"
// Create a temporary directory for our vault
@ -125,7 +129,8 @@ func TestSecretManagerIntegration(t *testing.T) {
// - work vault has pub.age file
// - work vault has unlockers.d/passphrase directory
// - Unlocker metadata and encrypted keys present
test04ImportMnemonic(t, tempDir, testMnemonic, testPassphrase, runSecretWithEnv)
// NOTE: Skipped because vault creation now includes mnemonic import
// test04ImportMnemonic(t, tempDir, testMnemonic, testPassphrase, runSecretWithEnv)
// Test 5: Add secrets with versioning
// Command: echo "password123" | secret add database/password
@ -452,6 +457,12 @@ func test02ListVaults(t *testing.T, runSecret func(...string) (string, error)) {
}
func test03CreateVault(t *testing.T, tempDir string, runSecret func(...string) (string, error)) {
// Set environment variables for vault creation
os.Setenv("SB_SECRET_MNEMONIC", testMnemonic)
os.Setenv("SB_UNLOCK_PASSPHRASE", "test-passphrase")
defer os.Unsetenv("SB_SECRET_MNEMONIC")
defer os.Unsetenv("SB_UNLOCK_PASSPHRASE")
// Create work vault
output, err := runSecret("vault", "create", "work")
require.NoError(t, err, "vault create should succeed")
@ -480,9 +491,9 @@ func test03CreateVault(t *testing.T, tempDir string, runSecret func(...string) (
secretsDir := filepath.Join(workVaultDir, "secrets.d")
verifyFileExists(t, secretsDir)
// Verify that work vault does NOT have a long-term key yet (no mnemonic imported)
// Verify that work vault has a long-term key (mnemonic was provided)
pubKeyFile := filepath.Join(workVaultDir, "pub.age")
verifyFileNotExists(t, pubKeyFile)
verifyFileExists(t, pubKeyFile)
// List vaults to verify both exist
output, err = runSecret("vault", "list")

View File

@ -42,6 +42,7 @@ func newRootCmd() *cobra.Command {
cmd.AddCommand(newDecryptCmd())
cmd.AddCommand(newVersionCmd())
cmd.AddCommand(newInfoCmd())
cmd.AddCommand(newCompletionCmd())
secret.Debug("newRootCmd completed")

View File

@ -39,10 +39,12 @@ func newAddCmd() *cobra.Command {
}
func newGetCmd() *cobra.Command {
cli := NewCLIInstance()
cmd := &cobra.Command{
Use: "get <secret-name>",
Short: "Retrieve a secret from the vault",
Args: cobra.ExactArgs(1),
ValidArgsFunction: getSecretNamesCompletionFunc(cli.fs, cli.stateDir),
RunE: func(cmd *cobra.Command, args []string) error {
version, _ := cmd.Flags().GetString("version")
cli := NewCLIInstance()
@ -108,6 +110,7 @@ func newImportCmd() *cobra.Command {
}
func newRemoveCmd() *cobra.Command {
cli := NewCLIInstance()
cmd := &cobra.Command{
Use: "remove <secret-name>",
Aliases: []string{"rm"},
@ -115,6 +118,7 @@ func newRemoveCmd() *cobra.Command {
Long: `Remove a secret and all its versions from the current vault. This action is permanent and ` +
`cannot be undone.`,
Args: cobra.ExactArgs(1),
ValidArgsFunction: getSecretNamesCompletionFunc(cli.fs, cli.stateDir),
RunE: func(cmd *cobra.Command, args []string) error {
cli := NewCLIInstance()
@ -126,6 +130,7 @@ func newRemoveCmd() *cobra.Command {
}
func newMoveCmd() *cobra.Command {
cli := NewCLIInstance()
cmd := &cobra.Command{
Use: "move <source> <destination>",
Aliases: []string{"mv", "rename"},
@ -133,6 +138,14 @@ func newMoveCmd() *cobra.Command {
Long: `Move or rename a secret within the current vault. ` +
`If the destination already exists, the operation will fail.`,
Args: cobra.ExactArgs(2), //nolint:mnd // Command requires exactly 2 arguments: source and destination
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
// Only complete the first argument (source)
if len(args) == 0 {
return getSecretNamesCompletionFunc(cli.fs, cli.stateDir)(cmd, args, toComplete)
}
return nil, cobra.ShellCompDirectiveNoFileComp
},
RunE: func(cmd *cobra.Command, args []string) error {
cli := NewCLIInstance()
@ -360,20 +373,21 @@ func (cli *Instance) ListSecrets(cmd *cobra.Command, jsonOutput bool, quietOutpu
return fmt.Errorf("failed to marshal JSON: %w", err)
}
cmd.Println(string(jsonBytes))
_, _ = fmt.Fprintln(cmd.OutOrStdout(), string(jsonBytes))
} else if quietOutput {
// Quiet output - just secret names
for _, secretName := range filteredSecrets {
cmd.Println(secretName)
_, _ = fmt.Fprintln(cmd.OutOrStdout(), secretName)
}
} else {
// Pretty table output
out := cmd.OutOrStdout()
if len(filteredSecrets) == 0 {
if filter != "" {
cmd.Printf("No secrets found in vault '%s' matching filter '%s'.\n", vlt.GetName(), filter)
_, _ = fmt.Fprintf(out, "No secrets found in vault '%s' matching filter '%s'.\n", vlt.GetName(), filter)
} else {
cmd.Println("No secrets found in current vault.")
cmd.Println("Run 'secret add <name>' to create one.")
_, _ = fmt.Fprintln(out, "No secrets found in current vault.")
_, _ = fmt.Fprintln(out, "Run 'secret add <name>' to create one.")
}
return nil
@ -381,12 +395,25 @@ func (cli *Instance) ListSecrets(cmd *cobra.Command, jsonOutput bool, quietOutpu
// Get current vault name for display
if filter != "" {
cmd.Printf("Secrets in vault '%s' matching '%s':\n\n", vlt.GetName(), filter)
_, _ = fmt.Fprintf(out, "Secrets in vault '%s' matching '%s':\n\n", vlt.GetName(), filter)
} else {
cmd.Printf("Secrets in vault '%s':\n\n", vlt.GetName())
_, _ = fmt.Fprintf(out, "Secrets in vault '%s':\n\n", vlt.GetName())
}
cmd.Printf("%-40s %-20s\n", "NAME", "LAST UPDATED")
cmd.Printf("%-40s %-20s\n", "----", "------------")
// Calculate the maximum name length for proper column alignment
maxNameLen := len("NAME") // Start with header length
for _, secretName := range filteredSecrets {
if len(secretName) > maxNameLen {
maxNameLen = len(secretName)
}
}
// Add some padding
maxNameLen += 2
// Print headers with dynamic width
nameFormat := fmt.Sprintf("%%-%ds", maxNameLen)
_, _ = fmt.Fprintf(out, nameFormat+" %-20s\n", "NAME", "LAST UPDATED")
_, _ = fmt.Fprintf(out, nameFormat+" %-20s\n", strings.Repeat("-", len("NAME")), "------------")
for _, secretName := range filteredSecrets {
lastUpdated := "unknown"
@ -394,14 +421,14 @@ func (cli *Instance) ListSecrets(cmd *cobra.Command, jsonOutput bool, quietOutpu
metadata := secretObj.GetMetadata()
lastUpdated = metadata.UpdatedAt.Format("2006-01-02 15:04")
}
cmd.Printf("%-40s %-20s\n", secretName, lastUpdated)
_, _ = fmt.Fprintf(out, nameFormat+" %-20s\n", secretName, lastUpdated)
}
cmd.Printf("\nTotal: %d secret(s)", len(filteredSecrets))
_, _ = fmt.Fprintf(out, "\nTotal: %d secret(s)", len(filteredSecrets))
if filter != "" {
cmd.Printf(" (filtered from %d)", len(secrets))
_, _ = fmt.Fprintf(out, " (filtered from %d)", len(secrets))
}
cmd.Println()
_, _ = fmt.Fprintln(out)
}
return nil

View File

@ -17,6 +17,23 @@ import (
"github.com/spf13/cobra"
)
// UnlockerInfo represents unlocker information for display
type UnlockerInfo struct {
ID string `json:"id"`
Type string `json:"type"`
CreatedAt time.Time `json:"createdAt"`
Flags []string `json:"flags,omitempty"`
IsCurrent bool `json:"isCurrent"`
}
// Table formatting constants
const (
unlockerIDWidth = 40
unlockerTypeWidth = 12
unlockerDateWidth = 20
unlockerFlagsWidth = 20
)
// getDefaultGPGKey returns the default GPG key ID if available
func getDefaultGPGKey() (string, error) {
// First try to get the configured default key using gpgconf
@ -94,22 +111,66 @@ func newUnlockerListCmd() *cobra.Command {
func newUnlockerAddCmd() *cobra.Command {
// Build the supported types list based on platform
supportedTypes := "passphrase, pgp"
typeDescriptions := `Available unlocker types:
passphrase - Traditional password-based encryption
Prompts for a passphrase that will be used to encrypt/decrypt the vault's master key.
The passphrase is never stored in plaintext.
pgp - GNU Privacy Guard (GPG) key-based encryption
Uses your existing GPG key to encrypt/decrypt the vault's master key.
Requires gpg to be installed and configured with at least one secret key.
Use --keyid to specify a particular key, otherwise uses your default GPG key.`
if runtime.GOOS == "darwin" {
supportedTypes = "passphrase, keychain, pgp"
typeDescriptions = `Available unlocker types:
passphrase - Traditional password-based encryption
Prompts for a passphrase that will be used to encrypt/decrypt the vault's master key.
The passphrase is never stored in plaintext.
keychain - macOS Keychain integration (macOS only)
Stores the vault's master key in the macOS Keychain, protected by your login password.
Automatically unlocks when your Keychain is unlocked (e.g., after login).
Provides seamless integration with macOS security features like Touch ID.
pgp - GNU Privacy Guard (GPG) key-based encryption
Uses your existing GPG key to encrypt/decrypt the vault's master key.
Requires gpg to be installed and configured with at least one secret key.
Use --keyid to specify a particular key, otherwise uses your default GPG key.`
}
cmd := &cobra.Command{
Use: "add <type>",
Short: "Add a new unlocker",
Long: fmt.Sprintf(`Add a new unlocker of the specified type (%s).
Long: fmt.Sprintf(`Add a new unlocker to the current vault.
For PGP unlockers, you can optionally specify a GPG key ID with --keyid.
If not specified, the default GPG key will be used.`, supportedTypes),
%s
Each vault can have multiple unlockers, allowing different authentication methods
to access the same vault. This provides flexibility and backup access options.`, typeDescriptions),
Args: cobra.ExactArgs(1),
ValidArgs: strings.Split(supportedTypes, ", "),
RunE: func(cmd *cobra.Command, args []string) error {
cli := NewCLIInstance()
unlockerType := args[0]
// Validate unlocker type
validTypes := strings.Split(supportedTypes, ", ")
valid := false
for _, t := range validTypes {
if unlockerType == t {
valid = true
break
}
}
if !valid {
return fmt.Errorf("invalid unlocker type '%s'\n\nSupported types: %s\n\n"+
"Run 'secret unlocker add --help' for detailed descriptions", unlockerType, supportedTypes)
}
// Check if --keyid was used with non-PGP type
if unlockerType != "pgp" && cmd.Flags().Changed("keyid") {
return fmt.Errorf("--keyid flag is only valid for PGP unlockers")
@ -125,6 +186,7 @@ If not specified, the default GPG key will be used.`, supportedTypes),
}
func newUnlockerRemoveCmd() *cobra.Command {
cli := NewCLIInstance()
cmd := &cobra.Command{
Use: "remove <unlocker-id>",
Aliases: []string{"rm"},
@ -133,6 +195,7 @@ func newUnlockerRemoveCmd() *cobra.Command {
`secrets unless --force is used. Warning: Without unlockers and without your mnemonic, vault data ` +
`will be permanently inaccessible.`,
Args: cobra.ExactArgs(1),
ValidArgsFunction: getUnlockerIDsCompletionFunc(cli.fs, cli.stateDir),
RunE: func(cmd *cobra.Command, args []string) error {
force, _ := cmd.Flags().GetBool("force")
cli := NewCLIInstance()
@ -147,10 +210,13 @@ func newUnlockerRemoveCmd() *cobra.Command {
}
func newUnlockerSelectCmd() *cobra.Command {
cli := NewCLIInstance()
return &cobra.Command{
Use: "select <unlocker-id>",
Short: "Select an unlocker as current",
Args: cobra.ExactArgs(1),
ValidArgsFunction: getUnlockerIDsCompletionFunc(cli.fs, cli.stateDir),
RunE: func(_ *cobra.Command, args []string) error {
cli := NewCLIInstance()
@ -167,6 +233,13 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
return err
}
// Get the current unlocker ID
var currentUnlockerID string
currentUnlocker, err := vlt.GetCurrentUnlocker()
if err == nil {
currentUnlockerID = currentUnlocker.GetID()
}
// Get the metadata first
unlockerMetadataList, err := vlt.ListUnlockers()
if err != nil {
@ -174,13 +247,6 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
}
// Load actual unlocker objects to get the proper IDs
type UnlockerInfo struct {
ID string `json:"id"`
Type string `json:"type"`
CreatedAt time.Time `json:"createdAt"`
Flags []string `json:"flags,omitempty"`
}
var unlockers []UnlockerInfo
for _, metadata := range unlockerMetadataList {
// Create unlocker instance to get the proper ID
@ -246,14 +312,23 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
Type: metadata.Type,
CreatedAt: metadata.CreatedAt,
Flags: metadata.Flags,
IsCurrent: properID == currentUnlockerID,
}
unlockers = append(unlockers, unlockerInfo)
}
if jsonOutput {
// JSON output
return cli.printUnlockersJSON(unlockers, currentUnlockerID)
}
return cli.printUnlockersTable(unlockers)
}
// printUnlockersJSON prints unlockers in JSON format
func (cli *Instance) printUnlockersJSON(unlockers []UnlockerInfo, currentUnlockerID string) error {
output := map[string]interface{}{
"unlockers": unlockers,
"currentUnlockerID": currentUnlockerID,
}
jsonBytes, err := json.MarshalIndent(output, "", " ")
@ -262,8 +337,12 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
}
cli.cmd.Println(string(jsonBytes))
} else {
// Pretty table output
return nil
}
// printUnlockersTable prints unlockers in a formatted table
func (cli *Instance) printUnlockersTable(unlockers []UnlockerInfo) error {
if len(unlockers) == 0 {
cli.cmd.Println("No unlockers found in current vault.")
cli.cmd.Println("Run 'secret unlocker add passphrase' to create one.")
@ -271,15 +350,22 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
return nil
}
cli.cmd.Printf("%-18s %-12s %-20s %s\n", "UNLOCKER ID", "TYPE", "CREATED", "FLAGS")
cli.cmd.Printf("%-18s %-12s %-20s %s\n", "-----------", "----", "-------", "-----")
cli.cmd.Printf(" %-40s %-12s %-20s %s\n", "UNLOCKER ID", "TYPE", "CREATED", "FLAGS")
cli.cmd.Printf(" %-40s %-12s %-20s %s\n",
strings.Repeat("-", unlockerIDWidth), strings.Repeat("-", unlockerTypeWidth),
strings.Repeat("-", unlockerDateWidth), strings.Repeat("-", unlockerFlagsWidth))
for _, unlocker := range unlockers {
flags := ""
if len(unlocker.Flags) > 0 {
flags = strings.Join(unlocker.Flags, ",")
}
cli.cmd.Printf("%-18s %-12s %-20s %s\n",
prefix := " "
if unlocker.IsCurrent {
prefix = "* "
}
cli.cmd.Printf("%s%-40s %-12s %-20s %s\n",
prefix,
unlocker.ID,
unlocker.Type,
unlocker.CreatedAt.Format("2006-01-02 15:04:05"),
@ -287,7 +373,6 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
}
cli.cmd.Printf("\nTotal: %d unlocker(s)\n", len(unlockers))
}
return nil
}
@ -331,6 +416,13 @@ func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error
cmd.Printf("Created passphrase unlocker: %s\n", passphraseUnlocker.GetID())
// Auto-select the newly created unlocker
if err := vlt.SelectUnlocker(passphraseUnlocker.GetID()); err != nil {
cmd.Printf("Warning: Failed to auto-select new unlocker: %v\n", err)
} else {
cmd.Printf("Automatically selected as current unlocker\n")
}
return nil
case "keychain":
@ -348,6 +440,17 @@ func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error
cmd.Printf("Keychain Item Name: %s\n", keyName)
}
// Auto-select the newly created unlocker
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil {
return fmt.Errorf("failed to get current vault: %w", err)
}
if err := vlt.SelectUnlocker(keychainUnlocker.GetID()); err != nil {
cmd.Printf("Warning: Failed to auto-select new unlocker: %v\n", err)
} else {
cmd.Printf("Automatically selected as current unlocker\n")
}
return nil
case "pgp":
@ -393,6 +496,13 @@ func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error
cmd.Printf("Created PGP unlocker: %s\n", pgpUnlocker.GetID())
cmd.Printf("GPG Key ID: %s\n", gpgKeyID)
// Auto-select the newly created unlocker
if err := vlt.SelectUnlocker(pgpUnlocker.GetID()); err != nil {
cmd.Printf("Warning: Failed to auto-select new unlocker: %v\n", err)
} else {
cmd.Printf("Automatically selected as current unlocker\n")
}
return nil
default:

View File

@ -66,10 +66,13 @@ func newVaultCreateCmd() *cobra.Command {
}
func newVaultSelectCmd() *cobra.Command {
cli := NewCLIInstance()
return &cobra.Command{
Use: "select <name>",
Short: "Select a vault as current",
Args: cobra.ExactArgs(1),
ValidArgsFunction: getVaultNamesCompletionFunc(cli.fs, cli.stateDir),
RunE: func(cmd *cobra.Command, args []string) error {
cli := NewCLIInstance()
@ -79,11 +82,14 @@ func newVaultSelectCmd() *cobra.Command {
}
func newVaultImportCmd() *cobra.Command {
cli := NewCLIInstance()
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),
ValidArgsFunction: getVaultNamesCompletionFunc(cli.fs, cli.stateDir),
RunE: func(cmd *cobra.Command, args []string) error {
vaultName := "default"
if len(args) > 0 {
@ -98,6 +104,7 @@ func newVaultImportCmd() *cobra.Command {
}
func newVaultRemoveCmd() *cobra.Command {
cli := NewCLIInstance()
cmd := &cobra.Command{
Use: "remove <name>",
Aliases: []string{"rm"},
@ -105,6 +112,7 @@ func newVaultRemoveCmd() *cobra.Command {
Long: `Remove a vault. Requires --force if the vault contains secrets. Will automatically ` +
`switch to another vault if removing the currently selected one.`,
Args: cobra.ExactArgs(1),
ValidArgsFunction: getVaultNamesCompletionFunc(cli.fs, cli.stateDir),
RunE: func(cmd *cobra.Command, args []string) error {
force, _ := cmd.Flags().GetBool("force")
cli := NewCLIInstance()
@ -171,12 +179,95 @@ func (cli *Instance) ListVaults(cmd *cobra.Command, jsonOutput bool) error {
func (cli *Instance) CreateVault(cmd *cobra.Command, name string) error {
secret.Debug("Creating new vault", "name", name, "state_dir", cli.stateDir)
// Get or 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 securely without echo
mnemonicBuffer, err := secret.ReadPassphrase("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)
}
defer mnemonicBuffer.Destroy()
mnemonicStr = mnemonicBuffer.String()
fmt.Fprintln(os.Stderr) // Add newline after hidden input
}
if mnemonicStr == "" {
return fmt.Errorf("mnemonic cannot be empty")
}
// Validate the mnemonic
mnemonicWords := strings.Fields(mnemonicStr)
secret.Debug("Validating BIP39 mnemonic", "word_count", len(mnemonicWords))
if !bip39.IsMnemonicValid(mnemonicStr) {
return fmt.Errorf("invalid BIP39 mnemonic phrase")
}
// Set mnemonic in environment for CreateVault to use
originalMnemonic := os.Getenv(secret.EnvMnemonic)
_ = os.Setenv(secret.EnvMnemonic, mnemonicStr)
defer func() {
if originalMnemonic != "" {
_ = os.Setenv(secret.EnvMnemonic, originalMnemonic)
} else {
_ = os.Unsetenv(secret.EnvMnemonic)
}
}()
// Create the vault - it will handle key derivation internally
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, name)
if err != nil {
return err
}
// Get the vault metadata to retrieve the derivation index
vaultDir := filepath.Join(cli.stateDir, "vaults.d", name)
metadata, err := vault.LoadVaultMetadata(cli.fs, vaultDir)
if err != nil {
return fmt.Errorf("failed to load vault metadata: %w", err)
}
// Derive the long-term key using the same index that CreateVault used
ltIdentity, err := agehd.DeriveIdentity(mnemonicStr, metadata.DerivationIndex)
if err != nil {
return fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
}
// Unlock the vault with the derived long-term key
vlt.Unlock(ltIdentity)
// Get or prompt for passphrase
var passphraseBuffer *memguard.LockedBuffer
if envPassphrase := os.Getenv(secret.EnvUnlockPassphrase); envPassphrase != "" {
secret.Debug("Using unlock passphrase from environment variable")
passphraseBuffer = memguard.NewBufferFromBytes([]byte(envPassphrase))
} else {
secret.Debug("Prompting user for unlock passphrase")
// Use secure passphrase input with confirmation
passphraseBuffer, err = readSecurePassphrase("Enter passphrase for unlocker: ")
if err != nil {
return fmt.Errorf("failed to read passphrase: %w", err)
}
}
defer passphraseBuffer.Destroy()
// Create passphrase-protected unlocker
secret.Debug("Creating passphrase-protected unlocker")
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
if err != nil {
return fmt.Errorf("failed to create unlocker: %w", err)
}
cmd.Printf("Created vault '%s'\n", vlt.GetName())
cmd.Printf("Long-term public key: %s\n", ltIdentity.Recipient().String())
cmd.Printf("Unlocker ID: %s\n", passphraseUnlocker.GetID())
return nil
}

View File

@ -37,6 +37,7 @@ func VersionCommands(cli *Instance) *cobra.Command {
Aliases: []string{"ls"},
Short: "List all versions of a secret",
Args: cobra.ExactArgs(1),
ValidArgsFunction: getSecretNamesCompletionFunc(cli.fs, cli.stateDir),
RunE: func(cmd *cobra.Command, args []string) error {
return cli.ListVersions(cmd, args[0])
},
@ -48,6 +49,14 @@ func VersionCommands(cli *Instance) *cobra.Command {
Short: "Promote a specific version to current",
Long: "Updates the current symlink to point to the specified version without modifying timestamps",
Args: cobra.ExactArgs(2), //nolint:mnd // Command requires exactly 2 arguments: secret-name and version
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
// Complete secret name for first arg
if len(args) == 0 {
return getSecretNamesCompletionFunc(cli.fs, cli.stateDir)(cmd, args, toComplete)
}
// TODO: Complete version numbers for second arg
return nil, cobra.ShellCompDirectiveNoFileComp
},
RunE: func(cmd *cobra.Command, args []string) error {
return cli.PromoteVersion(cmd, args[0], args[1])
},
@ -60,6 +69,14 @@ func VersionCommands(cli *Instance) *cobra.Command {
Short: "Remove a specific version of a secret",
Long: "Remove a specific version of a secret. Cannot remove the current version.",
Args: cobra.ExactArgs(2), //nolint:mnd // Command requires exactly 2 arguments: secret-name and version
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
// Complete secret name for first arg
if len(args) == 0 {
return getSecretNamesCompletionFunc(cli.fs, cli.stateDir)(cmd, args, toComplete)
}
// TODO: Complete version numbers for second arg
return nil, cobra.ShellCompDirectiveNoFileComp
},
RunE: func(cmd *cobra.Command, args []string) error {
return cli.RemoveVersion(cmd, args[0], args[1])
},

View File

@ -161,15 +161,18 @@ func (k *KeychainUnlocker) GetDirectory() string {
// GetID implements Unlocker interface - generates ID from keychain item name
func (k *KeychainUnlocker) GetID() string {
// Generate ID using keychain item name
keychainItemName, err := k.GetKeychainItemName()
// Generate ID in the format YYYY-MM-DD.HH.mm-hostname-keychain
// This matches the passphrase unlocker format
hostname, err := os.Hostname()
if err != nil {
// The vault metadata is corrupt - this is a fatal error
// We cannot continue with a fallback ID as that would mask data corruption
panic(fmt.Sprintf("Keychain unlocker metadata is corrupt or missing keychain item name: %v", err))
hostname = "unknown"
}
return fmt.Sprintf("%s-keychain", keychainItemName)
// Use the creation timestamp from metadata
createdAt := k.Metadata.CreatedAt
timestamp := createdAt.Format("2006-01-02.15.04")
return fmt.Sprintf("%s-%s-keychain", timestamp, hostname)
}
// Remove implements Unlocker interface - removes the keychain unlocker