Skip to content

Commit 030c516

Browse files
committed
Merge #131: guix-verify: Add modified gitian-verify for new build system
b44713b guix-verify: Add modified gitian-verify for new build system (Dimitri) Pull request description: Added a modified gitian-verify for the new build system. Closes #129 ACKs for top commit: laanwj: Tested ACK b44713b works just like gitian-verify e.g. Tree-SHA512: 9c2b75ed5d822b510577d327b4312d44371071cfa7a21161e0c33c6776a7e65bd6a932804ed4267d098270156dcbb2a5bd1b63ed05e3b758cc1c8e8f3e4468a4
2 parents 7647ace + b44713b commit 030c516

File tree

2 files changed

+330
-0
lines changed

2 files changed

+330
-0
lines changed

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,18 @@ The following statuses can be shown:
325325
- `Bad` Known key but invalid PGP signature.
326326
- `Mismatch` Correct PGP signature but mismatching binaries.
327327

328+
guix-verify
329+
-----------
330+
331+
A script to verify guix deterministic build signatures for a release in one
332+
glance. It will print a matrix of signer versus build package ("noncodesigned"
333+
and "all"), and a list of missing keys.
334+
335+
Arguments and usage are the same as gitian-verify except that you don't need the `pyyaml`
336+
module for this one.
337+
338+
Example usage: `./guix-verify.py -r 23.0rc2 -d ../guix.sigs -k ../bitcoin/contrib/builder-keys/keys.txt`
339+
328340
ghwatch
329341
-------
330342

guix-verify.py

