From 945364c19a78279edbf5f37ce6256f1352787e4b Mon Sep 17 00:00:00 2001
From: Patrick Uiterwijk <puiterwijk@redhat.com>
Date: Feb 28 2017 23:27:18 +0000
Subject: Implement a method to change key expiration


Signed-off-by: Patrick Uiterwijk <puiterwijk@redhat.com>

---

diff --git a/ChangeLog b/ChangeLog
index f640c96..272f56a 100644
--- a/ChangeLog
+++ b/ChangeLog
@@ -1,3 +1,13 @@
+2017-02-28  Patrick Uiterwijk  <puiterwijk@redhat.com>
+
+	* src/server.py: Implement change-key-expiration
+
+	* src/server_common.py: Implement GPG key edit
+
+	* src/bridge.py: Implement change-key-expiration
+
+	* src/client.py: Implement change-key-expiration
+
 2017-02-20  Patrick Uiterwijk  <puiterwijk@redhat.com>
 
 	* src/bind_methods.py: Added option for public-only PKCS11 tokens.
diff --git a/src/bridge.py b/src/bridge.py
index 8dda0b8..e19e5ae 100644
--- a/src/bridge.py
+++ b/src/bridge.py
@@ -1007,6 +1007,9 @@ request_types = {
     'modify-key': RT((SF('key'), SF('new-name', optional=True))),
     'list-key-users': RT((SF('key'),)),
     'grant-key-access': RT((SF('key'), SF('name'))),
+    'change-key-expiration': RT((SF('key'),
+                                 SF('subkey', optional=True),
+                                 YYYYMMDDField('expire-date', optional=True))),
     'revoke-key-access': RT((SF('key'), SF('name'))),
     'get-public-key': RT((SF('key'),)),
     'change-passphrase': RT((SF('key'),)),
@@ -1015,7 +1018,7 @@ request_types = {
                      BoolField('armor', optional=True)),
                     max_payload=1024*1024*1024),
     'sign-git-tag': RT((SF('key'),),
-                      max_payload=1024*1024*1024),
+                       max_payload=1024*1024*1024),
     'sign-container': RT((SF('key'),
                           SF('docker-reference')),
                          max_payload=1024*1024*1024),
diff --git a/src/client.py b/src/client.py
index 88d79b4..106e398 100644
--- a/src/client.py
+++ b/src/client.py
@@ -823,6 +823,34 @@ def cmd_grant_key_access(conn, args):
         with open(o2.passphrase_file, 'w') as ppfile:
             ppfile.write(bound_passphrase)
 
