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"v?(.*)(-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 # There have been two variants v6.10[..] and 6.10[..], drop the v
161 version_str_without_v = version_str[1:] if not version_str[0].isdigit() else version_str
162 version: Version = []
163
164 for component in re.split(r'\.|\-', version_str_without_v):
165 try:
166 version.append(int(component))
167 except ValueError:
168 version.append(component)
169 return version
170
171
172def version_string(version: Version) -> str:
173 return ".".join(str(component) for component in version)
174
175
176def major_kernel_version_key(kernel_version: Version) -> str:
177 return version_string(kernel_version[:-1])
178
179
180def commit_patches(*, kernel_key: str, message: str) -> None:
181 new_patches_path = HARDENED_PATCHES_PATH.with_suffix(".new")
182 with open(new_patches_path, "w") as new_patches_file:
183 json.dump(patches, new_patches_file, indent=4, sort_keys=True)
184 new_patches_file.write("\n")
185 os.rename(new_patches_path, HARDENED_PATCHES_PATH)
186 message = f"linux/hardened/patches/{kernel_key}: {message}"
187 print(message)
188 if os.environ.get("COMMIT"):
189 run(
190 "git",
191 "-C",
192 NIXPKGS_PATH,
193 "commit",
194 f"--message={message}",
195 HARDENED_PATCHES_PATH,
196 )
197
198
199# Load the existing patches.
200patches: Dict[str, Patch]
201with open(HARDENED_PATCHES_PATH) as patches_file:
202 patches = json.load(patches_file)
203
204# Get the set of currently packaged kernel versions.
205kernel_versions = {}
206with open(NIXPKGS_KERNEL_PATH / "kernels-org.json") as kernel_versions_json:
207 kernel_versions = json.load(kernel_versions_json)
208 for kernel_branch_str in kernel_versions:
209 if kernel_branch_str == "testing": continue
210 kernel_branch = [int(i) for i in kernel_branch_str.split(".")]
211 if kernel_branch < MIN_KERNEL_VERSION: continue
212 kernel_version = [int(i) for i in kernel_versions[kernel_branch_str]["version"].split(".")]
213 kernel_versions[kernel_branch_str] = kernel_version
214
215# Remove patches for unpackaged kernel versions.
216for kernel_key in sorted(patches.keys() - kernel_versions.keys()):
217 del patches[kernel_key]
218 commit_patches(kernel_key=kernel_key, message="remove")
219
220g = Github(os.environ.get("GITHUB_TOKEN"))
221repo = g.get_repo(HARDENED_GITHUB_REPO)
222failures = False
223
224# Match each kernel version with the best patch version.
225releases = {}
226i = 0
227for release in repo.get_releases():
228 # Dirty workaround to make sure that we don't run into issues because
229 # GitHub's API only allows fetching the last 1000 releases.
230 # It's not reliable to exit earlier because not every kernel minor may
231 # have hardened patches, hence the naive search below.
232 i += 1
233 if i > 100:
234 break
235
236 version = parse_version(release.tag_name)
237 # needs to look like e.g. 5.6.3-hardened1
238 if len(version) < 4:
239 continue
240
241 if not (isinstance(version[-2], int)):
242 continue
243
244 kernel_version = version[:-1]
245
246 kernel_key = major_kernel_version_key(kernel_version)
247 try:
248 packaged_kernel_version = kernel_versions[kernel_key]
249 except KeyError:
250 continue
251
252 release_info = ReleaseInfo(version=version, release=release)
253
254 if kernel_version == packaged_kernel_version:
255 releases[kernel_key] = release_info
256 else:
257 # Fall back to the latest patch for this major kernel version,
258 # skipping patches for kernels newer than the packaged one.
259 if '.'.join(str(x) for x in kernel_version) > '.'.join(str(x) for x in packaged_kernel_version):
260 continue
261 elif (
262 kernel_key not in releases or releases[kernel_key].version < version
263 ):
264 releases[kernel_key] = release_info
265
266# Update hardened-patches.json for each release.
267for kernel_key in sorted(releases.keys()):
268 release_info = releases[kernel_key]
269 release = release_info.release
270 version = release_info.version
271 version_str = release.tag_name
272 name = f"linux-hardened-{version_str}"
273
274 old_version: Optional[Version] = None
275 old_version_str: Optional[str] = None
276 update: bool
277 try:
278 old_filename = patches[kernel_key]["patch"]["name"]
279 old_version_str = old_filename.replace("linux-hardened-", "").replace(
280 ".patch", ""
281 )
282 old_version = parse_version(old_version_str)
283 update = old_version < version
284 except KeyError:
285 update = True
286
287 if update:
288 patch = fetch_patch(name=name, release_info=release_info)
289 if patch is None:
290 failures = True
291 else:
292 patches[kernel_key] = patch
293 if old_version:
294 message = f"{old_version_str} -> {version_str}"
295 else:
296 message = f"init at {version_str}"
297 commit_patches(kernel_key=kernel_key, message=message)
298
299missing_kernel_versions = kernel_versions.keys() - patches.keys()
300
301if missing_kernel_versions:
302 print(
303 f"warning: no patches for kernel versions "
304 + ", ".join(missing_kernel_versions),
305 file=sys.stderr,
306 )
307
308if failures:
309 sys.exit(1)