Lines changed: 318 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,318 @@
1+
#!/usr/bin/env python3
2+
'''
3+
Verify all guix signatures for a release and tabulate the outcome.
4+
'''
5+
import argparse
6+
import collections
7+
from enum import Enum, IntFlag
8+
import gpg
9+
import os
10+
import re
11+
import sys
12+
from typing import Dict, List, Set, Optional, Tuple
13+
14+
15+
class Status(Enum):
16+
'''Verification status enumeration.'''
17+
OK = 0 # Full match
18+
NO_FILE = 1 # Result file or sig file not found
19+
UNKNOWN_KEY = 2 # Name/key combination not in keys.txt
20+
MISSING_KEY = 3 # Unknown PGP key
21+
EXPIRED_KEY = 4 # PGP key is expired
22+
INVALID_SIG = 5 # Known key but invalid signature
23+
MISMATCH = 6 # Correct signature but mismatching file
24+
25+
class Missing(IntFlag):
26+
'''Bit field for missing keys,'''
27+
GPG = 1 # Missing from GPG
28+
KEYSTXT = 2 # Missing form keys.txt
29+
30+
class Attr:
31+
'''Terminal attributes.'''
32+
BOLD = '\033[1m'
33+
REVERSE = '\033[7m'
34+
RESET = '\033[0m'
35+
# Table entries
36+
# format is Status.X: (output, screen width*)
37+
# *can't use .len() due to ANSI formatting and unicode char width issues
38+
GLYPHS = {
39+
None: ('\x1b[90m-\x1b[0m', 1),
40+
Status.OK: ('\x1b[92mOK\x1b[0m', 2),
41+
Status.NO_FILE: ('\x1b[90m-\x1b[0m', 1),
42+
Status.MISSING_KEY: ('\x1b[96mNo Key\x1b[0m', 6),
43+
Status.EXPIRED_KEY: ('\x1b[96mExpired\x1b[0m', 7),
44+
Status.INVALID_SIG: ('\x1b[91mBad\x1b[0m', 3),
45+
Status.MISMATCH: ('\x1b[91mMismatch\x1b[0m', 8),
46+
}
47+
DIFF_OLD = '\033[91m'
48+
DIFF_NEW = '\033[92m'
49+
50+
VerificationResult = collections.namedtuple('VerificationResult', ['verify_ok', 'p_fingerprint', 's_fingerprint', 'error'])
51+
class VerificationInterface:
52+
'''
53+
Interface to verify GPG signatures.
54+
'''
55+
# Error values from verify_detached
56+
MISSING_KEY = 0
57+
BAD = 1
58+
EXPIRED_KEY = 2
59+
60+
def __init__(self) -> None:
61+
self.ctx = gpg.Context(offline=True)
62+
63+
def verify_detached(self, sig: bytes, result: bytes) -> VerificationResult:
64+
'''
65+
Verify a detached GPG signature.
66+
This function takes a OS path to the signature, and to the signed data.
67+
It returns a VerificationResult tuple (verify_ok, p_fingerprint, s_fingerprint, error).
68+
- verify_ok is a bool specifying if the signature was correctly verified.
69+
- primary_key is the key fingerprint of the primary key (or None if not known)
70+
- sub_key is the key fingerprint of the signing subkey used (or None if not known)
71+
- error is a an error code (if !verify_ok) MISSING_KEY, EXPIRED_KEY or BAD
72+
'''
73+
try:
74+
(_, r) = self.ctx.verify(signed_data=result, signature=sig)
75+
except gpg.errors.BadSignatures as e:
76+
r = e.result
77+
verify_ok = False
78+
else:
79+
verify_ok = True
80+
81+
assert(len(r.signatures) == 1) # we don't handle multiple signatures in one assert file
82+
p_fingerprint = None
83+
s_fingerprint = r.signatures[0].fpr
84+
error = None
85+
if r.signatures[0].summary & gpg.constants.sigsum.KEY_MISSING:
86+
error = VerificationInterface.MISSING_KEY
87+
else: # key is known to gnupg
88+
if r.signatures[0].summary & gpg.constants.sigsum.KEY_EXPIRED:
89+
error = VerificationInterface.EXPIRED_KEY
90+
elif not verify_ok: # verification failed, but no specific error to report
91+
error = VerificationInterface.BAD
92+
key = self.ctx.get_key(s_fingerprint)
93+
p_fingerprint = key.fpr
94+
95+
return VerificationResult(
96+
verify_ok=verify_ok,
97+
p_fingerprint=p_fingerprint,
98+
s_fingerprint=s_fingerprint,
99+
error=error)
100+
101+
BuildInfo = collections.namedtuple('BuildInfo', ['build_name', 'package_name'])
102+
103+
def load_keys_txt(filename: str) -> List[Tuple[str, str]]:
104+
'''
105+
Load signer aliases and key fingerprints from a keys.txt file.
106+
'''
107+
keys = []
108+
with open(filename, 'r') as f:
109+
for line in f:
110+
m = re.match('([0-9A-F]+) .* \((.*)\)', line)
111+
if m:
112+
for name in m.group(2).split(','):
113+
keys.append((m.group(1).upper(), name.strip().lower()))
114+
return keys
115+
116+
def parse_args() -> argparse.Namespace:
117+
'''Parse command line arguments.'''
118+
parser = argparse.ArgumentParser(description='Verify guix signatures')
119+
120+
parser.add_argument('--verbose', '-v', action='store_const', const=True, default=False, help='Be more verbose')
121+
parser.add_argument('--release', '-r', help='Release version (for example 23.0rc3)', required=True)
122+
parser.add_argument('--directory', '-d', help='Signatures directory', required=True)
123+
parser.add_argument('--keys', '-k', help='Path to keys.txt', required=True)
124+
parser.add_argument('--compare-to', '-c', help="Compare other manifests to COMPARE_TO's, if not given pick first")
125+
126+
return parser.parse_args()
127+
128+
def validate_build(verifier: VerificationInterface,
129+
compare_to: str,
130+
release_path: str,
131+
result_file: str,
132+
sig_file: str,
133+
verbose: bool,
134+
keys: List[Tuple[str, str]]) -> Tuple[Dict[str, Status], Dict[Tuple[str, str], Missing]]:
135+
'''Validate a single build (directory in guix.sigs).'''
136+
if not os.path.isdir(release_path):
137+
return ({}, {}, {})
138+
139+
reference = None
140+
if compare_to is not None:
141+
# Load a specific 'golden' manifest to compare to
142+
result_path = os.path.join(release_path, compare_to, result_file)
143+
with open(result_path, 'r') as f:
144+
reference = f.read()
145+
146+
results = {}
147+
mismatches = {}
148+
missing_keys: Dict[Tuple[str, str], Missing] = collections.defaultdict(lambda: Missing(0))
149+
for signer_name in os.listdir(release_path):
150+
if verbose:
151+
print(f'For { signer_name }...')
152+
signer_dir = os.path.join(release_path, signer_name)
153+
if not os.path.isdir(signer_dir):
154+
continue
155+
156+
result_path = os.path.join(signer_dir, result_file)
157+
sig_path = os.path.join(signer_dir, sig_file)
158+
159+
if not os.path.isfile(result_path) or not os.path.isfile(sig_path):
160+
results[signer_name] = Status.NO_FILE
161+
continue
162+
163+
with open(sig_path, 'rb') as f:
164+
sig_data = f.read()
165+
with open(result_path, 'rb') as f:
166+
result_data = f.read()
167+
vres = verifier.verify_detached(sig_data, result_data)
168+
169+
fingerprint = vres.p_fingerprint or vres.s_fingerprint
170+
# Check if the (signer, fingerprint) pair is specified in keys.txt (either the primary
171+
# or subkey is allowed to be specified).
172+
#
173+
# It is important to check the specific combination, otherwise a person
174+
# could guix-sign for someone else and it would be undetected.
175+
not_in_keys = False
176+
if (vres.p_fingerprint, signer_name.lower()) not in keys and (vres.s_fingerprint, signer_name.lower()) not in keys:
177+
missing_keys[(signer_name, fingerprint)] |= Missing.KEYSTXT
178+
not_in_keys = True
179+
180+
if not vres.verify_ok: # Invalid signature or missing key
181+
if vres.error == VerificationInterface.MISSING_KEY:
182+
# missing key, store fingerprint for reporting
183+
missing_keys[(signer_name, fingerprint)] |= Missing.GPG
184+
results[signer_name] = Status.MISSING_KEY
185+
elif vres.error == VerificationInterface.EXPIRED_KEY:
186+
results[signer_name] = Status.EXPIRED_KEY
187+
else:
188+
results[signer_name] = Status.INVALID_SIG
189+
continue
190+
else: # Valid PGP signature
191+
# if the key, signer pair is not in keys.txt, we can't trust it so
192+
# skip out here
193+
if not_in_keys:
194+
results[signer_name] = Status.MISSING_KEY
195+
continue
196+
197+
result = result_data.decode()
198+
199+
if reference is not None and result != reference:
200+
results[signer_name] = Status.MISMATCH
201+
mismatches[signer_name] = (result, reference)
202+
else:
203+
results[signer_name] = Status.OK
204+
205+
# if there is no reference, the first with a correct signature is the reference
206+
if reference is None:
207+
reference = result
208+
209+
if verbose:
210+
print(results[signer_name])
211+
212+
return (results, missing_keys, mismatches)
213+
214+
def center(s: str, width: int, total_width: int) -> str:
215+
'''Center text.'''
216+
pad = max(total_width - width, 0)
217+
return (' ' * (pad // 2)) + s + (' ' * ((pad + 1) // 2))
218+
219+
def main() -> None:
220+
args = parse_args()
221+
keys = load_keys_txt(args.keys)
222+
verifier = VerificationInterface()
223+
224+
# build descriptor is only used to determine the package name
225+
# maybe we could derive it otherwise (or simply look for *any* assert file)
226+
all_missing_keys: Dict[Tuple[str,str],int] = collections.defaultdict(int)
227+
all_results = {}
228+
all_mismatches = {}
229+
230+
builds = ["noncodesigned", "all"]
231+
232+
for build in builds:
233+
if args.verbose:
234+
print(f'Validate "{ build }" build')
235+
236+
release_path = os.path.join(args.directory, args.release)
237+
238+
result_file = f'{build}.SHA256SUMS'
239+
sig_file = result_file + '.asc'
240+
241+
# goal: create a matrix signer × variant → status
242+
# keep a list of unknown key fingerprints
243+
(results, missing_keys, mismatches) = validate_build(verifier, args.compare_to, release_path, result_file, sig_file, args.verbose, keys)
244+
all_results[build] = results
245+
for k, v in missing_keys.items():
246+
all_missing_keys[k] |= v
247+
all_mismatches[build] = mismatches
248+
249+
# Make a table of signer versus build
250+
all_signers_set: Set[str] = set()
251+
for result in all_results.values():
252+
all_signers_set.update(result.keys())
253+
all_signers = sorted(list(all_signers_set), key=str.casefold)
254+
255+
if not all_signers:
256+
print(f'No build results were found in {args.directory} for release {args.release}', file=sys.stderr)
257+
exit(1)
258+
259+
name_maxlen = max(max((len(name) for name in all_signers)), 8)
260+
build_maxlen = max(max(len(build) for build in builds),
261+
max(glyph[1] for glyph in Attr.GLYPHS.values()))
262+
263+
header = Attr.REVERSE + Attr.BOLD
264+
header += 'Signer'.ljust(name_maxlen)
265+
header += ' '
266+
for build in builds:
267+
pad = build_maxlen - len(build)
268+
header += center(build, len(build), build_maxlen)
269+
header += ' '
270+
header += Attr.RESET
271+
print(header)
272+
273+
for name in all_signers:
274+
statuses = []
275+
for build in builds:
276+
r: Optional[Status]
277+
try:
278+
r = all_results[build][name]
279+
except KeyError:
280+
r = None
281+
statuses.append(r)
282+
283+
line = name.ljust(name_maxlen)
284+
line += ' '
285+
for status in statuses:
286+
line += center(Attr.GLYPHS[status][0], Attr.GLYPHS[status][1], build_maxlen)
287+
line += ' '
288+
print(line)
289+
290+
if all_missing_keys:
291+
print()
292+
print(f'{Attr.REVERSE}Missing keys{Attr.RESET}')
293+
for (name, fingerprint),bits in all_missing_keys.items():
294+
line = name.ljust(name_maxlen)
295+
line += ' '
296+
line += fingerprint or '???'
297+
line += ' '
298+
miss = []
299+
if bits & Missing.GPG:
300+
miss.append('from GPG')
301+
if bits & Missing.KEYSTXT:
302+
miss.append('from keys.txt')
303+
line += ', '.join(miss)
304+
print(line)
305+
306+
if all_mismatches:
307+
print()
308+
print(f'{Attr.REVERSE}Mismatches{Attr.RESET}')
309+
for (build, m) in all_mismatches.items():
310+
for (signer, (result, reference)) in m.items():
311+
print(f'{signer} ({build}):')
312+
for (a, b) in zip(reference.split('\n'), result.split('\n')):
313+
if a != b:
314+
print(f' -{Attr.DIFF_OLD}{a}{Attr.RESET}')
315+
print(f' +{Attr.DIFF_NEW}{b}{Attr.RESET}')
316+
317+
if __name__ == '__main__':
318+
main()

0 commit comments

Comments
 (0)