Recently having docker login credentials stored in plain text on a server was bugging me. Granted the credentials are base64 encoded but easily decoded at which point the container registry is vulnerable as well. I thought using one of the credential helpers would be quick and simple but I was mistaken.

In my case I was using the pass credential helper on a Docker Swarm manager running Ubuntu server. Part of the problem was that docker-credential-pass is not well-documented; I found various resources that got me most of the way there but the complete solution required info across several resources and none of them automated it as much as I wanted.

With that in mind I pieced together this docker-credentials.sh script to make this easier. Most explanation of the script is inline with the comments.

#!/bin/sh

# Sets up a docker credential helper so docker login credentials are not stored encoded in base64 plain text.
# Uses the pass secret service as the credentials store.
#
# If previously logged in w/o cred helper, docker logout <registry> under each user or remove ~/.docker/config.json.
#
# For Swarm use just run once on a manager.
# Script written for use on Ubuntu.
# Run elevated logged in as the target user.

# To remove cred helper:
#   - delete /usr/local/bin/docker-credential-pass
#   - remove '{ "credsStore": "pass" }' from ~/.docker/config.json

# Ensure executable in git:
# git update-index --chmod=+x path/docker-credentials.sh

if ! [ $(id -u) = 0 ]; then
   echo "This script must be run as root"
   exit 1
fi

# Install dependencies - jq more optional for existing varying configuration in ~/.docker/config.json.
apt update && apt-get -y install gnupg2 pass rng-tools jq

# Check for later releases at https://github.com/docker/docker-credential-helpers/releases
version="v0.6.3"
archive="docker-credential-pass-$version-amd64.tar.gz"
url="https://github.com/docker/docker-credential-helpers/releases/download/$version/$archive"

# Download cred helper, unpack, make executable, move it where it'll be found
wget $url \
    && tar -xf $archive \
    && chmod +x docker-credential-pass \
    && mv -f docker-credential-pass /usr/local/bin/

# Done with the archive
rm -f $archive

config_path=~/.docker
config_filename=$config_path/config.json

# Could assume config.json isn't there or overwrite regardless and not use jq (or sed etc.)
# echo '{ "credsStore": "pass" }' > $config_filename

if [ ! -f $config_filename ]
then
    if [ ! -d $config_path ]
    then
        mkdir -p $config_path
    fi

    # Create default docker config file if it doesn't exist (never logged in etc.). Empty is fine currently.
    cat > $config_filename <<EOL
{
}
EOL
    echo "$config_filename created with defaults"
else
    echo "$config_filename already exists"
fi

# Whether config is new or existing, read into variable for easier file redirection (cat > truncate timing)
config_json=`cat $config_filename`

if [ -z "$config_json" ]; then
    # Empty file will prevent jq from working
    $config_json="{}"
fi

# Update Docker config to set the credential store. Used sed before but messy / edge cases.
echo "$config_json" | jq --arg credsStore pass '. + {credsStore: $credsStore}' > $config_filename

# Output / verify contents
echo "$config_filename:"
cat $config_filename | jq

# Help with entropy to prevent gpg2 full key generation hang
# Feeds data from a random number generator to the kernel's random number entropy pool
rngd -r /dev/urandom

# To cleanup extras from multiple runs: gpg --delete-secret-key <key-id>; gpg --delete-key <key-id>
echo "Generating GPG key, accept defaults but consider key size to 2048, supply user info"
gpg2 --full-generate-key

echo "Adjusting permissions"
sudo chown -R $USER:$USER ~/.gnupg
sudo find ~/.gnupg -type d -exec chmod 700 {} \;
sudo find ~/.gnupg -type f -exec chmod 600 {} \;

# List keys
gpg2 -k

# Grab target key
key=$(gpg2 --list-secret-keys | grep uid -B 1 | head -n 1 | sed 's/^ *//g')

echo "Initializing pass with key $key"
pass init $key

# Image can't be found when Swarm attempts to pull later if a pass phrase is here.
echo "Do not set a passphrase for this step (*IMPORTANT*)"
pass insert docker-credential-helpers/docker-pass-initialized-check

# Optionally show password but mask ***
# pass show docker-credential-helpers/docker-pass-initialized-check | sed -e 's/\(.\)/\*/g'

