Simplified the spack.util.gpg implementation (#23889)

* Simplified the spack.util.gpg implementation

All the classes defined in this Python module,
which were previously used to construct singleton
instances, have been removed in favor of four
global variables. These variables are initialized
lazily, like before.

The API of the module has been unchanged for the
most part. A few tests have been modified to use
the new global names.
This commit is contained in:
Massimiliano Culpo 2021-06-01 19:05:42 +02:00 committed by GitHub
parent c6d21fa154
commit 707a3f7df8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 320 additions and 384 deletions

View file

@ -73,7 +73,7 @@ def tutorial(parser, args):
tty.msg("Ensuring that we trust tutorial binaries", tty.msg("Ensuring that we trust tutorial binaries",
"spack gpg trust %s" % tutorial_key) "spack gpg trust %s" % tutorial_key)
spack.util.gpg.Gpg().trust(tutorial_key) spack.util.gpg.trust(tutorial_key)
# Note that checkout MUST be last. It changes Spack under our feet. # Note that checkout MUST be last. It changes Spack under our feet.
# If you don't put this last, you'll get import errors for the code # If you don't put this last, you'll get import errors for the code

View file

@ -318,8 +318,6 @@ def test_relative_rpaths_install_nondefault(mirror_dir):
buildcache_cmd('install', '-auf', cspec.name) buildcache_cmd('install', '-auf', cspec.name)
@pytest.mark.skipif(not spack.util.gpg.has_gpg(),
reason='This test requires gpg')
def test_push_and_fetch_keys(mock_gnupghome): def test_push_and_fetch_keys(mock_gnupghome):
testpath = str(mock_gnupghome) testpath = str(mock_gnupghome)
@ -333,7 +331,7 @@ def test_push_and_fetch_keys(mock_gnupghome):
# dir 1: create a new key, record its fingerprint, and push it to a new # dir 1: create a new key, record its fingerprint, and push it to a new
# mirror # mirror
with spack.util.gpg.gnupg_home_override(gpg_dir1): with spack.util.gpg.gnupghome_override(gpg_dir1):
spack.util.gpg.create(name='test-key', spack.util.gpg.create(name='test-key',
email='fake@test.key', email='fake@test.key',
expires='0', expires='0',
@ -347,7 +345,7 @@ def test_push_and_fetch_keys(mock_gnupghome):
# dir 2: import the key from the mirror, and confirm that its fingerprint # dir 2: import the key from the mirror, and confirm that its fingerprint
# matches the one created above # matches the one created above
with spack.util.gpg.gnupg_home_override(gpg_dir2): with spack.util.gpg.gnupghome_override(gpg_dir2):
assert len(spack.util.gpg.public_keys()) == 0 assert len(spack.util.gpg.public_keys()) == 0
bindist.get_keys(mirrors=mirrors, install=True, trust=True, force=True) bindist.get_keys(mirrors=mirrors, install=True, trust=True, force=True)

View file

@ -56,8 +56,6 @@ def test_urlencode_string():
assert(s_enc == 'Spack+Test+Project') assert(s_enc == 'Spack+Test+Project')
@pytest.mark.skipif(not spack.util.gpg.has_gpg(),
reason='This test requires gpg')
def test_import_signing_key(mock_gnupghome): def test_import_signing_key(mock_gnupghome):
signing_key_dir = spack_paths.mock_gpg_keys_path signing_key_dir = spack_paths.mock_gpg_keys_path
signing_key_path = os.path.join(signing_key_dir, 'package-signing-key') signing_key_path = os.path.join(signing_key_dir, 'package-signing-key')

View file

@ -138,8 +138,6 @@ def test_buildcache_create_fail_on_perm_denied(
tmpdir.chmod(0o700) tmpdir.chmod(0o700)
@pytest.mark.skipif(not spack.util.gpg.has_gpg(),
reason='This test requires gpg')
def test_update_key_index(tmpdir, mutable_mock_env_path, def test_update_key_index(tmpdir, mutable_mock_env_path,
install_mockery, mock_packages, mock_fetch, install_mockery, mock_packages, mock_fetch,
mock_stage, mock_gnupghome): mock_stage, mock_gnupghome):

View file

@ -647,8 +647,6 @@ def test_ci_generate_with_external_pkg(tmpdir, mutable_mock_env_path,
assert not any('externaltool' in key for key in yaml_contents) assert not any('externaltool' in key for key in yaml_contents)
@pytest.mark.skipif(not spack.util.gpg.has_gpg(),
reason='This test requires gpg')
def test_ci_rebuild(tmpdir, mutable_mock_env_path, env_deactivate, def test_ci_rebuild(tmpdir, mutable_mock_env_path, env_deactivate,
install_mockery, mock_packages, monkeypatch, install_mockery, mock_packages, monkeypatch,
mock_gnupghome, mock_fetch): mock_gnupghome, mock_fetch):
@ -864,8 +862,6 @@ def fake_dl_method(spec, dest, require_cdashid, m_url=None):
@pytest.mark.disable_clean_stage_check @pytest.mark.disable_clean_stage_check
@pytest.mark.skipif(not spack.util.gpg.has_gpg(),
reason='This test requires gpg')
def test_push_mirror_contents(tmpdir, mutable_mock_env_path, env_deactivate, def test_push_mirror_contents(tmpdir, mutable_mock_env_path, env_deactivate,
install_mockery, mock_packages, mock_fetch, install_mockery, mock_packages, mock_fetch,
mock_stage, mock_gnupghome): mock_stage, mock_gnupghome):

View file

@ -41,24 +41,20 @@ def test_find_gpg(cmd_name, version, tmpdir, mock_gnupghome, monkeypatch):
monkeypatch.setitem(os.environ, "PATH", str(tmpdir)) monkeypatch.setitem(os.environ, "PATH", str(tmpdir))
if version == 'undetectable' or version.endswith('1.3.4'): if version == 'undetectable' or version.endswith('1.3.4'):
with pytest.raises(spack.util.gpg.SpackGPGError): with pytest.raises(spack.util.gpg.SpackGPGError):
spack.util.gpg.ensure_gpg(reevaluate=True) spack.util.gpg.init(force=True)
else: else:
spack.util.gpg.ensure_gpg(reevaluate=True) spack.util.gpg.init(force=True)
gpg_exe = spack.util.gpg.get_global_gpg_instance().gpg_exe assert spack.util.gpg.GPG is not None
assert isinstance(gpg_exe, spack.util.executable.Executable) assert spack.util.gpg.GPGCONF is not None
gpgconf_exe = spack.util.gpg.get_global_gpg_instance().gpgconf_exe
assert isinstance(gpgconf_exe, spack.util.executable.Executable)
def test_no_gpg_in_path(tmpdir, mock_gnupghome, monkeypatch): def test_no_gpg_in_path(tmpdir, mock_gnupghome, monkeypatch):
monkeypatch.setitem(os.environ, "PATH", str(tmpdir)) monkeypatch.setitem(os.environ, "PATH", str(tmpdir))
with pytest.raises(spack.util.gpg.SpackGPGError): with pytest.raises(spack.util.gpg.SpackGPGError):
spack.util.gpg.ensure_gpg(reevaluate=True) spack.util.gpg.init(force=True)
@pytest.mark.maybeslow @pytest.mark.maybeslow
@pytest.mark.skipif(not spack.util.gpg.has_gpg(),
reason='These tests require gnupg2')
def test_gpg(tmpdir, mock_gnupghome): def test_gpg(tmpdir, mock_gnupghome):
# Verify a file with an empty keyring. # Verify a file with an empty keyring.
with pytest.raises(ProcessError): with pytest.raises(ProcessError):

View file

@ -842,8 +842,14 @@ def mock_gnupghome(monkeypatch):
# have to make our own tmpdir with a shorter name than pytest's. # have to make our own tmpdir with a shorter name than pytest's.
# This comes up because tmp paths on macOS are already long-ish, and # This comes up because tmp paths on macOS are already long-ish, and
# pytest makes them longer. # pytest makes them longer.
try:
spack.util.gpg.init()
except spack.util.gpg.SpackGPGError:
if not spack.util.gpg.GPG:
pytest.skip('This test requires gpg')
short_name_tmpdir = tempfile.mkdtemp() short_name_tmpdir = tempfile.mkdtemp()
with spack.util.gpg.gnupg_home_override(short_name_tmpdir): with spack.util.gpg.gnupghome_override(short_name_tmpdir):
yield short_name_tmpdir yield short_name_tmpdir
# clean up, since we are doing this manually # clean up, since we are doing this manually

View file

@ -39,8 +39,6 @@ def fake_fetchify(url, pkg):
pkg.fetcher = fetcher pkg.fetcher = fetcher
@pytest.mark.skipif(not spack.util.gpg.has_gpg(),
reason='This test requires gpg')
@pytest.mark.usefixtures('install_mockery', 'mock_gnupghome') @pytest.mark.usefixtures('install_mockery', 'mock_gnupghome')
def test_buildcache(mock_archive, tmpdir): def test_buildcache(mock_archive, tmpdir):
# tweak patchelf to only do a download # tweak patchelf to only do a download

View file

@ -9,6 +9,12 @@
import spack.util.gpg import spack.util.gpg
@pytest.fixture()
def has_socket_dir():
spack.util.gpg.init()
return bool(spack.util.gpg.SOCKET_DIR)
def test_parse_gpg_output_case_one(): def test_parse_gpg_output_case_one():
# Two keys, fingerprint for primary keys, but not subkeys # Two keys, fingerprint for primary keys, but not subkeys
output = """sec::2048:1:AAAAAAAAAAAAAAAA:AAAAAAAAAA:AAAAAAAAAA::::::::: output = """sec::2048:1:AAAAAAAAAAAAAAAA:AAAAAAAAAA:AAAAAAAAAA:::::::::
@ -20,7 +26,7 @@ def test_parse_gpg_output_case_one():
uid:::::::AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA::Joe (Test) <j.s@s.com>: uid:::::::AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA::Joe (Test) <j.s@s.com>:
ssb::2048:1:AAAAAAAAAAAAAAAA:AAAAAAAAAA:::::::::: ssb::2048:1:AAAAAAAAAAAAAAAA:AAAAAAAAAA::::::::::
""" """
keys = spack.util.gpg.parse_secret_keys_output(output) keys = spack.util.gpg._parse_secret_keys_output(output)
assert len(keys) == 2 assert len(keys) == 2
assert keys[0] == 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX' assert keys[0] == 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'
@ -37,7 +43,7 @@ def test_parse_gpg_output_case_two():
fpr:::::::::YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY: fpr:::::::::YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY:
grp:::::::::AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA: grp:::::::::AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:
""" """
keys = spack.util.gpg.parse_secret_keys_output(output) keys = spack.util.gpg._parse_secret_keys_output(output)
assert len(keys) == 1 assert len(keys) == 1
assert keys[0] == 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX' assert keys[0] == 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'
@ -56,19 +62,19 @@ def test_parse_gpg_output_case_three():
ssb::2048:1:AAAAAAAAAAAAAAAA:AAAAAAAAAA:::::::::: ssb::2048:1:AAAAAAAAAAAAAAAA:AAAAAAAAAA::::::::::
fpr:::::::::ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ:""" fpr:::::::::ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ:"""
keys = spack.util.gpg.parse_secret_keys_output(output) keys = spack.util.gpg._parse_secret_keys_output(output)
assert len(keys) == 2 assert len(keys) == 2
assert keys[0] == 'WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW' assert keys[0] == 'WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW'
assert keys[1] == 'YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY' assert keys[1] == 'YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY'
@pytest.mark.skipif(not spack.util.gpg.GpgConstants.user_run_dir,
reason='This test requires /var/run/user/$(id -u)')
@pytest.mark.requires_executables('gpg2') @pytest.mark.requires_executables('gpg2')
def test_really_long_gnupg_home_dir(tmpdir): def test_really_long_gnupghome_dir(tmpdir, has_socket_dir):
N = 960 if not has_socket_dir:
pytest.skip('This test requires /var/run/user/$(id -u)')
N = 960
tdir = str(tmpdir) tdir = str(tmpdir)
while len(tdir) < N: while len(tdir) < N:
tdir = os.path.join(tdir, 'filler') tdir = os.path.join(tdir, 'filler')
@ -76,10 +82,11 @@ def test_really_long_gnupg_home_dir(tmpdir):
tdir = tdir[:N].rstrip(os.sep) tdir = tdir[:N].rstrip(os.sep)
tdir += '0' * (N - len(tdir)) tdir += '0' * (N - len(tdir))
with spack.util.gpg.gnupg_home_override(tdir): with spack.util.gpg.gnupghome_override(tdir):
spack.util.gpg.create(name='Spack testing 1', spack.util.gpg.create(
name='Spack testing 1',
email='test@spack.io', email='test@spack.io',
comment='Spack testing key', comment='Spack testing key',
expires='0') expires='0'
)
spack.util.gpg.list(True, True) spack.util.gpg.list(True, True)

View file

@ -2,74 +2,123 @@
# Spack Project Developers. See the top-level COPYRIGHT file for details. # Spack Project Developers. See the top-level COPYRIGHT file for details.
# #
# SPDX-License-Identifier: (Apache-2.0 OR MIT) # SPDX-License-Identifier: (Apache-2.0 OR MIT)
import contextlib import contextlib
import errno import errno
import functools import functools
import os import os
import re import re
import llnl.util.lang
import spack.error import spack.error
import spack.paths import spack.paths
import spack.util.executable import spack.util.executable
import spack.version import spack.version
_gnupg_version_re = r"^gpg(conf)? \(GnuPG\) (.*)$" #: Executable instance for "gpg", initialized lazily
_gnupg_home_override = None GPG = None
_global_gpg_instance = None #: Executable instance for "gpgconf", initialized lazily
GPGCONF = None
#: Socket directory required if a non default home directory is used
SOCKET_DIR = None
#: GNUPGHOME environment variable in the context of this Python module
GNUPGHOME = None
def get_gnupg_home(gnupg_home=None): def clear():
"""Returns the directory that should be used as the GNUPGHOME environment """Reset the global state to uninitialized."""
variable when calling gpg. global GPG, GPGCONF, SOCKET_DIR, GNUPGHOME
GPG, GPGCONF, SOCKET_DIR, GNUPGHOME = None, None, None, None
If a [gnupg_home] is passed directly (and not None), that value will be
used.
Otherwise, if there is an override set (and it is not None), then that def init(gnupghome=None, force=False):
value will be used. """Initialize the global objects in the module, if not set.
Otherwise, if the environment variable "SPACK_GNUPGHOME" is set, then that When calling any gpg executable, the GNUPGHOME environment
value will be used. variable is set to:
Otherwise, the default gpg path for Spack will be used. 1. The value of the `gnupghome` argument, if not None
2. The value of the "SPACK_GNUPGHOME" environment variable, if set
3. The default gpg path for Spack otherwise
See also: gnupg_home_override() Args:
gnupghome (str): value to be used for GNUPGHOME when calling
GnuPG executables
force (bool): if True forces the re-initialization even if the
global objects are set already
""" """
return (gnupg_home or global GPG, GPGCONF, SOCKET_DIR, GNUPGHOME
_gnupg_home_override or if force:
clear()
# If the executables are already set, there's nothing to do
if GPG and GNUPGHOME:
return
# Set the value of GNUPGHOME to be used in this module
GNUPGHOME = (gnupghome or
os.getenv('SPACK_GNUPGHOME') or os.getenv('SPACK_GNUPGHOME') or
spack.paths.gpg_path) spack.paths.gpg_path)
# Set the executable objects for "gpg" and "gpgconf"
GPG, GPGCONF = _gpg(), _gpgconf()
GPG.add_default_env('GNUPGHOME', GNUPGHOME)
if GPGCONF:
GPGCONF.add_default_env('GNUPGHOME', GNUPGHOME)
# Set the socket dir if not using GnuPG defaults
SOCKET_DIR = _socket_dir(GPGCONF)
# Make sure that the GNUPGHOME exists
if not os.path.exists(GNUPGHOME):
os.makedirs(GNUPGHOME)
os.chmod(GNUPGHOME, 0o700)
if not os.path.isdir(GNUPGHOME):
msg = 'GNUPGHOME "{0}" exists and is not a directory'.format(GNUPGHOME)
raise SpackGPGError(msg)
if SOCKET_DIR is not None:
GPGCONF('--create-socketdir')
def _autoinit(func):
"""Decorator to ensure that global variables have been initialized before
running the decorated function.
Args:
func (callable): decorated function
"""
@functools.wraps(func)
def _wrapped(*args, **kwargs):
init()
return func(*args, **kwargs)
return _wrapped
@contextlib.contextmanager @contextlib.contextmanager
def gnupg_home_override(new_gnupg_home): def gnupghome_override(dir):
global _gnupg_home_override """Set the GNUPGHOME to a new location for this context.
global _global_gpg_instance
old_gnupg_home_override = _gnupg_home_override Args:
old_global_gpg_instance = _global_gpg_instance dir (str): new value for GNUPGHOME
"""
global GPG, GPGCONF, SOCKET_DIR, GNUPGHOME
_gnupg_home_override = new_gnupg_home # Store backup values
_global_gpg_instance = None _GPG, _GPGCONF = GPG, GPGCONF
_SOCKET_DIR, _GNUPGHOME = SOCKET_DIR, GNUPGHOME
clear()
# Clear global state
init(gnupghome=dir, force=True)
yield yield
_gnupg_home_override = old_gnupg_home_override clear()
_global_gpg_instance = old_global_gpg_instance GPG, GPGCONF = _GPG, _GPGCONF
SOCKET_DIR, GNUPGHOME = _SOCKET_DIR, _GNUPGHOME
def get_global_gpg_instance(): def _parse_secret_keys_output(output):
global _global_gpg_instance
if _global_gpg_instance is None:
_global_gpg_instance = Gpg()
return _global_gpg_instance
def parse_secret_keys_output(output):
keys = [] keys = []
found_sec = False found_sec = False
for line in output.split('\n'): for line in output.split('\n'):
@ -84,7 +133,7 @@ def parse_secret_keys_output(output):
return keys return keys
def parse_public_keys_output(output): def _parse_public_keys_output(output):
keys = [] keys = []
found_pub = False found_pub = False
for line in output.split('\n'): for line in output.split('\n'):
@ -99,103 +148,188 @@ def parse_public_keys_output(output):
return keys return keys
cached_property = getattr(functools, 'cached_property', None) class SpackGPGError(spack.error.SpackError):
"""Class raised when GPG errors are detected."""
# If older python version has no cached_property, emulate it here.
# TODO(opadron): maybe this shim should be moved to llnl.util.lang?
if not cached_property:
def cached_property(*args, **kwargs):
result = property(llnl.util.lang.memoized(*args, **kwargs))
attr = result.fget.__name__
@result.deleter
def result(self):
getattr(type(self), attr).fget.cache.pop((self,), None)
return result
class _GpgConstants(object): @_autoinit
@cached_property def create(**kwargs):
def target_version(self): """Create a new key pair."""
return spack.version.Version('2') r, w = os.pipe()
with contextlib.closing(os.fdopen(r, 'r')) as r:
with contextlib.closing(os.fdopen(w, 'w')) as w:
w.write('''
Key-Type: rsa
Key-Length: 4096
Key-Usage: sign
Name-Real: %(name)s
Name-Email: %(email)s
Name-Comment: %(comment)s
Expire-Date: %(expires)s
%%no-protection
%%commit
''' % kwargs)
GPG('--gen-key', '--batch', input=r)
@cached_property
def gpgconf_string(self):
exe_str = spack.util.executable.which_string(
'gpgconf', 'gpg2conf', 'gpgconf2')
no_gpgconf_msg = ( @_autoinit
def signing_keys(*args):
"""Return the keys that can be used to sign binaries."""
output = GPG(
'--list-secret-keys', '--with-colons', '--fingerprint',
*args, output=str
)
return _parse_secret_keys_output(output)
@_autoinit
def public_keys(*args):
"""Return the keys that can be used to verify binaries."""
output = GPG(
'--list-public-keys', '--with-colons', '--fingerprint',
*args, output=str
)
return _parse_public_keys_output(output)
@_autoinit
def export_keys(location, keys, secret=False):
"""Export public keys to a location passed as argument.
Args:
location (str): where to export the keys
keys (list): keys to be exported
secret (bool): whether to export secret keys or not
"""
if secret:
GPG("--export-secret-keys", "--armor", "--output", location, *keys)
else:
GPG("--batch", "--yes", "--armor", "--export", "--output", location, *keys)
@_autoinit
def trust(keyfile):
"""Import a public key from a file.
Args:
keyfile (str): file with the public key
"""
GPG('--import', keyfile)
@_autoinit
def untrust(signing, *keys):
"""Delete known keys.
Args:
signing (bool): if True deletes the secret keys
*keys: keys to be deleted
"""
if signing:
skeys = signing_keys(*keys)
GPG('--batch', '--yes', '--delete-secret-keys', *skeys)
pkeys = public_keys(*keys)
GPG('--batch', '--yes', '--delete-keys', *pkeys)
@_autoinit
def sign(key, file, output, clearsign=False):
"""Sign a file with a key.
Args:
key: key to be used to sign
file (str): file to be signed
output (str): output file (either the clearsigned file or
the detached signature)
clearsign (bool): if True wraps the document in an ASCII-armored
signature, if False creates a detached signature
"""
signopt = '--clearsign' if clearsign else '--detach-sign'
GPG(signopt, '--armor', '--default-key', key, '--output', output, file)
@_autoinit
def verify(signature, file, suppress_warnings=False):
"""Verify the signature on a file.
Args:
signature (str): signature of the file
file (str): file to be verified
suppress_warnings (bool): whether or not to suppress warnings
from GnuPG
"""
kwargs = {'error': str} if suppress_warnings else {}
GPG('--verify', signature, file, **kwargs)
@_autoinit
def list(trusted, signing):
"""List known keys.
Args:
trusted (bool): if True list public keys
signing (bool): if True list private keys
"""
if trusted:
GPG('--list-public-keys')
if signing:
GPG('--list-secret-keys')
def _verify_exe_or_raise(exe):
msg = (
'Spack requires gpgconf version >= 2\n' 'Spack requires gpgconf version >= 2\n'
' To install a suitable version using Spack, run\n' ' To install a suitable version using Spack, run\n'
' spack install gnupg@2:\n' ' spack install gnupg@2:\n'
' and load it by running\n' ' and load it by running\n'
' spack load gnupg@2:') ' spack load gnupg@2:'
)
if not exe:
raise SpackGPGError(msg)
if not exe_str:
raise SpackGPGError(no_gpgconf_msg)
exe = spack.util.executable.Executable(exe_str)
output = exe('--version', output=str) output = exe('--version', output=str)
match = re.search(_gnupg_version_re, output, re.M) match = re.search(r"^gpg(conf)? \(GnuPG\) (.*)$", output, re.M)
if not match: if not match:
raise SpackGPGError('Could not determine gpgconf version') raise SpackGPGError(
'Could not determine "{0}" version'.format(exe.name)
)
if spack.version.Version(match.group(2)) < self.target_version: if spack.version.Version(match.group(2)) < spack.version.Version('2'):
raise SpackGPGError(no_gpgconf_msg) raise SpackGPGError(msg)
def _gpgconf():
exe = spack.util.executable.which('gpgconf', 'gpg2conf', 'gpgconf2')
_verify_exe_or_raise(exe)
# ensure that the gpgconf we found can run "gpgconf --create-socketdir" # ensure that the gpgconf we found can run "gpgconf --create-socketdir"
try: try:
exe('--dry-run', '--create-socketdir') exe('--dry-run', '--create-socketdir')
except spack.util.executable.ProcessError: except spack.util.executable.ProcessError:
# no dice # no dice
exe_str = None exe = None
return exe_str return exe
@cached_property
def gpg_string(self):
exe_str = spack.util.executable.which_string('gpg2', 'gpg')
no_gpg_msg = ( def _gpg():
'Spack requires gpg version >= 2\n' exe = spack.util.executable.which('gpg2', 'gpg')
' To install a suitable version using Spack, run\n' _verify_exe_or_raise(exe)
' spack install gnupg@2:\n' return exe
' and load it by running\n'
' spack load gnupg@2:')
if not exe_str:
raise SpackGPGError(no_gpg_msg)
exe = spack.util.executable.Executable(exe_str) def _socket_dir(gpgconf):
output = exe('--version', output=str)
match = re.search(_gnupg_version_re, output, re.M)
if not match:
raise SpackGPGError('Could not determine gpg version')
if spack.version.Version(match.group(2)) < self.target_version:
raise SpackGPGError(no_gpg_msg)
return exe_str
@cached_property
def user_run_dir(self):
# Try to ensure that (/var)/run/user/$(id -u) exists so that # Try to ensure that (/var)/run/user/$(id -u) exists so that
# `gpgconf --create-socketdir` can be run later. # `gpgconf --create-socketdir` can be run later.
# #
# NOTE(opadron): This action helps prevent a large class of # NOTE(opadron): This action helps prevent a large class of
# "file-name-too-long" errors in gpg. # "file-name-too-long" errors in gpg.
try:
has_suitable_gpgconf = bool(GpgConstants.gpgconf_string)
except SpackGPGError:
has_suitable_gpgconf = False
# If there is no suitable gpgconf, don't even bother trying to # If there is no suitable gpgconf, don't even bother trying to
# precreate a user run dir. # pre-create a user run dir.
if not has_suitable_gpgconf: if not gpgconf:
return None return None
result = None result = None
@ -235,198 +369,3 @@ def user_run_dir(self):
result = user_dir result = user_dir
return result return result
def clear(self):
for attr in ('gpgconf_string', 'gpg_string', 'user_run_dir'):
try:
delattr(self, attr)
except AttributeError:
pass
GpgConstants = _GpgConstants()
def ensure_gpg(reevaluate=False):
if reevaluate:
GpgConstants.clear()
if GpgConstants.user_run_dir is not None:
GpgConstants.gpgconf_string
GpgConstants.gpg_string
return True
def has_gpg(*args, **kwargs):
try:
return ensure_gpg(*args, **kwargs)
except SpackGPGError:
return False
# NOTE(opadron): When adding methods to this class, consider adding convenience
# wrapper functions further down in this file.
class Gpg(object):
def __init__(self, gnupg_home=None):
self.gnupg_home = get_gnupg_home(gnupg_home)
@cached_property
def prep(self):
# Make sure that suitable versions of gpgconf and gpg are available
ensure_gpg()
# Make sure that the GNUPGHOME exists
if not os.path.exists(self.gnupg_home):
os.makedirs(self.gnupg_home)
os.chmod(self.gnupg_home, 0o700)
if not os.path.isdir(self.gnupg_home):
raise SpackGPGError(
'GNUPGHOME "{0}" exists and is not a directory'.format(
self.gnupg_home))
if GpgConstants.user_run_dir is not None:
self.gpgconf_exe('--create-socketdir')
return True
@cached_property
def gpgconf_exe(self):
exe = spack.util.executable.Executable(GpgConstants.gpgconf_string)
exe.add_default_env('GNUPGHOME', self.gnupg_home)
return exe
@cached_property
def gpg_exe(self):
exe = spack.util.executable.Executable(GpgConstants.gpg_string)
exe.add_default_env('GNUPGHOME', self.gnupg_home)
return exe
def __call__(self, *args, **kwargs):
if self.prep:
return self.gpg_exe(*args, **kwargs)
def create(self, **kwargs):
r, w = os.pipe()
r = os.fdopen(r, 'r')
w = os.fdopen(w, 'w')
w.write('''
Key-Type: rsa
Key-Length: 4096
Key-Usage: sign
Name-Real: %(name)s
Name-Email: %(email)s
Name-Comment: %(comment)s
Expire-Date: %(expires)s
%%no-protection
%%commit
''' % kwargs)
w.close()
self('--gen-key', '--batch', input=r)
r.close()
def signing_keys(self, *args):
output = self('--list-secret-keys', '--with-colons', '--fingerprint',
*args, output=str)
return parse_secret_keys_output(output)
def public_keys(self, *args):
output = self('--list-public-keys', '--with-colons', '--fingerprint',
*args, output=str)
return parse_public_keys_output(output)
def export_keys(self, location, keys, secret=False):
if secret:
self("--export-secret-keys", "--armor", "--output", location, *keys)
else:
self('--batch', '--yes', '--armor', '--export', '--output',
location, *keys)
def trust(self, keyfile):
self('--import', keyfile)
def untrust(self, signing, *keys):
if signing:
skeys = self.signing_keys(*keys)
self('--batch', '--yes', '--delete-secret-keys', *skeys)
pkeys = self.public_keys(*keys)
self('--batch', '--yes', '--delete-keys', *pkeys)
def sign(self, key, file, output, clearsign=False):
self(('--clearsign' if clearsign else '--detach-sign'),
'--armor', '--default-key', key,
'--output', output, file)
def verify(self, signature, file, suppress_warnings=False):
self('--verify', signature, file,
**({'error': str} if suppress_warnings else {}))
def list(self, trusted, signing):
if trusted:
self('--list-public-keys')
if signing:
self('--list-secret-keys')
class SpackGPGError(spack.error.SpackError):
"""Class raised when GPG errors are detected."""
# Convenience wrappers for methods of the Gpg class
# __call__ is a bit of a special case, since the Gpg instance is, itself, the
# "thing" that is being called.
@functools.wraps(Gpg.__call__)
def gpg(*args, **kwargs):
return get_global_gpg_instance()(*args, **kwargs)
gpg.name = 'gpg' # type: ignore[attr-defined]
@functools.wraps(Gpg.create)
def create(*args, **kwargs):
return get_global_gpg_instance().create(*args, **kwargs)
@functools.wraps(Gpg.signing_keys)
def signing_keys(*args, **kwargs):
return get_global_gpg_instance().signing_keys(*args, **kwargs)
@functools.wraps(Gpg.public_keys)
def public_keys(*args, **kwargs):
return get_global_gpg_instance().public_keys(*args, **kwargs)
@functools.wraps(Gpg.export_keys)
def export_keys(*args, **kwargs):
return get_global_gpg_instance().export_keys(*args, **kwargs)
@functools.wraps(Gpg.trust)
def trust(*args, **kwargs):
return get_global_gpg_instance().trust(*args, **kwargs)
@functools.wraps(Gpg.untrust)
def untrust(*args, **kwargs):
return get_global_gpg_instance().untrust(*args, **kwargs)
@functools.wraps(Gpg.sign)
def sign(*args, **kwargs):
return get_global_gpg_instance().sign(*args, **kwargs)
@functools.wraps(Gpg.verify)
def verify(*args, **kwargs):
return get_global_gpg_instance().verify(*args, **kwargs)
@functools.wraps(Gpg.list)
def list(*args, **kwargs):
return get_global_gpg_instance().list(*args, **kwargs)