Secure and low maintenance password management with pass and git over network with expiring keypairs

I have been using the unix cli tool as a password manager pass for a while now. The main benefit of using it is its minimalistic nature, and you can access it directly from the command line. All of the passwords are simply saved as gpg-encrypted text files, and you can perform bash scripting with them, if you wish. In addition, you can pipe the output of pass directly to something like xclip to add it to your clipboard.

Recently, I decided it was time to upgrade my gpg key since it was generated and unchanged since 2019. That keypair was still using rsa2048, but currently (2024) rsa4096 is more common. Also there is no way to take a single key and upgrade it to a higher bit factor. I was initially not sure if there was a convenient way to transition my current pass configuration to the new gpg key. The man page for pass says that pass init has some function to re-encrypt your passwords with a different set of gpg keys, but this was either unintuitive or not working for me. So I instead accomplished that part with a bash script.

One additional experience I had difficulty with in using pass was in simultaneously using my data on multiple different machines. My initial solution was to simply transfer the ~/.gnupg/ and ~/.password-store/ directories to each machine, but then I experienced some issues with keeping them all asynchronously congruent while still trying to stay secure.

I didn't find too many helpful guides on this process. But I came up with the following workflow, and I found it quite elegant.

How it works

I use a master gpg key on a "locked-down" device, and only provide expiring subkeys to my personal devices. This gives an extra layer of protection, if any of those devices are compromised, I can simply regenerate new subkeys to use with my password store. Because the subkeys expire, I do need to repeat some steps from this guide on a periodic basis.

In addition, I am hosting a git repository on this locked-down device, and any changes I make can be done as regular git pushes and pulls with gpg signature authentication. But I also configured the git remote to disallow any force pushes, only appending new changes if the user can sign the commit. Note of course I am not hosting the git repo on a public network like GitHub. This required some different action than you may already be used to.

Updating your GPG key

Firstly, I would recommend backing up any current gpg and pass configuration.


cp -r ~/.gnupg ~/.gnupg.bkp
cp -r ~/.password-store ~/.password-store.bkp

If you don't already have a gpg keypair, or want to create a new one, run.


gpg --full-generate-key

At any point, you can list your current private keys with the subkey IDs using the following command:


gpg --list-secret-keys --keyid-format LONG

My new key has a response that looks something like the following:


sec   rsa4096/7D4DXXXXXXXXXXXX 2024-11-28 [SC]
      0DEFXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
uid           [ultimate] myname 
ssb   rsa4096/6F24XXXXXXXXXXXX 2024-11-28 [E]

By default, it looks like the master key might already include some subkeys. We will generate new ones with an expiration date to distribute to other devices. One should be for signing and the other for encryption. Do this by editing the master key and running addkey in the gpg shell that spawns.


gpg --edit-key 0DEFXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
> addkey

Once you list the secret keys again, you should now also get something that looks like the following. Note the created date, expiry date, and usage of each key.


ssb  rsa4096/8D9FXXXXXXXXXXXX
     created: 2024-11-28  expires: 2025-11-28  usage: S
ssb  rsa4096/894BXXXXXXXXXXXX
     created: 2024-11-28  expires: 2025-11-28  usage: E

I opted to use a different password on my personal devices since I wanted the many characters for my master key but don't want to have to type that every time. There is less security in using fewer characters for these subkeys, but in this way at least I can revoke the subkeys whenever I want while still maintaining a secure key for my identity.

It appears impossible on the same machine to have different passwords for the master and subkeys unless you re-import the subkeys into a different directory.

Export your subkeys in the following manner, don't forget the exclamation mark:


gpg --export-secret-subkeys 8D9FXXXXXXXXXXXX! > signing_subkey.priv
gpg --export-secret-subkeys 894BXXXXXXXXXXXX! > encryption_subkey.priv

Now copy these over to your personal PC/mobile device(s) and import them using the master key password.


gpg --import signing_subkey.priv
gpg --import encryption_subkey.priv

Now set the password to something shorter.


gpg --edit-key 0DEFXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
> passwd

Enter the master key's true password, and then enter your new local password. It will overwrite the passwords for the local keys, but not the master key.

Export the keys with the shorter password, this is what you will share on other devices


gpg --export-secret-subkeys 8D9FXXXXXXXXXXXX! > signing_subkey_shortpass.priv
gpg --export-secret-subkeys 894BXXXXXXXXXXXX! > encryption_subkey_shortpass.priv

Moving on to managing passwords

During the following setps, make sure your gpg_agent.conf has at least the following:


pinentry-program <...>
default-cache-ttl 3600
max-cache-ttl 3600

This will ensure your password is kept in memory for some time after you enter it. Use the pinentry program that is most relevant to you, possibly the ncurses one or a GUI one. If you experience a failure in one of the following commands, it might be because you are not currently authenticated through pinentry and that can't happen in the command's subshell.

Set up GPG signing for git locally on this server device as well as any personal devices you want to access passwords from. This is required to push changes to your passwords. You may also want to pass the --global flag to apply to other git repos.


