secret/internal/cli/unlockers.go
sneak 75c3d22b62 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
2025-07-26 21:58:57 +02:00

634 lines
19 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"
)
// 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
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"
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 to the current vault.
%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")
}
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 {
cli := NewCLIInstance()
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),
ValidArgsFunction: getUnlockerIDsCompletionFunc(cli.fs, cli.stateDir),
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 {
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()
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 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 {
return err
}
// Load actual unlocker objects to get the proper IDs
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,
IsCurrent: properID == currentUnlockerID,
}
unlockers = append(unlockers, unlockerInfo)
}
if jsonOutput {
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, "", " ")
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
}
// 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())
// 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":
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)
}
// 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":
// 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)
// 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:
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
}