|
| 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