Merge remote-tracking branch 'origin/main'
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
Executable
+143
@@ -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())
|
||||
Reference in New Issue
Block a user