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 normalize_kernel_version(version_str: str) -> list[str|int]:
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
163 version: list[str|int] = []
164
165 for component in re.split(r'\.|\-', version_str_without_v):
166 try:
167 version.append(int(component))
168 except ValueError:
169 version.append(component)
170 return version
171
172
173def version_string(version: Version) -> str:
174 return ".".join(str(component) for component in version)
175
176
177def major_kernel_version_key(kernel_version: list[int|str]) -> str:
178 return version_string(kernel_version[:-1])
179
180
181def commit_patches(*, kernel_key: Version, message: str) -> None:
182 new_patches_path = HARDENED_PATCHES_PATH.with_suffix(".new")
183 with open(new_patches_path, "w") as new_patches_file:
184 json.dump(patch_json, new_patches_file, indent=4, sort_keys=True)
185 new_patches_file.write("\n")
186 os.rename(new_patches_path, HARDENED_PATCHES_PATH)
187 message = f"linux/hardened/patches/{kernel_key}: {message}"
188 print(message)
189 if os.environ.get("COMMIT"):
190 run(
191 "git",
192 "-C",
193 NIXPKGS_PATH,
194 "commit",
195 f"--message={message}",
196 HARDENED_PATCHES_PATH,
197 )
198
199
200# Load the existing patches.
201with open(HARDENED_PATCHES_PATH) as patches_file:
202 patch_json = json.load(patches_file)
203 patch_versions = set([parse_version(k) for k in patch_json.keys()])
204
205with open(NIXPKGS_KERNEL_PATH / "kernels-org.json") as kernel_versions_json:
206 kernel_versions = json.load(kernel_versions_json)
207
208 kernels = {
209 parse_version(version): meta
210 for version, meta in kernel_versions.items()
211 if version != "testing"
212 }
213
214 latest_lts = sorted(ver for ver, meta in kernels.items() if meta.get("lts", False))[-1]
215 keys = sorted(kernels.keys())
216 latest_release = keys[-1]
217 fallback = keys[-2]
218
219g = Github(os.environ.get("GITHUB_TOKEN"))
220repo = g.get_repo(HARDENED_GITHUB_REPO)
221failures = False
222
223all_candidates = set([latest_lts, latest_release, fallback])
224kernels_to_package = {}
225for release in repo.get_releases()[:30]:
226 version = normalize_kernel_version(release.tag_name)
227 # needs to look like e.g. 5.6.3-hardened1
228 if len(version) < 4:
229 continue
230
231 if not (isinstance(version[-2], int)):
232 continue
233
234 kernel_version = version[:-1]
235 kernel_key = parse_version(major_kernel_version_key(kernel_version))
236
237 if kernel_key not in all_candidates:
238 continue
239
240 try:
241 found = kernels_to_package[kernel_key]
242 if found.version > version:
243 continue
244 except KeyError:
245 pass
246
247 kernels_to_package[kernel_key] = ReleaseInfo(version=version, release=release)
248
249if latest_release in kernels_to_package:
250 if fallback != latest_lts:
251 del kernels_to_package[fallback]
252 kernel_versions = set([latest_lts, latest_release])
253else:
254 kernel_versions = set([latest_lts, fallback])
255
256# Remove patches for unpackaged kernel versions.
257removals = False
258for kernel_key in sorted(patch_versions - kernels_to_package.keys()):
259 del patch_json[str(kernel_key)]
260 removals = True
261 commit_patches(kernel_key=kernel_key, message="remove")
262
263# Update hardened-patches.json for each release.
264for kernel_key in sorted(kernels_to_package.keys()):
265 release_info = kernels_to_package[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[list[int|str]] = None
272 old_version_str: Optional[str] = None
273 update: bool
274 try:
275 old_filename = patch_json[str(kernel_key)]["patch"]["name"]
276 old_version_str = old_filename.replace("linux-hardened-", "").replace(
277 ".patch", ""
278 )
279 old_version = normalize_kernel_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 if str(kernel_key) in patch_json:
290 message = f"{old_version_str} -> {version_str}"
291 else:
292 message = f"init at {version_str}"
293 patch_json[str(kernel_key)] = patch
294
295 commit_patches(kernel_key=kernel_key, message=message)
296
297if removals:
298 print("Hardened kernels were removed. Don't forget to remove their attributes!")
299
300if failures:
301 sys.exit(1)