Thursday 8 February 2024

How do I read/store a password in a possibly locked gnome-keyring-manager from the shell without breaking existing gnome-keyring-manager clients?

 Another of my stackoverflow self-answered questions

https://stackoverflow.com/questions/77962998/how-do-i-read-store-a-password-in-a-possibly-locked-gnome-keyring-manager-from-t/77962999#77962999 

I want to use secret-tool and gnome-keyring-daemon from a shell session, to store and retrieve passwords. The shell session might be gnome-terminal under the X console, or independently of whether or not I also have an X login, it might be an ssh session or text login.

I do not want gnome-keyring-daemon prompting to unlock a keyring on some remote X session, I do not want it to prompt unlocking of a keyring at all.

If there is an unlocked keyring (e.g. an X console session) then I want to use that keyring session, but if not, then I don't want to unlock an existing keyring as that could interfere with the available credentials of that X session, instead I want to run a private instance of gnome-keyring-daemon just long enough to get the secret, without interfering with any existing clients of any existing gnome-keyring-daemon (e.g. seahorse, chrome, evolution, ssh-agent -- I don't want suddenly asking for ssh keys because something called gnome-keyring-daemon --replace).

And I want to do it from the command line with without perl and python tools.

Many solutions make use of gnome-keyring-daemon --unlock --replace which isn't an option as it destroys the relationship of existing clients.


The solution has multiple steps:

  1. Without prompting to unlock, check if there is an unlocked keyring-manager that secret-tool can use
  2. If not, create an isolated dbus environment in which you run and unlock a gnome-keyring-daemon which you can then use with secret-tool.

Is a keyring already available?

There are various solutions proposed to query whether or not an unlocked keyring is available. Some of this will start gnome-keyring-daemon if it is not running.

The method I settled on is:

busctl --timeout=10 --user get-property \
       org.freedesktop.secrets /org/freedesktop/secrets/collection/login \
       org.freedesktop.Secret.Collection Locked

There are 4 results:

  1. failure to get result
  2. get "locked"
  3. get "unlocked"
  4. get other result

I process it like this:

is-unlocked() {
  local locked
  if locked=$(busctl --timeout=10 --user get-property org.freedesktop.secrets \
              /org/freedesktop/secrets/collection/login org.freedesktop.Secret.Collection Locked
             ) && read _ locked _ <<<"$locked"
  then case "${locked,,}" in
            true) return 1;;
            false) return 0;;
            *) return 2;;     # try to start one
       esac
  else if type -p gnome-keyring-daemon 2>/dev/null
       then return 2
       else return 3
       fi
  fi
}

If the function returns 3 then there is nothing to be done, if it returns 0 then it is usable, otherwise a private gnome-keyring-daemon is required.

Launching a private keyring

Most of what I've read in all the various answers about launching gnome-keyring-daemon, damage the relationship with existing clients, causing them to fail. Evolution can't connect any more until it is fully restarted (and that takes a bit of doing), seahorse can't change any secrets (although it has cached what it has), ssh-agent doesn't have the passphrase for your keys any more and asks you...

If we want a private unlocked gnome-keyring-daemon we have to use either --login or --unlock and feed the password on stdin, but many people also suggest --replace which is misleading because without other precautions that aren't mentioned, it closes the existing daemon and ruins all the client sessions.

We need to run our private unlocked gnome-keyring-daemon in a private dbus session to avoid other interferences that will happen even if we don't use --replace

You'd think that dbus-run-session or bus-launch --exit-with-session would work, but you'd be wrong, they fight over the contents of XDG_RUNTIME_DIR where all the control sockets are kept, and whether or not this matters partly depends on whether you've ssh'd in or not and whether or not the keyring is locked.

Extensive experiments lead me to conclude that dbus-run-session is ideal provided that XDG_RUNTIME_DIR is also private.

Getting a password using a private gome-keyring-daemon might work something like this:

echo -n "$keyring_password" | \
     XDG_RUNTIME_DIR=${XDG_RUNTIME_DIR}/sub/$$ dbus-run-session -- \
     bash --noprofile --norc -c \
          'eval export $(gnome-keyring-daemon --replace --unlock ) && is-unlocked "$@"' keyring-manager "$@"

but it's not enough. You need to mkdir and chmod the temporary XDG_RUNTIME_DIR (and cleanup). You also need a lot of flow control so that you can separate your stderr of the different processes.

Also you don't want to be doing: echo -n "$keyring_password" because in bash set -x mode you will leak the password to stderr (or ${BASH_XTRACEFD}).

I use something like this:

