Zo controleer je dit zelf
Betaal-QR belooft dat je betaalgegevens dit apparaat niet verlaten, en dat de site gebouwd is uit de openbare broncode. Je hoeft dat niet op mijn woord te geloven. Hieronder staat hoe je het zelf nagaat, van eenvoudig (een paar klikken) tot grondig (zelf bouwen en vergelijken).
1Wat wordt beloofd
- De QR-code wordt in je eigen browser gemaakt. Naam, IBAN, bedrag en omschrijving worden nergens heen gestuurd, ook niet naar betaal.terhofte.net.
- Opgeslagen ontvangers staan alleen in de opslag van je eigen browser (localStorage). Er zijn geen cookies, geen statistieken en geen accounts.
- De site laadt alleen zijn eigen bestanden: geen scripts, lettertypen of beelden van andere sites.
- De broncode is openbaar (GitHub, EUPL-1.2) en klein: één pagina en een paar honderd regels JavaScript, plus drie ongewijzigde bibliotheken (zie 6).
- Een foto van een factuur wordt ook alleen in je browser gelezen, met tekstherkenning (Tesseract) die van deze site komt.
- Een factuur (PDF) die je opent, wordt alleen in je browser gelezen. Het bestand gaat nergens heen; de pagina mag ook dan geen verbindingen maken.
2Kijk zelf naar het netwerkverkeer
Dit kost een paar minuten en vraagt geen technische kennis.
- Open Betaal-QR en druk op F12 (Mac: Cmd+Option+I). Kies het tabblad Netwerk (Network) en laad de pagina opnieuw.
- Je ziet alleen bestanden van betaal.terhofte.net langskomen.
- Wis de lijst en vul een betaling in. Er komen geen nieuwe verzoeken bij, ook niet als de QR-code verschijnt of als je een ontvanger bewaart.
- Open een factuur (PDF). De eerste keer zie je de PDF-lezer (
pdflezer.mjsen de bestanden van pdf.js en jsQR) van betaal.terhofte.net komen. Je factuur zelf zie je niet langskomen: die wordt niet verstuurd. - Nog eenvoudiger: zet de wifi uit (of vliegtuigmodus aan) en maak een code. Na het eerste bezoek werkt alles gewoon.
3De browser dwingt het af
De pagina geeft je browser een Content-Security-Policy mee: een regel die bepaalt wat de pagina mag. In de app staat hij onder Wat deze pagina mag. Zo luidt hij:
default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' blob: data:; manifest-src 'self'; worker-src 'self'; connect-src 'none'; base-uri 'none'; form-action 'none'
Wat dat betekent:
connect-src 'none': de pagina mag geen enkele verbinding maken, ook niet met deze site zelf. Er is dus geen kanaal om iets te versturen.form-action 'none': er kan geen formulier ergens heen gestuurd worden.script-src 'self'endefault-src 'none': alleen scripts van deze site, en verder niets wat niet uitdrukkelijk is toegestaan.worker-src 'self': alleen de eigen service worker. Die bewaart de bestanden van de site voor offline gebruik en ziet geen formuliergegevens.
De beperking zit in de pagina zelf. Ook als er per ongeluk of met opzet code in zou zitten die iets wil versturen, houdt je browser dat tegen.
4De build narekenen: is de site gebouwd uit de broncode?
Onderaan de app staat gebouwd uit commit …: de commit waaruit de site is gebouwd. In sha256.json staat dezelfde commit, met van elk bestand de SHA-256 (een vingerafdruk van de inhoud).
De build doet bijna niets. Alle bestanden zijn precies gelijk aan die in de repository, op drie na:
- in
sw.jswordt de cachenaambetaalqr-devvervangen doorbetaalqr-<commit>; versie.jswordt gemaakt, met de commit en de commitdatum;sha256.jsonwordt gemaakt.
De build is reproduceerbaar: dezelfde commit geeft byte voor byte dezelfde bestanden.
Nodig: Git en Python 3 (alleen de standaardbibliotheek).
git clone https://github.com/henriterhofte/betaal-qr cd betaal-qr python tools/controleer.py
Het script haalt sha256.json van de site en bouwt die commit zelf opnieuw. Het vergelijkt elk bestand met het manifest, downloadt daarna elk bestand van de site en controleert dat ook. Bij succes staat er: gelijk: wat op https://betaal.terhofte.net/ draait, is gebouwd uit commit …
Daarnaast draait er elke week een automatische controle die precies dit doet. Die faalt als de site afwijkt.
5De herkomst: is dit echt door GitHub gebouwd?
Bij elke uitrol legt GitHub Actions vast dat sha256.json is gebouwd uit deze repository en deze commit. Dat gebeurt met een attestatie (SLSA-herkomst, ondertekend via Sigstore).
Nodig: de GitHub CLI (gh).
curl -O https://betaal.terhofte.net/sha256.json gh attestation verify sha256.json --repo henriterhofte/betaal-qr
Bij succes staat er welke workflow en welke commit het bestand hebben gebouwd.
6De bibliotheken: ongewijzigd
Betaal-QR gebruikt drie bibliotheken van anderen. Van elk staat een vaste kopie in de repository (vendor/), dus er wordt niets van een CDN geladen. Elk bestand is byte voor byte gelijk aan dat in de officiële npm-release:
| bestand | waarvoor | SHA-256 |
|---|---|---|
| vendor/qrcode.js | qrcode-generator 1.4.4 (Kazuhiko Arase, MIT): de QR-code tekenen | 18ae399f81182bc9de916e9c77b195df20cc58d6f2d55a62b085a299f1bf1780 |
| vendor/pdfjs/pdf.min.mjs | pdf.js 4.10.38 (Mozilla, Apache-2.0): een factuur-PDF lezen | 27fc2a057a00f92a4334ad06e17dbd7259912954e9fb7f76400bcca5fd190a9c |
| vendor/pdfjs/pdf.worker.min.mjs | 1baa1844c89c80a5b2797c916e75ab29254be46d8e9cb53cb6364d7aad84be36 | |
| vendor/tesseract/tesseract.esm.min.js | Tesseract.js 5.1.1 en tesseract.js-core 5.1.1 (Apache-2.0): tekstherkenning in foto's en scans | 2537be686335e4b2637e933cdc85a52dd80267a592689c1bd63235c8591540ae |
| vendor/tesseract/worker.min.js | aca1229639fc9907d86f96e825955a2b7c5716d17f3bc3acd71f9c7ab66181fc | |
| vendor/tesseract/core/tesseract-core-simd-lstm.wasm.js | ce20eda9533cbed1e6c2b4276fbae1e0adc61b6754b5513084be601787b457cf | |
| vendor/tesseract/core/tesseract-core-lstm.wasm.js | 8f04aa0cc81e7bde33f80e92fa01a7a665f0b4884d098acf5de9c7104a11dfaa | |
| vendor/tesseract/lang/nld.traineddata.gz | Nederlands taalmodel (tessdata_best, integer; npm @tesseract.js-data/nld 1.0.0) | a2d904b6ddc4feb0d31ecfcd7361a554102e7aa2e278c54f4fc029e0d0815571 |
| vendor/jsqr/jsQR.js | jsQR 1.4.0 (Apache-2.0): een betaal-QR op de factuur lezen | bc40c8a15196236b2314db0856f72ca0b49980cd5413b8c852a7349f5fee0859 |
pdf.js en jsQR worden pas geladen als je een factuur opent; Tesseract (± 7 MB) pas als je een foto of een gescande PDF opent. Daarna bewaart de service worker het, zodat het ook offline werkt. pdf.js draait met isEvalSupported: false en zonder webfonts, zodat het binnen de Content-Security-Policy blijft.
curl -sL https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-1.4.4.tgz | tar xzO package/qrcode.js | sha256sum curl -sL https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-4.10.38.tgz | tar xzO package/build/pdf.min.mjs | sha256sum curl -sL https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-4.10.38.tgz | tar xzO package/build/pdf.worker.min.mjs | sha256sum curl -sL https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz | tar xzO package/dist/jsQR.js | sha256sum curl -sL https://registry.npmjs.org/tesseract.js/-/tesseract.js-5.1.1.tgz | tar xzO package/dist/worker.min.js | sha256sum curl -sL https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-5.1.1.tgz | tar xzO package/tesseract-core-simd-lstm.wasm.js | sha256sum curl -sL https://registry.npmjs.org/@tesseract.js-data/nld/-/nld-1.0.0.tgz | tar xzO package/4.0.0_best_int/nld.traineddata.gz | sha256sum
7Wat dit niet dekt
- Je browser, je besturingssysteem en je extensies vertrouw je. Een kwaadwillende browserextensie kan meekijken op elke pagina, ook op deze.
- GitHub is de host en de bouwer. De attestatie en de weekcontrole maken misbruik zichtbaar, maar voorkomen niet dat er tussen twee controles iets anders wordt geleverd. Wie dat wil uitsluiten, opent de app uit een eigen build:
python tools/bouw.pyen danpython -m http.server -d _site. - De code is niet door anderen beoordeeld. Een gelijke vingerafdruk bewijst dat de site uit de broncode komt, niet dat de broncode goed is. Juist daarom is hij klein gehouden.
- Workers. Het zware werk (PDF lezen, tekstherkenning) gebeurt in zogeheten workers: aparte achtergrondscripts van deze site. Voor workers geldt de Content-Security-Policy van de pagina niet; daar geldt alleen wat de server meestuurt, en GitHub Pages stuurt niets mee. Het gaat om de ongewijzigde bibliotheken hierboven (met vingerafdruk), die alleen hun eigen bestanden van deze site laden. Je ziet dat ook in het netwerkverkeer (punt 2): de foto of PDF zelf gaat nergens heen.
- Delen op Android. Als je een PDF deelt naar de geïnstalleerde app, stuurt Android hem als "verzending" naar het adres
/gedeeld. De service worker van Betaal-QR vangt die op je telefoon af; er gaat niets over het netwerk. Alleen als die service worker ontbreekt (bijvoorbeeld als je de sitegegevens hebt gewist maar het app-icoon nog staat), zou de PDF naar de server van GitHub gaan, die hem met een foutmelding weggooit. Open de app daarom één keer gewoon voordat je deelt. "Openen met" op een laptop heeft dit niet: daar komt het bestand rechtstreeks bij de app, zonder netwerk. - Herkenning is geen controle. Uit een valse factuur leest Betaal-QR net zo goed een IBAN als uit een echte. Daarom blijft de stap "Klopt deze betaling?" verplicht, met de herkomst van elk gegeven.
- Een kwaadwillende kopie van deze site (de code is openbaar) zou een ander IBAN in de QR-code kunnen zetten dan op het scherm staat. Vergelijk daarom altijd in je bank-app het IBAN met de factuur, en let op de naam-nummercontrole. Dat geldt ook voor deze site.
- Bewust weggelaten: links met vooraf ingevulde gegevens. Die zouden oplichters kunnen gebruiken ("betaal via deze link"). Daarnaast weigert de app te werken als hij in een andere site is ingebed (clickjacking).
- Geen betaaldienst. Betaal-QR maakt alleen een plaatje van de gegevens die je invult. Je bank voert de betaling uit, nadat jij hem in de bank-app hebt bevestigd.