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 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 diff --git a/src/content/docs/networking/eduroam-network-installation.md b/src/content/docs/networking/eduroam-network-installation.md index 820c1e1..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 "bef16a8ce91644a26cdd428f8dd0300de8e49ed72d9cbf4b6d39efea6d8facc1 saxion-eduroam.py" | sha256sum -c +echo "b1a9b7ee4a55f77e118d40e886979ca96c8db8e145d593027f0bde0a578c46bc saxion-eduroam.py" | sha256sum -c # 3. Run python3 saxion-eduroam.py ``` -**SHA256:** `bef16a8ce91644a26cdd428f8dd0300de8e49ed72d9cbf4b6d39efea6d8facc1` +**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 0e75226..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 "bef16a8ce91644a26cdd428f8dd0300de8e49ed72d9cbf4b6d39efea6d8facc1 saxion-eduroam.py" | sha256sum -c +echo "b1a9b7ee4a55f77e118d40e886979ca96c8db8e145d593027f0bde0a578c46bc saxion-eduroam.py" | sha256sum -c # 3. Uitvoeren python3 saxion-eduroam.py ``` -**SHA256:** `bef16a8ce91644a26cdd428f8dd0300de8e49ed72d9cbf4b6d39efea6d8facc1` +**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 adc64e0..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) @@ -45,7 +149,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 +184,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 @@ -163,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) @@ -206,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( @@ -228,25 +349,38 @@ 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 if CA fails or is not found (still secure via domain suffix validation) - 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) + # 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, 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, + ) + sys.exit(1) # 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." )