Files
takana/recipes/atuq/extensions/sct/fondo.js
T
Sergio 02513b861f atuq: sct v1 — el navegador avisa cuando un sitio ya estable ejecuta código que nadie vio nunca
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`.
2026-09-10 01:57:07 +00:00

256 lines
12 KiB
JavaScript

"use strict";
// sct — transparencia de scripts (SDD 26 §6.1), v1.
//
// Lo que hace: por cada carga de página, junta los `<script src>` que se ejecutaron, y le pasa al
// host nativo (`puriy-costura`) el origen y el TEXTO de cada uno. El host hashea con BLAKE3, aprende
// el conjunto estable de cada sitio (TOFU) y contesta si ese sitio ya era estable y si ejecutó algo
// que nadie vio nunca. Acá NO se hashea nada y NO se guarda ningún registro: quien sabe es
// `puriy-sct`, del otro lado del cable. Duplicarlo en JS sería un segundo registro que se desalinea
// del primero, y el primero es el que está certificado.
//
// ── V1 OBSERVA, NO BLOQUEA — Y ESO NO ES UNA CONCESIÓN ────────────────────────────────────────
// El §6.1 escribió «consulta al testigo antes de dejarla pasar». La forma fuerte es la v2, con el
// gancho en el script loader de Gecko. Acá se observa y se AVISA, por dos razones y las dos son del
// diseño, no del apuro:
// 1. `puriy-sct` es explícito: su v1 «ALERTA (no bloquea)». Un bloqueo necesita que exista antes
// la acción del usuario (aceptar/pinnear un cambio), que es su M1.
// 2. Bloquear cada script hasta que vuelva un mensaje del proceso nativo pondría un viaje
// ida-y-vuelta entre procesos en el camino crítico de CADA script de CADA página. Eso no es
// «más seguro»: es un navegador que nadie usa, y una función que nadie usa no protege a nadie.
//
// ── LO QUE ESTA VÍA NO PUEDE VER, DICHO ANTES DE QUE ALGUIEN LO SUPONGA ───────────────────────
// `filterResponseData` entrega el cuerpo de una PETICIÓN. Los scripts **inline** no son una
// petición: viajan dentro del HTML del documento, así que por esta vía son invisibles. Para v1 está
// bien y no es un agujero disimulado: el ataque que `sct` nombra —sustitución en un CDN, supply
// chain comprometido, script dirigido a un usuario— es exactamente el caso `<script src>`. Los
// inline son código propio de la página, y los ve la v2.
const HOST = "puriy_costura";
// Tope de lo que se guarda por script. Un cuerpo más grande NO se manda a medias: hashear un texto
// truncado daría un hash que no corresponde a nada, y eso no produce «menos detección» sino ALERTAS
// FALSAS la próxima vez que el mismo script llegue entero. Se descarta y se dice.
const TOPE_CUERPO = 4 * 1024 * 1024;
// Cuánto se espera, tras el último script de una página, antes de mandarle la tanda al host. Los
// scripts no llegan con el documento —y los inyectados dinámicamente llegan bastante después—, así
// que no hay un «fin de carga» que sirva de disparador. Un rebote es honesto: junta lo que llegó.
const REBOTE_MS = 1500;
let puerto = null;
const tandas = new Map(); // clave `${tabId}|${documento}` → { origen, scripts[], reloj }
function di(s) {
// El veredicto sale por el stdout del proceso cuando `browser.dom.window.dump.enabled` está
// prendida — que es cómo lo miden los guardianes de `scripts/test-atuq-*`. En producción está
// apagada y esto no hace nada.
try { dump("SCT " + s + "\n"); } catch (e) {}
}
function conectar() {
try {
puerto = browser.runtime.connectNative(HOST);
puerto.onMessage.addListener(alRecibir);
puerto.onDisconnect.addListener(p => {
// El host es un proceso largo; si se cae, la función tiene que poder volver. Un `puerto` que
// queda muerto para siempre apaga `sct` en silencio hasta que alguien reinicie el navegador.
const causa = p.error ? p.error.message : "sin error";
di("DESCONECTADO " + causa);
puerto = null;
});
di("CONECTADO " + HOST);
} catch (e) {
// `connectNative` tira si no hay permiso; que no haya manifiesto se ve en `onDisconnect`.
di("SIN HOST " + e.message);
puerto = null;
}
}
function enviar(msg) {
if (!puerto) { conectar(); }
if (!puerto) { return false; }
try {
puerto.postMessage(msg);
return true;
} catch (e) {
di("ENVÍO FALLÓ " + e.message);
puerto = null;
return false;
}
}
function alRecibir(r) {
if (!r || r.ok !== true) {
di("RECHAZO " + (r && r.error ? r.error : "sin error"));
return;
}
if (r.verb !== "sct.observe") { return; }
const eventos = r.events || [];
di("VEREDICTO " + r.origin + " fase=" + r.phase + " visitas=" + r.visits +
" recursos=" + r.resources + " eventos=" + eventos.length);
for (const e of eventos) {
di("EVENTO " + r.origin + " " + e.resource + " " + e.old.slice(0, 12) + "→" +
e.new.slice(0, 12) + " " + (e.size_delta >= 0 ? "+" : "") + e.size_delta + "B");
}
// La pestaña sale del `id` que se mandó y el host DEVUELVE tal cual — para eso existe ese campo
// del protocolo. Inventar un `tabId` en la respuesta habría sido agregarle al host un concepto del
// navegador que no le toca conocer.
pintar(typeof r.id === "number" ? r.id : -1, r, eventos);
}
// El aviso es PASIVO: una insignia en la barra, no un 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.
function pintar(tabId, r, eventos) {
if (typeof tabId !== "number" || tabId < 0) { return; }
try {
if (eventos.length) {
browser.browserAction.setBadgeText({ tabId, text: String(eventos.length) });
browser.browserAction.setBadgeBackgroundColor({ tabId, color: "#c15728" });
browser.browserAction.setTitle({
tabId,
title: "atuq: " + eventos.length + " script(s) que este sitio nunca había ejecutado:\n" +
eventos.map(e => "· " + e.resource).join("\n"),
});
} else {
browser.browserAction.setBadgeText({ tabId, text: "" });
browser.browserAction.setTitle({
tabId,
title: "atuq · transparencia de scripts\n" + r.origin + " — " + r.phase +
" (" + r.visits + " visita(s), " + r.resources + " recurso(s))",
});
}
// Y se RELEE lo que quedó puesto. Poner una insignia y suponer que se puso es la forma de que el
// aviso —lo único que el usuario llega a ver de todo esto— sea la pieza sin medir de la cadena.
browser.browserAction.getBadgeText({ tabId }).then(
t => di("INSIGNIA tab=" + tabId + " texto=" + JSON.stringify(t)),
e => di("INSIGNIA NO SE PUDO LEER " + e.message));
} catch (e) { di("INSIGNIA FALLÓ " + e.message); }
}
// ⚠ LA CLAVE ES EL DOCUMENTO, **SIN** EL `tabId` — Y ESO ES MEDIDO, NO ESTILO (2026-09-10).
// Con el `tabId` adentro, `scripts/test-atuq-sct.py` mostró DOS tandas para una sola carga de página:
//
// TANDA 1|http://…/pagina.html scripts=1
// TANDA -1|http://…/pagina.html scripts=1 ← misma página, petición SIN pestaña
//
// Hay peticiones del mismo documento que llegan con `tabId = -1` (no pertenecen a ninguna pestaña), y
// cada tanda es una VISITA para el TOFU. El efecto no es cosmético: **inflar las visitas estabiliza
// el origen antes de tiempo**, y un origen estabilizado antes de conocer su código real alerta por
// churn legítimo — o sea, el falso positivo que la spec de `puriy-sct` pide evitar por encima de todo.
//
// Agrupando por documento, dos pestañas con la MISMA url caen en una tanda: eso CUENTA DE MENOS, que
// es el error seguro (tarda más en proteger, no alerta de más). El `tabId` sigue haciendo falta para
// la insignia, así que se guarda el primero que no sea -1.
function clave(d) {
return d.documentUrl || d.originUrl || "";
}
function anotar(d, texto) {
const documento = d.documentUrl || d.originUrl;
if (!documento) { return; } // sin documento no hay origen que registrar
const k = clave(d);
let t = tandas.get(k);
if (!t) {
t = { documento, tabId: -1, scripts: [], reloj: null };
tandas.set(k, t);
}
if (t.tabId < 0 && typeof d.tabId === "number" && d.tabId >= 0) { t.tabId = d.tabId; }
t.scripts.push({ url: d.url, body: texto, module: false });
if (t.reloj) { clearTimeout(t.reloj); }
t.reloj = setTimeout(() => vaciar(k), REBOTE_MS);
}
function vaciar(k) {
const t = tandas.get(k);
tandas.delete(k);
if (!t || !t.scripts.length) { return; }
// La TANDA se anuncia con su clave y su tamaño. No es ruido de depuración: una MISMA carga de
// página puede producir más de una tanda —el rebote las corta si un script llega tarde—, y cada
// tanda es una VISITA para el TOFU. Contar visitas de más hace que un origen se estabilice antes
// de tiempo, o sea que alerte por churn legítimo. Sin esta línea, eso se ve como «el contador va
// raro» y no como qué lo causó.
di("TANDA " + k + " scripts=" + t.scripts.length);
const msg = { verb: "sct.observe", url: t.documento, scripts: t.scripts };
// `id` es u64 del otro lado: una pestaña -1 (peticiones que no son de ninguna) no se manda, o el
// mensaje entero se rechazaría por un campo que sólo servía para pintar una insignia.
if (typeof t.tabId === "number" && t.tabId >= 0) { msg.id = t.tabId; }
enviar(msg);
}
// ── EL FILTRO ─────────────────────────────────────────────────────────────────────────────────
// ⚠ REGLA DE ORO DE ESTE BLOQUE: los bytes SIEMPRE pasan. Un filtro que se rompe y no escribe deja
// la respuesta colgada para siempre, y el síntoma no es «sct falló» sino «esta página no carga» —
// una extensión de seguridad que rompe la web se desinstala, y con ella la seguridad. Por eso cada
// camino tiene su `catch` y el `catch` desconecta el filtro, que es lo que deja fluir el resto.
browser.webRequest.onBeforeRequest.addListener(
d => {
let filtro;
try {
filtro = browser.webRequest.filterResponseData(d.requestId);
} catch (e) {
di("FILTRO NO DISPONIBLE " + e.message);
return {};
}
const trozos = [];
let total = 0;
let pasado = false;
filtro.ondata = e => {
try {
if (!pasado) {
total += e.data.byteLength;
if (total > TOPE_CUERPO) {
// Se deja de acumular, pero NO se deja de escribir: la página sigue igual.
pasado = true;
trozos.length = 0;
di("DEMASIADO GRANDE " + d.url + " (>" + TOPE_CUERPO + "B): no se observa");
} else {
trozos.push(new Uint8Array(e.data));
}
}
} catch (err) {
pasado = true;
}
filtro.write(e.data);
};
filtro.onstop = () => {
try {
if (!pasado && trozos.length) {
const todo = new Uint8Array(total);
let i = 0;
for (const t of trozos) { todo.set(t, i); i += t.length; }
// El texto YA DECODIFICADO es lo que se manda, y el host hashea su UTF-8. Está dicho allá
// qué implica: el hash vale para comparar dos cargas nuestras, no para compararse con el
// hash de los bytes que sirvió un tercero.
anotar(d, new TextDecoder("utf-8", { fatal: false }).decode(todo));
}
} catch (err) {
di("OBSERVAR FALLÓ " + err.message);
}
try { filtro.close(); } catch (err) { try { filtro.disconnect(); } catch (e2) {} }
};
filtro.onerror = () => {
// El navegador ya decidió (aborto, error de red): no hay nada que observar y nada que escribir.
trozos.length = 0;
};
return {};
},
{ urls: ["<all_urls>"], types: ["script"] },
["blocking"]
);
// Una pestaña que se cierra no deja una tanda pendiente esperando un rebote que ya no sirve.
browser.tabs.onRemoved.addListener(tabId => {
for (const [k, t] of tandas) {
if (t.tabId === tabId) {
if (t.reloj) { clearTimeout(t.reloj); }
tandas.delete(k);
}
}
});
conectar();
di("LISTA v1 observa y avisa; no bloquea");