Skip to content

Commit 2f106ff

Browse files
committed
Support checking for expired and untrusted keys
Adds state `trusted`. Fixes #9949
1 parent 4a2cc71 commit 2f106ff

File tree

2 files changed

+274
-79
lines changed

2 files changed

+274
-79
lines changed

plugins/modules/pacman_key.py

Lines changed: 110 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -78,9 +78,9 @@
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 present (added), trusted (signed and not expired) or absent (revoked).
8282
default: present
83-
choices: [absent, present]
83+
choices: [absent, present, trusted]
8484
type: str
8585
"""
8686

@@ -129,12 +129,55 @@
129129
from ansible.module_utils.common.text.converters import to_native
130130

131131

132+
class GpgListResult:
133+
"""Wraps gpg --list-* output."""
134+
135+
def __init__(self, line) -> None:
136+
self._parts = line.split(':')
137+
138+
@property
139+
def kind(self):
140+
return self._parts[0]
141+
142+
@property
143+
def valid(self):
144+
return self._parts[1]
145+
146+
@property
147+
def is_fully_valid(self):
148+
return self.valid == 'f'
149+
150+
@property
151+
def key(self):
152+
return self._parts[4]
153+
154+
@property
155+
def user_id(self):
156+
return self._parts[9]
157+
158+
159+
def gpg_get_first(lines, kind, attr):
160+
for line in lines:
161+
glr = GpgListResult(line)
162+
if glr.kind == kind:
163+
return getattr(glr, attr)
164+
165+
166+
def gpg_gather_all(lines, kind, attr):
167+
result = []
168+
for line in lines:
169+
glr = GpgListResult(line)
170+
if glr.kind == kind:
171+
result.append(getattr(glr, attr))
172+
return result
173+
174+
132175
class PacmanKey(object):
133176
def __init__(self, module):
134177
self.module = module
135178
# 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)
179+
self.gpg_binary = module.get_bin_path('gpg', required=True)
180+
self.pacman_key_binary = module.get_bin_path('pacman-key', required=True)
138181

139182
# obtain module parameters
140183
keyid = module.params['id']
@@ -150,43 +193,68 @@ def __init__(self, module):
150193

151194
# sanitise key ID & check if key exists in the keyring
152195
keyid = self.sanitise_keyid(keyid)
153-
key_present = self.key_in_keyring(keyring, keyid)
196+
key_validity = self.key_validity(keyring, keyid)
197+
key_present = len(key_validity) > 0
198+
key_valid = any(key_validity)
154199

155200
# check mode
156201
if module.check_mode:
157-
if state == "present":
202+
if state in ['present', 'trusted']:
158203
changed = (key_present and force_update) or not key_present
204+
if not changed and state == 'trusted':
205+
changed = not (key_valid and self.key_is_trusted(keyring, keyid))
159206
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)
164-
165-
if state == "present":
166-
if key_present and not force_update:
167-
module.exit_json(changed=False)
168-
207+
if state == 'absent':
208+
module.exit_json(changed=key_present)
209+
210+
if state in ['present', 'trusted']:
211+
trusted = key_valid and self.key_is_trusted(keyring, keyid)
212+
if not force_update:
213+
if (state == 'present' and key_present) or (state == 'trusted' and trusted):
214+
module.exit_json(changed=False)
215+
changed = False
169216
if data:
170217
file = self.save_key(data)
171218
self.add_key(keyring, file, keyid, verify)
172-
module.exit_json(changed=True)
219+
changed = True
173220
elif file:
174221
self.add_key(keyring, file, keyid, verify)
175-
module.exit_json(changed=True)
222+
changed = True
176223
elif url:
177224
data = self.fetch_key(url)
178225
file = self.save_key(data)
179226
self.add_key(keyring, file, keyid, verify)
180-
module.exit_json(changed=True)
227+
changed = True
181228
elif keyserver:
182229
self.recv_key(keyring, keyid, keyserver)
183-
module.exit_json(changed=True)
184-
elif state == "absent":
230+
changed = True
231+
if changed or (state == 'trusted' and not trusted):
232+
self.lsign_key(keyring=keyring, keyid=keyid)
233+
changed = True
234+
module.exit_json(changed=changed)
235+
elif state == 'absent':
185236
if key_present:
186237
self.remove_key(keyring, keyid)
187238
module.exit_json(changed=True)
188239
module.exit_json(changed=False)
189240

241+
def gpg(self, args, /, keyring=None, **kwargs):
242+
cmd = [self.gpg_binary]
243+
if keyring:
244+
cmd.append(f'--homedir={keyring}')
245+
cmd.extend(['--no-permission-warning', '--with-colons', '--quiet', '--batch', '--no-tty'])
246+
return self.module.run_command(cmd + args, **kwargs)
247+
248+
def pacman_key(self, args, /, keyring, **kwargs):
249+
return self.module.run_command(
250+
[self.pacman_key_binary, '--gpgdir', keyring] + args,
251+
**kwargs,
252+
)
253+
254+
def pacman_machine_key(self, keyring):
255+
_, stdout, _ = self.gpg(['--list-secret-key'], keyring=keyring)
256+
return gpg_get_first(stdout.splitlines(), 'sec', 'key')
257+
190258
def is_hexadecimal(self, string):
191259
"""Check if a given string is valid hexadecimal"""
192260
try:
@@ -216,14 +284,11 @@ def fetch_key(self, url):
216284

217285
def recv_key(self, keyring, keyid, keyserver):
218286
"""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)
287+
self.pacman_key(['--keyserver', keyserver, '--recv-keys', keyid], keyring=keyring, check_rc=True)
222288

