From 8e165480ace76ab97e40e9396293eccff36497e0 Mon Sep 17 00:00:00 2001 From: Christian Heimes Date: Wed, 23 May 2018 12:33:01 +0200 Subject: [PATCH] Use GnuPG 2 for backup/restore ipa-backup and ipa-restore now use GnuPG 2 for asymmetric encryption, too. The gpg2 command behaves a bit different and requires a gpg2 compatible config directory. Therefore the --keyring option has been deprecated. The backup and restore tools now use root's GPG keyring by default. Custom configuration and keyring can be used by setting GNUPGHOME environment variables. Fixes: https://pagure.io/freeipa/issue/7560 Signed-off-by: Christian Heimes Reviewed-By: Rob Crittenden --- freeipa.spec.in | 2 - install/tools/man/ipa-backup.1 | 12 +- install/tools/man/ipa-restore.1 | 12 +- ipaplatform/base/paths.py | 2 +- ipaserver/install/ipa_backup.py | 91 ++++++++------- ipaserver/install/ipa_restore.py | 69 ++++++----- .../test_install/test_installutils.py | 109 +++++++++++++++++- 7 files changed, 206 insertions(+), 91 deletions(-) diff --git a/freeipa.spec.in b/freeipa.spec.in index 4382e3338..26781146f 100755 --- a/freeipa.spec.in +++ b/freeipa.spec.in @@ -757,7 +757,6 @@ Provides: python2-ipaplatform = %{version}-%{release} %{!?python_provide:Provides: python-ipaplatform = %{version}-%{release}} Requires: %{name}-common = %{version}-%{release} Requires: python2-gssapi >= 1.2.0-5 -Requires: gnupg Requires: gnupg2 Requires: keyutils Requires: python2 >= 2.7.9 @@ -811,7 +810,6 @@ Provides: python3-ipaplatform = %{version}-%{release} %{?python_provide:%python_provide python3-ipaplatform} Requires: %{name}-common = %{version}-%{release} Requires: python3-gssapi >= 1.2.0 -Requires: gnupg Requires: gnupg2 Requires: keyutils Requires: python3-cryptography >= 1.6 diff --git a/install/tools/man/ipa-backup.1 b/install/tools/man/ipa-backup.1 index 9e2900f77..77081b61d 100644 --- a/install/tools/man/ipa-backup.1 +++ b/install/tools/man/ipa-backup.1 @@ -43,10 +43,7 @@ A backup can not be restored in a different version of IPA. Back up data only. The default is to back up all IPA files plus data. .TP \fB\-\-gpg\fR -Encrypt the back up file. -.TP -\fB\-\-gpg\-keyring\fR=\fIGPG_KEYRING\fR -The full path to a GPG keyring. The keyring consists of two files, a public and a private key (.sec and .pub respectively). Specify the path without an extension. +Encrypt the back up file. Set \fBGNUPGHOME\fR environment variable to use a custom keyring and gpg2 configuration. .TP \fB\-\-logs\fR Include the IPA service log files in the backup. @@ -71,6 +68,10 @@ Log to the given file 1 if an error occurred 2 if IPA is not configured +.SH "ENVIRONMENT VARIABLES" +.PP +\fBGNUPGHOME\fR +Use custom GnuPG keyring and settings (default: \fB~/.gnupg\fR). .SH "FILES" .PP \fI/var/lib/ipa/backup\fR @@ -83,4 +84,5 @@ The default directory for storing backup files. The log file for backups .PP .SH "SEE ALSO" -ipa\-restore(1). +.BR ipa\-restore(1) +.BR gpg2(1) \ No newline at end of file diff --git a/install/tools/man/ipa-restore.1 b/install/tools/man/ipa-restore.1 index 5f401818a..5843d5546 100644 --- a/install/tools/man/ipa-restore.1 +++ b/install/tools/man/ipa-restore.1 @@ -32,7 +32,7 @@ The type of backup is automatically detected. A data restore can be done from ei .TP \fBWARNING\fR: A full restore will restore files like /etc/passwd, /etc/group, /etc/resolv.conf as well. Any file that IPA may have touched is backed up and restored. .TP -An encrypted backup is also automatically detected and the root keyring is used by default. The \-\-keyring option can be used to define the full path to the private and public keys. +An encrypted backup is also automatically detected and the root keyring and gpg-agent is used by default. Set \fBGNUPGHOME\fR environment variable to use a custom keyring and gpg2 configuration. .TP Within the subdirectory is file, header, that describes the back up including the type, system, date of backup, the version of IPA, the version of the backup and the services on the master. .TP @@ -61,9 +61,6 @@ The Directory Manager password. \fB\-\-data\fR Restore the data only. The default is to restore everything in the backup. .TP -\fB\-\-gpg\-keyring\fR=\fIGPG_KEYRING\fR -The full path to a GPG keyring. The keyring consists of two files, a public and a private key (.sec and .pub respectively). Specify the path without an extension. -.TP \fB\-\-no\-logs\fR Exclude the IPA service log files in the backup (if they were backed up). .TP @@ -91,6 +88,10 @@ Log to the given file 0 if the command was successful 1 if an error occurred +.SH "ENVIRONMENT VARIABLES" +.PP +\fBGNUPGHOME\fR +Use custom GnuPG keyring and settings (default: \fB~/.gnupg\fR). .SH "FILES" .PP \fI/var/lib/ipa/backup\fR @@ -103,4 +104,5 @@ The default directory for storing backup files. The log file for restoration .PP .SH "SEE ALSO" -ipa\-backup(1). +.BR ipa\-backup(1) +.BR gpg2(1) diff --git a/ipaplatform/base/paths.py b/ipaplatform/base/paths.py index cade9782b..7089703af 100644 --- a/ipaplatform/base/paths.py +++ b/ipaplatform/base/paths.py @@ -167,8 +167,8 @@ class BasePathNamespace(object): CHROMIUM_BROWSER = "/usr/bin/chromium-browser" FIREFOX = "/usr/bin/firefox" GETCERT = "/usr/bin/getcert" - GPG = "/usr/bin/gpg" GPG2 = "/usr/bin/gpg2" + GPG_CONNECT_AGENT = "/usr/bin/gpg-connect-agent" GPG_AGENT = "/usr/bin/gpg-agent" IPA_GETCERT = "/usr/bin/ipa-getcert" KADMIN_LOCAL = '/usr/sbin/kadmin.local' diff --git a/ipaserver/install/ipa_backup.py b/ipaserver/install/ipa_backup.py index 62e5c9a91..20835efd6 100644 --- a/ipaserver/install/ipa_backup.py +++ b/ipaserver/install/ipa_backup.py @@ -17,11 +17,13 @@ # along with this program. If not, see . # -from __future__ import absolute_import +from __future__ import absolute_import, print_function import logging +import optparse # pylint: disable=deprecated-module import os import shutil +import sys import tempfile import time import pwd @@ -53,45 +55,38 @@ ISO8601_DATETIME_FMT = '%Y-%m-%dT%H:%M:%S' logger = logging.getLogger(__name__) """ -A test gpg can be generated like this: +A test GnuPG key can be generated like this: # cat >keygen <. # -from __future__ import absolute_import +from __future__ import absolute_import, print_function import logging +import optparse # pylint: disable=deprecated-module import os import shutil +import sys import tempfile import time import pwd @@ -77,7 +79,7 @@ def recursive_chown(path, uid, gid): os.chmod(os.path.join(root, file), 0o640) -def decrypt_file(tmpdir, filename, keyring): +def decrypt_file(tmpdir, filename): source = filename (dest, ext) = os.path.splitext(filename) @@ -87,19 +89,12 @@ def decrypt_file(tmpdir, filename, keyring): dest = os.path.basename(dest) dest = os.path.join(tmpdir, dest) - args = [paths.GPG, - '--batch', - '-o', dest] - - if keyring is not None: - args.append('--no-default-keyring') - args.append('--keyring') - args.append(keyring + '.pub') - args.append('--secret-keyring') - args.append(keyring + '.sec') - - args.append('-d') - args.append(source) + args = [ + paths.GPG2, + '--batch', + '--output', dest, + '--decrypt', source, + ] result = run(args, raiseonerr=False) if result.returncode != 0: @@ -161,21 +156,30 @@ class Restore(admintool.AdminTool): def add_options(cls, parser): super(Restore, cls).add_options(parser, debug_option=True) - parser.add_option("-p", "--password", dest="password", + parser.add_option( + "-p", "--password", dest="password", help="Directory Manager password") - parser.add_option("--gpg-keyring", dest="gpg_keyring", - help="The gpg key name to be used") - parser.add_option("--data", dest="data_only", action="store_true", + parser.add_option( + "--gpg-keyring", dest="gpg_keyring", + help=optparse.SUPPRESS_HELP) + parser.add_option( + "--data", dest="data_only", action="store_true", default=False, help="Restore only the data") - parser.add_option("--online", dest="online", action="store_true", - default=False, help="Perform the LDAP restores online, for data only.") - parser.add_option("--instance", dest="instance", + parser.add_option( + "--online", dest="online", action="store_true", + default=False, + help="Perform the LDAP restores online, for data only.") + parser.add_option( + "--instance", dest="instance", help="The 389-ds instance to restore (defaults to all found)") - parser.add_option("--backend", dest="backend", + parser.add_option( + "--backend", dest="backend", help="The backend to restore within the instance or instances") - parser.add_option('--no-logs', dest="no_logs", action="store_true", + parser.add_option( + '--no-logs', dest="no_logs", action="store_true", default=False, help="Do not restore log files from the backup") - parser.add_option('-U', '--unattended', dest="unattended", + parser.add_option( + '-U', '--unattended', dest="unattended", action="store_true", default=False, help="Unattended restoration never prompts the user") @@ -201,9 +205,11 @@ class Restore(admintool.AdminTool): parser.error("must provide path to backup directory") if options.gpg_keyring: - if (not os.path.exists(options.gpg_keyring + '.pub') or - not os.path.exists(options.gpg_keyring + '.sec')): - parser.error("no such key %s" % options.gpg_keyring) + print( + "--gpg-keyring is no longer supported, use GNUPGHOME " + "environment variable to use a custom GnuPG2 directory.", + file=sys.stderr + ) def ask_for_options(self): @@ -327,7 +333,7 @@ class Restore(admintool.AdminTool): try: dirsrv = services.knownservices.dirsrv - self.extract_backup(options.gpg_keyring) + self.extract_backup() if restore_type == 'FULL': self.restore_default_conf() @@ -754,8 +760,7 @@ class Restore(admintool.AdminTool): self.backup_services = config.get('ipa', 'services').split(',') # pylint: enable=no-member - - def extract_backup(self, keyring=None): + def extract_backup(self): ''' Extract the contents of the tarball backup into a temporary location, decrypting if necessary. @@ -776,7 +781,7 @@ class Restore(admintool.AdminTool): if encrypt: logger.info('Decrypting %s', filename) - filename = decrypt_file(self.dir, filename, keyring) + filename = decrypt_file(self.dir, filename) os.chdir(self.dir) diff --git a/ipatests/test_ipaserver/test_install/test_installutils.py b/ipatests/test_ipaserver/test_install/test_installutils.py index 7331aa2e2..bfa261759 100644 --- a/ipatests/test_ipaserver/test_install/test_installutils.py +++ b/ipatests/test_ipaserver/test_install/test_installutils.py @@ -1,15 +1,23 @@ # # Copyright (C) 2017 FreeIPA Contributors. See COPYING for license # +from __future__ import absolute_import +import binascii import os +import re +import subprocess import shutil import tempfile +import textwrap import pytest +from ipaplatform.paths import paths from ipapython import ipautil from ipaserver.install import installutils +from ipaserver.install import ipa_backup +from ipaserver.install import ipa_restore EXAMPLE_CONFIG = [ 'foo=1\n', @@ -22,8 +30,6 @@ WHITESPACE_CONFIG = [ ] - - @pytest.fixture def tempdir(request): tempdir = tempfile.mkdtemp() @@ -35,6 +41,87 @@ def tempdir(request): return tempdir +GPG_GENKEY = textwrap.dedent(""" +%echo Generating a standard key +Key-Type: RSA +Key-Length: 2048 +Name-Real: IPA Backup +Name-Comment: IPA Backup +Name-Email: root@example.com +Expire-Date: 0 +Passphrase: {passphrase} +%commit +%echo done +""") + + +@pytest.fixture +def gpgkey(request, tempdir): + passphrase = "Secret123" + gnupghome = os.path.join(tempdir, "gnupg") + os.makedirs(gnupghome, 0o700) + # provide clean env for gpg test + env = os.environ.copy() + orig_gnupghome = env.get('GNUPGHOME') + env['GNUPGHOME'] = gnupghome + env['LC_ALL'] = 'C.UTF-8' + env['LANGUAGE'] = 'C' + devnull = open(os.devnull, 'w') + + # allow passing passphrases to agent + with open(os.path.join(gnupghome, "gpg-agent.conf"), 'w') as f: + f.write("verbose\n") + f.write("allow-preset-passphrase\n") + + # run agent in background + agent = subprocess.Popen( + [paths.GPG_AGENT, '--batch', '--daemon'], + env=env, stdout=devnull, stderr=devnull + ) + + def fin(): + if orig_gnupghome is not None: + os.environ['GNUPGHOME'] = orig_gnupghome + else: + os.environ.pop('GNUPGHOME', None) + agent.kill() + agent.wait() + + request.addfinalizer(fin) + + # create public / private key pair + keygen = os.path.join(gnupghome, 'keygen') + with open(keygen, 'w') as f: + f.write(GPG_GENKEY.format(passphrase=passphrase)) + subprocess.check_call( + [paths.GPG2, '--batch', '--gen-key', keygen], + env=env, stdout=devnull, stderr=devnull + ) + + # get keygrip of private key + out = subprocess.check_output( + [paths.GPG2, "--list-secret-keys", "--with-keygrip"], + env=env, stderr=subprocess.STDOUT + ) + mo = re.search("Keygrip = ([A-Z0-9]{32,})", out.decode('utf-8')) + if mo is None: + raise ValueError(out.decode('utf-8')) + keygrip = mo.group(1) + + # unlock private key + cmd = "PRESET_PASSPHRASE {} -1 {}".format( + keygrip, + binascii.hexlify(passphrase.encode('utf-8')).decode('utf-8') + ) + subprocess.check_call( + [paths.GPG_CONNECT_AGENT, cmd, "/bye"], + env=env, stdout=devnull, stderr=devnull + ) + + # set env for the rest of the progress + os.environ['GNUPGHOME'] = gnupghome + + class test_set_directive_lines(object): def test_remove_directive(self): lines = installutils.set_directive_lines( @@ -198,3 +285,21 @@ def test_gpg_encrypt(tempdir): with pytest.raises(ipautil.CalledProcessError): installutils.decrypt_file(encrypted, decrypted, password='invalid') + + +def test_gpg_asymmetric(tempdir, gpgkey): + src = os.path.join(tempdir, "asymmetric.txt") + encrypted = src + ".gpg" + payload = 'Dummy text\n' + + with open(src, 'w') as f: + f.write(payload) + + ipa_backup.encrypt_file(src, remove_original=True) + assert os.path.isfile(encrypted) + assert not os.path.exists(src) + + ipa_restore.decrypt_file(tempdir, encrypted) + assert os.path.isfile(src) + with open(src) as f: + assert f.read() == payload