package cli import ( "context" "encoding/json" "errors" "fmt" "log" "maps" "net/url" "os" "os/exec" "path/filepath" "slices" "strings" "time" "filippo.io/age" "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 is already added as an unlocker". errGPGKeyAlreadyUnlocker = errors.New( "is already added as an unlocker") // errVaultCreatedAgain carries only the message tail; the caller // composes "vault '' was removed and created again ...". errVaultCreatedAgain = errors.New("was removed and created again " + "with another key while it was being unlocked; nothing was added") ) // 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 ", 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 ", 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 ", 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. // It also fails if the vault was removed and created again with another // long-term key, which the unlocker would then hold instead of the new // vault's own. func (cli *Instance) unlockThenLock(vlt *vault.Vault) (func(), error) { ltIdentity, 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.checkLongTermKey(vlt, ltIdentity) if err != nil { release() return nil, err } return release, nil } // checkLongTermKey checks, for the vault vlt unlocked as ltIdentity before // the state directory lock was taken, that vlt still exists and that its // long-term public key is still that of ltIdentity. Another command may have // removed the vault in the meantime, or removed it and created it again with // another long-term key. The caller holds the lock. func (cli *Instance) checkLongTermKey( vlt *vault.Vault, ltIdentity *age.X25519Identity, ) error { _, err := cli.existingVault(vlt.GetName()) if err != nil { return err } vaultDir, _ := vlt.GetDirectory() ltPubKey, err := afero.ReadFile(cli.fs, filepath.Join(vaultDir, "pub.age")) if err != nil { return fmt.Errorf("failed to read long-term public key: %w", err) } if string(ltPubKey) != ltIdentity.Recipient().String() { return fmt.Errorf("vault '%s' %w", vlt.GetName(), errVaultCreatedAgain) } return 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 }