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 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") 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 ", 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 ", 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 ", 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. It returns ("", nil) when the directory is // readable but holds no match, and a non-nil error when the directory // itself cannot be read. Callers must distinguish the two: an unreadable // directory means the unlocker's real ID is unknowable, so the entry has // to be skipped rather than reported under a synthesized ID. func findUnlockerIDByMetadata( fs afero.Fs, unlockersDir string, metadata secret.UnlockerMetadata, includeSecureEnclave bool, ) (string, error) { files, err := afero.ReadDir(fs, unlockersDir) if err != nil { return "", fmt.Errorf( "failed to read unlockers directory %s: %w", unlockersDir, err, ) } 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), nil } } return "", nil } // 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, err := findUnlockerIDByMetadata( cli.fs, unlockersDir, metadata, true, ) if err != nil { secret.Warn("Could not read unlockers directory, skipping unlocker", "unlockers_dir", unlockersDir, "error", err) continue } // 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("GPG key %s %w", gpgKeyID, errGPGKeyAlreadyUnlocker) } 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, err := findUnlockerIDByMetadata(cli.fs, unlockersDir, metadata, true) if err != nil { secret.Warn( "Could not read unlockers directory during duplicate check, "+ "skipping unlocker", "unlockers_dir", unlockersDir, "error", err) continue } if id != "" && id == unlockerID { return errUnlockerExists } } return nil }