echo "Docker credential password list (empty initially):"
docker-credential-pass list

echo "Done. Ready to test. Run: docker login <registry>"
echo "After login run: docker-credential-pass list; cat ~/.docker/config.json; pass show"

The initial sticking points were:

  • docker-credential-helpers #186docker-pass-initialized-check didn’t seem to work with Docker swarm when a passphrase was used. The helper setup would work but despite using docker stack deploy --with-registry-auth, credentials would no longer correctly sync with other nodes so the images couldn’t be found when starting services.
  • Needing to install gpg2 and pass.
  • rng-tools was needed to generate enough entropy so gpg2 did not hang generating a new key pair.
  • Needing to adjust permissions on ~/.gnupg.
  • Automating initializing pass with the desired gpg-id.

Running the script on the server looked like this…

mr-robot@ubun2:~$ sudo ./docker-credentials.sh

Hit:1 http://azure.archive.ubuntu.com/ubuntu bionic InRelease
Hit:2 http://azure.archive.ubuntu.com/ubuntu bionic-updates InRelease
Hit:3 http://azure.archive.ubuntu.com/ubuntu bionic-backports InRelease
Hit:4 http://security.ubuntu.com/ubuntu bionic-security InRelease
Hit:5 https://download.docker.com/linux/ubuntu bionic InRelease
Reading package lists... Done
Building dependency tree
Reading state information... Done
3 packages can be upgraded. Run 'apt list --upgradable' to see them.
Reading package lists... Done
Building dependency tree
Reading state information... Done
The following packages were automatically installed and are no longer required:
  grub-pc-bin linux-headers-4.15.0-117
Use 'sudo apt autoremove' to remove them.
The following additional packages will be installed:
  libice6 libjq1 libonig4 libqrencode3 libsm6 libxmu6 libxt6 qrencode tree x11-common xclip
Suggested packages:
  libxml-simple-perl ruby
The following NEW packages will be installed:
  gnupg2 jq libice6 libjq1 libonig4 libqrencode3 libsm6 libxmu6 libxt6 pass qrencode rng-tools tree
  x11-common xclip
