diff --git a/recipes/atuq/atuq.cfg b/recipes/atuq/atuq.cfg index 44afd70a..ee9da793 100644 --- a/recipes/atuq/atuq.cfg +++ b/recipes/atuq/atuq.cfg @@ -113,3 +113,21 @@ try { // nada. Queda en la consola de errores, que es donde se mira cuando «atuq se ve como Firefox». Components.utils.reportError("atuq.cfg: no pude registrar chrome/atuq.css: " + e); } + +// ── LA BÓVEDA, Y EL GESTOR QUE SE APAGA PARA DEJARLE LUGAR (SDD-BOVEDA D7) ───────────────────── +// Las contraseñas las guarda la suite: la bóveda las ofrece en el sitio que corresponde, las +// sincroniza entre los equipos de la persona y se puede recuperar si se pierde el equipo. Gecko +// trae su propio gestor, y DOS gestores peleando por el mismo campo es la peor experiencia que +// puede tener alguien que sólo quiere entrar a su correo: el sitio se rellena dos veces, o ninguna, +// y no hay forma de saber cuál de los dos tiene la contraseña buena. +// +// Por eso de fábrica manda la bóveda. Pero va como `defaultPref` y NO como política: el que +// prefiera el de Gecko lo prende y listo. Prohibírselo sería decidir por él, que es exactamente lo +// que este fichero dice en su cabecera que atuq no quiere ser — y el costo de equivocarse acá lo +// paga alguien que se queda sin poder guardar una contraseña en su propia máquina. +defaultPref("signon.rememberSignons", false); +defaultPref("signon.autofillForms", false); +// Y que tampoco ofrezca la que ya tenga guardada de antes: el usuario que migra sus claves con +// `foreign-mozlogins` se las lleva a la bóveda, y ver las dos listas a la vez es confuso sin +// motivo. +defaultPref("signon.autofillForms.autocompleteOff", false); diff --git a/recipes/atuq/distribution/policies.json b/recipes/atuq/distribution/policies.json index 4bb24e00..d47229c3 100644 --- a/recipes/atuq/distribution/policies.json +++ b/recipes/atuq/distribution/policies.json @@ -63,6 +63,10 @@ "ia@atuq.tawasuyu": { "installation_mode": "normal_installed", "install_url": "file:///usr/lib/atuq/distribution/extensions/ia@atuq.tawasuyu.xpi" + }, + "boveda@atuq.tawasuyu": { + "installation_mode": "normal_installed", + "install_url": "file:///usr/lib/atuq/distribution/extensions/boveda@atuq.tawasuyu.xpi" } } } diff --git a/recipes/atuq/extensions/boveda/campos.js b/recipes/atuq/extensions/boveda/campos.js new file mode 100644 index 00000000..1f64cead --- /dev/null +++ b/recipes/atuq/extensions/boveda/campos.js @@ -0,0 +1,75 @@ +"use strict"; +// bóveda · campos — lo único que corre DENTRO de la página. +// +// Su trabajo es mínimo a propósito, porque es la parte que comparte proceso con +// un sitio cualquiera: avisa que hay un formulario de acceso, pone lo que el +// fondo le manda, y avisa cuando el usuario envió algo. +// +// **No decide nada y no dice de qué sitio es.** La dirección la pone el chrome +// (`sender.tab.url`), no este archivo: si la página pudiera decir quién es, +// podría decir que es el banco. + +function campoClave() { + return document.querySelector('input[type="password"]:not([disabled])'); +} + +function campoUsuario(clave) { + // El de usuario es el campo de texto anterior al de contraseña en el mismo + // formulario. Es una heurística, y por eso rellenarlo NO es obligatorio: si + // no se encuentra, se pone sólo la contraseña, que es lo que importa. + const f = clave && clave.form; + const candidatos = (f || document).querySelectorAll( + 'input[type="text"], input[type="email"], input:not([type])' + ); + let ultimo = null; + for (const c of candidatos) { + if (clave && c.compareDocumentPosition(clave) & Node.DOCUMENT_POSITION_FOLLOWING) { + ultimo = c; + } + } + return ultimo; +} + +function poner(campo, valor) { + if (!campo) { return; } + campo.focus(); + campo.value = valor; + // Los dos eventos: los formularios modernos escuchan `input`, y algunos + // validadores sólo `change`. Sin esto el sitio cree que el campo está vacío. + campo.dispatchEvent(new Event("input", { bubbles: true })); + campo.dispatchEvent(new Event("change", { bubbles: true })); +} + +browser.runtime.onMessage.addListener(msg => { + if (!msg || msg.tipo !== "rellenar") { return; } + const clave = campoClave(); + if (!clave) { return; } + poner(campoUsuario(clave), msg.username); + poner(clave, msg.password); +}); + +function avisarSiHay() { + if (campoClave()) { + browser.runtime.sendMessage({ tipo: "hay-formulario" }); + } +} + +document.addEventListener("submit", ev => { + const f = ev.target; + if (!f || !f.querySelector) { return; } + const clave = f.querySelector('input[type="password"]'); + if (!clave || !clave.value) { return; } + const usuario = campoUsuario(clave); + browser.runtime.sendMessage({ + tipo: "se-envio", + username: usuario ? usuario.value : "", + password: clave.value, + }); +}, true); + +avisarSiHay(); +// Un formulario que aparece después (una pantalla de acceso que se abre sola). +new MutationObserver(avisarSiHay).observe(document.documentElement, { + childList: true, + subtree: true, +}); diff --git a/recipes/atuq/extensions/boveda/fondo.js b/recipes/atuq/extensions/boveda/fondo.js new file mode 100644 index 00000000..9b758381 --- /dev/null +++ b/recipes/atuq/extensions/boveda/fondo.js @@ -0,0 +1,157 @@ +"use strict"; +// bóveda — las contraseñas las guarda la suite, no el navegador (SDD-BOVEDA §8), v1. +// +// Esta extensión **no sabe nada**. Pregunta y muestra; quien decide qué credencial +// corresponde a qué página es la función pura de `pacha-boveda-core`, del otro lado +// del cable. Reimplementar acá la regla de coincidencia sería fabricar un sustituto +// paralelo del original —lo que la regla 10 de tawasuyu prohíbe— y, peor, sería +// poner en JS la decisión de a quién se le entrega una contraseña. +// +// ── LO QUE ESTA EXTENSIÓN NO PUEDE HACER, Y ES A PROPÓSITO ─────────────────── +// No puede sacar una contraseña de la bóveda. Pide `vault.match`, que contesta +// títulos y usuarios; la contraseña sale por `vault.fill`, y ese verbo **pregunta +// en el escritorio** antes de contestar. Si esta extensión queda comprometida, o si +// una página consigue hablarle, lo que obtiene es la lista de títulos de los sitios +// que coinciden con su propia dirección. +// +// ── LA URL LA PONE EL CHROME, NUNCA LA PÁGINA ──────────────────────────────── +// Todo lo que se le manda al host lleva `tab.url`, que lo dice el navegador. Si la +// dirección viniera del content script, una página podría decir que es el banco y +// pedir lo que no es suyo. El content script sólo avisa que **hay** un formulario; +// nunca dice de quién. + +const HOST = "puriy_costura"; + +let puerto = null; +let proximoId = 1; +// id de petición → qué hacer con la respuesta. +const esperando = new Map(); + +function di(s) { + try { dump("BOVEDA " + s + "\n"); } catch (e) {} +} + +function conectar() { + try { + puerto = browser.runtime.connectNative(HOST); + puerto.onMessage.addListener(alRecibir); + puerto.onDisconnect.addListener(p => { + di("DESCONECTADO " + (p.error ? p.error.message : "sin error")); + puerto = null; + // Lo que quedó esperando no va a llegar nunca: se le contesta que no, en + // vez de dejar a la página esperando para siempre. + for (const [, f] of esperando) { try { f(null); } catch (e) {} } + esperando.clear(); + }); + di("CONECTADO " + HOST); + } catch (e) { + di("SIN HOST " + e.message); + puerto = null; + } +} + +function pedir(mensaje) { + return new Promise(resolver => { + if (!puerto) { conectar(); } + if (!puerto) { resolver(null); return; } + const id = proximoId++; + esperando.set(id, resolver); + try { + puerto.postMessage(Object.assign({ id }, mensaje)); + } catch (e) { + esperando.delete(id); + di("NO PUDE PEDIR " + e.message); + resolver(null); + } + }); +} + +function alRecibir(r) { + if (!r || typeof r.id !== "number") { return; } + const f = esperando.get(r.id); + if (!f) { return; } + esperando.delete(r.id); + f(r); +} + +// ── La insignia: cuántas credenciales hay para lo que estás mirando ────────── + +async function mirar(tabId, url) { + if (!url || !/^https?:/.test(url)) { + browser.browserAction.setBadgeText({ tabId, text: "" }); + return 0; + } + const r = await pedir({ verb: "vault.match", url }); + if (!r || r.ok !== true) { + browser.browserAction.setBadgeText({ tabId, text: "" }); + return 0; + } + if (r.locked === true) { + // Cerrada no es lo mismo que vacía, y se dice: si no, el usuario cree que + // no tiene guardada la contraseña que sí tiene. + browser.browserAction.setBadgeText({ tabId, text: "🔒" }); + return 0; + } + const n = (r.items || []).length; + browser.browserAction.setBadgeText({ tabId, text: n ? String(n) : "" }); + return n; +} + +browser.tabs.onUpdated.addListener((tabId, cambio, tab) => { + if (cambio.status === "complete") { mirar(tabId, tab.url); } +}); + +// ── Rellenar: el usuario lo pide, el escritorio lo autoriza ────────────────── + +browser.browserAction.onClicked.addListener(async tab => { + const r = await pedir({ verb: "vault.match", url: tab.url }); + if (!r || r.ok !== true || r.locked === true) { + di("no hay bóveda abierta"); + return; + } + const items = r.items || []; + if (items.length === 0) { di("nada para " + tab.url); return; } + + // v1: si hay varias, se usa la primera —que viene de la más específica a la + // menos, decidido del otro lado—. Elegir entre varias es una pantalla, y la + // pantalla es F2c. + const elegida = items[0]; + const f = await pedir({ verb: "vault.fill", url: tab.url, item: elegida.id }); + if (!f || f.ok !== true) { di("fill falló"); return; } + if (f.denied === true) { di("el usuario dijo que no"); return; } + if (!f.password) { di("sin contraseña en la respuesta"); return; } + + browser.tabs.sendMessage(tab.id, { + tipo: "rellenar", + username: f.username || "", + password: f.password, + totp: f.totp || null, + }); +}); + +// ── Guardar lo que el usuario acaba de tipear ──────────────────────────────── + +browser.runtime.onMessage.addListener(async (msg, sender) => { + if (!msg || !sender || !sender.tab) { return; } + // ⚠ La URL sale de `sender.tab.url` —lo que dice el navegador— y NO del + // mensaje. Lo único que la página aporta es lo que el usuario tipeó en ella. + const url = sender.tab.url; + + if (msg.tipo === "hay-formulario") { + mirar(sender.tab.id, url); + return; + } + if (msg.tipo === "se-envio" && msg.password) { + const r = await pedir({ + verb: "vault.save", + url, + username: msg.username || "", + password: msg.password, + title: sender.tab.title || "", + }); + if (r && r.denied === true) { di("no se guardó: el usuario dijo que no"); } + else if (r && r.ok === true) { di("guardada para " + url); mirar(sender.tab.id, url); } + } +}); + +conectar(); diff --git a/recipes/atuq/extensions/boveda/manifest.json b/recipes/atuq/extensions/boveda/manifest.json new file mode 100644 index 00000000..e6ce54a9 --- /dev/null +++ b/recipes/atuq/extensions/boveda/manifest.json @@ -0,0 +1,36 @@ +{ + "manifest_version": 2, + "name": "atuq — la bóveda", + "description": "Tus contraseñas las guarda la suite, no el navegador: la bóveda las ofrece en el sitio que corresponde y no entrega ninguna sin que vos digas que sí (SDD-BOVEDA §8).", + "version": "1.0", + "browser_specific_settings": { + "gecko": { + "id": "boveda@atuq.tawasuyu", + "strict_min_version": "115.0" + } + }, + "permissions": [ + "nativeMessaging", + "tabs", + "activeTab", + "" + ], + "background": { + "scripts": ["fondo.js"] + }, + "content_scripts": [ + { + "matches": ["http://*/*", "https://*/*"], + "js": ["campos.js"], + "run_at": "document_idle", + "all_frames": false + } + ], + "browser_action": { + "default_title": "La bóveda", + "default_icon": "atuq128.png" + }, + "icons": { + "128": "atuq128.png" + } +} diff --git a/recipes/atuq/native-messaging/puriy_costura.json b/recipes/atuq/native-messaging/puriy_costura.json index 2269fc88..a8e1726e 100644 --- a/recipes/atuq/native-messaging/puriy_costura.json +++ b/recipes/atuq/native-messaging/puriy_costura.json @@ -10,6 +10,7 @@ "medios@atuq.tawasuyu", "torrent@atuq.tawasuyu", "foco@atuq.tawasuyu", - "ia@atuq.tawasuyu" + "ia@atuq.tawasuyu", + "boveda@atuq.tawasuyu" ] } diff --git a/scripts/test-atuq-boveda-coherente.py b/scripts/test-atuq-boveda-coherente.py new file mode 100755 index 00000000..94780aca --- /dev/null +++ b/scripts/test-atuq-boveda-coherente.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""¿La bóveda está enchufada ENTERA, o a medias? (SDD-BOVEDA §8, D7) + + python3 scripts/test-atuq-boveda-coherente.py + python3 scripts/test-atuq-boveda-coherente.py --negative-control + +── QUÉ MIRA, Y POR QUÉ ESTE GUARDIÁN EXISTE APARTE ─────────────────────────────────────────── +Una extensión de `atuq` está enchufada en CUATRO lugares distintos, y ninguno da error si falta: + + manifest.json el id que la extensión declara + distribution/policies.json la política que la instala + native-messaging/*.json el permiso para hablarle al host + atuq.cfg las preferencias que la acompañan + +Si falta la política, la extensión no se instala y el usuario ve un navegador sin la función. Si +falta el permiso del host, la extensión se instala y **se conecta a nada** —`connectNative` falla +en silencio y la insignia no aparece nunca—. Si faltan las preferencias, la bóveda y el gestor de +Gecko se pelean por el mismo campo. Ninguno de los tres se ve como un error: se ven como «no anda». + +Éste NO reemplaza al guardián de metal (servidor HTTP, navegador real, login real, el diálogo de +consentimiento a la vista). Lo precede: mide lo que se puede medir sin construir nada, en un +segundo, y ahorra descubrir en metal un renglón que faltaba en un JSON. + +`--negative-control` saca el permiso del host de una copia en memoria y exige que esto lo detecte. +Sin él, un guardián que siempre dice que sí se vería idéntico a uno que funciona. +""" +import json +import os +import re +import sys + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +ATUQ = os.path.join(ROOT, "recipes/atuq") +ID = "boveda@atuq.tawasuyu" + +# Las preferencias de D7: de fábrica manda la bóveda, pero el usuario puede volver atrás. +PREFS = ["signon.rememberSignons", "signon.autofillForms"] + +fallas = [] + + +def mal(msg): + fallas.append(msg) + print(f" ✗ {msg}") + + +def bien(msg): + print(f" ✓ {msg}") + + +def leer(p): + with open(os.path.join(ATUQ, p), encoding="utf-8") as f: + return f.read() + + +def revisar(permisos_host): + # 1. La extensión existe y declara el id que todos los demás nombran. + try: + m = json.loads(leer("extensions/boveda/manifest.json")) + except FileNotFoundError: + mal("no existe extensions/boveda/manifest.json") + return + declarado = m.get("browser_specific_settings", {}).get("gecko", {}).get("id") + if declarado != ID: + mal(f"el manifest declara {declarado!r} y el resto nombra {ID!r}") + else: + bien(f"la extensión declara {ID}") + + # 2. Los permisos que necesita para poder hacer su trabajo. + for p in ("nativeMessaging", "tabs"): + if p not in m.get("permissions", []): + mal(f"le falta el permiso {p!r}: sin eso no puede {'hablarle al host' if p == 'nativeMessaging' else 'saber qué página mira el usuario'}") + if not m.get("content_scripts"): + mal("sin content script no puede rellenar ningún campo") + if "background" not in m: + mal("sin fondo no hay quién hable con el host") + + # 3. Los archivos que el manifest promete. + for rel in [m.get("background", {}).get("scripts", []), *[c.get("js", []) for c in m.get("content_scripts", [])]]: + for f in rel: + if not os.path.exists(os.path.join(ATUQ, "extensions/boveda", f)): + mal(f"el manifest nombra {f} y el archivo no está") + + # 4. La política que la instala. + pol = json.loads(leer("distribution/policies.json")) + ext = pol.get("policies", {}).get("ExtensionSettings", {}) + if ID not in ext: + mal("no está en ExtensionSettings: el navegador no la va a instalar") + elif ext[ID].get("installation_mode") != "normal_installed": + mal(f"se instala en modo {ext[ID].get('installation_mode')!r}, que no la deja activada") + else: + bien("la política la instala") + + # 5. El permiso para hablarle al host. Éste es el que falla en SILENCIO. + if ID not in permisos_host: + mal("no está en allowed_extensions del host: se instala y no se conecta a nada") + else: + bien("el host la deja hablar") + + # 6. Las preferencias de D7, y que sean `defaultPref` y no `lockPref`. + cfg = leer("atuq.cfg") + for p in PREFS: + if not re.search(rf'defaultPref\("{re.escape(p)}"', cfg): + mal(f"falta defaultPref({p!r}): el gestor de Gecko va a pelear por el mismo campo") + if re.search(rf'lockPref\("{re.escape(p)}"', cfg): + mal(f"{p} está como lockPref: eso decide POR el usuario, y atuq no hace eso") + if not fallas: + bien("las preferencias están, y como valores de arranque y no como cárcel") + + # 7. Y lo que la extensión NO tiene que hacer: decidir ella qué credencial va. + fondo = leer("extensions/boveda/fondo.js") + if "sender.tab.url" not in fondo: + mal("el fondo no toma la dirección del chrome: una página podría decir que es el banco") + else: + bien("la dirección la pone el navegador, no la página") + + +def main(): + control = "--negative-control" in sys.argv + host = json.loads(leer("native-messaging/puriy_costura.json")) + permisos = list(host.get("allowed_extensions", [])) + if control: + print("⚠ control negativo: se le saca el permiso del host") + permisos = [x for x in permisos if x != ID] + + print(f"── la bóveda en atuq ──") + revisar(permisos) + + if control: + if any("allowed_extensions" in f for f in fallas): + print("\n✓ el control negativo lo detectó") + return 0 + print("\n✗ el control negativo NO lo detectó: este guardián no mide nada") + return 1 + if fallas: + print(f"\n✗ {len(fallas)} problema(s)") + return 1 + print("\n✓ la bóveda está enchufada entera") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())