at 23.11-beta 298 lines 9.2 kB view raw
1#! /usr/bin/env nix-shell 2#! nix-shell -i python -p "python3.withPackages (ps: [ps.pygithub])" git gnupg 3 4# This is automatically called by ../update.sh. 5 6from __future__ import annotations 7 8import json 9import os 10import re 11import subprocess 12import sys 13from dataclasses import dataclass 14from pathlib import Path 15from tempfile import TemporaryDirectory 16from typing import ( 17 Dict, 18 Iterator, 19 List, 20 Optional, 21 Sequence, 22 Tuple, 23 TypedDict, 24 Union, 25) 26 27from github import Github 28from github.GitRelease import GitRelease 29 30VersionComponent = Union[int, str] 31Version = List[VersionComponent] 32 33 34PatchData = TypedDict("PatchData", {"name": str, "url": str, "sha256": str, "extra": str}) 35Patch = TypedDict("Patch", { 36 "patch": PatchData, 37 "version": str, 38 "sha256": str, 39}) 40 41 42@dataclass 43class ReleaseInfo: 44 version: Version 45 release: GitRelease 46 47 48HERE = Path(__file__).resolve().parent 49NIXPKGS_KERNEL_PATH = HERE.parent 50NIXPKGS_PATH = HERE.parents[4] 51HARDENED_GITHUB_REPO = "anthraxx/linux-hardened" 52HARDENED_TRUSTED_KEY = HERE / "anthraxx.asc" 53HARDENED_PATCHES_PATH = HERE / "patches.json" 54MIN_KERNEL_VERSION: Version = [4, 14] 55 56 57def run(*args: Union[str, Path]) -> subprocess.CompletedProcess[bytes]: 58 try: 59 return subprocess.run( 60 args, 61 check=True, 62 stdout=subprocess.PIPE, 63 stderr=subprocess.PIPE, 64 encoding="utf-8", 65 ) 66 except subprocess.CalledProcessError as err: 67 print( 68 f"error: `{err.cmd}` failed unexpectedly\n" 69 f"status code: {err.returncode}\n" 70 f"stdout:\n{err.stdout.strip()}\n" 71 f"stderr:\n{err.stderr.strip()}", 72 file=sys.stderr, 73 ) 74 sys.exit(1) 75 76 77def nix_prefetch_url(url: str) -> Tuple[str, Path]: 78 output = run("nix-prefetch-url", "--print-path", url).stdout 79 sha256, path = output.strip().split("\n") 80 return sha256, Path(path) 81 82 83def verify_openpgp_signature( 84 *, name: str, trusted_key: Path, sig_path: Path, data_path: Path, 85) -> bool: 86 with TemporaryDirectory(suffix=".nixpkgs-gnupg-home") as gnupg_home_str: 87 gnupg_home = Path(gnupg_home_str) 88 run("gpg", "--homedir", gnupg_home, "--import", trusted_key) 89 keyring = gnupg_home / "pubring.kbx" 90 try: 91 subprocess.run( 92 ("gpgv", "--keyring", keyring, sig_path, data_path), 93 check=True, 94 stderr=subprocess.PIPE, 95 encoding="utf-8", 96 ) 97 return True 98 except subprocess.CalledProcessError as err: 99 print( 100 f"error: signature for {name} failed to verify!", 101 file=sys.stderr, 102 ) 103 print(err.stderr, file=sys.stderr, end="") 104 return False 105 106 107def fetch_patch(*, name: str, release_info: ReleaseInfo) -> Optional[Patch]: 108 release = release_info.release 109 extra = f'-{release_info.version[-1]}' 110 111 def find_asset(filename: str) -> str: 112 try: 113 it: Iterator[str] = ( 114 asset.browser_download_url 115 for asset in release.get_assets() 116 if asset.name == filename 117 ) 118 return next(it) 119 except StopIteration: 120 raise KeyError(filename) 121 122 patch_filename = f"{name}.patch" 123 try: 124 patch_url = find_asset(patch_filename) 125 sig_url = find_asset(patch_filename + ".sig") 126 except KeyError: 127 print(f"error: {patch_filename}{{,.sig}} not present", file=sys.stderr) 128 return None 129 130 sha256, patch_path = nix_prefetch_url(patch_url) 131 _, sig_path = nix_prefetch_url(sig_url) 132 sig_ok = verify_openpgp_signature( 133 name=name, 134 trusted_key=HARDENED_TRUSTED_KEY, 135 sig_path=sig_path, 136 data_path=patch_path, 137 ) 138 if not sig_ok: 139 return None 140 141 kernel_ver = re.sub(r"(.*)(-hardened[\d]+)$", r'\1', release_info.release.tag_name) 142 major = kernel_ver.split('.')[0] 143 sha256_kernel, _ = nix_prefetch_url(f"mirror://kernel/linux/kernel/v{major}.x/linux-{kernel_ver}.tar.xz") 144 145 return Patch( 146 patch=PatchData(name=patch_filename, url=patch_url, sha256=sha256, extra=extra), 147 version=kernel_ver, 148 sha256=sha256_kernel 149 ) 150 151 152def parse_version(version_str: str) -> Version: 153 version: Version = [] 154 for component in re.split('\.|\-', version_str): 155 try: 156 version.append(int(component)) 157 except ValueError: 158 version.append(component) 159 return version 160 161 162def version_string(version: Version) -> str: 163 return ".".join(str(component) for component in version) 164 165 166def major_kernel_version_key(kernel_version: Version) -> str: 167 return version_string(kernel_version[:-1]) 168 169 170def commit_patches(*, kernel_key: str, message: str) -> None: 171 new_patches_path = HARDENED_PATCHES_PATH.with_suffix(".new") 172 with open(new_patches_path, "w") as new_patches_file: 173 json.dump(patches, new_patches_file, indent=4, sort_keys=True) 174 new_patches_file.write("\n") 175 os.rename(new_patches_path, HARDENED_PATCHES_PATH) 176 message = f"linux/hardened/patches/{kernel_key}: {message}" 177 print(message) 178 if os.environ.get("COMMIT"): 179 run( 180 "git", 181 "-C", 182 NIXPKGS_PATH, 183 "commit", 184 f"--message={message}", 185 HARDENED_PATCHES_PATH, 186 ) 187 188 189# Load the existing patches. 190patches: Dict[str, Patch] 191with open(HARDENED_PATCHES_PATH) as patches_file: 192 patches = json.load(patches_file) 193 194# Get the set of currently packaged kernel versions. 195kernel_versions = {} 196with open(NIXPKGS_KERNEL_PATH / "kernels-org.json") as kernel_versions_json: 197 kernel_versions = json.load(kernel_versions_json) 198 for kernel_branch_str in kernel_versions: 199 if kernel_branch_str == "testing": continue 200 kernel_branch = [int(i) for i in kernel_branch_str.split(".")] 201 if kernel_branch < MIN_KERNEL_VERSION: continue 202 kernel_version = [int(i) for i in kernel_versions[kernel_branch_str]["version"].split(".")] 203 kernel_versions[kernel_branch_str] = kernel_version 204 205# Remove patches for unpackaged kernel versions. 206for kernel_key in sorted(patches.keys() - kernel_versions.keys()): 207 commit_patches(kernel_key=kernel_key, message="remove") 208 209g = Github(os.environ.get("GITHUB_TOKEN")) 210repo = g.get_repo(HARDENED_GITHUB_REPO) 211failures = False 212 213# Match each kernel version with the best patch version. 214releases = {} 215i = 0 216for release in repo.get_releases(): 217 # Dirty workaround to make sure that we don't run into issues because 218 # GitHub's API only allows fetching the last 1000 releases. 219 # It's not reliable to exit earlier because not every kernel minor may 220 # have hardened patches, hence the naive search below. 221 i += 1 222 if i > 500: 223 break 224 225 version = parse_version(release.tag_name) 226 # needs to look like e.g. 5.6.3-hardened1 227 if len(version) < 4: 228 continue 229 230 if not (isinstance(version[-2], int)): 231 continue 232 233 kernel_version = version[:-1] 234 235 kernel_key = major_kernel_version_key(kernel_version) 236 try: 237 packaged_kernel_version = kernel_versions[kernel_key] 238 except KeyError: 239 continue 240 241 release_info = ReleaseInfo(version=version, release=release) 242 243 if kernel_version == packaged_kernel_version: 244 releases[kernel_key] = release_info 245 else: 246 # Fall back to the latest patch for this major kernel version, 247 # skipping patches for kernels newer than the packaged one. 248 if '.'.join(str(x) for x in kernel_version) > '.'.join(str(x) for x in packaged_kernel_version): 249 continue 250 elif ( 251 kernel_key not in releases or releases[kernel_key].version < version 252 ): 253 releases[kernel_key] = release_info 254 255# Update hardened-patches.json for each release. 256for kernel_key in sorted(releases.keys()): 257 release_info = releases[kernel_key] 258 release = release_info.release 259 version = release_info.version 260 version_str = release.tag_name 261 name = f"linux-hardened-{version_str}" 262 263 old_version: Optional[Version] = None 264 old_version_str: Optional[str] = None 265 update: bool 266 try: 267 old_filename = patches[kernel_key]["patch"]["name"] 268 old_version_str = old_filename.replace("linux-hardened-", "").replace( 269 ".patch", "" 270 ) 271 old_version = parse_version(old_version_str) 272 update = old_version < version 273 except KeyError: 274 update = True 275 276 if update: 277 patch = fetch_patch(name=name, release_info=release_info) 278 if patch is None: 279 failures = True 280 else: 281 patches[kernel_key] = patch 282 if old_version: 283 message = f"{old_version_str} -> {version_str}" 284 else: 285 message = f"init at {version_str}" 286 commit_patches(kernel_key=kernel_key, message=message) 287 288missing_kernel_versions = kernel_versions.keys() - patches.keys() 289 290if missing_kernel_versions: 291 print( 292 f"warning: no patches for kernel versions " 293 + ", ".join(missing_kernel_versions), 294 file=sys.stderr, 295 ) 296 297if failures: 298 sys.exit(1)