0 upgraded, 15 newly installed, 0 to remove and 3 not upgraded.
Need to get 726 kB of archives.
After this operation, 2604 kB of additional disk space will be used.
Get:1 http://azure.archive.ubuntu.com/ubuntu bionic-updates/main amd64 x11-common all 1:7.7+19ubuntu7.1 [22.5 kB]
Get:2 http://azure.archive.ubuntu.com/ubuntu bionic/main amd64 libice6 amd64 2:1.0.9-2 [40.2 kB]
Get:3 http://azure.archive.ubuntu.com/ubuntu bionic/main amd64 libsm6 amd64 2:1.2.2-1 [15.8 kB]
Get:4 http://azure.archive.ubuntu.com/ubuntu bionic/universe amd64 libonig4 amd64 6.7.0-1 [119 kB]
Get:5 http://azure.archive.ubuntu.com/ubuntu bionic/universe amd64 libjq1 amd64 1.5+dfsg-2 [111 kB]
Get:6 http://azure.archive.ubuntu.com/ubuntu bionic/universe amd64 jq amd64 1.5+dfsg-2 [45.6 kB]
Get:7 http://azure.archive.ubuntu.com/ubuntu bionic/universe amd64 libqrencode3 amd64 3.4.4-1build1 [23.9 kB]
Get:8 http://azure.archive.ubuntu.com/ubuntu bionic/main amd64 libxt6 amd64 1:1.1.5-1 [160 kB]
Get:9 http://azure.archive.ubuntu.com/ubuntu bionic/main amd64 libxmu6 amd64 2:1.1.2-2 [46.0 kB]
Get:10 http://azure.archive.ubuntu.com/ubuntu bionic-updates/universe amd64 gnupg2 all 2.2.4-1ubuntu1.2 [4668 B]
Get:11 http://azure.archive.ubuntu.com/ubuntu bionic/universe amd64 tree amd64 1.7.0-5 [40.7 kB]
Get:12 http://azure.archive.ubuntu.com/ubuntu bionic/universe amd64 pass all 1.7.1-3 [36.3 kB]
Get:13 http://azure.archive.ubuntu.com/ubuntu bionic/universe amd64 qrencode amd64 3.4.4-1build1 [20.4 kB]
Get:14 http://azure.archive.ubuntu.com/ubuntu bionic/universe amd64 rng-tools amd64 5-0ubuntu4 [22.5 kB]
Get:15 http://azure.archive.ubuntu.com/ubuntu bionic/main amd64 xclip amd64 0.12+svn84-4build1 [17.5 kB]
Fetched 726 kB in 0s (15.7 MB/s)
Selecting previously unselected package x11-common.
(Reading database ... 76844 files and directories currently installed.)
Preparing to unpack .../00-x11-common_1%3a7.7+19ubuntu7.1_all.deb ...
dpkg-query: no packages found matching nux-tools
Unpacking x11-common (1:7.7+19ubuntu7.1) ...
Selecting previously unselected package libice6:amd64.
Preparing to unpack .../01-libice6_2%3a1.0.9-2_amd64.deb ...
Unpacking libice6:amd64 (2:1.0.9-2) ...
Selecting previously unselected package libsm6:amd64.
Preparing to unpack .../02-libsm6_2%3a1.2.2-1_amd64.deb ...
Unpacking libsm6:amd64 (2:1.2.2-1) ...
Selecting previously unselected package libonig4:amd64.
Preparing to unpack .../03-libonig4_6.7.0-1_amd64.deb ...
Unpacking libonig4:amd64 (6.7.0-1) ...
Selecting previously unselected package libjq1:amd64.
Preparing to unpack .../04-libjq1_1.5+dfsg-2_amd64.deb ...
Unpacking libjq1:amd64 (1.5+dfsg-2) ...
Selecting previously unselected package jq.
Preparing to unpack .../05-jq_1.5+dfsg-2_amd64.deb ...
Unpacking jq (1.5+dfsg-2) ...
Selecting previously unselected package libqrencode3:amd64.
Preparing to unpack .../06-libqrencode3_3.4.4-1build1_amd64.deb ...
Unpacking libqrencode3:amd64 (3.4.4-1build1) ...
Selecting previously unselected package libxt6:amd64.
Preparing to unpack .../07-libxt6_1%3a1.1.5-1_amd64.deb ...
Unpacking libxt6:amd64 (1:1.1.5-1) ...
Selecting previously unselected package libxmu6:amd64.
Preparing to unpack .../08-libxmu6_2%3a1.1.2-2_amd64.deb ...
Unpacking libxmu6:amd64 (2:1.1.2-2) ...
Selecting previously unselected package gnupg2.
Preparing to unpack .../09-gnupg2_2.2.4-1ubuntu1.2_all.deb ...
Unpacking gnupg2 (2.2.4-1ubuntu1.2) ...
Selecting previously unselected package tree.
Preparing to unpack .../10-tree_1.7.0-5_amd64.deb ...
Unpacking tree (1.7.0-5) ...
Selecting previously unselected package pass.
Preparing to unpack .../11-pass_1.7.1-3_all.deb ...
Unpacking pass (1.7.1-3) ...
Selecting previously unselected package qrencode.
Preparing to unpack .../12-qrencode_3.4.4-1build1_amd64.deb ...
Unpacking qrencode (3.4.4-1build1) ...
Selecting previously unselected package rng-tools.
Preparing to unpack .../13-rng-tools_5-0ubuntu4_amd64.deb ...
Unpacking rng-tools (5-0ubuntu4) ...
Selecting previously unselected package xclip.
Preparing to unpack .../14-xclip_0.12+svn84-4build1_amd64.deb ...
Unpacking xclip (0.12+svn84-4build1) ...
Setting up tree (1.7.0-5) ...
Setting up libonig4:amd64 (6.7.0-1) ...
Setting up pass (1.7.1-3) ...
Setting up libqrencode3:amd64 (3.4.4-1build1) ...
Setting up rng-tools (5-0ubuntu4) ...
Setting up libjq1:amd64 (1.5+dfsg-2) ...
Setting up gnupg2 (2.2.4-1ubuntu1.2) ...
Setting up x11-common (1:7.7+19ubuntu7.1) ...
update-rc.d: warning: start and stop actions are no longer supported; falling back to defaults
Setting up qrencode (3.4.4-1build1) ...
Setting up jq (1.5+dfsg-2) ...
Setting up libice6:amd64 (2:1.0.9-2) ...
Setting up libsm6:amd64 (2:1.2.2-1) ...
Setting up libxt6:amd64 (1:1.1.5-1) ...
Setting up libxmu6:amd64 (2:1.1.2-2) ...
Setting up xclip (0.12+svn84-4build1) ...
Processing triggers for systemd (237-3ubuntu10.42) ...
Processing triggers for man-db (2.8.3-2ubuntu0.1) ...
Processing triggers for ureadahead (0.100.0-21) ...
Processing triggers for libc-bin (2.27-3ubuntu1.2) ...
--2020-09-15 22:10:46--  https://github.com/docker/docker-credential-helpers/releases/download/v0.6.3/docker-credential-pass-v0.6.3-amd64.tar.gz
Resolving github.com (github.com)... 140.82.114.4
Connecting to github.com (github.com)|140.82.114.4|:443... connected.
HTTP request sent, awaiting response... 302 Found
Location: https://github-production-release-asset-2e65be.s3.amazonaws.com/51309425/d50f9700-a88b-11e9-8807-ac23deaea4f0?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIWNJYAX4CSVEH53A%2F20200915%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20200915T221047Z&X-Amz-Expires=300&X-Amz-Signature=44165cc88f45df3685cc8e4b93bd906b44a37a6aa0f5a4a978c360107b44d210&X-Amz-SignedHeaders=host&actor_id=0&key_id=0&repo_id=51309425&response-content-disposition=attachment%3B%20filename%3Ddocker-credential-pass-v0.6.3-amd64.tar.gz&response-content-type=application%2Foctet-stream [following]
--2020-09-15 22:10:47--  https://github-production-release-asset-2e65be.s3.amazonaws.com/51309425/d50f9700-a88b-11e9-8807-ac23deaea4f0?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIWNJYAX4CSVEH53A%2F20200915%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20200915T221047Z&X-Amz-Expires=300&X-Amz-Signature=44165cc88f45df3685cc8e4b93bd906b44a37a6aa0f5a4a978c360107b44d210&X-Amz-SignedHeaders=host&actor_id=0&key_id=0&repo_id=51309425&response-content-disposition=attachment%3B%20filename%3Ddocker-credential-pass-v0.6.3-amd64.tar.gz&response-content-type=application%2Foctet-stream
Resolving github-production-release-asset-2e65be.s3.amazonaws.com (github-production-release-asset-2e65be.s3.amazonaws.com)... 52.216.112.195
Connecting to github-production-release-asset-2e65be.s3.amazonaws.com (github-production-release-asset-2e65be.s3.amazonaws.com)|52.216.112.195|:443... connected.
HTTP request sent, awaiting response... 200 OK
Length: 1486371 (1.4M) [application/octet-stream]
Saving to: ‘docker-credential-pass-v0.6.3-amd64.tar.gz’

