sergio/takana
> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…
⎇ 02513b861f6fbb462045ba411c6bec0a2cae7e93
recipes/atuq/extensions/sct/fondo.js
Historia
Quién tocó qué
crudo
atuq: `sct` v1 — el navegador avisa cuando un sitio ya estable ejecuta código que nadie vio nunca
1"use strict";
2// sct — transparencia de scripts (SDD 26 §6.1), v1.
3//
4// Lo que hace: por cada carga de página, junta los `<script src>` que se ejecutaron, y le pasa al
5// host nativo (`puriy-costura`) el origen y el TEXTO de cada uno. El host hashea con BLAKE3, aprende
6// el conjunto estable de cada sitio (TOFU) y contesta si ese sitio ya era estable y si ejecutó algo
7// que nadie vio nunca. Acá NO se hashea nada y NO se guarda ningún registro: quien sabe es
8// `puriy-sct`, del otro lado del cable. Duplicarlo en JS sería un segundo registro que se desalinea
9// del primero, y el primero es el que está certificado.
10//
11// ── V1 OBSERVA, NO BLOQUEA — Y ESO NO ES UNA CONCESIÓN ────────────────────────────────────────
12// El §6.1 escribió «consulta al testigo antes de dejarla pasar». La forma fuerte es la v2, con el
13// gancho en el script loader de Gecko. Acá se observa y se AVISA, por dos razones y las dos son del
14// diseño, no del apuro:
15// 1. `puriy-sct` es explícito: su v1 «ALERTA (no bloquea)». Un bloqueo necesita que exista antes
16// la acción del usuario (aceptar/pinnear un cambio), que es su M1.
17// 2. Bloquear cada script hasta que vuelva un mensaje del proceso nativo pondría un viaje
18// ida-y-vuelta entre procesos en el camino crítico de CADA script de CADA página. Eso no es
19// «más seguro»: es un navegador que nadie usa, y una función que nadie usa no protege a nadie.
20//
21// ── LO QUE ESTA VÍA NO PUEDE VER, DICHO ANTES DE QUE ALGUIEN LO SUPONGA ───────────────────────
22// `filterResponseData` entrega el cuerpo de una PETICIÓN. Los scripts **inline** no son una
23// petición: viajan dentro del HTML del documento, así que por esta vía son invisibles. Para v1 está
24// bien y no es un agujero disimulado: el ataque que `sct` nombra —sustitución en un CDN, supply
25// chain comprometido, script dirigido a un usuario— es exactamente el caso `<script src>`. Los
26// inline son código propio de la página, y los ve la v2.
27
28const HOST = "puriy_costura";
29
30// Tope de lo que se guarda por script. Un cuerpo más grande NO se manda a medias: hashear un texto
31// truncado daría un hash que no corresponde a nada, y eso no produce «menos detección» sino ALERTAS
32// FALSAS la próxima vez que el mismo script llegue entero. Se descarta y se dice.
33const TOPE_CUERPO = 4 * 1024 * 1024;
34
35// Cuánto se espera, tras el último script de una página, antes de mandarle la tanda al host. Los
36// scripts no llegan con el documento —y los inyectados dinámicamente llegan bastante después—, así
37// que no hay un «fin de carga» que sirva de disparador. Un rebote es honesto: junta lo que llegó.
38const REBOTE_MS = 1500;
39
40let puerto = null;
41const tandas = new Map(); // clave `${tabId}|${documento}` → { origen, scripts[], reloj }
42
43function di(s) {
44 // El veredicto sale por el stdout del proceso cuando `browser.dom.window.dump.enabled` está
45 // prendida — que es cómo lo miden los guardianes de `scripts/test-atuq-*`. En producción está
46 // apagada y esto no hace nada.
47 try { dump("SCT " + s + "\n"); } catch (e) {}
48}
49
50function conectar() {
51 try {
52 puerto = browser.runtime.connectNative(HOST);
53 puerto.onMessage.addListener(alRecibir);
54 puerto.onDisconnect.addListener(p => {
55 // El host es un proceso largo; si se cae, la función tiene que poder volver. Un `puerto` que
56 // queda muerto para siempre apaga `sct` en silencio hasta que alguien reinicie el navegador.
57 const causa = p.error ? p.error.message : "sin error";
58 di("DESCONECTADO " + causa);
59 puerto = null;
60 });
61 di("CONECTADO " + HOST);
62 } catch (e) {
63 // `connectNative` tira si no hay permiso; que no haya manifiesto se ve en `onDisconnect`.
64 di("SIN HOST " + e.message);
65 puerto = null;
66 }
67}
68
69function enviar(msg) {
70 if (!puerto) { conectar(); }
71 if (!puerto) { return false; }
72 try {
73 puerto.postMessage(msg);
74 return true;
75 } catch (e) {
76 di("ENVÍO FALLÓ " + e.message);
77 puerto = null;
78 return false;
79 }
80}
81
82function alRecibir(r) {
83 if (!r || r.ok !== true) {
84 di("RECHAZO " + (r && r.error ? r.error : "sin error"));
85 return;
86 }
87 if (r.verb !== "sct.observe") { return; }
88 const eventos = r.events || [];
89 di("VEREDICTO " + r.origin + " fase=" + r.phase + " visitas=" + r.visits +
90 " recursos=" + r.resources + " eventos=" + eventos.length);
91 for (const e of eventos) {
92 di("EVENTO " + r.origin + " " + e.resource + " " + e.old.slice(0, 12) + "→" +
93 e.new.slice(0, 12) + " " + (e.size_delta >= 0 ? "+" : "") + e.size_delta + "B");
94 }
95 // La pestaña sale del `id` que se mandó y el host DEVUELVE tal cual — para eso existe ese campo
96 // del protocolo. Inventar un `tabId` en la respuesta habría sido agregarle al host un concepto del
97 // navegador que no le toca conocer.
98 pintar(typeof r.id === "number" ? r.id : -1, r, eventos);
99}
100
101// El aviso es PASIVO: una insignia en la barra, no un modal. Un modal por cada despliegue de un
102// sitio entrena a la gente a cerrarlo sin leer, y entonces el que importa también se cierra.
103function pintar(tabId, r, eventos) {
104 if (typeof tabId !== "number" || tabId < 0) { return; }
105 try {
106 if (eventos.length) {
107 browser.browserAction.setBadgeText({ tabId, text: String(eventos.length) });
108 browser.browserAction.setBadgeBackgroundColor({ tabId, color: "#c15728" });
109 browser.browserAction.setTitle({
110 tabId,
111 title: "atuq: " + eventos.length + " script(s) que este sitio nunca había ejecutado:\n" +
112 eventos.map(e => "· " + e.resource).join("\n"),
113 });
114 } else {
115 browser.browserAction.setBadgeText({ tabId, text: "" });
116 browser.browserAction.setTitle({
117 tabId,
118 title: "atuq · transparencia de scripts\n" + r.origin + " — " + r.phase +
119 " (" + r.visits + " visita(s), " + r.resources + " recurso(s))",
120 });
121 }
122 // Y se RELEE lo que quedó puesto. Poner una insignia y suponer que se puso es la forma de que el
123 // aviso —lo único que el usuario llega a ver de todo esto— sea la pieza sin medir de la cadena.
124 browser.browserAction.getBadgeText({ tabId }).then(
125 t => di("INSIGNIA tab=" + tabId + " texto=" + JSON.stringify(t)),
126 e => di("INSIGNIA NO SE PUDO LEER " + e.message));
127 } catch (e) { di("INSIGNIA FALLÓ " + e.message); }
128}
129
130// ⚠ LA CLAVE ES EL DOCUMENTO, **SIN** EL `tabId` — Y ESO ES MEDIDO, NO ESTILO (2026-09-10).
131// Con el `tabId` adentro, `scripts/test-atuq-sct.py` mostró DOS tandas para una sola carga de página:
132//
133// TANDA 1|http://…/pagina.html scripts=1
134// TANDA -1|http://…/pagina.html scripts=1 ← misma página, petición SIN pestaña
135//
136// Hay peticiones del mismo documento que llegan con `tabId = -1` (no pertenecen a ninguna pestaña), y
137// cada tanda es una VISITA para el TOFU. El efecto no es cosmético: **inflar las visitas estabiliza
138// el origen antes de tiempo**, y un origen estabilizado antes de conocer su código real alerta por
139// churn legítimo — o sea, el falso positivo que la spec de `puriy-sct` pide evitar por encima de todo.
140//
141// Agrupando por documento, dos pestañas con la MISMA url caen en una tanda: eso CUENTA DE MENOS, que
142// es el error seguro (tarda más en proteger, no alerta de más). El `tabId` sigue haciendo falta para
143// la insignia, así que se guarda el primero que no sea -1.
144function clave(d) {
145 return d.documentUrl || d.originUrl || "";
146}
147
148function anotar(d, texto) {
149 const documento = d.documentUrl || d.originUrl;
150 if (!documento) { return; } // sin documento no hay origen que registrar
151 const k = clave(d);
152 let t = tandas.get(k);
153 if (!t) {
154 t = { documento, tabId: -1, scripts: [], reloj: null };
155 tandas.set(k, t);
156 }
157 if (t.tabId < 0 && typeof d.tabId === "number" && d.tabId >= 0) { t.tabId = d.tabId; }
158 t.scripts.push({ url: d.url, body: texto, module: false });
159 if (t.reloj) { clearTimeout(t.reloj); }
160 t.reloj = setTimeout(() => vaciar(k), REBOTE_MS);
161}
162
163function vaciar(k) {
164 const t = tandas.get(k);
165 tandas.delete(k);
166 if (!t || !t.scripts.length) { return; }
167 // La TANDA se anuncia con su clave y su tamaño. No es ruido de depuración: una MISMA carga de
168 // página puede producir más de una tanda —el rebote las corta si un script llega tarde—, y cada
169 // tanda es una VISITA para el TOFU. Contar visitas de más hace que un origen se estabilice antes
170 // de tiempo, o sea que alerte por churn legítimo. Sin esta línea, eso se ve como «el contador va
171 // raro» y no como qué lo causó.
172 di("TANDA " + k + " scripts=" + t.scripts.length);
173 const msg = { verb: "sct.observe", url: t.documento, scripts: t.scripts };
174 // `id` es u64 del otro lado: una pestaña -1 (peticiones que no son de ninguna) no se manda, o el
175 // mensaje entero se rechazaría por un campo que sólo servía para pintar una insignia.
176 if (typeof t.tabId === "number" && t.tabId >= 0) { msg.id = t.tabId; }
177 enviar(msg);
178}
179
180// ── EL FILTRO ─────────────────────────────────────────────────────────────────────────────────
181// ⚠ REGLA DE ORO DE ESTE BLOQUE: los bytes SIEMPRE pasan. Un filtro que se rompe y no escribe deja
182// la respuesta colgada para siempre, y el síntoma no es «sct falló» sino «esta página no carga» —
183// una extensión de seguridad que rompe la web se desinstala, y con ella la seguridad. Por eso cada
184// camino tiene su `catch` y el `catch` desconecta el filtro, que es lo que deja fluir el resto.
185browser.webRequest.onBeforeRequest.addListener(
186 d => {
187 let filtro;
188 try {
189 filtro = browser.webRequest.filterResponseData(d.requestId);
190 } catch (e) {
191 di("FILTRO NO DISPONIBLE " + e.message);
192 return {};
193 }
194 const trozos = [];
195 let total = 0;
196 let pasado = false;
197
198 filtro.ondata = e => {
199 try {
200 if (!pasado) {
201 total += e.data.byteLength;
202 if (total > TOPE_CUERPO) {
203 // Se deja de acumular, pero NO se deja de escribir: la página sigue igual.
204 pasado = true;
205 trozos.length = 0;
206 di("DEMASIADO GRANDE " + d.url + " (>" + TOPE_CUERPO + "B): no se observa");
207 } else {
208 trozos.push(new Uint8Array(e.data));
209 }
210 }
211 } catch (err) {
212 pasado = true;
213 }
214 filtro.write(e.data);
215 };
216
217 filtro.onstop = () => {
218 try {
219 if (!pasado && trozos.length) {
220 const todo = new Uint8Array(total);
221 let i = 0;
222 for (const t of trozos) { todo.set(t, i); i += t.length; }
223 // El texto YA DECODIFICADO es lo que se manda, y el host hashea su UTF-8. Está dicho allá
224 // qué implica: el hash vale para comparar dos cargas nuestras, no para compararse con el
225 // hash de los bytes que sirvió un tercero.
226 anotar(d, new TextDecoder("utf-8", { fatal: false }).decode(todo));
227 }
228 } catch (err) {
229 di("OBSERVAR FALLÓ " + err.message);
230 }
231 try { filtro.close(); } catch (err) { try { filtro.disconnect(); } catch (e2) {} }
232 };
233
234 filtro.onerror = () => {
235 // El navegador ya decidió (aborto, error de red): no hay nada que observar y nada que escribir.
236 trozos.length = 0;
237 };
238 return {};
239 },
240 { urls: ["<all_urls>"], types: ["script"] },
241 ["blocking"]
242);
243
244// Una pestaña que se cierra no deja una tanda pendiente esperando un rebote que ya no sirve.
245browser.tabs.onRemoved.addListener(tabId => {
246 for (const [k, t] of tandas) {
247 if (t.tabId === tabId) {
248 if (t.reloj) { clearTimeout(t.reloj); }
249 tandas.delete(k);
250 }
251 }
252});
253
254conectar();
255di("LISTA v1 observa y avisa; no bloquea");