Update golangci-lint to v2.12.2 with canonical config
All checks were successful
check / check (push) Successful in 1m8s
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
This commit is contained in:
@@ -1,13 +1,16 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -18,6 +21,35 @@ import (
|
||||
"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"`
|
||||
@@ -37,12 +69,14 @@ const (
|
||||
|
||||
// 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.Command("gpgconf", "--list-options", "gpg")
|
||||
cmd := exec.CommandContext(ctx, "gpgconf", "--list-options", "gpg")
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err == nil {
|
||||
lines := strings.Split(string(output), "\n")
|
||||
for _, line := range lines {
|
||||
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)
|
||||
@@ -52,15 +86,15 @@ func getDefaultGPGKey() (string, error) {
|
||||
}
|
||||
|
||||
// If no default key is configured, get the first secret key
|
||||
cmd = exec.Command("gpg", "--list-secret-keys", "--with-colons")
|
||||
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
|
||||
lines := strings.Split(string(output), "\n")
|
||||
for _, line := range lines {
|
||||
for line := range strings.SplitSeq(string(output), "\n") {
|
||||
// sec line indicates a secret key
|
||||
if strings.HasPrefix(line, "sec:") {
|
||||
fields := strings.Split(line, ":")
|
||||
@@ -71,7 +105,7 @@ func getDefaultGPGKey() (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no GPG secret keys found")
|
||||
return "", errNoGPGSecretKeys
|
||||
}
|
||||
|
||||
func newUnlockerCmd() *cobra.Command {
|
||||
@@ -91,7 +125,7 @@ func newUnlockerCmd() *cobra.Command {
|
||||
|
||||
func newUnlockerListCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Use: cmdUseList,
|
||||
Aliases: []string{"ls"},
|
||||
Short: "List unlockers in the current vault",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
@@ -101,6 +135,7 @@ func newUnlockerListCmd() *cobra.Command {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||
}
|
||||
|
||||
cli.cmd = cmd
|
||||
|
||||
return cli.UnlockersList(jsonOutput)
|
||||
@@ -112,53 +147,80 @@ func newUnlockerListCmd() *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newUnlockerAddCmd() *cobra.Command {
|
||||
// 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:
|
||||
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."
|
||||
|
||||
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" {
|
||||
if runtime.GOOS == platformDarwin {
|
||||
supportedTypes = "passphrase, keychain, pgp, secure-enclave"
|
||||
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.
|
||||
|
||||
secure-enclave - Apple Secure Enclave hardware protection (macOS only)
|
||||
Stores the vault's master key encrypted by a non-exportable P-256 key
|
||||
held in the Secure Enclave. The key never leaves the hardware.
|
||||
Uses ECIES encryption; decryption is performed inside the SE.`
|
||||
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: 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),
|
||||
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 {
|
||||
@@ -166,33 +228,28 @@ to access the same vault. This provides flexibility and backup access options.`,
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||
}
|
||||
|
||||
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)
|
||||
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 != "pgp" && cmd.Flags().Changed("keyid") {
|
||||
return fmt.Errorf("--keyid flag is only valid for PGP unlockers")
|
||||
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)")
|
||||
cmd.Flags().String("keyid", "",
|
||||
"GPG key ID for PGP unlockers (optional, uses default key if not specified)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -202,17 +259,20 @@ func newUnlockerRemoveCmd() *cobra.Command {
|
||||
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.`,
|
||||
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)
|
||||
@@ -222,7 +282,8 @@ func newUnlockerRemoveCmd() *cobra.Command {
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolP("force", "f", false, "Force removal of last unlocker even if vault has secrets")
|
||||
cmd.Flags().BoolP("force", "f", false,
|
||||
"Force removal of last unlocker even if vault has secrets")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -249,6 +310,88 @@ func newUnlockerSelectCmd() *cobra.Command {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -259,6 +402,7 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
|
||||
|
||||
// Get the current unlocker ID
|
||||
var currentUnlockerID string
|
||||
|
||||
currentUnlocker, err := vlt.GetCurrentUnlocker()
|
||||
if err == nil {
|
||||
currentUnlockerID = currentUnlocker.GetID()
|
||||
@@ -272,74 +416,31 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
|
||||
|
||||
// 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)
|
||||
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")
|
||||
files, err := afero.ReadDir(cli.fs, unlockersDir)
|
||||
if err != nil {
|
||||
secret.Warn("Could not read unlockers directory", "error", err)
|
||||
|
||||
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 {
|
||||
secret.Warn("Could not read unlocker metadata file", "path", metadataPath, "error", err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
var diskMetadata secret.UnlockerMetadata
|
||||
if err := json.Unmarshal(metadataBytes, &diskMetadata); 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) {
|
||||
// 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)
|
||||
case "secure-enclave":
|
||||
unlocker = secret.NewSecureEnclaveUnlocker(cli.fs, unlockerDir, diskMetadata)
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
unlockerID := findUnlockerIDByMetadata(cli.fs, unlockersDir, metadata, true)
|
||||
|
||||
// Get the proper ID using the unlocker's ID() method
|
||||
var properID string
|
||||
if unlocker != nil {
|
||||
properID = unlocker.GetID()
|
||||
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)
|
||||
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{
|
||||
@@ -360,8 +461,10 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
|
||||
}
|
||||
|
||||
// printUnlockersJSON prints unlockers in JSON format
|
||||
func (cli *Instance) printUnlockersJSON(unlockers []UnlockerInfo, currentUnlockerID string) error {
|
||||
output := map[string]interface{}{
|
||||
func (cli *Instance) printUnlockersJSON(
|
||||
unlockers []UnlockerInfo, currentUnlockerID string,
|
||||
) error {
|
||||
output := map[string]any{
|
||||
"unlockers": unlockers,
|
||||
"currentUnlockerID": currentUnlockerID,
|
||||
}
|
||||
@@ -395,10 +498,12 @@ func (cli *Instance) printUnlockersTable(unlockers []UnlockerInfo) error {
|
||||
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,
|
||||
@@ -414,164 +519,186 @@ func (cli *Instance) printUnlockersTable(unlockers []UnlockerInfo) error {
|
||||
|
||||
// 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, secure-enclave"
|
||||
}
|
||||
|
||||
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 "secure-enclave":
|
||||
if runtime.GOOS != "darwin" {
|
||||
return fmt.Errorf("secure enclave unlockers are only supported on macOS")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
if err := vlt.SelectUnlocker(seUnlocker.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
|
||||
|
||||
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:
|
||||
return fmt.Errorf("unsupported unlocker type: %s (supported: %s)", unlockerType, supportedTypes)
|
||||
// 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 {
|
||||
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 {
|
||||
@@ -593,20 +720,24 @@ func (cli *Instance) UnlockersRemove(unlockerID string, force bool, cmd *cobra.C
|
||||
}
|
||||
|
||||
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("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")
|
||||
return errLastUnlocker
|
||||
}
|
||||
|
||||
if numSecrets > 0 && force {
|
||||
cmd.Println("WARNING: Removing the last unlocker. You MUST have your mnemonic phrase to access this vault again!")
|
||||
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 {
|
||||
err = vlt.RemoveUnlocker(unlockerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -639,65 +770,20 @@ func (cli *Instance) checkUnlockerExists(vlt *vault.Vault, unlockerID string) er
|
||||
// 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)
|
||||
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 based on type to get its ID
|
||||
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
||||
files, err := afero.ReadDir(cli.fs, unlockersDir)
|
||||
if err != nil {
|
||||
secret.Warn("Could not read unlockers directory during duplicate check", "error", err)
|
||||
|
||||
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 {
|
||||
secret.Warn("Could not read unlocker metadata during duplicate check", "path", metadataPath, "error", err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
var diskMetadata secret.UnlockerMetadata
|
||||
if err := json.Unmarshal(metadataBytes, &diskMetadata); err != nil {
|
||||
secret.Warn("Could not parse unlocker metadata during duplicate check", "path", metadataPath, "error", err)
|
||||
|
||||
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)
|
||||
case "secure-enclave":
|
||||
unlocker = secret.NewSecureEnclaveUnlocker(cli.fs, unlockerDir, diskMetadata)
|
||||
}
|
||||
|
||||
if unlocker != nil && unlocker.GetID() == unlockerID {
|
||||
return fmt.Errorf("unlocker already exists")
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
// Construct the unlocker matching this metadata to get its ID
|
||||
id := findUnlockerIDByMetadata(cli.fs, unlockersDir, metadata, true)
|
||||
if id != "" && id == unlockerID {
|
||||
return errUnlockerExists
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user