docker-credential-pass-v0.6 100%[=========================================>]   1.42M  --.-KB/s    in 0.04s

2020-09-15 22:10:47 (34.4 MB/s) - ‘docker-credential-pass-v0.6.3-amd64.tar.gz’ saved [1486371/1486371]

/home/mr-robot/.docker/config.json created with defaults
/home/mr-robot/.docker/config.json:
{
  "credsStore": "pass"
}
Generating GPG key, accept defaults but consider key size to 2048, supply user info
gpg: WARNING: unsafe ownership on homedir '/home/mr-robot/.gnupg'
gpg (GnuPG) 2.2.4; Copyright (C) 2017 Free Software Foundation, Inc.
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.

gpg: keybox '/home/mr-robot/.gnupg/pubring.kbx' created
Please select what kind of key you want:
   (1) RSA and RSA (default)
   (2) DSA and Elgamal
   (3) DSA (sign only)
   (4) RSA (sign only)
Your selection?
RSA keys may be between 1024 and 4096 bits long.
What keysize do you want? (3072) 2048
Requested keysize is 2048 bits
Please specify how long the key should be valid.
         0 = key does not expire
        = key expires in n days
      w = key expires in n weeks
      m = key expires in n months
      y = key expires in n years
Key is valid for? (0)
Key does not expire at all
Is this correct? (y/N) y

