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:
parent
a6f24e9581
commit
75c3d22b62
64
internal/cli/completion.go
Normal file
64
internal/cli/completion.go
Normal 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
144
internal/cli/completions.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
@ -18,6 +18,11 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"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
|
// TestMain runs before all tests and ensures the binary is built
|
||||||
func TestMain(m *testing.M) {
|
func TestMain(m *testing.M) {
|
||||||
// Get the current working directory
|
// Get the current working directory
|
||||||
@ -60,7 +65,6 @@ func TestSecretManagerIntegration(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Test configuration
|
// Test configuration
|
||||||
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
|
||||||
testPassphrase := "test-passphrase-123"
|
testPassphrase := "test-passphrase-123"
|
||||||
|
|
||||||
// Create a temporary directory for our vault
|
// 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 pub.age file
|
||||||
// - work vault has unlockers.d/passphrase directory
|
// - work vault has unlockers.d/passphrase directory
|
||||||
// - Unlocker metadata and encrypted keys present
|
// - 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
|
// Test 5: Add secrets with versioning
|
||||||
// Command: echo "password123" | secret add database/password
|
// 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)) {
|
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
|
// Create work vault
|
||||||
output, err := runSecret("vault", "create", "work")
|
output, err := runSecret("vault", "create", "work")
|
||||||
require.NoError(t, err, "vault create should succeed")
|
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")
|
secretsDir := filepath.Join(workVaultDir, "secrets.d")
|
||||||
verifyFileExists(t, secretsDir)
|
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")
|
pubKeyFile := filepath.Join(workVaultDir, "pub.age")
|
||||||
verifyFileNotExists(t, pubKeyFile)
|
verifyFileExists(t, pubKeyFile)
|
||||||
|
|
||||||
// List vaults to verify both exist
|
// List vaults to verify both exist
|
||||||
output, err = runSecret("vault", "list")
|
output, err = runSecret("vault", "list")
|
||||||
|
@ -42,6 +42,7 @@ func newRootCmd() *cobra.Command {
|
|||||||
cmd.AddCommand(newDecryptCmd())
|
cmd.AddCommand(newDecryptCmd())
|
||||||
cmd.AddCommand(newVersionCmd())
|
cmd.AddCommand(newVersionCmd())
|
||||||
cmd.AddCommand(newInfoCmd())
|
cmd.AddCommand(newInfoCmd())
|
||||||
|
cmd.AddCommand(newCompletionCmd())
|
||||||
|
|
||||||
secret.Debug("newRootCmd completed")
|
secret.Debug("newRootCmd completed")
|
||||||
|
|
||||||
|
@ -39,10 +39,12 @@ func newAddCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newGetCmd() *cobra.Command {
|
func newGetCmd() *cobra.Command {
|
||||||
|
cli := NewCLIInstance()
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "get <secret-name>",
|
Use: "get <secret-name>",
|
||||||
Short: "Retrieve a secret from the vault",
|
Short: "Retrieve a secret from the vault",
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
|
ValidArgsFunction: getSecretNamesCompletionFunc(cli.fs, cli.stateDir),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
version, _ := cmd.Flags().GetString("version")
|
version, _ := cmd.Flags().GetString("version")
|
||||||
cli := NewCLIInstance()
|
cli := NewCLIInstance()
|
||||||
@ -108,13 +110,15 @@ func newImportCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newRemoveCmd() *cobra.Command {
|
func newRemoveCmd() *cobra.Command {
|
||||||
|
cli := NewCLIInstance()
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "remove <secret-name>",
|
Use: "remove <secret-name>",
|
||||||
Aliases: []string{"rm"},
|
Aliases: []string{"rm"},
|
||||||
Short: "Remove a secret from the vault",
|
Short: "Remove a secret from the vault",
|
||||||
Long: `Remove a secret and all its versions from the current vault. This action is permanent and ` +
|
Long: `Remove a secret and all its versions from the current vault. This action is permanent and ` +
|
||||||
`cannot be undone.`,
|
`cannot be undone.`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
|
ValidArgsFunction: getSecretNamesCompletionFunc(cli.fs, cli.stateDir),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
cli := NewCLIInstance()
|
cli := NewCLIInstance()
|
||||||
|
|
||||||
@ -126,6 +130,7 @@ func newRemoveCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newMoveCmd() *cobra.Command {
|
func newMoveCmd() *cobra.Command {
|
||||||
|
cli := NewCLIInstance()
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "move <source> <destination>",
|
Use: "move <source> <destination>",
|
||||||
Aliases: []string{"mv", "rename"},
|
Aliases: []string{"mv", "rename"},
|
||||||
@ -133,6 +138,14 @@ func newMoveCmd() *cobra.Command {
|
|||||||
Long: `Move or rename a secret within the current vault. ` +
|
Long: `Move or rename a secret within the current vault. ` +
|
||||||
`If the destination already exists, the operation will fail.`,
|
`If the destination already exists, the operation will fail.`,
|
||||||
Args: cobra.ExactArgs(2), //nolint:mnd // Command requires exactly 2 arguments: source and destination
|
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 {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
cli := NewCLIInstance()
|
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)
|
return fmt.Errorf("failed to marshal JSON: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Println(string(jsonBytes))
|
_, _ = fmt.Fprintln(cmd.OutOrStdout(), string(jsonBytes))
|
||||||
} else if quietOutput {
|
} else if quietOutput {
|
||||||
// Quiet output - just secret names
|
// Quiet output - just secret names
|
||||||
for _, secretName := range filteredSecrets {
|
for _, secretName := range filteredSecrets {
|
||||||
cmd.Println(secretName)
|
_, _ = fmt.Fprintln(cmd.OutOrStdout(), secretName)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Pretty table output
|
// Pretty table output
|
||||||
|
out := cmd.OutOrStdout()
|
||||||
if len(filteredSecrets) == 0 {
|
if len(filteredSecrets) == 0 {
|
||||||
if filter != "" {
|
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 {
|
} else {
|
||||||
cmd.Println("No secrets found in current vault.")
|
_, _ = fmt.Fprintln(out, "No secrets found in current vault.")
|
||||||
cmd.Println("Run 'secret add <name>' to create one.")
|
_, _ = fmt.Fprintln(out, "Run 'secret add <name>' to create one.")
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@ -381,12 +395,25 @@ func (cli *Instance) ListSecrets(cmd *cobra.Command, jsonOutput bool, quietOutpu
|
|||||||
|
|
||||||
// Get current vault name for display
|
// Get current vault name for display
|
||||||
if filter != "" {
|
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 {
|
} 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 {
|
for _, secretName := range filteredSecrets {
|
||||||
lastUpdated := "unknown"
|
lastUpdated := "unknown"
|
||||||
@ -394,14 +421,14 @@ func (cli *Instance) ListSecrets(cmd *cobra.Command, jsonOutput bool, quietOutpu
|
|||||||
metadata := secretObj.GetMetadata()
|
metadata := secretObj.GetMetadata()
|
||||||
lastUpdated = metadata.UpdatedAt.Format("2006-01-02 15:04")
|
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 != "" {
|
if filter != "" {
|
||||||
cmd.Printf(" (filtered from %d)", len(secrets))
|
_, _ = fmt.Fprintf(out, " (filtered from %d)", len(secrets))
|
||||||
}
|
}
|
||||||
cmd.Println()
|
_, _ = fmt.Fprintln(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
@ -17,6 +17,23 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"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
|
// getDefaultGPGKey returns the default GPG key ID if available
|
||||||
func getDefaultGPGKey() (string, error) {
|
func getDefaultGPGKey() (string, error) {
|
||||||
// First try to get the configured default key using gpgconf
|
// First try to get the configured default key using gpgconf
|
||||||
@ -94,22 +111,66 @@ func newUnlockerListCmd() *cobra.Command {
|
|||||||
func newUnlockerAddCmd() *cobra.Command {
|
func newUnlockerAddCmd() *cobra.Command {
|
||||||
// Build the supported types list based on platform
|
// Build the supported types list based on platform
|
||||||
supportedTypes := "passphrase, pgp"
|
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" {
|
if runtime.GOOS == "darwin" {
|
||||||
supportedTypes = "passphrase, keychain, pgp"
|
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{
|
cmd := &cobra.Command{
|
||||||
Use: "add <type>",
|
Use: "add <type>",
|
||||||
Short: "Add a new unlocker",
|
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.
|
%s
|
||||||
If not specified, the default GPG key will be used.`, supportedTypes),
|
|
||||||
Args: cobra.ExactArgs(1),
|
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 {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
cli := NewCLIInstance()
|
cli := NewCLIInstance()
|
||||||
unlockerType := args[0]
|
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
|
// Check if --keyid was used with non-PGP type
|
||||||
if unlockerType != "pgp" && cmd.Flags().Changed("keyid") {
|
if unlockerType != "pgp" && cmd.Flags().Changed("keyid") {
|
||||||
return fmt.Errorf("--keyid flag is only valid for PGP unlockers")
|
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 {
|
func newUnlockerRemoveCmd() *cobra.Command {
|
||||||
|
cli := NewCLIInstance()
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "remove <unlocker-id>",
|
Use: "remove <unlocker-id>",
|
||||||
Aliases: []string{"rm"},
|
Aliases: []string{"rm"},
|
||||||
@ -132,7 +194,8 @@ func newUnlockerRemoveCmd() *cobra.Command {
|
|||||||
Long: `Remove an unlocker from the current vault. Cannot remove the last unlocker if the vault has ` +
|
Long: `Remove an unlocker from the current vault. Cannot remove the last unlocker if the vault has ` +
|
||||||
`secrets unless --force is used. Warning: Without unlockers and without your mnemonic, vault data ` +
|
`secrets unless --force is used. Warning: Without unlockers and without your mnemonic, vault data ` +
|
||||||
`will be permanently inaccessible.`,
|
`will be permanently inaccessible.`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
|
ValidArgsFunction: getUnlockerIDsCompletionFunc(cli.fs, cli.stateDir),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
force, _ := cmd.Flags().GetBool("force")
|
force, _ := cmd.Flags().GetBool("force")
|
||||||
cli := NewCLIInstance()
|
cli := NewCLIInstance()
|
||||||
@ -147,10 +210,13 @@ func newUnlockerRemoveCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newUnlockerSelectCmd() *cobra.Command {
|
func newUnlockerSelectCmd() *cobra.Command {
|
||||||
|
cli := NewCLIInstance()
|
||||||
|
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "select <unlocker-id>",
|
Use: "select <unlocker-id>",
|
||||||
Short: "Select an unlocker as current",
|
Short: "Select an unlocker as current",
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
|
ValidArgsFunction: getUnlockerIDsCompletionFunc(cli.fs, cli.stateDir),
|
||||||
RunE: func(_ *cobra.Command, args []string) error {
|
RunE: func(_ *cobra.Command, args []string) error {
|
||||||
cli := NewCLIInstance()
|
cli := NewCLIInstance()
|
||||||
|
|
||||||
@ -167,6 +233,13 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get the current unlocker ID
|
||||||
|
var currentUnlockerID string
|
||||||
|
currentUnlocker, err := vlt.GetCurrentUnlocker()
|
||||||
|
if err == nil {
|
||||||
|
currentUnlockerID = currentUnlocker.GetID()
|
||||||
|
}
|
||||||
|
|
||||||
// Get the metadata first
|
// Get the metadata first
|
||||||
unlockerMetadataList, err := vlt.ListUnlockers()
|
unlockerMetadataList, err := vlt.ListUnlockers()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -174,13 +247,6 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load actual unlocker objects to get the proper IDs
|
// 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
|
var unlockers []UnlockerInfo
|
||||||
for _, metadata := range unlockerMetadataList {
|
for _, metadata := range unlockerMetadataList {
|
||||||
// Create unlocker instance to get the proper ID
|
// Create unlocker instance to get the proper ID
|
||||||
@ -246,49 +312,68 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
|
|||||||
Type: metadata.Type,
|
Type: metadata.Type,
|
||||||
CreatedAt: metadata.CreatedAt,
|
CreatedAt: metadata.CreatedAt,
|
||||||
Flags: metadata.Flags,
|
Flags: metadata.Flags,
|
||||||
|
IsCurrent: properID == currentUnlockerID,
|
||||||
}
|
}
|
||||||
unlockers = append(unlockers, unlockerInfo)
|
unlockers = append(unlockers, unlockerInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
if jsonOutput {
|
if jsonOutput {
|
||||||
// JSON output
|
return cli.printUnlockersJSON(unlockers, currentUnlockerID)
|
||||||
output := map[string]interface{}{
|
|
||||||
"unlockers": unlockers,
|
|
||||||
}
|
|
||||||
|
|
||||||
jsonBytes, err := json.MarshalIndent(output, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to marshal JSON: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cli.cmd.Println(string(jsonBytes))
|
|
||||||
} else {
|
|
||||||
// Pretty table output
|
|
||||||
if len(unlockers) == 0 {
|
|
||||||
cli.cmd.Println("No unlockers found in current vault.")
|
|
||||||
cli.cmd.Println("Run 'secret unlocker add passphrase' to create one.")
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
cli.cmd.Printf("%-18s %-12s %-20s %s\n", "UNLOCKER ID", "TYPE", "CREATED", "FLAGS")
|
|
||||||
cli.cmd.Printf("%-18s %-12s %-20s %s\n", "-----------", "----", "-------", "-----")
|
|
||||||
|
|
||||||
for _, unlocker := range unlockers {
|
|
||||||
flags := ""
|
|
||||||
if len(unlocker.Flags) > 0 {
|
|
||||||
flags = strings.Join(unlocker.Flags, ",")
|
|
||||||
}
|
|
||||||
cli.cmd.Printf("%-18s %-12s %-20s %s\n",
|
|
||||||
unlocker.ID,
|
|
||||||
unlocker.Type,
|
|
||||||
unlocker.CreatedAt.Format("2006-01-02 15:04:05"),
|
|
||||||
flags)
|
|
||||||
}
|
|
||||||
|
|
||||||
cli.cmd.Printf("\nTotal: %d unlocker(s)\n", len(unlockers))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal JSON: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cli.cmd.Println(string(jsonBytes))
|
||||||
|
|
||||||
|
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.")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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, ",")
|
||||||
|
}
|
||||||
|
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"),
|
||||||
|
flags)
|
||||||
|
}
|
||||||
|
|
||||||
|
cli.cmd.Printf("\nTotal: %d unlocker(s)\n", len(unlockers))
|
||||||
|
|
||||||
return nil
|
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())
|
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
|
return nil
|
||||||
|
|
||||||
case "keychain":
|
case "keychain":
|
||||||
@ -348,6 +440,17 @@ func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error
|
|||||||
cmd.Printf("Keychain Item Name: %s\n", keyName)
|
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
|
return nil
|
||||||
|
|
||||||
case "pgp":
|
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("Created PGP unlocker: %s\n", pgpUnlocker.GetID())
|
||||||
cmd.Printf("GPG Key ID: %s\n", gpgKeyID)
|
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
|
return nil
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
@ -66,10 +66,13 @@ func newVaultCreateCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newVaultSelectCmd() *cobra.Command {
|
func newVaultSelectCmd() *cobra.Command {
|
||||||
|
cli := NewCLIInstance()
|
||||||
|
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "select <name>",
|
Use: "select <name>",
|
||||||
Short: "Select a vault as current",
|
Short: "Select a vault as current",
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
|
ValidArgsFunction: getVaultNamesCompletionFunc(cli.fs, cli.stateDir),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
cli := NewCLIInstance()
|
cli := NewCLIInstance()
|
||||||
|
|
||||||
@ -79,11 +82,14 @@ func newVaultSelectCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newVaultImportCmd() *cobra.Command {
|
func newVaultImportCmd() *cobra.Command {
|
||||||
|
cli := NewCLIInstance()
|
||||||
|
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "import <vault-name>",
|
Use: "import <vault-name>",
|
||||||
Short: "Import a mnemonic into a vault",
|
Short: "Import a mnemonic into a vault",
|
||||||
Long: `Import a BIP39 mnemonic phrase into the specified vault (default if not specified).`,
|
Long: `Import a BIP39 mnemonic phrase into the specified vault (default if not specified).`,
|
||||||
Args: cobra.MaximumNArgs(1),
|
Args: cobra.MaximumNArgs(1),
|
||||||
|
ValidArgsFunction: getVaultNamesCompletionFunc(cli.fs, cli.stateDir),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
vaultName := "default"
|
vaultName := "default"
|
||||||
if len(args) > 0 {
|
if len(args) > 0 {
|
||||||
@ -98,13 +104,15 @@ func newVaultImportCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newVaultRemoveCmd() *cobra.Command {
|
func newVaultRemoveCmd() *cobra.Command {
|
||||||
|
cli := NewCLIInstance()
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "remove <name>",
|
Use: "remove <name>",
|
||||||
Aliases: []string{"rm"},
|
Aliases: []string{"rm"},
|
||||||
Short: "Remove a vault",
|
Short: "Remove a vault",
|
||||||
Long: `Remove a vault. Requires --force if the vault contains secrets. Will automatically ` +
|
Long: `Remove a vault. Requires --force if the vault contains secrets. Will automatically ` +
|
||||||
`switch to another vault if removing the currently selected one.`,
|
`switch to another vault if removing the currently selected one.`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
|
ValidArgsFunction: getVaultNamesCompletionFunc(cli.fs, cli.stateDir),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
force, _ := cmd.Flags().GetBool("force")
|
force, _ := cmd.Flags().GetBool("force")
|
||||||
cli := NewCLIInstance()
|
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 {
|
func (cli *Instance) CreateVault(cmd *cobra.Command, name string) error {
|
||||||
secret.Debug("Creating new vault", "name", name, "state_dir", cli.stateDir)
|
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)
|
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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("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
|
return nil
|
||||||
}
|
}
|
||||||
|
@ -33,10 +33,11 @@ func VersionCommands(cli *Instance) *cobra.Command {
|
|||||||
|
|
||||||
// List versions command
|
// List versions command
|
||||||
listCmd := &cobra.Command{
|
listCmd := &cobra.Command{
|
||||||
Use: "list <secret-name>",
|
Use: "list <secret-name>",
|
||||||
Aliases: []string{"ls"},
|
Aliases: []string{"ls"},
|
||||||
Short: "List all versions of a secret",
|
Short: "List all versions of a secret",
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
|
ValidArgsFunction: getSecretNamesCompletionFunc(cli.fs, cli.stateDir),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
return cli.ListVersions(cmd, args[0])
|
return cli.ListVersions(cmd, args[0])
|
||||||
},
|
},
|
||||||
@ -48,6 +49,14 @@ func VersionCommands(cli *Instance) *cobra.Command {
|
|||||||
Short: "Promote a specific version to current",
|
Short: "Promote a specific version to current",
|
||||||
Long: "Updates the current symlink to point to the specified version without modifying timestamps",
|
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
|
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 {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
return cli.PromoteVersion(cmd, args[0], args[1])
|
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",
|
Short: "Remove a specific version of a secret",
|
||||||
Long: "Remove a specific version of a secret. Cannot remove the current version.",
|
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
|
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 {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
return cli.RemoveVersion(cmd, args[0], args[1])
|
return cli.RemoveVersion(cmd, args[0], args[1])
|
||||||
},
|
},
|
||||||
|
@ -161,15 +161,18 @@ func (k *KeychainUnlocker) GetDirectory() string {
|
|||||||
|
|
||||||
// GetID implements Unlocker interface - generates ID from keychain item name
|
// GetID implements Unlocker interface - generates ID from keychain item name
|
||||||
func (k *KeychainUnlocker) GetID() string {
|
func (k *KeychainUnlocker) GetID() string {
|
||||||
// Generate ID using keychain item name
|
// Generate ID in the format YYYY-MM-DD.HH.mm-hostname-keychain
|
||||||
keychainItemName, err := k.GetKeychainItemName()
|
// This matches the passphrase unlocker format
|
||||||
|
hostname, err := os.Hostname()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// The vault metadata is corrupt - this is a fatal error
|
hostname = "unknown"
|
||||||
// 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))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
// Remove implements Unlocker interface - removes the keychain unlocker
|
||||||
|
Loading…
Reference in New Issue
Block a user