From 02513b861f6fbb462045ba411c6bec0a2cae7e93 Mon Sep 17 00:00:00 2001 From: Sergio Date: Thu, 10 Sep 2026 01:57:07 +0000 Subject: [PATCH] =?UTF-8?q?atuq:=20`sct`=20v1=20=E2=80=94=20el=20navegador?= =?UTF-8?q?=20avisa=20cuando=20un=20sitio=20ya=20estable=20ejecuta=20c?= =?UTF-8?q?=C3=B3digo=20que=20nadie=20vio=20nunca?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La unidad 6 del SDD 26, que es el diferenciador del §6 que no tiene ningún navegador. Cadena entera, medida de punta a punta con un servidor HTTP real y seis cargas de página: servidor HTTP → filterResponseData → connectNative → /usr/lib/mozilla/native-messaging-hosts/ → puriy-costura --state → puriy-sct (TOFU + bitácora) carga 1-3 (mismo script) fase=learning eventos=0 insignia vacía carga 4 (mismo script) fase=stable eventos=0 insignia vacía carga 5 (mismo script, estable) fase=stable eventos=0 insignia vacía ← control carga 6 (script CAMBIADO) fase=stable eventos=1 insignia "1" EVENTO ext:…/app.js 0ff4771fb797→f7ccb9fedfbd +27B La extensión NO hashea ni guarda nada: ve bytes y pregunta. El registro es `puriy-sct`, del otro lado del cable — duplicarlo en JS habría sido un segundo registro que se desalinea del primero, y el primero es el que está certificado sin red. CINCO COSAS QUE SE MIDIERON EN VEZ DE SUPONERSE, y las cinco fallan calladas: 1. el manifiesto va en `/usr/lib/mozilla/native-messaging-hosts/` y NO en el appdir: la ruta sale de `XRESysNativeManifests`, un `/usr/lib/mozilla` COMPILADO dentro de Gecko; 2. **el manifiesto no puede llevar argumentos** — `NativeMessaging.sys.mjs` hace `command = manifest.path` y los únicos argumentos son `[ruta-del-manifiesto, id]`. Y sin `--state` el host corre en MEMORIA: cada arranque volvería a «aprendiendo» y nada alertaría nunca. De ahí el lanzador `bin/puriy-costura-host`, que además decide la ruta del estado — dónde vive el estado de un usuario es layout del FHS, o sea asunto de la distro y no del crate; 3. `filterResponseData` y el permiso `webRequestFilterResponse` SÍ están en nuestro `omni.ja` (se le preguntó al artefacto, no a la documentación de Mozilla); 4. los scripts `inline` NO se ven por esta vía —`filterResponseData` entrega el cuerpo de una PETICIÓN— y para v1 alcanza: el ataque que sct nombra es la sustitución en el CDN; 5. ⚠ **una carga de página puede producir dos peticiones del mismo documento, y una llega con `tabId = -1`.** La primera versión agrupaba por `(tabId, documento)` y contaba esa carga como DOS visitas. No es cosmético: inflar las visitas estabiliza el origen ANTES de conocer su código real, y entonces alerta por churn legítimo — el falso positivo que la spec de puriy-sct pide evitar por encima de todo. Ahora agrupa por documento (dos pestañas con la misma url cuentan UNA: es el error seguro, tarda más en proteger y no alerta de más) y el guardián VIGILA el invariante «una carga, una visita», así que si vuelve, falla ruidoso. DOS CORRECCIONES DEL PROPIO §6.1, que decía «consulta al testigo antes de dejarla pasar»: el cable del testigo es un POST con postcard, así que un JS no puede ser su cliente; y v1 OBSERVA Y AVISA, no bloquea — es lo que puriy-sct dice de su propia v1, y poner un viaje entre procesos en el camino crítico de cada script de cada página no es «más seguro», es un navegador que nadie usa. EL AVISO SE MIDE, NO SE SUPONE: la extensión relee la insignia con `getBadgeText` después de ponerla, y el guardián exige vacía en las cinco cargas sin novedad y "1" en la del script cambiado. Es la única parte de la cadena que el usuario ve; dejarla en «se llamó a la API» era dejar sin medir el final. CONTROL NEGATIVO: `--negative-control` borra el manifiesto y exige que NO haya veredicto — o sea que el veredicto de la corrida positiva viene del host y no de la extensión inventándolo. Y el aviso pasivo es decisión, no falta de tiempo: insignia y tooltip, no modal. Un modal por cada despliegue de un sitio entrena a la gente a cerrarlo sin leer, y entonces el que importa también se cierra. Además: `rebrand.py` instala y CRUZA los manifiestos nativos (que el `path` exista y sea ejecutable dentro del artefacto, y que sus `allowed_extensions` sean extensiones que de verdad empaquetamos), y de paso se corrige el comentario del §4.ter que repetía la afirmación falsa sobre quién instala las extensiones — lo mide `scripts/test-atuq-instalacion.py`: instala el escaneo de la carpeta. `runtime = [..., "puriy-costura"]` en atuq.toml: sin eso la imagen llevaría manifiesto y extensión y el host NO estaría, y la función se apagaría sola sin una línea de error. `yupana radio` confirma que llega a las cuatro imágenes de escritorio. ⚠ Límite escrito en `fondo.js`, en el `lib.rs` del host y en los dos LEEME: se hashea el TEXTO ya decodificado que entrega la extensión, no los bytes que sirvió el servidor. Vale para comparar dos cargas nuestras; NO es comparable con el hash que publique un tercero sobre los bytes servidos, ni con el de la v2, que engancha el script loader y ve los bytes reales. Y una segunda cosa que el guardián encontró y que es del PRODUCTO, no del test: **la página que abre el navegador al lanzarse puede no ser observada** — compite con la inicialización de la extensión, y la carrera se gana o se pierde según la corrida. En uso real sólo afecta a esa primera página (después la extensión ya está escuchando). Por eso las aserciones van sobre la SECUENCIA OBSERVADA y no sobre un calendario: se exige que ninguna carga se observe dos veces, que la única que puede faltar sea la del arranque, y que la secuencia aprender→estabilizar→no-alertar→alertar sea la correcta. Sin regresiones: `test-atuq-politica.py` («guardianes: todos correctos», y sus cinco roturas siguen matando el build con tres extensiones) y `test-atuq-inicio.py` (la home y la pestaña nueva siguen siendo las nuestras) pasan sobre el artefacto final `b3:d3ced586`. --- docs/26-atuq-envoltorio-gecko.md | 44 ++- recipes/atuq.toml | 25 +- recipes/atuq/README.md | 69 +++- recipes/atuq/bin/puriy-costura-host | 28 ++ recipes/atuq/distribution/policies.json | 4 + recipes/atuq/extensions/sct/fondo.js | 255 +++++++++++++ recipes/atuq/extensions/sct/manifest.json | 30 ++ .../atuq/native-messaging/puriy_costura.json | 7 + recipes/atuq/tools/rebrand.py | 90 ++++- scripts/test-atuq-sct.py | 341 ++++++++++++++++++ 10 files changed, 882 insertions(+), 11 deletions(-) create mode 100755 recipes/atuq/bin/puriy-costura-host create mode 100644 recipes/atuq/extensions/sct/fondo.js create mode 100644 recipes/atuq/extensions/sct/manifest.json create mode 100644 recipes/atuq/native-messaging/puriy_costura.json create mode 100755 scripts/test-atuq-sct.py diff --git a/docs/26-atuq-envoltorio-gecko.md b/docs/26-atuq-envoltorio-gecko.md index ea77638a..fc5fbcb2 100644 --- a/docs/26-atuq-envoltorio-gecko.md +++ b/docs/26-atuq-envoltorio-gecko.md @@ -506,10 +506,46 @@ no lo tiene nadie: ni Zen, ni Brave, ni Tor Browser. En tawasuyu ya está escrito y —dice su README— la lógica es **agnóstica del transporte** y está certificada sin red; `puriy-sct-testigo` es sólo el cable HTTP. -- **v1, sin tocar C++:** extensión con `webRequest` bloqueante que hashea cada respuesta de script y - consulta al testigo antes de dejarla pasar. +- **v1 — HECHA 2026-09-10, y con una corrección de este propio párrafo.** La extensión intercepta el + cuerpo de cada `sct +""" + + +def fatal(msg): + print(f"✗ {msg}", file=sys.stderr) + raise SystemExit(1) + + +def artefacto(receta, nombre, escotilla=None): + """El artefacto VIGENTE, por `takana hash` y nunca por glob. + + ⚠ La escotilla (`ATUQ_DIR`) grita: el corpus es compartido y otro frente puede re-hashear + `firefox`, dejando el atuq vigente sin sellar durante las horas de su build. Un resultado + obtenido con la escotilla NO se cita como «el atuq de hoy pasa». + """ + forzado = os.environ.get(escotilla) if escotilla else None + if forzado: + if not os.path.isdir(forzado): + fatal(f"{escotilla}={forzado} no existe") + print(f"⚠ {escotilla}: se mide {os.path.basename(forzado)}, que NO es necesariamente el vigente") + return forzado + r = subprocess.run([HAMMER, "--store", STORE, "hash", f"recipes/{receta}.toml"], + cwd=ROOT, capture_output=True, text=True) + if r.returncode != 0: + fatal(f"no pude hashear {receta}: {r.stderr.strip()}") + h = r.stdout.strip().splitlines()[-1].removeprefix("b3:") + d = os.path.join(STORE, f"{h}-{nombre}") + if not os.path.isdir(d): + fatal(f"{nombre} b3:{h[:8]} no está sellado — construilo antes") + return d + + +class Silencioso(http.server.SimpleHTTPRequestHandler): + """Sirve la página y el script desde memoria. El cuerpo del script lo cambia el test entre + cargas, que es justo lo que `sct` tiene que notar.""" + + cuerpo_script = SCRIPT_ORIGINAL + + def log_message(self, *a): + pass + + def do_GET(self): + if self.path.startswith("/app.js"): + datos, tipo = Silencioso.cuerpo_script.encode(), "application/javascript" + else: + datos, tipo = PAGINA.encode(), "text/html" + self.send_response(200) + self.send_header("Content-Type", tipo) + self.send_header("Content-Length", str(len(datos))) + # Sin caché: si el navegador reusa el script de su caché, la carga 6 no vería el cambio y la + # prueba diría «no alerta» por una razón que no tiene nada que ver con sct. + self.send_header("Cache-Control", "no-store, no-cache, must-revalidate") + self.end_headers() + self.wfile.write(datos) + + +def puerto_libre(): + s = socket.socket() + s.bind(("127.0.0.1", 0)) + p = s.getsockname()[1] + s.close() + return p + + +def correr(atuq, costura, salida, capa, url, negativo): + """Una carga de página: abre atuq headless con la URL y devuelve las líneas `SCT`.""" + borrar = f"rm -f {MANIFIESTO}" if negativo else "true" + guion = f""" + mkdir -p /root /salida/hogar + [ -s /etc/machine-id ] || tr -d - < /proc/sys/kernel/random/uuid > /etc/machine-id + {borrar} + export HOME=/salida/hogar + timeout 40 /usr/bin/atuq --profile /salida/perfil --no-remote --headless '{url}' \ + > /salida/consola.log 2>&1 + exit 0 + """ + subprocess.run([ + "bwrap", + "--overlay-src", ALPINE, "--overlay-src", RFS, + "--overlay-src", atuq, "--overlay-src", costura, "--overlay-src", capa, + "--tmp-overlay", "/", + "--dev-bind", "/dev", "/dev", "--proc", "/proc", "--bind", "/sys", "/sys", + "--tmpfs", "/tmp", "--tmpfs", "/run", "--unshare-pid", + "--bind", salida, "/salida", + "--uid", "0", "--gid", "0", + "--setenv", "PATH", "/usr/bin:/bin:/usr/sbin:/sbin", + "--setenv", "XDG_RUNTIME_DIR", "/run", + "--setenv", "XDG_DATA_DIRS", "/usr/share", "--setenv", "MOZ_HEADLESS", "1", + "--setenv", "MOZ_DISABLE_CONTENT_SANDBOX", "1", + "--setenv", "MOZ_DISABLE_GMP_SANDBOX", "1", + "--setenv", "MOZ_DISABLE_RDD_SANDBOX", "1", + "--setenv", "MOZ_DISABLE_SOCKET_PROCESS_SANDBOX", "1", + "--setenv", "MOZ_DISABLE_UTILITY_SANDBOX", "1", + "sh", "-c", guion, + ], check=False) + log = open(os.path.join(salida, "consola.log"), errors="replace").read() + return [l[len("SCT "):] for l in log.splitlines() if l.startswith("SCT ")], log + + +def preguntarle_al_host(costura, estado): + """Le pregunta al binario del host, DESDE AFUERA de la jaula, por el estado del origen. Es la + evidencia de que lo que el navegador aprendió quedó en disco y no en la memoria de un proceso.""" + binario = os.path.join(costura, "usr/bin/puriy-costura") + peticion = json.dumps({"verb": "sct.state", "url": "http://127.0.0.1/"}).encode() + entrada = struct.pack(" 1: + fatal(f"carga {i}: produjo {n} tandas y cada tanda es una VISITA para el TOFU" + f" — contar visitas de más estabiliza el origen antes de tiempo: {tandas_de(c)}") + + vistas = [(i + 1, veredicto(c), eventos(c), insignia(c)) + for i, c in enumerate(cargas) if veredicto(c)] + perdidas = [i + 1 for i, c in enumerate(cargas) if not veredicto(c)] + if perdidas: + print(f"\n(cargas sin observar: {perdidas} — la del arranque compite con la" + " inicialización de la extensión)") + if perdidas and perdidas != [1]: + fatal(f"se perdieron cargas que no son la del arranque: {perdidas}") + if len(vistas) < 5: + fatal(f"sólo {len(vistas)} de 6 cargas se observaron: con menos de cinco no se puede" + " afirmar la secuencia aprender→estabilizar→alertar") + if vistas[-1][0] != 6: + fatal("la carga del script CAMBIADO (la 6ª) no se observó — sin eso la prueba no" + " concluye nada") + + # Una carga observada = una visita: el número de visita tiene que seguir a la posición en la + # secuencia, sin saltos. Un salto es la señal de la tanda duplicada. + for pos, (nro, v, _, _) in enumerate(vistas, start=1): + if v.get("visitas") != str(pos): + fatal(f"carga {nro}: es la observación nº {pos} y el host contó" + f" visitas={v.get('visitas')} — una carga observada tiene que ser una visita") + + for pos in (1, 2, 3): + nro, v, ev, ins = vistas[pos - 1] + if v.get("fase") != "learning": + fatal(f"carga {nro} (observación {pos}): se esperaba fase=learning y salió {v}") + if ev: + fatal(f"carga {nro}: alertó mientras el origen APRENDÍA — eso es un falso positivo") + nro4, v4, ev4, _ = vistas[3] + if v4.get("fase") != "stable": + fatal(f"carga {nro4} (observación 4): se esperaba fase=stable (racha 3) y salió {v4}") + if ev4: + fatal(f"carga {nro4}: alertó en la observación que ESTABILIZA el origen: {ev4}") + + # La anterior a la del cambio: mismo script, ya estable ⇒ CERO eventos. Es el control que hace + # que la última signifique algo: sin él, un detector que alertara en cada carga se vería igual. + nroA, vA, evA, insA = vistas[-2] + if evA: + fatal(f"carga {nroA}: alertó con el MISMO script ya estable — alertaría en cada carga, y" + " un detector que grita todos los días no se mira más") + if vA.get("fase") != "stable": + fatal(f"carga {nroA}: se esperaba fase=stable antes del cambio y salió {vA}") + if insA != '""': + fatal(f"carga {nroA}: la insignia quedó en {insA} sin que hubiera novedad") + + # Y la del script cambiado: UN evento, que nombra el script, y la insignia que lo dice. + nroZ, vZ, evZ, insZ = vistas[-1] + if len(evZ) != 1: + fatal(f"carga {nroZ}: con el script cambiado se esperaba UN evento y hubo" + f" {len(evZ)}: {evZ}") + if "app.js" not in evZ[0]: + fatal(f"carga {nroZ}: el evento no nombra el script: {evZ[0]}") + # ── LA INSIGNIA, QUE ES LO ÚNICO QUE EL USUARIO VE DE TODA ESTA CADENA ──────────────── + # La extensión la RELEE con `getBadgeText` después de ponerla, así que esto no es «se llamó a + # la API»: es lo que quedó puesto. Sin medirla, el aviso sería la única pieza sin verificar. + if insZ != '"1"': + fatal(f"carga {nroZ}: la insignia quedó en {insZ}, se esperaba \"1\" — el evento no" + " llegó a la única parte que el usuario ve") + + estado = os.path.join(salida, "hogar", ".local", "state", "puriy-costura") + ficheros = sorted(os.listdir(estado)) if os.path.isdir(estado) else [] + if "registro.postcard" not in ficheros or "bitacora.postcard" not in ficheros: + fatal(f"el host no dejó su estado en disco (hay {ficheros}) — sin eso, cada arranque del" + " navegador volvería a «aprendiendo» y nada alertaría nunca") + print(f"\nestado en disco: {ficheros}") + + print("\n✓ la cadena entera anda: el navegador ejercita `filterResponseData` →" + " native messaging → puriy-costura → puriy-sct") + print("✓ una carga observada = una visita (el invariante que rompía agrupar por tabId)") + print("✓ y la insignia dice lo que tiene que decir: vacía en las cinco, «1» en la sexta") + print("✓ aprende tres cargas, se estabiliza en la cuarta, NO alerta en la quinta con el" + " mismo código, y alerta en la sexta con el código cambiado") + print("✓ y el aprendizaje sobrevive a cerrar el navegador seis veces: el estado está en disco") + return 0 + finally: + servidor.shutdown() + shutil.rmtree(tmp, ignore_errors=True) + + +sys.exit(main())