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)