// 🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩
// WWW.CAJANEGRA.ME · CAJA NEGRA — Diario íntimo cifrado de extremo a extremo en www.cajanegra.me/diario/ · v1.0 · Revisión: 19/10/2026 13:05 (hora de Madrid)
// 🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩
/*
* QUÉ HACE ESTE CÓDIGO
* --------------------
* Crea dentro del WordPress una app privada en https://www.cajanegra.me/diario/
* donde cada persona, con un nombre de usuario inventado y una contraseña,
* escribe textos que SOLO ELLA puede leer.
*
* Los textos se cifran en el navegador de la persona ANTES de salir de su
* dispositivo. El servidor (y quien tenga acceso a la base de datos, incluido
* el administrador) solo ve datos ilegibles.
*
* TODO VA EN ESTE BLOQUE: no hay que subir archivos ni tocar phpMyAdmin.
* · Las tablas se crean solas la primera vez que se carga la web.
* · La página, los estilos y el JavaScript de la app se sirven desde aquí.
*
* La app NO usa el tema ni los plugins de WordPress en su página: se envía
* aparte, con una política de seguridad que impide ejecutar código ajeno.
*
* REQUISITOS: enlaces permanentes que no sean "Simple" (Ajustes → Enlaces
* permanentes), HTTPS activo y PHP 7.4 o superior.
*
* ÍNDICE DE BLOQUES
* 010 · Configuración
* 020 · Tablas de la base de datos (se crean solas)
* 030 · Secreto del servidor
* 040 · Acceso a la base de datos
* 050 · Sesión
* 060 · Respuestas
* 070 · Validación de datos
* 080 · Límite de intentos
* 090 · Huellas de las claves
* 100 · Doble factor (TOTP)
* 110 · Acciones: cuenta
* 120 · Acciones: contraseña
* 130 · Acciones: documentos
* 140 · Acciones: doble factor
* 150 · Acciones: baja
* 160 · API: puerta de entrada
* 170 · Cabeceras de seguridad
* 180 · Enrutador de /diario/
* 190 · Archivo: index.html
* 200 · Archivo: estilos.css
* 210 · Archivo: textos-es.js
* 220 · Archivo: cripto.js
* 230 · Archivo: api.js
* 240 · Archivo: app.js
* 250 · Archivo: qrcode.mjs (librería externa)
* 260 · Archivo: icono.svg
*/
// ****************************************************************************
// 010 · CONFIGURACIÓN
// ****************************************************************************
// Puedes dejarlo todo como está. Lo único que quizá quieras cambiar es la
// dirección de la app (BDN_CAJA_RUTA).
// Versión del código. Al cambiarla, los navegadores descargan de nuevo
// el JavaScript y los estilos.
define('BDN_CAJA_VERSION', '1.0');
// Versión de la estructura de las tablas. Si cambia, se actualizan solas.
define('BDN_CAJA_BD_VERSION', '1');
// Dirección de la app dentro de la web: www.cajanegra.me/diario/
// (solo letras minúsculas, números y guiones).
define('BDN_CAJA_RUTA', 'diario');
// Minutos sin actividad tras los que la sesión se cierra en el servidor.
// El navegador, por su parte, cierra el diario a los 15 minutos.
define('BDN_CAJA_SESION_MINUTOS', 30);
// Vueltas del algoritmo PBKDF2 para cuentas nuevas (recomendación OWASP).
// No lo bajes nunca: haría más fácil adivinar contraseñas.
define('BDN_CAJA_ITERACIONES', 600000);
// Intentos fallidos antes de bloquear, y minutos de bloqueo.
define('BDN_CAJA_INTENTOS_USUARIO', 5);
define('BDN_CAJA_INTENTOS_IP', 20);
define('BDN_CAJA_BLOQUEO_MINUTOS', 15);
// Cuentas nuevas máximas por dirección IP y hora.
define('BDN_CAJA_ALTAS_IP_HORA', 10);
// Tamaño máximo de un texto cifrado (3 MB: varios libros) y textos por cuenta.
define('BDN_CAJA_TAMANO_MAX', 3 * 1024 * 1024);
define('BDN_CAJA_DOCUMENTOS_MAX', 10000);
// ****************************************************************************
// 020 · TABLAS DE LA BASE DE DATOS (se crean solas)
// ****************************************************************************
// Tres tablas propias, con el prefijo del WordPress (normalmente wp_):
// · wp_cajanegra_usuarios → cuentas. Sin contraseñas ni claves: solo
// huellas y claves "envueltas" que solo el
// navegador del usuario puede abrir.
// · wp_cajanegra_documentos → los textos, SIEMPRE cifrados.
// · wp_cajanegra_intentos → contador de intentos fallidos.
// Las fechas son marcas de tiempo Unix (segundos desde 1970).
/**
* Nombre completo de una tabla de la app, con el prefijo del WordPress.
*
* @param string $nombre 'usuarios', 'documentos' o 'intentos'.
* @return string Ejemplo: 'wp_cajanegra_usuarios'.
*/
function bdn_caja_tabla($nombre)
{
global $wpdb;
return $wpdb->prefix . 'cajanegra_' . $nombre;
}
/**
* Crea o actualiza las tablas con dbDelta (la herramienta oficial de
* WordPress para esto). Si ya existen, no toca los datos.
*/
function bdn_caja_crear_tablas()
{
global $wpdb;
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
$juego = $wpdb->get_charset_collate();
$usuarios = bdn_caja_tabla('usuarios');
$documentos = bdn_caja_tabla('documentos');
$intentos = bdn_caja_tabla('intentos');
// OJO: dbDelta es muy exigente con el formato (un campo por línea,
// dos espacios tras PRIMARY KEY). No cambies la forma de escribirlo.
dbDelta("CREATE TABLE $usuarios (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
usuario varchar(32) NOT NULL,
sal varchar(64) NOT NULL,
iteraciones int(10) unsigned NOT NULL,
hash_acceso varchar(255) NOT NULL,
dek_contrasena varchar(255) NOT NULL,
sal_recuperacion varchar(64) NOT NULL,
hash_recuperacion varchar(255) NOT NULL,
dek_recuperacion varchar(255) NOT NULL,
totp_secreto varchar(255) DEFAULT NULL,
totp_ultimo_paso bigint(20) DEFAULT NULL,
codigos_emergencia text DEFAULT NULL,
creado int(10) unsigned NOT NULL,
ultimo_acceso int(10) unsigned DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY usuario (usuario)
) $juego;");
dbDelta("CREATE TABLE $documentos (
id char(32) NOT NULL,
usuario_id bigint(20) unsigned NOT NULL,
contenido mediumtext NOT NULL,
creado int(10) unsigned NOT NULL,
modificado int(10) unsigned NOT NULL,
PRIMARY KEY (id),
KEY usuario_id (usuario_id)
) $juego;");
dbDelta("CREATE TABLE $intentos (
clave char(64) NOT NULL,
fallos int(10) unsigned NOT NULL DEFAULT 0,
primer_fallo int(10) unsigned NOT NULL,
bloqueado_hasta int(10) unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (clave)
) $juego;");
// Apuntamos la versión para no repetirlo en cada visita.
update_option('bdn_caja_bd_version', BDN_CAJA_BD_VERSION, false);
}
// Al cargar WordPress, si las tablas no están al día, se crean o actualizan.
add_action('init', function () {
if (get_option('bdn_caja_bd_version') !== BDN_CAJA_BD_VERSION) {
bdn_caja_crear_tablas();
}
}, 5);
// ****************************************************************************
// 030 · SECRETO DEL SERVIDOR
// ****************************************************************************
/**
* Secreto propio de la app (64 caracteres aleatorios). Se genera solo la
* primera vez y se guarda en las opciones de WordPress.
*
* Sirve para: fabricar "sales falsas" (que nadie averigüe qué usuarios
* existen), cifrar los secretos del doble factor y anonimizar las IPs del
* límite de intentos. No cifra los textos: eso lo hace la contraseña de
* cada persona, en su navegador.
*
* @return string
*/
function bdn_caja_secreto()
{
static $secreto = null;
if ($secreto !== null) {
return $secreto;
}
$secreto = get_option('bdn_caja_secreto');
if (!is_string($secreto) || strlen($secreto) < 32) {
// 'no' = no se carga en todas las páginas del WordPress.
add_option('bdn_caja_secreto', bin2hex(random_bytes(32)), '', 'no');
$secreto = (string) get_option('bdn_caja_secreto');
}
return $secreto;
}
// ****************************************************************************
// 040 · ACCESO A LA BASE DE DATOS
// ****************************************************************************
// Usamos $wpdb (la conexión de WordPress) siempre con consultas preparadas:
// así es imposible la "inyección SQL" con los datos del usuario.
/**
* Lanza un error si la última consulta ha fallado.
*/
function bdn_caja_comprobar_bd()
{
global $wpdb;
if ($wpdb->last_error !== '') {
throw new RuntimeException('Base de datos: ' . $wpdb->last_error);
}
}
/**
* Prepara una consulta con sus valores (marcadores %s texto, %d número).
*
* @param string $sql
* @param array $valores
* @return string
*/
function bdn_caja_preparar($sql, array $valores)
{
global $wpdb;
return $valores ? $wpdb->prepare($sql, $valores) : $sql;
}
/**
* Devuelve UNA fila como array, o null si no hay.
*
* @param string $sql
* @param array $valores
* @return array|null
*/
function bdn_caja_fila($sql, array $valores = [])
{
global $wpdb;
$fila = $wpdb->get_row(bdn_caja_preparar($sql, $valores), ARRAY_A);
bdn_caja_comprobar_bd();
return $fila ?: null;
}
/**
* Devuelve TODAS las filas como arrays.
*
* @param string $sql
* @param array $valores
* @return array
*/
function bdn_caja_filas($sql, array $valores = [])
{
global $wpdb;
$filas = $wpdb->get_results(bdn_caja_preparar($sql, $valores), ARRAY_A);
bdn_caja_comprobar_bd();
return $filas ?: [];
}
/**
* Ejecuta un INSERT, UPDATE o DELETE.
*
* @param string $sql
* @param array $valores
* @return int Filas afectadas.
*/
function bdn_caja_ejecutar($sql, array $valores = [])
{
global $wpdb;
$resultado = $wpdb->query(bdn_caja_preparar($sql, $valores));
bdn_caja_comprobar_bd();
return (int) $resultado;
}
// ****************************************************************************
// 050 · SESIÓN
// ****************************************************************************
/**
* Ruta de la app en la web, por ejemplo '/diario/'.
*
* @return string
*/
function bdn_caja_ruta_web()
{
$base = (string) parse_url(home_url('/'), PHP_URL_PATH);
return rtrim($base, '/') . '/' . BDN_CAJA_RUTA . '/';
}
/**
* Arranca la sesión PHP de la app con una cookie propia, distinta de las
* de WordPress, que solo viaja a /diario/:
* · HttpOnly → el JavaScript no puede leerla.
* · Secure (HTTPS) → solo viaja cifrada.
* · SameSite=Strict → no se envía desde otras webs (anti-CSRF).
* Si lleva demasiado tiempo sin actividad, se cierra.
*/
function bdn_caja_iniciar_sesion()
{
if (session_status() === PHP_SESSION_ACTIVE) {
return;
}
ini_set('session.use_strict_mode', '1');
ini_set('session.use_only_cookies', '1');
session_name('cajanegra_sesion');
session_set_cookie_params([
'lifetime' => 0,
'path' => bdn_caja_ruta_web(),
'secure' => is_ssl(),
'httponly' => true,
'samesite' => 'Strict',
]);
session_start();
if (isset($_SESSION['ultima_actividad'])
&& time() - $_SESSION['ultima_actividad'] > BDN_CAJA_SESION_MINUTOS * 60) {
bdn_caja_cerrar_sesion();
session_start();
}
$_SESSION['ultima_actividad'] = time();
}
/**
* Marca la sesión como "dentro" para un usuario. Cambia el identificador
* de sesión para evitar robos por fijación de sesión.
*
* @param int $usuario_id
*/
function bdn_caja_abrir_sesion_usuario($usuario_id)
{
session_regenerate_id(true);
$_SESSION = [
'usuario_id' => (int) $usuario_id,
'ultima_actividad' => time(),
];
}
/**
* Destruye la sesión por completo (datos y cookie).
*/
function bdn_caja_cerrar_sesion()
{
$_SESSION = [];
$p = session_get_cookie_params();
setcookie(session_name(), '', [
'expires' => time() - 3600,
'path' => $p['path'],
'secure' => $p['secure'],
'httponly' => true,
'samesite' => 'Strict',
]);
if (session_status() === PHP_SESSION_ACTIVE) {
session_destroy();
}
}
/**
* ID del usuario con sesión abierta, o corta con "sesion_caducada".
*
* @return int
*/
function bdn_caja_usuario_actual()
{
if (empty($_SESSION['usuario_id'])) {
bdn_caja_error('sesion_caducada', 401);
}
return (int) $_SESSION['usuario_id'];
}
// ****************************************************************************
// 060 · RESPUESTAS
// ****************************************************************************
// Solo se envían CÓDIGOS de error (p. ej. "usuario_ocupado"). El texto que
// ve el usuario está en textos-es.js, así se puede traducir sin tocar PHP.
/**
* Respuesta correcta en JSON, y fin.
*
* @param array $datos Se añaden a {"ok": true}.
*/
function bdn_caja_responder(array $datos = [])
{
echo wp_json_encode(['ok' => true] + $datos, JSON_UNESCAPED_UNICODE);
exit;
}
/**
* Respuesta de error en JSON, y fin.
*
* @param string $codigo Código del error.
* @param int $estado Código HTTP.
* @param array $extra Datos adicionales (p. ej. minutos de bloqueo).
*/
function bdn_caja_error($codigo, $estado = 400, array $extra = [])
{
status_header($estado);
echo wp_json_encode(['ok' => false, 'error' => $codigo] + $extra, JSON_UNESCAPED_UNICODE);
exit;
}
// ****************************************************************************
// 070 · VALIDACIÓN DE DATOS
// ****************************************************************************
/**
* Nombre de usuario: 3 a 32 caracteres, a-z, 0-9, punto, guion y guion bajo.
* Se pasa a minúsculas: "Luna" y "luna" son la misma cuenta.
*
* @param mixed $valor
* @return string
*/
function bdn_caja_validar_usuario($valor)
{
$usuario = strtolower(trim((string) $valor));
if (!preg_match('/^[a-z0-9._-]{3,32}$/', $usuario)) {
bdn_caja_error('usuario_no_valido');
}
return $usuario;
}
/**
* Comprueba que un valor es Base64 válido y mide entre $min y $max bytes.
* Todo lo binario (sales, claves, datos cifrados) viaja en Base64.
*
* @param mixed $valor
* @param int $min
* @param int $max
* @param string $campo Nombre del campo, para el error.
* @return string El Base64 original.
*/
function bdn_caja_validar_base64($valor, $min, $max, $campo)
{
if (!is_string($valor) || $valor === '') {
bdn_caja_error('dato_no_valido', 400, ['campo' => $campo]);
}
$bytes = base64_decode($valor, true);
if ($bytes === false || strlen($bytes) < $min || strlen($bytes) > $max) {
bdn_caja_error('dato_no_valido', 400, ['campo' => $campo]);
}
return $valor;
}
/**
* ID de documento: 32 caracteres hexadecimales creados en el navegador.
*
* @param mixed $valor
* @return string
*/
function bdn_caja_validar_id($valor)
{
$id = (string) $valor;
if (!preg_match('/^[a-f0-9]{32}$/', $id)) {
bdn_caja_error('dato_no_valido', 400, ['campo' => 'id']);
}
return $id;
}
/**
* Código de doble factor: 6 cifras (app) o 10 caracteres (emergencia).
*
* @param mixed $valor
* @return string Sin espacios ni guiones, en mayúsculas.
*/
function bdn_caja_validar_codigo($valor)
{
$codigo = strtoupper(preg_replace('/[\s-]/', '', (string) $valor));
if (!preg_match('/^([0-9]{6}|[A-Z0-9]{10})$/', $codigo)) {
bdn_caja_error('codigo_incorrecto');
}
return $codigo;
}
/**
* Vueltas de PBKDF2: ni menos de lo configurado ni una cifra absurda.
*
* @param mixed $valor
* @return int
*/
function bdn_caja_validar_iteraciones($valor)
{
$n = (int) $valor;
if ($n < BDN_CAJA_ITERACIONES || $n > 5000000) {
bdn_caja_error('dato_no_valido', 400, ['campo' => 'iteraciones']);
}
return $n;
}
/**
* Paquete de una contraseña nueva, preparado por el navegador:
* sal (16 bytes), iteraciones, clave_acceso (32 bytes) y
* dek_contrasena (clave maestra envuelta: 12 + 32 + 16 = 60 bytes).
*
* @param array $d
* @return array
*/
function bdn_caja_validar_paquete(array $d)
{
return [
'sal' => bdn_caja_validar_base64($d['sal'] ?? null, 16, 16, 'sal'),
'iteraciones' => bdn_caja_validar_iteraciones($d['iteraciones'] ?? 0),
'clave_acceso' => bdn_caja_validar_base64($d['clave_acceso'] ?? null, 32, 32, 'clave_acceso'),
'dek_contrasena' => bdn_caja_validar_base64($d['dek_contrasena'] ?? null, 60, 60, 'dek_contrasena'),
];
}
// ****************************************************************************
// 080 · LÍMITE DE INTENTOS
// ****************************************************************************
/**
* IP del visitante (solo para el límite de intentos; se guarda como huella).
*
* @return string
*/
function bdn_caja_ip()
{
return isset($_SERVER['REMOTE_ADDR']) ? (string) $_SERVER['REMOTE_ADDR'] : '0.0.0.0';
}
/**
* Huella de lo vigilado (p. ej. "u:luna" o "ip:1.2.3.4"), para no guardar
* nombres ni IPs en claro.
*
* @param string $que
* @return string
*/
function bdn_caja_clave_intento($que)
{
return hash_hmac('sha256', $que, bdn_caja_secreto());
}
/**
* Si lo vigilado está bloqueado, corta con "demasiados_intentos".
*
* @param string $que
*/
function bdn_caja_comprobar_bloqueo($que)
{
$fila = bdn_caja_fila(
'SELECT bloqueado_hasta FROM ' . bdn_caja_tabla('intentos') . ' WHERE clave = %s',
[bdn_caja_clave_intento($que)]
);
if ($fila && (int) $fila['bloqueado_hasta'] > time()) {
$minutos = (int) ceil(((int) $fila['bloqueado_hasta'] - time()) / 60);
bdn_caja_error('demasiados_intentos', 429, ['minutos' => $minutos]);
}
}
/**
* Suma un fallo y, si se supera el máximo en la ventana, bloquea.
*
* @param string $que
* @param int $maximo Fallos permitidos.
* @param int $ventana Segundos de la ventana de conteo.
*/
function bdn_caja_registrar_fallo($que, $maximo, $ventana = 900)
{
$tabla = bdn_caja_tabla('intentos');
$clave = bdn_caja_clave_intento($que);
$ahora = time();
$fila = bdn_caja_fila("SELECT * FROM $tabla WHERE clave = %s", [$clave]);
if (!$fila) {
bdn_caja_ejecutar(
"INSERT INTO $tabla (clave, fallos, primer_fallo, bloqueado_hasta) VALUES (%s, 1, %d, 0)",
[$clave, $ahora]
);
$fallos = 1;
} elseif ($ahora - (int) $fila['primer_fallo'] > $ventana) {
bdn_caja_ejecutar(
"UPDATE $tabla SET fallos = 1, primer_fallo = %d, bloqueado_hasta = 0 WHERE clave = %s",
[$ahora, $clave]
);
$fallos = 1;
} else {
$fallos = (int) $fila['fallos'] + 1;
bdn_caja_ejecutar("UPDATE $tabla SET fallos = %d WHERE clave = %s", [$fallos, $clave]);
}
if ($fallos >= $maximo) {
bdn_caja_ejecutar(
"UPDATE $tabla SET bloqueado_hasta = %d WHERE clave = %s",
[$ahora + BDN_CAJA_BLOQUEO_MINUTOS * 60, $clave]
);
}
}
/**
* Borra el contador de fallos (tras un acceso correcto).
*
* @param string $que
*/
function bdn_caja_limpiar_fallos($que)
{
bdn_caja_ejecutar(
'DELETE FROM ' . bdn_caja_tabla('intentos') . ' WHERE clave = %s',
[bdn_caja_clave_intento($que)]
);
}
// ****************************************************************************
// 090 · HUELLAS DE LAS CLAVES
// ****************************************************************************
/**
* Huella de una clave de acceso (Argon2id si el servidor lo tiene; si no,
* bcrypt). Irreversible y lenta a propósito.
*
* @param string $clave_acceso Base64 enviado por el navegador.
* @return string
*/
function bdn_caja_huella($clave_acceso)
{
return password_hash($clave_acceso, defined('PASSWORD_ARGON2ID') ? PASSWORD_ARGON2ID : PASSWORD_DEFAULT);
}
/**
* Huella "de mentira" que se compara cuando el usuario no existe, para que
* el tiempo de respuesta no delate qué usuarios están registrados.
*
* @return string
*/
function bdn_caja_huella_falsa()
{
static $falsa = null;
if ($falsa === null) {
$falsa = bdn_caja_huella('cajanegra-usuario-inexistente');
}
return $falsa;
}
/**
* Sal falsa, pero SIEMPRE LA MISMA, para un usuario que no existe.
*
* @param string $usuario
* @param string $tipo 'contrasena' o 'recuperacion'.
* @return string Base64 de 16 bytes.
*/
function bdn_caja_sal_falsa($usuario, $tipo)
{
$bytes = hash_hmac('sha256', 'sal|' . $tipo . '|' . $usuario, bdn_caja_secreto(), true);
return base64_encode(substr($bytes, 0, 16));
}
// ****************************************************************************
// 100 · DOBLE FACTOR (TOTP)
// ****************************************************************************
// Estándar RFC 6238, el de Google Authenticator, Microsoft Authenticator,
// Aegis... Móvil y servidor comparten un secreto y cada 30 segundos calculan,
// cada uno por su cuenta, el mismo código de 6 cifras.
/**
* Bytes → Base32 (formato del secreto que entienden las apps).
*
* @param string $bytes
* @return string
*/
function bdn_caja_base32_codificar($bytes)
{
$alfabeto = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
$bits = '';
foreach (str_split($bytes) as $byte) {
$bits .= str_pad(decbin(ord($byte)), 8, '0', STR_PAD_LEFT);
}
$texto = '';
foreach (str_split($bits, 5) as $trozo) {
$texto .= $alfabeto[bindec(str_pad($trozo, 5, '0', STR_PAD_RIGHT))];
}
return $texto;
}
/**
* Base32 → bytes.
*
* @param string $texto
* @return string
*/
function bdn_caja_base32_decodificar($texto)
{
$alfabeto = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
$bits = '';
foreach (str_split(strtoupper(rtrim($texto, '='))) as $letra) {
$posicion = strpos($alfabeto, $letra);
if ($posicion !== false) {
$bits .= str_pad(decbin($posicion), 5, '0', STR_PAD_LEFT);
}
}
$bytes = '';
foreach (str_split($bits, 8) as $octeto) {
if (strlen($octeto) === 8) {
$bytes .= chr(bindec($octeto));
}
}
return $bytes;
}
/**
* Código de 6 cifras para un "paso" (bloques de 30 s desde 1970).
* HMAC-SHA1 + truncado dinámico, según el estándar.
*
* @param string $secreto Base32.
* @param int $paso
* @return string
*/
function bdn_caja_totp_codigo($secreto, $paso)
{
$hmac = hash_hmac('sha1', pack('N2', 0, $paso), bdn_caja_base32_decodificar($secreto), true);
$o = ord($hmac[19]) & 0x0F;
$numero = ((ord($hmac[$o]) & 0x7F) << 24)
| ((ord($hmac[$o + 1]) & 0xFF) << 16)
| ((ord($hmac[$o + 2]) & 0xFF) << 8)
| (ord($hmac[$o + 3]) & 0xFF);
return str_pad((string) ($numero % 1000000), 6, '0', STR_PAD_LEFT);
}
/**
* Comprueba un código: acepta el actual y el de ±30 s (relojes algo
* desajustados) y rechaza los ya usados (paso igual o anterior al último).
*
* @param string $secreto
* @param string $codigo
* @param int|null $ultimo_paso
* @return int|false Paso que coincide, o false.
*/
function bdn_caja_totp_verificar($secreto, $codigo, $ultimo_paso)
{
$actual = intdiv(time(), 30);
for ($desfase = -1; $desfase <= 1; $desfase++) {
$paso = $actual + $desfase;
if ($ultimo_paso !== null && $paso <= $ultimo_paso) {
continue;
}
if (hash_equals(bdn_caja_totp_codigo($secreto, $paso), $codigo)) {
return $paso;
}
}
return false;
}
/**
* Clave AES-256 para cifrar los secretos TOTP guardados.
*
* @return string 32 bytes.
*/
function bdn_caja_totp_clave()
{
return hash_hmac('sha256', 'cifrado-totp', bdn_caja_secreto(), true);
}
/**
* Cifra un secreto TOTP (AES-256-GCM): Base64( IV 12 | etiqueta 16 | datos ).
*
* @param string $secreto
* @return string
*/
function bdn_caja_totp_cifrar($secreto)
{
$iv = random_bytes(12);
$etiqueta = '';
$cifrado = openssl_encrypt($secreto, 'aes-256-gcm', bdn_caja_totp_clave(), OPENSSL_RAW_DATA, $iv, $etiqueta);
return base64_encode($iv . $etiqueta . $cifrado);
}
/**
* Descifra un secreto TOTP guardado.
*
* @param string $guardado
* @return string|null
*/
function bdn_caja_totp_descifrar($guardado)
{
$b = base64_decode((string) $guardado, true);
if ($b === false || strlen($b) < 29) {
return null;
}
$r = openssl_decrypt(substr($b, 28), 'aes-256-gcm', bdn_caja_totp_clave(), OPENSSL_RAW_DATA, substr($b, 0, 12), substr($b, 12, 16));
return $r === false ? null : $r;
}
/**
* Huella de un código de emergencia (son largos y aleatorios: basta HMAC).
*
* @param string $codigo
* @return string
*/
function bdn_caja_huella_emergencia($codigo)
{
return hash_hmac('sha256', 'emergencia|' . $codigo, bdn_caja_secreto());
}
/**
* Comprueba el código de doble factor de un usuario (app o emergencia) y,
* si vale, lo "gasta": el de la app no se puede repetir y el de
* emergencia se borra.
*
* @param array $usuario Fila de la tabla usuarios.
* @param string $codigo Ya validado.
* @return bool
*/
function bdn_caja_comprobar_2fa(array $usuario, $codigo)
{
$tabla = bdn_caja_tabla('usuarios');
if (strlen($codigo) === 6) {
$secreto = bdn_caja_totp_descifrar($usuario['totp_secreto']);
if ($secreto === null) {
return false;
}
$ultimo = $usuario['totp_ultimo_paso'] === null ? null : (int) $usuario['totp_ultimo_paso'];
$paso = bdn_caja_totp_verificar($secreto, $codigo, $ultimo);
if ($paso === false) {
return false;
}
bdn_caja_ejecutar("UPDATE $tabla SET totp_ultimo_paso = %d WHERE id = %d", [$paso, $usuario['id']]);
return true;
}
$huellas = json_decode((string) $usuario['codigos_emergencia'], true) ?: [];
$buscada = bdn_caja_huella_emergencia($codigo);
foreach ($huellas as $i => $guardada) {
if (hash_equals($guardada, $buscada)) {
unset($huellas[$i]);
bdn_caja_ejecutar(
"UPDATE $tabla SET codigos_emergencia = %s WHERE id = %d",
[wp_json_encode(array_values($huellas)), $usuario['id']]
);
return true;
}
}
return false;
}
// ****************************************************************************
// 110 · ACCIONES: CUENTA
// ****************************************************************************
// Cada función "bdn_caja_accion_..." es una acción que el navegador puede
// pedir. Recibe $d: los datos enviados (ya convertidos a array).
/**
* Busca un usuario por nombre.
*
* @param string $usuario
* @return array|null
*/
function bdn_caja_buscar_usuario($usuario)
{
return bdn_caja_fila('SELECT * FROM ' . bdn_caja_tabla('usuarios') . ' WHERE usuario = %s', [$usuario]);
}
/**
* Busca un usuario por su ID interno.
*
* @param int $id
* @return array|null
*/
function bdn_caja_buscar_usuario_id($id)
{
return bdn_caja_fila('SELECT * FROM ' . bdn_caja_tabla('usuarios') . ' WHERE id = %d', [$id]);
}
/**
* ACCIÓN "sales" · Recibe { usuario } · Devuelve { sal, sal_recuperacion, iteraciones }
* Primer paso para entrar: la sal con la que el navegador fabrica las
* claves. Si el usuario no existe, sales falsas pero estables.
*/
function bdn_caja_accion_sales(array $d)
{
$usuario = bdn_caja_validar_usuario($d['usuario'] ?? '');
$fila = bdn_caja_buscar_usuario($usuario);
if ($fila) {
bdn_caja_responder([
'sal' => $fila['sal'],
'sal_recuperacion' => $fila['sal_recuperacion'],
'iteraciones' => (int) $fila['iteraciones'],
]);
}
bdn_caja_responder([
'sal' => bdn_caja_sal_falsa($usuario, 'contrasena'),
'sal_recuperacion' => bdn_caja_sal_falsa($usuario, 'recuperacion'),
'iteraciones' => BDN_CAJA_ITERACIONES,
]);
}
/**
* ACCIÓN "alta" · Crea la cuenta y deja la sesión abierta.
* Recibe { usuario, sal, iteraciones, clave_acceso, dek_contrasena,
* sal_recuperacion, clave_acceso_recuperacion, dek_recuperacion }
* Todo lo criptográfico viene hecho del navegador. La contraseña y la
* clave de recuperación NUNCA llegan aquí.
*/
function bdn_caja_accion_alta(array $d)
{
global $wpdb;
$vigilado = 'alta:' . bdn_caja_ip();
bdn_caja_comprobar_bloqueo($vigilado);
$usuario = bdn_caja_validar_usuario($d['usuario'] ?? '');
$paquete = bdn_caja_validar_paquete($d);
$rec_sal = bdn_caja_validar_base64($d['sal_recuperacion'] ?? null, 16, 16, 'sal_recuperacion');
$rec_clave = bdn_caja_validar_base64($d['clave_acceso_recuperacion'] ?? null, 32, 32, 'clave_acceso_recuperacion');
$rec_dek = bdn_caja_validar_base64($d['dek_recuperacion'] ?? null, 60, 60, 'dek_recuperacion');
if (bdn_caja_buscar_usuario($usuario)) {
bdn_caja_error('usuario_ocupado', 409);
}
$ok = $wpdb->insert(bdn_caja_tabla('usuarios'), [
'usuario' => $usuario,
'sal' => $paquete['sal'],
'iteraciones' => $paquete['iteraciones'],
'hash_acceso' => bdn_caja_huella($paquete['clave_acceso']),
'dek_contrasena' => $paquete['dek_contrasena'],
'sal_recuperacion' => $rec_sal,
'hash_recuperacion' => bdn_caja_huella($rec_clave),
'dek_recuperacion' => $rec_dek,
'creado' => time(),
'ultimo_acceso' => time(),
], ['%s', '%s', '%d', '%s', '%s', '%s', '%s', '%s', '%d', '%d']);
if ($ok === false) {
// Dos altas a la vez con el mismo nombre: el índice único lo impide.
if (stripos($wpdb->last_error, 'duplicate') !== false || stripos($wpdb->last_error, 'unique') !== false) {
bdn_caja_error('usuario_ocupado', 409);
}
bdn_caja_comprobar_bd();
}
bdn_caja_registrar_fallo($vigilado, BDN_CAJA_ALTAS_IP_HORA, 3600); // cuenta para el límite por hora
bdn_caja_abrir_sesion_usuario((int) $wpdb->insert_id);
bdn_caja_responder();
}
/**
* ACCIÓN "entrar" · Recibe { usuario, clave_acceso, codigo? }
* Devuelve { dek_contrasena, dosfa } o { necesita_codigo: true }.
* La clave maestra se devuelve ENVUELTA: solo la contraseña la abre.
*/
function bdn_caja_accion_entrar(array $d)
{
$usuario = bdn_caja_validar_usuario($d['usuario'] ?? '');
$clave = bdn_caja_validar_base64($d['clave_acceso'] ?? null, 32, 32, 'clave_acceso');
$vig_usuario = 'u:' . $usuario;
$vig_ip = 'ip:' . bdn_caja_ip();
bdn_caja_comprobar_bloqueo($vig_usuario);
bdn_caja_comprobar_bloqueo($vig_ip);
$fila = bdn_caja_buscar_usuario($usuario);
// Siempre una comprobación de huella, exista o no: mismo tiempo de respuesta.
$correcta = password_verify($clave, $fila ? $fila['hash_acceso'] : bdn_caja_huella_falsa());
if (!$fila || !$correcta) {
bdn_caja_registrar_fallo($vig_usuario, BDN_CAJA_INTENTOS_USUARIO);
bdn_caja_registrar_fallo($vig_ip, BDN_CAJA_INTENTOS_IP);
bdn_caja_error('credenciales_incorrectas', 401);
}
$dosfa = !empty($fila['totp_secreto']);
if ($dosfa) {
if (!isset($d['codigo']) || $d['codigo'] === '') {
bdn_caja_responder(['necesita_codigo' => true]);
}
if (!bdn_caja_comprobar_2fa($fila, bdn_caja_validar_codigo($d['codigo']))) {
bdn_caja_registrar_fallo($vig_usuario, BDN_CAJA_INTENTOS_USUARIO);
bdn_caja_registrar_fallo($vig_ip, BDN_CAJA_INTENTOS_IP);
bdn_caja_error('codigo_incorrecto', 401);
}
}
bdn_caja_limpiar_fallos($vig_usuario);
$tabla = bdn_caja_tabla('usuarios');
if (password_needs_rehash($fila['hash_acceso'], defined('PASSWORD_ARGON2ID') ? PASSWORD_ARGON2ID : PASSWORD_DEFAULT)) {
bdn_caja_ejecutar("UPDATE $tabla SET hash_acceso = %s WHERE id = %d", [bdn_caja_huella($clave), $fila['id']]);
}
bdn_caja_ejecutar("UPDATE $tabla SET ultimo_acceso = %d WHERE id = %d", [time(), $fila['id']]);
bdn_caja_abrir_sesion_usuario((int) $fila['id']);
bdn_caja_responder(['dek_contrasena' => $fila['dek_contrasena'], 'dosfa' => $dosfa]);
}
/**
* ACCIÓN "salir" · Cierra la sesión en el servidor.
*/
function bdn_caja_accion_salir(array $d)
{
bdn_caja_cerrar_sesion();
bdn_caja_responder();
}
/**
* ACCIÓN "estado" · Devuelve { usuario, dosfa, codigos_restantes, creado }.
*/
function bdn_caja_accion_estado(array $d)
{
$fila = bdn_caja_buscar_usuario_id(bdn_caja_usuario_actual());
if (!$fila) {
bdn_caja_cerrar_sesion();
bdn_caja_error('sesion_caducada', 401);
}
$codigos = json_decode((string) $fila['codigos_emergencia'], true) ?: [];
bdn_caja_responder([
'usuario' => $fila['usuario'],
'dosfa' => !empty($fila['totp_secreto']),
'codigos_restantes' => count($codigos),
'creado' => (int) $fila['creado'],
]);
}
// ****************************************************************************
// 120 · ACCIONES: CONTRASEÑA
// ****************************************************************************
/**
* ACCIÓN "cambiar_contrasena"
* Recibe { clave_acceso_actual, sal, iteraciones, clave_acceso, dek_contrasena }
* Solo cambia el "envoltorio" de la clave maestra: los textos NO se
* vuelven a cifrar.
*/
function bdn_caja_accion_cambiar_contrasena(array $d)
{
$fila = bdn_caja_buscar_usuario_id(bdn_caja_usuario_actual());
if (!$fila) {
bdn_caja_error('sesion_caducada', 401);
}
$vigilado = 'u:' . $fila['usuario'];
bdn_caja_comprobar_bloqueo($vigilado);
$actual = bdn_caja_validar_base64($d['clave_acceso_actual'] ?? null, 32, 32, 'clave_acceso_actual');
if (!password_verify($actual, $fila['hash_acceso'])) {
bdn_caja_registrar_fallo($vigilado, BDN_CAJA_INTENTOS_USUARIO);
bdn_caja_error('contrasena_actual_incorrecta', 401);
}
$nueva = bdn_caja_validar_paquete($d);
bdn_caja_ejecutar(
'UPDATE ' . bdn_caja_tabla('usuarios') . ' SET sal = %s, iteraciones = %d, hash_acceso = %s, dek_contrasena = %s WHERE id = %d',
[$nueva['sal'], $nueva['iteraciones'], bdn_caja_huella($nueva['clave_acceso']), $nueva['dek_contrasena'], $fila['id']]
);
bdn_caja_responder();
}
/**
* ACCIÓN "recuperar" (con la CLAVE DE RECUPERACIÓN), en dos pasos:
* Paso 1 · Recibe { usuario, clave_acceso_recuperacion }
* Devuelve { dek_recuperacion }
* Paso 2 · Recibe además { sal, iteraciones, clave_acceso, dek_contrasena }
* Guarda la contraseña nueva. Devuelve { dosfa_desactivado }
* Recuperar DESACTIVA el doble factor (la clave de recuperación es la
* llave de último recurso, también para quien ha perdido el móvil).
*/
function bdn_caja_accion_recuperar(array $d)
{
$usuario = bdn_caja_validar_usuario($d['usuario'] ?? '');
$clave = bdn_caja_validar_base64($d['clave_acceso_recuperacion'] ?? null, 32, 32, 'clave_acceso_recuperacion');
$vig_usuario = 'r:' . $usuario;
$vig_ip = 'ip:' . bdn_caja_ip();
bdn_caja_comprobar_bloqueo($vig_usuario);
bdn_caja_comprobar_bloqueo($vig_ip);
$fila = bdn_caja_buscar_usuario($usuario);
$correcta = password_verify($clave, $fila ? $fila['hash_recuperacion'] : bdn_caja_huella_falsa());
if (!$fila || !$correcta) {
bdn_caja_registrar_fallo($vig_usuario, BDN_CAJA_INTENTOS_USUARIO);
bdn_caja_registrar_fallo($vig_ip, BDN_CAJA_INTENTOS_IP);
bdn_caja_error('recuperacion_incorrecta', 401);
}
// Paso 1.
if (!isset($d['clave_acceso'])) {
bdn_caja_responder(['dek_recuperacion' => $fila['dek_recuperacion']]);
}
// Paso 2.
$nueva = bdn_caja_validar_paquete($d);
bdn_caja_ejecutar(
'UPDATE ' . bdn_caja_tabla('usuarios') . ' SET sal = %s, iteraciones = %d, hash_acceso = %s, dek_contrasena = %s,
totp_secreto = NULL, totp_ultimo_paso = NULL, codigos_emergencia = NULL WHERE id = %d',
[$nueva['sal'], $nueva['iteraciones'], bdn_caja_huella($nueva['clave_acceso']), $nueva['dek_contrasena'], $fila['id']]
);
bdn_caja_limpiar_fallos($vig_usuario);
bdn_caja_limpiar_fallos('u:' . $usuario);
bdn_caja_responder(['dosfa_desactivado' => !empty($fila['totp_secreto'])]);
}
// ****************************************************************************
// 130 · ACCIONES: DOCUMENTOS
// ****************************************************************************
/**
* ACCIÓN "documentos" · Devuelve TODOS los documentos, cifrados:
* { documentos: [ { id, contenido, creado, modificado } ] }
* El navegador los abre, monta el índice y busca dentro.
*/
function bdn_caja_accion_documentos(array $d)
{
$documentos = bdn_caja_filas(
'SELECT id, contenido, creado, modificado FROM ' . bdn_caja_tabla('documentos') . ' WHERE usuario_id = %d ORDER BY modificado DESC',
[bdn_caja_usuario_actual()]
);
foreach ($documentos as &$doc) {
$doc['creado'] = (int) $doc['creado'];
$doc['modificado'] = (int) $doc['modificado'];
}
unset($doc);
bdn_caja_responder(['documentos' => $documentos]);
}
/**
* ACCIÓN "guardar" · Recibe { id, contenido } · Devuelve { creado, modificado }
* Crea o actualiza. El ID va "atado" al cifrado: si alguien intercambiara
* el contenido de dos documentos, el navegador lo detectaría.
*/
function bdn_caja_accion_guardar(array $d)
{
$usuario_id = bdn_caja_usuario_actual();
$id = bdn_caja_validar_id($d['id'] ?? '');
$contenido = bdn_caja_validar_base64($d['contenido'] ?? null, 29, BDN_CAJA_TAMANO_MAX, 'contenido');
$tabla = bdn_caja_tabla('documentos');
$ahora = time();
$existente = bdn_caja_fila("SELECT usuario_id, creado FROM $tabla WHERE id = %s", [$id]);
if (!$existente) {
$total = (int) bdn_caja_fila("SELECT COUNT(*) AS n FROM $tabla WHERE usuario_id = %d", [$usuario_id])['n'];
if ($total >= BDN_CAJA_DOCUMENTOS_MAX) {
bdn_caja_error('limite_documentos', 403);
}
bdn_caja_ejecutar(
"INSERT INTO $tabla (id, usuario_id, contenido, creado, modificado) VALUES (%s, %d, %s, %d, %d)",
[$id, $usuario_id, $contenido, $ahora, $ahora]
);
bdn_caja_responder(['creado' => $ahora, 'modificado' => $ahora]);
}
// Ya existe: solo su dueño puede tocarlo.
if ((int) $existente['usuario_id'] !== $usuario_id) {
bdn_caja_error('no_permitido', 403);
}
bdn_caja_ejecutar(
"UPDATE $tabla SET contenido = %s, modificado = %d WHERE id = %s AND usuario_id = %d",
[$contenido, $ahora, $id, $usuario_id]
);
bdn_caja_responder(['creado' => (int) $existente['creado'], 'modificado' => $ahora]);
}
/**
* ACCIÓN "borrar" · Recibe { id } · Borra un documento para siempre.
*/
function bdn_caja_accion_borrar(array $d)
{
bdn_caja_ejecutar(
'DELETE FROM ' . bdn_caja_tabla('documentos') . ' WHERE id = %s AND usuario_id = %d',
[bdn_caja_validar_id($d['id'] ?? ''), bdn_caja_usuario_actual()]
);
bdn_caja_responder();
}
// ****************************************************************************
// 140 · ACCIONES: DOBLE FACTOR
// ****************************************************************************
/**
* Crea 8 códigos de emergencia de un solo uso (10 caracteres, sin
* caracteres confusos como 0/O o 1/I).
*
* @return array{codigos: string[], huellas: string[]}
*/
function bdn_caja_crear_codigos_emergencia()
{
$alfabeto = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
$codigos = [];
$huellas = [];
for ($i = 0; $i < 8; $i++) {
$codigo = '';
for ($j = 0; $j < 10; $j++) {
$codigo .= $alfabeto[random_int(0, strlen($alfabeto) - 1)];
}
$codigos[] = $codigo;
$huellas[] = bdn_caja_huella_emergencia($codigo);
}
return ['codigos' => $codigos, 'huellas' => $huellas];
}
/**
* ACCIÓN "dosfa_preparar" · Devuelve { secreto, enlace } para el QR.
* Aún no queda activado: se guarda en la sesión hasta confirmar con un código.
*/
function bdn_caja_accion_dosfa_preparar(array $d)
{
$fila = bdn_caja_buscar_usuario_id(bdn_caja_usuario_actual());
if (!$fila) {
bdn_caja_error('sesion_caducada', 401);
}
if (!empty($fila['totp_secreto'])) {
bdn_caja_error('dosfa_ya_activo', 409);
}
$secreto = bdn_caja_base32_codificar(random_bytes(20));
$_SESSION['totp_pendiente'] = $secreto;
bdn_caja_responder([
'secreto' => $secreto,
'enlace' => 'otpauth://totp/' . rawurlencode('Caja Negra:' . $fila['usuario'])
. '?secret=' . $secreto . '&issuer=' . rawurlencode('Caja Negra')
. '&algorithm=SHA1&digits=6&period=30',
]);
}
/**
* ACCIÓN "dosfa_activar" · Recibe { codigo } · Devuelve { codigos } (8 de emergencia).
*/
function bdn_caja_accion_dosfa_activar(array $d)
{
$usuario_id = bdn_caja_usuario_actual();
$secreto = $_SESSION['totp_pendiente'] ?? null;
if (!$secreto) {
bdn_caja_error('dosfa_no_preparado');
}
$codigo = bdn_caja_validar_codigo($d['codigo'] ?? '');
$paso = strlen($codigo) === 6 ? bdn_caja_totp_verificar($secreto, $codigo, null) : false;
if ($paso === false) {
bdn_caja_error('codigo_incorrecto');
}
$emergencia = bdn_caja_crear_codigos_emergencia();
bdn_caja_ejecutar(
'UPDATE ' . bdn_caja_tabla('usuarios') . ' SET totp_secreto = %s, totp_ultimo_paso = %d, codigos_emergencia = %s WHERE id = %d',
[bdn_caja_totp_cifrar($secreto), $paso, wp_json_encode($emergencia['huellas']), $usuario_id]
);
unset($_SESSION['totp_pendiente']);
bdn_caja_responder(['codigos' => $emergencia['codigos']]);
}
/**
* ACCIÓN "dosfa_desactivar" · Recibe { clave_acceso, codigo }.
*/
function bdn_caja_accion_dosfa_desactivar(array $d)
{
$fila = bdn_caja_buscar_usuario_id(bdn_caja_usuario_actual());
if (!$fila) {
bdn_caja_error('sesion_caducada', 401);
}
$vigilado = 'u:' . $fila['usuario'];
bdn_caja_comprobar_bloqueo($vigilado);
$clave = bdn_caja_validar_base64($d['clave_acceso'] ?? null, 32, 32, 'clave_acceso');
if (!password_verify($clave, $fila['hash_acceso'])) {
bdn_caja_registrar_fallo($vigilado, BDN_CAJA_INTENTOS_USUARIO);
bdn_caja_error('contrasena_actual_incorrecta', 401);
}
$codigo = bdn_caja_validar_codigo($d['codigo'] ?? '');
if (empty($fila['totp_secreto']) || !bdn_caja_comprobar_2fa($fila, $codigo)) {
bdn_caja_registrar_fallo($vigilado, BDN_CAJA_INTENTOS_USUARIO);
bdn_caja_error('codigo_incorrecto', 401);
}
bdn_caja_ejecutar(
'UPDATE ' . bdn_caja_tabla('usuarios') . ' SET totp_secreto = NULL, totp_ultimo_paso = NULL, codigos_emergencia = NULL WHERE id = %d',
[$fila['id']]
);
bdn_caja_responder();
}
// ****************************************************************************
// 150 · ACCIONES: BAJA
// ****************************************************************************
/**
* ACCIÓN "borrar_cuenta" · Recibe { clave_acceso, codigo? }
* Borra la cuenta y TODOS sus textos para siempre (derecho de supresión).
*/
function bdn_caja_accion_borrar_cuenta(array $d)
{
$fila = bdn_caja_buscar_usuario_id(bdn_caja_usuario_actual());
if (!$fila) {
bdn_caja_error('sesion_caducada', 401);
}
$vigilado = 'u:' . $fila['usuario'];
bdn_caja_comprobar_bloqueo($vigilado);
$clave = bdn_caja_validar_base64($d['clave_acceso'] ?? null, 32, 32, 'clave_acceso');
if (!password_verify($clave, $fila['hash_acceso'])) {
bdn_caja_registrar_fallo($vigilado, BDN_CAJA_INTENTOS_USUARIO);
bdn_caja_error('contrasena_actual_incorrecta', 401);
}
if (!empty($fila['totp_secreto'])
&& !bdn_caja_comprobar_2fa($fila, bdn_caja_validar_codigo($d['codigo'] ?? ''))) {
bdn_caja_registrar_fallo($vigilado, BDN_CAJA_INTENTOS_USUARIO);
bdn_caja_error('codigo_incorrecto', 401);
}
bdn_caja_ejecutar('DELETE FROM ' . bdn_caja_tabla('documentos') . ' WHERE usuario_id = %d', [$fila['id']]);
bdn_caja_ejecutar('DELETE FROM ' . bdn_caja_tabla('usuarios') . ' WHERE id = %d', [$fila['id']]);
bdn_caja_limpiar_fallos($vigilado);
bdn_caja_cerrar_sesion();
bdn_caja_responder();
}
// ****************************************************************************
// 160 · API: PUERTA DE ENTRADA
// ****************************************************************************
/**
* Atiende una petición a /diario/api/.
* · Solo POST con la cabecera "X-CajaNegra: 1" (un navegador no deja que
* otra web envíe esa cabecera: bloquea los ataques CSRF).
* · Lee el JSON con límite de tamaño.
* · Llama a la acción pedida, de una lista CERRADA.
* · Los fallos internos van al registro de errores del hosting con la
* marca [cajanegra], nunca a la pantalla.
*/
function bdn_caja_api()
{
global $wpdb;
$wpdb->hide_errors(); // que WordPress no imprima errores de BD
$wpdb->suppress_errors(true);
header('Content-Type: application/json; charset=utf-8');
header("Content-Security-Policy: default-src 'none'; frame-ancestors 'none'");
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST' || ($_SERVER['HTTP_X_CAJANEGRA'] ?? '') !== '1') {
bdn_caja_error('peticion_no_valida', 405);
}
$limite = (int) (BDN_CAJA_TAMANO_MAX * 1.4) + 4096;
$cuerpo = file_get_contents('php://input', false, null, 0, $limite + 1);
if ($cuerpo === false || strlen($cuerpo) > $limite) {
bdn_caja_error('demasiado_grande', 413);
}
$datos = json_decode($cuerpo, true);
if (!is_array($datos) || !isset($datos['accion']) || !is_string($datos['accion'])) {
bdn_caja_error('peticion_no_valida');
}
$acciones = [
'sales' => 'bdn_caja_accion_sales',
'alta' => 'bdn_caja_accion_alta',
'entrar' => 'bdn_caja_accion_entrar',
'salir' => 'bdn_caja_accion_salir',
'estado' => 'bdn_caja_accion_estado',
'cambiar_contrasena' => 'bdn_caja_accion_cambiar_contrasena',
'recuperar' => 'bdn_caja_accion_recuperar',
'documentos' => 'bdn_caja_accion_documentos',
'guardar' => 'bdn_caja_accion_guardar',
'borrar' => 'bdn_caja_accion_borrar',
'dosfa_preparar' => 'bdn_caja_accion_dosfa_preparar',
'dosfa_activar' => 'bdn_caja_accion_dosfa_activar',
'dosfa_desactivar' => 'bdn_caja_accion_dosfa_desactivar',
'borrar_cuenta' => 'bdn_caja_accion_borrar_cuenta',
];
if (!isset($acciones[$datos['accion']])) {
bdn_caja_error('accion_desconocida', 404);
}
try {
bdn_caja_iniciar_sesion();
call_user_func($acciones[$datos['accion']], $datos); // cada acción responde y termina
} catch (Throwable $e) {
error_log('[cajanegra] ' . get_class($e) . ': ' . $e->getMessage() . ' en ' . $e->getFile() . ':' . $e->getLine());
bdn_caja_error('error_servidor', 500);
}
}
// ****************************************************************************
// 170 · CABECERAS DE SEGURIDAD
// ****************************************************************************
/**
* Cabeceras que se envían con la página y los archivos de la app.
* La más importante es la Content-Security-Policy: el navegador SOLO
* ejecutará código que venga de esta misma web y de esta app. Nada de
* scripts externos (analíticas, anuncios, chats, Google Fonts...).
*/
function bdn_caja_cabeceras_seguridad()
{
header("Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'");
header('X-Frame-Options: DENY');
header('X-Content-Type-Options: nosniff');
header('Referrer-Policy: no-referrer');
header('Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=()');
header('Cross-Origin-Opener-Policy: same-origin');
header('X-Robots-Tag: noindex, nofollow');
if (is_ssl()) {
header('Strict-Transport-Security: max-age=31536000');
}
}
/**
* Pide a los plugins de caché (WP Rocket, LiteSpeed, W3TC...) que NO
* guarden ni modifiquen esta respuesta.
*/
function bdn_caja_sin_cache()
{
if (!defined('DONOTCACHEPAGE')) {
define('DONOTCACHEPAGE', true);
}
if (!defined('DONOTMINIFY')) {
define('DONOTMINIFY', true);
}
nocache_headers();
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('X-LiteSpeed-Cache-Control: no-cache');
}
// ****************************************************************************
// 180 · ENRUTADOR DE /diario/
// ****************************************************************************
/**
* Archivos de la app: ruta dentro de /diario/ → [tipo, función que devuelve el contenido].
*
* @return array
*/
function bdn_caja_archivos()
{
return [
'' => ['text/html; charset=utf-8', 'bdn_caja_archivo_index_html'],
'css/estilos.css' => ['text/css; charset=utf-8', 'bdn_caja_archivo_estilos_css'],
'js/textos-es.js' => ['text/javascript; charset=utf-8', 'bdn_caja_archivo_textos_js'],
'js/cripto.js' => ['text/javascript; charset=utf-8', 'bdn_caja_archivo_cripto_js'],
'js/api.js' => ['text/javascript; charset=utf-8', 'bdn_caja_archivo_api_js'],
'js/app.js' => ['text/javascript; charset=utf-8', 'bdn_caja_archivo_app_js'],
'js/vendor/qrcode.mjs' => ['text/javascript; charset=utf-8', 'bdn_caja_archivo_qrcode_js'],
'icono.svg' => ['image/svg+xml', 'bdn_caja_archivo_icono_svg'],
];
}
/**
* Si la dirección pedida es de la app (/diario/...), la atiende y termina,
* SIN cargar el tema ni pasar por las plantillas de WordPress.
* Si no, no hace nada y WordPress sigue como siempre.
*/
add_action('parse_request', function () {
// Dirección pedida, sin el dominio ni la carpeta de WordPress.
$ruta = (string) parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
$base = (string) parse_url(home_url('/'), PHP_URL_PATH);
if ($base !== '' && strpos($ruta, $base) === 0) {
$ruta = substr($ruta, strlen($base));
}
$ruta = ltrim($ruta, '/');
// No es de la app: WordPress sigue normal.
if ($ruta !== BDN_CAJA_RUTA && strpos($ruta, BDN_CAJA_RUTA . '/') !== 0) {
return;
}
// La app solo funciona con HTTPS (salvo en pruebas en el propio ordenador).
$local = in_array($_SERVER['HTTP_HOST'] ?? '', ['localhost', '127.0.0.1'], true)
|| strpos($_SERVER['HTTP_HOST'] ?? '', '127.0.0.1:') === 0
|| strpos($_SERVER['HTTP_HOST'] ?? '', 'localhost:') === 0;
if (!is_ssl() && !$local) {
wp_redirect('https://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'], 301);
exit;
}
// "/diario" sin barra final → "/diario/" (para que las rutas relativas funcionen).
if ($ruta === BDN_CAJA_RUTA) {
wp_redirect(home_url('/' . BDN_CAJA_RUTA . '/'), 301);
exit;
}
$dentro = (string) substr($ruta, strlen(BDN_CAJA_RUTA) + 1); // lo que va tras "diario/"
bdn_caja_sin_cache();
// La API.
if ($dentro === 'api' || $dentro === 'api/') {
status_header(200);
bdn_caja_api();
exit;
}
// La página o uno de sus archivos.
bdn_caja_cabeceras_seguridad();
$archivos = bdn_caja_archivos();
if (!isset($archivos[$dentro])) {
status_header(404);
header('Content-Type: text/plain; charset=utf-8');
echo 'No encontrado';
exit;
}
list($tipo, $funcion) = $archivos[$dentro];
status_header(200);
header('Content-Type: ' . $tipo);
echo call_user_func($funcion);
exit;
}, 0);
// ****************************************************************************
// 190 · ARCHIVO: index.html
// ****************************************************************************
// Página única de la app. A partir de aquí, cada bloque contiene un archivo
// de la app tal cual (HTML, CSS o JavaScript). Van entre marcas <<<'...'
// para que PHP no interprete nada de su contenido.
function bdn_caja_archivo_index_html()
{
return <<<'CAJANEGRA_FIN_INDEX_HTML'
Caja Negra · Tu diario
CAJANEGRA_FIN_INDEX_HTML;
}
// ****************************************************************************
// 200 · ARCHIVO: estilos.css
// ****************************************************************************
function bdn_caja_archivo_estilos_css()
{
return <<<'CAJANEGRA_FIN_ESTILOS_CSS'
/* =====================================================================
CAJA NEGRA · ESTILOS
---------------------------------------------------------------------
Estética: papel cálido, tinta oscura, letra de libro para escribir.
Íntimo y sereno, sin distracciones.
Los colores están definidos como variables al principio. Para
cambiar el aspecto de toda la app, basta con tocar esas variables.
Hay versión clara y oscura (sigue la del dispositivo).
NOTA: no se cargan fuentes de internet (Google Fonts, etc.): la
política de seguridad lo impide y además no las necesitamos. Se usan
las tipografías que ya trae cada dispositivo.
===================================================================== */
/* ---------------------------------------------------------------------
1. VARIABLES (colores, tipografías, medidas)
------------------------------------------------------------------- */
:root {
--fondo: #f4eee6; /* papel */
--superficie: #fffcf7; /* hoja/tarjeta */
--tinta: #2a2421; /* texto principal */
--tinta-suave: #6f655d; /* texto secundario */
--linea: #e4d9cc; /* bordes */
--acento: #b5471c; /* naranja oscuro de "caja negra": botones principales */
--acento-hover: #963a16;
--acento-texto: #fffaf5;
--peligro: #b3261e;
--peligro-fondo: #fbeceb;
--marca-fondo: #f4d58d; /* resaltado del buscador */
--nota-fondo: #f8ecd9; /* cajas de aviso importante */
--sombra: 0 1px 2px rgb(60 40 20 / 6%), 0 8px 24px rgb(60 40 20 / 8%);
--letra-texto: ui-serif, "Iowan Old Style", "Palatino Linotype", Palatino, "Book Antiqua", Georgia, serif;
--letra-ui: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--letra-mono: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
--radio: 12px;
--ancho-max: 760px;
color-scheme: light;
}
/* Modo oscuro automático */
@media (prefers-color-scheme: dark) {
:root {
--fondo: #1b1816;
--superficie: #24201d;
--tinta: #ece5dc;
--tinta-suave: #a69c92;
--linea: #3a3330;
--acento: #ef8a5a;
--acento-hover: #f59e74;
--acento-texto: #1b1816;
--peligro: #f2867d;
--peligro-fondo: #3a1f1c;
--marca-fondo: #6e5320;
--nota-fondo: #33291f;
--sombra: 0 1px 2px rgb(0 0 0 / 30%), 0 8px 24px rgb(0 0 0 / 30%);
color-scheme: dark;
}
}
/* ---------------------------------------------------------------------
2. BASE
------------------------------------------------------------------- */
*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
min-height: 100vh;
min-height: 100dvh;
background: var(--fondo);
color: var(--tinta);
font-family: var(--letra-ui);
font-size: 16px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
/* El atributo hidden SIEMPRE oculta, aunque otra regla diga lo contrario. */
[hidden] { display: none !important; }
button, input, textarea { font: inherit; color: inherit; }
:focus-visible {
outline: 2px solid var(--acento);
outline-offset: 2px;
}
.aviso-sin-js {
max-width: 32rem;
margin: 3rem auto;
padding: 1rem;
text-align: center;
}
/* ---------------------------------------------------------------------
3. PANTALLAS
------------------------------------------------------------------- */
.pantalla {
min-height: 100vh;
min-height: 100dvh;
}
/* Pantallas con una tarjeta centrada (acceso, alta, recuperar...) */
.pantalla--centrada {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem 16px;
}
.tarjeta {
width: 100%;
max-width: 420px;
background: var(--superficie);
border: 1px solid var(--linea);
border-radius: calc(var(--radio) + 4px);
box-shadow: var(--sombra);
padding: 2rem 1.5rem;
}
.tarjeta__titulo {
font-family: var(--letra-texto);
font-weight: 500;
font-size: 1.6rem;
line-height: 1.2;
margin: 0.5rem 0 0.5rem;
}
.tarjeta__texto {
color: var(--tinta-suave);
margin: 0 0 1.5rem;
}
.pie-seguridad {
max-width: 420px;
margin: 1.25rem 0 0;
color: var(--tinta-suave);
font-size: 0.85rem;
text-align: center;
}
/* ---------------------------------------------------------------------
4. MARCA
------------------------------------------------------------------- */
.marca {
text-align: center;
margin-bottom: 1.75rem;
}
/* Logo dibujado con CSS: una "caja negra" (cubo oscuro con una franja
de color, como las cajas negras de los aviones, que en realidad son
naranjas). Colores fijos, iguales en modo claro y oscuro. */
.marca__logo {
display: inline-block;
width: 46px;
height: 46px;
border-radius: 10px;
background: #1f1b19;
position: relative;
margin-bottom: 0.6rem;
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 8%);
}
.marca__logo::after {
content: "";
position: absolute;
left: 9px;
right: 9px;
top: 20px;
height: 6px;
border-radius: 3px;
background: #e8642c;
}
.marca__nombre {
font-family: var(--letra-texto);
font-style: italic;
font-weight: 500;
font-size: 2.2rem;
letter-spacing: -0.01em;
margin: 0;
line-height: 1.1;
}
.marca__lema {
color: var(--tinta-suave);
margin: 0.4rem 0 0;
}
/* ---------------------------------------------------------------------
5. FORMULARIOS
------------------------------------------------------------------- */
.formulario {
display: flex;
flex-direction: column;
gap: 1rem;
}
.formulario__intro { margin: 0; }
.formulario__ayuda {
margin: -0.25rem 0 0;
font-size: 0.85rem;
color: var(--tinta-suave);
}
.formulario__error {
margin: 0;
padding: 0.6rem 0.8rem;
border-radius: 8px;
background: var(--peligro-fondo);
color: var(--peligro);
font-size: 0.9rem;
}
.campo {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.campo__etiqueta {
font-size: 0.9rem;
font-weight: 600;
}
.campo__ayuda {
font-size: 0.8rem;
color: var(--tinta-suave);
}
.campo input {
width: 100%;
padding: 0.7rem 0.85rem;
border: 1px solid var(--linea);
border-radius: 10px;
background: var(--fondo);
transition: border-color 0.15s, background-color 0.15s;
}
.campo input:focus {
outline: none;
border-color: var(--acento);
background: var(--superficie);
}
.mono { font-family: var(--letra-mono); letter-spacing: 0.04em; }
.casilla {
display: flex;
gap: 0.6rem;
align-items: flex-start;
font-size: 0.9rem;
cursor: pointer;
}
.casilla input {
margin-top: 0.2rem;
width: 1.1rem;
height: 1.1rem;
accent-color: var(--acento);
flex-shrink: 0;
}
/* Medidor de fuerza de la contraseña */
.medidor {
display: block;
height: 4px;
border-radius: 2px;
background: var(--linea);
overflow: hidden;
}
.medidor__barra {
display: block;
height: 100%;
width: 0;
background: var(--peligro);
transition: width 0.2s, background-color 0.2s;
}
.medidor[data-nivel="0"] .medidor__barra { width: 8%; background: var(--peligro); }
.medidor[data-nivel="1"] .medidor__barra { width: 30%; background: var(--peligro); }
.medidor[data-nivel="2"] .medidor__barra { width: 55%; background: #c98a1b; }
.medidor[data-nivel="3"] .medidor__barra { width: 80%; background: #4f8a4b; }
.medidor[data-nivel="4"] .medidor__barra { width: 100%; background: #2f7a3f; }
/* Cajas de aviso */
.nota {
padding: 0.85rem 1rem;
border-radius: 10px;
font-size: 0.9rem;
}
.nota--importante {
background: var(--nota-fondo);
border-left: 3px solid var(--acento);
}
.tarjeta > .nota { margin-bottom: 1.25rem; }
.separador {
display: flex;
align-items: center;
gap: 0.75rem;
margin: 1.5rem 0 1rem;
color: var(--tinta-suave);
font-size: 0.85rem;
}
.separador::before, .separador::after {
content: "";
flex: 1;
height: 1px;
background: var(--linea);
}
/* ---------------------------------------------------------------------
6. BOTONES Y ENLACES
------------------------------------------------------------------- */
.boton {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
padding: 0.72rem 1.15rem;
border-radius: 10px;
border: 1px solid transparent;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
transition: background-color 0.15s, border-color 0.15s, opacity 0.15s;
}
.boton:disabled { opacity: 0.5; cursor: not-allowed; }
.boton--principal { background: var(--acento); color: var(--acento-texto); }
.boton--principal:hover:not(:disabled) { background: var(--acento-hover); }
.boton--secundario {
background: transparent;
border-color: var(--linea);
color: var(--tinta);
}
.boton--secundario:hover:not(:disabled) { border-color: var(--tinta-suave); }
.boton--peligro { background: var(--peligro); color: #fff; }
.boton--peligro:hover:not(:disabled) { filter: brightness(1.08); }
.tarjeta > .boton { width: 100%; }
.enlace, .volver {
background: none;
border: 0;
padding: 0.25rem 0;
color: var(--tinta-suave);
cursor: pointer;
font-size: 0.9rem;
text-decoration: underline;
text-underline-offset: 3px;
align-self: center;
}
.enlace:hover, .volver:hover { color: var(--tinta); }
.volver {
text-decoration: none;
align-self: flex-start;
margin-bottom: 0.5rem;
}
.fila-botones {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.tarjeta .fila-botones { justify-content: stretch; margin-bottom: 1rem; }
.tarjeta .fila-botones .boton { flex: 1; }
.boton-icono {
width: 40px;
height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 10px;
border: 0;
background: transparent;
color: var(--tinta-suave);
cursor: pointer;
}
.boton-icono:hover { background: var(--linea); color: var(--tinta); }
.boton-icono--peligro:hover { background: var(--peligro-fondo); color: var(--peligro); }
.boton-icono svg {
width: 20px;
height: 20px;
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
/* ---------------------------------------------------------------------
7. CLAVE DE RECUPERACIÓN Y CÓDIGOS
------------------------------------------------------------------- */
.clave-recuperacion {
display: block;
margin: 0 0 1rem;
padding: 1rem;
border: 1px dashed var(--acento);
border-radius: 10px;
background: var(--fondo);
font-family: var(--letra-mono);
font-size: 1.05rem;
line-height: 1.7;
letter-spacing: 0.06em;
text-align: center;
word-spacing: 0.2em;
white-space: pre-line; /* respeta el salto entre las dos mitades */
user-select: all; /* un clic selecciona toda la clave */
overflow-wrap: anywhere;
}
.clave-recuperacion--pequena { font-size: 0.9rem; margin-top: 0.25rem; }
.codigos {
list-style: none;
padding: 0;
margin: 1rem 0;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.5rem;
font-family: var(--letra-mono);
text-align: center;
}
.codigos li {
padding: 0.5rem;
border: 1px solid var(--linea);
border-radius: 8px;
background: var(--fondo);
}
.qr {
display: block;
width: 200px;
height: 200px;
margin: 1rem auto;
padding: 8px;
background: #fff; /* el QR necesita fondo blanco también en modo oscuro */
border-radius: 8px;
image-rendering: pixelated;
}
.pasos {
padding-left: 1.25rem;
margin: 0 0 0.5rem;
color: var(--tinta-suave);
font-size: 0.9rem;
}
.pasos li + li { margin-top: 0.3rem; }
/* ---------------------------------------------------------------------
8. BARRA SUPERIOR
------------------------------------------------------------------- */
.barra {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.6rem 16px;
padding-top: max(0.6rem, env(safe-area-inset-top));
background: color-mix(in srgb, var(--fondo) 88%, transparent);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border-bottom: 1px solid var(--linea);
}
.barra__marca {
font-family: var(--letra-texto);
font-style: italic;
font-size: 1.35rem;
}
.barra__titulo { font-weight: 600; }
.barra__hueco { width: 40px; }
.barra__acciones { display: flex; gap: 0.25rem; }
.volver--barra { margin: 0; padding: 0.5rem 0; }
.estado {
font-size: 0.8rem;
color: var(--tinta-suave);
text-align: center;
flex: 1;
}
.estado[data-estado="estado_error"] { color: var(--peligro); }
/* ---------------------------------------------------------------------
9. ÍNDICE
------------------------------------------------------------------- */
.contenido {
max-width: var(--ancho-max);
margin: 0 auto;
padding: 1.5rem 16px 4rem;
}
.contenido--estrecho { max-width: 560px; }
.indice__cabecera {
display: flex;
gap: 0.75rem;
align-items: center;
}
.buscador {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0 0.85rem;
border: 1px solid var(--linea);
border-radius: 10px;
background: var(--superficie);
}
.buscador:focus-within { border-color: var(--acento); }
.buscador svg {
width: 18px;
height: 18px;
flex-shrink: 0;
fill: none;
stroke: var(--tinta-suave);
stroke-width: 2;
stroke-linecap: round;
}
.buscador input {
flex: 1;
min-width: 0;
border: 0;
background: transparent;
padding: 0.72rem 0;
}
.buscador input:focus { outline: none; }
/* Quitamos la "x" azul que algunos navegadores añaden a los buscadores. */
.buscador input::-webkit-search-cancel-button { -webkit-appearance: none; appearance: none; }
.indice__resumen {
margin: 1.25rem 0 0.5rem;
font-size: 0.8rem;
color: var(--tinta-suave);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.lista {
list-style: none;
margin: 0;
padding: 0;
}
.item {
display: block;
width: 100%;
text-align: left;
padding: 1rem 0.25rem;
border: 0;
border-bottom: 1px solid var(--linea);
background: transparent;
cursor: pointer;
}
.item:hover .item__titulo { color: var(--acento); }
.item__cabeza {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 1rem;
}
.item__titulo {
font-family: var(--letra-texto);
font-size: 1.15rem;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: color 0.15s;
}
/* El título automático (primeras palabras) va en cursiva y sin negrita. */
.item__titulo--auto { font-weight: 400; font-style: italic; }
.item__fecha {
flex-shrink: 0;
font-size: 0.8rem;
color: var(--tinta-suave);
}
.item__fragmento {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
margin-top: 0.25rem;
font-family: var(--letra-texto);
color: var(--tinta-suave);
font-size: 0.95rem;
}
.item__fragmento:empty { display: none; }
.item--danado .item__titulo { color: var(--peligro); }
mark {
background: var(--marca-fondo);
color: inherit;
border-radius: 3px;
padding: 0 1px;
}
.vacio {
text-align: center;
padding: 4rem 1rem;
}
.vacio__titulo {
font-family: var(--letra-texto);
font-size: 1.4rem;
margin: 0 0 0.5rem;
}
.vacio__texto { color: var(--tinta-suave); margin: 0; }
#indice-sin-resultados { text-align: center; padding: 2rem 0; }
/* ---------------------------------------------------------------------
10. EDITOR (la "hoja")
------------------------------------------------------------------- */
.hoja {
max-width: 680px;
margin: 0 auto;
padding: 2rem 16px 6rem;
}
.hoja__titulo {
width: 100%;
border: 0;
background: transparent;
padding: 0;
margin: 0 0 1rem;
font-family: var(--letra-texto);
font-size: 1.9rem;
font-weight: 500;
line-height: 1.25;
}
.hoja__titulo:focus { outline: none; }
.hoja__titulo::placeholder { color: var(--tinta-suave); opacity: 0.5; }
.hoja__cuerpo {
display: block;
width: 100%;
min-height: 60vh;
border: 0;
background: transparent;
padding: 0;
resize: none;
overflow: hidden; /* crece con el contenido (ver app.js) */
font-family: var(--letra-texto);
font-size: 1.15rem;
line-height: 1.75;
}
.hoja__cuerpo:focus { outline: none; }
.hoja__cuerpo::placeholder { color: var(--tinta-suave); opacity: 0.5; }
.hoja__fechas {
margin: 2rem 0 0;
font-size: 0.8rem;
color: var(--tinta-suave);
}
/* ---------------------------------------------------------------------
11. AJUSTES
------------------------------------------------------------------- */
.bloque {
padding: 1.5rem 0;
border-bottom: 1px solid var(--linea);
}
.bloque:last-child { border-bottom: 0; }
.bloque__titulo {
margin: 0 0 0.5rem;
font-family: var(--letra-texto);
font-size: 1.25rem;
font-weight: 500;
}
.bloque__texto {
margin: 0 0 1rem;
color: var(--tinta-suave);
font-size: 0.95rem;
}
.bloque .formulario { max-width: 380px; }
.bloque .formulario .boton { align-self: flex-start; }
.bloque--peligro .bloque__titulo { color: var(--peligro); }
/* ---------------------------------------------------------------------
12. CAPA DE CARGA, DIÁLOGO Y AVISOS
------------------------------------------------------------------- */
.carga {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
background: color-mix(in srgb, var(--fondo) 80%, transparent);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
.carga__caja { text-align: center; }
.carga__texto { margin: 1rem 0 0; color: var(--tinta-suave); }
/* Un punto que "late": discreto, sin girar como un reloj. */
.carga__punto {
width: 16px;
height: 16px;
margin: 0 auto;
border-radius: 50%;
background: var(--acento);
animation: latido 1.1s ease-in-out infinite;
}
@keyframes latido {
0%, 100% { transform: scale(0.7); opacity: 0.5; }
50% { transform: scale(1.2); opacity: 1; }
}
.dialogo {
width: min(400px, calc(100vw - 32px));
padding: 0;
border: 1px solid var(--linea);
border-radius: calc(var(--radio) + 4px);
background: var(--superficie);
color: var(--tinta);
box-shadow: var(--sombra);
}
.dialogo::backdrop { background: rgb(0 0 0 / 35%); }
.dialogo__caja { padding: 1.5rem; }
.dialogo__texto { margin: 0 0 1.5rem; }
.aviso {
position: fixed;
left: 50%;
bottom: max(1.5rem, env(safe-area-inset-bottom));
transform: translateX(-50%);
z-index: 200;
width: max-content;
max-width: calc(100vw - 32px);
padding: 0.75rem 1.1rem;
border-radius: 10px;
background: var(--tinta);
color: var(--fondo);
font-size: 0.9rem;
box-shadow: var(--sombra);
}
/* ---------------------------------------------------------------------
13. MÓVIL
------------------------------------------------------------------- */
@media (max-width: 480px) {
.tarjeta { padding: 1.5rem 1.15rem; }
.hoja { padding-top: 1.25rem; }
.hoja__titulo { font-size: 1.6rem; }
.hoja__cuerpo { font-size: 1.1rem; }
.indice__cabecera .boton { padding-inline: 0.9rem; }
}
/* Quien tenga activado "reducir movimiento" no verá animaciones. */
@media (prefers-reduced-motion: reduce) {
* { animation: none !important; transition: none !important; }
}
CAJANEGRA_FIN_ESTILOS_CSS;
}
// ****************************************************************************
// 210 · ARCHIVO: textos-es.js (todos los textos que ve el usuario)
// ****************************************************************************
function bdn_caja_archivo_textos_js()
{
return <<<'CAJANEGRA_FIN_TEXTOS_JS'
/**
* =====================================================================
* CAJA NEGRA · TEXTOS DE LA INTERFAZ (CASTELLANO)
* ---------------------------------------------------------------------
* TODOS los textos que ve el usuario están aquí, y solo aquí.
*
* PARA AÑADIR OTRO IDIOMA (por ejemplo, catalán):
* 1. Copia este archivo como textos-ca.js.
* 2. Traduce los textos de la DERECHA (nunca las claves de la izquierda).
* 3. En js/app.js, cambia la línea import { TEXTOS } from './textos-es.js'
* (o añade un selector de idioma).
*
* Las palabras entre llaves, como {usuario} o {minutos}, se sustituyen
* automáticamente por su valor. No las traduzcas.
* =====================================================================
*/
export const IDIOMA = 'es-ES';
export const TEXTOS = {
/* ---------- Marca y acceso ---------- */
lema: 'Lo que escribes aquí solo lo puedes leer tú.',
campo_usuario: 'Nombre de usuario',
campo_contrasena: 'Contraseña',
boton_entrar: 'Entrar',
enlace_olvide: 'He olvidado mi contraseña',
enlace_volver: '← Volver',
primera_vez: '¿Es tu primera vez?',
boton_crear_diario: 'Crear mi diario',
pie_seguridad: 'Tus textos se cifran en tu dispositivo antes de salir de él. Ni siquiera nosotros podemos leerlos.',
codigo_intro: 'Tu cuenta tiene doble factor. Abre tu app de autenticación y escribe el código.',
campo_codigo: 'Código de 6 cifras o de emergencia',
codigo_ayuda: '¿Has perdido el móvil? Usa uno de tus códigos de emergencia.',
/* ---------- Alta ---------- */
alta_titulo: 'Crear mi diario',
alta_intro: 'No te pedimos email ni ningún dato personal. Solo un nombre inventado y una contraseña.',
campo_usuario_nuevo: 'Inventa un nombre de usuario',
ayuda_usuario: 'De 3 a 32 caracteres: letras, números, punto, guion o guion bajo. No uses tu nombre real.',
ayuda_contrasena: 'Mínimo 10 caracteres. Lo mejor: una frase que solo tú conozcas.',
campo_repetir: 'Repite la contraseña',
importante: 'Importante. ',
alta_aviso: 'Nadie más tiene la llave de tus textos: tampoco nosotros. Si olvidas la contraseña, solo podrás entrar con la clave de recuperación que te daremos ahora.',
alta_acepto: 'Entiendo que si pierdo la contraseña y la clave de recuperación, mis textos no se podrán recuperar nunca.',
/* ---------- Clave de recuperación ---------- */
clave_titulo: 'Tu clave de recuperación',
clave_intro: 'Es la única forma de recuperar tu diario si olvidas la contraseña. Apúntala en papel o guárdala en un lugar seguro.',
clave_aviso: 'Solo te la enseñaremos esta vez. No la guardes en el mismo sitio que tu contraseña.',
clave_guardada: 'La he guardado en un lugar seguro.',
boton_abrir_diario: 'Abrir mi diario',
boton_copiar: 'Copiar',
boton_descargar: 'Descargar',
clave_archivo: 'CAJA NEGRA · CLAVE DE RECUPERACIÓN\n\nUsuario: {usuario}\nClave: {clave}\n\nGuárdala en un lugar seguro. Sirve para volver a entrar si olvidas tu contraseña.\nQuien tenga esta clave y tu nombre de usuario podrá entrar en tu diario.\n',
/* ---------- Recuperar ---------- */
recuperar_titulo: 'Recuperar mi diario',
recuperar_intro: 'Escribe tu clave de recuperación y elige una contraseña nueva. Tus textos seguirán intactos.',
campo_clave_recuperacion: 'Clave de recuperación',
ejemplo_clave: 'XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX',
campo_contrasena_nueva: 'Contraseña nueva',
recuperar_nota_2fa: 'Si tenías activado el doble factor, se desactivará y podrás volver a activarlo desde Ajustes.',
boton_recuperar: 'Recuperar y entrar',
/* ---------- Índice ---------- */
ajustes: 'Ajustes',
salir: 'Salir',
buscar: 'Buscar en mis textos',
boton_nuevo: '+ Nuevo',
vacio_titulo: 'Tu diario está vacío',
vacio_texto: 'Pulsa «Nuevo» y escribe lo que quieras. Solo tú podrás leerlo.',
sin_resultados: 'No hay ningún texto con esas palabras.',
resumen_uno: '1 texto',
resumen_varios: '{n} textos',
resumen_busqueda: '{n} de {total}',
sin_titulo: 'Sin título',
/* ---------- Editor ---------- */
enlace_mis_textos: '← Mis textos',
borrar: 'Borrar este texto',
titulo_placeholder: 'Título (opcional)',
cuerpo_placeholder: 'Escribe aquí…',
estado_guardando: 'Guardando…',
estado_guardado: 'Guardado',
estado_cifrado: 'Cifrado y guardado',
estado_error: 'No se ha podido guardar. Reintentando…',
estado_nuevo: 'Nuevo',
fechas_editor: 'Creado el {creado} · Modificado el {modificado}',
confirmar_borrar: '¿Borrar «{titulo}»? No se podrá recuperar.',
boton_borrar: 'Borrar',
aviso_borrado: 'Texto borrado.',
confirmar_salir_sin_guardar: 'Hay cambios que no se han podido guardar (¿sin conexión?). Si sales ahora, se perderán.',
boton_salir_igual: 'Salir igualmente',
documento_danado: '⚠ Texto dañado',
/* ---------- Ajustes ---------- */
ajustes_cuenta: 'Tu cuenta',
cuenta_datos: 'Usuario «{usuario}» · en Caja Negra desde el {fecha}.',
exportar_titulo: 'Descargar mis textos',
exportar_texto: 'Descarga todos tus textos en un archivo de texto. Ojo: ese archivo NO está cifrado; guárdalo en un lugar seguro.',
boton_exportar: 'Descargar todo',
exportar_cabecera: 'CAJA NEGRA · Mis textos · Exportado el {fecha}',
exportar_vacio: 'Todavía no tienes textos que descargar.',
cambiar_titulo: 'Cambiar la contraseña',
campo_contrasena_actual: 'Contraseña actual',
boton_cambiar: 'Cambiar contraseña',
aviso_contrasena_cambiada: 'Contraseña cambiada. Tu clave de recuperación sigue siendo la misma.',
dosfa_titulo: 'Doble factor',
dosfa_activo: 'Activado. Te pediremos un código del móvil al entrar. Te quedan {n} códigos de emergencia.',
dosfa_inactivo: 'Desactivado.',
dosfa_explicacion: 'Con el doble factor, además de la contraseña necesitarás un código que cambia cada 30 segundos en tu móvil. Así nadie podrá entrar aunque descubra tu contraseña.',
boton_activar_2fa: 'Activar doble factor',
dosfa_paso1: 'Instala una app de autenticación gratuita: Google Authenticator, Microsoft Authenticator, Aegis…',
dosfa_paso2: 'En la app, pulsa «+» y escanea este código.',
dosfa_paso3: 'Escribe abajo el código de 6 cifras que te muestra la app.',
dosfa_sin_camara: '¿No puedes escanear? Introduce esta clave a mano en la app:',
campo_codigo_app: 'Código de la app',
boton_confirmar_2fa: 'Activar',
boton_cancelar: 'Cancelar',
dosfa_codigos_aviso: 'Doble factor activado. Guarda estos códigos de emergencia: cada uno sirve UNA vez para entrar si pierdes el móvil. No los volverás a ver.',
dosfa_archivo: 'CAJA NEGRA · CÓDIGOS DE EMERGENCIA\n\nUsuario: {usuario}\n\n{codigos}\n\nCada código sirve una sola vez. Guárdalos en un lugar seguro.\n',
boton_hecho: 'Ya los he guardado',
boton_desactivar_2fa: 'Desactivar doble factor',
aviso_2fa_desactivado: 'Doble factor desactivado.',
baja_titulo: 'Borrar mi cuenta',
baja_texto: 'Se borrarán tu cuenta y todos tus textos para siempre. No hay vuelta atrás.',
boton_baja: 'Borrar mi cuenta y mis textos',
confirmar_baja: '¿Seguro? Se borrarán TODOS tus textos y no se podrán recuperar.',
aviso_baja: 'Tu cuenta y tus textos se han borrado.',
/* ---------- Avisos generales ---------- */
aviso_copiado: 'Copiado.',
aviso_sesion_cerrada: 'Has salido de tu diario.',
aviso_inactividad: 'Por seguridad, hemos cerrado tu diario tras 15 minutos sin actividad.',
aviso_recuperado: 'Diario recuperado. Ya puedes usar tu contraseña nueva.',
aviso_recuperado_2fa: 'Diario recuperado. El doble factor se ha desactivado: puedes volver a activarlo en Ajustes.',
/* ---------- Pantalla de carga ---------- */
carga_abriendo: 'Abriendo tu diario…',
carga_creando: 'Creando tu diario y sus claves…',
carga_cambiando: 'Cambiando la contraseña…',
carga_recuperando: 'Recuperando tu diario…',
/* ---------- Medidor de contraseña ---------- */
fuerza_0: 'Demasiado corta: mínimo 10 caracteres.',
fuerza_1: 'Débil. Añade más palabras o caracteres.',
fuerza_2: 'Aceptable. Una frase más larga sería mejor.',
fuerza_3: 'Buena.',
fuerza_4: 'Muy buena.',
/* ---------- Errores ---------- */
err_usuario_no_valido: 'El nombre de usuario debe tener de 3 a 32 caracteres: letras, números, punto, guion o guion bajo.',
err_usuario_ocupado: 'Ese nombre de usuario ya está cogido. Prueba con otro.',
err_contrasena_corta: 'La contraseña debe tener al menos 10 caracteres.',
err_no_coinciden: 'Las dos contraseñas no coinciden.',
err_acepto: 'Marca la casilla para confirmar que lo entiendes.',
err_credenciales_incorrectas: 'Usuario o contraseña incorrectos.',
err_codigo_incorrecto: 'El código no es correcto o ya se ha usado.',
err_recuperacion_incorrecta: 'Usuario o clave de recuperación incorrectos.',
err_contrasena_actual_incorrecta: 'La contraseña no es correcta.',
err_demasiados_intentos: 'Demasiados intentos fallidos. Espera {minutos} minutos y vuelve a probar.',
err_sesion_caducada: 'Tu sesión ha caducado. Vuelve a entrar.',
err_sin_conexion: 'No hay conexión con el servidor. Comprueba tu internet.',
err_limite_documentos: 'Has llegado al número máximo de textos.',
err_demasiado_grande: 'Este texto es demasiado largo para guardarlo.',
err_descifrar: 'No se ha podido abrir el diario con esta contraseña.',
err_documento_danado: 'Este texto no se puede abrir: parece dañado.',
err_navegador: 'Tu navegador es demasiado antiguo para cifrar de forma segura. Actualízalo o usa otro.',
err_generico: 'Algo ha fallado. Inténtalo de nuevo.',
};
/**
* Devuelve un texto por su clave, sustituyendo las {variables}.
*
* @param {string} clave Clave del texto (p. ej. 'resumen_varios').
* @param {object} [datos] Valores para las variables (p. ej. { n: 5 }).
* @returns {string}
*
* @example t('resumen_varios', { n: 5 }) → '5 textos'
*/
export function t(clave, datos = {}) {
const plantilla = TEXTOS[clave] ?? clave; // si falta, se ve la clave (fácil de detectar)
return plantilla.replace(/\{(\w+)\}/g, (_, nombre) => (nombre in datos ? String(datos[nombre]) : `{${nombre}}`));
}
CAJANEGRA_FIN_TEXTOS_JS;
}
// ****************************************************************************
// 220 · ARCHIVO: cripto.js (todo el cifrado, en el navegador)
// ****************************************************************************
function bdn_caja_archivo_cripto_js()
{
return <<<'CAJANEGRA_FIN_CRIPTO_JS'
/**
* =====================================================================
* CAJA NEGRA · CIFRADO EN EL NAVEGADOR
* ---------------------------------------------------------------------
* Aquí está TODA la criptografía. Se ejecuta solo en el navegador del
* usuario, con la Web Crypto API (integrada en todos los navegadores
* modernos). No usa ninguna librería externa.
*
* EL ESQUEMA, EN CRISTIANO
* ------------------------
* · CLAVE MAESTRA (en inglés, DEK): 32 bytes aleatorios creados al darse
* de alta. Es la que cierra y abre TODOS los documentos. Nunca sale
* del navegador sin envolver.
*
* · De la CONTRASEÑA + su SAL, con PBKDF2 (600.000 vueltas), salen
* 64 bytes que partimos en dos:
* - primeros 32 → CLAVE DE ACCESO: se envía al servidor solo para
* demostrar quién eres. El servidor guarda su huella.
* - últimos 32 → CLAVE DE ENVOLTURA: cifra ("envuelve") la clave
* maestra. NUNCA sale del navegador.
* Saber la clave de acceso NO permite calcular la de envoltura.
*
* · Con la CLAVE DE RECUPERACIÓN (la que se apunta en papel) se hace
* exactamente lo mismo, con su propia sal. Así hay dos "llaves" que
* abren la misma clave maestra: la contraseña y la de recuperación.
*
* · Cada DOCUMENTO (título + texto) se cifra con la clave maestra usando
* AES-256-GCM, con un número aleatorio (IV) nuevo en cada guardado.
* AES-GCM además detecta cualquier manipulación de los datos.
*
* VENTAJA DE ESTE DISEÑO: cambiar la contraseña solo cambia el
* "envoltorio" de la clave maestra. No hay que volver a cifrar los
* documentos, por muchos que haya.
* =====================================================================
*/
/** Atajo a la API criptográfica del navegador. */
const sutil = globalThis.crypto.subtle;
/** Codificador y decodificador de texto ⇄ bytes (UTF-8). */
const codificador = new TextEncoder();
const decodificador = new TextDecoder();
/**
* Textos fijos que se mezclan en el cifrado ("datos asociados").
* Atan cada cifrado a su uso: un trozo cifrado para una cosa no se
* puede hacer pasar por otra.
*/
const ETIQUETA_DEK = codificador.encode('cajanegra-dek-v1');
const PREFIJO_DOCUMENTO = 'cajanegra-doc-v1:';
/** Vueltas de PBKDF2 por defecto (debe coincidir con privado/config.php). */
export const ITERACIONES_POR_DEFECTO = 600000;
/* =====================================================================
* UTILIDADES: Base64 y números aleatorios
* ===================================================================== */
/**
* Bytes → texto Base64 (así viaja lo binario en JSON).
* @param {Uint8Array} bytes
* @returns {string}
*/
export function aBase64(bytes) {
let binario = '';
// Por trozos, para no desbordar la pila con documentos grandes.
for (let i = 0; i < bytes.length; i += 0x8000) {
binario += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
}
return btoa(binario);
}
/**
* Texto Base64 → bytes.
* @param {string} texto
* @returns {Uint8Array}
*/
export function deBase64(texto) {
const binario = atob(texto);
const bytes = new Uint8Array(binario.length);
for (let i = 0; i < binario.length; i++) bytes[i] = binario.charCodeAt(i);
return bytes;
}
/**
* Bytes aleatorios criptográficamente seguros.
* @param {number} n Cuántos bytes.
* @returns {Uint8Array}
*/
export function aleatorio(n) {
return globalThis.crypto.getRandomValues(new Uint8Array(n));
}
/**
* Une varios trozos de bytes en uno.
* @param {...Uint8Array} trozos
* @returns {Uint8Array}
*/
function unir(...trozos) {
const total = trozos.reduce((suma, t) => suma + t.length, 0);
const resultado = new Uint8Array(total);
let posicion = 0;
for (const t of trozos) {
resultado.set(t, posicion);
posicion += t.length;
}
return resultado;
}
/**
* Identificador aleatorio para un documento nuevo: 32 caracteres
* hexadecimales (16 bytes). Lo crea el navegador para poder "atar" el
* cifrado al documento desde el primer guardado.
* @returns {string}
*/
export function nuevoIdDocumento() {
return Array.from(aleatorio(16), (b) => b.toString(16).padStart(2, '0')).join('');
}
/* =====================================================================
* NORMALIZACIÓN
* ===================================================================== */
/**
* Normaliza la contraseña a la forma Unicode NFC.
*
* Por qué: una "á" se puede representar como un solo carácter o como
* "a" + tilde. Según el teclado o el sistema, la misma contraseña
* produciría bytes distintos y, por tanto, una clave distinta. Con NFC
* siempre se convierte a la misma forma.
*
* @param {string} contrasena
* @returns {string}
*/
export function normalizarContrasena(contrasena) {
return String(contrasena).normalize('NFC');
}
/**
* Normaliza una clave de recuperación tecleada por el usuario:
* mayúsculas, sin espacios ni guiones, y corrige confusiones típicas
* (0→O, 1→I, 8→B) porque el alfabeto Base32 no usa esos números.
*
* @param {string} texto
* @returns {string}
*/
export function normalizarClaveRecuperacion(texto) {
return String(texto)
.toUpperCase()
.replace(/[\s-]/g, '')
.replace(/0/g, 'O')
.replace(/1/g, 'I')
.replace(/8/g, 'B');
}
/* =====================================================================
* DERIVAR CLAVES A PARTIR DE UN SECRETO (contraseña o recuperación)
* ===================================================================== */
/**
* Fabrica las dos claves a partir de un secreto + sal:
* · claveAcceso (Base64) → se envía al servidor para entrar.
* · claveEnvoltura (CryptoKey) → envuelve la clave maestra. No sale nunca.
*
* Es lento a propósito (PBKDF2 con cientos de miles de vueltas): tarda
* ~0,5-1 s en un móvil, lo que hace carísimo probar millones de
* contraseñas si alguien robara la base de datos.
*
* @param {string} secreto Contraseña (ya normalizada) o clave de recuperación.
* @param {string} salBase64 Sal del usuario.
* @param {number} iteraciones Vueltas de PBKDF2.
* @returns {Promise<{claveAcceso: string, claveEnvoltura: CryptoKey}>}
*/
export async function derivarClaves(secreto, salBase64, iteraciones) {
// 1. El secreto como "material de partida".
const material = await sutil.importKey(
'raw', codificador.encode(secreto), 'PBKDF2', false, ['deriveBits']
);
// 2. PBKDF2-SHA256 → 512 bits (64 bytes).
const bits = new Uint8Array(await sutil.deriveBits(
{ name: 'PBKDF2', salt: deBase64(salBase64), iterations: iteraciones, hash: 'SHA-256' },
material,
512
));
// 3. Mitad para entrar, mitad para envolver.
const claveAcceso = aBase64(bits.slice(0, 32));
const claveEnvoltura = await sutil.importKey(
'raw', bits.slice(32, 64), { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']
);
bits.fill(0); // borramos los bytes de la memoria en cuanto no hacen falta
return { claveAcceso, claveEnvoltura };
}
/* =====================================================================
* ENVOLVER Y DESENVOLVER LA CLAVE MAESTRA
* ===================================================================== */
/**
* Cifra ("envuelve") los 32 bytes de la clave maestra.
* Resultado: Base64( IV de 12 bytes | clave cifrada + etiqueta ) = 60 bytes.
*
* @param {CryptoKey} claveEnvoltura
* @param {Uint8Array} dekBruta 32 bytes de la clave maestra.
* @returns {Promise}
*/
export async function envolverDEK(claveEnvoltura, dekBruta) {
const iv = aleatorio(12);
const cifrado = new Uint8Array(await sutil.encrypt(
{ name: 'AES-GCM', iv, additionalData: ETIQUETA_DEK }, claveEnvoltura, dekBruta
));
return aBase64(unir(iv, cifrado));
}
/**
* Abre ("desenvuelve") la clave maestra. Si la contraseña era otra,
* AES-GCM lo detecta y lanza un error.
*
* @param {CryptoKey} claveEnvoltura
* @param {string} envueltaBase64
* @returns {Promise} 32 bytes de la clave maestra.
*/
export async function desenvolverDEK(claveEnvoltura, envueltaBase64) {
const bytes = deBase64(envueltaBase64);
return new Uint8Array(await sutil.decrypt(
{ name: 'AES-GCM', iv: bytes.slice(0, 12), additionalData: ETIQUETA_DEK },
claveEnvoltura,
bytes.slice(12)
));
}
/**
* Convierte los bytes de la clave maestra en una clave de uso diario
* NO EXTRAÍBLE: el propio navegador impide sacarla de su memoria, ni
* siquiera desde código malicioso de la página. Después borra los bytes.
*
* @param {Uint8Array} dekBruta
* @returns {Promise}
*/
export async function importarDEK(dekBruta) {
const clave = await sutil.importKey(
'raw', dekBruta, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']
);
dekBruta.fill(0);
return clave;
}
/* =====================================================================
* CLAVE DE RECUPERACIÓN
* ===================================================================== */
/** Alfabeto Base32 (RFC 4648): sin 0, 1 ni 8, que se confunden. */
const BASE32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
/**
* Crea una clave de recuperación: 20 bytes aleatorios (160 bits) en
* Base32, en 8 grupos de 4 letras. Ejemplo: "QK3T-ZP7M-...".
* 160 bits es imposible de adivinar por fuerza bruta.
*
* @returns {string}
*/
export function nuevaClaveRecuperacion() {
const bytes = aleatorio(20);
let bits = '';
for (const b of bytes) bits += b.toString(2).padStart(8, '0');
let texto = '';
for (let i = 0; i < bits.length; i += 5) texto += BASE32[parseInt(bits.slice(i, i + 5), 2)];
return texto.match(/.{4}/g).join('-');
}
/* =====================================================================
* PAQUETES COMPLETOS (lo que se envía al servidor)
* ===================================================================== */
/**
* Prepara TODO lo necesario para darse de alta:
* · crea la clave maestra,
* · la envuelve con la contraseña,
* · crea la clave de recuperación y envuelve también con ella.
*
* @param {string} contrasena Tal como la escribe el usuario.
* @param {number} iteraciones
* @returns {Promise<{envio: object, claveRecuperacion: string, dek: CryptoKey}>}
* envio → datos para la acción "alta" del servidor.
* claveRecuperacion → se enseña UNA vez al usuario para que la apunte.
* dek → clave maestra lista para usar en esta sesión.
*/
export async function prepararAlta(contrasena, iteraciones) {
const dekBruta = aleatorio(32);
// Con la contraseña.
const sal = aBase64(aleatorio(16));
const deContrasena = await derivarClaves(normalizarContrasena(contrasena), sal, iteraciones);
// Con la clave de recuperación.
const claveRecuperacion = nuevaClaveRecuperacion();
const salRecuperacion = aBase64(aleatorio(16));
const deRecuperacion = await derivarClaves(
normalizarClaveRecuperacion(claveRecuperacion), salRecuperacion, iteraciones
);
const envio = {
sal,
iteraciones,
clave_acceso: deContrasena.claveAcceso,
dek_contrasena: await envolverDEK(deContrasena.claveEnvoltura, dekBruta),
sal_recuperacion: salRecuperacion,
clave_acceso_recuperacion: deRecuperacion.claveAcceso,
dek_recuperacion: await envolverDEK(deRecuperacion.claveEnvoltura, dekBruta),
};
const dek = await importarDEK(dekBruta); // también borra dekBruta
return { envio, claveRecuperacion, dek };
}
/**
* Prepara el paquete de una contraseña NUEVA (al cambiarla o al
* recuperar la cuenta): sal nueva, clave de acceso nueva y la clave
* maestra envuelta con la nueva contraseña.
*
* @param {Uint8Array} dekBruta Clave maestra ya abierta (32 bytes).
* @param {string} nuevaContrasena
* @param {number} iteraciones
* @returns {Promise