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 ", 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 ", 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 ", 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 }