- Restrict --keyid flag to PGP unlocker type only - Add validation to prevent --keyid usage with non-PGP unlockers - Implement 'secret move' command with 'mv' and 'rename' aliases - Add comprehensive tests for move functionality - Update documentation to reflect optional nature of --keyid for PGP The move command allows renaming or moving secrets within a vault while preserving all versions and metadata. It fails if the destination already exists to prevent accidental overwrites.
524 lines
14 KiB
Go
524 lines
14 KiB
Go
package cli
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.eeqj.de/sneak/secret/internal/secret"
|
|
"git.eeqj.de/sneak/secret/internal/vault"
|
|
"github.com/awnumar/memguard"
|
|
"github.com/spf13/afero"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
// getDefaultGPGKey returns the default GPG key ID if available
|
|
func getDefaultGPGKey() (string, error) {
|
|
// First try to get the configured default key using gpgconf
|
|
cmd := exec.Command("gpgconf", "--list-options", "gpg")
|
|
output, err := cmd.Output()
|
|
if err == nil {
|
|
lines := strings.Split(string(output), "\n")
|
|
for _, line := range lines {
|
|
fields := strings.Split(line, ":")
|
|
if len(fields) > 9 && fields[0] == "default-key" && fields[9] != "" {
|
|
// The default key is in field 10 (index 9)
|
|
return fields[9], nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// If no default key is configured, get the first secret key
|
|
cmd = exec.Command("gpg", "--list-secret-keys", "--with-colons")
|
|
output, err = cmd.Output()
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to list GPG keys: %w", err)
|
|
}
|
|
|
|
// Parse output to find the first usable secret key
|
|
lines := strings.Split(string(output), "\n")
|
|
for _, line := range lines {
|
|
// sec line indicates a secret key
|
|
if strings.HasPrefix(line, "sec:") {
|
|
fields := strings.Split(line, ":")
|
|
// Field 5 contains the key ID
|
|
if len(fields) > 4 && fields[4] != "" {
|
|
return fields[4], nil
|
|
}
|
|
}
|
|
}
|
|
|
|
return "", fmt.Errorf("no GPG secret keys found")
|
|
}
|
|
|
|
func newUnlockerCmd() *cobra.Command {
|
|
cmd := &cobra.Command{
|
|
Use: "unlocker",
|
|
Short: "Manage unlockers",
|
|
Long: `Create, list, and remove unlockers for the current vault.`,
|
|
}
|
|
|
|
cmd.AddCommand(newUnlockerListCmd())
|
|
cmd.AddCommand(newUnlockerAddCmd())
|
|
cmd.AddCommand(newUnlockerRemoveCmd())
|
|
cmd.AddCommand(newUnlockerSelectCmd())
|
|
|
|
return cmd
|
|
}
|
|
|
|
func newUnlockerListCmd() *cobra.Command {
|
|
cmd := &cobra.Command{
|
|
Use: "list",
|
|
Aliases: []string{"ls"},
|
|
Short: "List unlockers in the current vault",
|
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
|
jsonOutput, _ := cmd.Flags().GetBool("json")
|
|
|
|
cli := NewCLIInstance()
|
|
cli.cmd = cmd
|
|
|
|
return cli.UnlockersList(jsonOutput)
|
|
},
|
|
}
|
|
|
|
cmd.Flags().Bool("json", false, "Output in JSON format")
|
|
|
|
return cmd
|
|
}
|
|
|
|
func newUnlockerAddCmd() *cobra.Command {
|
|
// Build the supported types list based on platform
|
|
supportedTypes := "passphrase, pgp"
|
|
if runtime.GOOS == "darwin" {
|
|
supportedTypes = "passphrase, keychain, pgp"
|
|
}
|
|
|
|
cmd := &cobra.Command{
|
|
Use: "add <type>",
|
|
Short: "Add a new unlocker",
|
|
Long: fmt.Sprintf(`Add a new unlocker of the specified type (%s).
|
|
|
|
For PGP unlockers, you can optionally specify a GPG key ID with --keyid.
|
|
If not specified, the default GPG key will be used.`, supportedTypes),
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
cli := NewCLIInstance()
|
|
unlockerType := args[0]
|
|
|
|
// 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")
|
|
}
|
|
|
|
return cli.UnlockersAdd(unlockerType, cmd)
|
|
},
|
|
}
|
|
|
|
cmd.Flags().String("keyid", "", "GPG key ID for PGP unlockers (optional, uses default key if not specified)")
|
|
|
|
return cmd
|
|
}
|
|
|
|
func newUnlockerRemoveCmd() *cobra.Command {
|
|
cmd := &cobra.Command{
|
|
Use: "remove <unlocker-id>",
|
|
Aliases: []string{"rm"},
|
|
Short: "Remove an unlocker",
|
|
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 ` +
|
|
`will be permanently inaccessible.`,
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
force, _ := cmd.Flags().GetBool("force")
|
|
cli := NewCLIInstance()
|
|
|
|
return cli.UnlockersRemove(args[0], force, cmd)
|
|
},
|
|
}
|
|
|
|
cmd.Flags().BoolP("force", "f", false, "Force removal of last unlocker even if vault has secrets")
|
|
|
|
return cmd
|
|
}
|
|
|
|
func newUnlockerSelectCmd() *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "select <unlocker-id>",
|
|
Short: "Select an unlocker as current",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(_ *cobra.Command, args []string) error {
|
|
cli := NewCLIInstance()
|
|
|
|
return cli.UnlockerSelect(args[0])
|
|
},
|
|
}
|
|
}
|
|
|
|
// UnlockersList lists unlockers in the current vault
|
|
func (cli *Instance) UnlockersList(jsonOutput bool) error {
|
|
// Get current vault
|
|
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Get the metadata first
|
|
unlockerMetadataList, err := vlt.ListUnlockers()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// 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
|
|
vaultDir, err := vlt.GetDirectory()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
// Find the unlocker directory by type and created time
|
|
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
|
files, err := afero.ReadDir(cli.fs, unlockersDir)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
var unlocker secret.Unlocker
|
|
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(cli.fs, metadataPath)
|
|
if err != nil {
|
|
continue // FIXME this error needs to be handled
|
|
}
|
|
|
|
var diskMetadata secret.UnlockerMetadata
|
|
if err := json.Unmarshal(metadataBytes, &diskMetadata); err != nil {
|
|
continue // FIXME this error needs to be handled
|
|
}
|
|
|
|
// Match by type and creation time
|
|
if diskMetadata.Type == metadata.Type && diskMetadata.CreatedAt.Equal(metadata.CreatedAt) {
|
|
// Create the appropriate unlocker instance
|
|
switch metadata.Type {
|
|
case "passphrase":
|
|
unlocker = secret.NewPassphraseUnlocker(cli.fs, unlockerDir, diskMetadata)
|
|
case "keychain":
|
|
unlocker = secret.NewKeychainUnlocker(cli.fs, unlockerDir, diskMetadata)
|
|
case "pgp":
|
|
unlocker = secret.NewPGPUnlocker(cli.fs, unlockerDir, diskMetadata)
|
|
}
|
|
|
|
break
|
|
}
|
|
}
|
|
|
|
// Get the proper ID using the unlocker's ID() method
|
|
var properID string
|
|
if unlocker != nil {
|
|
properID = unlocker.GetID()
|
|
} else {
|
|
// Generate ID as fallback
|
|
properID = fmt.Sprintf("%s-%s", metadata.CreatedAt.Format("2006-01-02.15.04"), metadata.Type)
|
|
}
|
|
|
|
unlockerInfo := UnlockerInfo{
|
|
ID: properID,
|
|
Type: metadata.Type,
|
|
CreatedAt: metadata.CreatedAt,
|
|
Flags: metadata.Flags,
|
|
}
|
|
unlockers = append(unlockers, unlockerInfo)
|
|
}
|
|
|
|
if jsonOutput {
|
|
// JSON output
|
|
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 nil
|
|
}
|
|
|
|
// UnlockersAdd adds a new unlocker
|
|
func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error {
|
|
// Build the supported types list based on platform
|
|
supportedTypes := "passphrase, pgp"
|
|
if runtime.GOOS == "darwin" {
|
|
supportedTypes = "passphrase, keychain, pgp"
|
|
}
|
|
|
|
switch unlockerType {
|
|
case "passphrase":
|
|
// Get current vault
|
|
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get current vault: %w", err)
|
|
}
|
|
|
|
// For passphrase unlockers, we don't need the vault to be unlocked
|
|
// The CreatePassphraseUnlocker method will handle getting the long-term key
|
|
|
|
// Check if passphrase is set in environment variable
|
|
var passphraseBuffer *memguard.LockedBuffer
|
|
if envPassphrase := os.Getenv(secret.EnvUnlockPassphrase); envPassphrase != "" {
|
|
passphraseBuffer = memguard.NewBufferFromBytes([]byte(envPassphrase))
|
|
} else {
|
|
// 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()
|
|
|
|
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
cmd.Printf("Created passphrase unlocker: %s\n", passphraseUnlocker.GetID())
|
|
|
|
return nil
|
|
|
|
case "keychain":
|
|
if runtime.GOOS != "darwin" {
|
|
return fmt.Errorf("keychain unlockers are only supported on macOS")
|
|
}
|
|
|
|
keychainUnlocker, err := secret.CreateKeychainUnlocker(cli.fs, cli.stateDir)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create macOS Keychain unlocker: %w", err)
|
|
}
|
|
|
|
cmd.Printf("Created macOS Keychain unlocker: %s\n", keychainUnlocker.GetID())
|
|
if keyName, err := keychainUnlocker.GetKeychainItemName(); err == nil {
|
|
cmd.Printf("Keychain Item Name: %s\n", keyName)
|
|
}
|
|
|
|
return nil
|
|
|
|
case "pgp":
|
|
// Get GPG key ID from flag, environment, or default key
|
|
var gpgKeyID string
|
|
if flagKeyID, _ := cmd.Flags().GetString("keyid"); flagKeyID != "" {
|
|
gpgKeyID = flagKeyID
|
|
} else if envKeyID := os.Getenv(secret.EnvGPGKeyID); envKeyID != "" {
|
|
gpgKeyID = envKeyID
|
|
} else {
|
|
// Try to get the default GPG key
|
|
defaultKeyID, err := getDefaultGPGKey()
|
|
if err != nil {
|
|
return fmt.Errorf("no GPG key specified and no default key found: %w", err)
|
|
}
|
|
gpgKeyID = defaultKeyID
|
|
cmd.Printf("Using default GPG key: %s\n", gpgKeyID)
|
|
}
|
|
|
|
// Check if this key is already added as an unlocker
|
|
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get current vault: %w", err)
|
|
}
|
|
|
|
// Resolve the GPG key ID to its fingerprint
|
|
fingerprint, err := secret.ResolveGPGKeyFingerprint(gpgKeyID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to resolve GPG key fingerprint: %w", err)
|
|
}
|
|
|
|
// Check if this GPG key is already added
|
|
expectedID := fmt.Sprintf("pgp-%s", fingerprint)
|
|
if err := cli.checkUnlockerExists(vlt, expectedID); err != nil {
|
|
return fmt.Errorf("GPG key %s is already added as an unlocker", gpgKeyID)
|
|
}
|
|
|
|
pgpUnlocker, err := secret.CreatePGPUnlocker(cli.fs, cli.stateDir, gpgKeyID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
cmd.Printf("Created PGP unlocker: %s\n", pgpUnlocker.GetID())
|
|
cmd.Printf("GPG Key ID: %s\n", gpgKeyID)
|
|
|
|
return nil
|
|
|
|
default:
|
|
return fmt.Errorf("unsupported unlocker type: %s (supported: %s)", unlockerType, supportedTypes)
|
|
}
|
|
}
|
|
|
|
// UnlockersRemove removes an unlocker with safety checks
|
|
func (cli *Instance) UnlockersRemove(unlockerID string, force bool, cmd *cobra.Command) error {
|
|
// Get current vault
|
|
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Get list of unlockers
|
|
unlockers, err := vlt.ListUnlockers()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to list unlockers: %w", err)
|
|
}
|
|
|
|
// Check if we're removing the last unlocker
|
|
if len(unlockers) == 1 {
|
|
// Check if vault has secrets
|
|
numSecrets, err := vlt.NumSecrets()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to count secrets: %w", err)
|
|
}
|
|
|
|
if numSecrets > 0 && !force {
|
|
cmd.Println("ERROR: Cannot remove the last unlocker when the vault contains secrets.")
|
|
cmd.Println("WARNING: Without unlockers, you MUST have your mnemonic phrase to decrypt the vault.")
|
|
cmd.Println("If you want to proceed anyway, use --force")
|
|
|
|
return fmt.Errorf("refusing to remove last unlocker")
|
|
}
|
|
|
|
if numSecrets > 0 && force {
|
|
cmd.Println("WARNING: Removing the last unlocker. You MUST have your mnemonic phrase to access this vault again!")
|
|
}
|
|
}
|
|
|
|
// Remove the unlocker
|
|
if err := vlt.RemoveUnlocker(unlockerID); err != nil {
|
|
return err
|
|
}
|
|
|
|
cmd.Printf("Removed unlocker '%s'\n", unlockerID)
|
|
|
|
return nil
|
|
}
|
|
|
|
// UnlockerSelect selects an unlocker as current
|
|
func (cli *Instance) UnlockerSelect(unlockerID string) error {
|
|
// Get current vault
|
|
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return vlt.SelectUnlocker(unlockerID)
|
|
}
|
|
|
|
// checkUnlockerExists checks if an unlocker with the given ID exists
|
|
func (cli *Instance) checkUnlockerExists(vlt *vault.Vault, unlockerID string) error {
|
|
// Get the list of unlockers and check if any match the ID
|
|
unlockers, err := vlt.ListUnlockers()
|
|
if err != nil {
|
|
return nil // If we can't list unlockers, assume it doesn't exist
|
|
}
|
|
|
|
// Get vault directory to construct unlocker instances
|
|
vaultDir, err := vlt.GetDirectory()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
// Check each unlocker's ID
|
|
for _, metadata := range unlockers {
|
|
// Construct the unlocker based on type to get its ID
|
|
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
|
files, err := afero.ReadDir(cli.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 matches our metadata
|
|
metadataBytes, err := afero.ReadFile(cli.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) {
|
|
var unlocker secret.Unlocker
|
|
switch metadata.Type {
|
|
case "passphrase":
|
|
unlocker = secret.NewPassphraseUnlocker(cli.fs, unlockerDir, diskMetadata)
|
|
case "keychain":
|
|
unlocker = secret.NewKeychainUnlocker(cli.fs, unlockerDir, diskMetadata)
|
|
case "pgp":
|
|
unlocker = secret.NewPGPUnlocker(cli.fs, unlockerDir, diskMetadata)
|
|
}
|
|
|
|
if unlocker != nil && unlocker.GetID() == unlockerID {
|
|
return fmt.Errorf("unlocker already exists")
|
|
}
|
|
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|