Since 1.8 we've supported using gpg-agent to store passwords in memory.
http://subversion.apache.org/docs/release-notes/1.8.html#gpg-agent

Today I was getting asked about some odd behavior that a customer was seeing so
I took some time to investigate.

The behavior of this setup is very wonky and not what a user would expect.
When gpg-agent is setup the first time you connect to a realm you'll be
prompted by the Subversion client for the password.  It'll then store in our
auth cache that the password is stored in gpg-agent.  However, the agent
doesn't get this password from this action.  The next time the user connects to
this realm they are prompted by the pinentry program that is configured with
gpg-agent to get the password.  After that there is a confirmation prompt
requiring they re-enter the password.  Subsequent connections to the realm then
use the password if it is still cached in gpg-agent.  If the agent's cache has
been cleared (timeout or other actions that would clear it) then the pinentry
prompt (confirmation entry included) is repeated.

This seems like a very poor implementation from an end user perspective.  None
of the above behavior is documented in our release notes.  So the average user
is probably going to believe that things are not working properly.  There are
of course some very good reasons for this behavior.

gpg-agent and Subversion's auth system has somewhat contradictory designs.
gpg-agent takes on the responsibility of not just storing the password but also
of the user interface for the user to input the password if it is not stored.
Subversion on the other hand presumes that the cache systems are just dumb
storage and that it has to handle the UI.  Subversion independently stores that
it has a password cached in a given cache system (in a file under
~/.subversion/auth/svn.simple with the file being the MD5 hash of the realm).
It only contacts that system for the cached password if the appropriate
svn.simple entry is present.

With every other Subversion password caching mechanism Subversion would prompt
you for your password the first time you connected to the realm and then if the
password is correct it would store it in the appropriate cache and create the
svn.simple entry.  However, with gpg-agent it does not store the password with
that first use, but does store the svn.simple entry causing subsequent requests
to go to gpg-agent.  Since gpg-agent does not have the password cached it then
prompts the user.

Subversion currently tells gpg-agent when prompting the user for a password to
confirm the password by asking the user to re-enter it.  This choice appears to
have been made because gpg-agent will cache a password without knowing for sure
that the password is correct.  If an incorrect password were to be cached then
the user would have to take steps to clear the gpg-agent cache on their own
(kill -HUP, gpg-connect-agent CLEAR_PASSPHARSE).  Until they did so they would
be prompted for the password by Subversion (since the cached password failed)
on every single connection.  Since the cached password in gpg-agent would be
tried on every connection, the user may run into systems that lock them out as
well.  The choice of a confirmation entry doesn't really seem very good in my
opinion.  First, it only protects against typos, a user entering the wrong
password (though entering the same password) twice will still experience the
problem described above.  Second, the default timeout for passwords in
gpg-agent is 10 minutes.  So now a user using this setup will have to enter
their password twice every 10 minutes.  The caching feature is a convenience
feature, but having to enter the password twice seems not very convenient.

Fortunately there are some thing we can do to resolve these issues.  First
let's talk about the double prompt from pinentry.  This is entirely
unnecessary.  Subversion's API provides two functions to retrieve credentials
from the cache.  A first credential function and a next credential function.
The purpose being to allow multiple credentials to be stored for a realm and
then walked until you find the ones that work.  In practice we've never used
this for passwords because there's no point in storing more than one credential
for a password since only one will work.  However, we can use this to allow us
to drive gpg-agent as it intends.  We do a GET_PASSPHRASE in first and then if
we receive a next call we do a CLEAR_PASSPHRASE followed by a GET_PASSPHRASE
with an error text telling the user the authentication failed (if we're being
run in interactive mode).  I've attached a patch that implements this bit.  But
I haven't committed it because I'm not sure this is necessarily the best
solution.  I'm also abusing the parameters hash to avoid duplicating the
svn_auth__simple_creds_cache_get() function.

The failure to cache on the first connection to the realm issue is a little bit
harder to solve.  There is actually a PRESET_PASSPHARSE call in gpg-agent's
API.  But it only works when gpg-agent is started with
--allow-preset-passphrase.  I think we should make the save function of the
gpg-agent provider implement the PRESET_PASSPHRASE call.  We can document to
users they will have a better experience if they provide the
--allow-preset-passphrase option to gpg-agent when they start it.  We can
ignore errors if it doesn't.

There is another option and that is to use gpg-agent as a dumb store like we do
other caches by combining PRESET_PASSPHRASE in the save function and
GET_PASSPHARSE --no-ask in the first function.  This would allow us to retain
the behavior that the svn client asks for the password and thus not have to
jump through hoops to support the cache system being responsible for the UI to
request the password.  Doing this of course would rquire that
--allow-preset-passphrase be passed.  So I think it'd probably be best to have
a setting in our Subversion config that enables this mode but that then fails
if --allow-preset-passphrase is not enabled on gpg-agent.  This behavior would
give the best experience to our users, but since it's intrusive on gpg-agent's
configuration I don't think it should be default.