git config user.signingkey 8D9FXXXXXXXXXXXX

Enable gpg signing for commits. Again consider --global


git config commit.gpgsign true

If you do not have one already, create password-store git repository for your private keys. I think you should NOT include an exclamation mark after the subkey here


pass init 0DEFXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 894BXXXXXXXXXXXX
pass git init

If you want this machine to be a git remote for my mobile devices, we need to ensure the host machine's git repo only allows GPG-verified clients to append to the repo. This requires each commit to be signed.

Now add the following script as a pre-receive hook


cd ~/.password-store
vim .git/hooks/pre-receive


#!/bin/bash

# List of acceptable GPG key IDs (or subkeys) that can sign commits
REQUIRED_GPG_KEYS=(
    #0DEFXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX # I comment this out for maximum safety
    8D9FXXXXXXXXXXXX # The current non-expired subkey
    # Add more key IDs or subkey IDs as needed
)

while read oldrev newrev refname; do

    # Prevent any force push (i.e., the newrev is not an ancestor of oldrev)
    if [ "$oldrev" != "0000000000000000000000000000000000000000" ]; then # This allows the first commit to go through, especially for new branches.
        if ! git merge-base --is-ancestor $oldrev $newrev > /dev/null; then
            echo "ERROR: Force push detected! Push rejected."
            exit 1
        fi
    fi

    # Check each commit in the push
    if ! git rev-list $newrev | while read commit; do
            
            # Get the GPG key ID used to sign the commit
            signer=$(git show --no-patch --no-notes --pretty='%GK' $commit)
            
            # Check if any of the required GPG key IDs match the signer
            match_found=false
            for key in "${REQUIRED_GPG_KEYS[@]}"; do
                echo "Current signer: $signer"
                echo "Required key: $key"
                if [[ "$signer" == *"$key"* ]]; then
                    match_found=true
                    break
                fi
            done
            
            # If no match was found, reject the push
            if [[ "$match_found" == false ]]; then
                echo "ERROR: Commit $commit is not signed with an allowed GPG key."
                exit 1
            fi
        done; then
        echo "Push rejected: Some commits are not signed with an allowed GPG key."
        exit 1
    fi
done

Also make sure the hook is executable.


chmod +x .git/hooks/pre-receive

I would recommend committing this file to your git repo as well in case you might lose it. Run the following command and hit enter to confirm the commit.


cp .git/hooks/pre-receive ./pre-receive.hook && git add pre-receive.hook && git status && read && git commit -m "Added a copy of the server's pre-receive hook to the repository to track any changes." && git log

If for some reason at any point you need to reapply signatures to your git commit history (perhaps some were missing before you adjust the pre-receive hook shortly), you can run the following command to go and re-approve all of them.


git rebase --exec 'git commit --amend --no-edit -S' -i --root

Update passwords to the new key:


NEWKEY_SUB="894BXXXXXXXXXXXX" find . -type f -name "*.gpg" | while read -r file ; do plaintext="$(gpg --quiet --batch --yes --decrypt "$file")" && echo "$plaintext" | gpg --encrypt --quiet --batch --yes -r ${NEWKEY_SUB}! -o "$file" && echo "Re-encrypted: $file" || echo "Failed to encrypt: $file" >&2 ; done

Test the updated password hashes with a regular pass command.


pass path/to/some/password.gpg

Now commit those to the .password-store git repo on your server.

Because git by default prevents collaborators from overwriting files on your system while the repo is checked out, you need to set the following configuration option to automatically trigger the server's local checked-out files to be overwritten when a push comes in. Otherwise you could on this server run git checkout --detach to allow commits from other machines and then git checkout main to update the contents of your directory after such a commit.


git config receive.denyCurrentBranch updateInstead

Now, connecting via ssh, clone the git repo onto a personal device and enable signing as you did for the server.


git clone ssh://user@192.168.0.1XX:22/home/user/.password-store

You could test the pre-release hook with a simple test commit.


touch a && git add a && git status && read && git commit -m "test commit" && git push origin main

Try reverting the commit on your client:


git reset --hard HEAD~1 && git clean -fd && git push --force origin main

And actually succeed in doing so on the server


git reset --hard HEAD~1 && git clean -fd

For extra good measure, on a new device try cloning the repo and committing something else without first importing gpg keys. In fact, do so by cloning the repo from your more-unsecure personal device.


git clone ssh://user@192.168.0.1YY:22/home/user/.password-store
...

Finally, when you're ready, clean up and delete any old files you don't want to keep around. I use shred to prevent people from recovering the data off my drive post-deletion. Note the following commands could be dangerous if you don't modify carefully (watch those *s!).


shred -u signing_subkey.priv encryption_subkey.priv
shred -u signing_subkey_shortpass.priv encryption_subkey_shortpass.priv
sudo for i in .password-store.bkp .gnupg.bkp ; do find "$i" -type f -exec shred -u -v {} \; && find "$i" -type d -empty -exec rmdir -v {} \; ; done

Also you can revoke or delete keys from your keyring, if you find that helpful.