Integrating Pass into Emacs

Posted on February 29, 2024

For better or worse, I've been using Pass to manage my passwords since around mid-October of last year, and although I like its simplicity, using it was always intimidating. Creating a password is easy enough, but I didn't get comfortable enough with the CLI to search my password store for things effectively, and when it came time to edit a password, it was easier to just open the file in Emacs and edit it manually. Of course, this pretty much defeats the purpose of the tool, so I've kept the idea of integrating it into Emacs in the back of my mind, and I finally wrote a package to do it1. Emacs already comes with some pass integration in the form of auth-source-pass.el, but that really only helps Emacs query Pass and doesn't really help me add a password.

There is password-store.el, but oddly enough the insert functionality isn't there, either. The other day, I discovered Emacs's with-editor-async-shell-command function, which was the impetus for the code I wrote to give me what I felt was missing. There is some friction in how with-editor-async-shell-command handles Pass prompts, but I'm hoping to iron out the few kinks that remain.

Defining Some Functions

Since all Pass commands look pretty similar, I decided to use a wrapper function to call Pass:

(defun fast-pass--run (verb noun buffer &optional args modifier)
  "Run pass command VERB acting on NOUN with list of optional ARGS and/or
MODIFIER where appropriate."
  (let ((command (string-join `("pass"
                                ,verb
                                ,@args
                                ,noun
                                ,modifier)
                              " ")))
    (with-editor-async-shell-command command
                                     buffer)))

The function just concatenates arguments into a Pass command, and passes it to with-editor-async-shell-command. The verb parameter is meant to be one of the commands Pass accepts like show, insert, find, and the rest. The noun is what the verb is acting on, usually a password name but sometimes a query string. The args argument is a list of optional switches like --clip or --multiline. Lastly, modifier is for the rare case where one noun isn't enough, like when moving a password or initializing the password store. This function was also a good opportunity to use list splicing2.

The output of the pass command is passed to a buffer creatively named *Pass*. The cool thing about with-editor-async-shell-command is that it uses Emacs as the Editor for the command it runs, so any stdin prompts are relayed to the minibuffer or the output buffer parameter passed to the function, and you can fill them in interactively in Emacs.

I thought it would be clever to leverage Pass's file structure and use read-file-name and read-directory-name to provide completions for the password name. When the starting directory for read-file-name is set to the root directory of the password store, this works quite well. The downside to this approach is that the root directory needs to be trimmed, so I wrote a function named pass-utils--password-from-path to handle this and any other quirks that might come up with password names:

(defun pass-utils--password-from-path (path)
  "Convert PATH to a pass-name pass knows about."
  (string-replace ".gpg" "" (file-relative-name path
                                                pass-utils-pass-directory)))

In addition to trimming the root directory, the function strips off the .gpg file extension. I suppose exposing the file extension where Pass does not is another downside, but at this point I'm willing to make that sacrifice to avoid building my own completions.

Putting Things to Use

Aside from some custom variables to set things up, the rest of the package is transient prefixes. To keep things less cluttered, I created a landing pad that is essentially a nested prefix that jumps to the various functions Pass supports. Here's the entry point:

(transient-define-prefix fast-pass-menu ()
  "General transient menu for fast-pass."
  ["Search"
   ("f" "find" (lambda (name)
                 (interactive "sSearch query: ")
                 (fast-pass--run "find"
                                 name
                                 fast-pass-output-buffer)))
   ("l" "ls" (lambda (location)
               (interactive (list (fast-pass--password-from-path
                                   (read-directory-name "List directory: "
                                                   fast-pass-pass-directory))))
               (fast-pass--run "ls"
                               location
                               fast-pass-output-buffer)))
   ("r" "grep" fast-pass-grep)
   ("s" "show" fast-pass-show)]
  ["Password Creation"
   ("i" "insert" fast-pass-insert)
   ("g" "generate" fast-pass-generate)]
  ["Store Management"
   ("e" "edit" (lambda (name)
                 (interactive (list (fast-pass--password-from-path
                                     (read-file-name "Edit password: "
                                                     fast-pass-pass-directory))))
                 (fast-pass--run "edit"
                                 name
                                 fast-pass-output-buffer)))
   ("i" "init" fast-pass-init)
   ("o" "file operations" fast-pass-file)
   ("t" "git" (lambda ()
                (interactive)
                (cond ((eq 'vc fast-pass-git-interface)
                       (vc-dir fast-pass-pass-directory))
                      ((eq 'magit fast-pass-git-interface)
                       (magit-status fast-pass-pass-directory)))))]
  [("q" "Quit" transient-quit-one)])

In transient vernacular, each group (the square brackets surrounding Search, Password Creation, etc.) contains suffixes, which end with functions. These functions can also be more transient prefixes. Each suffix ties to a Pass command, so for the pass commands that don't have any options the suffix is a lambda function that calls fast-pass--run. If the Pass command does have options, then the suffix is another prefix that lets the user select those arguments interactively as infixes. Here's an example of that:

(transient-define-prefix fast-pass-show (args)
  "Transient menu for finding a password."
  :incompatible '(("--clip=" "--qrcode="))
  ["Options"
   ("-c" "Clip a specific line number" "--clip=" :allow-empty t)
   ("-q" "Print as a QR code" "--qrcode=" :allow-empty t)]
  [("s" "show" (lambda (args password-name)
                 (interactive (list (transient-get-value)
                                    (fast-pass--password-from-path
                                     (read-file-name "Show password: "
                                                     fast-pass-pass-directory))))
                 (fast-pass--run "show"
                                 password-name
                                 fast-pass-output-buffer)))])

This introduces some other features of transient prefixes. The first is the :incompatible key word, which is a list of lists of arguments that are mutually exclusive: only one can be active at a time. Here I only care about two: --clip and --qrcode=. Transient passes the selected options in as the args variable in the transient prefix definition. Note the equals sign at the end of the --qrcode flag: this indicates that the argument takes user input.

The Options group contains the definitions of the option (or infixes) that will get passed to the command. You may be wondering how Transient distinguishes between infixes and suffixes; the groups look pretty similar and the labels seem arbitrary. The difference is that suffixes end with a command and infixes do not. It's probably in the documentation somewhere, but this took me a hot second to figure out.

The only thing left is the suffix to actually run pass show, which is similar to the lambdas in the landing point prefix.

All of the other transients in the package are variations on a theme, so for brevity's sake I don't want to go over them but the code is in my GitLab2. I will say that option groups can get complicated, which I learned when working through pass's grep integration. There are still :incompatible options there that need to be sorted through, and the complete list of options spans almost two pages. It works, but it's unwieldy; simply making the menu two columns would help a lot, but I'm not sure if that's possible.

As far as how well the package works, the things I use frequently are tested and it has made Pass much more approachable; I kind of wish I had thought to dig into Transient sooner. The only hard part was understanding the semantics behind prefixes, infixes, and suffixes and how Transient distinguishes between infixes and suffixes.

Footnotes

1 Andrew Burch, "fast-pass", Codeberg, accessed March 4th, 2024. https://codeberg.org/ablatedsprocket/fast-pass
2 "Backquote (GNU Emacs Lisp Reference Manual)", gnu.org, accessed February 29, 2024. https://gnu.org/software/emacs/manual/html_node/elisp/Backquote.html.