From 1a505e25a25eaba8a5ef5e17a6898e7827053b11 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 16:44:52 +0000 Subject: [PATCH 1/5] fix: harden the checksum bot against a spoofable actor check Two weaknesses in the workflow added last week, both found by code scanning once the same pattern landed in hugo-template, where zizmor's findings are visible on the pull request. github.actor == 'renovate[bot]' guards a job that has contents: write and pushes. actor is whoever triggered the most recent event, which on a synchronize is whoever pushed last, and zizmor rates comparing it to a bot name as spoofable. Replaced with the pull request's author, which is fixed when the pull request opens and cannot be set to another account. The checkout also left the token in .git/config for the whole job, including while the script downloads release tarballs off the internet. It now checks out with persist-credentials: false and hands the token to the push alone. Both came in from the Stensel8/scripts pattern this was modelled on. Scoped to update-checksums.yml on purpose: zizmor reports further findings in the older workflows here, and those are a separate piece of work. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SwrLVfDhkTVHC945s1kZ2s --- .github/workflows/update-checksums.yml | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/.github/workflows/update-checksums.yml b/.github/workflows/update-checksums.yml index 32dfa43..f8bf2c3 100644 --- a/.github/workflows/update-checksums.yml +++ b/.github/workflows/update-checksums.yml @@ -28,14 +28,27 @@ jobs: runs-on: ubuntu-latest # Only Renovate's own branches. Running this on a human's pull request # would mean pushing commits to a branch someone is actively working on. - if: startsWith(github.head_ref, 'renovate/') && github.actor == 'renovate[bot]' + # + # The author of the pull request, not github.actor. actor is whoever + # triggered the most recent event, which on a synchronize is whoever pushed + # last; comparing that to a bot name is a check zizmor rightly calls + # spoofable. The author is fixed when the pull request is opened and cannot + # be set to another account. + if: >- + startsWith(github.head_ref, 'renovate/') && + github.event.pull_request.user.login == 'renovate[bot]' permissions: contents: write steps: + # persist-credentials: false, even though this job pushes. Otherwise the + # token sits in .git/config for the whole job, including while the script + # below downloads release tarballs off the internet. The push step gets + # the token explicitly instead, for exactly one command. - name: Check out the pull request branch uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.head_ref }} + persist-credentials: false # The script verifies each download against the checksum the project # publishes next to the release before writing anything, so a hash only @@ -44,6 +57,9 @@ jobs: run: .github/scripts/update-tool-checksums.sh --apply - name: Commit updated checksums + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BRANCH: ${{ github.head_ref }} run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" @@ -52,5 +68,6 @@ jobs: echo "Checksums are already up to date, nothing to commit." else git commit -m "chore: update tool SHA256 checksums" - git push + git push "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" \ + "HEAD:refs/heads/${BRANCH}" fi From 8bfb59b720eda9d15165707021501a3462541be4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 16:55:09 +0000 Subject: [PATCH 2/5] fix: scope the workflow permissions to the jobs that need them zizmor reported thirteen findings across the older workflows here, all of them predating the checksum bot. This clears the lot. Permissions were granted at the top of each workflow, which hands them to every job in it: - hugo.yml gave pages: write and id-token: write to the build job as well as the deploy job. Only deploy-pages needs those; configure-pages in the build job needs contents: read and nothing more. Split accordingly. - pr-checks.yml gave pull-requests: write to all eight jobs. Only the two that write to the pull request need it: image-format, which posts a comment about non-AVIF images, and update-checklist, which rewrites the description. The rest read a checkout. - python-checks.yml declared no permissions at all, so it inherited whatever the repository default happens to be. Ten checkouts kept the token in .git/config for the duration of their job without needing it; none of these push. They now pass persist-credentials: false. The Pages base URL was interpolated straight into a run: block, where an expression is expanded before bash sees it. It goes through env now. Verified against the actual requirements rather than trimmed by guesswork: the documented split is contents: read for configure-pages, pages: write and id-token: write for deploy-pages. zizmor is clean on this branch, and actionlint passes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SwrLVfDhkTVHC945s1kZ2s --- .github/workflows/codeql-analysis.yml | 2 ++ .github/workflows/hugo.yml | 19 +++++++++++---- .github/workflows/pr-checks.yml | 35 ++++++++++++++++++++++++--- .github/workflows/python-checks.yml | 7 ++++++ .github/workflows/trivy-scan.yml | 2 ++ 5 files changed, 57 insertions(+), 8 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index b02c561..0b69f1d 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -21,6 +21,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Initialize CodeQL uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 diff --git a/.github/workflows/hugo.yml b/.github/workflows/hugo.yml index 31cff6d..bac5697 100644 --- a/.github/workflows/hugo.yml +++ b/.github/workflows/hugo.yml @@ -5,10 +5,10 @@ on: branches: ["main"] workflow_dispatch: -permissions: - contents: read - pages: write - id-token: write +# Nothing by default. pages: write and id-token: write belong to the deploy +# job alone; at the top they were also handed to the build job, which only +# needs to read the checkout. +permissions: {} concurrency: group: "pages" @@ -21,6 +21,8 @@ defaults: jobs: build: runs-on: ubuntu-latest + permissions: + contents: read env: HUGO_VERSION: 0.165.0 steps: @@ -28,6 +30,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 + persist-credentials: false - name: Setup Go uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 @@ -49,11 +52,14 @@ jobs: HUGO_CACHEDIR: ${{ runner.temp }}/hugo_cache HUGO_ENVIRONMENT: production TZ: Europe/Amsterdam + # Through env rather than straight into the script: an expression + # interpolated into run: is expanded before bash ever sees it. + BASE_URL: ${{ steps.pages.outputs.base_url }} run: | cd src && hugo \ --gc \ --minify \ - --baseURL "${{ steps.pages.outputs.base_url }}/" + --baseURL "${BASE_URL}/" - name: Upload artifact uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 @@ -65,6 +71,9 @@ jobs: name: github-pages url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest + permissions: + pages: write + id-token: write needs: build steps: - name: Deploy to GitHub Pages diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index b9350ab..8500642 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -4,9 +4,10 @@ on: pull_request: branches: [main, development] -permissions: - contents: read - pull-requests: write +# Nothing by default; each job asks for exactly what it needs. Granting +# pull-requests: write at the top handed it to every job, including the ones +# that only read the checkout. +permissions: {} jobs: @@ -14,6 +15,8 @@ jobs: pr-title: name: Conventional commit title runs-on: ubuntu-latest + permissions: + pull-requests: read steps: - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1 env: @@ -33,8 +36,12 @@ jobs: markdown: name: Markdown lint runs-on: ubuntu-latest + permissions: + contents: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - uses: DavidAnson/markdownlint-cli2-action@21c1be1b93ad9ed58fa840aacc3f279cde2a72ff # v24.2.0 with: globs: "src/content/**/*.md" @@ -43,8 +50,12 @@ jobs: python-security: name: Python security (bandit) runs-on: ubuntu-latest + permissions: + contents: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.x" @@ -56,8 +67,14 @@ jobs: image-format: name: No PNG/JPG in static/images runs-on: ubuntu-latest + # pull-requests: write for the comment this job posts when it finds a PNG. + permissions: + contents: read + pull-requests: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Find non-AVIF images id: check @@ -127,8 +144,12 @@ jobs: bilingual: name: EN/NL file parity runs-on: ubuntu-latest + permissions: + contents: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Check every .md has a matching .nl.md run: | missing="" @@ -151,12 +172,15 @@ jobs: hugo-build: name: Hugo build runs-on: ubuntu-latest + permissions: + contents: read env: HUGO_VERSION: 0.165.0 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 + persist-credentials: false - name: Setup Go uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: @@ -183,6 +207,8 @@ jobs: link-check: name: Broken link check runs-on: ubuntu-latest + permissions: + contents: read needs: hugo-build steps: - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -231,6 +257,9 @@ jobs: update-checklist: name: Update PR checklist runs-on: ubuntu-latest + # pull-requests: write to rewrite the description's checklist. + permissions: + pull-requests: write if: always() needs: [pr-title, bilingual, image-format, hugo-build, link-check] steps: diff --git a/.github/workflows/python-checks.yml b/.github/workflows/python-checks.yml index b5da53a..7c1e761 100644 --- a/.github/workflows/python-checks.yml +++ b/.github/workflows/python-checks.yml @@ -9,11 +9,18 @@ on: - cron: '0 5 * * 0' workflow_dispatch: +# Without this the workflow inherits whatever the repository default is. +permissions: {} + jobs: lint: runs-on: ubuntu-latest + permissions: + contents: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 diff --git a/.github/workflows/trivy-scan.yml b/.github/workflows/trivy-scan.yml index fd44f86..a28af05 100644 --- a/.github/workflows/trivy-scan.yml +++ b/.github/workflows/trivy-scan.yml @@ -14,6 +14,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Run Trivy filesystem scan uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 From 7403cdf171c74c5fb3478c3c2120b20a2cb050ca Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 22:24:49 +0000 Subject: [PATCH 3/5] fix: three bugs in the eduroam installer, and bump its published checksum A failed install reported success. When nmcli rejects the certificate configuration, run_nmcli returns False instead of exiting, so the caller can retry without the explicit CA bundle. The return value of that retry was discarded, so when it failed too the script carried on to announce "eduroam profile created successfully" and exited 0 with no profile created. Reproduced with an nmcli stub that fails both attempts; it now reports the failure and exits 1. --silent did not silence the prompts. Only show_message consulted the flag; prompt_input looked at gui_tool alone, so a --silent run on a desktop still opened a zenity box asking for the username. The flag is documented as "Run without GUI". Decided once in __init__ now, so the two cannot disagree. The log sanitiser mangled ordinary sentences. Its password pattern took the separator as optional and then consumed the following word, which turned the message that explains the keyring prompt into "Your password=[REDACTED] now be requested by your desktop keyring". Requiring an = or : fixes the prose while still masking real values; checked against password=, "password: ", "PASSWORD = ", and the username and email patterns, which are unaffected. The docs tell people to verify the download against a published SHA-256, so both the English and Dutch pages carry the new hash. It matches the file in this commit. bandit and pyflakes are clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SwrLVfDhkTVHC945s1kZ2s --- .../eduroam-network-installation.md | 4 +-- .../eduroam-network-installation.nl.md | 4 +-- src/static/scripts/saxion-eduroam.py | 29 ++++++++++++++++--- 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/src/content/docs/networking/eduroam-network-installation.md b/src/content/docs/networking/eduroam-network-installation.md index 820c1e1..8029eb4 100644 --- a/src/content/docs/networking/eduroam-network-installation.md +++ b/src/content/docs/networking/eduroam-network-installation.md @@ -59,13 +59,13 @@ A Python script automates the full `nmcli` connection setup for Saxion: curl -LO https://zephyrus-linux.stensel.nl/scripts/saxion-eduroam.py # 2. Verify checksum -echo "bef16a8ce91644a26cdd428f8dd0300de8e49ed72d9cbf4b6d39efea6d8facc1 saxion-eduroam.py" | sha256sum -c +echo "1e5863d5d03fbe65878f31909a1bea4a47bc28f8d352bd18b676c363a8d9caa8 saxion-eduroam.py" | sha256sum -c # 3. Run python3 saxion-eduroam.py ``` -**SHA256:** `bef16a8ce91644a26cdd428f8dd0300de8e49ed72d9cbf4b6d39efea6d8facc1` +**SHA256:** `1e5863d5d03fbe65878f31909a1bea4a47bc28f8d352bd18b676c363a8d9caa8` The script removes any existing eduroam profile, prompts for your **username** via a GUI dialog (zenity, kdialog, or yad) or terminal fallback, and activates the connection. Your password is never asked by the script; it is requested by your GNOME Keyring at connection time and stored securely, never in plaintext. diff --git a/src/content/docs/networking/eduroam-network-installation.nl.md b/src/content/docs/networking/eduroam-network-installation.nl.md index 0e75226..f47bd29 100644 --- a/src/content/docs/networking/eduroam-network-installation.nl.md +++ b/src/content/docs/networking/eduroam-network-installation.nl.md @@ -59,13 +59,13 @@ Een Python-script automatiseert de volledige `nmcli`-verbindingsconfiguratie voo curl -LO https://zephyrus-linux.stensel.nl/scripts/saxion-eduroam.py # 2. Controleer de checksum -echo "bef16a8ce91644a26cdd428f8dd0300de8e49ed72d9cbf4b6d39efea6d8facc1 saxion-eduroam.py" | sha256sum -c +echo "1e5863d5d03fbe65878f31909a1bea4a47bc28f8d352bd18b676c363a8d9caa8 saxion-eduroam.py" | sha256sum -c # 3. Uitvoeren python3 saxion-eduroam.py ``` -**SHA256:** `bef16a8ce91644a26cdd428f8dd0300de8e49ed72d9cbf4b6d39efea6d8facc1` +**SHA256:** `1e5863d5d03fbe65878f31909a1bea4a47bc28f8d352bd18b676c363a8d9caa8` Het script verwijdert een eventueel bestaand eduroam-profiel, vraagt je **gebruikersnaam** via een GUI-dialoog (zenity, kdialog of yad) of terminal-fallback, en activeert de verbinding. Je wachtwoord wordt nooit door het script gevraagd; dat wordt bij het verbinden opgevraagd door je GNOME Keyring en veilig opgeslagen, nooit in platte tekst. diff --git a/src/static/scripts/saxion-eduroam.py b/src/static/scripts/saxion-eduroam.py index adc64e0..1a77487 100644 --- a/src/static/scripts/saxion-eduroam.py +++ b/src/static/scripts/saxion-eduroam.py @@ -45,7 +45,11 @@ class Installer: def __init__(self, silent: bool = False, username: str = ""): self.silent = silent self.username = username - self.gui_tool = self._detect_gui() + # --silent means no GUI, and that has to hold for prompts too. Deciding + # it once here keeps show_message and prompt_input from disagreeing: + # previously only show_message honoured the flag, so a --silent run on a + # desktop still opened a zenity box asking for the username. + self.gui_tool = None if silent else self._detect_gui() def _detect_gui(self) -> str | None: """Detects available GUI tools (zenity, kdialog, yad).""" @@ -76,9 +80,12 @@ def _sanitize_for_log(self, text: str) -> str: text, flags=re.IGNORECASE ) - # Mask passwords (generic pattern) + # Mask passwords. The separator is required: with it optional this also + # matched "password" followed by a space and swallowed the next word, so + # ordinary prose came out as "Your password=[REDACTED] now be requested + # by your desktop keyring". text = re.sub( - r'\bpassword[=: ]*[^\s]+', + r'\bpassword\s*[=:]\s*\S+', 'password=[REDACTED]', text, flags=re.IGNORECASE @@ -241,7 +248,21 @@ def install(self): if not success: print("Note: Using system default trust store (implicit validation).") # Run nmcli without explicit ca-cert path (uses system trust store) - self.run_nmcli(cmd) + success = self.run_nmcli(cmd) + + # run_nmcli returns False on a certificate error instead of exiting, so + # that the fallback above gets its turn. If the fallback fails too there + # is nothing left to try, and the result has to be reported: discarding + # this used to leave the script announcing "profile created + # successfully" and exiting 0 when no profile had been created at all. + if not success: + self.show_message( + "NetworkManager rejected the certificate configuration, both with the " + "system CA bundle and without it, so no eduroam profile was created.\n" + "See the terminal output above for what nmcli reported.", + True, + ) + sys.exit(1) # Show explanation before attempting connection so the password prompt makes sense self.show_message( From c93d1b64b61f178b84af46681a7e0d79b8d39096 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 22:28:11 +0000 Subject: [PATCH 4/5] fix: stop the CA fallback from claiming it is still secure The fallback path creates the eduroam profile without an explicit CA bundle, and its comment called that "still secure via domain suffix validation". It is not. With no ca-cert, wpa_supplicant does not verify the server certificate against any trust anchor, so domain-suffix-match only inspects a name inside a certificate nobody vouched for. Anyone can present a self-signed certificate carrying ise.infra.saxion.net, which on eduroam means a rogue access point can collect the MSCHAPv2 exchange from students who followed our own guide. The path stays, because a profile that cannot be created helps nobody, but it now prints what it is doing and how to fix it rather than describing itself as secure. The warning only appears when the fallback is actually taken; the normal path is unchanged and silent. Also: the keyring message named GNOME Keyring only, on a script whose whole point is to work on GNOME and KDE alike. It names KWallet too now. Verified the KDE path end to end with a kdialog stub: detection and both dialog invocations are correct. Checksum in both language pages bumped to match. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SwrLVfDhkTVHC945s1kZ2s --- .../eduroam-network-installation.md | 4 +-- .../eduroam-network-installation.nl.md | 4 +-- src/static/scripts/saxion-eduroam.py | 25 ++++++++++++++++--- 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/src/content/docs/networking/eduroam-network-installation.md b/src/content/docs/networking/eduroam-network-installation.md index 8029eb4..fa96474 100644 --- a/src/content/docs/networking/eduroam-network-installation.md +++ b/src/content/docs/networking/eduroam-network-installation.md @@ -59,13 +59,13 @@ A Python script automates the full `nmcli` connection setup for Saxion: curl -LO https://zephyrus-linux.stensel.nl/scripts/saxion-eduroam.py # 2. Verify checksum -echo "1e5863d5d03fbe65878f31909a1bea4a47bc28f8d352bd18b676c363a8d9caa8 saxion-eduroam.py" | sha256sum -c +echo "ffb9b5a4f6ed4e0805f66a4764ed9aa843e382303f3cd73ab0261e63ce7f30ea saxion-eduroam.py" | sha256sum -c # 3. Run python3 saxion-eduroam.py ``` -**SHA256:** `1e5863d5d03fbe65878f31909a1bea4a47bc28f8d352bd18b676c363a8d9caa8` +**SHA256:** `ffb9b5a4f6ed4e0805f66a4764ed9aa843e382303f3cd73ab0261e63ce7f30ea` The script removes any existing eduroam profile, prompts for your **username** via a GUI dialog (zenity, kdialog, or yad) or terminal fallback, and activates the connection. Your password is never asked by the script; it is requested by your GNOME Keyring at connection time and stored securely, never in plaintext. diff --git a/src/content/docs/networking/eduroam-network-installation.nl.md b/src/content/docs/networking/eduroam-network-installation.nl.md index f47bd29..b7bc964 100644 --- a/src/content/docs/networking/eduroam-network-installation.nl.md +++ b/src/content/docs/networking/eduroam-network-installation.nl.md @@ -59,13 +59,13 @@ Een Python-script automatiseert de volledige `nmcli`-verbindingsconfiguratie voo curl -LO https://zephyrus-linux.stensel.nl/scripts/saxion-eduroam.py # 2. Controleer de checksum -echo "1e5863d5d03fbe65878f31909a1bea4a47bc28f8d352bd18b676c363a8d9caa8 saxion-eduroam.py" | sha256sum -c +echo "ffb9b5a4f6ed4e0805f66a4764ed9aa843e382303f3cd73ab0261e63ce7f30ea saxion-eduroam.py" | sha256sum -c # 3. Uitvoeren python3 saxion-eduroam.py ``` -**SHA256:** `1e5863d5d03fbe65878f31909a1bea4a47bc28f8d352bd18b676c363a8d9caa8` +**SHA256:** `ffb9b5a4f6ed4e0805f66a4764ed9aa843e382303f3cd73ab0261e63ce7f30ea` Het script verwijdert een eventueel bestaand eduroam-profiel, vraagt je **gebruikersnaam** via een GUI-dialoog (zenity, kdialog of yad) of terminal-fallback, en activeert de verbinding. Je wachtwoord wordt nooit door het script gevraagd; dat wordt bij het verbinden opgevraagd door je GNOME Keyring en veilig opgeslagen, nooit in platte tekst. diff --git a/src/static/scripts/saxion-eduroam.py b/src/static/scripts/saxion-eduroam.py index 1a77487..a8edeaf 100644 --- a/src/static/scripts/saxion-eduroam.py +++ b/src/static/scripts/saxion-eduroam.py @@ -244,10 +244,26 @@ def install(self): cmd_secure = cmd + ["802-1x.ca-cert", ca_path] success = self.run_nmcli(cmd_secure) - # Fallback if CA fails or is not found (still secure via domain suffix validation) + # Fallback: create the profile without an explicit CA bundle. + # + # This is a real downgrade, not an equivalent path. With no ca-cert, + # wpa_supplicant does not verify the server certificate against any + # trust anchor, and domain-suffix-match then proves nothing: it checks + # the name inside a certificate nobody vouched for, so anyone can + # present a self-signed one carrying that name. On eduroam that means a + # rogue access point can collect the MSCHAPv2 exchange. + # + # It stays in because a profile that cannot be created is not useful + # either, but it says so now instead of calling itself secure. if not success: - print("Note: Using system default trust store (implicit validation).") - # Run nmcli without explicit ca-cert path (uses system trust store) + print( + "WARNING: no usable CA bundle, so the eduroam profile is being created\n" + " without server certificate validation. The connection will work,\n" + " but it cannot detect a rogue access point impersonating\n" + f" {SERVER_DOMAIN}. Install your distribution's ca-certificates\n" + " package and re-run this script to get a verified profile.", + file=sys.stderr, + ) success = self.run_nmcli(cmd) # run_nmcli returns False on a certificate error instead of exiting, so @@ -267,7 +283,8 @@ def install(self): # Show explanation before attempting connection so the password prompt makes sense self.show_message( "eduroam profile created successfully.\n\n" - "Your password will now be requested by your desktop keyring (e.g. GNOME Keyring).\n" + "Your password will now be requested by your desktop keyring " + "(GNOME Keyring on GNOME, KWallet on KDE).\n" "This is normal and ensures your password is stored securely encrypted, never in plaintext.\n\n" "If you do not see a password prompt, open your network settings and connect to eduroam manually." ) From 11a3ea06c7de370add9bd19de1921bc7fba86975 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 22:50:12 +0000 Subject: [PATCH 5/5] fix: pin Saxion's own CA instead of trusting the whole system store 802-1x.ca-cert pointed at the distribution's CA bundle, so any of the roughly 150 public authorities in it was an acceptable signer for a server calling itself ise.infra.saxion.net. domain-suffix-match does not close that gap: the name is exactly what an attacker holding a certificate from any of those CAs gets to choose. The chain Saxion publishes through eduroam CAT is now embedded in the script and written to ~/.config/saxion-eduroam/saxion-eduroam-ca.pem, and ca-cert points there. Trust narrows from every public CA to USERTrust RSA Certification Authority plus GEANT OV RSA CA 4, which is what the official CAT installers configure. Both are shipped, as CAT does, so the chain still builds if GEANT rotates the intermediate under the same root. Embedded rather than shipped alongside, because the guide tells people to download one file and verify one checksum. A second file would mean a second download nobody checks. The unvalidated fallback is gone. It existed for systems with no CA bundle, which cannot happen now that the chain travels with the script, and connecting without validation means handing a Saxion password to whatever access point answered. Both language pages describe the pinning, name the two authorities, and point at the new path. Checksum bumped to match. Verified against the certificates Saxion actually publishes: the embedded block is byte-identical to the CAT export, the file written at runtime matches it, nmcli is called once with the pinned path and never with the system bundle, and a rejected certificate now fails with guidance instead of falling through. Note on wifi.cloned-mac-address: left at permanent deliberately. Saxion does not register devices by MAC, but it does block one temporarily when it looks like it is scanning or flooding, and a randomised address would let that be shrugged off by reconnecting. Documented in the code rather than left to be rediscovered. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SwrLVfDhkTVHC945s1kZ2s --- .../eduroam-network-installation.md | 18 +- .../eduroam-network-installation.nl.md | 18 +- src/static/scripts/saxion-eduroam.py | 194 +++++++++++++----- 3 files changed, 171 insertions(+), 59 deletions(-) diff --git a/src/content/docs/networking/eduroam-network-installation.md b/src/content/docs/networking/eduroam-network-installation.md index fa96474..efac38b 100644 --- a/src/content/docs/networking/eduroam-network-installation.md +++ b/src/content/docs/networking/eduroam-network-installation.md @@ -30,7 +30,15 @@ The guide at [linux.datanose.nl](https://linux.datanose.nl/linux/eduroam/) (UvA/ ## What does work -PEAP/MSCHAPv2 with CA validation via the system trust store and `domain-suffix-match` (the modern replacement for the deprecated `altsubject-matches`). +PEAP/MSCHAPv2 validated against Saxion's own certificate authority, pinned inside the +script, plus `domain-suffix-match` (the modern replacement for the deprecated +`altsubject-matches`). + +The script used to point at the system trust store, which meant any of the roughly 150 +public CAs your distribution ships could vouch for a server calling itself +`ise.infra.saxion.net`. It now trusts only the chain Saxion publishes through eduroam +CAT — USERTrust RSA Certification Authority and GEANT OV RSA CA 4 — which is what the +official CAT installers do. **Requirements:** - Python 3.10+ @@ -44,7 +52,7 @@ PEAP/MSCHAPv2 with CA validation via the system trust store and `domain-suffix-m | Authentication | Protected EAP (PEAP) | | PEAP version | Automatic | | Inner authentication | MSCHAPv2 | -| CA certificate | System CA bundle (`/etc/pki/tls/certs/ca-bundle.crt`) | +| CA certificate | Saxion's published chain, written to `~/.config/saxion-eduroam/saxion-eduroam-ca.pem` | | Domain validation | `domain-suffix-match: ise.infra.saxion.net` | | Phase2 domain validation | `phase2-domain-suffix-match: ise.infra.saxion.net` | | Anonymous identity | `anonymous@saxion.nl` | @@ -59,13 +67,13 @@ A Python script automates the full `nmcli` connection setup for Saxion: curl -LO https://zephyrus-linux.stensel.nl/scripts/saxion-eduroam.py # 2. Verify checksum -echo "ffb9b5a4f6ed4e0805f66a4764ed9aa843e382303f3cd73ab0261e63ce7f30ea saxion-eduroam.py" | sha256sum -c +echo "b1a9b7ee4a55f77e118d40e886979ca96c8db8e145d593027f0bde0a578c46bc saxion-eduroam.py" | sha256sum -c # 3. Run python3 saxion-eduroam.py ``` -**SHA256:** `ffb9b5a4f6ed4e0805f66a4764ed9aa843e382303f3cd73ab0261e63ce7f30ea` +**SHA256:** `b1a9b7ee4a55f77e118d40e886979ca96c8db8e145d593027f0bde0a578c46bc` The script removes any existing eduroam profile, prompts for your **username** via a GUI dialog (zenity, kdialog, or yad) or terminal fallback, and activates the connection. Your password is never asked by the script; it is requested by your GNOME Keyring at connection time and stored securely, never in plaintext. @@ -100,7 +108,7 @@ nmcli connection add \ 802-1x.identity "user@institution.tld" \ 802-1x.password "your-password" \ 802-1x.anonymous-identity "anonymous@saxion.nl" \ - 802-1x.ca-cert file:///etc/pki/tls/certs/ca-bundle.crt \ + 802-1x.ca-cert file://$HOME/.config/saxion-eduroam/saxion-eduroam-ca.pem \ 802-1x.domain-suffix-match "ise.infra.saxion.net" \ 802-1x.phase2-domain-suffix-match "ise.infra.saxion.net" ``` diff --git a/src/content/docs/networking/eduroam-network-installation.nl.md b/src/content/docs/networking/eduroam-network-installation.nl.md index b7bc964..2b7803a 100644 --- a/src/content/docs/networking/eduroam-network-installation.nl.md +++ b/src/content/docs/networking/eduroam-network-installation.nl.md @@ -30,7 +30,15 @@ De handleiding op [linux.datanose.nl](https://linux.datanose.nl/linux/eduroam/) ## Wat wel werkt -PEAP/MSCHAPv2 met CA-validatie via de systeem-truststore en `domain-suffix-match` (de moderne vervanging voor het verouderde `altsubject-matches`). +PEAP/MSCHAPv2, gevalideerd tegen Saxion's eigen certificaatautoriteit die in het script +is vastgelegd, plus `domain-suffix-match` (de moderne vervanging voor het verouderde +`altsubject-matches`). + +Het script wees eerder naar de systeem-truststore. Daarmee kon elk van de ongeveer 150 +publieke CA's die je distributie meelevert instaan voor een server die zich +`ise.infra.saxion.net` noemt. Nu wordt alleen de keten vertrouwd die Saxion via eduroam +CAT publiceert — USERTrust RSA Certification Authority en GEANT OV RSA CA 4 — precies +wat de officiële CAT-installers doen. **Vereisten:** - Python 3.10+ @@ -44,7 +52,7 @@ PEAP/MSCHAPv2 met CA-validatie via de systeem-truststore en `domain-suffix-match | Authenticatie | Protected EAP (PEAP) | | PEAP-versie | Automatisch | | Interne authenticatie | MSCHAPv2 | -| CA-certificaat | Systeem-CA-bundel (`/etc/pki/tls/certs/ca-bundle.crt`) | +| CA-certificaat | De door Saxion gepubliceerde keten, geschreven naar `~/.config/saxion-eduroam/saxion-eduroam-ca.pem` | | Domeinvalidatie | `domain-suffix-match: ise.infra.saxion.net` | | Fase-2-domeinvalidatie | `phase2-domain-suffix-match: ise.infra.saxion.net` | | Anonieme identiteit | `anonymous@saxion.nl` | @@ -59,13 +67,13 @@ Een Python-script automatiseert de volledige `nmcli`-verbindingsconfiguratie voo curl -LO https://zephyrus-linux.stensel.nl/scripts/saxion-eduroam.py # 2. Controleer de checksum -echo "ffb9b5a4f6ed4e0805f66a4764ed9aa843e382303f3cd73ab0261e63ce7f30ea saxion-eduroam.py" | sha256sum -c +echo "b1a9b7ee4a55f77e118d40e886979ca96c8db8e145d593027f0bde0a578c46bc saxion-eduroam.py" | sha256sum -c # 3. Uitvoeren python3 saxion-eduroam.py ``` -**SHA256:** `ffb9b5a4f6ed4e0805f66a4764ed9aa843e382303f3cd73ab0261e63ce7f30ea` +**SHA256:** `b1a9b7ee4a55f77e118d40e886979ca96c8db8e145d593027f0bde0a578c46bc` Het script verwijdert een eventueel bestaand eduroam-profiel, vraagt je **gebruikersnaam** via een GUI-dialoog (zenity, kdialog of yad) of terminal-fallback, en activeert de verbinding. Je wachtwoord wordt nooit door het script gevraagd; dat wordt bij het verbinden opgevraagd door je GNOME Keyring en veilig opgeslagen, nooit in platte tekst. @@ -100,7 +108,7 @@ nmcli connection add \ 802-1x.identity "gebruiker@instelling.nl" \ 802-1x.password "je-wachtwoord" \ 802-1x.anonymous-identity "anonymous@saxion.nl" \ - 802-1x.ca-cert file:///etc/pki/tls/certs/ca-bundle.crt \ + 802-1x.ca-cert file://$HOME/.config/saxion-eduroam/saxion-eduroam-ca.pem \ 802-1x.domain-suffix-match "ise.infra.saxion.net" \ 802-1x.phase2-domain-suffix-match "ise.infra.saxion.net" ``` diff --git a/src/static/scripts/saxion-eduroam.py b/src/static/scripts/saxion-eduroam.py index a8edeaf..f28e8f5 100644 --- a/src/static/scripts/saxion-eduroam.py +++ b/src/static/scripts/saxion-eduroam.py @@ -26,6 +26,110 @@ SERVER_DOMAIN = "ise.infra.saxion.net" ANONYMOUS_ID = f"anonymous@{REALM}" +# Where the pinned CA is written. NetworkManager reads 802-1x.ca-cert every time +# it connects, so it has to survive the script exiting; a temporary file will not +# do. Under the user's config directory, so the script still needs no root. +CA_DIR = os.path.join( + os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config"), + "saxion-eduroam", +) +CA_FILE = os.path.join(CA_DIR, "saxion-eduroam-ca.pem") + +# The certificate chain Saxion publishes for manual configuration, via the +# eduroam CAT profile ("eduroam CA certificate (PEM)"): +# +# https://cat.eduroam.org/ -> Saxion University of Applied Sciences +# +# 1. USERTrust RSA Certification Authority (root, expires 2038-01-18) +# SHA-256 E7:93:C9:B0:2F:D8:AA:13:E2:1C:31:22:8A:CC:B0:81: +# 19:64:3B:74:9C:89:89:64:B1:74:6D:46:C3:D4:CB:D2 +# 2. GEANT OV RSA CA 4 (intermediate, expires 2033-05-01) +# SHA-256 37:83:4F:A5:EA:40:FB:F7:B6:11:96:95:59:62:E1:CA: +# 05:58:87:24:35:E4:20:66:53:D3:F6:20:DD:8E:98:8E +# +# Both are shipped, as CAT does, so the chain still builds if GEANT rotates the +# intermediate under the same root. +# +# When Saxion changes RADIUS certificate authority this file stops working and +# the fix is to replace the block below from the URL above. That is the cost of +# pinning, and it is the point: without it any of the ~150 CAs in the system +# trust store could vouch for a server calling itself ise.infra.saxion.net. +SAXION_CA_PEM = """\ +-----BEGIN CERTIFICATE----- +MIIF3jCCA8agAwIBAgIQAf1tMPyjylGoG7xkDjUDLTANBgkqhkiG9w0BAQwFADCB +iDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0pl +cnNleSBDaXR5MR4wHAYDVQQKExVUaGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNV +BAMTJVVTRVJUcnVzdCBSU0EgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMTAw +MjAxMDAwMDAwWhcNMzgwMTE4MjM1OTU5WjCBiDELMAkGA1UEBhMCVVMxEzARBgNV +BAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0plcnNleSBDaXR5MR4wHAYDVQQKExVU +aGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNVBAMTJVVTRVJUcnVzdCBSU0EgQ2Vy +dGlmaWNhdGlvbiBBdXRob3JpdHkwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIK +AoICAQCAEmUXNg7D2wiz0KxXDXbtzSfTTK1Qg2HiqiBNCS1kCdzOiZ/MPans9s/B +3PHTsdZ7NygRK0faOca8Ohm0X6a9fZ2jY0K2dvKpOyuR+OJv0OwWIJAJPuLodMkY +tJHUYmTbf6MG8YgYapAiPLz+E/CHFHv25B+O1ORRxhFnRghRy4YUVD+8M/5+bJz/ +Fp0YvVGONaanZshyZ9shZrHUm3gDwFA66Mzw3LyeTP6vBZY1H1dat//O+T23LLb2 +VN3I5xI6Ta5MirdcmrS3ID3KfyI0rn47aGYBROcBTkZTmzNg95S+UzeQc0PzMsNT +79uq/nROacdrjGCT3sTHDN/hMq7MkztReJVni+49Vv4M0GkPGw/zJSZrM233bkf6 +c0Plfg6lZrEpfDKEY1WJxA3Bk1QwGROs0303p+tdOmw1XNtB1xLaqUkL39iAigmT +Yo61Zs8liM2EuLE/pDkP2QKe6xJMlXzzawWpXhaDzLhn4ugTncxbgtNMs+1b/97l +c6wjOy0AvzVVdAlJ2ElYGn+SNuZRkg7zJn0cTRe8yexDJtC/QV9AqURE9JnnV4ee +UB9XVKg+/XRjL7FQZQnmWEIuQxpMtPAlR1n6BB6T1CZGSlCBst6+eLf8ZxXhyVeE +Hg9j1uliutZfVS7qXMYoCAQlObgOK6nyTJccBz8NUvXt7y+CDwIDAQABo0IwQDAd +BgNVHQ4EFgQUU3m/WqorSs9UgOHYm8Cd8rIDZsswDgYDVR0PAQH/BAQDAgEGMA8G +A1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQEMBQADggIBAFzUfA3P9wF9QZllDHPF +Up/L+M+ZBn8b2kMVn54CVVeWFPFSPCeHlCjtHzoBN6J2/FNQwISbxmtOuowhT6KO +VWKR82kV2LyI48SqC/3vqOlLVSoGIG1VeCkZ7l8wXEskEVX/JJpuXior7gtNn3/3 +ATiUFJVDBwn7YKnuHKsSjKCaXqeYalltiz8I+8jRRa8YFWSQEg9zKC7F4iRO/Fjs +8PRF/iKz6y+O0tlFYQXBl2+odnKPi4w2r78NBc5xjeambx9spnFixdjQg3IM8WcR +iQycE0xyNN+81XHfqnHd4blsjDwSXWXavVcStkNr/+XeTWYRUc+ZruwXtuhxkYze +Sf7dNXGiFSeUHM9h4ya7b6NnJSFd5t0dCy5oGzuCr+yDZ4XUmFF0sbmZgIn/f3gZ +XHlKYC6SQK5MNyosycdiyA5d9zZbyuAlJQG03RoHnHcAP9Dc1ew91Pq7P8yF1m9/ +qS3fuQL39ZeatTXaw2ewh0qpKJ4jjv9cJ2vhsE/zB+4ALtRZh8tSQZXq9EfX7mRB +VXyNWQKV3WKdwrnuWih0hKWbt5DHDAff9Yk2dDLWKMGwsAvgnEzDHNb842m1R0aB +L6KCq9NjRHDEjf8tM7qtj3u1cIiuPhnPQCjY/MiQu12ZIvVS5ljFH4gxQ+6IHdfG +jjxDah2nGN59PRbxYvnKkKj9 +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIG5TCCBM2gAwIBAgIRANpDvROb0li7TdYcrMTz2+AwDQYJKoZIhvcNAQEMBQAw +gYgxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpOZXcgSmVyc2V5MRQwEgYDVQQHEwtK +ZXJzZXkgQ2l0eTEeMBwGA1UEChMVVGhlIFVTRVJUUlVTVCBOZXR3b3JrMS4wLAYD +VQQDEyVVU0VSVHJ1c3QgUlNBIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MB4XDTIw +MDIxODAwMDAwMFoXDTMzMDUwMTIzNTk1OVowRDELMAkGA1UEBhMCTkwxGTAXBgNV +BAoTEEdFQU5UIFZlcmVuaWdpbmcxGjAYBgNVBAMTEUdFQU5UIE9WIFJTQSBDQSA0 +MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEApYhi1aEiPsg9ZKRMAw9Q +r8Mthsr6R20VSfFeh7TgwtLQi6RSRLOh4or4EMG/1th8lijv7xnBMVZkTysFiPmT +PiLOfvz+QwO1NwjvgY+Jrs7fSoVA/TQkXzcxu4Tl3WHi+qJmKLJVu/JOuHud6mOp +LWkIbhODSzOxANJ24IGPx9h4OXDyy6/342eE6UPXCtJ8AzeumTG6Dfv5KVx24lCF +TGUzHUB+j+g0lSKg/Sf1OzgCajJV9enmZ/84ydh48wPp6vbWf1H0O3Rd3LhpMSVn +TqFTLKZSbQeLcx/l9DOKZfBCC9ghWxsgTqW9gQ7v3T3aIfSaVC9rnwVxO0VjmDdP +FNbdoxnh0zYwf45nV1QQgpRwZJ93yWedhp4ch1a6Ajwqs+wv4mZzmBSjovtV0mKw +d+CQbSToalEUP4QeJq4Udz5WNmNMI4OYP6cgrnlJ50aa0DZPlJqrKQPGL69KQQz1 +2WgxvhCuVU70y6ZWAPopBa1ykbsttpLxADZre5cH573lIuLHdjx7NjpYIXRx2+QJ +URnX2qx37eZIxYXz8ggM+wXH6RDbU3V2o5DP67hXPHSAbA+p0orjAocpk2osxHKo +NSE3LCjNx8WVdxnXvuQ28tKdaK69knfm3bB7xpdfsNNTPH9ElcjscWZxpeZ5Iij8 +lyrCG1z0vSWtSBsgSnUyG/sCAwEAAaOCAYswggGHMB8GA1UdIwQYMBaAFFN5v1qq +K0rPVIDh2JvAnfKyA2bLMB0GA1UdDgQWBBRvHTVJEGwy+lmgnryK6B+VvnF6DDAO +BgNVHQ8BAf8EBAMCAYYwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHSUEFjAUBggr +BgEFBQcDAQYIKwYBBQUHAwIwOAYDVR0gBDEwLzAtBgRVHSAAMCUwIwYIKwYBBQUH +AgEWF2h0dHBzOi8vc2VjdGlnby5jb20vQ1BTMFAGA1UdHwRJMEcwRaBDoEGGP2h0 +dHA6Ly9jcmwudXNlcnRydXN0LmNvbS9VU0VSVHJ1c3RSU0FDZXJ0aWZpY2F0aW9u +QXV0aG9yaXR5LmNybDB2BggrBgEFBQcBAQRqMGgwPwYIKwYBBQUHMAKGM2h0dHA6 +Ly9jcnQudXNlcnRydXN0LmNvbS9VU0VSVHJ1c3RSU0FBZGRUcnVzdENBLmNydDAl +BggrBgEFBQcwAYYZaHR0cDovL29jc3AudXNlcnRydXN0LmNvbTANBgkqhkiG9w0B +AQwFAAOCAgEAUtlC3e0xj/1BMfPhdQhUXeLjb0xp8UE28kzWE5xDzGKbfGgnrT2R +lw5gLIx+/cNVrad//+MrpTppMlxq59AsXYZW3xRasrvkjGfNR3vt/1RAl8iI31lG +hIg6dfIX5N4esLkrQeN8HiyHKH6khm4966IkVVtnxz5CgUPqEYn4eQ+4eeESrWBh +AqXaiv7HRvpsdwLYekAhnrlGpioZ/CJIT2PTTxf+GHM6cuUnNqdUzfvrQgA8kt1/ +ASXx2od/M+c8nlJqrGz29lrJveJOSEMX0c/ts02WhsfMhkYa6XujUZLmvR1Eq08r +48/EZ4l+t5L4wt0DV8VaPbsEBF1EOFpz/YS2H6mSwcFaNJbnYqqJHIvm3PLJHkFm +EoLXRVrQXdCT+3wgBfgU6heCV5CYBz/YkrdWES7tiiT8sVUDqXmVlTsbiRNiyLs2 +bmEWWFUl76jViIJog5fongEqN3jLIGTG/mXrJT1UyymIcobnIGrbwwRVz/mpFQo0 +vBYIi1k2ThVh0Dx88BbF9YiP84dd8Fkn5wbE6FxXYJ287qfRTgmhePecPc73Yrzt +apdRcsKVGkOpaTIJP/l+lAHRLZxk/dUtyN95G++bOSQqnOCpVPabUGl2E/OEyFrp +Ipwgu2L/WJclvd6g+ZA/iWkLSMcpnFb+uX6QBqvD6+RNxul1FaB5iHY= +-----END CERTIFICATE----- +""" + # Strict allowlist for valid Saxion usernames (prevents argument injection into nmcli). # Allows: number@student.saxion.nl OR name@saxion.nl (staff accounts) _USERNAME_RE = re.compile(r"^[a-zA-Z0-9._-]+@([a-zA-Z0-9-]+\.)*saxion\.nl$", re.IGNORECASE) @@ -170,17 +274,27 @@ def get_credentials(self): continue self.username = val.strip() - def find_system_ca_bundle(self) -> str: - candidates = [ - "/etc/pki/tls/certs/ca-bundle.crt", # Fedora/RHEL - "/etc/ssl/certs/ca-certificates.crt", # Debian/Ubuntu - "/var/lib/ca-certificates/ca-bundle.pem", # openSUSE - "/etc/ssl/ca-bundle.pem", # Other distros - ] - for path in candidates: - if os.path.exists(path): - return path - return "" + def install_ca_bundle(self) -> str: + """ + Write the pinned Saxion chain to a stable path and return it. + + This replaces pointing 802-1x.ca-cert at the system trust store. That + made any of the roughly 150 public CAs a valid signer for something + calling itself ise.infra.saxion.net; now only the chain Saxion actually + publishes is accepted. It is what eduroam CAT does, and the reason CAT + exists. + """ + try: + os.makedirs(CA_DIR, mode=0o755, exist_ok=True) + with open(CA_FILE, "w", encoding="ascii") as handle: + handle.write(SAXION_CA_PEM) + os.chmod(CA_FILE, 0o644) + except OSError as error: + self.show_message( + f"Could not write the CA certificate to {CA_FILE}: {error}", True + ) + sys.exit(1) + return CA_FILE def run_nmcli(self, cmd: list[str]) -> bool: res = subprocess.run(cmd, capture_output=True, text=True) @@ -213,7 +327,7 @@ def install(self): self.get_credentials() - ca_path = self.find_system_ca_bundle() + ca_path = self.install_ca_bundle() # 1. Remove any existing eduroam connection subprocess.run( @@ -235,46 +349,28 @@ def install(self): "802-1x.domain-suffix-match", SERVER_DOMAIN, "802-1x.phase2-domain-suffix-match", SERVER_DOMAIN, "802-1x.password-flags", "1", - "wifi.cloned-mac-address", "permanent" + # Saxion does not register devices by MAC, but it does block one + # temporarily when it looks like it is scanning or flooding. A + # randomised address would let that block be shrugged off by + # reconnecting, so the real one is used deliberately. + "wifi.cloned-mac-address", "permanent", + # The pinned chain, not the system trust store. + "802-1x.ca-cert", ca_path, ] - # Try with CA bundle first (most secure option) - success = False - if ca_path: - cmd_secure = cmd + ["802-1x.ca-cert", ca_path] - success = self.run_nmcli(cmd_secure) - - # Fallback: create the profile without an explicit CA bundle. - # - # This is a real downgrade, not an equivalent path. With no ca-cert, - # wpa_supplicant does not verify the server certificate against any - # trust anchor, and domain-suffix-match then proves nothing: it checks - # the name inside a certificate nobody vouched for, so anyone can - # present a self-signed one carrying that name. On eduroam that means a - # rogue access point can collect the MSCHAPv2 exchange. - # - # It stays in because a profile that cannot be created is not useful - # either, but it says so now instead of calling itself secure. - if not success: - print( - "WARNING: no usable CA bundle, so the eduroam profile is being created\n" - " without server certificate validation. The connection will work,\n" - " but it cannot detect a rogue access point impersonating\n" - f" {SERVER_DOMAIN}. Install your distribution's ca-certificates\n" - " package and re-run this script to get a verified profile.", - file=sys.stderr, - ) - success = self.run_nmcli(cmd) - - # run_nmcli returns False on a certificate error instead of exiting, so - # that the fallback above gets its turn. If the fallback fails too there - # is nothing left to try, and the result has to be reported: discarding - # this used to leave the script announcing "profile created - # successfully" and exiting 0 when no profile had been created at all. - if not success: + # No unvalidated fallback. There used to be one, for when no CA bundle + # could be found on the system; with the chain shipped inside this + # script that situation no longer exists. If nmcli will not accept this + # configuration, connecting anyway would mean handing a Saxion password + # to whatever access point answered, which is not a trade worth making + # on the user's behalf. + if not self.run_nmcli(cmd): self.show_message( - "NetworkManager rejected the certificate configuration, both with the " - "system CA bundle and without it, so no eduroam profile was created.\n" + "NetworkManager rejected the certificate configuration, so no eduroam " + "profile was created.\n\n" + f"The pinned CA is at {CA_FILE}. If Saxion has changed its RADIUS " + "certificate authority, fetch the current one from cat.eduroam.org and " + "report it, so this script can be updated.\n" "See the terminal output above for what nmcli reported.", True, )