# bots buiten de deur houden zonder google binnen te laten

· #privacy #self-hosting #matrix

salix@host:~/posts/privacy-friendly-captcha

Als je hebt geprobeerd je te registreren op salix.host, is het rijspelletje je vast opgevallen. Je moet achttien seconden verkeer overleven voordat je een registratietoken krijgt. Het is een beetje mal, maar er is een reden dat het bestaat.

Een open Matrix-server draaien betekent dat ik een manier nodig heb om automatische registraties buiten te houden. Als registratie helemaal open is, vinden bots die vroeg of laat. Ze kunnen de accounts gebruiken voor spam, oplichting of dingen die ik echt niet op mijn server gehost wil hebben. Registratie sluiten zou dat oplossen, maar dan kunnen normale mensen er ook niet meer bij, en dat mist het hele punt van een publieke server.

Het gebruikelijke antwoord is een CAPTCHA toevoegen. Meestal betekent dat een blokje van Google, Cloudflare of een ander groot bedrijf op de pagina zetten. Het werkt, maar het betekent ook dat iedereen die een account wil eerst met dat bedrijf moet praten. Hun browser stuurt een extra request, hun IP-adres gaat ergens anders heen, en soms mogen ze vijf minuten verkeerslichten aanwijzen omdat een onzichtbare score ze niet mocht.

Dat voelde nogal krom voor een privacygerichte Matrix-server. “Wij geven om je privacy, meld je eerst even bij Google voordat je binnenkomt” is geen heel overtuigend welkomstbericht.

Dus maakte ik er zelf een.

De eerste versie

Het registratiesysteem gebruikte al proof of work. De server geeft je browser een kleine SHA-256-puzzel, en de browser probeert getallen tot hij het juiste vindt. Op een normaal apparaat duurt dat een paar seconden, maar het duizenden keren doen wordt duur voor een spammer.

Proof of work is nuttig, maar op zichzelf niet genoeg. Een echte bezoeker wacht één keer een paar seconden. Een bot kan de hele dag puzzels oplossen, zeker als de accounts iets waard zijn. Het verhoogt de kosten, en dat is mooi, maar het maakt niet echt onderscheid tussen mensen en scripts.

Ik wilde een tweede check die wat interactie vroeg, zonder persoonlijke informatie te verzamelen. Om de een of andere reden was mijn antwoord daarop een vijfbaansweg vol slecht bestuurde pixelauto’s en vrachtwagens.

Het idee is geïnspireerd op CAPTCHA Hell, een aankomende game over iemand die zich door een stapel steeds belachelijkere CAPTCHA’s moet werken. Ik zag daar de rij-captcha en vond hem grappig. Toen begon ik me af te vragen of zoiets ook als echte CAPTCHA kon werken. De eer voor het rare idee ligt dus daar. Ik heb er alleen mijn eigen versie van gemaakt en het waarschijnlijk veel te serieus genomen.

Je bestuurt een autootje met een schuifje. Andere voertuigen komen de weg af, sommige wisselen van rijstrook, en jij moet ze achttien seconden lang ontwijken. De auto heeft een beetje traagheid, dus je kunt niet in één klap van de ene kant naar de andere springen. Crash je, dan mag je dezelfde weg opnieuw proberen.

Het is geen wetenschappelijke test op mens-zijn. Het is gewoon een taak die net vervelend genoeg is dat simpele registratiescripts niet op een knop kunnen drukken en onbeperkt accounts krijgen.

De browser wordt niet vertrouwd

Het voor de hand liggende probleem met een zelfgemaakte CAPTCHA is dat alle browsercode zichtbaar is. Iedereen kan de developer tools openen, de JavaScript lezen en hem aanpassen. Als de pagina simpelweg ik heb gewonnen naar de server zou sturen, zou een bot ook ik heb gewonnen sturen, waarschijnlijk een stuk sneller dan ik.

De server vertrouwt daarom niet op het resultaat dat de browser meldt. Tijdens het spel registreert de browser op elke game-tick de positie van het stuurschuifje. Die lijst met stuurinvoer is wat aan het einde wordt opgestuurd.

De server draait vervolgens dezelfde bewegingsfysica nog een keer. Hij zet de auto in het midden, past elke stuurinvoer toe en vergelijkt de berekende positie met het verkeer op dat moment. Raakt de replay een voertuig, dan wordt de poging afgekeurd. De browser mag nooit zelf beslissen waar de auto echt was.

Er was nog een probleem. Als ik alle verkeersdata als nette gestructureerde JSON zou versturen, kon een script elke voertuigpositie lezen en een perfecte route uitrekenen. In plaats daarvan maakt de server het verkeer aan en rendert het naar een PNG-afbeelding. De browser krijgt de pixels die nodig zijn om de weg te tonen, maar geen handige lijst met “vrachtwagen op rijstrook drie op tick 120”.

Natuurlijk kun je pixels alsnog analyseren. Ik beweer niet dat ik hier een onkraakbare botdetector heb uitgevonden. Maar een geanimeerde pixelweg analyseren en daar geldige stuurinvoer uit produceren is flink meer werk dan een API aanroepen met de juiste JSON-velden. Voor een kleine hobbyserver maakt dat verschil uit.

