Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: extend key expiration date #167

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions gnupg/_meta.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ class GPGBase(object):
'list': _parsers.ListKeys,
'sign': _parsers.Sign,
'verify': _parsers.Verify,
'extension': _parsers.KeyExtensionResult,
'packets': _parsers.ListPackets }

def __init__(self, binary=None, home=None, keyring=None, secring=None,
Expand Down
63 changes: 63 additions & 0 deletions gnupg/_parsers.py
Original file line number Diff line number Diff line change
Expand Up @@ -485,13 +485,15 @@ def _get_options_group(group=None):
'--recipient',
'--recv-keys',
'--send-keys',
'--edit-key'
])
#: These options expect value which are left unchecked, though still run
#: through :func:`_fix_unsafe`.
unchecked_options = frozenset(['--list-options',
'--passphrase-fd',
'--status-fd',
'--verify-options',
'--command-fd',
])
#: These have their own parsers and don't really fit into a group
other_options = frozenset(['--debug-level',
Expand Down Expand Up @@ -808,6 +810,67 @@ def progress(status_code):
return value


class KeyExtensionInterface(object):
""" Interface that guards against misuse of --edit-key combined with --command-fd"""

def __init__(self, validity, passphrase=None):
self._passphrase = passphrase
self._validity_option = validity
self._clean_key_extension_option()

def _clean_key_extension_option(self):
"""validates the extension option supplied"""
allowed_entry = re.findall('^(\d+)(|w|m|y)$', self._validity_option)
if not allowed_entry:
raise UsageError("Key extension option: %s is not valid" % self._validity_option)

def _input_passphrase(self, _input):
if self._passphrase:
return "%s%s\n" % (_input, self._passphrase)
return _input

def _main_key_command(self):
main_key_input = "expire\n%s\n" % self._validity_option
return self._input_passphrase(main_key_input)

def _sub_key_command(self, sub_key_number=1):
sub_key_input = "key %d\nexpire\n%s\n" % (sub_key_number, self._validity_option)
return self._input_passphrase(sub_key_input)

def gpg_interactive_input(self, extend_subkey=True):
""" processes series of inputs normally supplied on --edit-key but passed through stdin
this ensures that no other --edit-key command is actually passing through.
"""
_input = self._main_key_command()
if extend_subkey:
_input += self._sub_key_command()
return "%ssave\n" % _input


class KeyExtensionResult(object):
"""Handle status messages for key expiry extension
It does not really have a job, but just to conform to the API
"""
def __init__(self, gpg):
self._gpg = gpg
self.status = 'ok'

def _handle_status(self, key, value):
"""Parse a status code from the attached GnuPG process.

:raises: :exc:`~exceptions.ValueError` if the status message is unknown.
"""
if key in ("USERID_HINT", "NEED_PASSPHRASE",
"GET_HIDDEN", "SIGEXPIRED", "KEYEXPIRED",
"GOOD_PASSPHRASE", "GOT_IT", "GET_LINE"):
pass
elif key in ("BAD_PASSPHRASE", "MISSING_PASSPHRASE"):
self.status = key.replace("_", " ").lower()
else:
self.status = 'failed'
raise ValueError("Unknown status message: %r" % key)


class GenKey(object):
"""Handle status messages for key generation.

Expand Down
33 changes: 32 additions & 1 deletion gnupg/gnupg.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
from . import _util
from . import _trust
from ._meta import GPGBase
from ._parsers import _fix_unsafe
from ._parsers import _fix_unsafe, KeyExtensionInterface
from ._util import _is_list_or_tuple
from ._util import _is_stream
from ._util import _make_binary_stream
Expand Down Expand Up @@ -542,6 +542,37 @@ def _parse_keys(self, result):
if keyword in valid_keywords:
getattr(result, keyword)(L)

def extend_key(self, keyid, validity='1y', passphrase=None, extend_subkey=True):
"""Extends a GnuPG key by passing in new validity period (from now) through
subprocess's stdin

>>> import gnupg
>>> gpg = gnupg.GPG(homedir="doctests")
>>> key_input = gpg.gen_key_input()
>>> key = gpg.gen_key(key_input)
>>> gpg.extend_key(key.fingerprint, '2w', 'good passphrase')

:param str keyid: key shortID, longID, email_address or fingerprint
:param str validity: 0 or number of days (d), or weeks (*w) , or months (*m) or years (*y)
to extend the key, from its creation date.
:param str passphrase: passphrase used when creating the key, leave None otherwise
:param bool extend_subkey: to indicate whether the first subkey will also extended
by the same period --default is True

:returns: The result giving status of the extension...
the new expiration date can be obtained by .list_keys()
"""

args = ["--command-fd 0", "--edit-key %s" % keyid]

p = self._open_subprocess(args)
result = self._result_map['extension'](self)
passphrase = passphrase.encode(self._encoding) if passphrase else passphrase
extension_input = KeyExtensionInterface(validity, passphrase).gpg_interactive_input(extend_subkey)
p.stdin.write(extension_input)
self._collect_output(p, result, stdin=p.stdin)
return result

def gen_key(self, input):
"""Generate a GnuPG key through batch file key generation. See
:meth:`GPG.gen_key_input()` for creating the control input.
Expand Down
65 changes: 64 additions & 1 deletion gnupg/test/test_gnupg.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from __future__ import print_function
from __future__ import with_statement

import datetime
from argparse import ArgumentParser
from codecs import open as open
from functools import wraps
Expand Down Expand Up @@ -357,6 +358,7 @@ def test_copy_data_bytesio(self):
os.remove(outfile)

def generate_key_input(self, real_name, email_domain, key_length=None,
expire_date=1,
key_type=None, subkey_type=None, passphrase=None):
"""Generate a GnuPG batch file for key unattended key creation."""
name = real_name.lower().replace(' ', '')
Expand All @@ -366,7 +368,7 @@ def generate_key_input(self, real_name, email_domain, key_length=None,

batch = {'Key-Type': key_type,
'Key-Length': key_length,
'Expire-Date': 1,
'Expire-Date': expire_date,
'Name-Real': '%s' % real_name,
'Name-Email': ("%s@%s" % (name, email_domain))}

Expand Down Expand Up @@ -1364,6 +1366,63 @@ def test_encryption_with_output(self):
encrypted_message = fh.read()
self.assertTrue(b"-----BEGIN PGP MESSAGE-----" in encrypted_message)

def test_key_extension(self):
"""Test that extending key expiry date succeeds."""
today = datetime.date.today()
date_format = '%Y-%m-%d'
tomorrow = today + datetime.timedelta(days=1)
key = self.generate_key("Haha", "ho.ho", passphrase="haha.hehe", expire_date=tomorrow.strftime(date_format))

self.gpg.extend_key(key.fingerprint, validity='1w', passphrase="haha.hehe")
next_week = today + datetime.timedelta(weeks=1)

current_keys = self.gpg.list_keys()
for fecthed_key in current_keys:
self.assertEqual(next_week, datetime.date.fromtimestamp(int(fecthed_key['expires'])))
self.assertEqual(key.fingerprint, fecthed_key['fingerprint'])

def test_passphrase_with_space_on_key_extension(self):
"""Test that wrong passphrase does not allow extension."""
today = datetime.date.today()
date_format = '%Y-%m-%d'
tomorrow = today + datetime.timedelta(days=1)
password_with_space = "passphrase with space"
key = self.generate_key("Haha", "ho.ho", passphrase=password_with_space,
expire_date=tomorrow.strftime(date_format))

self.gpg.extend_key(key.fingerprint, validity='1w', passphrase=password_with_space)
next_week = today + datetime.timedelta(weeks=1)

current_keys = self.gpg.list_keys()
for fecthed_key in current_keys:
self.assertEqual(next_week, datetime.date.fromtimestamp(int(fecthed_key['expires'])))
self.assertEqual(key.fingerprint, fecthed_key['fingerprint'])

def test_wrong_passphrase_on_key_extension(self):
"""Test that wrong passphrase does not allow extension."""
today = datetime.date.today()
date_format = '%Y-%m-%d'
tomorrow = today + datetime.timedelta(days=1)
key = self.generate_key("Haha", "ho.ho", passphrase="haha.hehe", expire_date=tomorrow.strftime(date_format))

self.gpg.extend_key(key.fingerprint, validity='1w', passphrase="wrong passphrase")

current_keys = self.gpg.list_keys()
for fecthed_key in current_keys:
self.assertEqual(tomorrow, datetime.date.fromtimestamp(int(fecthed_key['expires'])))
self.assertEqual(key.fingerprint, fecthed_key['fingerprint'])

def test_invalid_extension_period_throws_exception_on_key_extension(self):
"""Test that key extension has to be positive value"""
today = datetime.date.today()
date_format = '%Y-%m-%d'
tomorrow = today + datetime.timedelta(days=1)
key = self.generate_key("Haha", "ho.ho", passphrase="haha.hehe", expire_date=tomorrow.strftime(date_format))

invalid_extension_option = "-1w"
with self.assertRaises(_parsers.UsageError):
self.gpg.extend_key(key.fingerprint, validity=invalid_extension_option, passphrase="haha.hehe")


suites = { 'parsers': set(['test_parsers_fix_unsafe',
'test_parsers_fix_unsafe_semicolon',
Expand Down Expand Up @@ -1433,6 +1492,10 @@ def test_encryption_with_output(self):
'test_deletion_subkeys',
'test_import_only']),
'recvkeys': set(['test_recv_keys_default']),
'extend': set(['test_key_extension',
'test_passphrase_with_space_on_key_extension',
'test_wrong_passphrase_on_key_extension',
'test_invalid_extension_period_throws_exception_on_key_extension']),
}

def main(args):
Expand Down
82 changes: 82 additions & 0 deletions gnupg/test/test_parsers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
#
# This file is part of python-gnupg, a Python interface to GnuPG.
# Copyright © 2013 Isis Lovecruft, <isis@leap.se> 0xA3ADB67A2CDB8B35
# © 2013 Andrej B.
# © 2013 LEAP Encryption Access Project
# © 2008-2012 Vinay Sajip
# © 2005 Steve Traugott
# © 2004 A.M. Kuchling
#
# This program 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 3 of the License, or (at your option)
# any later version.
#
# This program 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 included LICENSE file for details.

"""test_util.py
----------------
A test harness and unittests for _util.py.
"""

from __future__ import absolute_import
from __future__ import print_function
from __future__ import with_statement

import unittest
from gnupg._parsers import UsageError

## see PEP-366 http://www.python.org/dev/peps/pep-0366/

print("NAME: %r" % __name__)
print("PACKAGE: %r" % __package__)
try:
import gnupg._parsers as parsers
except (ImportError, ValueError) as ierr:
raise SystemExit(str(ierr))


class TestKeyExpiryExtensionParser(unittest.TestCase):
""":class:`unittest.TestCase <TestCase>`s for python-gnupg util."""

def test_happy_path(self):
try:
parsers.KeyExtensionInterface("0")
parsers.KeyExtensionInterface("113")
parsers.KeyExtensionInterface("2w")
parsers.KeyExtensionInterface("3m")
parsers.KeyExtensionInterface("54y")
except UsageError:
self.fail('more than one digit, key extension option, raises exceptions')

def test_anything_that_is_not_w_y_m_is_not_allowed(self):
with self.assertRaises(UsageError):
parsers.KeyExtensionInterface("2x")

def test_negative_number_is_not_allowed(self):
with self.assertRaises(UsageError):
parsers.KeyExtensionInterface("-1")

def test_letters_without_a_number_is_not_allowed(self):
with self.assertRaises(UsageError):
parsers.KeyExtensionInterface("w")

def test_letters_before_a_number_is_not_allowed(self):
with self.assertRaises(UsageError):
parsers.KeyExtensionInterface("w3")

def test_more_than_w_is_not_allowed_option(self):
with self.assertRaises(UsageError):
parsers.KeyExtensionInterface("2ww")

def test_more_than_m_is_not_allowed_option(self):
with self.assertRaises(UsageError):
parsers.KeyExtensionInterface("7mm")

def test_more_than_y_is_not_allowed_option(self):
with self.assertRaises(UsageError):
parsers.KeyExtensionInterface("9yy")