Caching credentials using the Linux keyring in Go

This post describes how to cache credentials for your Go (and by proxy bash) scripts.

…​

Definitions

Keyring

In cryptography, a keyring stores known encryption keys (and, in some cases, passwords).

Difference between the Gnome Keyring and the Linux Kernel Keyring

Basically, The Gnome Keyring actually stores creds, while Linux Caches them.

keyutils - keyctl

key management facility control.

keyctl is very useful while playing with this. For example:

keyctl show session keyrings
$ keyctl show @s
Keyring
 823180767 --alswrv   1000 65534  keyring: _uid_ses.1000
 109050238 --alswrv   1000 65534   \_ keyring: _uid.1000
keyctl show user keyrings
$ keyctl show @u
Keyring
 109050238 --alswrv   1000 65534  keyring: _uid.1000

A Go library

There seems to be lots of alternative Go libraries to work with the different keyrings. I landed on Jesse Sipprell’s keyctl since it focuses on the Linux keyring only, it has no external dependencies and the code is easy to parse.

import (
	...
	"github.com/jsipprell/keyctl"
)

The library requires creating a keyring session first:

// Create session
keyring, err := keyctl.UserSessionKeyring()
if err != nil {
	return nil, fmt.Errorf("couldn't create keyring session: %w", err)
}

Then you can save a key with a given timeout in seconds:

// Store key
keyring.SetDefaultTimeout(timeoutSeconds)
key, err := keyring.Add(name, []byte(password))
if err != nil {
	return fmt.Errorf("couldn't store '%s': %s", name, err)
}
info, _ := key.Info()
logger.Printf("key: %+v", info)

To refresh the timer, simply save again. To invalidate a key, you can save it with a 1 second timeout and it will expire.

Finally, to retrieve it:

// Retrieve
key, err := keyring.Search(name)
if err == nil {
	data, err := key.Get()
	if err != nil {
		return nil, fmt.Errorf("couldn't retrieve key data: %w", err)
	}
	info, _ := key.Info()
	logger.Printf("key: %+v", info)
	return data, nil
}

Piecing it together

package main

import (
	"fmt"
	"io/ioutil"
	"log"
	"os"
	"syscall"

	"github.com/DavidGamba/go-getoptions"
	"github.com/jsipprell/keyctl"
	"golang.org/x/crypto/ssh/terminal"
)

var logger = log.New(ioutil.Discard, "", log.LstdFlags)

func main() {
	var timeout int
	opt := getoptions.New()
	opt.Self("", `Saves/Retrieves passwords from/to the Linux keyring.
	It will cache the password for a given timeout.
	If a password doesn't exist in the keyring it will prompt the user for one.`)
	opt.Bool("help", false, opt.Alias("?"))
	opt.Bool("debug", false)
	opt.Bool("print", false, opt.Description("Print password to STDOUT"))
	opt.IntVar(&timeout, "timeout", 900, opt.ArgName("seconds"),
		opt.Description("Timeout in seconds, default 15 minutes"))
	opt.HelpSynopsisArgs("<key-name>")
	remaining, err := opt.Parse(os.Args[1:])
	if opt.Called("help") {
		fmt.Println(opt.Help())
		os.Exit(1)
	}
	if err != nil {
		fmt.Fprintf(os.Stderr, "ERROR: %s\n", err)
		os.Exit(1)
	}
	if opt.Called("debug") {
		logger.SetOutput(os.Stderr)
	}
	logger.Println(remaining)
	if len(remaining) < 1 {
		fmt.Fprintf(os.Stderr, "ERROR: Missing key\n")
		os.Exit(1)
	}
	keyName := remaining[0]

	// Retrieve existing password or ask user to add one
	data, err := GetPassword(keyName)
	if err != nil {
		fmt.Fprintf(os.Stderr, "ERROR: %s\n", err)
		os.Exit(1)
	}

	err = CachePassword(keyName, string(data), uint(timeout))
	if err != nil {
		fmt.Fprintf(os.Stderr, "ERROR: %s\n", err)
	}
	if opt.Called("print") {
		fmt.Printf("%s", data)
	}
}

// GetPassword - Gets a secret from the User Session Keyring.
// If the key doesn't exist, it asks the user to enter the password value.
// It will cache the secret for a given number of seconds.
func GetPassword(name string) ([]byte, error) {
	// Create session
	keyring, err := keyctl.UserSessionKeyring()
	if err != nil {
		return nil, fmt.Errorf("couldn't create keyring session: %w", err)
	}

	// Retrieve
	key, err := keyring.Search(name)
	if err == nil {
		data, err := key.Get()
		if err != nil {
			return nil, fmt.Errorf("couldn't retrieve key data: %w", err)
		}
		info, _ := key.Info()
		logger.Printf("key: %+v", info)
		return data, nil
	}

	// If not found promt user
	fmt.Printf("Enter password for '%s': ", name)
	password, err := terminal.ReadPassword(int(syscall.Stdin))
	fmt.Println()
	if err != nil {
		return nil, fmt.Errorf("failed to read password: %w", err)
	}
	return password, nil
}

// CachePassword - Saves a secret to the User Session Keyring.
// It will cache the secret for a given number of seconds.
//
// To invalidate a password, save it with a 1 second timeout.
func CachePassword(name, password string, timeoutSeconds uint) error {
	// Create session
	keyring, err := keyctl.UserSessionKeyring()
	if err != nil {
		return fmt.Errorf("couldn't create keyring session: %w", err)
	}

	// Store key
	keyring.SetDefaultTimeout(timeoutSeconds)
	key, err := keyring.Add(name, []byte(password))
	if err != nil {
		return fmt.Errorf("couldn't store '%s': %s", name, err)
	}
	info, _ := key.Info()
	logger.Printf("key: %+v", info)
	return nil
}

Bash

The above Go script can be called from bash, it will handle the user prompt.

#!/bin/bash

# Allow interactive operation
./password-cache mykey -t 60
if [[ $? == 0 ]]; then
	# Read from store
	password=$(./password-cache mykey -t 60 --print)
	# Use
	echo "|$password|"
fi

Then we call it the first time:

$ bash bash-script.sh
Enter 'mykey' password:
|password|

The second time around it just retrieves the key as expected:

$ bash bash-script.sh
|password|
$ keyctl list @us
2 keys in keyring:
109050238: --alswrv  1000 65534 keyring: _uid.1000
147013626: --alswrv  1000  1000 user: mykey
$ keyctl print 147013626
password

Migrating count to for_each in Terraform v0.12