Zorgen dat het spel eerlijk is

Willekeurig verkeer klinkt leuk, tot het spel een muur van vrachtwagens over alle rijstroken genereert.

Voordat de server een challenge verstuurt, controleert hij of de weg echt te halen is met dezelfde snelheidslimieten als de auto van de speler. Hij werkt achterstevoren door het spel en kijkt welke posities nog een ontsnappingsroute hebben. Hij keurt ook wegen af waar stil in het midden blijven zitten genoeg zou zijn, want dan zou het een nogal nutteloze rijtest zijn.

Dit deel kostte meer werk dan ik had verwacht. Willekeurige auto’s genereren is makkelijk. Willekeurige auto’s genereren die onvoorspelbaar ogen, sturen vereisen en een normale speler nooit in een onmogelijke val zetten is minder makkelijk.

Auto’s die van rijstrook wisselen knipperen zelfs voordat ze gaan. Dat is deels om eerlijk te zijn, en deels omdat ik me blijkbaar tegenwoordig druk maak om de verkeersmanieren van CAPTCHA-verkeer.

Meerdere kleine sloten in plaats van één groot slot

Het rijspelletje is maar één laag. Terwijl je rijdt, lost de browser op de achtergrond de proof-of-work-puzzel op. De challenge is ondertekend door de server, gekoppeld aan de rate-limit-bucket van het IP-adres van de bezoeker, verloopt na vijftien minuten en kan maar één keer worden gebruikt. In minder dan achttien seconden finishen wordt ook afgekeurd, om vrij voor de hand liggende redenen.

Er zitten limieten op hoeveel challenges en tokens één adres kan aanvragen, plus een globale limiet voor het geval iemand met een grote hoeveelheid adressen aan komt zetten. IPv6 vraagt hier wat zorg, want één apparaat kan enorm veel adressen hebben, dus ik tel ze per netwerk in plaats van te doen alsof elk adres een andere bezoeker is.

Als alles slaagt, is het registratietoken tien minuten geldig en goed voor één account. Dat is genoeg tijd voor een persoon om het in Element te plakken, maar niet erg nuttig voor iemand die tokens verzamelt voor later.

Geen van deze beschermingen is in z’n eentje perfect. Samen maken ze misbruik onhandig genoeg zonder registratie vreselijk te maken voor normale mensen. Dat is in elk geval de balans die ik probeer te vinden.

Het gevaarlijke geheim ergens anders bewaren

De publieke dispenser heeft toestemming nodig om registratietokens uit te delen, maar ik wilde niet dat het publieke proces het administratortoken van Synapse in handen had. Zit er een bug in de dispenser, dan geeft dat geheim een aanvaller veel meer macht dan die ooit zou mogen krijgen.

Het aanmaken van tokens is daarom opgesplitst in een tweede, heel kleine dienst. Het publieke proces controleert de puzzel, de rijpoging en de rate limits. Pas als dat allemaal klopt, vraagt het de tweede dienst om via een lokale Unix-socket één token te munten.

Die tweede dienst begrijpt precies één commando: mint. Hij heeft zijn eigen globale rate limit en geeft een kortlevend token terug dat één keer te gebruiken is. Hij luistert niet op een publieke netwerkpoort. Het publieke proces kan met hem praten, maar kan het administratortoken dat hij vasthoudt niet lezen.

Processcheiding is niet spannend op een screenshot, maar het is misschien wel het onderdeel waar ik het meest om geef. Een publieke webdienst heeft altijd een aanvalsoppervlak. Hoe minder geheimen je hem geeft, hoe minder rampzalig een fout is.

Wat het niet oplost

Ik weet dat een rijspelletje toegankelijkheidsproblemen heeft. Het vraagt zicht en redelijk precieze invoer, en voor sommige mensen kan het lastig of onmogelijk zijn. Dat is de grootste zwakte van dit ontwerp. Op dit moment is contact met mij opnemen voor een account de terugvaloptie, maar dat is niet zo goed als een toegankelijke optie die in de pagina zelf zit. Hier moet ik nog een beter antwoord op vinden.

Het houdt ook geen vastberaden aanvaller voor altijd tegen. Iemand kan er computer vision voor bouwen, een browser automatiseren of gewoon mensen betalen om challenges op te lossen. CAPTCHA’s zijn een wapenwedloop en een kleine zelfgebouwde ontsnapt daar niet magisch aan.

Wat het wél tegenhoudt is goedkope, generieke automatisering. Er is geen standaard CAPTCHA-oplosdienst die al klaarstaat voor precies deze rare weg. Er zijn geen cookies, geen fingerprinting en geen script van een derde partij dat leert wie de registratiepagina bezocht. De tijdelijke IP-limieten leven op mijn server, waar de registratie toch al plaatsvindt.

Dat is goed genoeg voor mijn huidige dreigingsmodel. salix.host is een kleine homeserver, geen bank en niet het volgende gigantische sociale netwerk. Ik hoef niet elke bot op aarde te verslaan. Ik moet massaregistratie meer gedoe maken dan deze server waard is om te misbruiken.

En als mensen er een klein pixelracespelletje aan overhouden, is dat eerlijk gezegd leuker dan negen wazige fietsen aanklikken.

~$