Skip to content

Commit 0c7d8bc

Browse files
committed
Support checking for expired and untrusted keys
Adds option `ensure_trusted`. Fixes #9949
1 parent 4a2cc71 commit 0c7d8bc

File tree

3 files changed

+277
-75
lines changed

3 files changed

+277
-75
lines changed
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
---
2+
minor_changes:
3+
- pacman_key - support verifying that keys are trusted and not expired (https://github.com/ansible-collections/community.general/issues/9949, https://github.com/ansible-collections/community.general/pull/9950).

plugins/modules/pacman_key.py

Lines changed: 108 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -78,10 +78,16 @@
7878
default: /etc/pacman.d/gnupg
7979
state:
8080
description:
81-
- Ensures that the key is present (added) or absent (revoked).
81+
- Ensures that the key is V(present) (added) or V(absent) (revoked).
8282
default: present
8383
choices: [absent, present]
8484
type: str
85+
ensure_trusted:
86+
description:
87+
- Ensure that the key is trusted (signed by the Pacman machine key and not expired).
88+
- This option has been added in community.general 10.6.0.
89+
type: bool
90+
default: false
8591
"""
8692

8793
EXAMPLES = r"""
@@ -129,12 +135,55 @@
129135
from ansible.module_utils.common.text.converters import to_native
130136

131137

138+
class GpgListResult(object):
139+
"""Wraps gpg --list-* output."""
140+
141+
def __init__(self, line):
142+
self._parts = line.split(':')
143+
144+
@property
145+
def kind(self):
146+
return self._parts[0]
147+
148+
@property
149+
def valid(self):
150+
return self._parts[1]
151+
152+
@property
153+
def is_fully_valid(self):
154+
return self.valid == 'f'
155+
156+
@property
157+
def key(self):
158+
return self._parts[4]
159+
160+
@property
161+
def user_id(self):
162+
return self._parts[9]
163+
164+
165+
def gpg_get_first(lines, kind, attr):
166+
for line in lines:
167+
glr = GpgListResult(line)
168+
if glr.kind == kind:
169+
return getattr(glr, attr)
170+
171+
172+
def gpg_gather_all(lines, kind, attr):
173+
result = []
174+
for line in lines:
175+
glr = GpgListResult(line)
176+
if glr.kind == kind:
177+
result.append(getattr(glr, attr))
178+
return result
179+
180+
132181
class PacmanKey(object):
133182
def __init__(self, module):
134183
self.module = module
135184
# obtain binary paths for gpg & pacman-key
136-
self.gpg = module.get_bin_path('gpg', required=True)
137-
self.pacman_key = module.get_bin_path('pacman-key', required=True)
185+
self.gpg_binary = module.get_bin_path('gpg', required=True)
186+
self.pacman_key_binary = module.get_bin_path('pacman-key', required=True)
138187

139188
# obtain module parameters
140189
keyid = module.params['id']
@@ -146,47 +195,71 @@ def __init__(self, module):
146195
force_update = module.params['force_update']
147196
keyring = module.params['keyring']
148197
state = module.params['state']
198+
ensure_trusted = module.params['ensure_trusted']
149199
self.keylength = 40
150200

151201
# sanitise key ID & check if key exists in the keyring
152202
keyid = self.sanitise_keyid(keyid)
153-
key_present = self.key_in_keyring(keyring, keyid)
203+
key_validity = self.key_validity(keyring, keyid)
204+
key_present = len(key_validity) > 0
205+
key_valid = any(key_validity)
154206

155207
# check mode
156208
if module.check_mode:
157-
if state == "present":
209+
if state == 'present':
158210
changed = (key_present and force_update) or not key_present
211+
if not changed and ensure_trusted:
212+
changed = not (key_valid and self.key_is_trusted(keyring, keyid))
159213
module.exit_json(changed=changed)
160-
elif state == "absent":
161-
if key_present:
162-
module.exit_json(changed=True)
163-
module.exit_json(changed=False)
214+
if state == 'absent':
215+
module.exit_json(changed=key_present)
164216

165-
if state == "present":
166-
if key_present and not force_update:
217+
if state == 'present':
218+
trusted = key_valid and self.key_is_trusted(keyring, keyid)
219+
if not force_update and key_present and (not ensure_trusted or trusted):
167220
module.exit_json(changed=False)
168-
221+
changed = False
169222
if data:
170223
file = self.save_key(data)
171224
self.add_key(keyring, file, keyid, verify)
172-
module.exit_json(changed=True)
225+
changed = True
173226
elif file:
174227
self.add_key(keyring, file, keyid, verify)
175-
module.exit_json(changed=True)
228+
changed = True
176229
elif url:
177230
data = self.fetch_key(url)
178231
file = self.save_key(data)
179232
self.add_key(keyring, file, keyid, verify)
180-
module.exit_json(changed=True)
233+
changed = True
181234
elif keyserver:
182235
self.recv_key(keyring, keyid, keyserver)
183-
module.exit_json(changed=True)
184-
elif state == "absent":
236+
changed = True
237+
if changed or (ensure_trusted and not trusted):
238+
self.lsign_key(keyring=keyring, keyid=keyid)
239+
changed = True
240+
module.exit_json(changed=changed)
241+
elif state == 'absent':
185242
if key_present:
186243
self.remove_key(keyring, keyid)
187244
module.exit_json(changed=True)
188245
module.exit_json(changed=False)
189246

247+
def gpg(self, args, keyring=None, **kwargs):
248+
cmd = [self.gpg_binary]
249+
if keyring:
250+
cmd.append('--homedir={keyring}'.format(keyring=keyring))
251+
cmd.extend(['--no-permission-warning', '--with-colons', '--quiet', '--batch', '--no-tty'])
252+
return self.module.run_command(cmd + args, **kwargs)
253+
254+
def pacman_key(self, args, keyring, **kwargs):
255+
return self.module.run_command(
256+
[self.pacman_key_binary, '--gpgdir', keyring] + args,
257+
**kwargs)
258+
259+
def pacman_machine_key(self, keyring):
260+
unused_rc, stdout, unused_stderr = self.gpg(['--list-secret-key'], keyring=keyring)
261+
return gpg_get_first(stdout.splitlines(), 'sec', 'key')
262+
190263
def is_hexadecimal(self, string):
191264
"""Check if a given string is valid hexadecimal"""
192265
try:
@@ -216,14 +289,11 @@ def fetch_key(self, url):
216289

217290
def recv_key(self, keyring, keyid, keyserver):
218291
"""Receives key via keyserver"""
219-
cmd = [self.pacman_key, '--gpgdir', keyring, '--keyserver', keyserver, '--recv-keys', keyid]
220-
self.module.run_command(cmd, check_rc=True)
221-
self.lsign_key(keyring, keyid)
292+
self.pacman_key(['--keyserver', keyserver, '--recv-keys', keyid], keyring=keyring, check_rc=True)
222293

223294
def lsign_key(self, keyring, keyid):
224295
"""Locally sign key"""
225-
cmd = [self.pacman_key, '--gpgdir', keyring]
226-
self.module.run_command(cmd + ['--lsign-key', keyid], check_rc=True)
296+
self.pacman_key(['--lsign-key', keyid], keyring=keyring, check_rc=True)
227297

228298
def save_key(self, data):
229299
"Saves key data to a temporary file"
@@ -238,14 +308,11 @@ def add_key(self, keyring, keyfile, keyid, verify):
238308
"""Add key to pacman's keyring"""
239309
if verify:
240310
self.verify_keyfile(keyfile, keyid)
241-
cmd = [self.pacman_key, '--gpgdir', keyring, '--add', keyfile]
242-
self.module.run_command(cmd, check_rc=True)
243-
self.lsign_key(keyring, keyid)
311+
self.pacman_key(['--add', keyfile], keyring=keyring, check_rc=True)
244312

245313
def remove_key(self, keyring, keyid):
246314
"""Remove key from pacman's keyring"""
247-
cmd = [self.pacman_key, '--gpgdir', keyring, '--delete', keyid]
248-
self.module.run_command(cmd, check_rc=True)
315+
self.pacman_key(['--delete', keyid], keyring=keyring, check_rc=True)
249316

250317
def verify_keyfile(self, keyfile, keyid):
251318
"""Verify that keyfile matches the specified key ID"""
@@ -254,48 +321,29 @@ def verify_keyfile(self, keyfile, keyid):
254321
elif keyid is None:
255322
self.module.fail_json(msg="expected a key ID, got none")
256323

257-
rc, stdout, stderr = self.module.run_command(
258-
[
259-
self.gpg,
260-
'--with-colons',
261-
'--with-fingerprint',
262-
'--batch',
263-
'--no-tty',
264-
'--show-keys',
265-
keyfile
266-
],
324+
rc, stdout, stderr = self.gpg(
325+
['--with-fingerprint', '--show-keys', keyfile],
267326
check_rc=True,
268327
)
269328

270-
extracted_keyid = None
271-
for line in stdout.splitlines():
272-
if line.startswith('fpr:'):
273-
extracted_keyid = line.split(':')[9]
274-
break
275-
329+
extracted_keyid = gpg_get_first(stdout.splitlines(), 'fpr', 'user_id')
276330
if extracted_keyid != keyid:
277331
self.module.fail_json(msg="key ID does not match. expected %s, got %s" % (keyid, extracted_keyid))
278332

279-
def key_in_keyring(self, keyring, keyid):
280-
"Check if the key ID is in pacman's keyring"
281-
rc, stdout, stderr = self.module.run_command(
282-
[
283-
self.gpg,
284-
'--with-colons',
285-
'--batch',
286-
'--no-tty',
287-
'--no-default-keyring',
288-
'--keyring=%s/pubring.gpg' % keyring,
289-
'--list-keys', keyid
290-
],
291-
check_rc=False,
292-
)
333+
def key_validity(self, keyring, keyid):
334+
"Check if the key ID is in pacman's keyring and not expired"
335+
rc, stdout, stderr = self.gpg(['--no-default-keyring', '--list-keys', keyid], keyring=keyring, check_rc=False)
293336
if rc != 0:
294337
if stderr.find("No public key") >= 0:
295-
return False
338+
return []
296339
else:
297340
self.module.fail_json(msg="gpg returned an error: %s" % stderr)
298-
return True
341+
return gpg_gather_all(stdout.splitlines(), 'uid', 'is_fully_valid')
342+
343+
def key_is_trusted(self, keyring, keyid):
344+
"""Check if key is signed and not expired."""
345+
unused_rc, stdout, unused_stderr = self.gpg(['--check-signatures', keyid], keyring=keyring)
346+
return self.pacman_machine_key(keyring) in gpg_gather_all(stdout.splitlines(), 'sig', 'key')
299347

300348

301349
def main():
@@ -309,6 +357,7 @@ def main():
309357
verify=dict(type='bool', default=True),
310358
force_update=dict(type='bool', default=False),
311359
keyring=dict(type='path', default='/etc/pacman.d/gnupg'),
360+
ensure_trusted=dict(type='bool', default=False),
312361
state=dict(type='str', default='present', choices=['absent', 'present']),
313362
),
314363
supports_check_mode=True,

0 commit comments

Comments
 (0)