Skip to content

Commit 7a6e7ff

Browse files
achow101theuni
authored andcommitted
contrib: Use machine parseable GPG output in verifybinaries
GPG has an option to provide machine parseable output. Use that instead of trying to parse the human readable output.
1 parent 6b2cebf commit 7a6e7ff

File tree

1 file changed

+69
-74
lines changed

1 file changed

+69
-74
lines changed

contrib/verifybinaries/verify.py

Lines changed: 69 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
import urllib.request
4343
import enum
4444
from hashlib import sha256
45-
from pathlib import PurePath
45+
from pathlib import PurePath, Path
4646

4747
# The primary host; this will fail if we can't retrieve files from here.
4848
HOST1 = "https://bitcoincore.org"
@@ -141,14 +141,19 @@ def verify_with_gpg(
141141
signature_filename,
142142
output_filename: t.Optional[str] = None
143143
) -> t.Tuple[int, str]:
144-
args = [
145-
'gpg', '--yes', '--verify', '--verify-options', 'show-primary-uid-only',
146-
'--output', output_filename if output_filename else '', signature_filename, filename]
144+
with tempfile.NamedTemporaryFile() as status_file:
145+
args = [
146+
'gpg', '--yes', '--verify', '--verify-options', 'show-primary-uid-only', "--status-file", status_file.name,
147+
'--output', output_filename if output_filename else '', signature_filename, filename]
147148

148-
env = dict(os.environ, LANGUAGE='en')
149-
result = subprocess.run(args, stderr=subprocess.STDOUT, stdout=subprocess.PIPE, env=env)
150-
log.debug(f'Result from GPG ({result.returncode}): {result.stdout}')
151-
return result.returncode, result.stdout.decode().rstrip()
149+
env = dict(os.environ, LANGUAGE='en')
150+
result = subprocess.run(args, stderr=subprocess.STDOUT, stdout=subprocess.PIPE, env=env)
151+
152+
gpg_data = status_file.read().decode().rstrip()
153+
154+
log.debug(f'Result from GPG ({result.returncode}): {result.stdout.decode()}')
155+
log.debug(f"{gpg_data}")
156+
return result.returncode, gpg_data
152157

153158

154159
def remove_files(filenames):
@@ -158,11 +163,14 @@ def remove_files(filenames):
158163

159164
class SigData:
160165
"""GPG signature data as parsed from GPG stdout."""
161-
def __init__(self, key: str, name: str, trusted: bool, status: str):
162-
self.key = key
163-
self.name = name
164-
self.trusted = trusted
165-
self.status = status
166+
def __init__(self):
167+
self.key = None
168+
self.name = ""
169+
self.trusted = False
170+
self.status = ""
171+
172+
def __bool__(self):
173+
return self.key is not None
166174