223289
def lsign_key(self, keyring, keyid):
224290
"""Locally sign key"""
225-
cmd = [self.pacman_key, '--gpgdir', keyring]
226-
self.module.run_command(cmd + ['--lsign-key', keyid], check_rc=True)
291+
self.pacman_key(['--lsign-key', keyid], keyring=keyring, check_rc=True)
227292

228293
def save_key(self, data):
229294
"Saves key data to a temporary file"
@@ -238,14 +303,11 @@ def add_key(self, keyring, keyfile, keyid, verify):
238303
"""Add key to pacman's keyring"""
239304
if verify:
240305
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)
306+
self.pacman_key(['--add', keyfile], keyring=keyring, check_rc=True)
244307

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

250312
def verify_keyfile(self, keyfile, keyid):
251313
"""Verify that keyfile matches the specified key ID"""
@@ -254,48 +316,29 @@ def verify_keyfile(self, keyfile, keyid):
254316
elif keyid is None:
255317
self.module.fail_json(msg="expected a key ID, got none")
256318

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-
],
319+
rc, stdout, stderr = self.gpg(
320+
['--with-fingerprint', '--show-keys', keyfile],
267321
check_rc=True,
268322
)
269323

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

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-
)
328+
def key_validity(self, keyring, keyid):
329+
"Check if the key ID is in pacman's keyring and not expired"
330+
rc, stdout, stderr = self.gpg(['--no-default-keyring', '--list-keys', keyid], keyring=keyring, check_rc=False)
293331
if rc != 0:
294332
if stderr.find("No public key") >= 0:
295-
return False
333+
return []
296334
else:
297335
self.module.fail_json(msg="gpg returned an error: %s" % stderr)
298-
return True
336+
return gpg_gather_all(stdout.splitlines(), 'uid', 'is_fully_valid')
337+
338+
def key_is_trusted(self, keyring, keyid):
339+
"""Check if key is signed and not expired."""
340+
_, stdout, _ = self.gpg(['--check-signatures', keyid], keyring=keyring)
341+
return self.pacman_machine_key(keyring) in gpg_gather_all(stdout.splitlines(), 'sig', 'key')
299342

300343

301344
def main():
@@ -309,7 +352,11 @@ def main():
309352
verify=dict(type='bool', default=True),
310353
force_update=dict(type='bool', default=False),
311354
keyring=dict(type='path', default='/etc/pacman.d/gnupg'),
312-
state=dict(type='str', default='present', choices=['absent', 'present']),
355+
state=dict(
356+
type='str',
357+
default='present',
358+
choices=['absent', 'present', 'trusted'],
359+
),
313360
),
314361
supports_check_mode=True,
315362
mutually_exclusive=(('data', 'file', 'url', 'keyserver'),),

0 commit comments

Comments
 (0)