Merge remote-tracking branch 'origin/main'

This commit is contained in:
Sergio
2026-09-15 17:18:04 +00:00
7 changed files with 435 additions and 1 deletions
+18
View File
@@ -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);
+4
View File
@@ -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"
}
}
}
+75
View File
@@ -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,
});
+157
View File
@@ -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();
@@ -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",
"<all_urls>"
],
"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"
}
}
@@ -10,6 +10,7 @@
"medios@atuq.tawasuyu",
"torrent@atuq.tawasuyu",
"foco@atuq.tawasuyu",
"ia@atuq.tawasuyu"
"ia@atuq.tawasuyu",
"boveda@atuq.tawasuyu"
]
}
+143
View File
@@ -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())