with-private-keyring-manager() {
  export -f is-unlocked # so it is easily available inside the private session
  mkdir -p "${XDG_RUNTIME_DIR}/sub/$$" && 
  chmod 700 "${XDG_RUNTIME_DIR}/sub/$$" &&
  { tr -d $'\n' <<<"$keyring_password" | \
   XDG_RUNTIME_DIR=${XDG_RUNTIME_DIR}/sub/$$ dbus-run-session -- \
   bash --noprofile --norc -c \
        'exec >/dev/fd/4 && eval export $(gnome-keyring-daemon --replace --unlock ) &&  { is-unlocked <&- || exit 253 ; } <&3 && "$@"' scx-keyring-manager "$@"
  } 3<&0 2>&${BASH_XTRACEFD:-2} 4>&1 1>&2
  set -- $?
  rm -fr "${XDG_RUNTIME_DIR}/sub/$$"
  return $1 # saved exit code
}

What I'm trying to do here is preserve the original stdin, stdout, stderr of the calling context to the passed command and args in $@, while still feeding the password on stdin, and ensuring that stderr of dbus-run-session and stdout of anything in there not taint the process stdout.

The missing pieces

You can call get-secret and put-secret with the typical arguments that secret-tool has

put-secret() {
  with-keyring secret-tool store "$@"
}
get-secret() {
  with-keyring secret-tool lookup "$@"
}
with-keyring() {
  if is-unlocked
  then "$@"
  else get-keyring-password && with-private-keyring-manager "$@"
  fi
}
get-keyring-password() {
  read -r -s -p $"$prompt: " keyring_password && test ${#keyring_password} != 0
}

examples

If you run these in a terminal as part of the X console login, they will generally work without a password. If you lock the keyring with seahorse then you will need to provide the password. Or, if you just ssh into a machine that doesn't have a keyring running you will need to provide a password.

put-secret --label "Test secret" purpose test <<<"It's a secret"
get-secret purpose test

When running the tests, check the existence of gnome-keyring-daemon processes before and after. Typically any that were running before should continue undisturbed.

Sometimes when running the tests, if you are on the X console and you locked your keyring with seahorse, you will be spontaneously prompted to unlock it for the benefit of existing keyring clients which are nothing to do with what you are testing here

Tips on passwords in the shell

You don't really want to store passwords in the shell because you'll leak them by having them on as command line parameters of externally invoked processes, accidentally have the in environment variables which will leak to external processes and will be visible if set -x is active (and for protection against that, use things like test ${#password} = 0 instead of test -z "${password}" and instead of echo "$password" | have <<<"$password" and instead of echo -n "$password" | have tr -d '\n' <<<"$password |

You probably would rather use dialog to pipe the password straight into keyring manager:

The full script

To make it easy to cut-n-paste the full script is here.

#! /bin/bash

is-unlocked() {
  local locked
  if locked=$(busctl --timeout=10 --user get-property org.freedesktop.secrets \
              /org/freedesktop/secrets/collection/login org.freedesktop.Secret.Collection Locked
             ) && read _ locked _ <<<"$locked"
  then case "${locked,,}" in
            true) return 1;;
            false) return 0;;
            *) return 2;;     # try to start one
       esac
  else if type -p gnome-keyring-daemon 2>/dev/null
       then return 2
       else return 3
       fi
  fi
}

with-private-keyring-manager() {
  export -f is-unlocked # so it is easily available inside the private session
  mkdir -p "${XDG_RUNTIME_DIR}/sub/$$" && 
  chmod 700 "${XDG_RUNTIME_DIR}/sub/$$" &&
  { tr -d $'\n' <<<"$keyring_password" | \
   XDG_RUNTIME_DIR=${XDG_RUNTIME_DIR}/sub/$$ dbus-run-session -- \
   bash --noprofile --norc -c \
        'exec >/dev/fd/4 && eval export $(gnome-keyring-daemon --replace --unlock ) &&  { is-unlocked <&- || exit 253 ; } <&3 && "$@"' scx-keyring-manager "$@"
  } 3<&0 2>&${BASH_XTRACEFD:-2} 4>&1 1>&2
  set -- $?
  rm -fr "${XDG_RUNTIME_DIR}/sub/$$"
  return $1 # saved exit code
}

put-secret() {
  with-keyring secret-tool store "$@"
}
get-secret() {
  with-keyring secret-tool lookup "$@"
}
with-keyring() {
  if is-unlocked
  then "$@"
  else get-keyring-password && with-private-keyring-manager "$@"
  fi
}
get-keyring-password() {
  read -r -s -p $"$prompt: " keyring_password && test ${#keyring_password} != 0
}

put-secret --label "Test secret" purpose test <<<"It's a secret"
get-secret purpose test

No comments:

Post a Comment