Because of these things I believe the patch should be applied and the later two
things should be iterated on top of that.

Thoughts?

Commit message for the patch:
[[[
Make the gpg-agent pinentry not ask for confirmation of password entries and
make it re-prompt if the password is incorrect.

* subversion/libsvn_subr/gpg_agent.c:
  (ATTEMPT_PARAMETER): New macro.
  (send_options, get_cache_id): New functions with code taken out of
    password_get_gpg_agent() so it can be reused.
  (password_get_gpg_agent): Use send_options() and get_cache_id(),
    retrieve the attempt from the parameters and use it to determine
    if we should set an error message that will be displayed in pinentry.
  (simple_gpg_agent_first_creds): Set a iter_baton so we can limit the
    retries, put the iter_baton in the parameters so password_get_gpg_agent()
    can access it.
  (simple_gpg_agent_next_creds): New function, removes the cached password
    and prompts the user again.
  (gpg_agent_simple_provider): Add simple_gpg_agent_next_creds callback.
]]]
Index: subversion/libsvn_subr/gpg_agent.c
===================================================================
--- subversion/libsvn_subr/gpg_agent.c  (revision 1599379)
+++ subversion/libsvn_subr/gpg_agent.c  (working copy)
@@ -72,6 +72,7 @@
 #include "svn_cmdline.h"
 #include "svn_checksum.h"
 #include "svn_string.h"
+#include "svn_hash.h"
 
 #include "auth.h"
 #include "private/svn_auth_private.h"
@@ -81,6 +82,7 @@
 #ifdef SVN_HAVE_GPG_AGENT
 
 #define BUFFER_SIZE 1024
+#define ATTEMPT_PARAMETER "svn.simple.gpg_agent.attempt"
 
 /* Modify STR in-place such that blanks are escaped as required by the
  * gpg-agent protocol. Return a pointer to STR. */
@@ -99,6 +101,24 @@ escape_blanks(char *str)
   return str;
 }
 
