All checks were successful
check / check (push) Successful in 1m8s
- Replace .golangci.yml with the canonical strict config (all linters enabled except the standard disable list; lll 88, funlen 80/50, cyclop 15, dupl 100; test files now linted) - Pin the Dockerfile lint stage to golangci/golangci-lint:v2.12.2 by tag and digest (Debian-based) - Fix all ~1550 findings surfaced by the new config: line wrapping, wsl_v5/nlreturn blank lines, noinlineerr splits, err113 sentinel errors, perfsprint/modernize rewrites, goconst constants, thelper, testifylint, noctx CommandContext, testpackage conversions, t.Parallel() where safe, and complexity/dupl helper extraction - Record the change and follow-up items in TODO.md
792 lines
23 KiB
Go
792 lines
23 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"slices"
|
|
"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"
|
|
)
|
|
|
|
// Unlocker type names and platform identifiers shared across the CLI
|
|
const (
|
|
unlockerTypePassphrase = "passphrase"
|
|
unlockerTypeKeychain = "keychain"
|
|
unlockerTypePGP = "pgp"
|
|
unlockerTypeSecureEnclave = "secure-enclave"
|
|
|
|
platformDarwin = "darwin"
|
|
|
|
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")
|
|
errKeychainMacOSOnly = errors.New(
|
|
"keychain unlockers are only supported on macOS")
|
|
errSecureEnclaveMacOSOnly = errors.New(
|
|
"secure enclave unlockers are only supported on macOS")
|
|
errGPGKeyAlreadyUnlocker = errors.New(
|
|
"GPG key is already added as an unlocker")
|
|
errUnsupportedUnlockerType = errors.New("unsupported unlocker type")
|
|
errLastUnlocker = errors.New("refusing to remove last unlocker")
|
|
errUnlockerExists = errors.New("unlocker already exists")
|
|
)
|
|
|
|
// 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 {
|
|
for line := range strings.SplitSeq(string(output), "\n") {
|
|
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.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
|
|
}
|
|
|
|
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: 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 for the current platform
|
|
func unlockerAddHelp() (string, string) {
|
|
// Build the supported types list based on platform
|
|
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."
|
|
|
|
if runtime.GOOS == platformDarwin {
|
|
supportedTypes = "passphrase, keychain, pgp, secure-enclave"
|
|
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" +
|
|
" keychain - macOS Keychain integration (macOS only)\n" +
|
|
" Stores the vault's master key in the " +
|
|
"macOS Keychain, protected by your login password.\n" +
|
|
" Automatically unlocks when your Keychain " +
|
|
"is unlocked (e.g., after login).\n" +
|
|
" Provides seamless integration with macOS " +
|
|
"security features like Touch ID.\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.\n" +
|
|
"\n" +
|
|
" secure-enclave - Apple Secure Enclave hardware protection " +
|
|
"(macOS only)\n" +
|
|
" Stores the vault's master key encrypted " +
|
|
"by a non-exportable P-256 key\n" +
|
|
" held in the Secure Enclave. The key " +
|
|
"never leaves the hardware.\n" +
|
|
" Uses ECIES encryption; decryption is " +
|
|
"performed inside the SE."
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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. 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, 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,
|
|
"Force removal of last unlocker even if vault has secrets")
|
|
|
|
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])
|
|
},
|
|
}
|
|
}
|
|
|
|
// unlockerIDFromDir constructs an unlocker of the given metadata type
|
|
// rooted at unlockerDir and returns its ID. Returns "" for unknown types
|
|
// and, when includeSecureEnclave is false, for secure enclave unlockers.
|
|
func unlockerIDFromDir(
|
|
fs afero.Fs, unlockerDir string, metadata secret.UnlockerMetadata,
|
|
includeSecureEnclave bool,
|
|
) string {
|
|
// Create the appropriate unlocker instance
|
|
var unlocker secret.Unlocker
|
|
|
|
switch metadata.Type {
|
|
case unlockerTypePassphrase:
|
|
unlocker = secret.NewPassphraseUnlocker(fs, unlockerDir, metadata)
|
|
case unlockerTypeKeychain:
|
|
unlocker = secret.NewKeychainUnlocker(fs, unlockerDir, metadata)
|
|
case unlockerTypePGP:
|
|
unlocker = secret.NewPGPUnlocker(fs, unlockerDir, metadata)
|
|
case unlockerTypeSecureEnclave:
|
|
if includeSecureEnclave {
|
|
unlocker = secret.NewSecureEnclaveUnlocker(fs, unlockerDir, metadata)
|
|
}
|
|
}
|
|
|
|
if unlocker == nil {
|
|
return ""
|
|
}
|
|
|
|
return unlocker.GetID()
|
|
}
|
|
|
|
// findUnlockerIDByMetadata scans unlockersDir for the directory whose
|
|
// stored metadata matches the given type and creation time and returns
|
|
// the matching unlocker's ID. Returns "" if no match is found.
|
|
func findUnlockerIDByMetadata(
|
|
fs afero.Fs, unlockersDir string, metadata secret.UnlockerMetadata,
|
|
includeSecureEnclave bool,
|
|
) string {
|
|
files, err := afero.ReadDir(fs, unlockersDir)
|
|
if err != nil {
|
|
secret.Warn("Could not read unlockers directory", "error", err)
|
|
|
|
return ""
|
|
}
|
|
|
|
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(fs, metadataPath)
|
|
if err != nil {
|
|
secret.Warn("Could not read unlocker metadata file",
|
|
"path", metadataPath, "error", err)
|
|
|
|
continue
|
|
}
|
|
|
|
var diskMetadata secret.UnlockerMetadata
|
|
|
|
err = json.Unmarshal(metadataBytes, &diskMetadata)
|
|
if err != nil {
|
|
secret.Warn("Could not parse unlocker metadata file",
|
|
"path", metadataPath, "error", err)
|
|
|
|
continue
|
|
}
|
|
|
|
// Match by type and creation time
|
|
if diskMetadata.Type == metadata.Type &&
|
|
diskMetadata.CreatedAt.Equal(metadata.CreatedAt) {
|
|
return unlockerIDFromDir(fs, unlockerDir, diskMetadata,
|
|
includeSecureEnclave)
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// 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 {
|
|
secret.Warn("Could not get vault directory while listing unlockers",
|
|
"error", err)
|
|
|
|
continue
|
|
}
|
|
|
|
// Find the unlocker directory by type and created time
|
|
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
|
unlockerID := findUnlockerIDByMetadata(cli.fs, unlockersDir, metadata, true)
|
|
|
|
// Get the proper ID using the unlocker's ID() method
|
|
var properID string
|
|
if unlockerID != "" {
|
|
properID = unlockerID
|
|
} else {
|
|
// Generate ID as fallback
|
|
properID = fmt.Sprintf("%s-%s",
|
|
metadata.CreatedAt.Format("2006-01-02.15.04"), metadata.Type)
|
|
secret.Warn("Could not create unlocker instance, using fallback ID",
|
|
"fallback_id", properID, "type", 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]any{
|
|
"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 {
|
|
switch unlockerType {
|
|
case unlockerTypePassphrase:
|
|
return cli.addPassphraseUnlocker(cmd)
|
|
case unlockerTypeKeychain:
|
|
return cli.addKeychainUnlocker(cmd)
|
|
case unlockerTypeSecureEnclave:
|
|
return cli.addSecureEnclaveUnlocker(cmd)
|
|
case unlockerTypePGP:
|
|
return cli.addPGPUnlocker(cmd)
|
|
default:
|
|
// Build the supported types list based on platform
|
|
supportedTypes := "passphrase, pgp"
|
|
if runtime.GOOS == platformDarwin {
|
|
supportedTypes = "passphrase, keychain, pgp, secure-enclave"
|
|
}
|
|
|
|
return fmt.Errorf("%w: %s (supported: %s)",
|
|
errUnsupportedUnlockerType, unlockerType, supportedTypes)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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
|
|
autoSelectUnlocker(cmd, vlt, passphraseUnlocker.GetID())
|
|
|
|
return nil
|
|
}
|
|
|
|
// addKeychainUnlocker creates a macOS Keychain unlocker in the current vault
|
|
func (cli *Instance) addKeychainUnlocker(cmd *cobra.Command) error {
|
|
if runtime.GOOS != platformDarwin {
|
|
return errKeychainMacOSOnly
|
|
}
|
|
|
|
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())
|
|
|
|
keyName, err := keychainUnlocker.GetKeychainItemName()
|
|
if 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)
|
|
}
|
|
|
|
autoSelectUnlocker(cmd, vlt, keychainUnlocker.GetID())
|
|
|
|
return nil
|
|
}
|
|
|
|
// addSecureEnclaveUnlocker creates a Secure Enclave unlocker in the
|
|
// current vault
|
|
func (cli *Instance) addSecureEnclaveUnlocker(cmd *cobra.Command) error {
|
|
if runtime.GOOS != platformDarwin {
|
|
return errSecureEnclaveMacOSOnly
|
|
}
|
|
|
|
seUnlocker, err := secret.CreateSecureEnclaveUnlocker(cli.fs, cli.stateDir)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create Secure Enclave unlocker: %w", err)
|
|
}
|
|
|
|
cmd.Printf("Created Secure Enclave unlocker: %s\n", seUnlocker.GetID())
|
|
|
|
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get current vault: %w", err)
|
|
}
|
|
|
|
autoSelectUnlocker(cmd, vlt, seUnlocker.GetID())
|
|
|
|
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)
|
|
}
|
|
|
|
// 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 := "pgp-" + fingerprint
|
|
|
|
err = cli.checkUnlockerExists(vlt, expectedID)
|
|
if err != nil {
|
|
return fmt.Errorf("%w: %s", errGPGKeyAlreadyUnlocker, 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
|
|
autoSelectUnlocker(cmd, vlt, pgpUnlocker.GetID())
|
|
|
|
return nil
|
|
}
|
|
|
|
// 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 errLastUnlocker
|
|
}
|
|
|
|
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
|
|
err = vlt.RemoveUnlocker(unlockerID)
|
|
if 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 {
|
|
secret.Warn("Could not list unlockers during duplicate check", "error", err)
|
|
|
|
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 {
|
|
secret.Warn("Could not get vault directory during duplicate check",
|
|
"error", err)
|
|
|
|
return nil
|
|
}
|
|
|
|
// Check each unlocker's ID
|
|
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
|
|
|
for _, metadata := range unlockers {
|
|
// Construct the unlocker matching this metadata to get its ID
|
|
id := findUnlockerIDByMetadata(cli.fs, unlockersDir, metadata, true)
|
|
if id != "" && id == unlockerID {
|
|
return errUnlockerExists
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|