diff --git a/NEWS b/NEWS index 889a5265..617d1d37 100644 --- a/NEWS +++ b/NEWS @@ -25,6 +25,7 @@ Noteworthy changes in version 1.8.1 (unreleased) py: Context.create_subkey NEW. py: Context.key_add_uid NEW. py: Context.key_revoke_uid NEW. + py: Context.key_sign NEW. py: core.pubkey_algo_string NEW. py: core.addrspec_from_uid NEW. diff --git a/doc/gpgme.texi b/doc/gpgme.texi index c088cfed..78225d58 100644 --- a/doc/gpgme.texi +++ b/doc/gpgme.texi @@ -4044,11 +4044,10 @@ object (@code{gpgme_user_id_t}) is to be used. To select more than one user ID put them all into one string separated by linefeeds characters (@code{\n}) and set the flag @code{GPGME_KEYSIGN_LFSEP}. -@var{expires} can be set to the number of seconds since Epoch of the -desired expiration date in UTC for the new signature. The common case -is to use 0 to not set an expiration date. However, if the -configuration of the engine defines a default expiration for key -signatures, that is still used unless the flag +@var{expires} specifies the expiration time of the new signature in +seconds. The common case is to use 0 to not set an expiration date. +However, if the configuration of the engine defines a default +expiration for key signatures, that is still used unless the flag @code{GPGME_KEYSIGN_NOEXPIRE} is used. Note that this parameter takes an unsigned long value and not a @code{time_t} to avoid problems on systems which use a signed 32 bit @code{time_t}. Note further that diff --git a/lang/python/gpg/constants/__init__.py b/lang/python/gpg/constants/__init__.py index 2bf180e5..79d1fbc1 100644 --- a/lang/python/gpg/constants/__init__.py +++ b/lang/python/gpg/constants/__init__.py @@ -26,14 +26,14 @@ del util # For convenience, we import the modules here. from . import data, keylist, sig # The subdirs. -from . import create, event, md, pk, protocol, sigsum, status, validity +from . import create, event, keysign, md, pk, protocol, sigsum, status, validity # A complication arises because 'import' is a reserved keyword. # Import it as 'Import' instead. globals()['Import'] = getattr(__import__('', globals(), locals(), [str('import')], 1), "import") -__all__ = ['data', 'event', 'import', 'keylist', 'md', 'pk', +__all__ = ['data', 'event', 'import', 'keysign', 'keylist', 'md', 'pk', 'protocol', 'sig', 'sigsum', 'status', 'validity', 'create'] # GPGME 1.7 replaced gpgme_op_edit with gpgme_op_interact. We diff --git a/lang/python/gpg/constants/keysign.py b/lang/python/gpg/constants/keysign.py new file mode 100644 index 00000000..fccdbc42 --- /dev/null +++ b/lang/python/gpg/constants/keysign.py @@ -0,0 +1,25 @@ +# Flags for key signing +# +# Copyright (C) 2017 g10 Code GmbH +# +# This file is part of GPGME. +# +# GPGME is free software; you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as +# published by the Free Software Foundation; either version 2.1 of the +# License, or (at your option) any later version. +# +# GPGME is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General +# Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this program; if not, see . + +from __future__ import absolute_import, print_function, unicode_literals +del absolute_import, print_function, unicode_literals + +from gpg import util +util.process_constants('GPGME_KEYSIGN_', globals()) +del util diff --git a/lang/python/gpg/core.py b/lang/python/gpg/core.py index 28d4629e..cb4ccf73 100644 --- a/lang/python/gpg/core.py +++ b/lang/python/gpg/core.py @@ -675,6 +675,47 @@ class Context(GpgmeWrapper): """ self.op_revuid(key, uid, 0) + def key_sign(self, key, uids=None, expires_in=False, local=False): + """Sign a key + + Sign a key with the current set of signing keys. Calling this + function is only valid for the OpenPGP protocol. + + If UIDS is None (the default), then all UIDs are signed. If + it is a string, then only the matching UID is signed. If it + is a list of strings, then all matching UIDs are signed. Note + that a case-sensitive exact string comparison is done. + + EXPIRES_IN specifies the expiration time of the signature in + seconds. If EXPIRES_IN is False, the signature does not + expire. + + Keyword arguments: + uids -- user ids to sign, see above (default: sign all) + expires_in -- validity period of the signature in seconds + (default: do not expire) + local -- create a local, non-exportable signature + (default: False) + + Raises: + GPGMEError -- as signaled by the underlying library + + """ + flags = 0 + if uids == None or util.is_a_string(uids): + pass#through unchanged + else: + flags |= constants.keysign.LFSEP + uids = "\n".join(uids) + + if not expires_in: + flags |= constants.keysign.NOEXPIRE + + if local: + flags |= constants.keysign.LOCAL + + self.op_keysign(key, uids, expires_in, flags) + def assuan_transact(self, command, data_cb=None, inquire_cb=None, status_cb=None): """Issue a raw assuan command diff --git a/lang/python/tests/Makefile.am b/lang/python/tests/Makefile.am index 1d5e1db5..7251cd30 100644 --- a/lang/python/tests/Makefile.am +++ b/lang/python/tests/Makefile.am @@ -53,7 +53,8 @@ py_tests = t-wrapper.py \ t-protocol-assuan.py \ t-quick-key-creation.py \ t-quick-subkey-creation.py \ - t-quick-key-manipulation.py + t-quick-key-manipulation.py \ + t-quick-key-signing.py XTESTS = initial.py $(py_tests) final.py EXTRA_DIST = support.py $(XTESTS) encrypt-only.asc sign-only.asc \ diff --git a/lang/python/tests/t-quick-key-signing.py b/lang/python/tests/t-quick-key-signing.py new file mode 100755 index 00000000..f9778a33 --- /dev/null +++ b/lang/python/tests/t-quick-key-signing.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python + +# Copyright (C) 2017 g10 Code GmbH +# +# This file is part of GPGME. +# +# GPGME is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# GPGME is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General +# Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this program; if not, see . + +from __future__ import absolute_import, print_function, unicode_literals +del absolute_import, print_function, unicode_literals + +import gpg +import itertools +import time + +import support + +with support.EphemeralContext() as ctx: + uid_counter = 0 + def make_uid(): + global uid_counter + uid_counter += 1 + return "user{0}@invalid.example.org".format(uid_counter) + + def make_key(): + uids = [make_uid() for i in range(3)] + res = ctx.create_key(uids[0], certify=True) + key = ctx.get_key(res.fpr) + for u in uids[1:]: + ctx.key_add_uid(key, u) + return key, uids + + def check_sigs(key, expected_sigs): + keys = list(ctx.keylist(key.fpr, mode=(gpg.constants.keylist.mode.LOCAL + |gpg.constants.keylist.mode.SIGS))) + assert len(keys) == 1 + key_uids = {uid.uid: [s for s in uid.signatures] for uid in keys[0].uids} + expected = list(expected_sigs) + + while key_uids and expected: + uid, signing_key, func = expected[0] + match = False + for i, s in enumerate(key_uids[uid]): + if signing_key.fpr.endswith(s.keyid): + if func: + func(s) + match = True + break + if match: + expected.pop(0) + key_uids[uid].pop(i) + if not key_uids[uid]: + del key_uids[uid] + + assert not key_uids, "Superfluous signatures: {0}".format(key_uids) + assert not expected, "Missing signatures: {0}".format(expected) + + # Simplest case. Sign without any options. + key_a, uids_a = make_key() + key_b, uids_b = make_key() + ctx.signers = [key_a] + + def exportable_non_expiring(s): + assert s.exportable + assert s.expires == 0 + + check_sigs(key_b, itertools.product(uids_b, [key_b], [exportable_non_expiring])) + ctx.key_sign(key_b) + check_sigs(key_b, itertools.product(uids_b, [key_b, key_a], [exportable_non_expiring])) + + # Create a non-exportable signature, and explicitly name all uids. + key_c, uids_c = make_key() + ctx.signers = [key_a, key_b] + + def non_exportable_non_expiring(s): + assert s.exportable == 0 + assert s.expires == 0 + + ctx.key_sign(key_c, local=True, uids=uids_c) + check_sigs(key_c, + list(itertools.product(uids_c, [key_c], + [exportable_non_expiring])) + + list(itertools.product(uids_c, [key_b, key_a], + [non_exportable_non_expiring]))) + + # Create a non-exportable, expiring signature for a single uid. + key_d, uids_d = make_key() + ctx.signers = [key_c] + expires_in = 600 + slack = 10 + + def non_exportable_expiring(s): + assert s.exportable == 0 + assert abs(time.time() + expires_in - s.expires) < slack + + ctx.key_sign(key_d, local=True, expires_in=expires_in, uids=uids_d[0]) + check_sigs(key_d, + list(itertools.product(uids_d, [key_d], + [exportable_non_expiring])) + + list(itertools.product(uids_d[:1], [key_c], + [non_exportable_expiring]))) + + # Now sign the second in the same fashion, but use a singleton list. + ctx.key_sign(key_d, local=True, expires_in=expires_in, uids=uids_d[1:2]) + check_sigs(key_d, + list(itertools.product(uids_d, [key_d], + [exportable_non_expiring])) + + list(itertools.product(uids_d[:2], [key_c], + [non_exportable_expiring])))