167175
def __repr__(self):
168176
return (
@@ -174,60 +182,60 @@ def parse_gpg_result(
174182
output: t.List[str]
175183
) -> t.Tuple[t.List[SigData], t.List[SigData], t.List[SigData]]:
176184
"""Returns good, unknown, and bad signatures from GPG stdout."""
177-
good_sigs = []
178-
unknown_sigs = []
179-
bad_sigs = []
185+
good_sigs: t.List[SigData] = []
186+
unknown_sigs: t.List[SigData] = []
187+
bad_sigs: t.List[SigData] = []
180188
total_resolved_sigs = 0
181-
curr_key = None
182189

183190
# Ensure that all lines we match on include a prefix that prevents malicious input
184191
# from fooling the parser.
185192
def line_begins_with(patt: str, line: str) -> t.Optional[re.Match]:
186-
return re.match(r'^\s*(gpg:)?(\s+)' + patt, line)
187-
188-
detected_name = ''
189-
190-
for i, line in enumerate(output):
191-
if line_begins_with(r"using (ECDSA|RSA) key (0x[0-9a-fA-F]{16}|[0-9a-fA-F]{40})$", line):
192-
if curr_key:
193-
raise RuntimeError(
194-
f"WARNING: encountered a new sig without resolving the last ({curr_key}) - "
195-
"this could mean we have encountered a bad signature! check GPG output!")
196-
curr_key = line.split('key ')[-1].strip()
197-
assert len(curr_key) == 40 or (len(curr_key) == 18 and curr_key.startswith('0x'))
198-
199-
if line_begins_with(r"Can't check signature: No public key$", line):
200-
if not curr_key:
201-
raise RuntimeError("failed to detect signature being resolved")
202-
unknown_sigs.append(SigData(curr_key, detected_name, False, ''))
203-
detected_name = ''
204-
curr_key = None
205-
206-
if line_begins_with(r'Good signature from (".+")(\s+)(\[.+\])$', line):
207-
if not curr_key:
208-
raise RuntimeError("failed to detect signature being resolved")
209-
name, status = parse_gpg_from_line(line)
210-
211-
# It's safe to index output[i + 1] because if we saw a good sig, there should
212-
# always be another line
213-
trusted = (
214-
'This key is not certified with a trusted signature' not in output[i + 1])
215-
good_sigs.append(SigData(curr_key, name, trusted, status))
216-
curr_key = None
217-
218-
if line_begins_with("issuer ", line):
219-
detected_name = line.split("issuer ")[-1].strip('"')
220-
221-
if 'bad signature from' in line.lower():
222-
if not curr_key:
223-
raise RuntimeError("failed to detect signature being resolved")
224-
name, status = parse_gpg_from_line(line)
225-
bad_sigs.append(SigData(curr_key, name, False, status))
226-
curr_key = None
227-
228-
# Track total signatures included
229-
if line_begins_with('Signature made ', line):
193+
return re.match(r'^(\[GNUPG:\])\s+' + patt, line)
194+
195+
curr_sigs = unknown_sigs
196+
curr_sigdata = SigData()
197+
198+
for line in output:
199+
if line_begins_with(r"NEWSIG(?:\s|$)", line):
230200
total_resolved_sigs += 1
201+
if curr_sigdata:
202+
curr_sigs.append(curr_sigdata)
203+
curr_sigdata = SigData()
204+
newsig_split = line.split()
205+
if len(newsig_split) == 3:
206+
curr_sigdata.name = newsig_split[2]
207+
208+
elif line_begins_with(r"GOODSIG(?:\s|$)", line):
209+
curr_sigdata.key, curr_sigdata.name = line.split(maxsplit=3)[2:4]
210+
curr_sigs = good_sigs
211+
212+
elif line_begins_with(r"EXPKEYSIG(?:\s|$)", line):
213+
curr_sigdata.key, curr_sigdata.name = line.split(maxsplit=3)[2:4]
214+
curr_sigs = good_sigs
215+
curr_sigdata.status = "expired"
216+
217+
elif line_begins_with(r"REVKEYSIG(?:\s|$)", line):
218+
curr_sigdata.key, curr_sigdata.name = line.split(maxsplit=3)[2:4]
219+
curr_sigs = good_sigs
220+
curr_sigdata.status = "revoked"
221+
222+
elif line_begins_with(r"BADSIG(?:\s|$)", line):
223+
curr_sigdata.key, curr_sigdata.name = line.split(maxsplit=3)[2:4]
224+
curr_sigs = bad_sigs
225+
226+
elif line_begins_with(r"ERRSIG(?:\s|$)", line):
227+
curr_sigdata.key, _, _, _, _, _ = line.split()[2:8]
228+
curr_sigs = unknown_sigs
229+
230+
elif line_begins_with(r"TRUST_(UNDEFINED|NEVER)(?:\s|$)", line):
231+
curr_sigdata.trusted = False
232+
233+
elif line_begins_with(r"TRUST_(MARGINAL|FULLY|ULTIMATE)(?:\s|$)", line):
234+
curr_sigdata.trusted = True
235+
236+
# The last one won't have been added, so add it now
237+
assert curr_sigdata
238+
curr_sigs.append(curr_sigdata)
231239

232240
all_found = len(good_sigs + bad_sigs + unknown_sigs)
233241
if all_found != total_resolved_sigs:
@@ -238,19 +246,6 @@ def line_begins_with(patt: str, line: str) -> t.Optional[re.Match]:
238246
return (good_sigs, unknown_sigs, bad_sigs)
239247

240248

241-
def parse_gpg_from_line(line: str) -> t.Tuple[str, str]:
242-
"""Returns name and expiration status."""
243-
assert 'signature from' in line
244-
245-
name_end = line.split(' from ')[-1]
246-
m = re.search(r'(?P<name>".+") \[(?P<status>\w+)\]', name_end)
247-
assert m
248-
(name, status) = m.groups()
249-
name = name.strip('"\'')
250-
251-
return (name, status)
252-
253-
254249
def files_are_equal(filename1, filename2):
255250
with open(filename1, 'rb') as file1:
256251
contents1 = file1.read()

0 commit comments

Comments
 (0)