Improve PGP unlocker ergonomics
- Support 'secret unlockers add pgp [keyid]' positional argument syntax - Automatically detect and use default GPG key when no key is specified - Change PGP unlocker ID format from <keyid>-pgp to pgp-<keyid> - Check if PGP key is already added before creating duplicate unlocker - Add getDefaultGPGKey() that checks gpgconf first, then falls back to first secret key - Export ResolveGPGKeyFingerprint() for use in CLI - Add checkUnlockerExists() helper to verify unlocker IDs The new behavior: - 'secret unlockers add pgp' uses default GPG key - 'secret unlockers add pgp KEYID' uses specified key - 'secret unlockers add pgp --keyid=KEYID' also works - Errors if key is already added or no default key exists
This commit is contained in:
parent
09b3a1fcdc
commit
7af1e6efa8
@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@ -15,9 +16,44 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Import from init.go
|
// getDefaultGPGKey returns the default GPG key ID if available
|
||||||
|
func getDefaultGPGKey() (string, error) {
|
||||||
|
// First try to get the configured default key using gpgconf
|
||||||
|
cmd := exec.Command("gpgconf", "--list-options", "gpg")
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err == nil {
|
||||||
|
lines := strings.Split(string(output), "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
fields := strings.Split(line, ":")
|
||||||
|
if len(fields) > 9 && fields[0] == "default-key" && fields[9] != "" {
|
||||||
|
// The default key is in field 10 (index 9)
|
||||||
|
return fields[9], nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ... existing imports ...
|
// If no default key is configured, get the first secret key
|
||||||
|
cmd = exec.Command("gpg", "--list-secret-keys", "--with-colons")
|
||||||
|
output, err = cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to list GPG keys: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse output to find the first usable secret key
|
||||||
|
lines := strings.Split(string(output), "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
// sec line indicates a secret key
|
||||||
|
if strings.HasPrefix(line, "sec:") {
|
||||||
|
fields := strings.Split(line, ":")
|
||||||
|
// Field 5 contains the key ID
|
||||||
|
if len(fields) > 4 && fields[4] != "" {
|
||||||
|
return fields[4], nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("no GPG secret keys found")
|
||||||
|
}
|
||||||
|
|
||||||
func newUnlockersCmd() *cobra.Command {
|
func newUnlockersCmd() *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
@ -55,13 +91,19 @@ func newUnlockersListCmd() *cobra.Command {
|
|||||||
|
|
||||||
func newUnlockersAddCmd() *cobra.Command {
|
func newUnlockersAddCmd() *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "add <type>",
|
Use: "add <type> [keyid]",
|
||||||
Short: "Add a new unlocker",
|
Short: "Add a new unlocker",
|
||||||
Long: `Add a new unlocker of the specified type (passphrase, keychain, pgp).`,
|
Long: `Add a new unlocker of the specified type (passphrase, keychain, pgp).`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.RangeArgs(1, 2), //nolint:mnd // Command accepts 1 or 2 arguments
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
cli := NewCLIInstance()
|
cli := NewCLIInstance()
|
||||||
|
|
||||||
|
// For PGP type, check if keyid is provided as positional argument
|
||||||
|
if args[0] == "pgp" && len(args) == 2 {
|
||||||
|
// Override any flag value with the positional argument
|
||||||
|
_ = cmd.Flags().Set("keyid", args[1])
|
||||||
|
}
|
||||||
|
|
||||||
return cli.UnlockersAdd(args[0], cmd)
|
return cli.UnlockersAdd(args[0], cmd)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@ -300,14 +342,38 @@ func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error
|
|||||||
return nil
|
return nil
|
||||||
|
|
||||||
case "pgp":
|
case "pgp":
|
||||||
// Get GPG key ID from flag or environment variable
|
// Get GPG key ID from flag, environment, or default key
|
||||||
var gpgKeyID string
|
var gpgKeyID string
|
||||||
if flagKeyID, _ := cmd.Flags().GetString("keyid"); flagKeyID != "" {
|
if flagKeyID, _ := cmd.Flags().GetString("keyid"); flagKeyID != "" {
|
||||||
gpgKeyID = flagKeyID
|
gpgKeyID = flagKeyID
|
||||||
} else if envKeyID := os.Getenv(secret.EnvGPGKeyID); envKeyID != "" {
|
} else if envKeyID := os.Getenv(secret.EnvGPGKeyID); envKeyID != "" {
|
||||||
gpgKeyID = envKeyID
|
gpgKeyID = envKeyID
|
||||||
} else {
|
} else {
|
||||||
return fmt.Errorf("GPG key ID required: use --keyid flag or set SB_GPG_KEY_ID environment variable")
|
// 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)
|
pgpUnlocker, err := secret.CreatePGPUnlocker(cli.fs, cli.stateDir, gpgKeyID)
|
||||||
@ -380,3 +446,69 @@ func (cli *Instance) UnlockerSelect(unlockerID string) error {
|
|||||||
|
|
||||||
return vlt.SelectUnlocker(unlockerID)
|
return vlt.SelectUnlocker(unlockerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkUnlockerExists checks if an unlocker with the given ID exists
|
||||||
|
func (cli *Instance) checkUnlockerExists(vlt *vault.Vault, unlockerID string) error {
|
||||||
|
// Get the list of unlockers and check if any match the ID
|
||||||
|
unlockers, err := vlt.ListUnlockers()
|
||||||
|
if err != nil {
|
||||||
|
return nil // If we can't list unlockers, assume it doesn't exist
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get vault directory to construct unlocker instances
|
||||||
|
vaultDir, err := vlt.GetDirectory()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check each unlocker's ID
|
||||||
|
for _, metadata := range unlockers {
|
||||||
|
// Construct the unlocker based on type to get its ID
|
||||||
|
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
||||||
|
files, err := afero.ReadDir(cli.fs, unlockersDir)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, file := range files {
|
||||||
|
if !file.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
unlockerDir := filepath.Join(unlockersDir, file.Name())
|
||||||
|
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
|
||||||
|
|
||||||
|
// Check if this matches our metadata
|
||||||
|
metadataBytes, err := afero.ReadFile(cli.fs, metadataPath)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var diskMetadata secret.UnlockerMetadata
|
||||||
|
if err := json.Unmarshal(metadataBytes, &diskMetadata); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match by type and creation time
|
||||||
|
if diskMetadata.Type == metadata.Type && diskMetadata.CreatedAt.Equal(metadata.CreatedAt) {
|
||||||
|
var unlocker secret.Unlocker
|
||||||
|
switch metadata.Type {
|
||||||
|
case "passphrase":
|
||||||
|
unlocker = secret.NewPassphraseUnlocker(cli.fs, unlockerDir, diskMetadata)
|
||||||
|
case "keychain":
|
||||||
|
unlocker = secret.NewKeychainUnlocker(cli.fs, unlockerDir, diskMetadata)
|
||||||
|
case "pgp":
|
||||||
|
unlocker = secret.NewPGPUnlocker(cli.fs, unlockerDir, diskMetadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
if unlocker != nil && unlocker.GetID() == unlockerID {
|
||||||
|
return fmt.Errorf("unlocker already exists")
|
||||||
|
}
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
@ -128,7 +128,7 @@ func (p *PGPUnlocker) GetDirectory() string {
|
|||||||
|
|
||||||
// GetID implements Unlocker interface - generates ID from GPG key ID
|
// GetID implements Unlocker interface - generates ID from GPG key ID
|
||||||
func (p *PGPUnlocker) GetID() string {
|
func (p *PGPUnlocker) GetID() string {
|
||||||
// Generate ID using GPG key ID: <keyid>-pgp
|
// Generate ID using GPG key ID: pgp-<keyid>
|
||||||
gpgKeyID, err := p.GetGPGKeyID()
|
gpgKeyID, err := p.GetGPGKeyID()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// The vault metadata is corrupt - this is a fatal error
|
// The vault metadata is corrupt - this is a fatal error
|
||||||
@ -136,7 +136,7 @@ func (p *PGPUnlocker) GetID() string {
|
|||||||
panic(fmt.Sprintf("PGP unlocker metadata is corrupt or missing GPG key ID: %v", err))
|
panic(fmt.Sprintf("PGP unlocker metadata is corrupt or missing GPG key ID: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Sprintf("%s-pgp", gpgKeyID)
|
return fmt.Sprintf("pgp-%s", gpgKeyID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove implements Unlocker interface - removes the PGP unlocker
|
// Remove implements Unlocker interface - removes the PGP unlocker
|
||||||
@ -267,7 +267,7 @@ func CreatePGPUnlocker(fs afero.Fs, stateDir string, gpgKeyID string) (*PGPUnloc
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Step 9: Resolve the GPG key ID to its full fingerprint
|
// Step 9: Resolve the GPG key ID to its full fingerprint
|
||||||
fingerprint, err := resolveGPGKeyFingerprint(gpgKeyID)
|
fingerprint, err := ResolveGPGKeyFingerprint(gpgKeyID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to resolve GPG key fingerprint: %w", err)
|
return nil, fmt.Errorf("failed to resolve GPG key fingerprint: %w", err)
|
||||||
}
|
}
|
||||||
@ -313,8 +313,8 @@ func validateGPGKeyID(keyID string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveGPGKeyFingerprint resolves any GPG key identifier to its full fingerprint
|
// ResolveGPGKeyFingerprint resolves any GPG key identifier to its full fingerprint
|
||||||
func resolveGPGKeyFingerprint(keyID string) (string, error) {
|
func ResolveGPGKeyFingerprint(keyID string) (string, error) {
|
||||||
if err := validateGPGKeyID(keyID); err != nil {
|
if err := validateGPGKeyID(keyID); err != nil {
|
||||||
return "", fmt.Errorf("invalid GPG key ID: %w", err)
|
return "", fmt.Errorf("invalid GPG key ID: %w", err)
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user