diff --git a/Docs/CHANGELOG-electrum-gate.md b/Docs/CHANGELOG-electrum-gate.md index b23aa88..68dd1b7 100644 --- a/Docs/CHANGELOG-electrum-gate.md +++ b/Docs/CHANGELOG-electrum-gate.md @@ -8,6 +8,60 @@ Wat er nog moet gebeuren staat **niet** hier maar in de plannen; zie [CONTINUE_H Een lijst met geplande features op twee plekken loopt uit elkaar, en dan is geen van beide meer te vertrouwen. +## [0.0.16] - 2026-08-27 + +### Added + +- **De zelfcontrole: de agent verbindt met de eigen TLS-poort.** Tot nu toe keek de app of de + Electrum-server antwoordde, maar niet of hij zélf antwoordde, en dat laatste is zijn enige taak. De agent + zet nu een TLS-verbinding op naar poort 50022 van de nginx-container ernaast, maakt de handdruk af en + vergelijkt het getoonde certificaat **byte voor byte** met het gekozen bestand. + + Die vergelijking is het punt, en niet de handdruk. Een certificaatwissel die nginx nooit heeft toegepast + ziet er van buiten goed uit en loopt pas bij de wallet stuk op naamverificatie; dat is precies het geval + waar een controle op vertrouwen blind voor is. Er wordt daarom bewust **niet** tegen de certificaatwinkel + van het besturingssysteem geverifieerd: een zelfondertekend certificaat uploaden is een ondersteunde + bron, en die opstelling zou dan als kapot gemeld worden. + + De uitkomst staat in `status.json` onder `tls.self_check`, met vier toestanden: `off` (geen certificaat + gekozen, dus geen poort), `ok`, `wrong-certificate` en `failed`. Bij een **wijziging** komt er een regel + in het activiteitenlog; elke ronde dezelfde regel zou ruis zijn. + + **Wat het niet bewijst, en dat is minder dan het plan beloofde.** Er wordt na de handdruk niets + verstuurd, dus de doorverbinding naar de Electrum-server zit er niet in; die wordt apart bevraagd. En de + verbinding loopt van container naar container, dus de poortmapping naar de host en de doorstuurregel in + de router blijven ongetest. + +### Changed + +- **De controle draait niet elke ronde, en zijn eigen sessies worden uit het activiteitenlog gefilterd.** + Elke verbinding met poort 50022 is voor nginx een gewone sessie en levert dus een logregel op. Zonder rem + zou het log elke minuut over onszelf gaan in plaats van over wallets. Er wordt daarom hoogstens elke vijf + minuten gemeten, en de agent onthoudt wanneer hij zelf verbond om de bijbehorende regel weg te laten. + + Twee eigenschappen van die filter horen erbij: hij laat **alleen** regels weg waarin niets is doorgegeven, + want een sessie met verkeer kan een storing zijn en die verbergen is erger; en hij kan een échte scan + missen die toevallig in hetzelfde venster van twee seconden eindigde. + +- **Vlak na een certificaatwissel wordt er juist niet gemeten.** nginx herlaadt binnen tien seconden, dus + een meting op dat moment ziet nog het vorige certificaat en zou dat als storing melden. De meting schuift + één ronde op, en dan is het meteen de interessantste die er is. + +## [0.0.15] - 2026-08-25 + +Deze regel is op 27-08-2026 alsnog toegevoegd; bij de verhuizing is de changelog overgeslagen. + +### Changed + +- **De app store is verhuisd naar `UmbrelApps`**, en `website`, `repo`, `support`, `submission` en `icon` + wijzen daarheen. De app zelf is ongewijzigd: het app-id hangt aan het store-id `whatsnext` en niet aan de + URL. Aanleiding was Evolu Relay, want umbrelOS leest per store één repo en een tweede app maakte een naam + naar deze ene app onhoudbaar. + + Wat hierbij geleerd is en verder gaat dan deze release: **een geïnstalleerde app overleeft een wisseling + van store-URL niet.** umbrelOS tóónde de update wel en voerde hem niet uit, zonder foutmelding. + Deïnstalleren plus opnieuw installeren loste het op, en de prijs daarvan is de certificaatkeuze. + ## [0.0.14] - 2026-08-20 ### Added diff --git a/Docs/CONTINUE_HERE.md b/Docs/CONTINUE_HERE.md index 6afbfd6..1b4d21c 100644 --- a/Docs/CONTINUE_HERE.md +++ b/Docs/CONTINUE_HERE.md @@ -23,7 +23,7 @@ | Plan | App | Volgende stap | Status | |-|-|-|-| -| [Webinterface](Plannen/Actief/005-Webinterface/TAKEN.md) | Gate | **Fase 6, de tweede indeling** (opgegeven 27-08-2026): vijf kleine widgets op de bovenste rij, de certificaatkeuze naar een dialoog achter een menu, log over de volle breedte, en het instelkader dichtgeklapt en compacter. Begint met de zelfcontrole op de eigen TLS-poort, want dat is het enige nieuwe werk en het levert de vijfde widget | 🔶 | +| [Webinterface](Plannen/Actief/005-Webinterface/TAKEN.md) | Gate | **Fase 6, de tweede indeling** (opgegeven 27-08-2026): vijf kleine widgets op de bovenste rij, de certificaatkeuze naar een dialoog achter een menu, log over de volle breedte, en het instelkader dichtgeklapt en compacter. De zelfcontrole eronder is gebouwd in 0.0.16 (27-08-2026), dus wat er nu ligt is de verbouwing zelf | 🔶 | | [Umbrelapp](Plannen/Actief/008-Umbrelapp/TAKEN.md) | Relay | **Hij draait sinds 25-08-2026, maar kan in de kern niet werken.** Uit de broncode van Suite: bij een eigen relay-URL registreert de cliënt geen eigenaar, terwijl Trezor's relay iedereen zonder limietenrij weigert. Volgende stap is de kále Evolu-relay proberen (`docker.io/evoluhq/relay:latest`, één container, gepubliceerde image) en waarschijnlijk daarnaartoe verbouwen. Zie open punt 6 | 🔶 | | [Proefopstelling](Plannen/Actief/007-Proefopstelling/TAKEN.md) | Relay | **Eerst beslissen of dit plan nog bestaansrecht heeft.** Fase 1 is af, maar fase 2 en 3 zijn ingehaald: het pakket draait al op de Umbrel, dus een lokale opstelling ernaast meet minder en kost meer. Wat overblijft zijn twee vragen die al als blokkade in **Umbrelapp** staan, en die horen op één plek | 🔶 | diff --git a/Docs/Plannen/Actief/005-Webinterface/OPEN.md b/Docs/Plannen/Actief/005-Webinterface/OPEN.md index c8e5f00..9e73bc7 100644 --- a/Docs/Plannen/Actief/005-Webinterface/OPEN.md +++ b/Docs/Plannen/Actief/005-Webinterface/OPEN.md @@ -39,8 +39,17 @@ de einddatum van het actieve certificaat gelezen wordt. Het lag stil omdat de agent nog nooit gedraaid had, en die reden is vervallen. De uitkomst wordt de - vijfde widget van de bovenste rij uit [PLAN.md](PLAN.md) §4a2. Staat als eerste taak in fase 6, want - het is het enige echte nieuwe werk in die fase en de rest is opmaak eromheen. + vijfde widget van de bovenste rij uit [PLAN.md](PLAN.md) §4a2. + + **Gebouwd in 0.0.16, diezelfde dag, en het bewijst minder dan hierboven staat.** De zin "bewijst het + luisteren, het certificaat en de doorverbinding in één keer" klopte voor de eerste twee en niet voor de + derde: er wordt na de handdruk niets verstuurd. Een echt verzoek zou de sessie bytes geven, en sessies + met bytes worden nooit uit het activiteitenlog gefilterd, want die kunnen een storing zijn. De backend + wordt apart bevraagd, dus wat overblijft is alleen de `proxy_pass`. + + Wat er in de plaats kwam is scherper dan verwacht: het getoonde certificaat wordt byte voor byte + vergeleken met het gekozen bestand, en dát vangt een herlading die nginx nooit heeft toegepast. Dat is + precies het geval waar een controle op vertrouwen blind voor is. 3. **Komt het activiteitenlog er?** - **Ja, en gebouwd** (opgemerkt 27-08-2026; het besluit is nooit als zodanig opgeschreven, het werk is gewoon gedaan en uitgerold in 0.0.6 en verfijnd in 0.0.14). diff --git a/Docs/Plannen/Actief/005-Webinterface/PLAN.md b/Docs/Plannen/Actief/005-Webinterface/PLAN.md index 56a9ef6..21122b9 100644 --- a/Docs/Plannen/Actief/005-Webinterface/PLAN.md +++ b/Docs/Plannen/Actief/005-Webinterface/PLAN.md @@ -284,9 +284,20 @@ bijna verlopen certificaat: dat staat al als waarschuwing bovenaan en die verhui - **De dialoog met het `dialog`-element**, om dezelfde reden: focus en Escape zitten erin. Wat er in moet gebeuren is precies wat het certificaatkader nu doet, inclusief het uploaden en de melding dat de pagina níet zelf zegt dat het gelukt is (§4b en fase 4b van `TAKEN.md`). -- **De zelfcontrole-widget is nieuw werk in de agent**, geen opmaak. Zie open punt 5: de agent zet een - TLS-verbinding naar zijn eigen poort op en maakt de handdruk af. Dat bewijst luisteren, certificaat en - doorverbinding in één keer. +- **De zelfcontrole-widget is nieuw werk in de agent**, geen opmaak. Zie open punt 5. **Gebouwd in 0.0.16; + de widget zelf hoort bij deze fase.** Wat er te tonen is, staat in `status.json` onder `tls.self_check`, + met vier toestanden: `off` (geen certificaat gekozen, dus geen poort, en dat is geen storing), `ok`, + `wrong-certificate` en `failed` met een reden. + + **Bij het bouwen bleek de belofte hierboven te ruim**, en de widget moet niet meer beweren dan er + gemeten wordt. Hier stond dat de controle luisteren, certificaat en doorverbinding in één keer bewijst. + De eerste twee kloppen; de doorverbinding niet. Er wordt na de handdruk niets verstuurd, want een + verzoek zou de sessie bytes geven en sessies met bytes worden nooit uit het activiteitenlog gefilterd. + De backend wordt apart bevraagd, dus wat tussen wal en schip valt is alleen de `proxy_pass` zelf. + + Evenmin gemeten: de weg van buiten naar binnen. De verbinding loopt van container naar container, dus + een router die de poort niet meer doorstuurt leest hier als in orde. Een widget met de tekst "reachable" + zou dus liegen; hij gaat over de voordeur en niet over de weg ernaartoe. ### 4b. Waarschuwen op een aflopend certificaat diff --git a/Docs/Plannen/Actief/005-Webinterface/PROGRESS.md b/Docs/Plannen/Actief/005-Webinterface/PROGRESS.md index 3754f23..c954d27 100644 --- a/Docs/Plannen/Actief/005-Webinterface/PROGRESS.md +++ b/Docs/Plannen/Actief/005-Webinterface/PROGRESS.md @@ -1,5 +1,29 @@ # Voortgang - Webinterface +## 27-08-2026 - de app kijkt eindelijk naar zijn eigen voordeur, en meet minder dan beloofd + +Open punt 5 is goedgekeurd en gebouwd in 0.0.16. De agent verbindt met de eigen TLS-poort, maakt de +handdruk af en vergelijkt het getoonde certificaat byte voor byte met het gekozen bestand. Dat laatste +bleek waardevoller dan de handdruk zelf: het vangt een certificaatwissel die nginx nooit heeft toegepast, +en dat is precies het geval waar een controle op vertrouwen blind voor is. + +**Twee dingen liepen anders dan het plan zei, en allebei zijn ze in de documentatie rechtgezet.** De +belofte "bewijst luisteren, certificaat en doorverbinding in één keer" klopte voor twee van de drie: na de +handdruk wordt er niets verstuurd. Een echt verzoek zou de sessie bytes geven, en sessies met bytes worden +nooit uit het activiteitenlog gefilterd, want dat zou een storing kunnen verbergen. + +En dat filteren was de tweede verrassing: elke meting is voor nginx een gewone sessie en levert dus een +logregel op. Zonder rem en zonder filter ging het activiteitenlog over onszelf in plaats van over wallets. +Er zit nu een rem op van vijf minuten, er wordt niet gemeten vlak na een herlading (dan staat het vorige +certificaat er nog), en de eigen regels worden weggelaten op grond van het moment. + +De widget zelf komt bij fase 6; tot die tijd is een wijziging zichtbaar als regel in het activiteitenlog. + +**Geraakt:** `agent.py.template`, `docker-compose.yml`, `umbrel-app.yml` (0.0.16), +`tests/test_agent_zelfcontrole.py` (nieuw), dit plan, de changelog (waar 0.0.15 nog ontbrak). +**Tests:** 22 goed 0 fout nieuw, plus 54, 39 en 32 goed 0 fout bestaand. Alle vier de beslissende regels +mutatie-getest. + ## 20-08-2026 - scans zijn geen weigeringen, en meer lucht tussen de kaders De gebruiker vroeg wat een logregel `refused ... status 500` met nul bytes betekende. Antwoord: een diff --git a/Docs/Plannen/Actief/005-Webinterface/TAKEN.md b/Docs/Plannen/Actief/005-Webinterface/TAKEN.md index 9308f12..760c8dc 100644 --- a/Docs/Plannen/Actief/005-Webinterface/TAKEN.md +++ b/Docs/Plannen/Actief/005-Webinterface/TAKEN.md @@ -231,11 +231,20 @@ deze indeling zelf oproept. **Volgorde: eerst de zelfcontrole, dan de opmaak.** Die eerste is het enige echte nieuwe werk en hij levert de vijfde widget; de rest is de pagina verbouwen om wat er dan staat. -- [ ] **De zelfcontrole bouwen (open punt 5, goedgekeurd 27-08-2026).** De agent zet een TLS-verbinding naar - zijn eigen poort 50022 op en maakt de handdruk af. Dat bewijst luisteren, certificaat en - doorverbinding in één keer. Raakt de agent, dus **mét test, en de test gaat over het niet-gelukkige - pad**: geen certificaat, poort niet open, handdruk geweigerd. Wat de widget bij een mislukking toont, - hoort bij het bouwen bedacht te worden en niet erna +- [x] **De zelfcontrole gebouwd in 0.0.16 (27-08-2026).** De agent verbindt met poort 50022 van de + nginx-container ernaast, maakt de handdruk af en vergelijkt het getoonde certificaat byte voor byte + met het gekozen bestand. Die vergelijking is het punt: een herlading die niet aankwam ziet er van + buiten goed uit. Vier toestanden in `status.json` onder `tls.self_check`: `off`, `ok`, + `wrong-certificate`, `failed`. Bij een wijziging komt er een regel in het activiteitenlog + + **Twee dingen die bij het bouwen anders liepen dan het plan zei.** Ten eerste bewijst de controle de + doorverbinding naar de Electrum-server **niet**; zie §4a2 voor waarom dat een bewuste ruil is. Ten + tweede bleek elke meting een sessieregel op te leveren, dus er zit nu een rem op (vijf minuten) en + een filter die de eigen regels weglaat. Zonder die twee ging het activiteitenlog over onszelf + + Getest in `tests/test_agent_zelfcontrole.py`, 22 toetsen, met de nadruk op de niet-gelukkige paden: + geen certificaat, dichte poort, iets dat luistert maar geen TLS spreekt, en een ander certificaat aan + de lijn. Alle vier de beslissende regels zijn mutatie-getest - [ ] Rij 1 wordt vijf gelijke widgets: Electrum-server, blokhoogte, reactietijd, certificaat, zelfcontrole - [ ] Een eigen raster van vijf kolommen voor die rij, en beslissen wat er tussen "vijf naast elkaar" en "gestapeld op mobiel" gebeurt. Vijf valt niet netjes in twaalf kolommen; dit is nieuw werk diff --git a/tests/test_agent_zelfcontrole.py b/tests/test_agent_zelfcontrole.py new file mode 100644 index 0000000..0c5c153 --- /dev/null +++ b/tests/test_agent_zelfcontrole.py @@ -0,0 +1,332 @@ +"""Toetst de zelfcontrole van de agent: antwoordt de eigen TLS-poort? + +Waarom deze test bestaat: dit is de enige controle die over de app zelf gaat, en +hij kan op twee manieren stil verkeerd zijn. Hij kan "ok" melden terwijl er iets +anders aan de lijn hangt dan het gekozen certificaat, en hij kan het +activiteitenlog vullen met zijn eigen verbindingen. Dat tweede is geen schoonheids- +foutje: elke meting is voor nginx een gewone sessie, dus zonder rem en zonder +filter gaat het log over onszelf in plaats van over wallets. + +De nadruk ligt daarom op wat er níet moet gebeuren: niet meten vlak na een +herlading, niet meten als er net gemeten is, en de eigen sessieregel niet laten +staan. + +Draaien: + + python tests/test_agent_zelfcontrole.py + +Wat een echte TLS-server vraagt, vraagt openssl. Is dat er niet, dan slaan die +toetsen zichzelf over en zeggen dat erbij; de rest draait gewoon. +""" + +import sys + +# Zelfde reden als in test_agent_certificates.py: geen .pyc naast de app-map. +sys.dont_write_bytecode = True + +import os # noqa: E402 +import socket # noqa: E402 +import ssl # noqa: E402 +import tempfile # noqa: E402 +import threading # noqa: E402 +import time # noqa: E402 + +HERE = os.path.dirname(os.path.abspath(__file__)) +if HERE not in sys.path: + sys.path.insert(0, HERE) + +# De buurtest is hier de gereedschapskist: hij laadt agent.py.template als module +# en maakt echte sleutelparen met openssl. Dat tweede keer overdoen zou een tweede +# versie opleveren die uit elkaar loopt. +from test_agent_certificates import Uitslag, load_agent, openssl_paar # noqa: E402 + + +class TlsServer: + """Een echte TLS-server op een vrije poort van de loopback. + + Echt en niet nagebootst, want wat hier getoetst wordt is een handdruk. Een + verzonnen server bewijst niets over `wrap_socket`. + """ + + def __init__(self, cert, key): + self.context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + self.context.load_cert_chain(cert, key) + self.sock = socket.socket() + self.sock.bind(("127.0.0.1", 0)) + self.sock.listen(5) + self.port = self.sock.getsockname()[1] + self.stoppen = False + self.thread = threading.Thread(target=self._loop, daemon=True) + self.thread.start() + + def _loop(self): + while not self.stoppen: + try: + rauw, _ = self.sock.accept() + except OSError: + return + try: + with self.context.wrap_socket(rauw, server_side=True) as tls: + # Wachten tot de client sluit. De agent sluit meteen na de + # handdruk, dus dit levert b"" op en niet een hangende lus. + tls.recv(1) + except OSError: + pass + + def close(self): + self.stoppen = True + try: + self.sock.close() + except OSError: + pass + + +class KaleServer: + """Luistert wel, maar spreekt geen TLS. Accepteert en sluit meteen.""" + + def __init__(self): + self.sock = socket.socket() + self.sock.bind(("127.0.0.1", 0)) + self.sock.listen(5) + self.port = self.sock.getsockname()[1] + self.stoppen = False + self.thread = threading.Thread(target=self._loop, daemon=True) + self.thread.start() + + def _loop(self): + while not self.stoppen: + try: + rauw, _ = self.sock.accept() + except OSError: + return + rauw.close() + + def close(self): + self.stoppen = True + try: + self.sock.close() + except OSError: + pass + + +def dichte_poort(): + """Een poortnummer waar zeker niets luistert.""" + s = socket.socket() + s.bind(("127.0.0.1", 0)) + poort = s.getsockname()[1] + s.close() + return poort + + +def richt(agent, poort): + agent.TLS_HOST = "127.0.0.1" + agent.TLS_PORT = poort + + +def test_zonder_certificaat_wordt_er_niet_verbonden(agent, u): + """Geen keuze betekent geen poort 50022, en dus geen storing. + + Dit is de normale toestand van een verse installatie. Hem als fout melden zou + de ergste soort onwaarheid zijn: precies daar staat al een foutmelding dat er + een certificaat gekozen moet worden, en een tweede rode melding erbij leidt + weg van de knop die het oplost. + """ + richt(agent, dichte_poort()) + + for leeg in (None, {}): + uitkomst = agent.probe_self(leeg) + u.check("zonder certificaat is de toestand 'off' (%r)" % (leeg,), + uitkomst.get("state") == "off", "gaf %r" % uitkomst) + u.check("en dan is er niets gemeten (%r)" % (leeg,), + "ms" not in uitkomst and "error" not in uitkomst, + "gaf %r" % uitkomst) + + +def test_niemand_luistert(agent, u): + """Wel een certificaat, geen poort. Dat is wel een storing.""" + richt(agent, dichte_poort()) + uitkomst = agent.probe_self({"domain": "gate.example.org", "cert": "/bestaat/niet"}) + u.check("een dichte poort geeft 'failed'", + uitkomst.get("state") == "failed", "gaf %r" % uitkomst) + u.check("en de reden staat erbij", + bool(uitkomst.get("error")), "gaf %r" % uitkomst) + u.check("een mislukking meldt geen meettijd", + "ms" not in uitkomst, "gaf %r" % uitkomst) + + +def test_luisteren_is_niet_genoeg(agent, u): + """Iets dat luistert maar geen TLS spreekt, is ook een storing. + + Dit onderscheidt de controle van een simpele poortcheck. Een open poort + bewijst niet dat er een bruikbaar certificaat achter zit, en juist dat is wat + een wallet nodig heeft. + """ + server = KaleServer() + try: + richt(agent, server.port) + uitkomst = agent.probe_self({"domain": "gate.example.org", "cert": "/bestaat/niet"}) + u.check("luisteren zonder TLS geeft 'failed'", + uitkomst.get("state") == "failed", "gaf %r" % uitkomst) + finally: + server.close() + + +def test_gelukkig_pad_en_verkeerd_certificaat(agent, u): + """De handdruk lukt, en dan gaat het erom wélk certificaat er terugkomt. + + Het tweede geval is de reden dat deze controle niet op vertrouwen leunt maar + op de bytes van het certificaat: nginx dat na een mislukte herlading nog het + vorige certificaat serveert, is voor elke trust-controle in orde en is toch + precies de storing die je zoekt. + """ + with tempfile.TemporaryDirectory() as tmp: + paar = openssl_paar(tmp, "gekozen", "gate.example.org") + ander = openssl_paar(tmp, "ander", "andere.example.org") + if not paar or not ander: + print("OVERGESLAGEN: geen openssl, dus de handdruk is niet getoetst") + return + cert, key = paar + ander_cert, _ = ander + + server = TlsServer(cert, key) + try: + richt(agent, server.port) + + uitkomst = agent.probe_self({"domain": "gate.example.org", "cert": cert}) + u.check("het gekozen certificaat aan de lijn geeft 'ok'", + uitkomst.get("state") == "ok", "gaf %r" % uitkomst) + u.check("en een meettijd in milliseconden", + isinstance(uitkomst.get("ms"), int) and uitkomst["ms"] >= 0, + "gaf %r" % uitkomst) + + uitkomst = agent.probe_self({"domain": "andere.example.org", + "cert": ander_cert}) + u.check("een ander certificaat aan de lijn wordt herkend", + uitkomst.get("state") == "wrong-certificate", "gaf %r" % uitkomst) + finally: + server.close() + + +def test_eigen_sessies_uit_het_log(agent, u): + """De filter die het log over wallets laat gaan in plaats van over onszelf.""" + def regel(t, event="probe", inn=0, uit=0): + return {"t": t, "event": event, "bytes_in": inn, "bytes_out": uit} + + entries = [regel(1000), regel(2000)] + over = agent.suppress_self_probes(entries, [1001]) + u.check("een eigen meting verdwijnt uit het log", + [e["t"] for e in over] == [2000], "over: %r" % over) + + over = agent.suppress_self_probes([regel(1000)], [1010]) + u.check("een scan buiten het venster blijft staan", + len(over) == 1, "over: %r" % over) + + # Dit is de belangrijkste: een sessie waarin wél iets is doorgegeven, is per + # definitie niet van ons. Die weglaten zou een storing verbergen. + echt = regel(1000, event="refused", inn=120, uit=300) + over = agent.suppress_self_probes([echt], [1000]) + u.check("een sessie met verkeer blijft staan, ook op hetzelfde moment", + over == [echt], "over: %r" % over) + + # Eén onthouden meting laat hoogstens één regel weg. Anders zou een enkele + # meting een reeks echte scans kunnen opslokken. + over = agent.suppress_self_probes([regel(1000), regel(1001)], [1000]) + u.check("één meting laat hoogstens één regel weg", + len(over) == 1, "over: %r" % over) + + u.check("zonder metingen verandert er niets", + agent.suppress_self_probes([regel(1000)], []) == [regel(1000)]) + + +def test_ronde_meet_niet_elke_keer(agent, u): + """De rem, via `build_status`, dus via de ronde die de agent echt draait. + + Drie dingen op een rij die alle drie stil fout kunnen gaan: + + 1. vlak na een certificaatwissel wordt er niet gemeten. nginx herlaadt binnen + tien seconden, dus meten zou het vorige certificaat zien en dat als storing + melden; + 2. de ronde daarna wordt er wel gemeten; + 3. de ronde daarna weer niet, want er is net gemeten. Zonder deze rem staat er + elke minuut een eigen regel in het activiteitenlog. + """ + with tempfile.TemporaryDirectory() as tmp: + bron = os.path.join(tmp, "certs") + os.makedirs(bron) + paar = openssl_paar(bron, "gate", "gate.example.org") + if not paar: + print("OVERGESLAGEN: geen openssl, dus de rem is niet getoetst") + return + cert, key = paar + + agent.SOURCES = [("Own folder", bron)] + agent.ELECTRUM_HOST = "" # dan faalt de backend-probe zonder netwerk + agent.STATE_DIR = tmp + agent.CONFIG_DIR = os.path.join(tmp, "config") + agent.SELECTED_FILE = os.path.join(agent.CONFIG_DIR, "selected-cert") + agent.STATUS_FILE = os.path.join(tmp, "status.json") + agent.CERT_CONF = os.path.join(tmp, "cert.conf") + agent.RELOAD_FLAG = os.path.join(tmp, "reload") + agent.STREAM_LOG = os.path.join(tmp, "stream.log") + agent.SESSIONS_FILE = os.path.join(tmp, "sessions") + agent.SELF_CHECK_INTERVAL = 300 + + server = TlsServer(cert, key) + try: + richt(agent, server.port) + + def ronde(): + status = agent.build_status([]) + agent.write_status(status) + return status + + eerste = ronde()["tls"]["self_check"] + u.check("vlak na de eerste keuze wordt er niet gemeten", + eerste.get("state") == "pending", "gaf %r" % eerste) + + tweede = ronde()["tls"]["self_check"] + u.check("de ronde daarna wel, en die lukt", + tweede.get("state") == "ok", "gaf %r" % tweede) + + derde = ronde() + u.check("en daarna niet opnieuw, want er is net gemeten", + derde["tls"]["self_check"].get("at") == tweede.get("at"), + "gaf %r" % derde["tls"]["self_check"]) + u.check("er is dus precies één eigen meting onthouden", + len(derde.get("self_probes", [])) == 1, + "gaf %r" % derde.get("self_probes")) + + # En nu het sluitstuk: de sessieregel die nginx van diezelfde meting + # geschreven zou hebben, hoort niet in het log te komen. Een tweede + # regel even ervoor is niet van ons en moet blijven staan. + gemeten = tweede["at"] + with open(agent.STREAM_LOG, "w", encoding="utf-8") as f: + for t in (gemeten - 60, gemeten): + f.write("%s 500 0 0 0.010\n" + % time.strftime("%Y-%m-%dT%H:%M:%S+00:00", time.gmtime(t))) + + log = ronde()["log"] + momenten = [e["t"] for e in log if e.get("event") == "probe"] + u.check("de eigen sessieregel staat niet in het log", + gemeten not in momenten, "regels op: %r" % momenten) + u.check("en de scan van iemand anders wel", + (gemeten - 60) in momenten, "regels op: %r" % momenten) + finally: + server.close() + + +def main(): + u = Uitslag() + agent = load_agent() + test_zonder_certificaat_wordt_er_niet_verbonden(agent, u) + test_niemand_luistert(agent, u) + test_luisteren_is_niet_genoeg(agent, u) + test_gelukkig_pad_en_verkeerd_certificaat(agent, u) + test_eigen_sessies_uit_het_log(agent, u) + # Als laatste, want deze verzet de module-instellingen naar een tijdelijke map. + test_ronde_meet_niet_elke_keer(agent, u) + return u.rapport() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/whatsnext-electrum-gate/agent.py.template b/whatsnext-electrum-gate/agent.py.template index 70e2c41..568a296 100644 --- a/whatsnext-electrum-gate/agent.py.template +++ b/whatsnext-electrum-gate/agent.py.template @@ -46,6 +46,22 @@ UPLOAD_MAX = 96 * 1024 ELECTRUM_HOST = os.environ.get("GATE_ELECTRUM_HOST", "") ELECTRUM_PORT = int(os.environ.get("GATE_ELECTRUM_PORT", "50001")) TLS_PORT = int(os.environ.get("GATE_TLS_PORT", "50022")) + +# De zelfcontrole: de agent verbindt met de eigen TLS-poort. Zie het plan +# Webinterface, OPEN.md punt 5. +# +# GATE_TLS_HOST is de nginx-container hiernaast. Servicenamen resolven binnen het +# compose-netwerk; dat is dezelfde weg die nginx andersom gebruikt om bij deze +# agent te komen. Niet localhost: dat is een andere container en dus een andere +# netwerk-namespace. +# +# Waarom deze controle niet elke ronde draait: elke verbinding met poort 50022 is +# voor nginx een gewone sessie en levert dus een regel in het activiteitenlog op. +# Elke minuut een eigen regel maakt dat log onbruikbaar. Vijf minuten is vaak +# genoeg voor een waarde die zelden verandert, en na een certificaatwissel wordt +# er sowieso gemeten. +TLS_HOST = os.environ.get("GATE_TLS_HOST", "server") +SELF_CHECK_INTERVAL = int(os.environ.get("GATE_SELF_CHECK_INTERVAL", "300")) INTERVAL = int(os.environ.get("GATE_INTERVAL", "60")) API_PORT = int(os.environ.get("GATE_API_PORT", "8000")) HISTORY_SECONDS = 24 * 3600 @@ -643,6 +659,101 @@ def probe_backend(): return result +# Wat er in het activiteitenlog komt als de uitkomst verandert. Engels, want dit +# belandt op de pagina. Alleen bij een wijziging: elke ronde dezelfde regel is +# ruis, en de toestand zelf staat in status.json. +SELF_CHECK_NOTES = { + "ok": "the gateway answered on its own TLS port", + "failed": "the gateway did not answer on its own TLS port", + "wrong-certificate": "the gateway answered with a different certificate than the one selected", + "off": "TLS is off; no certificate selected", +} + + +def probe_self(active): + """Antwoordt de eigen TLS-poort, en met het gekozen certificaat? + + Dit is de enige controle die gaat over wat deze app zelf doet. De agent kijkt + al of de Electrum-server antwoordt, maar dat zegt niets over de voordeur, en + de voordeur is de hele app. Eén verbinding met een afgemaakte handdruk bewijst + drie dingen tegelijk: nginx luistert, er ligt een certificaat, en het is te + gebruiken. + + Bewust géén controle op vertrouwen. `verify_mode` staat op CERT_NONE en de + hostnaam wordt niet nagelopen, en dat is geen slordigheid maar het verschil + tussen deze controle en die van een wallet: + + - een gebruiker mag een zelfondertekend certificaat uploaden, dat is een + ondersteunde bron. Verifiëren tegen de certificaatwinkel van het + besturingssysteem zou die opstelling als kapot melden terwijl hij werkt; + - de winkel hoeft in de image niet te bestaan. De bestaande test slaat de + vergelijking met `ssl` om die reden soms over, en een controle die zichzelf + overslaat op het apparaat is geen controle. + + Wat er in plaats daarvan gebeurt is scherper: het getoonde certificaat wordt + byte voor byte vergeleken met het gekozen bestand. Dat vangt het geval waar + trust-verificatie juist blind voor is, namelijk dat nginx nog het vórige + certificaat serveert omdat een herlading niet gelukt is. + + **Wat dit niet bewijst, en dat is minder dan het plan beloofde.** Er wordt na + de handdruk niets verstuurd, dus de doorverbinding naar de Electrum-server zit + er níet in. Dat is een bewuste ruil: één echt verzoek zou de hele keten + bewijzen, maar dan draagt de sessie bytes, en sessies met bytes worden nooit + uit het activiteitenlog gefilterd. Zie suppress_self_probes: een sessie waarin + iets is doorgegeven kan een storing zijn, en die verbergen is erger dan deze + controle mislopen. De backend wordt bovendien apart bevraagd door + probe_backend; wat tussen wal en schip valt is alleen de proxy_pass zelf. + + Evenmin bewezen: de weg van buiten naar binnen. Deze verbinding loopt van + container naar container over het compose-netwerk, dus de poortmapping naar de + host en de doorstuurregel in de router blijven ongetest. + + Geeft altijd een dict met een `state` terug, nooit een uitzondering: + + - `off` er is geen certificaat gekozen, dus poort 50022 luistert + niet. Geen storing maar de normale begintoestand; + - `ok` handdruk afgemaakt en het gekozen certificaat kwam terug; + - `wrong-certificate` handdruk afgemaakt, maar met een ander certificaat; + - `failed` geen verbinding of geen handdruk, met de reden erbij. + """ + if not active: + return {"at": int(time.time()), "state": "off"} + + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + # In deze volgorde: verify_mode op CERT_NONE zetten terwijl check_hostname + # nog aanstaat is een ValueError. + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + + # De naam die een wallet ook zou sturen. Er is maar één server-blok, dus + # nginx kiest hier niets op; het houdt de meting alleen gelijk aan de echte + # situatie. Leeg mag niet, dus dan liever niets. + server_name = active.get("domain") or None + + start = time.monotonic() + try: + with socket.create_connection((TLS_HOST, TLS_PORT), timeout=10) as raw: + with context.wrap_socket(raw, server_hostname=server_name) as tls: + presented = tls.getpeercert(binary_form=True) + elapsed = int(round((time.monotonic() - start) * 1000.0)) + except Exception as e: # noqa: BLE001 - elke fout betekent hetzelfde voor de + # pagina: de voordeur antwoordde niet. + return {"at": int(time.time()), "state": "failed", "error": str(e)} + + result = {"at": int(time.time()), "ms": elapsed} + try: + expected = read_pem(active["cert"]) + except Exception: # noqa: BLE001 - een onleesbaar bestand is al elders + # gemeld; hier zou het alleen een tweede, vagere melding opleveren. + expected = None + + if expected is not None and presented is not None and presented != expected: + result["state"] = "wrong-certificate" + return result + result["state"] = "ok" + return result + + # --- Het activiteitenlog ---------------------------------------------------- # nginx schrijft per stream-sessie één regel, en pas bij het sluiten. Een wallet # houdt zijn verbinding uren open, dus een lopende sessie staat er nog niet in. @@ -721,6 +832,44 @@ def read_stream_log(cutoff): return out +def suppress_self_probes(entries, probes): + """Haalt de sessieregels van de eigen zelfcontrole uit het log. + + Nodig omdat die controle een echte verbinding met poort 50022 is: nginx ziet + geen verschil met een wallet en schrijft dus een regel. Die regel komt als + `probe` binnen, want er wordt niets doorgegeven, en zonder deze filter zou het + activiteitenlog voor het grootste deel uit onze eigen controles bestaan. + + Er is niets in de logregel waaraan onze verbinding te herkennen is, en dat is + met opzet: er staat geen client-adres in en dat blijft zo. Wat er wél is, is + het moment. De agent onthoudt wanneer hij zelf verbond, en dat wordt hier + tegen de eindtijd van de sessie gelegd. + + Twee dingen om te weten voordat je hierop leunt: + + - **de speling is twee seconden**, want de logregel draagt de eindtijd van de + sessie en de agent noteert het moment vlak erna; + - **er kan een échte scan verdwijnen** die toevallig in datzelfde venster + eindigde. Per onthouden controle wordt hoogstens één regel weggelaten, dus + het blijft bij die ene. Dat is de prijs, en hij is lager dan een log dat + elke vijf minuten over onszelf gaat. + """ + rest = list(probes) + out = [] + for e in entries: + if e.get("event") == "probe" and not e.get("bytes_in") and not e.get("bytes_out"): + match = None + for t in rest: + if abs(e.get("t", 0) - t) <= 2: + match = t + break + if match is not None: + rest.remove(match) + continue + out.append(e) + return out + + def read_open_sessions(): """Hoeveel wallet-verbindingen er nu openstaan, of None. @@ -800,20 +949,52 @@ def build_status(events): "note": "the Electrum server did not answer", }) + # De zelfcontrole op de eigen TLS-poort. Niet elke ronde; zie + # SELF_CHECK_INTERVAL voor waarom. + previous_self = (previous.get("tls") or {}).get("self_check") or {} + probes = [t for t in previous.get("self_probes", []) + if isinstance(t, int) and t > now - LOG_SECONDS] + + if reloaded: + # Nu meten zou vrijwel zeker "wrong-certificate" opleveren en dat zou + # onwaar zijn: nginx herlaadt binnen tien seconden en tot die tijd staat + # het vorige certificaat er nog. Een ronde overslaan en het dan meten + # geeft juist de interessantste meting die er is, want een herlading die + # niet aankomt is precies wat deze controle moet vinden. + self_check = {"state": "pending"} + elif now - int(previous_self.get("at") or 0) >= SELF_CHECK_INTERVAL: + self_check = probe_self(active) + if self_check.get("state") != "off": + # Ook bij een mislukking: een geweigerde handdruk is voor nginx nog + # steeds een sessie en levert dus een regel op. Alleen als er niet + # verbonden is, is er niets weg te filteren. + probes.append(self_check["at"]) + was = previous_self.get("state") + if was and was != self_check["state"]: + events.append({ + "t": now, + "event": "selfcheck", + "note": SELF_CHECK_NOTES.get( + self_check["state"], "self-check: " + self_check["state"]), + }) + else: + self_check = previous_self + history = [h for h in previous.get("history", []) if isinstance(h, dict) and h.get("t", 0) > now - HISTORY_SECONDS] if "response_ms" in backend: history.append({"t": now, "response_ms": backend["response_ms"]}) cutoff = now - LOG_SECONDS - entries = read_stream_log(cutoff) + entries = suppress_self_probes(read_stream_log(cutoff), probes) # De eigen gebeurtenissen van de agent uit de vorige ronde weer meenemen; de # sessieregels komen uit de nginx-log en staan daar al. Vergeet je er één in # deze opsomming, dan verdwijnt dat soort regel na één minuut weer van de # pagina, en dat is precies wat een connect-regel onbruikbaar maakt. entries.extend(e for e in previous.get("log", []) if isinstance(e, dict) and e.get("t", 0) > cutoff - and e.get("event") in ("start", "reload", "refused", "connect")) + and e.get("event") in ("start", "reload", "refused", "connect", + "selfcheck")) entries.extend(events) # Dubbele meldingen dat de backend niet antwoordt zijn ruis; één per ronde # is genoeg en de rest zegt hetzelfde. @@ -832,10 +1013,14 @@ def build_status(events): "interval_seconds": INTERVAL, # open_connections blijft weg als het niet bekend is. Zie # read_open_sessions: nul beweren is hier erger dan zwijgen. - "tls": {"port": TLS_PORT}, + "tls": {"port": TLS_PORT, "self_check": self_check}, "backend": backend, "history": history, "log": unique[-200:], + # Wanneer de agent zelf verbond. Boekhouding voor suppress_self_probes en + # niet iets om te tonen; het venster is even lang als dat van het log, + # want daar wordt het tegen gelegd. + "self_probes": probes[-500:], "certificates": [ { "id": c["id"], diff --git a/whatsnext-electrum-gate/docker-compose.yml b/whatsnext-electrum-gate/docker-compose.yml index 4a12bd4..4ca8003 100644 --- a/whatsnext-electrum-gate/docker-compose.yml +++ b/whatsnext-electrum-gate/docker-compose.yml @@ -28,6 +28,16 @@ services: GATE_ELECTRUM_HOST: ${APP_ELECTRS_NODE_IP} GATE_ELECTRUM_PORT: ${APP_ELECTRS_NODE_PORT} GATE_TLS_PORT: "50022" + # De zelfcontrole: de agent verbindt met de TLS-poort van de server + # hieronder en maakt de handdruk af. De servicenaam resolveert binnen het + # compose-netwerk, en dat is dezelfde weg die nginx andersom gebruikt met + # zijn proxy_pass naar 'agent'. Niet localhost: dat is een andere container. + GATE_TLS_HOST: server + # Niet elke ronde meten, want elke meting is voor nginx een gewone sessie + # en levert dus een regel in het activiteitenlog op. Vijf minuten is vaak + # genoeg voor een waarde die zelden verandert; na een certificaatwissel + # wordt er sowieso gemeten. + GATE_SELF_CHECK_INTERVAL: "300" GATE_INTERVAL: "60" GATE_API_PORT: "8000" GATE_STATE_DIR: /var/lib/gate diff --git a/whatsnext-electrum-gate/umbrel-app.yml b/whatsnext-electrum-gate/umbrel-app.yml index 89b9020..1bf7f7c 100644 --- a/whatsnext-electrum-gate/umbrel-app.yml +++ b/whatsnext-electrum-gate/umbrel-app.yml @@ -9,7 +9,7 @@ manifestVersion: 1 id: whatsnext-electrum-gate category: bitcoin name: Electrum Gate -version: "0.0.15" +version: "0.0.16" tagline: Your own node from anywhere, without waiting for Tor description: >- The privacy win is already yours: you run the Electrum server. A public one gets asked for @@ -56,18 +56,32 @@ description: >- # staan. In 0.0.9 stond er daardoor drie keer "Earlier releases" en twee keer # dezelfde 0.0.4-regel. De toets let er nu op. releaseNotes: >- - The app store this app comes from has moved to a new address, and every link in this listing now - points there. The app itself is unchanged. If you added the store at its old address, remove it in - umbrelOS and add the new one. + The app now checks its own front door. Until this release it watched whether your Electrum server was + answering, but never whether the gateway itself was, which is the one thing it exists to do. - The move is because the store now carries a second app, so naming it after this one no longer made - sense. + Every few minutes it opens a connection to its own TLS port, completes the handshake, and compares the + certificate it gets back with the one you picked. That last part catches something nothing else did: a + certificate change that nginx never actually applied, which looks fine from the outside and fails at + your wallet. + + + For now the result appears as a line in the activity log when it changes. A tile on the dashboard + follows in a later release. The check runs every few minutes rather than every minute, and its own + connections are kept out of the activity log, so the log stays about your wallets. + + + What it does not tell you: whether your gateway can be reached from outside. This check runs inside the + app, so a router that stopped forwarding the port still reads as fine here. Earlier releases: + 0.0.15 moved the app store this app comes from to a new address, and pointed every link in this listing + there. If you added the store at its old address, remove it in umbrelOS and add the new one. + + 0.0.14 stopped calling a scan from the internet a refusal. Those now read "probe", in grey, and red is kept for a session that did carry traffic and then broke. The panels also got more room between them.