GnuPG needs to construct a user ID to identify your key.

Real name: Robot
Email address: robot@domain.com
Comment:
You selected this USER-ID:
    "Robot "

Change (N)ame, (C)omment, (E)mail or (O)kay/(Q)uit? O
We need to generate a lot of random bytes. It is a good idea to perform
some other action (type on the keyboard, move the mouse, utilize the
disks) during the prime generation; this gives the random number
generator a better chance to gain enough entropy.
We need to generate a lot of random bytes. It is a good idea to perform
some other action (type on the keyboard, move the mouse, utilize the
disks) during the prime generation; this gives the random number
generator a better chance to gain enough entropy.
gpg: /home/mr-robot/.gnupg/trustdb.gpg: trustdb created
gpg: key AA4FD1A5B14B84B6 marked as ultimately trusted
gpg: directory '/home/mr-robot/.gnupg/openpgp-revocs.d' created
gpg: revocation certificate stored as '/home/mr-robot/.gnupg/openpgp-revocs.d/0421A82FFC8D8B20A483E807AA4FD1A5B14B84B6.rev'
public and secret key created and signed.

pub   rsa2048 2020-09-15 [SC]
      0421A82FFC8D8B20A483E807AA4FD1A5B14B84B6
uid                      Robot 
sub   rsa2048 2020-09-15 [E]

Adjusting permissions
gpg: checking the trustdb
gpg: marginals needed: 3  completes needed: 1  trust model: pgp
gpg: depth: 0  valid:   1  signed:   0  trust: 0-, 0q, 0n, 0m, 0f, 1u
/home/mr-robot/.gnupg/pubring.kbx
---------------------------------
pub   rsa2048 2020-09-15 [SC]
      0421A82FFC8D8B20A483E807AA4FD1A5B14B84B6
uid           [ultimate] Robot 
sub   rsa2048 2020-09-15 [E]

Initializing pass with key 0421A82FFC8D8B20A483E807AA4FD1A5B14B84B6
mkdir: created directory '/home/mr-robot/.password-store/'
Password store initialized for 0421A82FFC8D8B20A483E807AA4FD1A5B14B84B6
Do not set a passphrase for this step (*IMPORTANT*)
mkdir: created directory '/home/mr-robot/.password-store/docker-credential-helpers'
Enter password for docker-credential-helpers/docker-pass-initialized-check:
Retype password for docker-credential-helpers/docker-pass-initialized-check:
Docker credential password list (empty initially):
{}

Done. Ready to test. Run: docker login 
After login run: docker-credential-pass list; cat ~/.docker/config.json; pass show

Next it was time to test the setup with some steps, starting with logging into a private registry requiring authentication.

mr-robot@ubun2:~$ sudo docker login theworldisahoax.azurecr.io

Username: theworldisahoax
Password:
Login Succeeded

Next up, listing docker credential pass authentications.

mr-robot@ubun2:~$ sudo docker-credential-pass list

{"theworldisahoax.azurecr.io":"theworldisahoax"}

Verifying the Docker daemon config:

mr-robot@ubun2:~$ sudo cat ~/.docker/config.json

{
    "auths": {
      "theworldisahoax.azurecr.io": {}
    },
    "HttpHeaders": {
      "User-Agent": "Docker-Client/19.03.12 (linux)"
    },
    "credsStore": "pass"
  }

Listing the names of passwords with tree:

mr-robot@ubun2:~$ sudo pass ls

Password Store
└── docker-credential-helpers
    ├── dGhld29ybGRpc2Fob2F4LmF6dXJlY3IuaW8=
    │   └── theworldisahoax
    └── docker-pass-initialized-check

The password can then be retrieved with the following which will first securely prompt for the password used when generating the key during setup.

mr-robot@ubun2:~$ sudo pass show docker-credential-helpers/dGhld29ybGRpc2Fob2F4LmF6dXJlY3IuaW8=/theworldisahoax

9GBTLOiig7lMuzWYwb5mdw/7Ut8PpFvu

I’m not an expert in Linux or security so there’s probably room for improvement with this.

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.