diff --git a/NEWS b/NEWS index 7d30b94d..d2df4448 100644 --- a/NEWS +++ b/NEWS @@ -22,6 +22,7 @@ Noteworthy changes in version 1.8.1 (unreleased) py: Context.home_dir NEW. py: Context.keylist EXTENDED: New keyword arg mode. py: Context.create_key NEW. + py: Context.create_subkey NEW. py: core.pubkey_algo_string NEW. py: core.addrspec_from_uid NEW. diff --git a/lang/python/gpg/core.py b/lang/python/gpg/core.py index c5af1b18..2a4df99b 100644 --- a/lang/python/gpg/core.py +++ b/lang/python/gpg/core.py @@ -579,6 +579,78 @@ class Context(GpgmeWrapper): return self.op_genkey_result() + def create_subkey(self, key, algorithm=None, expires_in=0, expires=True, + sign=False, encrypt=False, authenticate=False, passphrase=None): + """Create a subkey + + Create a subkey for the given KEY. As subkeys are a concept + of OpenPGP, calling this is only valid for the OpenPGP + protocol. + + ALGORITHM may be used to specify the public key encryption + algorithm for the new subkey. By default, a reasonable + default is chosen. You may use "future-default" to select an + algorithm that will be the default in a future implementation + of the engine. ALGORITHM may be a string like "rsa", or + "rsa2048" to explicitly request an algorithm and a key size. + + EXPIRES_IN specifies the expiration time of the subkey in + number of seconds since the subkeys creation. By default, a + reasonable expiration time is chosen. If you want to create a + subkey that does not expire, use the keyword argument EXPIRES. + + SIGN, ENCRYPT, and AUTHENTICATE can be used to request the + capabilities of the new subkey. If you don't request any, an + encryption subkey is generated. + + If PASSPHRASE is None (the default), then the subkey will not + be protected with a passphrase. If PASSPHRASE is a string, it + will be used to protect the subkey. If PASSPHRASE is True, + the passphrase must be supplied using a passphrase callback or + out-of-band with a pinentry. + + Keyword arguments: + algorithm -- public key algorithm, see above (default: reasonable) + expires_in -- expiration time in seconds (default: reasonable) + expires -- whether or not the subkey should expire (default: True) + sign -- request the signing capability (see above) + encrypt -- request the encryption capability (see above) + authenticate -- request the authentication capability (see above) + passphrase -- protect the subkey with a passphrase (default: no passphrase) + + Returns: + -- an object describing the result of the subkey creation + + Raises: + GPGMEError -- as signaled by the underlying library + + """ + if util.is_a_string(passphrase): + old_pinentry_mode = self.pinentry_mode + old_passphrase_cb = getattr(self, '_passphrase_cb', None) + self.pinentry_mode = constants.PINENTRY_MODE_LOOPBACK + def passphrase_cb(hint, desc, prev_bad, hook=None): + return passphrase + self.set_passphrase_cb(passphrase_cb) + + try: + self.op_createsubkey(key, algorithm, + 0, # reserved + expires_in, + ((constants.create.SIGN if sign else 0) + | (constants.create.ENCR if encrypt else 0) + | (constants.create.AUTH if authenticate else 0) + | (constants.create.NOPASSWD + if passphrase == None else 0) + | (0 if expires else constants.create.NOEXPIRE))) + finally: + if util.is_a_string(passphrase): + self.pinentry_mode = old_pinentry_mode + if old_passphrase_cb: + self.set_passphrase_cb(*old_passphrase_cb[1:]) + + return self.op_genkey_result() + 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 5469e751..62c6087f 100644 --- a/lang/python/tests/Makefile.am +++ b/lang/python/tests/Makefile.am @@ -51,7 +51,8 @@ py_tests = t-wrapper.py \ t-file-name.py \ t-idiomatic.py \ t-protocol-assuan.py \ - t-quick-key-creation.py + t-quick-key-creation.py \ + t-quick-subkey-creation.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-subkey-creation.py b/lang/python/tests/t-quick-subkey-creation.py new file mode 100755 index 00000000..0d9f71fb --- /dev/null +++ b/lang/python/tests/t-quick-subkey-creation.py @@ -0,0 +1,121 @@ +#!/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 os +import shutil +import time + +import support + +alpha = "Alpha " +bravo = "Bravo " + +def copy_configuration(destination): + home = os.environ['GNUPGHOME'] + shutil.copy(os.path.join(home, "gpg.conf"), destination) + shutil.copy(os.path.join(home, "gpg-agent.conf"), destination) + +with support.TemporaryDirectory() as tmp: + copy_configuration(tmp) + with gpg.Context(home_dir=tmp) as ctx: + res = ctx.create_key(alpha, certify=True) + keys = list(ctx.keylist()) + assert len(keys) == 1, "Weird number of keys created" + key = keys[0] + assert key.fpr == res.fpr + assert len(key.subkeys) == 1, "Expected one primary key and no subkeys" + + def get_subkey(fpr): + k = ctx.get_key(fpr) + for sk in k.subkeys: + if sk.fpr == fpr: + return sk + return None + + # Check gpg.constants.create.NOEXPIRE... + res = ctx.create_subkey(key, expires=False) + subkey = get_subkey(res.fpr) + assert subkey.expires == 0, "Expected subkey not to expire" + assert subkey.can_encrypt, \ + "Default subkey capabilities do not include encryption" + + t = 2 * 24 * 60 * 60 + slack = 5 * 60 + res = ctx.create_subkey(key, expires_in=t) + subkey = get_subkey(res.fpr) + assert abs(time.time() + t - subkey.expires) < slack, \ + "subkeys expiration time is off" + + # Check capabilities + for sign, encrypt, authenticate in itertools.product([False, True], + [False, True], + [False, True]): + # Filter some out + if not (sign or encrypt or authenticate): + # This triggers the default capabilities tested before. + continue + + res = ctx.create_subkey(key, sign=sign, encrypt=encrypt, + authenticate=authenticate) + subkey = get_subkey(res.fpr) + assert sign == subkey.can_sign + assert encrypt == subkey.can_encrypt + assert authenticate == subkey.can_authenticate + + # Check algorithm + res = ctx.create_subkey(key, algorithm="rsa") + subkey = get_subkey(res.fpr) + assert subkey.pubkey_algo == 1 + + # Check algorithm with size + res = ctx.create_subkey(key, algorithm="rsa1024") + subkey = get_subkey(res.fpr) + assert subkey.pubkey_algo == 1 + assert subkey.length == 1024 + + # Check algorithm future-default + ctx.create_subkey(key, algorithm="future-default") + + # Check passphrase protection. For this we create a new key + # so that we have a key with just one encryption subkey. + bravo_res = ctx.create_key(bravo, certify=True) + bravo_key = ctx.get_key(bravo_res.fpr) + assert len(bravo_key.subkeys) == 1, "Expected one primary key and no subkeys" + + passphrase = "streng geheim" + res = ctx.create_subkey(bravo_key, passphrase=passphrase) + ciphertext, _, _ = ctx.encrypt(b"hello there", + recipients=[ctx.get_key(bravo_res.fpr)]) + + cb_called = False + def cb(*args): + global cb_called + cb_called = True + return passphrase + ctx.pinentry_mode = gpg.constants.PINENTRY_MODE_LOOPBACK + ctx.set_passphrase_cb(cb) + + plaintext, _, _ = ctx.decrypt(ciphertext) + assert plaintext == b"hello there" + assert cb_called