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())