check / check (push) Waiting to run
init, vault create, unlocker add, and add, import and generate secret with --force asked for the mnemonic or passphrase, or unlocked the vault, while holding the state directory lock, so every other command that changes anything waited at the prompt. They now ask and unlock first and lock after. What they checked before asking is checked again under the lock: unlocker add fails if the vault was removed meanwhile and refuses a GPG key added meanwhile, and a forced add fails if another command created the secret meanwhile, rather than ask under the lock. secret.CreatePGPUnlocker now takes the vault, already unlocked. Not done: secret move between vaults still unlocks both under the lock; the issue does not name it. Model: opus-5-5
821 lines
23 KiB
Go
821 lines
23 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"maps"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/spf13/afero"
|
|
"github.com/spf13/cobra"
|
|
"sneak.berlin/go/secret/internal/secret"
|
|
"sneak.berlin/go/secret/internal/vault"
|
|
)
|
|
|
|
// Unlocker type names shared across the CLI
|
|
const (
|
|
unlockerTypePassphrase = "passphrase"
|
|
unlockerTypePGP = "pgp"
|
|
|
|
cmdUseList = "list"
|
|
)
|
|
|
|
// Sentinel errors for unlocker operations
|
|
var (
|
|
errNoGPGSecretKeys = errors.New("no GPG secret keys found")
|
|
errInvalidUnlockerType = errors.New("invalid unlocker type")
|
|
errKeyIDOnlyForPGP = errors.New(
|
|
"--keyid flag is only valid for PGP unlockers")
|
|
// errGPGKeyAlreadyUnlocker carries only the message tail; the caller
|
|
// composes "GPG key <id> is already added as an unlocker".
|
|
errGPGKeyAlreadyUnlocker = errors.New(
|
|
"is already added as an unlocker")
|
|
)
|
|
|
|
// 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) {
|
|
ctx := context.Background()
|
|
|
|
// First try to get the configured default key using gpgconf
|
|
cmd := exec.CommandContext(ctx, "gpgconf", "--list-options", "gpg")
|
|
|
|
output, err := cmd.Output()
|
|
if err == nil {
|
|
defaultKey, parseErr := parseDefaultGPGKey(string(output))
|
|
if parseErr != nil {
|
|
return "", parseErr
|
|
}
|
|
|
|
if defaultKey != "" {
|
|
return defaultKey, nil
|
|
}
|
|
}
|
|
|
|
// If no default key is configured, get the first secret key
|
|
cmd = exec.CommandContext(ctx, "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
|
|
for line := range strings.SplitSeq(string(output), "\n") {
|
|
// 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 "", errNoGPGSecretKeys
|
|
}
|
|
|
|
// parseDefaultGPGKey returns the default-key value in the output of
|
|
// `gpgconf --list-options gpg`, or "" when gpg.conf sets none. gpgconf(1)
|
|
// prints a string value with a leading `"` and percent-escaped.
|
|
func parseDefaultGPGKey(gpgconfOutput string) (string, error) {
|
|
for line := range strings.SplitSeq(gpgconfOutput, "\n") {
|
|
fields := strings.Split(line, ":")
|
|
if len(fields) > 9 && fields[0] == "default-key" && fields[9] != "" {
|
|
// The default key is in field 10 (index 9)
|
|
keyID, err := url.PathUnescape(strings.TrimPrefix(fields[9], `"`))
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to decode gpgconf default-key %s: %w",
|
|
fields[9], err)
|
|
}
|
|
|
|
return keyID, nil
|
|
}
|
|
}
|
|
|
|
return "", nil
|
|
}
|
|
|
|
func newUnlockerCmd() *cobra.Command {
|
|
cmd := &cobra.Command{
|
|
Use: "unlocker",
|
|
Short: "Manage unlockers",
|
|
Long: `Create, list, and remove unlockers for the current vault.`,
|
|
// Cobra checks Args only on a command that has a RunE; without
|
|
// one, an unknown subcommand prints this help and exits 0.
|
|
Args: cobra.NoArgs,
|
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
|
return cmd.Help()
|
|
},
|
|
}
|
|
|
|
cmd.AddCommand(newUnlockerListCmd())
|
|
cmd.AddCommand(newUnlockerAddCmd())
|
|
cmd.AddCommand(newUnlockerRemoveCmd())
|
|
cmd.AddCommand(newUnlockerSelectCmd())
|
|
|
|
return cmd
|
|
}
|
|
|
|
func newUnlockerListCmd() *cobra.Command {
|
|
cmd := &cobra.Command{
|
|
Use: cmdUseList,
|
|
Aliases: []string{"ls"},
|
|
Short: "List unlockers in the current vault",
|
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
|
jsonOutput, _ := cmd.Flags().GetBool("json")
|
|
|
|
cli, err := NewCLIInstance()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to initialize CLI: %w", err)
|
|
}
|
|
|
|
cli.cmd = cmd
|
|
|
|
return cli.UnlockersList(jsonOutput)
|
|
},
|
|
}
|
|
|
|
cmd.Flags().Bool("json", false, "Output in JSON format")
|
|
|
|
return cmd
|
|
}
|
|
|
|
// unlockerAddHelp returns the supported unlocker types list and their
|
|
// descriptions
|
|
func unlockerAddHelp() (string, string) {
|
|
supportedTypes := "passphrase, pgp"
|
|
typeDescriptions := "Available unlocker types:\n" +
|
|
"\n" +
|
|
" passphrase - Traditional password-based encryption\n" +
|
|
" Prompts for a passphrase that will be used to " +
|
|
"encrypt/decrypt the vault's master key.\n" +
|
|
" The passphrase is never stored in plaintext.\n" +
|
|
"\n" +
|
|
" pgp - GNU Privacy Guard (GPG) key-based encryption \n" +
|
|
" Uses your existing GPG key to encrypt/decrypt " +
|
|
"the vault's master key.\n" +
|
|
" Requires gpg to be installed and configured " +
|
|
"with at least one secret key.\n" +
|
|
" Use --keyid to specify a particular key, " +
|
|
"otherwise uses your default GPG key."
|
|
|
|
return supportedTypes, typeDescriptions
|
|
}
|
|
|
|
func newUnlockerAddCmd() *cobra.Command {
|
|
supportedTypes, typeDescriptions := unlockerAddHelp()
|
|
|
|
cmd := &cobra.Command{
|
|
Use: "add <type>",
|
|
Short: "Add a new unlocker",
|
|
Long: "Add a new unlocker to the current vault.\n" +
|
|
"\n" +
|
|
typeDescriptions + "\n" +
|
|
"\n" +
|
|
"Each vault can have multiple unlockers, allowing different " +
|
|
"authentication methods\n" +
|
|
"to access the same vault. This provides flexibility and " +
|
|
"backup access options.",
|
|
Args: cobra.ExactArgs(1),
|
|
ValidArgs: strings.Split(supportedTypes, ", "),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
cli, err := NewCLIInstance()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to initialize CLI: %w", err)
|
|
}
|
|
|
|
destroySecrets := cli.readSecretEnv()
|
|
defer destroySecrets()
|
|
|
|
unlockerType := args[0]
|
|
|
|
// Validate unlocker type
|
|
validTypes := strings.Split(supportedTypes, ", ")
|
|
if !slices.Contains(validTypes, unlockerType) {
|
|
return fmt.Errorf("%w '%s'\n\nSupported types: %s\n\n"+
|
|
"Run 'secret unlocker add --help' for detailed descriptions",
|
|
errInvalidUnlockerType, unlockerType, supportedTypes)
|
|
}
|
|
|
|
// Check if --keyid was used with non-PGP type
|
|
if unlockerType != unlockerTypePGP && cmd.Flags().Changed("keyid") {
|
|
return errKeyIDOnlyForPGP
|
|
}
|
|
|
|
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, err := NewCLIInstance()
|
|
if err != nil {
|
|
log.Fatalf("failed to initialize CLI: %v", err)
|
|
}
|
|
|
|
cmd := &cobra.Command{
|
|
Use: "remove <unlocker-id>",
|
|
Aliases: []string{"rm"},
|
|
Short: "Remove an unlocker",
|
|
Long: `Remove an unlocker from the current vault. Asks for ` +
|
|
`confirmation first, saying whether it is the vault's last ` +
|
|
`unlocker; when stdin is not a terminal, fails unless --force ` +
|
|
`is given. Removing the current unlocker makes another of the ` +
|
|
`vault's unlockers current, if it has one that ` +
|
|
`'secret unlocker select' accepts. 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, err := NewCLIInstance()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to initialize CLI: %w", err)
|
|
}
|
|
|
|
return cli.UnlockersRemove(args[0], force, cmd)
|
|
},
|
|
}
|
|
|
|
cmd.Flags().BoolP("force", "f", false,
|
|
"Remove without asking for confirmation, even the last unlocker")
|
|
|
|
return cmd
|
|
}
|
|
|
|
func newUnlockerSelectCmd() *cobra.Command {
|
|
cli, err := NewCLIInstance()
|
|
if err != nil {
|
|
log.Fatalf("failed to initialize CLI: %v", err)
|
|
}
|
|
|
|
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, err := NewCLIInstance()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to initialize CLI: %w", err)
|
|
}
|
|
|
|
return cli.UnlockerSelect(args[0])
|
|
},
|
|
}
|
|
}
|
|
|
|
// UnlockersList lists unlockers in the current vault, each under its ID,
|
|
// the name of its directory in unlockers.d
|
|
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()
|
|
}
|
|
|
|
unlockerMetadata, err := vlt.ListUnlockers()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Not nil, so that a vault with no unlockers prints [] in JSON, not null
|
|
unlockers := make([]UnlockerInfo, 0, len(unlockerMetadata))
|
|
|
|
for _, unlockerID := range slices.Sorted(maps.Keys(unlockerMetadata)) {
|
|
metadata := unlockerMetadata[unlockerID]
|
|
|
|
unlockers = append(unlockers, UnlockerInfo{
|
|
ID: unlockerID,
|
|
Type: metadata.Type,
|
|
CreatedAt: metadata.CreatedAt,
|
|
Flags: metadata.Flags,
|
|
IsCurrent: unlockerID == currentUnlockerID,
|
|
})
|
|
}
|
|
|
|
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]any{
|
|
"unlockers": unlockers,
|
|
"currentUnlockerID": currentUnlockerID,
|
|
}
|
|
|
|
jsonBytes, err := json.MarshalIndent(output, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal JSON: %w", err)
|
|
}
|
|
|
|
_, _ = fmt.Fprintln(cli.cmd.OutOrStdout(), string(jsonBytes))
|
|
|
|
return nil
|
|
}
|
|
|
|
// printUnlockersTable prints unlockers in a formatted table
|
|
func (cli *Instance) printUnlockersTable(unlockers []UnlockerInfo) error {
|
|
out := cli.cmd.OutOrStdout()
|
|
|
|
if len(unlockers) == 0 {
|
|
_, _ = fmt.Fprintln(out, "No unlockers found in current vault.")
|
|
_, _ = fmt.Fprintln(out, "Run 'secret unlocker add passphrase' to create one.")
|
|
|
|
return nil
|
|
}
|
|
|
|
_, _ = fmt.Fprintf(out, " %-40s %-12s %-20s %s\n",
|
|
"UNLOCKER ID", "TYPE", "CREATED", "FLAGS")
|
|
_, _ = fmt.Fprintf(out, " %-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 = "* "
|
|
}
|
|
|
|
_, _ = fmt.Fprintf(out, "%s%-40s %-12s %-20s %s\n",
|
|
prefix,
|
|
unlocker.ID,
|
|
unlocker.Type,
|
|
unlocker.CreatedAt.Format("2006-01-02 15:04:05"),
|
|
flags)
|
|
}
|
|
|
|
_, _ = fmt.Fprintf(out, "\nTotal: %d unlocker(s)\n", len(unlockers))
|
|
|
|
return nil
|
|
}
|
|
|
|
// UnlockersAdd adds a new unlocker
|
|
func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error {
|
|
switch unlockerType {
|
|
case unlockerTypePassphrase:
|
|
return cli.addPassphraseUnlocker(cmd)
|
|
case unlockerTypePGP:
|
|
return cli.addPGPUnlocker(cmd)
|
|
default:
|
|
supportedTypes := "passphrase, pgp"
|
|
|
|
return fmt.Errorf("%w: %s (supported: %s)",
|
|
errInvalidUnlockerType, unlockerType, supportedTypes)
|
|
}
|
|
}
|
|
|
|
// unlockThenLock gets the long-term key of vlt, the vault an unlocker is
|
|
// added to, then takes the state directory lock and returns the function
|
|
// that releases it. Getting the key can ask for a passphrase, so it comes
|
|
// before the lock, as the question of askThenLock does, and no other
|
|
// command waits while the user answers. Once the lock is held, it fails if
|
|
// another command has removed the vault in the meantime, since writing the
|
|
// unlocker would create the vault's directory again, holding nothing else.
|
|
func (cli *Instance) unlockThenLock(vlt *vault.Vault) (func(), error) {
|
|
_, err := vlt.GetOrDeriveLongTermKey()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get long-term key: %w", err)
|
|
}
|
|
|
|
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
_, err = cli.existingVault(vlt.GetName())
|
|
if err != nil {
|
|
release()
|
|
|
|
return nil, err
|
|
}
|
|
|
|
return release, nil
|
|
}
|
|
|
|
// autoSelectUnlocker selects the newly created unlocker as current,
|
|
// printing a warning if selection fails
|
|
func autoSelectUnlocker(cmd *cobra.Command, vlt *vault.Vault, unlockerID string) {
|
|
err := vlt.SelectUnlocker(unlockerID)
|
|
if err != nil {
|
|
cmd.Printf("Warning: Failed to auto-select new unlocker: %v\n", err)
|
|
} else {
|
|
cmd.Printf("Automatically selected as current unlocker\n")
|
|
}
|
|
}
|
|
|
|
// addPassphraseUnlocker creates a passphrase unlocker in the current vault
|
|
func (cli *Instance) addPassphraseUnlocker(cmd *cobra.Command) error {
|
|
// Get current vault
|
|
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get current vault: %w", err)
|
|
}
|
|
|
|
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
|
|
|
|
// The new passphrase is asked for even when SB_UNLOCK_PASSPHRASE is
|
|
// set, and the variable unlocks the vault. With no terminal to ask on,
|
|
// the new unlocker takes the passphrase in the variable.
|
|
fromVariable := false
|
|
|
|
passphraseBuffer, err := readSecurePassphrase("Enter passphrase for unlocker: ")
|
|
|
|
switch {
|
|
case errors.Is(err, secret.ErrNoTerminal) && cli.UnlockPassphrase != nil:
|
|
passphraseBuffer = cli.UnlockPassphrase
|
|
fromVariable = true
|
|
case err != nil:
|
|
return err
|
|
default:
|
|
defer passphraseBuffer.Destroy()
|
|
}
|
|
|
|
release, err := cli.unlockThenLock(vlt)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer release()
|
|
|
|
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
cmd.Printf("Created passphrase unlocker: %s\n", passphraseUnlocker.GetID())
|
|
|
|
if fromVariable {
|
|
cmd.Printf("The new unlocker takes its passphrase from %s, since "+
|
|
"there is no terminal to ask for a new one on\n",
|
|
secret.EnvUnlockPassphrase)
|
|
}
|
|
|
|
// CreatePassphraseUnlocker has already made it the current unlocker
|
|
cmd.Printf("Automatically selected as current unlocker\n")
|
|
|
|
return nil
|
|
}
|
|
|
|
// addPGPUnlocker creates a PGP unlocker in the current vault
|
|
func (cli *Instance) addPGPUnlocker(cmd *cobra.Command) error {
|
|
// 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)
|
|
}
|
|
|
|
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get current vault: %w", err)
|
|
}
|
|
|
|
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
|
|
|
|
// Resolve the GPG key ID to its fingerprint, once: the duplicate check
|
|
// and the new unlocker's metadata both use this result
|
|
fingerprint, err := secret.ResolveGPGKeyFingerprint(gpgKeyID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to resolve GPG key fingerprint: %w", err)
|
|
}
|
|
|
|
// Refuse a key that is already an unlocker before asking for anything,
|
|
// and again once the lock is held, since another command may have added
|
|
// it in the meantime
|
|
err = cli.checkPGPKeyNotAdded(vlt, gpgKeyID, fingerprint)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
release, err := cli.unlockThenLock(vlt)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer release()
|
|
|
|
err = cli.checkPGPKeyNotAdded(vlt, gpgKeyID, fingerprint)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
pgpUnlocker, err := secret.CreatePGPUnlocker(vlt, gpgKeyID, fingerprint)
|
|
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
|
|
autoSelectUnlocker(cmd, vlt, pgpUnlocker.GetID())
|
|
|
|
return nil
|
|
}
|
|
|
|
// UnlockersRemove removes an unlocker from the current vault, after asking
|
|
// the user to confirm unless force is set.
|
|
func (cli *Instance) UnlockersRemove(
|
|
unlockerID string, force bool, cmd *cobra.Command,
|
|
) error {
|
|
var found unlockerToRemove
|
|
|
|
release, err := cli.askThenLock(cmd, force, func() (string, error) {
|
|
var err error
|
|
|
|
found, err = cli.findUnlockerToRemove(unlockerID)
|
|
|
|
return found.question, err
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer release()
|
|
|
|
return cli.removeUnlocker(unlockerID, found, cmd)
|
|
}
|
|
|
|
// unlockerToRemove is what removing an unlocker removes, as
|
|
// findUnlockerToRemove found it.
|
|
type unlockerToRemove struct {
|
|
vlt *vault.Vault
|
|
// last is set when the unlocker counts as the vault's last one, and
|
|
// secrets is then the number of secrets in the vault.
|
|
last bool
|
|
secrets int
|
|
// question names what is removed, for the user to confirm.
|
|
question string
|
|
}
|
|
|
|
// findUnlockerToRemove checks that the current vault has the unlocker and
|
|
// finds whether it is the vault's last one.
|
|
func (cli *Instance) findUnlockerToRemove(
|
|
unlockerID string,
|
|
) (unlockerToRemove, error) {
|
|
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
|
if err != nil {
|
|
return unlockerToRemove{}, err
|
|
}
|
|
|
|
exists, err := vlt.HasUnlocker(unlockerID)
|
|
if err != nil {
|
|
return unlockerToRemove{}, err
|
|
}
|
|
|
|
if !exists {
|
|
return unlockerToRemove{}, fmt.Errorf("unlocker with ID %s %w",
|
|
unlockerID, vault.ErrUnlockerNotFound)
|
|
}
|
|
|
|
// Get list of unlockers. It leaves out a directory whose metadata file
|
|
// is missing or cannot be checked for, read or parsed.
|
|
unlockers, err := vlt.ListUnlockers()
|
|
if err != nil {
|
|
return unlockerToRemove{},
|
|
fmt.Errorf("failed to list unlockers: %w", err)
|
|
}
|
|
|
|
vaultDir, err := vlt.GetDirectory()
|
|
if err != nil {
|
|
return unlockerToRemove{},
|
|
fmt.Errorf("failed to get vault directory: %w", err)
|
|
}
|
|
|
|
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
|
|
|
found := unlockerToRemove{
|
|
vlt: vlt,
|
|
question: fmt.Sprintf("Permanently remove unlocker '%s' from vault "+
|
|
"'%s'? It is not the vault's last unlocker.",
|
|
unlockerID, vlt.GetName()),
|
|
}
|
|
|
|
if len(unlockers) == 1 {
|
|
_, found.last = unlockers[unlockerID]
|
|
}
|
|
|
|
// unlockerID may instead name a directory left out of the list. If its
|
|
// metadata file is missing or corrupt it is not a working unlocker, so
|
|
// removing it never removes the last one. If the file cannot be checked
|
|
// for or read, the unlocker may be the only working one, so removing it
|
|
// counts as removing the last unlocker.
|
|
if metadataUnreadable(cli.fs, filepath.Join(unlockersDir, unlockerID)) {
|
|
found.last = true
|
|
}
|
|
|
|
if found.last {
|
|
found.secrets, err = vlt.NumSecrets()
|
|
if err != nil {
|
|
return unlockerToRemove{},
|
|
fmt.Errorf("failed to count secrets: %w", err)
|
|
}
|
|
|
|
found.question = fmt.Sprintf("Permanently remove unlocker '%s', "+
|
|
"the last unlocker of vault '%s', which holds %d secret(s)? "+
|
|
"Without an unlocker the vault opens only with its mnemonic.",
|
|
unlockerID, vlt.GetName(), found.secrets)
|
|
}
|
|
|
|
return found, nil
|
|
}
|
|
|
|
// removeUnlocker removes the unlocker that findUnlockerToRemove found. The
|
|
// caller holds the state directory lock.
|
|
func (cli *Instance) removeUnlocker(
|
|
unlockerID string, found unlockerToRemove, cmd *cobra.Command,
|
|
) error {
|
|
if found.last && found.secrets > 0 {
|
|
cmd.Println("WARNING: Removing the last unlocker. You MUST " +
|
|
"have your mnemonic phrase to access this vault again!")
|
|
}
|
|
|
|
newCurrentID, err := found.vlt.RemoveUnlocker(unlockerID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
cmd.Printf("Removed unlocker '%s'\n", unlockerID)
|
|
|
|
if newCurrentID != "" {
|
|
cmd.Printf("Switched current unlocker to '%s'\n", newCurrentID)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// metadataUnreadable reports whether checking for or reading the metadata
|
|
// file in the unlocker directory unlockerDir fails. A missing file is not
|
|
// a failure.
|
|
func metadataUnreadable(fs afero.Fs, unlockerDir string) bool {
|
|
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
|
|
|
|
exists, err := afero.Exists(fs, metadataPath)
|
|
if err == nil && exists {
|
|
_, err = afero.ReadFile(fs, metadataPath)
|
|
}
|
|
|
|
return err != nil
|
|
}
|
|
|
|
// UnlockerSelect selects an unlocker as current
|
|
func (cli *Instance) UnlockerSelect(unlockerID string) error {
|
|
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer release()
|
|
|
|
// Get current vault
|
|
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return vlt.SelectUnlocker(unlockerID)
|
|
}
|
|
|
|
// checkPGPKeyNotAdded fails when vlt already has a PGP unlocker for the GPG
|
|
// key gpgKeyID, whose fingerprint is fingerprint, or when that cannot be
|
|
// checked.
|
|
func (cli *Instance) checkPGPKeyNotAdded(
|
|
vlt *vault.Vault, gpgKeyID, fingerprint string,
|
|
) error {
|
|
exists, err := cli.pgpUnlockerExists(vlt, fingerprint)
|
|
if err != nil {
|
|
return fmt.Errorf(
|
|
"could not check whether GPG key %s is already an unlocker: %w",
|
|
gpgKeyID, err,
|
|
)
|
|
}
|
|
|
|
if exists {
|
|
return fmt.Errorf("GPG key %s %w", gpgKeyID, errGPGKeyAlreadyUnlocker)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// pgpUnlockerExists reports whether the vault already has a PGP unlocker
|
|
// for the GPG key with the given fingerprint. It returns an error, and no
|
|
// answer, when unlockers.d or an unlocker's metadata file cannot be read;
|
|
// the caller must then not create the unlocker. It reads unlockers.d itself
|
|
// because vault.ListUnlockers skips an unlocker it cannot read, which suits
|
|
// `unlocker list` but not this check: the skipped unlocker may be the
|
|
// duplicate. A directory whose metadata file is missing or corrupt is not
|
|
// a working unlocker and is passed over.
|
|
func (cli *Instance) pgpUnlockerExists(
|
|
vlt *vault.Vault, fingerprint string,
|
|
) (bool, error) {
|
|
vaultDir, err := vlt.GetDirectory()
|
|
if err != nil {
|
|
return false, fmt.Errorf("failed to get vault directory: %w", err)
|
|
}
|
|
|
|
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
|
|
|
entries, err := afero.ReadDir(cli.fs, unlockersDir)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return false, nil
|
|
}
|
|
|
|
if err != nil {
|
|
return false, fmt.Errorf(
|
|
"failed to read unlockers directory %s: %w", unlockersDir, err,
|
|
)
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() {
|
|
continue
|
|
}
|
|
|
|
unlockerDir := filepath.Join(unlockersDir, entry.Name())
|
|
|
|
metadataBytes, err := afero.ReadFile(
|
|
cli.fs, filepath.Join(unlockerDir, "unlocker-metadata.json"))
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
continue
|
|
}
|
|
|
|
if err != nil {
|
|
return false, fmt.Errorf(
|
|
"failed to read metadata of unlocker %s: %w", unlockerDir, err,
|
|
)
|
|
}
|
|
|
|
var metadata secret.PGPUnlockerMetadata
|
|
|
|
err = json.Unmarshal(metadataBytes, &metadata)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
if metadata.Type == unlockerTypePGP && metadata.GPGKeyID == fingerprint {
|
|
return true, nil
|
|
}
|
|
}
|
|
|
|
return false, nil
|
|
}
|