From 945364c19a78279edbf5f37ce6256f1352787e4b Mon Sep 17 00:00:00 2001 From: Patrick Uiterwijk Date: Feb 28 2017 23:27:18 +0000 Subject: Implement a method to change key expiration Signed-off-by: Patrick Uiterwijk --- diff --git a/ChangeLog b/ChangeLog index f640c96..272f56a 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,3 +1,13 @@ +2017-02-28 Patrick Uiterwijk + + * 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 * 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 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 + +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 < server/server.conf < client/client.conf < 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])