+/* Generate the string CACHE_ID_P based on the REALMSTRING allocated in
+ * RESULT_POOL using SCRATCH_POOL for temporary allocations.  This is similar
+ * to other password caching mechanisms. */
+static svn_error_t *
+get_cache_id(const char **cache_id_p, const char *realmstring,
+             apr_pool_t *scratch_pool, apr_pool_t *result_pool)
+{
+  const char *cache_id = NULL;
+  svn_checksum_t *digest = NULL;
+
+  SVN_ERR(svn_checksum(&digest, svn_checksum_md5, realmstring,
+                       strlen(realmstring), scratch_pool));
+  cache_id = svn_checksum_to_cstring(digest, result_pool);
+  *cache_id_p = cache_id;
+
+  return SVN_NO_ERROR;
+}
+
 /* Attempt to read a gpg-agent response message from the socket SD into
  * buffer BUF. Buf is assumed to be N bytes large. Return TRUE if a response
  * message could be read that fits into the buffer. Else return FALSE.
@@ -266,49 +286,20 @@ find_running_gpg_agent(int *new_sd, apr_pool_t *po
   return SVN_NO_ERROR;
 }
 
-/* Implementation of svn_auth__password_get_t that retrieves the password
-   from gpg-agent */
-static svn_error_t *
-password_get_gpg_agent(svn_boolean_t *done,
-                       const char **password,
-                       apr_hash_t *creds,
-                       const char *realmstring,
-                       const char *username,
-                       apr_hash_t *parameters,
-                       svn_boolean_t non_interactive,
-                       apr_pool_t *pool)
+static svn_boolean_t
+send_options(int sd, char *buf, size_t n, apr_pool_t *scratch_pool)
 {
-  int sd;
-  const char *p = NULL;
-  char *ep = NULL;
-  char *buffer;
-  const char *request = NULL;
-  const char *cache_id = NULL;
   const char *tty_name;
   const char *tty_type;
   const char *lc_ctype;
   const char *display;
-  svn_checksum_t *digest = NULL;
-  char *password_prompt;
-  char *realm_prompt;
 
-  *done = FALSE;
-
-  SVN_ERR(find_running_gpg_agent(&sd, pool));
-  if (sd == -1)
-    return SVN_NO_ERROR;
-
-  buffer = apr_palloc(pool, BUFFER_SIZE);
-
   /* Send TTY_NAME to the gpg-agent daemon. */
   tty_name = getenv("GPG_TTY");
   if (tty_name != NULL)
     {
-      if (!send_option(sd, buffer, BUFFER_SIZE, "ttyname", tty_name, pool))
-        {
-          close(sd);
-          return SVN_NO_ERROR;
-        }
+      if (!send_option(sd, buf, n, "ttyname", tty_name, scratch_pool))
+        return FALSE;
     }
 
   /* Send TTY_TYPE to the gpg-agent daemon. */
@@ -315,11 +306,8 @@ find_running_gpg_agent(int *new_sd, apr_pool_t *po
   tty_type = getenv("TERM");
   if (tty_type != NULL)
     {
-      if (!send_option(sd, buffer, BUFFER_SIZE, "ttytype", tty_type, pool))
-        {
-          close(sd);
-          return SVN_NO_ERROR;
-        }
+      if (!send_option(sd, buf, n, "ttytype", tty_type, scratch_pool))
+        return FALSE;
     }
 
   /* Compute LC_CTYPE. */
@@ -332,11 +320,8 @@ find_running_gpg_agent(int *new_sd, apr_pool_t *po
   /* Send LC_CTYPE to the gpg-agent daemon. */
   if (lc_ctype != NULL)
     {
-      if (!send_option(sd, buffer, BUFFER_SIZE, "lc-ctype", lc_ctype, pool))
-        {
-          close(sd);
-          return SVN_NO_ERROR;
-        }
+      if (!send_option(sd, buf, n, "lc-ctype", lc_ctype, scratch_pool))
+        return FALSE;
     }
 
   /* Send DISPLAY to the gpg-agent daemon. */
@@ -343,27 +328,69 @@ find_running_gpg_agent(int *new_sd, apr_pool_t *po
   display = getenv("DISPLAY");
   if (display != NULL)
     {
-      if (!send_option(sd, buffer, BUFFER_SIZE, "display", display, pool))
-        {
-          close(sd);
-          return SVN_NO_ERROR;
-        }
+      if (!send_option(sd, buf, n, "display", display, scratch_pool))
+        return FALSE;
     }
 
-  /* Create the CACHE_ID which will be generated based on REALMSTRING similar
-     to other password caching mechanisms. */
-  SVN_ERR(svn_checksum(&digest, svn_checksum_md5, realmstring,
-                       strlen(realmstring), pool));
-  cache_id = svn_checksum_to_cstring(digest, pool);
+  return TRUE;
+}
 
+/* Implementation of svn_auth__password_get_t that retrieves the password
+   from gpg-agent */
+static svn_error_t *
+password_get_gpg_agent(svn_boolean_t *done,
+                       const char **password,
+                       apr_hash_t *creds,
+                       const char *realmstring,
+                       const char *username,
+                       apr_hash_t *parameters,
+                       svn_boolean_t non_interactive,
+                       apr_pool_t *pool)
+{
+  int sd;
+  const char *p = NULL;
+  char *ep = NULL;
+  char *buffer;
+  const char *request = NULL;
+  const char *cache_id = NULL;
+  char *password_prompt;
+  char *realm_prompt;
+  char *error_prompt;
+  int *attempt;
+
+  *done = FALSE;
+
+  attempt = svn_hash_gets(parameters, ATTEMPT_PARAMETER);
+
+  SVN_ERR(find_running_gpg_agent(&sd, pool));
+  if (sd == -1)
+    return SVN_NO_ERROR;
+
+  buffer = apr_palloc(pool, BUFFER_SIZE);
+
+  if (!send_options(sd, buffer, BUFFER_SIZE, pool))
+    {
+      close(sd);
+      return SVN_NO_ERROR;
+    }
+
+  SVN_ERR(get_cache_id(&cache_id, realmstring, pool, pool));
+
   password_prompt = apr_psprintf(pool, _("Password for '%s': "), username);
   realm_prompt = apr_psprintf(pool, _("Enter your Subversion password for %s"),
                               realmstring);
+  if (*attempt == 1)
+    /* X means no error to the gpg-agent protocol */
+    error_prompt = apr_pstrdup(pool, "X");
+  else
+    error_prompt = apr_pstrdup(pool, _("Authentication failed"));
+
   request = apr_psprintf(pool,
-                         "GET_PASSPHRASE --data %s--repeat=1 "
-                         "%s X %s %s\n",
+                         "GET_PASSPHRASE --data %s"
+                         "%s %s %s %s\n",
                          non_interactive ? "--no-ask " : "",
                          cache_id,
+                         escape_blanks(error_prompt),
                          escape_blanks(password_prompt),
                          escape_blanks(realm_prompt));
 
@@ -441,14 +468,111 @@ simple_gpg_agent_first_creds(void **credentials,
                              const char *realmstring,
                              apr_pool_t *pool)
 {
-  return svn_auth__simple_creds_cache_get(credentials, iter_baton,
-                                          provider_baton, parameters,
-                                          realmstring, password_get_gpg_agent,
-                                          SVN_AUTH__GPG_AGENT_PASSWORD_TYPE,
-                                          pool);
+  svn_error_t *err;
+  int *attempt = apr_palloc(pool, sizeof(*attempt));
+
+  *attempt = 1;
+  svn_hash_sets(parameters, ATTEMPT_PARAMETER, attempt);
+  err = svn_auth__simple_creds_cache_get(credentials, iter_baton,
+                                         provider_baton, parameters,
+                                         realmstring, password_get_gpg_agent,
+                                         SVN_AUTH__GPG_AGENT_PASSWORD_TYPE,
+                                         pool);
+  *iter_baton = attempt;
+
+  return err;
 }
 
+/* An implementation of svn_auth_provider_t::next_credentials() */
+static svn_error_t *
+simple_gpg_agent_next_creds(void **credentials,
+                            void *iter_baton,
+                            void *provider_baton,
+                            apr_hash_t *parameters,
+                            const char *realmstring,
+                            apr_pool_t *pool)
+{
+  int *attempt = (int *)iter_baton;
+  int sd;
+  char *buffer;
+  const char *cache_id = NULL;
+  const char *request = NULL;
 
+  *credentials = NULL;
+
+  /* The users previous credentials failed so first remove the cached entry,
+   * before trying to retrieve them again.  Because gpg-agent stores cached
+   * credentials immediately upon retrieving them, this gives us the
+   * opportunity to prompt remove the invalid credentials and prompt the
+   * user again.  While it's possible that server side issues could trigger
+   * this, this cache is emphermial so at worst we're just speeding up
+   * when the user would need to reenter their password. */
+
+  if (svn_hash_gets(parameters, SVN_AUTH_PARAM_NON_INTERACTIVE))
+    {
+      /* In this case since we're running non-interactively we do not
+       * want to clear the cache since the user was never prompted by
+       * gpg-agent to set a password. */
+      return SVN_NO_ERROR;
+    }
+
+  *attempt = *attempt + 1;
+
+  SVN_ERR(find_running_gpg_agent(&sd, pool));
+  if (sd == -1)
+    return SVN_NO_ERROR;
+
+  buffer = apr_palloc(pool, BUFFER_SIZE);
+
+  if (!send_options(sd, buffer, BUFFER_SIZE, pool))
+    {
+      close(sd);
+      return SVN_NO_ERROR;
+    }
+
+  SVN_ERR(get_cache_id(&cache_id, realmstring, pool, pool));
+
+  request = apr_psprintf(pool, "CLEAR_PASSPHRASE %s\n", cache_id);
+
+  if (write(sd, request, strlen(request)) == -1)
+    {
+      close(sd);
+      return SVN_NO_ERROR;
+    }
+
+  if (!receive_from_gpg_agent(sd, buffer, BUFFER_SIZE))
+    {
+      close(sd);
+      return SVN_NO_ERROR;
+    }
+
+  if (strncmp(buffer, "OK\n", 3) != 0)
+    {
+      close(sd);
+      return SVN_NO_ERROR;
+    }
+
+  /* TODO: This attempt limit hard codes it at 3 attempts (or 2 retries)
+   * which matches that svn command line client's retry_limit as set in
+   * svn_cmdline_create_auth_baton().  It would be nice to have that
+   * limit reflected here but that violates the boundry between the
+   * prompt provider and the cache provider.  gpg-agent is acting as
+   * both here due to the peculiarties of their design so we'll have to
+   * live with this for now.  Note that when these failures get exceeded
+   * it'll eventually fall back on the retry limits of whatever prompt
+   * provider is in effect, so this effectively doubles the limit. */
+  if (*attempt < 4)
+    return svn_auth__simple_creds_cache_get(credentials, &iter_baton,
+                                            provider_baton, parameters,
+                                            realmstring,
+                                            password_get_gpg_agent,
+                                            SVN_AUTH__GPG_AGENT_PASSWORD_TYPE,
+                                            pool);
+
+  return SVN_NO_ERROR;
+}
+
+
 /* An implementation of svn_auth_provider_t::save_credentials() */
 static svn_error_t *
 simple_gpg_agent_save_creds(svn_boolean_t *saved,
@@ -469,7 +593,7 @@ simple_gpg_agent_save_creds(svn_boolean_t *saved,
 static const svn_auth_provider_t gpg_agent_simple_provider = {
   SVN_AUTH_CRED_SIMPLE,
   simple_gpg_agent_first_creds,
-  NULL,
+  simple_gpg_agent_next_creds,
   simple_gpg_agent_save_creds
 };
 

Reply via email to