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