+def cmd_change_key_expiration(conn, args):
+    p2 = optparse.OptionParser(usage='%prog change-key-expiration key',
+                               description='Change key expiration date')
+    p2.add_option('--expire-date', metavar='YYYY-MM-DD',
+                  help='Key expiration date')
+    p2.add_option('--subkey', help='Subkey identifier')
+    (o2, args) = p2.parse_args(args)
+    if len(args) != 1:
+        p2.error('key name expected')
+    if o2.expire_date is not None:
+        if not utils.yyyy_mm_dd_is_valid(o2.expire_date):
+            p2.error('invalid --expire-date')
+    if o2.subkey is not None:
+        if not utils.is_int(o2.subkey):
+            p2.error('invalid subkey identifier')
+
+    passphrase = read_key_passphrase(conn.config)
+
+    args = {'key': safe_string(args[0])}
+    if o2.expire_date is not None:
+        args['expire-date'] = o2.expire_date
+    if o2.subkey is not None:
+        args['subkey'] = o2.subkey
+    conn.connect('change-key-expiration', args)
+    conn.empty_payload()
+    conn.send_inner({'passphrase': passphrase})
+    conn.read_response(no_payload=True)
+
 def cmd_revoke_key_access(conn, args):
     p2 = optparse.OptionParser(usage='%prog revoke-key-access [options] key '
                                'user',
@@ -1451,6 +1479,8 @@ command_handlers = {
     'modify-key': (cmd_modify_key, 'Modify a key'),
     'list-key-users': (cmd_list_key_users, 'List users that can access a key'),
     'grant-key-access': (cmd_grant_key_access, 'Grant key access to a user'),
+    'change-key-expiration': (cmd_change_key_expiration,
+                              'Change key expiration date'),
     'revoke-key-access': (cmd_revoke_key_access,
                           'Revoke key acess from a user'),
     'get-public-key': (cmd_get_public_key, 'Output public part of the key'),
diff --git a/src/server.py b/src/server.py
index 12102a5..1c72143 100644
--- a/src/server.py
+++ b/src/server.py
@@ -1194,6 +1194,35 @@ def cmd_get_public_key(db, conn):
     conn.send_reply_payload(payload)
 
 @request_handler()
+def cmd_change_key_expiration(db, conn):
+    (access, passphrase) = conn.authenticate_key_admin(db)
+    expire = conn.safe_outer_field('expire-date')
+    if expire is None:
+        # This means to mark the key as non-expiring
+        expire = ''
+    subkey = conn.safe_outer_field('subkey')
+    states = []
+    if subkey is not None:
+        keyarg = 'KEY %d' % int(subkey)
+        states.extend([(gpgme.STATUS_GET_LINE, 'keyedit.prompt', keyarg),
+                       (gpgme.STATUS_GOT_IT, None, None)])
+    states.extend([(gpgme.STATUS_GET_LINE, 'keyedit.prompt', 'EXPIRE'),
+                   (gpgme.STATUS_GOT_IT, None, None),
+                   (gpgme.STATUS_GET_LINE, 'keygen.valid', expire),
+                   (gpgme.STATUS_GOT_IT, None, None),
+                   (gpgme.STATUS_USERID_HINT, None, None),
+                   (gpgme.STATUS_NEED_PASSPHRASE, None, None),
+                   (gpgme.STATUS_GET_HIDDEN, 'passphrase.enter', passphrase),
+                   (gpgme.STATUS_GOT_IT, None, None),
+                   (gpgme.STATUS_GOOD_PASSPHRASE, None, None),
+                   (gpgme.STATUS_GET_LINE, 'keyedit.prompt', 'SAVE'),
+                   (gpgme.STATUS_GOT_IT, None, None),
+                   (gpgme.STATUS_EOF, None, None)])
+    server_common.gpg_edit_key(conn.config, access.key.fingerprint, states)
+    db.commit()
+    conn.send_reply_ok_only()
+
+@request_handler()
 def cmd_change_passphrase(db, conn):
     (access, key_passphrase) = conn.authenticate_user(db)
     new_passphrase = conn.inner_field('new-passphrase', required=True)
diff --git a/src/server_common.py b/src/server_common.py
index 3a7db61..f42f97f 100644
--- a/src/server_common.py
+++ b/src/server_common.py
@@ -17,6 +17,7 @@
 # Red Hat Author: Patrick Uiterwijk <puiterwijk@redhat.com>
 
 import cStringIO
+import copy
 import crypt
 import json
 import logging
@@ -218,6 +219,18 @@ class GPGError(Exception):
     '''Error performing a GPG operation.'''
     pass
 
+class GPGEditError(GPGError):
+    '''Error performing a GPG edit operation.'''
+    def __init__(self, msg, expected_state, actual_state, expected_arg,
+                 actual_arg, *args):
+        self.message = msg
+        self.expected_state = expected_state
+        self.actual_state = actual_state
+        self.expected_arg = expected_arg
+        self.actual_arg = actual_arg
+        super(GPGEditError, self).__init__(msg, *args)
+
+
 class GPGConfiguration(utils.Configuration):
 
     def _add_defaults(self, defaults):
@@ -276,6 +289,74 @@ def gpg_delete_key(config, fingerprint):
     key = ctx.get_key(fingerprint, True)
     ctx.delete(key, True)
 
+
+def gpg_edit_key(config, fingerprint, input_states):
+    '''Edit a GPG key
+
+    This uses pygpgme.Context.edit, and implements a state machine to perform
+    the full conversation.
+
+    This code is insane, but it is what it is due to insanity at (py)gpg(me).
+
+    input_states is a list of three-tuples, describing the expected state and
+    argument at every point in the conversation, and the answer we are going to
+    send.
+
+    example: [(gpgme.STATUS_GET_LINE, 'keyedit.prompt', 'KEY 1')]
+    '''
+    error = None
+    states = copy.copy(input_states)
+    replies = []
+    out_fd = cStringIO.StringIO()
+
+    def update_out():
+        out_fd.seek(0)
+        replies.append(out_fd.read())
+        out_fd.seek(0)
+        out_fd.truncate()
+
+    def edit_callback(status, arg, in_fd):
+        global error
+
+        update_out()
+
+        if len(states) == 0:
+            error = GPGEditError('More states expected',
+                                 None, status, None, arg)
+            raise error
+        expected_status, expected_arg, answer = states.pop(0)
+        if expected_status != status:
+            error = GPGEditError('Mismatched status',
+                                 expected_status, status, expected_arg, arg)
+            raise error
+        if expected_arg is not None and arg != expected_arg:
+            error = GPGEditError('Mismatched argument',
+                                 expected_status, status, expected_arg, arg)
+            raise error
+        if answer is not None:
+            if in_fd == -1:
+                error = GPGEditError('No input fd when trying to answer',
+                                     expected_status, status, expected_arg,
+                                     arg)
+                raise error
+            else:
+                os.write(in_fd, '%s\n' % answer)
+
+    # We are done setting everything up... Now let's do this
+    ctx = _gpg_open(config)
+    key = ctx.get_key(fingerprint, True)
+    try:
+        ctx.edit(key, edit_callback, out_fd)
+    except gpgme.GpgmeError as ex:
+        # This is because gpgme hides all errors: every error we throw gets
+        # thrown up to the edit call as gpgme.GpgmeError('General error')
+        if error is not None:
+            raise error
+        else:
+            raise ex
+    return replies
+
+
 def _restore_gnupg_home(config, backup_dir):
     '''Restore config.gnupg_home from a backup in backup_dir.'''
     tmp_dir = tempfile.mktemp(prefix=os.path.basename(config.gnupg_home),
diff --git a/src/utils.py b/src/utils.py
index 9c6855f..1a4f9db 100644
--- a/src/utils.py
+++ b/src/utils.py
@@ -583,6 +583,14 @@ def yyyy_mm_dd_is_valid(s):
         return False
     return True
 
+def is_int(s):
+    '''Return True if s is a valid int.'''
+    try:
+        int(s)
+        return True
+    except:
+        return False
+
 # Threading utilities
 
 class WorkerQueueOrphanedError(Exception):
diff --git a/tests/key-expiration.at b/tests/key-expiration.at
new file mode 100644
index 0000000..f2bac95
--- /dev/null
+++ b/tests/key-expiration.at
@@ -0,0 +1,195 @@
+# Copyright (C) 2017 Red Hat, Inc.  All rights reserved.
+#
+# This copyrighted material is made available to anyone wishing to use, modify,
+# copy, or redistribute it subject to the terms and conditions of the GNU
+# General Public License v.2.  This program is distributed in the hope that it
+# will be useful, but WITHOUT ANY WARRANTY expressed or implied, including the
+# implied warranties of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+# See the GNU General Public License for more details.  You should have
+# received a copy of the GNU General Public License along with this program; if
+# not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth
+# Floor, Boston, MA 02110-1301, USA.  Any Red Hat trademarks that are
+# incorporated in the source code or documentation are not subject to the GNU
+# General Public License and may only be used or replicated with the express
+# permission of Red Hat, Inc.
+#
+# Red Hat Author: Patrick Uiterwijk <puiterwijk@redhat.com>
+
+AT_SETUP([Key expiration modification check])
+
+mkdir ca client bridge server gnupg rpm
+chmod 700 gnupg
+
+AT_DATA([nss_password_file], [[nss-pw
+]])
+AT_DATA([pkcs12_password_file], [[pk12-pw
+]])
+
+# Set up a CA and create all certificates
+AT_CHECK([certutil -d ca -N -f nss_password_file])
+# Specify serial number (-m) explicitly because it is time-based by default,
+# and creating certificates quickly can result in a collision.
+AT_CHECK([certutil -d ca -S -f nss_password_file -z /dev/null -n my-ca \
+		-s 'CN=My CA' -t CT,, -x -v 120 -m 1], , , [ignore])
+AT_CHECK([certutil -d ca -L -n my-ca -a > ca.pem])
+AT_CHECK([certutil -d ca -S -f nss_password_file -z /dev/null \
+		-n sigul-bridge-cert -s 'CN=localhost,OU=bridge' -c my-ca \
+		-t u,, -v 120 -m 2], , , [ignore])
+AT_CHECK([pk12util -d ca -o bridge.p12 -n sigul-bridge-cert \
+		-k nss_password_file -w pkcs12_password_file], , [ignore])
+AT_CHECK([certutil -d ca -S -f nss_password_file -z /dev/null \
+		-n sigul-server-cert -s 'CN=localhost,OU=server' -c my-ca \
+		-t u,, -v 120 -m 3], , , [ignore])
+AT_CHECK([pk12util -d ca -o server.p12 -n sigul-server-cert \
+		-k nss_password_file -w pkcs12_password_file], , [ignore])
+AT_CHECK([certutil -d ca -S -f nss_password_file -z /dev/null \
+		-n sigul-client-cert -s 'CN=root' -c my-ca -t u,, -v 120 -m 4],
+	 , , [ignore])
+AT_CHECK([pk12util -d ca -o client.p12 -n sigul-client-cert \
+		-k nss_password_file -w pkcs12_password_file], , [ignore])
+
+
+# Set up and start bridge:
+AT_CHECK([certutil -d bridge -N -f nss_password_file])
+AT_CHECK([certutil -d bridge -A -n my-ca -t CT,, -a -i ca.pem])
+AT_CHECK([pk12util -d bridge -i bridge.p12 -k nss_password_file \
+		-w pkcs12_password_file], , [ignore])
+rm bridge.p12
+
+[cat > bridge/bridge.conf <<EOF
+[bridge]
+required-fas-group:
+[daemon]
+unix-user:
+unix-group:
+[nss]
+nss-dir: $(pwd)/bridge
+nss-password: nss-pw
+EOF]
+
+AT_CHECK([sigul_bridge -c bridge/bridge.conf -d -v -v \
+		--internal-log-dir="$(pwd)/bridge" \
+		--internal-pid-dir="$(pwd)/bridge"])
+
+
+# Set up and start server:
+AT_CHECK([certutil -d server -N -f nss_password_file])
+AT_CHECK([certutil -d server -A -n my-ca -t CT,, -a -i ca.pem])
+AT_CHECK([pk12util -d server -i server.p12 -k nss_password_file \
+		-w pkcs12_password_file], , [ignore])
+rm server.p12
+
+mkdir server/gnupg/
+[cat > server/server.conf <<EOF
+[server]
+bridge-hostname: localhost
+# This check is lenient here since we try different usernames
+lenient-username-check: yes
+[database]
+database-path: $(pwd)/server/server.sqlite
+[gnupg]
+gnupg-home: $(pwd)/server/gnupg
+gnupg-key-type: RSA
+gnupg-key-usage: sign
+[daemon]
+unix-user:
+unix-group:
+[nss]
+nss-dir: $(pwd)/server
+nss-password: nss-pw
+[binding]
+enabled: test
+EOF]
+
+AT_CHECK([sigul_server_create_db -c server/server.conf -v -v])
+AT_CHECK([printf 'rootroot\0' | \
+		sigul_server_add_admin -c server/server.conf --batch -v -v \
+		-n root])
+
+AT_CHECK([sigul_server -c server/server.conf -d -v -v \
+		--internal-log-dir="$(pwd)/server" \
+		--internal-pid-dir="$(pwd)/server"])
+
+
+# Set up client
+AT_CHECK([certutil -d client -N -f nss_password_file])
+AT_CHECK([certutil -d client -A -n my-ca -t CT,, -a -i ca.pem])
+AT_CHECK([pk12util -d client -i client.p12 -k nss_password_file \
+		-w pkcs12_password_file], , [ignore])
+rm client.p12
+
+[cat > client/client.conf <<EOF
+[client]
+bridge-hostname: localhost
+server-hostname: localhost
+user-name: root
+[nss]
+nss-dir: $(pwd)/client
+nss-password: nss-pw
+[binding]
+enabled: test
+EOF]
+
+# Import test key
+AT_CHECK([printf 'rootroot\0imported\0imported-key-pw\0' | \
+ 		sigul -c client/client.conf --batch -v -v \
+		import-key imported-key \
+		"$abs_srcdir/tests/imported-secret-key.asc"])
+AT_CHECK([printf 'rootroot\0' | \
+		sigul -c client/client.conf --batch -v -v \
+		list-keys], ,
+[imported-key
+])
+AT_CHECK([printf 'imported-key-pw\0' | \
+		sigul -c client/client.conf --batch -v -v \
+		get-public-key imported-key > public.asc])
+AT_CHECK([gpg -q --homedir gnupg --import public.asc])
+rm public.asc
+
+# First make sure neither of the keys have expiration dates to start with
+AT_CHECK([GNUPGHOME=server/gnupg gpg --list-keys | grep expires], 1, [], [ignore])
+
+# Set expiration for primary key
+AT_CHECK([printf 'imported-key-pw\0' | \
+        sigul -c client/client.conf --batch -v -v \
+        change-key-expiration imported-key --expire-date 2030-01-01], 0, [], [])
+
+AT_CHECK([GNUPGHOME=server/gnupg gpg --list-keys | grep expires], 0,
+         [pub   2048R/868C1849 2011-05-19 [[expires: 2030-01-01]]
+], [ignore])
+
+# Set expiration for subkey
+AT_CHECK([printf 'imported-key-pw\0' | \
+        sigul -c client/client.conf --batch -v -v \
+        change-key-expiration imported-key --expire-date 2030-01-01 --subkey 1],
+        0, [], [])
+
+AT_CHECK([GNUPGHOME=server/gnupg gpg --list-keys | grep expires], 0,
+         [pub   2048R/868C1849 2011-05-19 [[expires: 2030-01-01]]
+sub   2048R/6011BFFA 2011-05-19 [[expires: 2030-01-01]]
+], [ignore])
+
+# Clear expiration for primary key
+AT_CHECK([printf 'imported-key-pw\0' | \
+        sigul -c client/client.conf --batch -v -v \
+        change-key-expiration imported-key],
+        0, [], [])
+
+AT_CHECK([GNUPGHOME=server/gnupg gpg --list-keys | grep expires], 0,
+         [sub   2048R/6011BFFA 2011-05-19 [[expires: 2030-01-01]]
+], [ignore])
+
+# Clear expiration for subkey
+AT_CHECK([printf 'imported-key-pw\0' | \
+        sigul -c client/client.conf --batch -v -v \
+        change-key-expiration imported-key --subkey 1],
+        0, [], [])
+
+AT_CHECK([GNUPGHOME=server/gnupg gpg --list-keys | grep expires], 1, [], [ignore])
+
+
+# Terminate daemons
+AT_CHECK([kill "$(cat server/sigul_server.pid)"])
+AT_CHECK([kill -QUIT "$(cat bridge/sigul_bridge.pid)"])
+
+AT_CLEANUP
diff --git a/tests/testsuite.at b/tests/testsuite.at
index 2548e1c..4602dff 100644
--- a/tests/testsuite.at
+++ b/tests/testsuite.at
@@ -23,6 +23,7 @@ m4_include([basic.at])
 m4_include([ostree.at])
 m4_include([containers.at])
 m4_include([strict-usernames.at])
+m4_include([key-expiration.at])
 m4_include([bound-passphrase.at])
 m4_include([bound-passphrase-tpm.at])
 m4_include([kojikrb5.at])

